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

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

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

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

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

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

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

View File

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