diff --git a/VERSION b/VERSION index a48658c..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.13 +0.8.0 diff --git a/docs/mascot/README.md b/docs/mascot/README.md index 3f65301..7e3dfbc 100644 --- a/docs/mascot/README.md +++ b/docs/mascot/README.md @@ -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` | false→true edge triggers the `thinking` reaction, held while true | | [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable`, **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` | 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 | ``, 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 diff --git a/plans/2026-07-20-desktop-mascot.md b/plans/2026-07-20-desktop-mascot.md index 9e022c5..776fb70 100644 --- a/plans/2026-07-20-desktop-mascot.md +++ b/plans/2026-07-20-desktop-mascot.md @@ -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 diff --git a/web/public/mascot/LICENSE-eggs.txt b/web/public/mascot/LICENSE-eggs.txt new file mode 100644 index 0000000..2325b0d --- /dev/null +++ b/web/public/mascot/LICENSE-eggs.txt @@ -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. \ No newline at end of file diff --git a/web/public/mascot/LICENSE.txt b/web/public/mascot/LICENSE.txt new file mode 100644 index 0000000..c7fcea0 --- /dev/null +++ b/web/public/mascot/LICENSE.txt @@ -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. \ No newline at end of file diff --git a/web/public/mascot/blink.png b/web/public/mascot/blink.png new file mode 100644 index 0000000..ce49a94 Binary files /dev/null and b/web/public/mascot/blink.png differ diff --git a/web/public/mascot/bubble-dotdotdot.png b/web/public/mascot/bubble-dotdotdot.png new file mode 100644 index 0000000..f5697b9 Binary files /dev/null and b/web/public/mascot/bubble-dotdotdot.png differ diff --git a/web/public/mascot/bubble-exclaim.png b/web/public/mascot/bubble-exclaim.png new file mode 100644 index 0000000..67b36e2 Binary files /dev/null and b/web/public/mascot/bubble-exclaim.png differ diff --git a/web/public/mascot/bubble-love.png b/web/public/mascot/bubble-love.png new file mode 100644 index 0000000..734f504 Binary files /dev/null and b/web/public/mascot/bubble-love.png differ diff --git a/web/public/mascot/bubble-question.png b/web/public/mascot/bubble-question.png new file mode 100644 index 0000000..3b29c48 Binary files /dev/null and b/web/public/mascot/bubble-question.png differ diff --git a/web/public/mascot/bubble-red-exclaim.png b/web/public/mascot/bubble-red-exclaim.png new file mode 100644 index 0000000..543617a Binary files /dev/null and b/web/public/mascot/bubble-red-exclaim.png differ diff --git a/web/public/mascot/egg-crack.png b/web/public/mascot/egg-crack.png new file mode 100644 index 0000000..14d8496 Binary files /dev/null and b/web/public/mascot/egg-crack.png differ diff --git a/web/public/mascot/egg-idle.png b/web/public/mascot/egg-idle.png new file mode 100644 index 0000000..0fc40a9 Binary files /dev/null and b/web/public/mascot/egg-idle.png differ diff --git a/web/public/mascot/egg-shell.png b/web/public/mascot/egg-shell.png new file mode 100644 index 0000000..2280e55 Binary files /dev/null and b/web/public/mascot/egg-shell.png differ diff --git a/web/public/mascot/hurt.png b/web/public/mascot/hurt.png new file mode 100644 index 0000000..8aeb70e Binary files /dev/null and b/web/public/mascot/hurt.png differ diff --git a/web/public/mascot/idle.png b/web/public/mascot/idle.png new file mode 100644 index 0000000..492214f Binary files /dev/null and b/web/public/mascot/idle.png differ diff --git a/web/public/mascot/jump.png b/web/public/mascot/jump.png new file mode 100644 index 0000000..4262c1f Binary files /dev/null and b/web/public/mascot/jump.png differ diff --git a/web/public/mascot/peck.png b/web/public/mascot/peck.png new file mode 100644 index 0000000..5c575f4 Binary files /dev/null and b/web/public/mascot/peck.png differ diff --git a/web/public/mascot/peep.png b/web/public/mascot/peep.png new file mode 100644 index 0000000..86f3e72 Binary files /dev/null and b/web/public/mascot/peep.png differ diff --git a/web/public/mascot/react-displeased.png b/web/public/mascot/react-displeased.png new file mode 100644 index 0000000..6edd56b Binary files /dev/null and b/web/public/mascot/react-displeased.png differ diff --git a/web/public/mascot/react-joy.png b/web/public/mascot/react-joy.png new file mode 100644 index 0000000..c5c86d2 Binary files /dev/null and b/web/public/mascot/react-joy.png differ diff --git a/web/public/mascot/react-sigh.png b/web/public/mascot/react-sigh.png new file mode 100644 index 0000000..20376a8 Binary files /dev/null and b/web/public/mascot/react-sigh.png differ diff --git a/web/public/mascot/react-surprise.png b/web/public/mascot/react-surprise.png new file mode 100644 index 0000000..7228107 Binary files /dev/null and b/web/public/mascot/react-surprise.png differ diff --git a/web/public/mascot/react-yell.png b/web/public/mascot/react-yell.png new file mode 100644 index 0000000..477cc35 Binary files /dev/null and b/web/public/mascot/react-yell.png differ diff --git a/web/public/mascot/sleep.png b/web/public/mascot/sleep.png new file mode 100644 index 0000000..4d96ae5 Binary files /dev/null and b/web/public/mascot/sleep.png differ diff --git a/web/public/mascot/walk.png b/web/public/mascot/walk.png new file mode 100644 index 0000000..3ebb4d4 Binary files /dev/null and b/web/public/mascot/walk.png differ diff --git a/web/public/mascot/walk2.png b/web/public/mascot/walk2.png new file mode 100644 index 0000000..fbf7987 Binary files /dev/null and b/web/public/mascot/walk2.png differ diff --git a/web/src/lib/components/desktop-shell/Desktop.svelte b/web/src/lib/components/desktop-shell/Desktop.svelte index 05092f2..5bc6c26 100644 --- a/web/src/lib/components/desktop-shell/Desktop.svelte +++ b/web/src/lib/components/desktop-shell/Desktop.svelte @@ -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 @@ + + diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte new file mode 100644 index 0000000..478f38c --- /dev/null +++ b/web/src/lib/mascot/Mascot.svelte @@ -0,0 +1,329 @@ + + + + +{#if model.name} +
+ {model.name} +
+{/if} diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte new file mode 100644 index 0000000..9825c6d --- /dev/null +++ b/web/src/lib/mascot/MascotLayer.svelte @@ -0,0 +1,204 @@ + + +
+ +
+ +{#if menuPos} + +{/if} + +{#if nameDialogOpen} + (nameDialogOpen = false)} + /> +{/if} diff --git a/web/src/lib/mascot/NameDialog.svelte b/web/src/lib/mascot/NameDialog.svelte new file mode 100644 index 0000000..bfe7bc8 --- /dev/null +++ b/web/src/lib/mascot/NameDialog.svelte @@ -0,0 +1,98 @@ + + + + + + diff --git a/web/src/lib/mascot/RadialMenu.svelte b/web/src/lib/mascot/RadialMenu.svelte new file mode 100644 index 0000000..e9e432f --- /dev/null +++ b/web/src/lib/mascot/RadialMenu.svelte @@ -0,0 +1,152 @@ + + + + + + + diff --git a/web/src/lib/mascot/actions.ts b/web/src/lib/mascot/actions.ts new file mode 100644 index 0000000..c66bbdc --- /dev/null +++ b/web/src/lib/mascot/actions.ts @@ -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) +} diff --git a/web/src/lib/mascot/behavior.ts b/web/src/lib/mascot/behavior.ts new file mode 100644 index 0000000..f2107e8 --- /dev/null +++ b/web/src/lib/mascot/behavior.ts @@ -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 = { + 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 (2–6s 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) +} diff --git a/web/src/lib/mascot/render.ts b/web/src/lib/mascot/render.ts new file mode 100644 index 0000000..251f344 --- /dev/null +++ b/web/src/lib/mascot/render.ts @@ -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() +} diff --git a/web/src/lib/mascot/sprites.ts b/web/src/lib/mascot/sprites.ts new file mode 100644 index 0000000..3862947 --- /dev/null +++ b/web/src/lib/mascot/sprites.ts @@ -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>> = { + 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 = { + 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() + +function loadOne(src: string): Promise { + 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 { + const srcs = new Set() + 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 { + 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 +} diff --git a/web/src/lib/mascot/state.svelte.ts b/web/src/lib/mascot/state.svelte.ts new file mode 100644 index 0000000..04ca408 --- /dev/null +++ b/web/src/lib/mascot/state.svelte.ts @@ -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 + 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 | 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') + } +} diff --git a/web/src/lib/mascot/stimuli.ts b/web/src/lib/mascot/stimuli.ts new file mode 100644 index 0000000..df8d85d --- /dev/null +++ b/web/src/lib/mascot/stimuli.ts @@ -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 = { + 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() + +/** 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() + let firstActivityEmission = true + unsubs.push( + activityLog.subscribe((entries) => { + const currentIds = new Set() + 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 } diff --git a/web/src/lib/mascot/types.ts b/web/src/lib/mascot/types.ts new file mode 100644 index 0000000..703f868 --- /dev/null +++ b/web/src/lib/mascot/types.ts @@ -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/). 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 +}