feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
@@ -13,11 +13,16 @@
|
|||||||
> feature of it — the same relationship [components.md](../mbse/components.md)
|
> feature of it — the same relationship [components.md](../mbse/components.md)
|
||||||
> has to [README.md](../mbse/README.md), applied recursively.
|
> has to [README.md](../mbse/README.md), applied recursively.
|
||||||
|
|
||||||
**Status of this Model:** the subsystem it describes does not exist in
|
**Status of this Model:** the subsystem it describes is **implemented**
|
||||||
code yet. Every View below is marked **Planned**, not **Verified** —
|
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
|
||||||
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
|
Views below are marked **Implemented** where the code matches; a small
|
||||||
which this document borrows. The corresponding implementation plan is
|
number of requirements (distinct adult art, reaction speech-bubble
|
||||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
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
|
## Views in this model
|
||||||
|
|
||||||
@@ -77,22 +82,23 @@ flowchart TB
|
|||||||
|
|
||||||
## 2. Requirements
|
## 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 |
|
| 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-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) under gravity | User request + design decision | Planned |
|
| 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 | 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 | Implemented |
|
||||||
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
|
| 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 | User request | Planned |
|
| 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") | Planned |
|
| 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 | Planned |
|
| 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") | 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") | 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") | 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 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-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 | Planned |
|
| 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) | 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) | Implemented |
|
||||||
|
|
||||||
## 3. Structural View
|
## 3. Structural View
|
||||||
|
|
||||||
@@ -206,7 +212,7 @@ explicit before any of it is coded.
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> egg
|
[*] --> egg
|
||||||
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
|
egg --> chick : first naming submitted\n(forceHatch: hatchProgress=1)
|
||||||
|
|
||||||
state chick_and_adult_behaviors {
|
state chick_and_adult_behaviors {
|
||||||
[*] --> idle
|
[*] --> idle
|
||||||
@@ -243,14 +249,17 @@ for the concrete weights.
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> egg : first load,\ndefaultModel()
|
[*] --> 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)
|
chick --> adult : xp >= ADULT_XP (200)
|
||||||
adult --> [*]
|
adult --> [*]
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
|
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`
|
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
|
### 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
|
sequenceDiagram
|
||||||
participant SSE as stores/events.ts (SSE)
|
participant SSE as stores/events.ts (SSE)
|
||||||
participant Stim as stimuli.ts attachStimuli
|
participant Stim as stimuli.ts attachStimuli
|
||||||
|
participant Layer as MascotLayer.svelte (emit callback)
|
||||||
participant FSM as behavior.ts
|
participant FSM as behavior.ts
|
||||||
participant Mascot as Mascot.svelte (canvas)
|
participant Mascot as Mascot.svelte (canvas)
|
||||||
|
|
||||||
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
||||||
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
|
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->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
|
||||||
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
|
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
|
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
|
## 5. Interfaces View
|
||||||
|
|
||||||
**Stakeholders:** an engineer wiring a new store into the mascot's
|
**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/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/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)`) |
|
| [`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 |
|
| [`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
|
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)
|
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||||
for the same list in implementation-order context):
|
for the same list in implementation-order context):
|
||||||
|
|
||||||
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
|
- Egg renders grounded at the surface bottom, wiggles gently while the
|
||||||
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
|
name dialog is open, and survives a reload at the same x (confirm
|
||||||
fire from mere walking, only from discrete transitions).
|
`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
|
- Dragging the egg up and releasing triggers a flutter-fall with no
|
||||||
tunneling below the taskbar; dragging past the surface edges clamps.
|
tunneling below the taskbar; dragging past the surface edges clamps.
|
||||||
- Forcing hatch (debug menu action) transitions to chick, opens the name
|
- A fresh egg (no name) opens the name dialog on mount; submitting it
|
||||||
dialog, and the name persists across reload.
|
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
|
- Chick wanders and flips sprite at surface edges, pecks, sleeps
|
||||||
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
||||||
- Right-clicking the chicken opens the radial menu centered on it, without
|
- Right-clicking the chicken opens the radial menu centered on it, without
|
||||||
|
|||||||
@@ -1,6 +1,48 @@
|
|||||||
# 2026-07-20 — Desktop mascot ("Cluck")
|
# 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
|
## Why
|
||||||
|
|
||||||
@@ -181,9 +223,11 @@ export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?:
|
|||||||
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||||||
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||||||
`react`.
|
`react`.
|
||||||
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
|
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
|
||||||
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
|
wiggles gently via a render-time transform); dragging is still
|
||||||
egg can be picked up and moved).
|
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()),
|
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||||||
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||||||
|
|
||||||
@@ -195,14 +239,13 @@ export interface MascotModel {
|
|||||||
version: 1
|
version: 1
|
||||||
stage: MascotStage
|
stage: MascotStage
|
||||||
name: string | null
|
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
|
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||||||
xp: number // chick -> adult growth hook
|
xp: number // chick -> adult growth hook
|
||||||
hatchedAt: number | null
|
hatchedAt: number | null
|
||||||
lastPos: { x: 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 const ADULT_XP = 200
|
||||||
export function grantXp(n: number): void
|
export function grantXp(n: number): void
|
||||||
export function feed(): void
|
export function feed(): void
|
||||||
@@ -210,6 +253,7 @@ export function pet(): void
|
|||||||
export function setName(name: string): void
|
export function setName(name: string): void
|
||||||
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||||||
export function advanceStageIfReady(): void
|
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
|
- `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
|
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||||||
rename. `lastPos.x` is written only on behavior transitions and
|
rename. `lastPos.x` is written only on behavior transitions and
|
||||||
drag-end, never per frame.
|
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
|
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||||||
last-writer-wins — accepted for this scaffolding, not solved; a future
|
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||||||
pass could listen to the `storage` event if it becomes a real problem.
|
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
|
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||||||
stream open (ref-counted alongside any page that also subscribes) only
|
stream open (ref-counted alongside any page that also subscribes) only
|
||||||
while mounted.
|
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
|
- On first emission of `liveEvents`, just record the head event id — do
|
||||||
not replay history as reactions on mount.
|
not replay history as reactions on mount.
|
||||||
- Dispatch: `emit(reaction)` checks the cooldown map and
|
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||||||
|
|||||||
20
web/public/mascot/LICENSE-eggs.txt
Normal 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.
|
||||||
5
web/public/mascot/LICENSE.txt
Normal 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
|
After Width: | Height: | Size: 343 B |
BIN
web/public/mascot/bubble-dotdotdot.png
Normal file
|
After Width: | Height: | Size: 190 B |
BIN
web/public/mascot/bubble-exclaim.png
Normal file
|
After Width: | Height: | Size: 193 B |
BIN
web/public/mascot/bubble-love.png
Normal file
|
After Width: | Height: | Size: 222 B |
BIN
web/public/mascot/bubble-question.png
Normal file
|
After Width: | Height: | Size: 203 B |
BIN
web/public/mascot/bubble-red-exclaim.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
web/public/mascot/egg-crack.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/public/mascot/egg-idle.png
Normal file
|
After Width: | Height: | Size: 132 B |
BIN
web/public/mascot/egg-shell.png
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
web/public/mascot/hurt.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
web/public/mascot/idle.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
web/public/mascot/jump.png
Normal file
|
After Width: | Height: | Size: 335 B |
BIN
web/public/mascot/peck.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
web/public/mascot/peep.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
web/public/mascot/react-displeased.png
Normal file
|
After Width: | Height: | Size: 330 B |
BIN
web/public/mascot/react-joy.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
web/public/mascot/react-sigh.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
web/public/mascot/react-surprise.png
Normal file
|
After Width: | Height: | Size: 361 B |
BIN
web/public/mascot/react-yell.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
web/public/mascot/sleep.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
web/public/mascot/walk.png
Normal file
|
After Width: | Height: | Size: 375 B |
BIN
web/public/mascot/walk2.png
Normal file
|
After Width: | Height: | Size: 390 B |
@@ -14,6 +14,7 @@
|
|||||||
import TaskLauncher from './TaskLauncher.svelte'
|
import TaskLauncher from './TaskLauncher.svelte'
|
||||||
import WindowLayer from './WindowLayer.svelte'
|
import WindowLayer from './WindowLayer.svelte'
|
||||||
import Taskbar from './Taskbar.svelte'
|
import Taskbar from './Taskbar.svelte'
|
||||||
|
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||||
@@ -100,6 +101,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WindowLayer />
|
<WindowLayer />
|
||||||
|
|
||||||
|
<MascotLayer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Taskbar />
|
<Taskbar />
|
||||||
|
|||||||
329
web/src/lib/mascot/Mascot.svelte
Normal 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}
|
||||||
204
web/src/lib/mascot/MascotLayer.svelte
Normal 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}
|
||||||
98
web/src/lib/mascot/NameDialog.svelte
Normal 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>
|
||||||
152
web/src/lib/mascot/RadialMenu.svelte
Normal 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>
|
||||||
163
web/src/lib/mascot/actions.ts
Normal 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)
|
||||||
|
}
|
||||||
385
web/src/lib/mascot/behavior.ts
Normal 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 (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)
|
||||||
|
}
|
||||||
87
web/src/lib/mascot/render.ts
Normal 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()
|
||||||
|
}
|
||||||
144
web/src/lib/mascot/sprites.ts
Normal 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
|
||||||
|
}
|
||||||
208
web/src/lib/mascot/state.svelte.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
191
web/src/lib/mascot/stimuli.ts
Normal 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
@@ -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
|
||||||
|
}
|
||||||