feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Implements plans/2026-07-20-desktop-mascot.md. New code under
web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/
stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus
CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack
+ reaction bubbles). MascotLayer is inserted into Desktop.svelte after
WindowLayer; <2-line integration.

Tamagotchi: egg -> chick -> adult lifecycle persisted to
localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first
naming (no timed incubation per implementation deviation). Chick/adult
wander, peck, sleep, blink autonomously via a weighted-random FSM; the
chicken walks above windows (ground line = highest window top edge
beneath its x, recomputed each tick from wmState; rides the ground when
the window beneath is dragged).

Interaction: draggable with flutter-fall physics on release mid-air;
plain click = pet (heart bubble + happy anim); right-click opens a
rounded-button radial menu (Interact/Care/Identity/Debug nested groups)
mirroring the desktop's own right-click menu styling; auto-flips above/
left near screen edges.

Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts
activityLog (knowledge-entry diff), events.ts liveEvents (critical/
signal -> alarmed, execution -> happy), with priority+cooldown gating.
Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased.

Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground
convention, dt clamped to 100ms; position via transform: translate3d
+ will-change: transform for compositor-friendly motion. Z-index
ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu
z-50 < RadialMenu/NameDialog z-[60].

Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to
Implemented with a deviations note covering hatch-on-naming, PNG-sheet
art, button-column radial menu, 60fps loop, egg-reaction suppression,
and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
This commit is contained in:
2026-07-20 14:27:48 +02:00
parent f1cdf4ea13
commit 7b1dfbc8aa
39 changed files with 2213 additions and 37 deletions

View File

@@ -1,6 +1,48 @@
# 2026-07-20 — Desktop mascot ("Cluck")
**Status:** Planned
**Status:** Implemented
> **Deviations from the original plan, applied 2026-07-20 during
> implementation:**
> - **Hatching is no longer timed.** The egg → chick transition fires
> once, on first naming (the name dialog opens on first mount of a
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
> longer plays a progressive `egg-crack` animation — it sits on
> `egg-idle` until named. `hatchProgress` is retained as a binary
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
> action still work.
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
> chicken comes from a CC0 16x16 sprite-sheet pack at
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
> frames from sheets instead of painting string grids. Chick and
> adult share sheets (distinguished only by render scale) until
> distinct adult art is added.
> - **The radial menu is a rounded-button column, not a circular
> ring.** The plan's polar-layout ring was found to hide labels; the
> menu now mirrors the desktop's own right-click menu styling
> (full-text buttons, nested via a "Back" breadcrumb).
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
> clamped to 100ms. Position is applied via `transform: translate3d`
> + `will-change: transform` (compositor layer) instead of CSS
> `left`/`top` to avoid per-frame layout reflow.
> - **Egg-stage reactions are suppressed.** The stimulus bus still
> subscribes to chat/activity/events while the egg is on screen, but
> MascotLayer's emit callback drops any reaction when
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
> alarm/eureka animations behind the naming dialog would be jarring.
> - **The mascot walks on top of windows.** The ground line is
> recomputed each tick from `wmState`: it's the top edge of the
> highest non-minimized window whose horizontal span covers the
> mascot's x, or the surface bottom when no window is beneath. When
> the mascot strolls over a window, the ground rises to that
> window's top edge; when it walks off the side, the ground drops
> and it flutter-falls to the next surface beneath (another window,
> or the desktop). This generalizes the original "walks along the
> desktop surface's bottom edge" decision to a multi-surface model.
## Why
@@ -181,9 +223,11 @@ export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?:
`weight` and are entered only via `forceBehavior()` — pointer code calls
it for `dragged`, gravity logic for `falling`, the stimulus bus for
`react`.
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
egg can be picked up and moved).
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
wiggles gently via a render-time transform); dragging is still
allowed (the egg can be picked up and moved). The egg → chick
transition fires once, on first naming — see the deviation note at
the top of this plan.
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
@@ -195,14 +239,13 @@ export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
hatchProgress: number // 0..1, egg stage only
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
happiness: number // 0..100, slow decay, boosted by pet/feed
xp: number // chick -> adult growth hook
hatchedAt: number | null
lastPos: { x: number } | null
lastSeen: number // for capped offline egg-incubation progress
lastSeen: number // for capping passive decay
}
export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly)
export const ADULT_XP = 200
export function grantXp(n: number): void
export function feed(): void
@@ -210,6 +253,7 @@ export function pet(): void
export function setName(name: string): void
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
export function advanceStageIfReady(): void
export function forceHatch(): void // called by the name-dialog submit handler on first naming
```
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
@@ -220,6 +264,11 @@ export function advanceStageIfReady(): void
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
rename. `lastPos.x` is written only on behavior transitions and
drag-end, never per frame.
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
name=null) opens the name dialog on mount; submitting it calls
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
Returning users with a named mascot skip the dialog. See the deviation
note at the top of this plan.
- Multi-tab races (two tabs both writing `oikos-mascot`) are
last-writer-wins — accepted for this scaffolding, not solved; a future
pass could listen to the `storage` event if it becomes a real problem.
@@ -293,6 +342,12 @@ Initial wiring:
unsubscribe into the returned teardown, so the mascot keeps the SSE
stream open (ref-counted alongside any page that also subscribes) only
while mounted.
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
callback drops any reaction when `model.stage === 'egg'` — the egg
isn't "alive" yet (no name, no hatched chick to react), so stimulus
events are silently ignored until the egg hatches. This keeps the egg
calm during the naming dialog rather than playing alarm animations
behind it.
- On first emission of `liveEvents`, just record the head event id — do
not replay history as reactions on mount.
- Dispatch: `emit(reaction)` checks the cooldown map and