From d82095213ab9618f9bb0b34e48c859fc662c6ad6 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 20 Jul 2026 23:23:30 +0200 Subject: [PATCH] fix(web): mascot physics, drag reliability, and speech-bubble polish 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 --- plans/2026-07-20-mascot-physics-audit.md | 296 +++++++++++++++++ plans/index.md | 3 +- web/public/mascot/bubble-dotdotdot.png | Bin 190 -> 0 bytes web/public/mascot/bubble-exclaim.png | Bin 193 -> 0 bytes web/public/mascot/bubble-love.png | Bin 222 -> 0 bytes web/public/mascot/bubble-question.png | Bin 203 -> 0 bytes web/public/mascot/bubble-red-exclaim.png | Bin 197 -> 0 bytes web/src/lib/mascot/Mascot.svelte | 398 ++++++++++++++++++++--- web/src/lib/mascot/MascotLayer.svelte | 23 +- web/src/lib/mascot/RadialMenu.svelte | 17 +- web/src/lib/mascot/actions.ts | 156 +++++++-- web/src/lib/mascot/behavior.ts | 95 +++++- web/src/lib/mascot/render.ts | 34 +- web/src/lib/mascot/sprites.ts | 5 - web/src/lib/mascot/stimuli.ts | 10 +- web/src/lib/mascot/types.ts | 32 +- 16 files changed, 929 insertions(+), 140 deletions(-) create mode 100644 plans/2026-07-20-mascot-physics-audit.md delete mode 100644 web/public/mascot/bubble-dotdotdot.png delete mode 100644 web/public/mascot/bubble-exclaim.png delete mode 100644 web/public/mascot/bubble-love.png delete mode 100644 web/public/mascot/bubble-question.png delete mode 100644 web/public/mascot/bubble-red-exclaim.png diff --git a/plans/2026-07-20-mascot-physics-audit.md b/plans/2026-07-20-mascot-physics-audit.md new file mode 100644 index 0000000..195912d --- /dev/null +++ b/plans/2026-07-20-mascot-physics-audit.md @@ -0,0 +1,296 @@ +# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan + +**Status:** P0–P2 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.5–3s 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; 1–3 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. diff --git a/plans/index.md b/plans/index.md index 49b13c4..43726ce 100644 --- a/plans/index.md +++ b/plans/index.md @@ -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) | P0–P2 implemented; P3 ("cool stuff") ideas open | ## Done diff --git a/web/public/mascot/bubble-dotdotdot.png b/web/public/mascot/bubble-dotdotdot.png deleted file mode 100644 index f5697b98c1ba40e5a6b4ad4ad93ba1173ea52a5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Dm+~rLo9ml zPW0w$P~c%*eoVCdQNX+q)k{*#dAG>nXNF;4Gf;HelF{r5}E)PRz?Z{ diff --git a/web/public/mascot/bubble-exclaim.png b/web/public/mascot/bubble-exclaim.png deleted file mode 100644 index 67b36e27255d55113f4001a2ec0f83df0679783f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 193 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|sy$sCLo9l) zPV(kzFyL{CKN9+GigV>8nN!v~ul7z9i;lAA;47J2W~a%V?{t^p+O@##71e6r6c5sc?f~+i8<_kF=R;9L);OaZ7~y pid+jU;~pK&$#j%ryV^M*-&OZK*Hjs`k|!PC{xWt~$(69D;Z zU%j+ruSHWDlfvV|J4Y2b9nAYTJUcV1c>DVvH`$7mhSb|hvMz=u8KFwg^VsjFoWGJJ zaWi?|T@C+NtBVaxoB#e(xvJ!;o^CCD{QTsspG(q{zsyTLDr+yYSiMil?tb_OwxWlt VBBMR?mH^$z;OXk;vd$@?2>`>4Sj_+c diff --git a/web/public/mascot/bubble-question.png b/web/public/mascot/bubble-question.png deleted file mode 100644 index 3b29c48c52018926b7380fa9ab15ca4929bce141..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 203 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|T0LDHLo9mN zPC6*qpuofOUMBu!=b|+#t~V#g|CL}-nIL&rQcmEVhew0Gpf5wgxoH`jcIOwe7&9`6 z$(6rpOp$zZbVt?G32R*5-ixql)>!BGOEZ?Lo9la zPCUrlpdi5Vyhs1kL37tg&fW~sZ%@MQ=QvI&=yWqzRAk?MhOHu4kF8|7IYIna0GXXn%AvsRmW#(8RPWuE!JY|e^AMtz`D7(8A5T-G@yGywpoDMX?G diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte index 478f38c..b8d64cd 100644 --- a/web/src/lib/mascot/Mascot.svelte +++ b/web/src/lib/mascot/Mascot.svelte @@ -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 = { + 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(null) + let bubbleTextEl = $state(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) + }) + + -{#if model.name} +{#if runtime.bubbleText || model.name}
- {model.name} + {#if runtime.bubbleText} +
+
+ {runtime.bubbleText} +
+
+
+ {:else} +
+ {model.name} +
+ {/if}
{/if} + + diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte index 9825c6d..7386ef4 100644 --- a/web/src/lib/mascot/MascotLayer.svelte +++ b/web/src/lib/mascot/MascotLayer.svelte @@ -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 @@
{ + nameDialogMode = 'hatch' + nameDialogOpen = true + }} />
diff --git a/web/src/lib/mascot/RadialMenu.svelte b/web/src/lib/mascot/RadialMenu.svelte index e9e432f..143113f 100644 --- a/web/src/lib/mascot/RadialMenu.svelte +++ b/web/src/lib/mascot/RadialMenu.svelte @@ -106,7 +106,7 @@