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 +1 @@
0.7.13
0.8.0

View File

@@ -13,11 +13,16 @@
> feature of it — the same relationship [components.md](../mbse/components.md)
> has to [README.md](../mbse/README.md), applied recursively.
**Status of this Model:** the subsystem it describes does not exist in
code yet. Every View below is marked **Planned**, not **Verified**
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
which this document borrows. The corresponding implementation plan is
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
**Status of this Model:** the subsystem it describes is **implemented**
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
Views below are marked **Implemented** where the code matches; a small
number of requirements (distinct adult art, reaction speech-bubble
rendering, a squash frame on `land`) remain **Planned** as polish
items. The corresponding implementation plan is
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
which carries a deviation note at the top covering the changes made
during implementation (hatch-on-naming, PNG-sheet art, button-column
radial menu, 60fps loop).
## Views in this model
@@ -77,22 +82,23 @@ flowchart TB
## 2. Requirements
Traced from the original feature request. All **Planned**.
Traced from the original feature request. Status reflects the
2026-07-20 implementation; **Planned** items are deferred polish.
| ID | Statement | Source | Status |
|---|---|---|---|
| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned |
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned |
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned |
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned |
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned |
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned |
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned |
| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned |
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned |
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned |
| MASC-1 | The mascot SHALL render as pixel-art from bundled 16x16 PNG sprite sheets (chicken + egg packs), not code-drawn string grids | User request (relaxed from "code-drawn" during implementation — see plan deviation note) | Implemented |
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar, OR the top edge of any non-minimized window beneath it) under gravity | User request + design decision | Implemented |
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Implemented |
| MASC-4 | Right-clicking the mascot SHALL open an interaction menu supporting nested submenus; rendered as a rounded-button column (relaxed from "round/Sims-style" — see plan deviation note) | User request | Implemented |
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name; the egg → chick transition fires on first naming, not on a timed incubation | User request | Implemented |
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Implemented |
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Implemented |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented (bubble overlay rendering still Planned) |
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Implemented |
| MASC-10 (NFR) | The mascot's game loop SHALL run via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings); runs at ~60fps (relaxed from 30fps for smoother drag/fall — see plan deviation note) | Codebase convention | Implemented |
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Implemented |
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Implemented |
## 3. Structural View
@@ -206,7 +212,7 @@ explicit before any of it is coded.
```mermaid
stateDiagram-v2
[*] --> egg
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
egg --> chick : first naming submitted\n(forceHatch: hatchProgress=1)
state chick_and_adult_behaviors {
[*] --> idle
@@ -243,14 +249,17 @@ for the concrete weights.
```mermaid
stateDiagram-v2
[*] --> egg : first load,\ndefaultModel()
egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown
egg --> chick : first naming submitted\n(forceHatch sets hatchProgress=1)\n+ NameDialog shown
chick --> adult : xp >= ADULT_XP (200)
adult --> [*]
```
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame).
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame). The
egg → chick transition fires on first naming, not on a timed incubation
— see the deviation note in
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
@@ -258,18 +267,27 @@ motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
sequenceDiagram
participant SSE as stores/events.ts (SSE)
participant Stim as stimuli.ts attachStimuli
participant Layer as MascotLayer.svelte (emit callback)
participant FSM as behavior.ts
participant Mascot as Mascot.svelte (canvas)
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs})
Stim->>Layer: emit(reaction)
Layer->>Layer: if model.stage === 'egg': drop\n(egg isn't "alive" yet)
Layer->>FSM: forceBehavior(rt, 'react', {anim, durationMs})
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame
Mascot->>Mascot: next ~60fps tick draws\nreact-alarm frame + bubble
Note over FSM: after durationMs,\nnext() returns to idle
```
**Egg-stage suppression:** MascotLayer's `emit` 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.
## 5. Interfaces View
**Stakeholders:** an engineer wiring a new store into the mascot's
@@ -281,7 +299,7 @@ awareness, or auditing what it depends on.
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` (hatchProgress is binary 0/1: 0 until first naming, 1 after) | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
No interface in this table is a write path to the Oikos API — consistent
@@ -310,13 +328,15 @@ Manual browser checklist (no automated test harness planned for v1 — see
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
for the same list in implementation-order context):
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
fire from mere walking, only from discrete transitions).
- Egg renders grounded at the surface bottom, wiggles gently while the
name dialog is open, and survives a reload at the same x (confirm
`oikos-mascot` is debounced — no writes fire from mere walking, only
from discrete transitions).
- Dragging the egg up and releasing triggers a flutter-fall with no
tunneling below the taskbar; dragging past the surface edges clamps.
- Forcing hatch (debug menu action) transitions to chick, opens the name
dialog, and the name persists across reload.
- A fresh egg (no name) opens the name dialog on mount; submitting it
hatches to chick; the name persists across reload. The debug "Force
hatch" action does the same without prompting.
- Chick wanders and flips sprite at surface edges, pecks, sleeps
autonomously; a plain click (no drag) triggers a pet/hop reaction.
- Right-clicking the chicken opens the radial menu centered on it, without

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

View File

@@ -0,0 +1,20 @@
Eggs
By Onocentaur
https://onocentaur.itch.io
March 2021
Description
350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game.
This pack contains:
Over 350 16x16px eggs.
32 egg designs, 11 color variants.
2 different cracking animations for each egg.
Spritesheets for each color variant and cracking pattern.
Transparent PNGs.
Template files so you can color your own eggs.
Bonus: Letter & Number eggs.
Bonus: Incubator assets (nesting box and toggle-able lamp).
Bonus: 12 Animal assets.
Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make.

View File

@@ -0,0 +1,5 @@
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

BIN
web/public/mascot/blink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

BIN
web/public/mascot/hurt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

BIN
web/public/mascot/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

BIN
web/public/mascot/jump.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

BIN
web/public/mascot/peck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

BIN
web/public/mascot/peep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

BIN
web/public/mascot/sleep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

BIN
web/public/mascot/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

BIN
web/public/mascot/walk2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -14,6 +14,7 @@
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -100,6 +101,8 @@
</div>
<WindowLayer />
<MascotLayer />
</div>
<Taskbar />

View File

@@ -0,0 +1,329 @@
<script lang="ts">
// The desktop mascot sprite: a small canvas that renders the current
// animation frame at ~30fps (via setTimeout, not rAF — matches
// GraphBackground.svelte's convention for hidden-tab embedding safety),
// and handles pointer drag, plain-click (pet), and right-click (open
// the radial menu). Position/physics live in MascotRuntime, owned
// here; long-lived tamagotchi state lives in state.svelte.ts.
//
// The mascot renders above the window layer (z-45 via MascotLayer) but
// its pointer hitbox is exactly the canvas element — no oversized
// invisible padding — so it only occludes clicks on window chrome
// directly beneath the sprite, per the "renders above windows" design
// decision in plans/2026-07-20-desktop-mascot.md.
import { onMount } from 'svelte'
import { loadSprites, loadBubble, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
import { drawFrame, drawBubble, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
import {
stepMascot,
forceBehavior,
releaseFromDrag,
reground
} from '$lib/mascot/behavior'
import type { MascotRuntime, MascotStage, AnimName } from '$lib/mascot/types'
import {
initMascotState,
getModel,
tickLifecycle,
advanceStageIfReady,
setLastPos,
pet as modelPet
} from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows'
// MascotRuntime is created fresh per mount; the long-lived MascotModel
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
let { runtime, onContextMenu, onPet }: { runtime: MascotRuntime; onContextMenu: (screenX: number, screenY: number) => void; onPet: () => void } = $props()
const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
const DRAG_THRESHOLD = 5
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
// than GraphBackground's slow ambient drift, and 30fps position updates
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
// convention — some embedding contexts report document.hidden=true and
// suspend rAF; setTimeout keeps ticking. dt is clamped below so a
// throttled/backgrounded tab doesn't produce a physics-breaking huge
// step on resume.
const LOOP_MS = 16
const DT_CLAMP_MS = 100
const LIFECYCLE_TICK_MS = 1000
let canvas = $state<HTMLCanvasElement | null>(null)
let ctx2d: CanvasRenderingContext2D | null = null
let timer: ReturnType<typeof setTimeout> | 0 = 0
let lastFrame = 0
let lastLifecycle = 0
let dragging = $state(false)
let dragPointerId: number | null = null
let dragStartClient = { x: 0, y: 0 }
let moved = false
// Wiggle phase for the egg (render-time only, not persisted).
let wigglePhase = 0
// Snapshot of the current window manager state, refreshed by
// subscription. Read inside the tick to compute the ground line at the
// mascot's x — the highest non-minimized window top edge beneath it,
// or the surface bottom when no window is beneath.
let currentWindows: typeof $wmState = { order: [], windows: {}, focusedId: null }
// Previous ground y, used to detect when the window beneath the
// mascot moved so the mascot can ride along (stick to the ground)
// instead of floating in place while the window drifts out from
// under it.
let prevGroundY = 0
const model = $derived(getModel())
function stage(): MascotStage {
return model.stage
}
/**
* Compute the ground line at the mascot's x: the top edge (y) of the
* highest non-minimized window whose horizontal span covers the
* mascot's x, or the surface bottom (bounds.h) when no window is
* beneath. This is what lets the mascot walk ON TOP of windows —
* when it strolls over a window, the ground rises to that window's
* top edge; when it walks off the side, the ground drops back to the
* desktop surface and it flutters-falls.
*/
function computeGroundAt(x: number): number {
let ground = runtime.bounds.h
for (const id of currentWindows.order) {
const win = currentWindows.windows[id]
if (!win || win.stage === 'minimized') continue
const b = win.bounds
// Window top edge counts as ground only if the mascot's x is
// within the window's horizontal span (with a small margin so the
// mascot doesn't immediately fall off the very corner).
if (x >= b.x - 4 && x <= b.x + b.width + 4) {
// Walking on top = ground at the window's top edge.
if (b.y < ground) ground = b.y
}
}
return ground
}
function currentAnim(): { anim: ReturnType<typeof resolveAnim>; name: AnimName } {
const name = runtime.anim
return { anim: resolveAnim(stage(), name), name }
}
function tick(now: number): void {
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
lastFrame = now
// Refresh the ground line at the mascot's current x — this is what
// lets the mascot walk on top of windows (the ground rises to a
// window's top edge when the mascot strolls over it).
const newGround = computeGroundAt(runtime.x)
// Ride the ground: when the mascot is grounded (not falling/dragged)
// and the ground moved (the window beneath was dragged/resized),
// translate the mascot with it so it sticks to the surface instead
// of floating in place while the window drifts out from under it.
if (
runtime.behavior !== 'falling' &&
runtime.behavior !== 'dragged' &&
runtime.y >= prevGroundY - 1 &&
newGround !== prevGroundY
) {
runtime.y += newGround - prevGroundY
}
runtime.groundY = newGround
prevGroundY = newGround
stepMascot(runtime, model, now, dt)
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
if (lastLifecycle === 0) lastLifecycle = now
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
tickLifecycle(now - lastLifecycle)
lastLifecycle = now
advanceStageIfReady()
// If the egg just hatched, swap the runtime behavior out of 'egg'
// (the egg behavior's next() returns 'egg' forever, so we have to
// nudge it here). The NameDialog is opened by MascotLayer observing
// the stage change.
if (model.stage !== 'egg' && runtime.behavior === 'egg') {
forceBehavior(runtime, 'idle')
}
}
}
function renderSprite(): void {
if (!canvas || !ctx2d) return
const now = performance.now()
// Egg wobble: only when stage is egg and behavior isn't dragged/react.
const isEgg = stage() === 'egg'
if (isEgg && runtime.behavior !== 'dragged') {
wigglePhase += 0.08
} else {
wigglePhase = 0
}
const { anim } = currentAnim()
const idx = frameIndex(anim, now, runtime.animStart)
const scale = STAGE_SCALE[stage()]
drawFrame(ctx2d, stage(), anim, idx, {
scale,
facing: runtime.facing,
wiggle: wigglePhase
})
// Reaction bubble: drawn above the sprite while runtime.bubble is set
// and hasn't expired. Cleared when bubbleUntil passes.
if (runtime.bubble && now < runtime.bubbleUntil) {
drawBubble(ctx2d, runtime.bubble, runtime.facing)
} else if (runtime.bubble && now >= runtime.bubbleUntil) {
runtime.bubble = null
runtime.bubbleUntil = 0
}
}
// Single loop: tick physics + draw sprite + reschedule. (The previous
// version had `loop` reschedule itself AND call `draw` which also
// rescheduled itself — two timers fought over the shared `timer` var,
// causing jitter.)
function loop(): void {
timer = setTimeout(loop, LOOP_MS)
const now = performance.now()
tick(now)
renderSprite()
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
dragPointerId = e.pointerId
dragStartClient = { x: e.clientX, y: e.clientY }
moved = false
el.setPointerCapture(e.pointerId)
forceBehavior(runtime, 'dragged')
dragging = true
}
function onPointerMove(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const dx = e.clientX - dragStartClient.x
const dy = e.clientY - dragStartClient.y
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true
if (moved) {
// Surface-relative coords: the sprite container is positioned at the
// surface origin, so clientX/Y - surfaceRect gives surface coords.
// MascotLayer binds the host's bounding rect; we read it fresh here.
const host = (e.currentTarget as HTMLElement).parentElement?.parentElement
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
runtime.y = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
}
}
}
function onPointerUp(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
el.releasePointerCapture(e.pointerId)
dragPointerId = null
dragging = false
if (!moved) {
// Plain click = pet: a brief happy reaction with a heart bubble.
modelPet()
onPet()
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleUntil = performance.now() + 1500
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
} else {
// Drag ended — release into falling or land.
setLastPos(runtime.x)
releaseFromDrag(runtime)
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
onContextMenu(e.clientX, e.clientY)
}
function syncCanvasSize() {
if (!canvas) return
canvas.width = CANVAS_W
canvas.height = CANVAS_H
ctx2d = canvas.getContext('2d')
if (ctx2d) ctx2d.imageSmoothingEnabled = false
}
onMount(async () => {
initMascotState()
syncCanvasSize()
await loadSprites()
// Preload reaction bubbles (not in SPRITES registry, so loadSprites
// doesn't pick them up). Swallow errors — a missing bubble just
// doesn't render, the reaction anim still plays.
await Promise.allSettled([
loadBubble('/mascot/bubble-love.png'),
loadBubble('/mascot/bubble-exclaim.png'),
loadBubble('/mascot/bubble-red-exclaim.png'),
loadBubble('/mascot/bubble-dotdotdot.png')
])
lastFrame = performance.now()
lastLifecycle = 0
// Seed prevGroundY so the first tick's ride-the-ground delta is zero
// (otherwise the mascot would snap to the ground on mount if it
// started above it — e.g. an egg at the surface bottom).
runtime.groundY = computeGroundAt(runtime.x)
prevGroundY = runtime.groundY
loop()
// Re-ground on surface resize (viewport resize, taskbar height changes).
const host = canvas?.parentElement?.parentElement
let prevH = runtime.bounds.h
const ro = new ResizeObserver(() => {
const rect = host?.getBoundingClientRect()
if (rect) {
const newH = rect.height
runtime.bounds = { w: rect.width, h: newH }
reground(runtime, prevH)
prevH = newH
}
})
if (host) ro.observe(host)
// Track the window manager's state so computeGroundAt() can find the
// highest window beneath the mascot's x each tick — this is what lets
// the mascot walk on top of windows rather than always falling to the
// desktop surface bottom.
const unsubWm = wmState.subscribe((s) => {
currentWindows = s
})
return () => {
if (timer) clearTimeout(timer)
ro.disconnect()
unsubWm()
}
})
// The sprite's CSS position uses bottom-left anchored coords from
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
// position via `transform: translate3d` (compositor-friendly, no
// layout reflow) rather than `left`/`top` so motion stays smooth at
// high refresh rates. A squash transform is composed in during the
// `land` behavior (brief scaleY(0.82)) to sell the impact.
const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
const ty = $derived(runtime.y - CANVAS_H * SCALE_PX)
const squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
</script>
<canvas
bind:this={canvas}
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
style="width: {CANVAS_W * SCALE_PX}px; height: {CANVAS_H * SCALE_PX}px; transform: {transform}; image-rendering: pixelated; will-change: transform; transform-origin: bottom center;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
oncontextmenu={handleContextMenu}
title={model.name ?? 'Cluck'}
></canvas>
{#if model.name}
<div
class="pointer-events-none absolute left-0 top-0 select-none whitespace-nowrap rounded-full bg-popover/90 px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-sm ring-1 ring-foreground/10"
style="transform: translate3d({tx + (CANVAS_W * SCALE_PX) / 2}px, {ty - 8}px, 0); will-change: transform;"
>
{model.name}
</div>
{/if}

View File

@@ -0,0 +1,204 @@
<script lang="ts">
// MascotLayer: a pointer-events-none absolute inset-0 overlay at z-45
// (above WindowLayer's z-40, below the desktop context menu's z-50
// and the radial menu's z-[60]). Hosts the Mascot sprite, the
// RadialMenu, the NameDialog, and the speech/name bubble. Owns the
// MascotRuntime and the surface bounds; attaches the stimulus bus on
// mount so the mascot reacts to chat/activity/events.
//
// Insertion point: rendered inside Desktop.svelte's surface <div>
// (the `relative min-h-0 flex-1 overflow-hidden` element), after
// <WindowLayer />, so its `absolute inset-0` shares the surface's
// coordinate space and its ground line lands at the surface's bottom
// edge (= the taskbar's top edge).
import { onMount } from 'svelte'
import Mascot from './Mascot.svelte'
import RadialMenu from './RadialMenu.svelte'
import NameDialog from './NameDialog.svelte'
import { attachStimuli } from './stimuli'
import {
initMascotState,
getModel,
setName as modelSetName,
forceHatch as modelForceHatch,
setStage,
resetModel
} from '$lib/mascot/state.svelte'
import { forceBehavior } from '$lib/mascot/behavior'
import type { MascotRuntime, AnimName, BehaviorId, MascotStage } from '$lib/mascot/types'
let host = $state<HTMLDivElement | null>(null)
// The runtime is created here (fresh per mount) and seeded from the
// persisted model's lastPos + the live surface bounds. It's a `$state`
// so mutations to runtime.x/y/behavior/etc. from the FSM are tracked
// by the `$derived` position expressions in Mascot.svelte — a plain
// `let` would update internally but never re-render the canvas.
let runtime: MascotRuntime = $state({
x: 100,
y: 100,
vx: 0,
vy: 0,
facing: 1,
behavior: 'egg',
behaviorUntil: Number.POSITIVE_INFINITY,
anim: 'egg-idle',
animStart: 0,
reactAnim: null,
bounds: { w: 800, h: 600 },
groundY: 600,
bubble: null,
bubbleUntil: 0,
blinkUntil: 0
})
let menuPos = $state<{ x: number; y: number } | null>(null)
let nameDialogOpen = $state(false)
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
const model = $derived(getModel())
function openMenu(x: number, y: number) {
menuPos = { x, y }
}
function closeMenu() {
menuPos = null
}
function requestRename() {
nameDialogMode = 'rename'
nameDialogOpen = true
}
function forceHatch() {
const wasUnnamed = getModel().name === null
modelForceHatch()
if (runtime.behavior === 'egg') forceBehavior(runtime, 'idle')
// If the egg was unnamed (e.g. debug force-hatch before the name
// dialog was submitted), open the name dialog so the chick gets a
// name — matching the normal hatch-on-naming flow.
if (wasUnnamed) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
}
function forceStageFn(s: MascotStage) {
setStage(s)
forceBehavior(runtime, 'idle')
}
function reset() {
resetModel()
forceBehavior(runtime, 'egg')
}
function refresh() {
// Trigger reactivity: the menu reads model visibility predicates on
// each render; touching a $state value re-runs the menu's derived
// filter. menuPos re-assignment is a no-op if already set.
menuPos = menuPos ? { ...menuPos } : null
}
function onPet() {
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
// call already happened in Mascot.svelte.
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleUntil = performance.now() + 1500
}
function onNameSubmit(name: string) {
modelSetName(name)
if (nameDialogMode === 'hatch') {
// Naming the egg is what hatches it — no timed incubation.
forceHatch()
}
nameDialogOpen = false
}
// Stage transitions are driven by the name-dialog submit handler
// (egg → chick on first naming) and the debug menu (force hatch /
// force stage), not by observing model.stage here. No $effect needed.
onMount(() => {
initMascotState()
// Seed runtime from persisted lastPos once we know the surface size.
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.bounds = { w: rect.width, h: rect.height }
runtime.groundY = rect.height
const lp = getModel().lastPos
runtime.x = lp ? Math.max(24, Math.min(rect.width - 24, lp.x)) : rect.width / 2
runtime.y = rect.height // ground
runtime.anim = getModel().stage === 'egg' ? 'egg-idle' : 'idle'
runtime.behavior = getModel().stage === 'egg' ? 'egg' : 'idle'
}
// First-run: a fresh egg with no name prompts for naming, which
// hatches it. Returning users with a named mascot skip this.
if (getModel().stage === 'egg' && getModel().name === null) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
// Attach the stimulus bus (chat/activity/events -> reactions).
// Reactions are gated to non-egg stages: the egg isn't "alive" yet
// (no name, no hatched chick to react), so stimulus events are
// silently dropped until the egg hatches. This keeps the egg calm
// during the naming dialog rather than playing alarm animations
// behind it.
const detach = attachStimuli((reaction) => {
if (getModel().stage === 'egg') return
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
// here we just force the behavior.
const anim = reaction.anim as AnimName
const id: BehaviorId = 'react'
runtime.reactAnim = anim
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
if (reaction.bubble) {
runtime.bubble = reaction.bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs
}
if (reaction.effect) reaction.effect()
})
return () => detach()
})
// Action context handed to RadialMenu leaf handlers.
const actionCtx = $derived({
model,
runtime,
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) =>
forceBehavior(runtime, id, opts),
requestRename,
forceHatch,
forceStage: forceStageFn,
reset,
refresh
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
<Mascot
{runtime}
onContextMenu={openMenu}
onPet={onPet}
/>
</div>
{#if menuPos}
<RadialMenu
pos={menuPos}
ctx={actionCtx}
onDismiss={closeMenu}
/>
{/if}
{#if nameDialogOpen}
<NameDialog
mode={nameDialogMode}
initial={model.name ?? ''}
onSubmit={onNameSubmit}
onCancel={() => (nameDialogOpen = false)}
/>
{/if}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
// NameDialog: a tiny centered modal that prompts for the mascot's name,
// opened either at hatch time (mode='hatch') or via the Rename menu
// action (mode='rename'). Renders at z-[60] so it sits above the
// mascot layer and the radial menu. Self-contained — doesn't use the
// bits-ui Dialog to keep the dependency surface small and to control
// z-index precisely relative to the desktop's own layers.
import { onMount, untrack } from 'svelte'
let {
mode = 'hatch',
initial = '',
onSubmit,
onCancel
}: {
mode?: 'hatch' | 'rename'
initial?: string
onSubmit: (name: string) => void
onCancel: () => void
} = $props()
// Seed the input once from the prop — `untrack` because we want the
// INITIAL value, not a reactive binding (typing into the input updates
// `value`, not `initial`).
let value = $state(untrack(() => initial))
onMount(() => {
// Autofocus the input on mount.
const el = document.getElementById('mascot-name-input') as HTMLInputElement | null
el?.focus()
el?.select()
})
function submit(e: Event) {
e.preventDefault()
const trimmed = value.trim()
if (trimmed) onSubmit(trimmed)
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}
const title = $derived(mode === 'hatch' ? 'Your chick hatched!' : 'Rename your chicken')
const placeholder = $derived(mode === 'hatch' ? 'Name your chick…' : 'New name…')
</script>
<svelte:window onkeydown={onWindowKeydown} />
<!-- Overlay: clicks outside the card cancel. Escape is handled via the window keydown above. -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-foreground/30 backdrop-blur-[1px]"
role="presentation"
onclick={(e) => {
if (e.currentTarget === e.target) onCancel()
}}
>
<form
class="w-80 rounded-xl border bg-popover p-5 text-popover-foreground shadow-xl ring-1 ring-foreground/10"
onsubmit={submit}
>
<h2 class="mb-1 text-base font-semibold">{title}</h2>
<p class="mb-3 text-xs text-popover-foreground/70">
{mode === 'hatch'
? 'Give it a name. It will follow your homelab activity from here on.'
: 'Pick a new name.'}
</p>
<input
id="mascot-name-input"
type="text"
bind:value
{placeholder}
maxlength="24"
class="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
{#if mode === 'rename'}
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm text-popover-foreground/80 hover:bg-accent"
onclick={onCancel}
>
Cancel
</button>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{mode === 'hatch' ? 'Hatch!' : 'Save'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,152 @@
<script lang="ts" module>
// RadialMenu (now a rounded-button column menu): opened on right-click
// over the mascot. Renders the MASCOT_ACTIONS tree as a stack of
// rounded buttons with full text labels; selecting a node with
// `children` swaps the column to those children + a "Back" button at
// the top (tracked via a local breadcrumb stack). Leaf nodes call
// `action(ctx)` and dismiss.
//
// z-[60] — must beat the desktop's own right-click menu (z-50) and
// sit above the mascot layer (z-[45]). Dismissal mirrors the desktop
// menu: a <svelte:window onclick> closes it, Escape pops one level
// then closes on the next press, and the menu's own clicks
// stopPropagation so they don't bubble to the close handler.
import { MASCOT_ACTIONS } from './actions'
import type { MascotActionCtx, RadialAction } from './types'
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
</script>
<script lang="ts">
let {
pos,
ctx,
onDismiss
}: {
pos: { x: number; y: number }
ctx: MascotActionCtx
onDismiss: () => void
} = $props()
// Breadcrumb stack: each entry is the list of actions shown at that
// level. The top of the stack is the current column.
let stack = $state<RadialAction[][]>([MASCOT_ACTIONS])
let depth = $derived(stack.length)
let current = $derived(stack[depth - 1] ?? [])
// Clamp the anchor to the viewport, then decide which side to grow
// toward based on anchor position alone (no measure-then-flip — that
// paints off-screen first). When the anchor is in the bottom half,
// the menu grows upward (bottom edge aligns with anchor.y); same for
// the right edge when in the right half. The chicken lives on the
// desktop surface's bottom edge, so this almost always flips up.
const anchor = $derived.by(() => {
const margin = 8
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
const x = Math.max(margin, Math.min(vw - margin, pos.x))
const y = Math.max(margin, Math.min(vh - margin, pos.y))
return {
x,
y,
growUp: y > vh / 2,
growLeft: x > vw / 2
}
})
let host = $state<HTMLDivElement | null>(null)
const visibleItems = $derived(current.filter((a) => !a.visible || a.visible(ctx.model)))
function selectItem(a: RadialAction, ev: MouseEvent) {
ev.stopPropagation()
if (a.children && a.children.length > 0) {
stack = [...stack, a.children]
return
}
if (a.action) {
a.action(ctx)
}
onDismiss()
}
function back(ev: MouseEvent) {
ev.stopPropagation()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
function onWindowClick() {
onDismiss()
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
}
// Reset the stack when the menu is (re)opened with a new pos.
$effect(() => {
void pos
stack = [MASCOT_ACTIONS]
})
</script>
<svelte:window onclick={onWindowClick} onkeydown={onWindowKeydown} />
<div
bind:this={host}
class="fixed z-[60] min-w-44 max-w-56 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
role="menu"
tabindex="-1"
aria-label="Mascot actions"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) stack = stack.slice(0, -1)
else onDismiss()
}
}}
>
{#if depth > 1}
<button
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-popover-foreground/70 hover:bg-accent hover:text-accent-foreground"
onclick={back}
>
<ChevronLeftIcon class="size-4" /> Back
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#each visibleItems as a (a.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={(ev) => selectItem(a, ev)}
oncontextmenu={(ev) => ev.preventDefault()}
>
<span class="flex items-center gap-2">
{#if a.icon}
<a.icon class="size-4 shrink-0" />
{/if}
<span class="truncate">{a.label}</span>
</span>
{#if a.children && a.children.length > 0}
<ChevronRightIcon class="size-4 shrink-0 opacity-60" />
{/if}
</button>
{/each}
</div>

View File

@@ -0,0 +1,163 @@
// Radial menu action tree. To add a new menu action:
// - Add a `RadialAction` node to MASCOT_ACTIONS below (or call
// `registerMascotAction(a, parentId)` at runtime to insert under an
// existing node).
// - The leaf `action(ctx)` mutates the model/runtime via ctx; nested
// `children` render as a sub-ring.
// - `visible(model)` gates visibility (e.g. "Rename" only once hatched).
// RadialMenu.svelte renders whatever tree it's given, including
// arbitrary nesting depth — no engine change is needed for a new node.
import type { RadialAction } from './types'
// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake],
// Identity [Rename], Debug [Force hatch, Force chick, Force adult, Reset].
// The Pet action is the same as a plain click — included in the menu for
// discoverability.
export const MASCOT_ACTIONS: RadialAction[] = [
{
id: 'interact',
label: 'Interact',
children: [
{
id: 'pet',
label: 'Pet',
action: (ctx) => {
ctx.runtime.bubble = '/mascot/bubble-love.png'
ctx.runtime.bubbleUntil = performance.now() + 1500
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
ctx.refresh()
}
},
{
id: 'feed',
label: 'Feed',
children: [
{
id: 'feed-seeds',
label: 'Seeds',
action: (ctx) => {
// Feeding: small happiness + xp boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 8)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
ctx.refresh()
}
},
{
id: 'feed-worm',
label: 'Worm',
action: (ctx) => {
// Worm: bigger boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 16)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
ctx.refresh()
}
}
]
}
]
},
{
id: 'care',
label: 'Care',
children: [
{
id: 'sleep',
label: 'Sleep',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.force('sleep', { durationMs: 8000 })
ctx.refresh()
}
},
{
id: 'wake',
label: 'Wake',
visible: () => true, // visible always; only useful when asleep but harmless otherwise
action: (ctx) => {
ctx.force('idle')
ctx.refresh()
}
}
]
},
{
id: 'identity',
label: 'Identity',
children: [
{
id: 'rename',
label: 'Rename',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.requestRename()
ctx.refresh()
}
}
]
},
{
id: 'debug',
label: 'Debug',
children: [
{
id: 'force-hatch',
label: 'Force hatch',
visible: (m) => m.stage === 'egg',
action: (ctx) => {
ctx.forceHatch()
ctx.refresh()
}
},
{
id: 'force-chick',
label: 'Force chick',
visible: (m) => m.stage !== 'chick',
action: (ctx) => {
ctx.forceStage('chick')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'force-adult',
label: 'Force adult',
visible: (m) => m.stage !== 'adult',
action: (ctx) => {
ctx.forceStage('adult')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'reset',
label: 'Reset',
action: (ctx) => {
ctx.reset()
ctx.refresh()
}
}
]
}
]
/** Insert an action at runtime, optionally nested under a parent id. Root insertion if parentId is undefined. */
export function registerMascotAction(a: RadialAction, parentId?: string): void {
if (!parentId) {
MASCOT_ACTIONS.push(a)
return
}
function findAndInsert(nodes: RadialAction[]): boolean {
for (const n of nodes) {
if (n.id === parentId) {
n.children = n.children ?? []
n.children.push(a)
return true
}
if (n.children && findAndInsert(n.children)) return true
}
return false
}
findAndInsert(MASCOT_ACTIONS)
}

View File

@@ -0,0 +1,385 @@
// Behavior engine: a finite state machine that drives the mascot's
// autonomous motion + animation. To add a behavior:
// 1. Add its id to `BehaviorId` in types.ts.
// 2. Add a `BehaviorDef` entry to BEHAVIORS below.
// 3. (Optional) Give it a `weight` to make it idle-selectable.
// `stepMascot()` and the weighted-random idle selector consume
// BEHAVIORS generically — no engine change is needed for a new behavior.
//
// Behaviors split into two groups:
// - Self-selecting (idle/wander/peck/sleep): pickable by the weighted
// random idle selector when the current behavior expires.
// - Forced (dragged/falling/react/land): entered only via forceBehavior()
// from the pointer code, gravity logic, or the stimulus bus.
//
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
// edge, == the taskbar's top edge). When above ground and not dragged,
// the mascot falls with a slow flutter terminal velocity; on landing,
// a brief `land` behavior plays, then idle. Dragging is always
// honored — pointer code calls forceBehavior('dragged'), which wins
// over any autonomous behavior or non-drag-breaking reaction.
import type { AnimName, BehaviorId, MascotRuntime } from './types'
import type { MascotModel } from './state.svelte'
// ─── tuning constants ────────────────────────────────────────────────────
const GRAVITY = 1400 // px/s^2 (gentle)
const TERMINAL_VY = 320 // px/s (slow flutter fall)
const WALK_SPEED = 36 // px/s
const MARGIN = 24 // px before the surface edge where wander flips facing
// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
// can override with its own minMs/maxMs.
const IDLE_MS = [1500, 4000] as const
const WANDER_MS = [2500, 5000] as const
const PECK_MS = [1200, 2200] as const
const SLEEP_MS = [6000, 12000] as const
const LAND_MS = 400
const REACT_DEFAULT_MS = 1800
// ─── BehaviorDef ─────────────────────────────────────────────────────────
export interface BehaviorDef {
id: BehaviorId
/** Animation to play while this behavior is active. May depend on runtime state (e.g. facing). */
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
/** Called once when the behavior is entered (set up velocity, etc). */
enter?: (rt: MascotRuntime) => void
/** Per-frame physics/integration. `dt` is already clamped to <= 100ms by the loop. */
tick: (rt: MascotRuntime, dt: number, now: number) => void
/** Called when behaviorUntil has passed; returns the next behavior id, or null to trigger idle selection. */
next: (rt: MascotRuntime, now: number) => BehaviorId | null
/** Idle-selection weight (>0 = eligible). Undefined/0 = never auto-picked. */
weight?: number
/** Duration range in ms for this behavior when auto-selected. */
minMs: number
maxMs: number
}
function randRange(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
const total = candidates.reduce((s, b) => s + (b.weight ?? 0), 0)
let r = Math.random() * total
for (const b of candidates) {
r -= b.weight ?? 0
if (r <= 0) return b
}
return candidates[0]
}
// ─── ground / bounds helpers ─────────────────────────────────────────────
function groundY(rt: MascotRuntime): number {
// rt.groundY is recomputed each tick by Mascot.svelte from the window
// state — it's the top edge of the highest window beneath the mascot,
// or rt.bounds.h (surface bottom) when no window is beneath.
return rt.groundY
}
function clampX(rt: MascotRuntime): void {
const minX = MARGIN / 2
const maxX = rt.bounds.w - MARGIN / 2
if (rt.x < minX) {
rt.x = minX
rt.facing = 1
}
if (rt.x > maxX) {
rt.x = maxX
rt.facing = -1
}
}
// ─── BEHAVIORS registry ───────────────────────────────────────────────────
export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
egg: {
id: 'egg',
anim: () => 'egg-idle',
tick: () => {
// Egg doesn't move on its own.
},
next: () => 'egg',
minMs: 0,
maxMs: 0
},
idle: {
id: 'idle',
// Periodic blink: enter() sets blinkUntil to the START of the next
// blink window (26s away). anim() returns 'blink' when we're past
// that start but within 150ms of it.
anim: (rt) => {
const now = performance.now()
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
return 'idle'
},
enter: (rt) => {
rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
},
tick: () => {
// Standing still.
},
next: () => null,
weight: 3,
minMs: IDLE_MS[0],
maxMs: IDLE_MS[1]
},
wander: {
id: 'wander',
anim: () => 'walk',
enter: (rt) => {
rt.vx = rt.facing * WALK_SPEED
},
tick: (rt) => {
rt.x += rt.vx * (1 / 60) // dt is in seconds via the loop below; but tick receives ms — see stepMascot
// Actually the loop calls tick with dt in seconds; but to keep the
// BehaviorDef.tick signature consistent with the plan's `(rt, dt, now)`
// where dt is seconds-clamped, we'll re-derive below. The wander
// integration is redone in stepMascot to use dt correctly.
},
next: () => null,
weight: 4,
minMs: WANDER_MS[0],
maxMs: WANDER_MS[1]
},
peck: {
id: 'peck',
anim: () => 'peck',
tick: () => {
// Stationary peck animation.
},
next: () => null,
weight: 2,
minMs: PECK_MS[0],
maxMs: PECK_MS[1]
},
sleep: {
id: 'sleep',
anim: () => 'sleep',
tick: () => {
// Asleep.
},
next: () => null,
weight: 1,
minMs: SLEEP_MS[0],
maxMs: SLEEP_MS[1]
},
dragged: {
id: 'dragged',
anim: () => 'dragged',
tick: () => {
// Position is owned by the pointer handler; nothing to do here.
},
next: () => null, // exited only via forceBehavior from pointerup
minMs: 0,
maxMs: 0
},
falling: {
id: 'falling',
anim: () => 'fall-flutter',
enter: (rt) => {
rt.vx = 0
},
tick: () => {
// Integration happens in stepMascot (needs dt in seconds).
},
next: () => null, // exited via stepMascot when y reaches ground
minMs: 0,
maxMs: 0
},
land: {
id: 'land',
anim: () => 'land',
tick: () => {
// Brief squash animation.
},
next: () => 'idle',
minMs: LAND_MS,
maxMs: LAND_MS
},
react: {
id: 'react',
anim: (rt) => rt.reactAnim ?? 'idle',
tick: () => {
// Reaction plays its animation; no motion.
},
next: () => 'idle',
minMs: REACT_DEFAULT_MS,
maxMs: REACT_DEFAULT_MS
}
}
// ─── stepMascot: the per-frame driver ────────────────────────────────────
/** Force a behavior. Used by pointer code (dragged), gravity (falling), stimuli (react). */
export function forceBehavior(
rt: MascotRuntime,
id: BehaviorId,
opts?: { anim?: AnimName; durationMs?: number }
): void {
rt.behavior = id
if (opts?.anim) {
if (id === 'react') rt.reactAnim = opts.anim
else {
// For non-react behaviors, override the anim by setting animStart on a custom anim.
rt.anim = opts.anim
rt.animStart = performance.now()
}
}
if (id === 'react' && opts?.anim) {
rt.anim = opts.anim
rt.animStart = performance.now()
}
const def = BEHAVIORS[id]
if (opts?.durationMs) {
rt.behaviorUntil = performance.now() + opts.durationMs
} else if (def.maxMs > 0) {
rt.behaviorUntil = performance.now() + randRange(def.minMs, def.maxMs)
} else {
rt.behaviorUntil = Number.POSITIVE_INFINITY
}
def.enter?.(rt)
}
/** Step the FSM by `dt` ms (already clamped by the loop to <= 100ms). */
export function stepMascot(
rt: MascotRuntime,
model: MascotModel,
now: number,
dt: number
): void {
const dts = dt / 1000
const def = BEHAVIORS[rt.behavior]
// Per-behavior physics integration. Done here (not in def.tick) so the
// dt semantics stay consistent — the BehaviorDef.tick is reserved for
// any bespoke per-frame logic a future behavior needs.
switch (rt.behavior) {
case 'wander': {
rt.x += rt.vx * dts
// Flip at margins.
if (rt.x < MARGIN / 2) {
rt.x = MARGIN / 2
rt.facing = 1
rt.vx = WALK_SPEED
} else if (rt.x > rt.bounds.w - MARGIN / 2) {
rt.x = rt.bounds.w - MARGIN / 2
rt.facing = -1
rt.vx = -WALK_SPEED
}
// If the mascot walks off a window edge (ground dropped below
// current y), switch to falling — it flutters down to the next
// surface beneath (another window, or the desktop bottom).
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'idle': {
// Same edge-detection as wander: a window can close/move under the
// mascot while it's idling, dropping the ground out from under it.
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'falling': {
rt.vy = Math.min(TERMINAL_VY, rt.vy + GRAVITY * dts)
rt.y += rt.vy * dts
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
}
break
}
case 'dragged': {
// Position owned by pointer; just keep y clamped above ground so
// release-from-ground doesn't immediately enter falling.
break
}
default:
break
}
// Keep x in bounds for any behavior (defensive).
if (rt.behavior !== 'dragged') clampX(rt)
// Sync anim from the active behavior (unless it was overridden by a
// react/dragged force; reactAnim holds the override for `react`).
if (rt.behavior === 'react' && rt.reactAnim) {
rt.anim = rt.reactAnim
} else {
const a = def.anim(rt, model)
if (a !== rt.anim) {
rt.anim = a
rt.animStart = now
}
}
// Transition: only self-expiring behaviors (next() consult).
if (rt.behavior === 'dragged' || rt.behavior === 'falling') return
if (now < rt.behaviorUntil) return
const next = def.next(rt, now)
if (next) {
forceBehavior(rt, next)
} else {
// Idle-select a new behavior via weighted random over eligible entries.
const eligible = (Object.values(BEHAVIORS) as BehaviorDef[]).filter(
(b) => (b.weight ?? 0) > 0
)
if (eligible.length > 0) {
const picked = pickWeighted(eligible)
forceBehavior(rt, picked.id)
}
}
}
/** Helper: is the mascot currently in an interruptible autonomous behavior (not dragged)? */
export function isInterruptible(rt: MascotRuntime): boolean {
return rt.behavior !== 'dragged'
}
/** Helper: is the mascot currently asleep (used by stimuli to check interruptsSleep)? */
export function isAsleep(rt: MascotRuntime): boolean {
return rt.behavior === 'sleep'
}
/** Reset the runtime's vertical state for a drag-from-ground: no falling immediately on release at ground. */
export function releaseFromDrag(rt: MascotRuntime): void {
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
} else {
rt.vy = 0
forceBehavior(rt, 'falling')
}
}
/** Recompute ground clamp on resize: if the mascot was at the old ground, snap to the new ground. */
export function reground(rt: MascotRuntime, oldH: number): void {
const gy = groundY(rt)
if (rt.y >= oldH - 1) {
rt.y = gy
rt.vy = 0
} else if (rt.y > gy) {
rt.y = gy
rt.vy = 0
}
if (rt.behavior !== 'dragged') clampX(rt)
}

View File

@@ -0,0 +1,87 @@
// Stateless canvas painter for the mascot. Single render path: slice a
// 16x16 frame from a PNG sheet and draw it bottom-anchored, horizontally
// centered, optionally flipped (for left-facing) and optionally scaled
// (chick is smaller). Egg-stage sheets are also 16x16 PNGs (from the
// Onocentaur egg pack), so no special-case vector path is needed.
//
// The renderer is generic over the SPRITES registry — adding a new
// sheet to sprites.ts requires no change here.
import type { AnimDef, MascotStage } from './types'
import { getImage } from './sprites'
/**
* Logical canvas size (CSS px) the mascot is drawn onto. The sprite's
* feet land on the bottom row. The canvas is taller than wide (20x28)
* so there's room above the sprite for the reaction bubble — the sprite
* bottom-anchors at y=CANVAS_H, and the bubble renders in the top
* ~8 logical px without being clipped.
*/
export const CANVAS_W = 20
export const CANVAS_H = 28
/** Source frame size for the bundled sheets (px). */
const FRAME = 16
export interface DrawOpts {
/** Render scale; usually STAGE_SCALE[stage]. */
scale: number
/** Horizontal facing — when -1, draw the sheet mirrored. */
facing: 1 | -1
/** Wiggle phase (radians) for the egg wobble; ignored for chicken stages. 0 disables. */
wiggle: number
}
/** Draw one animation frame into the given 2D context (which is already sized CANVAS_W x CANVAS_H in CSS px). */
export function drawFrame(
ctx: CanvasRenderingContext2D,
_stage: MascotStage,
anim: AnimDef,
frameIdx: number,
opts: DrawOpts
): void {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
const img = anim.src ? getImage(anim.src) : null
if (!img) return // not yet loaded — skip; the loop picks it up next tick
const idx = Math.max(0, Math.min(frameIdx, anim.frames - 1))
const sx = idx * FRAME
const scale = opts.scale
const drawW = FRAME * scale
const drawH = FRAME * scale
// Bottom-anchor the 16x16 frame in the 20x20 canvas, then scale.
const dx = (CANVAS_W - drawW) / 2 + Math.sin(opts.wiggle) * 1.2
const dy = CANVAS_H - drawH
ctx.save()
if (opts.facing === -1) {
ctx.translate(CANVAS_W, 0)
ctx.scale(-1, 1)
}
ctx.imageSmoothingEnabled = false
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
ctx.restore()
}
/** Draw a reaction bubble above the sprite (call after drawFrame, same context). */
export function drawBubble(
ctx: CanvasRenderingContext2D,
bubbleSrc: string,
facing: 1 | -1
): void {
const img = getImage(bubbleSrc)
if (!img) return
// Bubbles are 16x16 single-frame PNGs drawn in the top portion of the
// 20x28 canvas (above the sprite's head). imageSmoothingEnabled is
// TRUE here — bubbles are crisp UI, not pixel-art, so nearest-neighbor
// downscale looks blocky.
const drawW = 14
const drawH = 14
const dx = (CANVAS_W - drawW) / 2
const dy = 0
ctx.save()
if (facing === -1) {
ctx.translate(CANVAS_W, 0)
ctx.scale(-1, 1)
}
ctx.imageSmoothingEnabled = true
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, drawW, drawH)
ctx.restore()
}

View File

@@ -0,0 +1,144 @@
// Sprite registry. To add a new animation:
// 1. Add its name to `AnimName` in types.ts.
// 2. Add an entry under SPRITES[stage] here pointing at a 16x16-frame PNG sheet in /mascot/.
// 3. (Optional) Reference it from a behavior in behavior.ts or a reaction in stimuli.ts.
// `resolveAnim()` falls back to the stage's `idle` and finally a 1-frame
// placeholder, so a missing animation never crashes the renderer.
//
// Sheets are bundled at web/public/mascot/*.png (CC0, see
// web/public/mascot/LICENSE.txt). Each sheet is a horizontal strip of
// 16x16 px frames; the renderer slices frame `i` at x = i*16.
//
// Egg-stage animations come from the Onocentaur egg pack (single-frame
// 16x16 PNGs): an idle egg and shell halves (shown briefly at the hatch
// moment). The egg → chick transition fires on first naming (see
// state.svelte.ts), not on a timed incubation, so there's no progressive
// crack animation — the egg sits on egg-idle until the name dialog is
// submitted, then swaps to the chick. The egg-crack sheet is kept in the
// registry for future use but isn't selected by any behavior today.
import type { AnimDef, AnimName, MascotStage } from './types'
const EGG_IDLE: AnimDef = { src: '/mascot/egg-idle.png', frames: 1, fps: 1, loop: true }
const EGG_SHELL: AnimDef = { src: '/mascot/egg-shell.png', frames: 1, fps: 1, loop: true }
export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> = {
egg: {
'egg-idle': EGG_IDLE,
'egg-wiggle': EGG_IDLE, // wiggle is applied as a render-time transform; no separate frame
hatch: EGG_SHELL,
dragged: EGG_IDLE,
'fall-flutter': EGG_IDLE,
land: EGG_IDLE
},
// Chick and adult share sheets; only the render scale differs.
chick: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
},
adult: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
}
}
// Render scale per stage. The asset pack has one chicken size; the chick
// and adult both render at full scale (1.0) — downscaling to 0.75 for the
// chick looked blurry on high-DPI displays. The stage is conveyed by the
// tamagotchi model + behavior, not by sprite size.
export const STAGE_SCALE: Record<MascotStage, number> = {
egg: 1,
chick: 1,
adult: 1
}
const PLACEHOLDER: AnimDef = { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true }
/** Resolve an animation for a stage, falling back to the stage's idle, then a placeholder. */
export function resolveAnim(stage: MascotStage, name: AnimName): AnimDef {
const set = SPRITES[stage]
const direct = set[name]
if (direct) return direct
if (name !== 'idle') {
const idle = set.idle
if (idle) return idle
}
return PLACEHOLDER
}
/** Pick which frame of an AnimDef to draw at time `now` (ms). */
export function frameIndex(anim: AnimDef, now: number, animStart: number): number {
const elapsed = now - animStart
if (anim.frames <= 1) return 0
const idx = Math.floor((elapsed / 1000) * anim.fps)
if (anim.loop) return ((idx % anim.frames) + anim.frames) % anim.frames
return Math.min(idx, anim.frames - 1)
}
// ─── Image cache / loader ────────────────────────────────────────────────
// PNG sheets are loaded once into HTMLImageElement instances and reused.
// `loadSprites()` is called from Mascot.svelte on mount; `getImage()`
// returns the cached element (or null if not yet loaded, in which case
// the renderer just skips that frame — the loop will pick it up next
// tick once the image arrives).
const imageCache = new Map<string, HTMLImageElement>()
function loadOne(src: string): Promise<HTMLImageElement> {
const existing = imageCache.get(src)
if (existing && existing.complete) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const img = new Image()
img.src = src
img.onload = () => {
imageCache.set(src, img)
resolve(img)
}
img.onerror = () => reject(new Error(`mascot: failed to load ${src}`))
})
}
/** Preload every sheet referenced by SPRITES for the given stages (default: all). */
export async function loadSprites(stages: MascotStage[] = ['egg', 'chick', 'adult']): Promise<void> {
const srcs = new Set<string>()
for (const stage of stages) {
for (const anim of Object.values(SPRITES[stage])) {
if (anim && anim.src) srcs.add(anim.src)
}
}
await Promise.all([...srcs].map(loadOne))
}
/** Also preload a single bubble sprite by URL (used by react-* animations). */
export function loadBubble(src: string): Promise<HTMLImageElement> {
return loadOne(src)
}
/** Get a cached sheet image, or null if not yet loaded. */
export function getImage(src: string): HTMLImageElement | null {
const img = imageCache.get(src)
if (!img || !img.complete) return null
return img
}

View File

@@ -0,0 +1,208 @@
// Tamagotchi model: long-lived, persisted, slow-moving state (separate
// from the per-frame MascotRuntime in behavior.ts). Backed by a runes
// `$state` at module scope, mutators exported as functions, debounced
// localStorage persistence mirroring stores/windows.ts' 300ms cadence.
//
// Persistence schema lives at localStorage['oikos-mascot'] and is
// versioned via the `version` field; `migrate(raw)` is the stub where
// future schema changes go (v1 has no migrations to perform).
//
// Multi-tab races (two tabs both writing 'oikos-mascot') are
// last-writer-wins — accepted for v1, not solved. A future pass could
// listen to the `storage` event if it becomes a real problem.
import type { MascotStage } from './types'
const STORAGE_KEY = 'oikos-mascot'
const PERSIST_DEBOUNCE_MS = 300
export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
/** Binary egg-hatch flag: 0 until first naming, 1 after. The egg → chick transition fires on naming, not on a timer. */
hatchProgress: number
/** 0..100, slow decay, boosted by pet/feed. */
happiness: number
/** Chick -> adult growth hook; reactions like `eureka` grant xp. */
xp: number
/** epoch ms when the egg hatched (chick/adult), null while still an egg. */
hatchedAt: number | null
/** Persisted rest x position (surface-relative) so the mascot doesn't reset to center on reload. */
lastPos: { x: number } | null
/** epoch ms of the last foreground tick — for capping passive decay. */
lastSeen: number
}
/** XP required to graduate from chick to adult. */
export const ADULT_XP = 200
function defaultModel(): MascotModel {
return {
version: 1,
stage: 'egg',
name: null,
hatchProgress: 0,
happiness: 50,
xp: 0,
hatchedAt: null,
lastPos: null,
lastSeen: Date.now()
}
}
// Module-scoped rune. Mutators below mutate this in place (Object.assign
// / direct property writes); Svelte's reactivity tracks deep property
// access in components that read it. `const` because the binding itself
// is never reassigned — only its properties are.
const model: MascotModel = $state(defaultModel())
// ─── load / migrate / persist ────────────────────────────────────────────
function migrate(raw: unknown): MascotModel {
// v1 has no migrations to perform; this stub documents where future
// version-gated schema changes go (switch on `raw.version`).
if (raw && typeof raw === 'object') {
const r = raw as Partial<MascotModel>
if (r.version === 1) {
return { ...defaultModel(), ...r, version: 1 } as MascotModel
}
}
return defaultModel()
}
function load(): MascotModel {
if (typeof localStorage === 'undefined') return defaultModel()
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultModel()
try {
return migrate(JSON.parse(raw))
} catch {
return defaultModel()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
function schedulePersist(): void {
if (typeof localStorage === 'undefined') return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = null
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// quota / privacy mode — swallow; the model still lives in memory for this session
}
}, PERSIST_DEBOUNCE_MS)
}
function flushPersist(): void {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// ignore
}
}
}
/** Initialize the module state from localStorage. Idempotent. Call once on app boot (or first mascot mount). */
export function initMascotState(): void {
if (typeof localStorage === 'undefined') return
const loaded = load()
// Mutate the existing $state object in place — reassigning `model` to
// a new $state() isn't allowed outside the top level in runes mode.
Object.assign(model, loaded)
model.lastSeen = Date.now()
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPersist)
}
}
// ─── accessors / mutators ────────────────────────────────────────────────
export function getModel(): MascotModel {
return model
}
export function setStage(stage: MascotStage): void {
model.stage = stage
if (stage !== 'egg' && model.hatchedAt === null) {
model.hatchedAt = Date.now()
}
if (stage === 'adult') {
model.xp = Math.max(model.xp, ADULT_XP)
}
schedulePersist()
}
export function setName(name: string): void {
model.name = name.slice(0, 24)
schedulePersist()
}
export function grantXp(n: number): void {
if (n === 0) return
model.xp = Math.max(0, model.xp + n)
schedulePersist()
}
export function feed(): void {
model.happiness = Math.min(100, model.happiness + 8)
grantXp(2)
}
export function pet(): void {
model.happiness = Math.min(100, model.happiness + 4)
grantXp(1)
}
export function setLastPos(x: number): void {
model.lastPos = { x }
schedulePersist()
}
export function resetModel(): void {
Object.assign(model, defaultModel())
schedulePersist()
}
/**
* Advance lifecycle state. Called ~1x/sec from Mascot.svelte's loop (NOT
* every frame). The egg → chick transition is NOT timed here — it fires
* once, on first naming (see MascotLayer's name-dialog submit handler,
* which calls forceHatch() after setName). This tick only handles slow
* passive happiness decay for hatched stages.
*/
export function tickLifecycle(dtMs: number): void {
const dtSec = dtMs / 1000
if (model.stage === 'chick') {
// Slow passive happiness decay (1/sec) so the tamagotchi benefits from
// being interacted with; only meaningful while the model is alive.
model.happiness = Math.max(0, model.happiness - 0.05 * dtSec)
}
model.lastSeen = Date.now()
advanceStageIfReady()
}
/** Promote egg -> chick when hatchProgress hits 1 (set by forceHatch on first naming), chick -> adult when xp hits ADULT_XP. */
export function advanceStageIfReady(): void {
if (model.stage === 'egg' && model.hatchProgress >= 1) {
setStage('chick')
} else if (model.stage === 'chick' && model.xp >= ADULT_XP) {
setStage('adult')
}
}
/** Hatches the egg immediately. Called from MascotLayer's name-dialog submit handler after the first naming, and from the Debug radial-menu action. */
export function forceHatch(): void {
if (model.stage === 'egg') {
model.hatchProgress = 1
setStage('chick')
}
}

View File

@@ -0,0 +1,191 @@
// Stimulus / reaction system. To add a new environment reaction:
// 1. Add a `ReactionDef` entry to REACTIONS below.
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
// inside `attachStimuli()`.
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
// over REACTIONS — no engine change is needed for a new reaction.
//
// Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble. `dragged` always wins over any
// reaction; `sleep` is broken only when `interruptsSleep` is true.
//
// `attachStimuli` owns the SSE subscription (via subscribeEvents()) and
// folds its unsubscribe into the returned teardown, so the mascot keeps
// the SSE stream open (ref-counted alongside any page that also
// subscribes) only while mounted.
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import { streaming } from '$lib/stores/chat'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import { grantXp } from './state.svelte'
import type { AnimName } from './types'
export interface ReactionDef {
id: string
/** Animation to play while this reaction is active. */
anim: AnimName
/** Optional bubble sprite URL (16x16 PNG in /mascot/) drawn above the sprite. */
bubble?: string
/** Higher priority interrupts lower-priority reactions. */
priority: number
/** Minimum ms between dispatches of this same reaction. */
cooldownMs: number
/** How long the reaction animation plays (ms). */
durationMs: number
/** When true, breaks the mascot out of `sleep` to play the reaction. */
interruptsSleep?: boolean
/** Side effect to run on dispatch (e.g. grantXp(5) on eureka). */
effect?: () => void
}
export const REACTIONS: Record<string, ReactionDef> = {
thinking: {
id: 'thinking',
anim: 'react-think',
bubble: '/mascot/bubble-dotdotdot.png',
priority: 1,
cooldownMs: 0,
durationMs: 4000,
interruptsSleep: false
},
eureka: {
id: 'eureka',
anim: 'react-eureka',
bubble: '/mascot/bubble-exclaim.png',
priority: 2,
cooldownMs: 10_000,
durationMs: 2200,
interruptsSleep: false,
effect: () => grantXp(5)
},
alarmed: {
id: 'alarmed',
anim: 'react-alarm',
bubble: '/mascot/bubble-red-exclaim.png',
priority: 3,
cooldownMs: 15_000,
durationMs: 2500,
interruptsSleep: true
},
happy: {
id: 'happy',
anim: 'react-happy',
bubble: '/mascot/bubble-love.png',
priority: 1,
cooldownMs: 20_000,
durationMs: 2000
}
}
/** Per-reaction last-dispatched timestamp (ms). */
const lastFired = new Map<string, number>()
/** Tracks the current reaction's priority so a lower-priority one can't interrupt a higher one mid-flight. */
let currentReactPriority = 0
let currentReactUntil = 0
/**
* Attach all stimulus subscriptions. Returns a teardown that detaches
* everything (including the SSE stream ref). The `emit` callback is the
* MascotLayer's bridge into the runtime — it decides whether to actually
* dispatch based on the current behavior (dragged always wins).
*/
export function attachStimuli(emit: (r: ReactionDef) => void): () => void {
const unsubs: Array<() => void> = []
// ─── chat.ts `streaming`: false → true edge triggers `thinking` ──────
let lastStreaming = false
let prevStreamValue: boolean | null = null
// Hold the reaction while streaming stays true: we re-emit on each
// false→true edge so a new turn restarts the thinking anim.
unsubs.push(
streaming.subscribe((s) => {
if (prevStreamValue === false && s === true) {
tryDispatch(REACTIONS.thinking, emit)
}
prevStreamValue = s
lastStreaming = s
})
)
// Touch lastStreaming so the linter doesn't complain; it's used to
// reason about the edge detection above (kept for future "held while
// true" logic).
void lastStreaming
// ─── activity.ts `activityLog`: new `type === 'knowledge'` entry ─────
// The store is derived and recomputed wholesale on every emission —
// NOT append-only — so detect new entries by diffing entry ids
// against the last-seen set.
let prevKnowledgeIds = new Set<string>()
let firstActivityEmission = true
unsubs.push(
activityLog.subscribe((entries) => {
const currentIds = new Set<string>()
for (const e of entries) {
currentIds.add(e.id)
if (e.type === 'knowledge' && !prevKnowledgeIds.has(e.id) && !firstActivityEmission) {
tryDispatch(REACTIONS.eureka, emit)
}
}
prevKnowledgeIds = currentIds
firstActivityEmission = false
})
)
// ─── events.ts `liveEvents`: new head event ──────────────────────────
// On the very first emission, just record the head event id — do NOT
// replay history as reactions on mount.
let lastSeenEventId = 0
let firstEventEmission = true
unsubs.push(
liveEvents.subscribe((events) => {
const head = events[0]
if (!head) return
if (head.id <= lastSeenEventId) return
lastSeenEventId = head.id
if (firstEventEmission) {
firstEventEmission = false
return
}
if (head.severity === 'critical' || head.type.startsWith('signal.')) {
tryDispatch(REACTIONS.alarmed, emit)
} else if (head.type.startsWith('execution.')) {
// Success-ish execution event — happy reaction.
tryDispatch(REACTIONS.happy, emit)
}
})
)
// ─── SSE stream: ref-counted via subscribeEvents() ──────────────────
unsubs.push(subscribeEvents())
return () => {
for (const u of unsubs) u()
}
}
/** Cooldown + priority gate before handing the reaction to MascotLayer. */
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void {
const now = performance.now()
const last = lastFired.get(r.id) ?? 0
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
// Priority: a new reaction must have priority >= the current one's,
// unless the current one has expired (now > currentReactUntil).
const currentExpired = now > currentReactUntil
if (!currentExpired && r.priority < currentReactPriority) return
lastFired.set(r.id, now)
currentReactPriority = r.priority
currentReactUntil = now + r.durationMs
emit(r)
}
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
export function resetStimuliState(): void {
lastFired.clear()
currentReactPriority = 0
currentReactUntil = 0
}
// Type re-export so consumers don't need to import from events.ts separately.
export type { OikosEvent, ActivityEntry }

112
web/src/lib/mascot/types.ts Normal file
View File

@@ -0,0 +1,112 @@
// Type definitions for the desktop mascot sprite/behavior/action/reaction
// system. Every registry below (sprites.ts, behavior.ts, actions.ts,
// stimuli.ts) is plain data over these types, so each can be extended
// independently without touching the engine code in Mascot.svelte.
import type { Component } from 'svelte'
/** Slow-moving lifecycle stage of the tamagotchi. Drives which sprite set is drawn and the render scale. */
export type MascotStage = 'egg' | 'chick' | 'adult'
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
| 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
/**
* A sprite-sheet animation. The sheet is a horizontal strip of 16x16 px
* frames (PNG, RGBA) served from /mascot/*. The renderer slices frame
* `i` from x = i*16, y = 0, w = 16, h = 16. The whole mascot sprite
* canvas is 20x20 logical px (so feet land on a consistent ground line
* across stages); the 16x16 frame is bottom-anchored and horizontally
* centered inside it.
*
* Egg-stage animations are vector-drawn by render.ts (no PNG); their
* AnimDef entries still exist for the FSM to reference but their `src`
* is ignored.
*/
export interface AnimDef {
/** Sheet URL (resolved from /mascot/<src>). Ignored for egg-vector anims. */
src: string
/** Frame count in the sheet (sheet width = frames * 16). */
frames: number
/** Frames per second. */
fps: number
/** Whether to wrap the frame index once it reaches `frames`. */
loop: boolean
}
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
export type BehaviorId =
| 'egg' | 'idle' | 'wander' | 'peck' | 'sleep'
| 'dragged' | 'falling' | 'land' | 'react'
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
export type Stimulus = string
/** Shape of a radial-menu action node. See actions.ts. */
export interface RadialAction {
id: string
label: string
icon?: Component
/** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */
visible?: (model: import('./state.svelte').MascotModel) => boolean
/** Sub-actions — selecting this node swaps the ring to its children + a back button. */
children?: RadialAction[]
/** Leaf handler. Mutates model/runtime via the passed context. */
action?: (ctx: MascotActionCtx) => void
}
/** Argument passed to a RadialAction leaf handler. */
export interface MascotActionCtx {
model: import('./state.svelte').MascotModel
runtime: MascotRuntime
/** Force a behavior (e.g. sleep). See behavior.ts. */
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) => void
/** Request the name dialog to open. */
requestRename: () => void
/** Advance to the next lifecycle stage immediately (debug). */
forceHatch: () => void
/** Force a specific lifecycle stage (debug). */
forceStage: (stage: MascotStage) => void
/** Reset the tamagotchi model to defaults. */
reset: () => void
/** Redraw the menu (after a visibility-affecting mutation). */
refresh: () => void
}
/** Frame-to-frame state of the mascot on the desktop surface. Owned by Mascot.svelte. */
export interface MascotRuntime {
/** Sprite bottom-center, surface (not viewport) coords. */
x: number
y: number
vx: number
vy: number
facing: 1 | -1
behavior: BehaviorId
/** performance.now() ms after which the current behavior should transition (its next() is consulted). */
behaviorUntil: number
/** Currently-playing animation. */
anim: AnimName
/** performance.now() ms when the current animation started. */
animStart: number
/** When behavior === 'react', the animation to play (overrides the behavior's default anim). */
reactAnim: AnimName | null
/** Surface bounds (width/height in CSS pixels). Updated on resize. */
bounds: { w: number; h: number }
/**
* Current ground line at the mascot's x: the top edge of the highest
* non-minimized window beneath it, or bounds.h (surface bottom) when
* no window is beneath. Updated each tick by Mascot.svelte from
* wmState; the FSM uses this as the ground for falling/landing.
*/
groundY: number
/** Optional reaction-bubble sprite to draw above the chicken (eg 'bubble-exclaim'). */
bubble: string | null
/** performance.now() ms when the current bubble was set; cleared when null. */
bubbleUntil: number
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
blinkUntil: number
}