fix(web): mascot physics, drag reliability, and speech-bubble polish
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Audits and fixes ground-teleport/flat-fall/toss-momentum physics bugs,
fixes drag getting stuck via missing pointercancel handling, replaces
sprite-based speech bubbles with real HTML text/emoji bubbles, adds
drag-onto-icon "investigate" reactions and idle chatter, merges the
name badge and reaction bubble into one floating element, and caps the
bubble to one line with a teleprompter-style auto-scroll instead of
ellipsizing overflow text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:23:30 +02:00
parent 7b1dfbc8aa
commit d82095213a
16 changed files with 929 additions and 140 deletions

View File

@@ -0,0 +1,296 @@
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
**Status:** P0P2 implemented. P3 ("cool stuff") ideas remain open/undecided.
**Verification of the fixes** (re-ran this document's own instrumented
tests against the fixed code):
- Case A (window closes under the mascot): position now falls smoothly
over ~2.53s with visible x-drift (e.g. bottom went 84→87→104→125→...→912
across ~3.3s), instead of jumping straight to the floor in one tick.
- Case B (window opens over a grounded mascot with a gap beneath it): the
mascot stayed pinned to the floor (`bottom: 950`) for 2.6s straight while
standing under an open window whose top was far above it — no snap-up at
all.
- Toss momentum: a fast upward-and-sideways release made the sprite keep
*rising* for several frames after pointerup before gravity won, then fall
with visible deceleration bumps roughly every ~550ms (the flap cycle)
instead of a flat monotonic increase.
- No new console errors; `npm run build` stays clean.
Companion to [plans/2026-07-20-desktop-mascot.md](2026-07-20-desktop-mascot.md)
(the original scaffolding plan, now implemented) and
[docs/mascot/README.md](../docs/mascot/README.md) (the MBSE model). This
document is a post-implementation review: static code read of every file
under `web/src/lib/mascot/`, plus live testing in the browser (dragging,
opening/closing/moving windows under the mascot, the radial menu, hatching),
including two tests instrumented with synthetic pointer events + high-frequency
position polling to get hard timing data rather than guessing from a laggy
screenshot loop.
## Summary
The scaffolding (registries, FSM shape, persistence, stimulus bus) is sound
and matches the original plan's architecture. The actual **physics is where
it falls short of feeling alive**, for one root cause plus a few smaller
gaps:
**The mascot doesn't actually fall in the cases that matter most — it
teleports.** The only code path where a real, animated fall happens is
"user drags it into the air and lets go." Every other ground-change case
(a window closes or moves out from under it, a window opens or moves under
it, it walks off a window's edge) snaps its position instantly, with zero
animation, because of one specific piece of logic in `Mascot.svelte`. This
was proven with instrumented timing data, not just read from the source —
see Finding 1.
## How this was tested
- Read every file in `web/src/lib/mascot/` (behavior.ts, Mascot.svelte,
MascotLayer.svelte, state.svelte.ts, stimuli.ts, sprites.ts, render.ts,
actions.ts, RadialMenu.svelte, NameDialog.svelte, types.ts).
- Ran the app (`npm run dev`), hatched a chick, and interactively tested:
drag-and-release at various heights, opening/closing/dragging a window
under the mascot, the right-click menu (including nested Feed), plain-click
pet, and the hatch dialog.
- For the two timing-sensitive claims below, screenshot-based verification
was too slow/laggy to distinguish "instant teleport" from "fast but real
fall" — so both were re-verified with a single `javascript_exec` call that
dispatches synthetic `PointerEvent`s to drag the mascot precisely onto a
window, then clicks that window's close button and polls
`canvas.getBoundingClientRect()` every ~65ms for 2+ seconds, all inside one
script (no inter-call latency to contaminate the result).
- `npm run build` passes with no new warnings.
## Findings
### Finding 1 (Critical) — Ground changes teleport the mascot instead of animating a fall or rise
**Root cause**, `web/src/lib/mascot/Mascot.svelte` `tick()` (~lines 111-131):
every tick, `computeGroundAt(runtime.x)` recomputes the ground line, and if
the mascot is "grounded" (not already `falling`/`dragged`) and the ground
changed at all, this runs unconditionally:
```js
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' &&
runtime.y >= prevGroundY - 1 && newGround !== prevGroundY) {
runtime.y += newGround - prevGroundY // instant, any magnitude, either direction
}
```
This was meant to make the mascot "ride along" smoothly while a window it's
standing on is being dragged (and it does do that correctly — verified,
see below). But it fires for *any* ground change, not just a smooth drag,
and it runs *before* `stepMascot()`/`behavior.ts` gets a chance to notice
"I'm now floating" and start a real `falling` behavior — so the FSM's own
fall-detection in the `wander`/`idle` cases
(`if (rt.y < groundY(rt) - 1) forceBehavior(rt, 'falling')`) never actually
fires; by the time it runs, `runtime.y` has already been silently snapped to
match.
**Proven case A — window closes underneath the mascot (should fall):**
dragged the mascot onto an open window's title bar via synthetic pointer
events (landed cleanly: sprite bottom = 96px = window top = 96px), then
clicked the window's close button and polled position every 65ms:
| t (ms) | sprite bottom (px) |
|---|---|
| 0 (before close) | 96 |
| 67 | **950** (floor) |
| 132 2000 | 950 (unchanged) |
The coded physics (gravity 1400 px/s², capped at 320 px/s) would take
**~2.8 seconds** to fall 854px. It happened in **under 67ms** — an instant
snap, not a fall. No `falling`/`land` animation plays.
**Proven case B — window opens/overlaps underneath a grounded mascot
(should NOT rise, or should climb visibly):** with the mascot standing on
the empty desktop floor (bottom = 950px), opened the Tasks window (which
renders top=71px, bottom=751px at that position — its underside never
reaches the floor, leaving a ~200px gap). Within 150ms, the mascot's sprite
bottom was already **71px** — snapped straight up onto the new window's
title bar, 880px in under 150ms, despite the window's bottom edge (751px)
never actually touching the mascot's original position. `computeGroundAt()`
has no check that the candidate window is anywhere near the mascot's
*current* position — it just returns the topmost window overlapping the
mascot's x column, full stop, so any window opening/moving/resizing
anywhere in that column instantly relocates the mascot to its top edge, no
matter the vertical distance.
**What does work correctly:** dragging an already-mascot-bearing window
smoothly (title-bar drag, not open/close) — the ride-along correctly
translates the mascot's y by the same delta as the window moves, so it
visually "stands" on the window through the drag. Also, manual drag-and-drop
of the mascot itself (pick it up, release above ground) *does* enter a real,
animated `falling``land``idle` sequence, because that path is driven
entirely by `releaseFromDrag()` from the pointer handler, which isn't
touched by the tick-level snap.
**Fix direction:** `computeGroundAt` needs to return the highest surface
*at or below* the mascot's current `y* (a downward raycast from the current
position), not the global topmost window in the column. Separately, the
tick-level "ride along" needs to distinguish *small, continuous* deltas
(the window carrying the mascot while being dragged — legitimate instant
translation) from *large or discontinuous* ones (a window appearing,
disappearing, or the mascot walking off an edge — should hand off to
`forceBehavior(rt, 'falling')` for a downward change, and a short new
`rising`/hop transition for an upward one, not a silent teleport in either
direction).
### Finding 2 (Critical) — the one real fall is a flat, straight drop; this is the user's specific complaint
Even in the one path that *does* animate (manual drag-release), the fall
itself has no attempt at flight:
- `falling.enter()` in `behavior.ts` hard-sets `rt.vx = 0` — zero horizontal
drift, ever.
- `fall-flutter`'s animation is just `jump.png` on a loop
(`sprites.ts`) — the *name* says flutter, the physics is a monotonic
`vy = min(TERMINAL_VY, vy + GRAVITY*dt)` capped fall, no oscillation, no
upward impulses.
- There's an already-defined, already-loaded `flap` animation
(`jump.png` again, distinct `AnimName`) that **no behavior ever
references** — it's dead weight in the registry right now.
This is exactly the "not always fall directly, try to fly a little" ask.
### Finding 3 (Critical) — no toss/throw momentum on release
The original plan called for tracking recent pointer deltas during a drag
and using them to give the release a real velocity (a toss/arc). The
shipped `onPointerUp`/`onPointerMove` in `Mascot.svelte` track no pointer
history at all — releasing while moving fast imparts nothing; the mascot
just drops straight down from wherever the pointer let go, same as a slow
release.
### Finding 4 (Moderate) — sprite/name/bubble clip off-screen near the top edge
The mascot's canvas is 20×28 logical px (extra headroom above the sprite
for the name label and reaction bubble), positioned bottom-anchored at
`runtime.y`. When the ground is near the top of the viewport (e.g.
standing on a window whose title bar sits close to `y=0`, which is common
for a freshly-opened window), the canvas — and the name label, positioned
even further above it — render partially or fully off-screen.
Reproduced directly: standing on a window with top=32px clipped the
sprite from `y=-52` to `y=32`, more than half invisible above the browser
viewport.
### Finding 5 (Minor) — `interruptsSleep` is defined but never read
`stimuli.ts`'s `ReactionDef.interruptsSleep` (set `true` only on `alarmed`)
documents an intended rule ("sleep is broken only by reactions that opt
in"), but nothing in `MascotLayer.svelte`'s dispatch callback or
`behavior.ts` ever reads it — every reaction unconditionally calls
`forceBehavior(runtime, 'react', ...)` regardless of current behavior.
Practically, this also means a reaction can visually interrupt an active
**drag** (the sprite briefly shows a reaction animation mid-drag, though
position tracking is unaffected since that's driven separately by the
pointer handler) — the documented "`dragged` always wins" rule isn't
enforced either.
### Finding 6 (Cosmetic / scope gap) — radial menu isn't round
The implementing agent deviated from the original "round, Sims-style"
requirement to a vertical rounded-button column (documented in the plan's
deviation note — the polar ring layout hid labels). It works correctly,
including nesting, but it's a direct miss against what was asked for. Worth
a deliberate decision: keep the readable column, or revisit a true ring
with icon-only buttons + a hover/center text readout.
### Finding 7 (Minor) — first-hatch naming can be dismissed with no easy way back
`NameDialog`'s Escape handler always calls `onCancel`, which just closes it
— on the very first hatch prompt (no `Cancel` button is shown in `hatch`
mode, but Escape still works via the window-level listener), a user who
hits Escape is left with an unnamed, un-hatched egg and no obvious way to
reopen the dialog short of reloading or finding the Debug → Force hatch
menu action.
## Improvement plan
Ordered by priority; 13 directly address the user's stated complaints.
### P0 — Fix the ground-detection/teleport bug (Finding 1)
1. Change `computeGroundAt(x)` to only consider a window a ground candidate
if its top edge is **at or below** the mascot's current `y` (plus a
small tolerance for the "about to land on it" case) — i.e. the nearest
surface *underneath*, not the global topmost overlapping window.
2. Replace the unconditional `tick()`-level position snap with a threshold
check: deltas under ~4px/tick (a window being smoothly dragged with the
mascot riding it) still translate instantly; anything larger routes
through `forceBehavior(rt, 'falling')` (ground dropped) or a new short
`rising` behavior (ground rose — a quick hop/flutter-up, not a snap).
3. This also fixes the FSM's existing (currently unreachable) `wander`/`idle`
fall-detection — once the snap isn't preempting it, that code path
should work as originally intended.
### P1 — Make falling actually look like an attempt at flight (Findings 2 & 3)
4. Wire the unused `flap` animation into `falling`: instead of one
continuous `fall-flutter` loop, alternate short `flap` bursts (each
burst applies a brief small negative `vy` impulse — a wing-beat that
measurably slows the descent for a few frames) with `fall-flutter` glide
segments. Net effect: still descends, but in a scalloped, fluttering
arc rather than a flat monotonic line — reads as "trying to fly, not
quite making it" rather than "dropped like a rock."
5. Add a small horizontal drift during `falling` (e.g. a slow sine wobble
or a fraction of the pre-release pointer velocity — see next point) so
the fall isn't perfectly vertical either.
6. Track a short rolling history of pointer positions during `dragged`
(last ~100ms of `onPointerMove` samples is enough) and derive a release
velocity from it in `onPointerUp`; feed that into `falling`'s initial
`vx`/`vy` instead of hard-zeroing them, so a fast toss actually arcs.
### P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)
7. Clamp the sprite's screen-space draw position (or reserve top margin on
the surface) so the canvas/name/bubble never render above `y=0`,
independent of where the logical ground sits.
8. Either wire `interruptsSleep`/a drag-guard into the reaction dispatch
path in `MascotLayer.svelte` (skip forcing `react` while
`runtime.behavior === 'dragged'`, and gate sleep-interruption on the
flag as documented), or remove the field if the current
always-interrupts behavior is actually preferred — right now it's an
unenforced contract, which is worse than either explicit choice.
9. On first hatch, prevent the naming dialog from being fully dismissed
without a name (or make it trivially reopenable — e.g. clicking the
still-unnamed egg reopens it) rather than requiring a reload/debug
menu to recover.
### P3 — Ideas worth considering ("cool stuff")
Not committed, listed for discussion:
- **Investigate badges**: have the mascot occasionally walk toward a
desktop icon that currently has an unread badge (Signals, Operations)
and peck at it curiously — a very literal, delightful expression of
"aware of its environment" using icon positions already in
`stores/icons.ts`.
- **Startle-and-flee on alarm**: instead of a static `react-alarm` frame,
have the `alarmed` reaction actually scurry the mascot a short distance
(reuse `wander`-style motion) before settling, more visceral than a
still reaction sprite.
- **A "home" spot**: remember a preferred idle location (e.g. near its
hatch point or a favorite window) and occasionally wander back to it,
giving its roaming a sense of place rather than pure randomness.
- **True round radial menu v2**: revisit Finding 6 with icon-only buttons
on an actual ring and a text label in a tooltip/center readout on
hover/focus — closer to the original ask while keeping labels legible
(the problem the first attempt hit).
- **Distinct adult sprite** (already flagged as deferred polish in the
original plan's deviation note) — currently chick and adult share art.
## Verification (once fixed)
- Re-run this document's two instrumented tests (drag-onto-window-then-close;
open-window-over-grounded-mascot) and confirm the position samples show a
smooth multi-frame transition instead of a single-tick jump.
- Manually: drag the mascot up and release with a fast flick — confirm it
arcs/drifts rather than dropping straight down, and that `flap` frames
visibly appear during the descent.
- Stand the mascot on a window, drag that window so its title bar approaches
`y=0` — confirm the sprite/name/bubble stay on-screen.
- Trigger a reaction (e.g. force an `eureka`) while mid-drag — confirm the
sprite keeps showing the `dragged` animation, not the reaction, until
released (if Finding 5 is fixed by enforcing the guard).
- `npm run build` stays clean.

View File

@@ -18,8 +18,9 @@ went sideways, open an investigation.
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started |
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open |
## Done

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 B

View File

@@ -13,8 +13,8 @@
// decision in plans/2026-07-20-desktop-mascot.md.
import { onMount } from 'svelte'
import { loadSprites, loadBubble, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
import { drawFrame, drawBubble, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
import { loadSprites, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
import { drawFrame, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
import {
stepMascot,
forceBehavior,
@@ -31,13 +31,50 @@
pet as modelPet
} from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows'
import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons'
import { APPS, type AppDef } from '$lib/apps'
// MascotRuntime is created fresh per mount; the long-lived MascotModel
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
let { runtime, onContextMenu, onPet }: { runtime: MascotRuntime; onContextMenu: (screenX: number, screenY: number) => void; onPet: () => void } = $props()
// `runtime` is $bindable(): this component mutates it constantly (every
// tick, every pointer event) rather than treating it as read-only input,
// which Svelte 5 flags as an "ownership_invalid_mutation" dev warning
// unless the prop is declared bindable and the parent uses bind:runtime.
let {
runtime = $bindable(),
onContextMenu,
onPet,
onRequestName
}: {
runtime: MascotRuntime
onContextMenu: (screenX: number, screenY: number) => void
onPet: () => void
/** Called on a plain click while the mascot is a not-yet-named egg — reopens the naming dialog (see NameDialog's Escape-dismiss). */
onRequestName: () => void
} = $props()
const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
const DRAG_THRESHOLD = 5
// Ground tracking (see behavior.ts's comment block for the fuller
// rationale): a small per-tick ground change (a window being dragged
// smoothly, with the mascot riding along) follows instantly; a bigger
// drop hands off to `falling` instead of snapping the mascot's position.
const GROUND_FOLLOW_MAX_STEP = 6 // px/tick the mascot may instantly follow a rising/shifting ground
const GROUND_DROP_FALL_PX = 12 // px — a ground drop bigger than this triggers falling, not a snap
// A window is only a ground candidate if its top is at/below the
// mascot's CURRENT position (a downward raycast from where it stands) —
// otherwise a window opening/moving anywhere in its column, even one
// that never gets near it, would yank it upward onto that window's top.
const GROUND_CANDIDATE_EPSILON = 4
// Toss momentum: a short rolling window of recent pointer-move samples
// during a drag, used to derive a release velocity (onPointerUp) instead
// of always dropping straight down from wherever the pointer let go.
const VELOCITY_WINDOW_MS = 120
const TOSS_MAX_VX = 900 // px/s
const TOSS_MAX_UPWARD_VY = 700 // px/s (a hard upward flick can toss it up a bit before gravity wins)
const TOSS_MAX_DOWNWARD_VY = 400 // px/s (don't let a fast downward fling outrun the fall's own gravity feel)
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
// than GraphBackground's slow ambient drift, and 30fps position updates
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
@@ -58,6 +95,10 @@
let dragPointerId: number | null = null
let dragStartClient = { x: 0, y: 0 }
let moved = false
// Recent pointer-move samples during a drag (surface coords + timestamp),
// trimmed to the last VELOCITY_WINDOW_MS — used to derive a release
// (toss) velocity in onPointerUp.
let dragSamples: { t: number; x: number; y: number }[] = []
// Wiggle phase for the egg (render-time only, not persisted).
let wigglePhase = 0
// Snapshot of the current window manager state, refreshed by
@@ -79,12 +120,20 @@
/**
* Compute the ground line at the mascot's x: the top edge (y) of the
* highest non-minimized window whose horizontal span covers the
* mascot's x, or the surface bottom (bounds.h) when no window is
* beneath. This is what lets the mascot walk ON TOP of windows —
* when it strolls over a window, the ground rises to that window's
* top edge; when it walks off the side, the ground drops back to the
* desktop surface and it flutters-falls.
* NEAREST non-minimized window whose horizontal span covers the
* mascot's x AND whose top is at/below the mascot's current position —
* i.e. a downward raycast from where it's standing, not "the topmost
* window anywhere in this column." Without the vertical check, a window
* opening or moving anywhere above the mascot (even with empty space
* between its underside and the mascot) would be picked as ground and
* the ride-along logic in tick() would yank the mascot up onto it
* instantly — this was a confirmed bug (see
* plans/2026-07-20-mascot-physics-audit.md, Finding 1 case B).
* Falls back to the surface bottom (bounds.h) when nothing qualifies.
* This is what lets the mascot walk ON TOP of windows — when it strolls
* over one, the ground rises to its top edge; when it walks off the
* side (or the window moves/closes), the ground drops and tick()'s
* chase logic hands off to a real `falling` behavior.
*/
function computeGroundAt(x: number): number {
let ground = runtime.bounds.h
@@ -94,10 +143,14 @@
const b = win.bounds
// Window top edge counts as ground only if the mascot's x is
// within the window's horizontal span (with a small margin so the
// mascot doesn't immediately fall off the very corner).
if (x >= b.x - 4 && x <= b.x + b.width + 4) {
// Walking on top = ground at the window's top edge.
if (b.y < ground) ground = b.y
// mascot doesn't immediately fall off the very corner)...
const inSpan = x >= b.x - 4 && x <= b.x + b.width + 4
// ...AND the window's top is at/below where the mascot currently
// is (it's a real surface underfoot, not a window floating above
// with a gap beneath it).
const atOrBelow = b.y >= runtime.y - GROUND_CANDIDATE_EPSILON
if (inSpan && atOrBelow && b.y < ground) {
ground = b.y
}
}
return ground
@@ -108,6 +161,32 @@
return { anim: resolveAnim(stage(), name), name }
}
// Icon investigate: drop the mascot on a desktop icon and it reacts —
// see onPointerUp's drag-release branch, which checks this against the
// release position and, if it hits, shows the line below and sets
// runtime.investigateOnLand so the next landing pecks instead of idling.
const ICON_INVESTIGATE_LINES: Record<string, string> = {
tasks: '📋 Tasks!',
kb: '🗂️ Ooh, data!',
ops: '🛡️ All clear?',
signals: '📡 Anything new?',
knowledge: '🔍 Curious…',
learning: '📈 Growing!',
settings: '⚙️ Tinkering?'
}
const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!'
/** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. */
function iconAt(x: number, y: number): AppDef | null {
const positions = getIconPositions()
for (const app of APPS) {
const pos = positions[app.id] ?? { col: 0, row: 0 }
const { x: ix, y: iy } = iconPixelPos(pos)
if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return app
}
return null
}
function tick(now: number): void {
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
lastFrame = now
@@ -115,21 +194,36 @@
// lets the mascot walk on top of windows (the ground rises to a
// window's top edge when the mascot strolls over it).
const newGround = computeGroundAt(runtime.x)
// Ride the ground: when the mascot is grounded (not falling/dragged)
// and the ground moved (the window beneath was dragged/resized),
// translate the mascot with it so it sticks to the surface instead
// of floating in place while the window drifts out from under it.
if (
runtime.behavior !== 'falling' &&
runtime.behavior !== 'dragged' &&
runtime.y >= prevGroundY - 1 &&
newGround !== prevGroundY
) {
runtime.y += newGround - prevGroundY
// Chase the ground when grounded (not already falling/dragged):
// - if it dropped away by more than GROUND_DROP_FALL_PX (a window
// closed, moved out from under the mascot, or it walked off an
// edge), hand off to a real `falling` behavior instead of snapping
// — this was Finding 1 in the physics audit: the mascot used to
// teleport straight to the new ground with zero animation.
// - otherwise, step toward the new ground by at most
// GROUND_FOLLOW_MAX_STEP per tick. A window being dragged smoothly
// moves only a few px/tick, so this still reads as an instant,
// solid "ride along"; anything bigger (a window snapping to a new
// position, a resize) catches up over a couple of frames instead
// of jumping.
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged') {
if (newGround - prevGroundY > GROUND_DROP_FALL_PX && runtime.y >= prevGroundY - 1) {
forceBehavior(runtime, 'falling')
} else if (runtime.y !== newGround) {
const diff = newGround - runtime.y
runtime.y += Math.abs(diff) <= GROUND_FOLLOW_MAX_STEP ? diff : Math.sign(diff) * GROUND_FOLLOW_MAX_STEP
}
}
runtime.groundY = newGround
prevGroundY = newGround
stepMascot(runtime, model, now, dt)
// Reaction bubble expiry: the bubble is a real DOM element now (see
// the template), driven reactively by bubbleText/bubbleUntil — this
// is the one place that clears it once its time is up.
if (runtime.bubbleText && now >= runtime.bubbleUntil) {
runtime.bubbleText = null
runtime.bubbleUntil = 0
}
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
if (lastLifecycle === 0) lastLifecycle = now
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
@@ -164,14 +258,6 @@
facing: runtime.facing,
wiggle: wigglePhase
})
// Reaction bubble: drawn above the sprite while runtime.bubble is set
// and hasn't expired. Cleared when bubbleUntil passes.
if (runtime.bubble && now < runtime.bubbleUntil) {
drawBubble(ctx2d, runtime.bubble, runtime.facing)
} else if (runtime.bubble && now >= runtime.bubbleUntil) {
runtime.bubble = null
runtime.bubbleUntil = 0
}
}
// Single loop: tick physics + draw sprite + reschedule. (The previous
@@ -191,6 +277,7 @@
dragPointerId = e.pointerId
dragStartClient = { x: e.clientX, y: e.clientY }
moved = false
dragSamples = []
el.setPointerCapture(e.pointerId)
forceBehavior(runtime, 'dragged')
dragging = true
@@ -210,28 +297,121 @@
if (rect) {
runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
runtime.y = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const t = performance.now()
dragSamples.push({ t, x: runtime.x, y: runtime.y })
while (dragSamples.length > 1 && t - dragSamples[0].t > VELOCITY_WINDOW_MS) dragSamples.shift()
}
}
}
function onPointerUp(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
el.releasePointerCapture(e.pointerId)
/** Derive a release (toss) velocity from the last VELOCITY_WINDOW_MS of drag samples. */
function tossVelocity(): { vx: number; vy: number } {
if (dragSamples.length < 2) return { vx: 0, vy: 0 }
const first = dragSamples[0]
const last = dragSamples[dragSamples.length - 1]
const dt = (last.t - first.t) / 1000
if (dt < 0.01) return { vx: 0, vy: 0 }
const vx = Math.max(-TOSS_MAX_VX, Math.min(TOSS_MAX_VX, (last.x - first.x) / dt))
const rawVy = (last.y - first.y) / dt
const vy = rawVy < 0 ? Math.max(-TOSS_MAX_UPWARD_VY, rawVy) : Math.min(TOSS_MAX_DOWNWARD_VY, rawVy)
return { vx, vy }
}
/**
* Release capture and clear drag-tracking state. Split out from
* onPointerUp so onPointerCancel and the window-level fallback below can
* share it — every path that ends a drag needs to do this exact
* cleanup, or the mascot is left stuck in `dragged` forever.
*/
function endDragTracking(pointerId: number, el?: HTMLElement | null): void {
if (el?.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
dragPointerId = null
dragging = false
}
function onPointerUp(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
if (!moved) {
// An unnamed egg (e.g. the naming dialog was Escaped away) has no
// real pet reaction yet — reopen the naming prompt instead so it's
// never stuck un-hatchable without a reload/debug action.
if (stage() === 'egg' && model.name === null) {
dragSamples = []
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
onRequestName()
return
}
// Plain click = pet: a brief happy reaction with a heart bubble.
modelPet()
onPet()
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleText = '❤️'
runtime.bubbleUntil = performance.now() + 1500
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
} else {
// Drag ended — release into falling or land.
// Drag ended — derive a toss velocity from the recent pointer
// motion and release into falling (or land, if already grounded).
setLastPos(runtime.x)
const { vx, vy } = tossVelocity()
runtime.vx = vx
runtime.vy = vy
// Dropped on a desktop icon? Show an "investigate" bubble right
// away (works whether it's about to fall or is already grounded)
// and flag the next landing to peck instead of idle — see the
// `land` BehaviorDef in behavior.ts.
if (stage() !== 'egg') {
const app = iconAt(runtime.x, runtime.y)
if (app) {
runtime.bubbleText = ICON_INVESTIGATE_LINES[app.id] ?? ICON_INVESTIGATE_FALLBACK
runtime.bubbleUntil = performance.now() + 1800
runtime.investigateOnLand = true
modelPet()
}
}
releaseFromDrag(runtime)
}
dragSamples = []
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
}
/**
* The browser aborts a pointer interaction (fires `pointercancel`
* instead of `pointerup`) in several real situations: the pointer
* leaves the window fast enough that the OS/browser interprets it as a
* different gesture, a touch/stylus is force-cancelled, or something
* else takes over pointer capture. Without handling this, the mascot
* gets stuck in the `dragged` behavior forever — no more pointerup is
* coming, so nothing else would ever reset dragPointerId/dragging. This
* was the "dragging sometimes doesn't release" bug. There's no reliable
* release gesture to derive a toss from here, so it just falls/lands
* from wherever it was, same as a still-mid-air drag release with no
* velocity.
*/
function onPointerCancel(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
dragSamples = []
runtime.vx = 0
runtime.vy = 0
releaseFromDrag(runtime)
endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
}
/**
* Defense-in-depth fallback: if for any reason the canvas's own
* pointerup/pointercancel above doesn't fire (pointer capture should
* guarantee it does, but "should" isn't "always" across browsers/
* embeddings), this window-level catch-all still ends the drag. Safe to
* have both — pointer capture redirects the *target* of the event, not
* its bubbling, so a canvas-handled pointerup also reaches window; by
* then dragPointerId is already cleared, so this is a no-op in the
* normal case.
*/
function onWindowPointerEnd(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
dragSamples = []
runtime.vx = 0
runtime.vy = 0
releaseFromDrag(runtime)
endDragTracking(e.pointerId, canvas)
}
function handleContextMenu(e: MouseEvent) {
@@ -252,15 +432,6 @@
initMascotState()
syncCanvasSize()
await loadSprites()
// Preload reaction bubbles (not in SPRITES registry, so loadSprites
// doesn't pick them up). Swallow errors — a missing bubble just
// doesn't render, the reaction anim still plays.
await Promise.allSettled([
loadBubble('/mascot/bubble-love.png'),
loadBubble('/mascot/bubble-exclaim.png'),
loadBubble('/mascot/bubble-red-exclaim.png'),
loadBubble('/mascot/bubble-dotdotdot.png')
])
lastFrame = performance.now()
lastLifecycle = 0
// Seed prevGroundY so the first tick's ride-the-ground delta is zero
@@ -302,12 +473,90 @@
// layout reflow) rather than `left`/`top` so motion stays smooth at
// high refresh rates. A squash transform is composed in during the
// `land` behavior (brief scaleY(0.82)) to sell the impact.
//
// `ty` is clamped to >= 0: the canvas reserves headroom above the
// sprite for the reaction bubble, and when the mascot's ground is near
// the top of the viewport (e.g. standing on a freshly-opened window),
// that headroom would otherwise push the canvas — and the name label
// above it — partly off-screen. This only affects the *rendered*
// position; runtime.y (the physics/ground line) is untouched, so it's a
// display-only fix, not a physics change (see the physics audit's
// Finding 4).
const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
const ty = $derived(runtime.y - CANVAS_H * SCALE_PX)
const ty = $derived(Math.max(0, runtime.y - CANVAS_H * SCALE_PX))
// The canvas (CANVAS_H=28 logical px) is taller than the sprite frame
// it draws (FRAME_PX=16, bottom-anchored — see render.ts's drawFrame),
// leaving ~36 screen px of transparent headroom ABOVE the sprite's
// actual head. `ty` is the canvas's top, not the chicken's — anchoring
// the name/bubble to `ty` floated them oddly high above the chicken
// with a big empty gap. `spriteTopY` is where the visible pixels
// actually start, so the name/bubble can hug the head instead.
const FRAME_PX = 16
const spriteTopY = $derived(ty + Math.max(0, (CANVAS_H - FRAME_PX * STAGE_SCALE[stage()]) * SCALE_PX))
const tagCenterX = $derived(tx + (CANVAS_W * SCALE_PX) / 2)
// Name and reaction text share one floating tag above the head instead
// of two stacked elements — it shows the reaction (with the speech-
// bubble-and-tail treatment) when one's active, and falls back to just
// the name (a plain small pill) the rest of the time. Since only one of
// the two ever renders, the anchor only needs to account for whichever
// one is showing, growing upward from a fixed point near the head so it
// doesn't jump around when it switches between the two.
const TAG_GAP = 4
const PILL_HEIGHT = 18
const BUBBLE_HEIGHT = 40 // card + its downward-pointing tail
const tagY = $derived(Math.max(0, spriteTopY - TAG_GAP - (runtime.bubbleText ? BUBBLE_HEIGHT : PILL_HEIGHT)))
const squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
// Bubble text marquee: the bubble is capped at one line (no wrap) and a
// fixed max width, so text longer than that would normally need
// ellipsis — but truncating a reaction line silently drops information
// (e.g. which app it's reacting to). Instead, when the text overflows
// the bubble's width, it slides left/right like a teleprompter so the
// whole line eventually becomes readable; short text that already fits
// just sits still, centered.
let bubbleTrackEl = $state<HTMLDivElement | null>(null)
let bubbleTextEl = $state<HTMLSpanElement | null>(null)
let marqueeDistance = $state(0)
let marqueeDuration = $state(0)
const MARQUEE_PX_PER_S = 45 // travel speed while sliding (excludes the hold time at each end)
$effect(() => {
// Re-measure whenever the visible bubble text changes.
void runtime.bubbleText
const track = bubbleTrackEl
const textEl = bubbleTextEl
if (!track || !textEl) {
marqueeDistance = 0
marqueeDuration = 0
return
}
// Measure after layout settles (the span's content just changed).
const raf = requestAnimationFrame(() => {
const overflow = textEl.scrollWidth - track.clientWidth
if (overflow > 2) {
marqueeDistance = -overflow
marqueeDuration = (overflow / MARQUEE_PX_PER_S) * 2 + 1.6
// Callers set bubbleUntil from the reaction/action's own short
// duration (e.g. 1.5-2.2s), which is usually plenty for a static
// line but would cut a slow scroll off mid-glide. Stretch the
// display time to cover one full out-and-back cycle so overflowing
// text always finishes scrolling into view before the bubble
// disappears — otherwise the point of scrolling instead of
// ellipsizing (showing the whole line) would be defeated.
const minUntil = performance.now() + marqueeDuration * 1000
if (runtime.bubbleUntil < minUntil) runtime.bubbleUntil = minUntil
} else {
marqueeDistance = 0
marqueeDuration = 0
}
})
return () => cancelAnimationFrame(raf)
})
</script>
<svelte:window onpointerup={onWindowPointerEnd} onpointercancel={onWindowPointerEnd} />
<canvas
bind:this={canvas}
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
@@ -315,15 +564,60 @@
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
oncontextmenu={handleContextMenu}
title={model.name ?? 'Cluck'}
></canvas>
{#if model.name}
{#if runtime.bubbleText || model.name}
<div
class="pointer-events-none absolute left-0 top-0 select-none whitespace-nowrap rounded-full bg-popover/90 px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-sm ring-1 ring-foreground/10"
style="transform: translate3d({tx + (CANVAS_W * SCALE_PX) / 2}px, {ty - 8}px, 0); will-change: transform;"
class="pointer-events-none absolute left-0 top-0 select-none"
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
>
{model.name}
{#if runtime.bubbleText}
<div class="relative max-w-40 rounded-xl border bg-popover px-2.5 py-1 text-sm leading-none text-popover-foreground shadow-md">
<div
bind:this={bubbleTrackEl}
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
>
<span
bind:this={bubbleTextEl}
class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
style={marqueeDistance !== 0
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
: ''}
>{runtime.bubbleText}</span>
</div>
<div class="absolute -bottom-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-r border-b bg-popover"></div>
</div>
{:else}
<div class="whitespace-nowrap rounded-full bg-popover/40 px-1.5 py-0.5 text-[10px] font-normal text-popover-foreground/75">
{model.name}
</div>
{/if}
</div>
{/if}
<style>
/* Slides overflowing bubble text into view and back, holding briefly at
each end (like a teleprompter) instead of ellipsizing. Only applied
when the text is wider than the bubble (see the marqueeDistance
effect above) — --marquee-distance is the negative px offset needed
to reveal the clipped tail. */
.bubble-marquee {
animation: bubble-marquee var(--marquee-duration, 4s) ease-in-out infinite;
}
@keyframes bubble-marquee {
0%,
12% {
transform: translateX(0);
}
50%,
62% {
transform: translateX(var(--marquee-distance, 0px));
}
100% {
transform: translateX(0);
}
}
</style>

View File

@@ -48,9 +48,11 @@
reactAnim: null,
bounds: { w: 800, h: 600 },
groundY: 600,
bubble: null,
bubbleText: null,
bubbleUntil: 0,
blinkUntil: 0
blinkUntil: 0,
fallPhaseAt: 0,
investigateOnLand: false
})
let menuPos = $state<{ x: number; y: number } | null>(null)
@@ -105,7 +107,7 @@
function onPet() {
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
// call already happened in Mascot.svelte.
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleText = '❤️'
runtime.bubbleUntil = performance.now() + 1500
}
@@ -149,6 +151,13 @@
// behind it.
const detach = attachStimuli((reaction) => {
if (getModel().stage === 'egg') return
// Dragging always wins — never let a reaction interrupt an active
// drag (previously it could visually flash a reaction animation
// mid-drag, even though position tracking stayed correct; see the
// physics audit's Finding 5). Sleep is only interrupted by
// reactions that explicitly opt in via interruptsSleep.
if (runtime.behavior === 'dragged') return
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
// here we just force the behavior.
const anim = reaction.anim as AnimName
@@ -156,7 +165,7 @@
runtime.reactAnim = anim
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
if (reaction.bubble) {
runtime.bubble = reaction.bubble
runtime.bubbleText = reaction.bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs
}
if (reaction.effect) reaction.effect()
@@ -180,9 +189,13 @@
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
<Mascot
{runtime}
bind:runtime
onContextMenu={openMenu}
onPet={onPet}
onRequestName={() => {
nameDialogMode = 'hatch'
nameDialogOpen = true
}}
/>
</div>

View File

@@ -106,7 +106,7 @@
<div
bind:this={host}
class="fixed z-[60] min-w-44 max-w-56 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
class="fixed z-[60] min-w-56 max-w-72 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
role="menu"
tabindex="-1"
@@ -134,18 +134,23 @@
{#each visibleItems as a (a.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
class="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={(ev) => selectItem(a, ev)}
oncontextmenu={(ev) => ev.preventDefault()}
>
<span class="flex items-center gap-2">
<span class="flex min-w-0 flex-1 items-start gap-2">
{#if a.icon}
<a.icon class="size-4 shrink-0" />
<a.icon class="mt-0.5 size-4 shrink-0" />
{/if}
<span class="truncate">{a.label}</span>
<span class="flex min-w-0 flex-col">
<span class="truncate">{a.label}</span>
{#if a.description}
<span class="text-[11px] leading-snug font-normal text-popover-foreground/60">{a.description}</span>
{/if}
</span>
</span>
{#if a.children && a.children.length > 0}
<ChevronRightIcon class="size-4 shrink-0 opacity-60" />
<ChevronRightIcon class="mt-0.5 size-4 shrink-0 opacity-60" />
{/if}
</button>
{/each}

View File

@@ -8,12 +8,34 @@
// RadialMenu.svelte renders whatever tree it's given, including
// arbitrary nesting depth — no engine change is needed for a new node.
import type { RadialAction } from './types'
import type { MascotActionCtx, RadialAction } from './types'
import { REACTIONS } from './stimuli'
// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake],
// Identity [Rename], Debug [Force hatch, Force chick, Force adult, Reset].
// The Pet action is the same as a plain click — included in the menu for
// discoverability.
// Identity [Rename], Debug [Lifecycle → [...], Reactions → [...], Force
// fall, Reset]. The Pet action is the same as a plain click — included in
// the menu for discoverability.
//
// Every Debug leaf carries a `description` naming the real, non-debug
// trigger it's simulating (rendered as a muted second line by
// RadialMenu.svelte) — the point of a debug menu is to let you fire
// something without waiting for the real condition, but that's only
// useful for testing if you also know what condition it's standing in for.
/** Play a REACTIONS entry (see stimuli.ts) exactly as the real stimulus bus would — same anim/bubble/duration/effect — without needing to fake the chat/activity/event stream that normally triggers it. */
function triggerReaction(ctx: MascotActionCtx, id: keyof typeof REACTIONS): void {
const r = REACTIONS[id]
ctx.runtime.bubbleText = r.bubble ?? null
ctx.runtime.bubbleUntil = performance.now() + r.durationMs
ctx.force('react', { anim: r.anim, durationMs: r.durationMs })
r.effect?.()
}
/** Show a speech-bubble line (text and/or emoji — see Mascot.svelte's template) above the mascot for `ms`. */
function showBubble(ctx: MascotActionCtx, text: string, ms: number): void {
ctx.runtime.bubbleText = text
ctx.runtime.bubbleUntil = performance.now() + ms
}
export const MASCOT_ACTIONS: RadialAction[] = [
{
@@ -24,8 +46,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
id: 'pet',
label: 'Pet',
action: (ctx) => {
ctx.runtime.bubble = '/mascot/bubble-love.png'
ctx.runtime.bubbleUntil = performance.now() + 1500
showBubble(ctx, '❤️', 1500)
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
ctx.refresh()
}
@@ -40,7 +61,14 @@ export const MASCOT_ACTIONS: RadialAction[] = [
action: (ctx) => {
// Feeding: small happiness + xp boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 8)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
showBubble(ctx, '🌾 Yum!', 1800)
// Force the dedicated 'peck' behavior, not 'idle' with an anim
// override — stepMascot() re-derives the anim from the active
// behavior's own anim() every tick (except for 'react', which
// has a special-cased override via reactAnim), so an anim
// override on any other behavior gets silently clobbered
// within one frame. 'peck' always resolves to the peck anim.
ctx.force('peck', { durationMs: 1800 })
ctx.refresh()
}
},
@@ -50,7 +78,14 @@ export const MASCOT_ACTIONS: RadialAction[] = [
action: (ctx) => {
// Worm: bigger boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 16)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
showBubble(ctx, '🐛 Yum!', 1800)
// Force the dedicated 'peck' behavior, not 'idle' with an anim
// override — stepMascot() re-derives the anim from the active
// behavior's own anim() every tick (except for 'react', which
// has a special-cased override via reactAnim), so an anim
// override on any other behavior gets silently clobbered
// within one frame. 'peck' always resolves to the peck anim.
ctx.force('peck', { durationMs: 1800 })
ctx.refresh()
}
}
@@ -67,6 +102,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
label: 'Sleep',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
showBubble(ctx, '😴 Zzz…', 2000)
ctx.force('sleep', { durationMs: 8000 })
ctx.refresh()
}
@@ -102,37 +138,101 @@ export const MASCOT_ACTIONS: RadialAction[] = [
label: 'Debug',
children: [
{
id: 'force-hatch',
label: 'Force hatch',
visible: (m) => m.stage === 'egg',
action: (ctx) => {
ctx.forceHatch()
ctx.refresh()
}
id: 'debug-lifecycle',
label: 'Lifecycle',
children: [
{
id: 'force-hatch',
label: 'Force hatch',
description: 'Normally fires the instant you submit a name for a fresh egg — hatching is tied to naming, not a timer.',
visible: (m) => m.stage === 'egg',
action: (ctx) => {
ctx.forceHatch()
ctx.refresh()
}
},
{
id: 'force-chick',
label: 'Force chick',
description: 'Sets the stage directly. Normally happens automatically the moment a freshly-named egg hatches.',
visible: (m) => m.stage !== 'chick',
action: (ctx) => {
ctx.forceStage('chick')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'force-adult',
label: 'Force adult',
description: 'Sets the stage directly. Normally happens once xp reaches 200 — earned by petting, feeding, and reactions like Eureka.',
visible: (m) => m.stage !== 'adult',
action: (ctx) => {
ctx.forceStage('adult')
ctx.force('idle')
ctx.refresh()
}
}
]
},
{
id: 'force-chick',
label: 'Force chick',
visible: (m) => m.stage !== 'chick',
action: (ctx) => {
ctx.forceStage('chick')
ctx.force('idle')
ctx.refresh()
}
id: 'debug-reactions',
label: 'Reactions',
visible: (m) => m.stage !== 'egg', // reactions are suppressed while an egg — see MascotLayer's attachStimuli callback
children: [
{
id: 'trigger-thinking',
label: 'Trigger: Thinking',
description: 'Normally fires when Nomos starts streaming a reply in any open chat window.',
action: (ctx) => {
triggerReaction(ctx, 'thinking')
ctx.refresh()
}
},
{
id: 'trigger-eureka',
label: 'Trigger: Eureka',
description: 'Normally fires when a new knowledge-graph entry is written (an "upsert_knowledge" tool result). Grants +5 xp.',
action: (ctx) => {
triggerReaction(ctx, 'eureka')
ctx.refresh()
}
},
{
id: 'trigger-alarmed',
label: 'Trigger: Alarmed',
description: 'Normally fires on a critical event or a new signal.* event on the live event stream. The only reaction that wakes the mascot from sleep.',
action: (ctx) => {
triggerReaction(ctx, 'alarmed')
ctx.refresh()
}
},
{
id: 'trigger-happy',
label: 'Trigger: Happy',
description: 'Normally fires when an execution.* event lands on the live event stream.',
action: (ctx) => {
triggerReaction(ctx, 'happy')
ctx.refresh()
}
}
]
},
{
id: 'force-adult',
label: 'Force adult',
visible: (m) => m.stage !== 'adult',
id: 'force-fall',
label: 'Force fall',
description: 'Normally starts when you drop the mascot mid-air, it walks off the edge of a window, or the surface beneath it disappears (a window closes or moves away). Lifts it up first so theres room to actually fall.',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.forceStage('adult')
ctx.force('idle')
ctx.runtime.y = Math.max(0, ctx.runtime.y - 160)
ctx.force('falling')
ctx.refresh()
}
},
{
id: 'reset',
label: 'Reset',
description: 'No natural trigger — clears all mascot state (stage, name, stats, position) back to a fresh, unnamed egg.',
action: (ctx) => {
ctx.reset()
ctx.refresh()

View File

@@ -29,6 +29,26 @@ const TERMINAL_VY = 320 // px/s (slow flutter fall)
const WALK_SPEED = 36 // px/s
const MARGIN = 24 // px before the surface edge where wander flips facing
// Falling is a series of glide/flap sub-phases, not a flat monotonic drop:
// every FLAP_CYCLE_MS, a wing-beat impulse briefly cuts the descent speed
// (a real, if losing, attempt at flight), and the animation swaps to `flap`
// for the FLAP_BURST_MS right after each impulse. A gentle sine wobble adds
// horizontal drift so the fall isn't perfectly vertical either. See
// stepMascot()'s 'falling' case and the `falling` BehaviorDef below.
const FLAP_CYCLE_MS = 550
const FLAP_BURST_MS = 160
const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle
const FLAP_MAX_LIFT = -150 // px/s — how negative (upward) a flap may push vy
const WOBBLE_VX = 22 // px/s amplitude of the sideways drift while falling
const VX_DECAY_PER_S = 1.4 // exponential decay rate for toss/drift vx
// Ground tracking: Mascot.svelte's tick() chases the ground line (the top
// of whatever window/surface is beneath the mascot) each frame. A small
// per-tick change (a window being dragged smoothly, with the mascot riding
// along) follows instantly; anything the ground drops away by more than
// this is treated as the surface disappearing — falling takes over instead
// of snapping. See GROUND_FOLLOW_MAX_STEP/GROUND_DROP_FALL_PX in Mascot.svelte.
// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
// can override with its own minMs/maxMs.
const IDLE_MS = [1500, 4000] as const
@@ -38,6 +58,29 @@ const SLEEP_MS = [6000, 12000] as const
const LAND_MS = 400
const REACT_DEFAULT_MS = 1800
// Idle chatter: very occasional, unprompted, purely cosmetic one-liners —
// no signal value, just personality. Rolled once each time `idle` is
// (re-)entered, gated by both a probability and a cooldown so it stays
// rare rather than firing on every idle cycle (idle gets re-picked often
// by the weighted-random selector). See the `idle` BehaviorDef below.
const IDLE_CHATTER_CHANCE = 0.12
const IDLE_CHATTER_COOLDOWN_MS = 25_000
const IDLE_CHATTER_DURATION_MS = 2200
const IDLE_CHATTER_LINES = [
'🐔 Bawk.',
"💭 Wonder what's up on hubris…",
'🌾 Any seeds around?',
'😌 Nice day for uptime.',
'📦 So many containers…',
'☁️ Backup time yet?',
'🥚 Remember when I was an egg?',
'🔧 *pecks at nothing in particular*',
'🐧 Penguins are cool too, I guess.'
]
// Module-level (not per-runtime) since there's only ever one mascot —
// matches stimuli.ts's own module-level cooldown tracking.
let lastChatterAt = 0
// ─── BehaviorDef ─────────────────────────────────────────────────────────
export interface BehaviorDef {
@@ -119,6 +162,14 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
},
enter: (rt) => {
rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
// Idle chatter: rare, cosmetic-only bubble line (see the constants
// above). Doesn't touch the FSM/behavior at all, just the bubble.
const now = performance.now()
if (now - lastChatterAt > IDLE_CHATTER_COOLDOWN_MS && Math.random() < IDLE_CHATTER_CHANCE) {
lastChatterAt = now
rt.bubbleText = IDLE_CHATTER_LINES[Math.floor(Math.random() * IDLE_CHATTER_LINES.length)]
rt.bubbleUntil = now + IDLE_CHATTER_DURATION_MS
}
},
tick: () => {
// Standing still.
@@ -185,9 +236,15 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
falling: {
id: 'falling',
anim: () => 'fall-flutter',
// Flap briefly right after each wing-beat impulse (see stepMascot),
// glide the rest of the cycle.
anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
enter: (rt) => {
rt.vx = 0
rt.fallPhaseAt = performance.now()
// vx/vy are deliberately NOT reset here — they carry over from
// drag-release toss momentum (set by Mascot.svelte's onPointerUp)
// when falling starts from a throw, or stay at 0 when it starts from
// walking off an edge / a surface disappearing underfoot.
},
tick: () => {
// Integration happens in stepMascot (needs dt in seconds).
@@ -203,7 +260,17 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
tick: () => {
// Brief squash animation.
},
next: () => 'idle',
// Routes to 'peck' instead of 'idle' when the drag that led here was
// released on top of a desktop icon (Mascot.svelte's onPointerUp sets
// investigateOnLand) — a little "investigate" reaction, whether the
// landing was immediate or came after a fall. Consumed once.
next: (rt) => {
if (rt.investigateOnLand) {
rt.investigateOnLand = false
return 'peck'
}
return 'idle'
},
minMs: LAND_MS,
maxMs: LAND_MS
},
@@ -295,12 +362,25 @@ export function stepMascot(
break
}
case 'falling': {
// Wing-beat: every FLAP_CYCLE_MS, cut the descent speed sharply —
// a real (if losing) attempt at flight rather than a flat drop.
if (now - rt.fallPhaseAt >= FLAP_CYCLE_MS) {
rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE)
rt.fallPhaseAt = now
}
rt.vy = Math.min(TERMINAL_VY, rt.vy + GRAVITY * dts)
// Toss/drift horizontal velocity decays so it doesn't carry forever,
// plus a gentle sideways wobble so even a straight-down drop isn't
// perfectly vertical.
rt.vx *= Math.max(0, 1 - VX_DECAY_PER_S * dts)
const wobble = Math.sin(now / 260) * WOBBLE_VX
rt.x += (rt.vx + wobble) * dts
rt.y += rt.vy * dts
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
rt.vx = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
}
break
@@ -358,15 +438,20 @@ export function isAsleep(rt: MascotRuntime): boolean {
return rt.behavior === 'sleep'
}
/** Reset the runtime's vertical state for a drag-from-ground: no falling immediately on release at ground. */
/**
* Release from a drag: land immediately if already at/below ground, or
* start falling otherwise. `rt.vx`/`rt.vy` are expected to already hold the
* release's toss velocity (set by Mascot.svelte's onPointerUp from recent
* pointer-move samples) — they're carried into `falling`, not reset here.
*/
export function releaseFromDrag(rt: MascotRuntime): void {
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
rt.vx = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
} else {
rt.vy = 0
forceBehavior(rt, 'falling')
}
}

View File

@@ -12,10 +12,10 @@ import { getImage } from './sprites'
/**
* Logical canvas size (CSS px) the mascot is drawn onto. The sprite's
* feet land on the bottom row. The canvas is taller than wide (20x28)
* so there's room above the sprite for the reaction bubble — the sprite
* bottom-anchors at y=CANVAS_H, and the bubble renders in the top
* ~8 logical px without being clipped.
* feet land on the bottom row; the extra height above it (20x28, not
* 20x20) leaves a little headroom before the name label/reaction bubble
* (both real HTML elements floating above the canvas — see Mascot.svelte's
* template) start overlapping the sprite itself.
*/
export const CANVAS_W = 20
export const CANVAS_H = 28
@@ -59,29 +59,3 @@ export function drawFrame(
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
ctx.restore()
}
/** Draw a reaction bubble above the sprite (call after drawFrame, same context). */
export function drawBubble(
ctx: CanvasRenderingContext2D,
bubbleSrc: string,
facing: 1 | -1
): void {
const img = getImage(bubbleSrc)
if (!img) return
// Bubbles are 16x16 single-frame PNGs drawn in the top portion of the
// 20x28 canvas (above the sprite's head). imageSmoothingEnabled is
// TRUE here — bubbles are crisp UI, not pixel-art, so nearest-neighbor
// downscale looks blocky.
const drawW = 14
const drawH = 14
const dx = (CANVAS_W - drawW) / 2
const dy = 0
ctx.save()
if (facing === -1) {
ctx.translate(CANVAS_W, 0)
ctx.scale(-1, 1)
}
ctx.imageSmoothingEnabled = true
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, drawW, drawH)
ctx.restore()
}

View File

@@ -131,11 +131,6 @@ export async function loadSprites(stages: MascotStage[] = ['egg', 'chick', 'adul
await Promise.all([...srcs].map(loadOne))
}
/** Also preload a single bubble sprite by URL (used by react-* animations). */
export function loadBubble(src: string): Promise<HTMLImageElement> {
return loadOne(src)
}
/** Get a cached sheet image, or null if not yet loaded. */
export function getImage(src: string): HTMLImageElement | null {
const img = imageCache.get(src)

View File

@@ -25,7 +25,7 @@ export interface ReactionDef {
id: string
/** Animation to play while this reaction is active. */
anim: AnimName
/** Optional bubble sprite URL (16x16 PNG in /mascot/) drawn above the sprite. */
/** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). */
bubble?: string
/** Higher priority interrupts lower-priority reactions. */
priority: number
@@ -43,7 +43,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
thinking: {
id: 'thinking',
anim: 'react-think',
bubble: '/mascot/bubble-dotdotdot.png',
bubble: '💭 Thinking',
priority: 1,
cooldownMs: 0,
durationMs: 4000,
@@ -52,7 +52,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
eureka: {
id: 'eureka',
anim: 'react-eureka',
bubble: '/mascot/bubble-exclaim.png',
bubble: '💡 Eureka!',
priority: 2,
cooldownMs: 10_000,
durationMs: 2200,
@@ -62,7 +62,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
alarmed: {
id: 'alarmed',
anim: 'react-alarm',
bubble: '/mascot/bubble-red-exclaim.png',
bubble: '❗ Uh oh!',
priority: 3,
cooldownMs: 15_000,
durationMs: 2500,
@@ -71,7 +71,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
happy: {
id: 'happy',
anim: 'react-happy',
bubble: '/mascot/bubble-love.png',
bubble: '🎉 Nice work!',
priority: 1,
cooldownMs: 20_000,
durationMs: 2000

View File

@@ -51,6 +51,14 @@ export interface RadialAction {
id: string
label: string
icon?: Component
/**
* Shown as a small muted second line under the label — mainly used by
* Debug entries to say what normally triggers the thing being forced
* (e.g. "Normally fires when Nomos starts streaming a reply"), so a
* manual test doesn't need to be cross-referenced against the code to
* know what it's simulating.
*/
description?: string
/** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */
visible?: (model: import('./state.svelte').MascotModel) => boolean
/** Sub-actions — selecting this node swaps the ring to its children + a back button. */
@@ -103,10 +111,28 @@ export interface MascotRuntime {
* wmState; the FSM uses this as the ground for falling/landing.
*/
groundY: number
/** Optional reaction-bubble sprite to draw above the chicken (eg 'bubble-exclaim'). */
bubble: string | null
/** performance.now() ms when the current bubble was set; cleared when null. */
/**
* Optional text/emoji shown above the mascot in a real HTML speech
* bubble (Mascot.svelte's template), not a sprite — e.g. '❤️', '💡', or
* a short phrase.
*/
bubbleText: string | null
/** performance.now() ms until which bubbleText stays visible; the game loop (tick()) clears it once this passes. */
bubbleUntil: number
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
blinkUntil: number
/**
* performance.now() ms marking the start of the current flap/glide
* sub-phase within a `falling` behavior — see FLAP_CYCLE_MS/FLAP_BURST_MS
* in behavior.ts. Reset each time `falling` is entered.
*/
fallPhaseAt: number
/**
* Set (by Mascot.svelte's onPointerUp) when a drag is released on top of
* a desktop icon. Consumed once by the `land` BehaviorDef's `next()` —
* routes the next landing to `peck` instead of `idle`, whether that
* landing happens immediately (already on the ground) or after a fall.
* Always false outside that one moment.
*/
investigateOnLand: boolean
}