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.