feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user