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-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-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-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 | [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
|
## 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.
|
// decision in plans/2026-07-20-desktop-mascot.md.
|
||||||
|
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { loadSprites, loadBubble, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
|
import { loadSprites, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
|
||||||
import { drawFrame, drawBubble, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
|
import { drawFrame, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
|
||||||
import {
|
import {
|
||||||
stepMascot,
|
stepMascot,
|
||||||
forceBehavior,
|
forceBehavior,
|
||||||
@@ -31,13 +31,50 @@
|
|||||||
pet as modelPet
|
pet as modelPet
|
||||||
} from '$lib/mascot/state.svelte'
|
} from '$lib/mascot/state.svelte'
|
||||||
import { wmState } from '$lib/stores/windows'
|
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
|
// MascotRuntime is created fresh per mount; the long-lived MascotModel
|
||||||
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
|
// (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 SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
|
||||||
const DRAG_THRESHOLD = 5
|
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)
|
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
|
||||||
// than GraphBackground's slow ambient drift, and 30fps position updates
|
// than GraphBackground's slow ambient drift, and 30fps position updates
|
||||||
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
|
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
|
||||||
@@ -58,6 +95,10 @@
|
|||||||
let dragPointerId: number | null = null
|
let dragPointerId: number | null = null
|
||||||
let dragStartClient = { x: 0, y: 0 }
|
let dragStartClient = { x: 0, y: 0 }
|
||||||
let moved = false
|
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).
|
// Wiggle phase for the egg (render-time only, not persisted).
|
||||||
let wigglePhase = 0
|
let wigglePhase = 0
|
||||||
// Snapshot of the current window manager state, refreshed by
|
// 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
|
* Compute the ground line at the mascot's x: the top edge (y) of the
|
||||||
* highest non-minimized window whose horizontal span covers the
|
* NEAREST non-minimized window whose horizontal span covers the
|
||||||
* mascot's x, or the surface bottom (bounds.h) when no window is
|
* mascot's x AND whose top is at/below the mascot's current position —
|
||||||
* beneath. This is what lets the mascot walk ON TOP of windows —
|
* i.e. a downward raycast from where it's standing, not "the topmost
|
||||||
* when it strolls over a window, the ground rises to that window's
|
* window anywhere in this column." Without the vertical check, a window
|
||||||
* top edge; when it walks off the side, the ground drops back to the
|
* opening or moving anywhere above the mascot (even with empty space
|
||||||
* desktop surface and it flutters-falls.
|
* 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 {
|
function computeGroundAt(x: number): number {
|
||||||
let ground = runtime.bounds.h
|
let ground = runtime.bounds.h
|
||||||
@@ -94,10 +143,14 @@
|
|||||||
const b = win.bounds
|
const b = win.bounds
|
||||||
// Window top edge counts as ground only if the mascot's x is
|
// 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
|
// within the window's horizontal span (with a small margin so the
|
||||||
// mascot doesn't immediately fall off the very corner).
|
// mascot doesn't immediately fall off the very corner)...
|
||||||
if (x >= b.x - 4 && x <= b.x + b.width + 4) {
|
const inSpan = x >= b.x - 4 && x <= b.x + b.width + 4
|
||||||
// Walking on top = ground at the window's top edge.
|
// ...AND the window's top is at/below where the mascot currently
|
||||||
if (b.y < ground) ground = b.y
|
// 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
|
return ground
|
||||||
@@ -108,6 +161,32 @@
|
|||||||
return { anim: resolveAnim(stage(), name), name }
|
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 {
|
function tick(now: number): void {
|
||||||
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
|
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
|
||||||
lastFrame = now
|
lastFrame = now
|
||||||
@@ -115,21 +194,36 @@
|
|||||||
// lets the mascot walk on top of windows (the ground rises to a
|
// lets the mascot walk on top of windows (the ground rises to a
|
||||||
// window's top edge when the mascot strolls over it).
|
// window's top edge when the mascot strolls over it).
|
||||||
const newGround = computeGroundAt(runtime.x)
|
const newGround = computeGroundAt(runtime.x)
|
||||||
// Ride the ground: when the mascot is grounded (not falling/dragged)
|
// Chase the ground when grounded (not already falling/dragged):
|
||||||
// and the ground moved (the window beneath was dragged/resized),
|
// - if it dropped away by more than GROUND_DROP_FALL_PX (a window
|
||||||
// translate the mascot with it so it sticks to the surface instead
|
// closed, moved out from under the mascot, or it walked off an
|
||||||
// of floating in place while the window drifts out from under it.
|
// edge), hand off to a real `falling` behavior instead of snapping
|
||||||
if (
|
// — this was Finding 1 in the physics audit: the mascot used to
|
||||||
runtime.behavior !== 'falling' &&
|
// teleport straight to the new ground with zero animation.
|
||||||
runtime.behavior !== 'dragged' &&
|
// - otherwise, step toward the new ground by at most
|
||||||
runtime.y >= prevGroundY - 1 &&
|
// GROUND_FOLLOW_MAX_STEP per tick. A window being dragged smoothly
|
||||||
newGround !== prevGroundY
|
// moves only a few px/tick, so this still reads as an instant,
|
||||||
) {
|
// solid "ride along"; anything bigger (a window snapping to a new
|
||||||
runtime.y += newGround - prevGroundY
|
// 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
|
runtime.groundY = newGround
|
||||||
prevGroundY = newGround
|
prevGroundY = newGround
|
||||||
stepMascot(runtime, model, now, dt)
|
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.
|
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
|
||||||
if (lastLifecycle === 0) lastLifecycle = now
|
if (lastLifecycle === 0) lastLifecycle = now
|
||||||
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
|
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
|
||||||
@@ -164,14 +258,6 @@
|
|||||||
facing: runtime.facing,
|
facing: runtime.facing,
|
||||||
wiggle: wigglePhase
|
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
|
// Single loop: tick physics + draw sprite + reschedule. (The previous
|
||||||
@@ -191,6 +277,7 @@
|
|||||||
dragPointerId = e.pointerId
|
dragPointerId = e.pointerId
|
||||||
dragStartClient = { x: e.clientX, y: e.clientY }
|
dragStartClient = { x: e.clientX, y: e.clientY }
|
||||||
moved = false
|
moved = false
|
||||||
|
dragSamples = []
|
||||||
el.setPointerCapture(e.pointerId)
|
el.setPointerCapture(e.pointerId)
|
||||||
forceBehavior(runtime, 'dragged')
|
forceBehavior(runtime, 'dragged')
|
||||||
dragging = true
|
dragging = true
|
||||||
@@ -210,28 +297,121 @@
|
|||||||
if (rect) {
|
if (rect) {
|
||||||
runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
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))
|
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) {
|
/** Derive a release (toss) velocity from the last VELOCITY_WINDOW_MS of drag samples. */
|
||||||
if (dragPointerId !== e.pointerId) return
|
function tossVelocity(): { vx: number; vy: number } {
|
||||||
const el = e.currentTarget as HTMLElement
|
if (dragSamples.length < 2) return { vx: 0, vy: 0 }
|
||||||
el.releasePointerCapture(e.pointerId)
|
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
|
dragPointerId = null
|
||||||
dragging = false
|
dragging = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (dragPointerId !== e.pointerId) return
|
||||||
if (!moved) {
|
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.
|
// Plain click = pet: a brief happy reaction with a heart bubble.
|
||||||
modelPet()
|
modelPet()
|
||||||
onPet()
|
onPet()
|
||||||
runtime.bubble = '/mascot/bubble-love.png'
|
runtime.bubbleText = '❤️'
|
||||||
runtime.bubbleUntil = performance.now() + 1500
|
runtime.bubbleUntil = performance.now() + 1500
|
||||||
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
|
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
|
||||||
} else {
|
} 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)
|
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)
|
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) {
|
function handleContextMenu(e: MouseEvent) {
|
||||||
@@ -252,15 +432,6 @@
|
|||||||
initMascotState()
|
initMascotState()
|
||||||
syncCanvasSize()
|
syncCanvasSize()
|
||||||
await loadSprites()
|
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()
|
lastFrame = performance.now()
|
||||||
lastLifecycle = 0
|
lastLifecycle = 0
|
||||||
// Seed prevGroundY so the first tick's ride-the-ground delta is zero
|
// 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
|
// layout reflow) rather than `left`/`top` so motion stays smooth at
|
||||||
// high refresh rates. A squash transform is composed in during the
|
// high refresh rates. A squash transform is composed in during the
|
||||||
// `land` behavior (brief scaleY(0.82)) to sell the impact.
|
// `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 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 squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
|
||||||
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onpointerup={onWindowPointerEnd} onpointercancel={onWindowPointerEnd} />
|
||||||
|
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
|
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||||
@@ -315,15 +564,60 @@
|
|||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerCancel}
|
||||||
oncontextmenu={handleContextMenu}
|
oncontextmenu={handleContextMenu}
|
||||||
title={model.name ?? 'Cluck'}
|
title={model.name ?? 'Cluck'}
|
||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
{#if model.name}
|
{#if runtime.bubbleText || model.name}
|
||||||
<div
|
<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"
|
class="pointer-events-none absolute left-0 top-0 select-none"
|
||||||
style="transform: translate3d({tx + (CANVAS_W * SCALE_PX) / 2}px, {ty - 8}px, 0); will-change: transform;"
|
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
|
||||||
>
|
>
|
||||||
|
{#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}
|
{model.name}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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,
|
reactAnim: null,
|
||||||
bounds: { w: 800, h: 600 },
|
bounds: { w: 800, h: 600 },
|
||||||
groundY: 600,
|
groundY: 600,
|
||||||
bubble: null,
|
bubbleText: null,
|
||||||
bubbleUntil: 0,
|
bubbleUntil: 0,
|
||||||
blinkUntil: 0
|
blinkUntil: 0,
|
||||||
|
fallPhaseAt: 0,
|
||||||
|
investigateOnLand: false
|
||||||
})
|
})
|
||||||
|
|
||||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
function onPet() {
|
function onPet() {
|
||||||
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
|
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
|
||||||
// call already happened in Mascot.svelte.
|
// call already happened in Mascot.svelte.
|
||||||
runtime.bubble = '/mascot/bubble-love.png'
|
runtime.bubbleText = '❤️'
|
||||||
runtime.bubbleUntil = performance.now() + 1500
|
runtime.bubbleUntil = performance.now() + 1500
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +151,13 @@
|
|||||||
// behind it.
|
// behind it.
|
||||||
const detach = attachStimuli((reaction) => {
|
const detach = attachStimuli((reaction) => {
|
||||||
if (getModel().stage === 'egg') return
|
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);
|
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
|
||||||
// here we just force the behavior.
|
// here we just force the behavior.
|
||||||
const anim = reaction.anim as AnimName
|
const anim = reaction.anim as AnimName
|
||||||
@@ -156,7 +165,7 @@
|
|||||||
runtime.reactAnim = anim
|
runtime.reactAnim = anim
|
||||||
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
|
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
|
||||||
if (reaction.bubble) {
|
if (reaction.bubble) {
|
||||||
runtime.bubble = reaction.bubble
|
runtime.bubbleText = reaction.bubble
|
||||||
runtime.bubbleUntil = performance.now() + reaction.durationMs
|
runtime.bubbleUntil = performance.now() + reaction.durationMs
|
||||||
}
|
}
|
||||||
if (reaction.effect) reaction.effect()
|
if (reaction.effect) reaction.effect()
|
||||||
@@ -180,9 +189,13 @@
|
|||||||
|
|
||||||
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
||||||
<Mascot
|
<Mascot
|
||||||
{runtime}
|
bind:runtime
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
onPet={onPet}
|
onPet={onPet}
|
||||||
|
onRequestName={() => {
|
||||||
|
nameDialogMode = 'hatch'
|
||||||
|
nameDialogOpen = true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={host}
|
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'};"
|
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"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -134,18 +134,23 @@
|
|||||||
{#each visibleItems as a (a.id)}
|
{#each visibleItems as a (a.id)}
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onclick={(ev) => selectItem(a, ev)}
|
||||||
oncontextmenu={(ev) => ev.preventDefault()}
|
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}
|
{#if a.icon}
|
||||||
<a.icon class="size-4 shrink-0" />
|
<a.icon class="mt-0.5 size-4 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
<span class="truncate">{a.label}</span>
|
<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>
|
</span>
|
||||||
{#if a.children && a.children.length > 0}
|
{#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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -8,12 +8,34 @@
|
|||||||
// RadialMenu.svelte renders whatever tree it's given, including
|
// RadialMenu.svelte renders whatever tree it's given, including
|
||||||
// arbitrary nesting depth — no engine change is needed for a new node.
|
// 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],
|
// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake],
|
||||||
// Identity [Rename], Debug [Force hatch, Force chick, Force adult, Reset].
|
// Identity [Rename], Debug [Lifecycle → [...], Reactions → [...], Force
|
||||||
// The Pet action is the same as a plain click — included in the menu for
|
// fall, Reset]. The Pet action is the same as a plain click — included in
|
||||||
// discoverability.
|
// 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[] = [
|
export const MASCOT_ACTIONS: RadialAction[] = [
|
||||||
{
|
{
|
||||||
@@ -24,8 +46,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
id: 'pet',
|
id: 'pet',
|
||||||
label: 'Pet',
|
label: 'Pet',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
ctx.runtime.bubble = '/mascot/bubble-love.png'
|
showBubble(ctx, '❤️', 1500)
|
||||||
ctx.runtime.bubbleUntil = performance.now() + 1500
|
|
||||||
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
|
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
}
|
}
|
||||||
@@ -40,7 +61,14 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
// Feeding: small happiness + xp boost.
|
// Feeding: small happiness + xp boost.
|
||||||
ctx.model.happiness = Math.min(100, ctx.model.happiness + 8)
|
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()
|
ctx.refresh()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -50,7 +78,14 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
// Worm: bigger boost.
|
// Worm: bigger boost.
|
||||||
ctx.model.happiness = Math.min(100, ctx.model.happiness + 16)
|
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()
|
ctx.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
label: 'Sleep',
|
label: 'Sleep',
|
||||||
visible: (m) => m.stage !== 'egg',
|
visible: (m) => m.stage !== 'egg',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
|
showBubble(ctx, '😴 Zzz…', 2000)
|
||||||
ctx.force('sleep', { durationMs: 8000 })
|
ctx.force('sleep', { durationMs: 8000 })
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
}
|
}
|
||||||
@@ -100,10 +136,15 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'debug',
|
id: 'debug',
|
||||||
label: 'Debug',
|
label: 'Debug',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'debug-lifecycle',
|
||||||
|
label: 'Lifecycle',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'force-hatch',
|
id: 'force-hatch',
|
||||||
label: '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',
|
visible: (m) => m.stage === 'egg',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
ctx.forceHatch()
|
ctx.forceHatch()
|
||||||
@@ -113,6 +154,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'force-chick',
|
id: 'force-chick',
|
||||||
label: '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',
|
visible: (m) => m.stage !== 'chick',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
ctx.forceStage('chick')
|
ctx.forceStage('chick')
|
||||||
@@ -123,16 +165,74 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'force-adult',
|
id: 'force-adult',
|
||||||
label: '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',
|
visible: (m) => m.stage !== 'adult',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
ctx.forceStage('adult')
|
ctx.forceStage('adult')
|
||||||
ctx.force('idle')
|
ctx.force('idle')
|
||||||
ctx.refresh()
|
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-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.runtime.y = Math.max(0, ctx.runtime.y - 160)
|
||||||
|
ctx.force('falling')
|
||||||
|
ctx.refresh()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reset',
|
id: 'reset',
|
||||||
label: 'Reset',
|
label: 'Reset',
|
||||||
|
description: 'No natural trigger — clears all mascot state (stage, name, stats, position) back to a fresh, unnamed egg.',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
ctx.reset()
|
ctx.reset()
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
|
|||||||
@@ -29,6 +29,26 @@ const TERMINAL_VY = 320 // px/s (slow flutter fall)
|
|||||||
const WALK_SPEED = 36 // px/s
|
const WALK_SPEED = 36 // px/s
|
||||||
const MARGIN = 24 // px before the surface edge where wander flips facing
|
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
|
// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
|
||||||
// can override with its own minMs/maxMs.
|
// can override with its own minMs/maxMs.
|
||||||
const IDLE_MS = [1500, 4000] as const
|
const IDLE_MS = [1500, 4000] as const
|
||||||
@@ -38,6 +58,29 @@ const SLEEP_MS = [6000, 12000] as const
|
|||||||
const LAND_MS = 400
|
const LAND_MS = 400
|
||||||
const REACT_DEFAULT_MS = 1800
|
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 ─────────────────────────────────────────────────────────
|
// ─── BehaviorDef ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface BehaviorDef {
|
export interface BehaviorDef {
|
||||||
@@ -119,6 +162,14 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
},
|
},
|
||||||
enter: (rt) => {
|
enter: (rt) => {
|
||||||
rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
|
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: () => {
|
tick: () => {
|
||||||
// Standing still.
|
// Standing still.
|
||||||
@@ -185,9 +236,15 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
|
|
||||||
falling: {
|
falling: {
|
||||||
id: '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) => {
|
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: () => {
|
tick: () => {
|
||||||
// Integration happens in stepMascot (needs dt in seconds).
|
// Integration happens in stepMascot (needs dt in seconds).
|
||||||
@@ -203,7 +260,17 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
tick: () => {
|
tick: () => {
|
||||||
// Brief squash animation.
|
// 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,
|
minMs: LAND_MS,
|
||||||
maxMs: LAND_MS
|
maxMs: LAND_MS
|
||||||
},
|
},
|
||||||
@@ -295,12 +362,25 @@ export function stepMascot(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'falling': {
|
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)
|
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
|
rt.y += rt.vy * dts
|
||||||
const gy = groundY(rt)
|
const gy = groundY(rt)
|
||||||
if (rt.y >= gy) {
|
if (rt.y >= gy) {
|
||||||
rt.y = gy
|
rt.y = gy
|
||||||
rt.vy = 0
|
rt.vy = 0
|
||||||
|
rt.vx = 0
|
||||||
forceBehavior(rt, 'land', { durationMs: LAND_MS })
|
forceBehavior(rt, 'land', { durationMs: LAND_MS })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -358,15 +438,20 @@ export function isAsleep(rt: MascotRuntime): boolean {
|
|||||||
return rt.behavior === 'sleep'
|
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 {
|
export function releaseFromDrag(rt: MascotRuntime): void {
|
||||||
const gy = groundY(rt)
|
const gy = groundY(rt)
|
||||||
if (rt.y >= gy) {
|
if (rt.y >= gy) {
|
||||||
rt.y = gy
|
rt.y = gy
|
||||||
rt.vy = 0
|
rt.vy = 0
|
||||||
|
rt.vx = 0
|
||||||
forceBehavior(rt, 'land', { durationMs: LAND_MS })
|
forceBehavior(rt, 'land', { durationMs: LAND_MS })
|
||||||
} else {
|
} else {
|
||||||
rt.vy = 0
|
|
||||||
forceBehavior(rt, 'falling')
|
forceBehavior(rt, 'falling')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import { getImage } from './sprites'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Logical canvas size (CSS px) the mascot is drawn onto. The sprite's
|
* 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)
|
* feet land on the bottom row; the extra height above it (20x28, not
|
||||||
* so there's room above the sprite for the reaction bubble — the sprite
|
* 20x20) leaves a little headroom before the name label/reaction bubble
|
||||||
* bottom-anchors at y=CANVAS_H, and the bubble renders in the top
|
* (both real HTML elements floating above the canvas — see Mascot.svelte's
|
||||||
* ~8 logical px without being clipped.
|
* template) start overlapping the sprite itself.
|
||||||
*/
|
*/
|
||||||
export const CANVAS_W = 20
|
export const CANVAS_W = 20
|
||||||
export const CANVAS_H = 28
|
export const CANVAS_H = 28
|
||||||
@@ -59,29 +59,3 @@ export function drawFrame(
|
|||||||
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
|
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
|
||||||
ctx.restore()
|
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))
|
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. */
|
/** Get a cached sheet image, or null if not yet loaded. */
|
||||||
export function getImage(src: string): HTMLImageElement | null {
|
export function getImage(src: string): HTMLImageElement | null {
|
||||||
const img = imageCache.get(src)
|
const img = imageCache.get(src)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ReactionDef {
|
|||||||
id: string
|
id: string
|
||||||
/** Animation to play while this reaction is active. */
|
/** Animation to play while this reaction is active. */
|
||||||
anim: AnimName
|
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
|
bubble?: string
|
||||||
/** Higher priority interrupts lower-priority reactions. */
|
/** Higher priority interrupts lower-priority reactions. */
|
||||||
priority: number
|
priority: number
|
||||||
@@ -43,7 +43,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
thinking: {
|
thinking: {
|
||||||
id: 'thinking',
|
id: 'thinking',
|
||||||
anim: 'react-think',
|
anim: 'react-think',
|
||||||
bubble: '/mascot/bubble-dotdotdot.png',
|
bubble: '💭 Thinking…',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
cooldownMs: 0,
|
cooldownMs: 0,
|
||||||
durationMs: 4000,
|
durationMs: 4000,
|
||||||
@@ -52,7 +52,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
eureka: {
|
eureka: {
|
||||||
id: 'eureka',
|
id: 'eureka',
|
||||||
anim: 'react-eureka',
|
anim: 'react-eureka',
|
||||||
bubble: '/mascot/bubble-exclaim.png',
|
bubble: '💡 Eureka!',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
cooldownMs: 10_000,
|
cooldownMs: 10_000,
|
||||||
durationMs: 2200,
|
durationMs: 2200,
|
||||||
@@ -62,7 +62,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
alarmed: {
|
alarmed: {
|
||||||
id: 'alarmed',
|
id: 'alarmed',
|
||||||
anim: 'react-alarm',
|
anim: 'react-alarm',
|
||||||
bubble: '/mascot/bubble-red-exclaim.png',
|
bubble: '❗ Uh oh!',
|
||||||
priority: 3,
|
priority: 3,
|
||||||
cooldownMs: 15_000,
|
cooldownMs: 15_000,
|
||||||
durationMs: 2500,
|
durationMs: 2500,
|
||||||
@@ -71,7 +71,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
happy: {
|
happy: {
|
||||||
id: 'happy',
|
id: 'happy',
|
||||||
anim: 'react-happy',
|
anim: 'react-happy',
|
||||||
bubble: '/mascot/bubble-love.png',
|
bubble: '🎉 Nice work!',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
cooldownMs: 20_000,
|
cooldownMs: 20_000,
|
||||||
durationMs: 2000
|
durationMs: 2000
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface RadialAction {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
icon?: Component
|
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. */
|
/** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */
|
||||||
visible?: (model: import('./state.svelte').MascotModel) => boolean
|
visible?: (model: import('./state.svelte').MascotModel) => boolean
|
||||||
/** Sub-actions — selecting this node swaps the ring to its children + a back button. */
|
/** 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.
|
* wmState; the FSM uses this as the ground for falling/landing.
|
||||||
*/
|
*/
|
||||||
groundY: number
|
groundY: number
|
||||||
/** Optional reaction-bubble sprite to draw above the chicken (eg 'bubble-exclaim'). */
|
/**
|
||||||
bubble: string | null
|
* Optional text/emoji shown above the mascot in a real HTML speech
|
||||||
/** performance.now() ms when the current bubble was set; cleared when null. */
|
* 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
|
bubbleUntil: number
|
||||||
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
|
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
|
||||||
blinkUntil: number
|
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