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 <noreply@anthropic.com>
This commit is contained in:
296
plans/2026-07-20-mascot-physics-audit.md
Normal file
296
plans/2026-07-20-mascot-physics-audit.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 there’s 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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user