feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
This commit is contained in:
385
web/src/lib/mascot/behavior.ts
Normal file
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)
|
||||
}
|
||||
Reference in New Issue
Block a user