// 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 — but the fall // is alive: panic-flap wing-beats slow the descent on a speed-scaled, // jittered cycle, faster-than-terminal tosses decay under drag, hard // impacts bounce once and skid, and hard sideways throws ricochet off // the surface's side bounds. On landing, a brief `land` behavior plays // (the squash/spring render layer keys off rt.squashAt/impactVy), 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) // A fall can exceed TERMINAL_VY (a hard downward toss); instead of a // hard clamp, drag pulls it back toward terminal at this rate, so a // fling reads fast-then-settling instead of unnaturally capped. const SUPER_TERMINAL_DRAG = 1000 // px/s^2 const WALK_SPEED = 36 // px/s const MARGIN = 24 // px before the surface edge where wander flips facing // Falling is a series of glide/flap sub-phases, not a flat monotonic drop: // every flapCycleMs, a wing-beat impulse briefly cuts the descent speed // (a real, if losing, attempt at flight), and the animation swaps to `flap` // for the FLAP_BURST_MS right after each impulse. The cycle is DYNAMIC — // the faster the descent, the more frantic the flapping (scheduleFlap // below), with jitter so it never reads metronomic. A gentle sine wobble // adds horizontal drift so the fall isn't perfectly vertical either. See // stepMascot()'s 'falling' case and the `falling` BehaviorDef below. const FLAP_CYCLE_MIN_MS = 330 // frantic (fast descent) const FLAP_CYCLE_MAX_MS = 600 // lazy (slow flutter) const FLAP_BURST_MS = 160 const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle const FLAP_MAX_LIFT = -150 // px/s — how negative (upward) a flap may push vy const WOBBLE_VX = 22 // px/s amplitude of the sideways drift while falling const VX_DECAY_PER_S = 1.4 // exponential decay rate for toss/drift vx // Impact bounce: a round fluffy body, not a rock. A hard-enough impact // bounces once (diminished), then lands. The contact squash is rendered // by Mascot.svelte's spring from rt.squashAt/impactVy. const BOUNCE_MIN_VY = 250 // px/s impact below which there's no bounce const BOUNCE_RESTITUTION = 0.34 const BOUNCE_MAX = 1 // Landing skid: real sideways momentum survives touchdown as a short // friction slide instead of the old dead stop. const SKID_MIN_VX = 140 // px/s — slower sideways landings just stop const SKID_ENTRY_MAX = 420 // px/s — cap on carried-in skid speed const SKID_KEEP = 0.5 // fraction of touchdown vx kept as skid const SKID_FRICTION = 900 // px/s^2 // Wall ricochet: hard sideways throws bounce off the surface's side // bounds mid-fall; a gentle drift still just stops at the margin. const WALL_BOUNCE_MIN_VX = 150 // px/s const WALL_BOUNCE_RESTITUTION = 0.45 // Hop: a small autonomous forward hop (chickens hop!) — real projectile // motion, no flapping. If the ground drops out mid-hop (hopped off a // window edge), stepMascot hands off to a real fall. const HOP_VY = 210 // px/s takeoff speed const HOP_VX = 70 // px/s forward speed const HOP_BACKSTOP_MS = 900 // behaviorUntil backstop; real exit is on landing // Ground tracking: Mascot.svelte's tick() chases the ground line (the top // of whatever window/surface is beneath the mascot) each frame. A small // per-tick change (a window being dragged smoothly, with the mascot riding // along) follows instantly; anything the ground drops away by more than // this is treated as the surface disappearing — falling takes over instead // of snapping. See GROUND_FOLLOW_MAX_STEP/GROUND_DROP_FALL_PX in Mascot.svelte. // 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 // Idle chatter: very occasional, unprompted, purely cosmetic one-liners — // no signal value, just personality. Rolled once each time `idle` is // (re-)entered, gated by both a probability and a cooldown so it stays // rare rather than firing on every idle cycle (idle gets re-picked often // by the weighted-random selector). See the `idle` BehaviorDef below. const IDLE_CHATTER_CHANCE = 0.12 const IDLE_CHATTER_COOLDOWN_MS = 25_000 const IDLE_CHATTER_DURATION_MS = 2200 const IDLE_CHATTER_LINES = [ '🐔 Bawk.', "💭 Wonder what's up on hubris…", '🌾 Any seeds around?', '😌 Nice day for uptime.', '📦 So many containers…', '☁️ Backup time yet?', '🥚 Remember when I was an egg?', '🔧 *pecks at nothing in particular*', '🐧 Penguins are cool too, I guess.' ] // Module-level (not per-runtime) since there's only ever one mascot — // matches stimuli.ts's own module-level cooldown tracking. let lastChatterAt = 0 // ─── 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 ───────────────────────────────────────────── /** * (Re)start a flap cycle: records `now` as the last wing-beat and picks * the next cycle length from the CURRENT descent speed — a fast fall * (hard toss) flaps frantically, a gentle flutter flaps lazily — plus * ±15% jitter so the rhythm never sounds like a metronome. */ function scheduleFlap(rt: MascotRuntime, now: number): void { rt.fallPhaseAt = now const speedFactor = Math.max(0, Math.min(1, rt.vy / TERMINAL_VY)) const base = FLAP_CYCLE_MAX_MS - speedFactor * (FLAP_CYCLE_MAX_MS - FLAP_CYCLE_MIN_MS) rt.flapCycleMs = base * (0.85 + Math.random() * 0.3) } function groundY(rt: MascotRuntime): number { // rt.groundY is recomputed each tick by Mascot.svelte from the window // state — it's the top edge of the highest window beneath the mascot, // or rt.bounds.h (surface bottom) when no window is beneath. return rt.groundY } function clampX(rt: MascotRuntime): void { const minX = MARGIN / 2 const maxX = rt.bounds.w - MARGIN / 2 if (rt.x < minX) { rt.x = minX rt.facing = 1 } if (rt.x > maxX) { rt.x = maxX rt.facing = -1 } } // ─── BEHAVIORS registry ─────────────────────────────────────────────────── export const BEHAVIORS: Record = { egg: { id: 'egg', anim: () => 'egg-idle', tick: () => { // Egg doesn't move on its own. }, next: () => 'egg', minMs: 0, maxMs: 0 }, idle: { id: 'idle', // Periodic blink: enter() sets blinkUntil to the START of the next // blink window (2–6s away). anim() returns 'blink' when we're past // that start but within 150ms of it. A chatter bubble (see enter() // below) takes over the sprite for as long as it's showing — the // mouth-flap 'talk' loop reads as the mascot actually saying the line // instead of just standing there while text happens to appear above it. anim: (rt) => { if (rt.bubbleText) return 'talk' 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 // Idle chatter: rare, cosmetic-only bubble line (see the constants // above). Doesn't touch the FSM/behavior at all, just the bubble. const now = performance.now() if (now - lastChatterAt > IDLE_CHATTER_COOLDOWN_MS && Math.random() < IDLE_CHATTER_CHANCE) { lastChatterAt = now rt.bubbleText = IDLE_CHATTER_LINES[Math.floor(Math.random() * IDLE_CHATTER_LINES.length)] rt.bubbleUntil = now + IDLE_CHATTER_DURATION_MS } }, 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] }, hop: { id: 'hop', // A little forward hop (chickens hop!). Real projectile motion — // enter() throws it up-and-forward and stepMascot's 'hop' case // integrates gravity until touchdown, which forces idle with a small // landing squash (impactVy ≈ HOP_VY: light, no poof). anim: () => 'flap', enter: (rt) => { rt.vy = -HOP_VY rt.vx = rt.facing * HOP_VX }, tick: () => { // Integration happens in stepMascot (needs dt in seconds). }, next: () => null, weight: 2, minMs: HOP_BACKSTOP_MS, maxMs: HOP_BACKSTOP_MS * 2 }, 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', // Flap briefly right after each wing-beat impulse (see stepMascot), // glide the rest of the cycle. anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'), enter: (rt) => { rt.bounceCount = 0 scheduleFlap(rt, performance.now()) // vx/vy are deliberately NOT reset here — they carry over from // drag-release toss momentum (set by Mascot.svelte's onPointerUp) // when falling starts from a throw, or stay at 0 when it starts from // walking off an edge / a surface disappearing underfoot. }, 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. }, // Routes to 'peck' instead of 'idle' when the drag that led here was // released on top of a desktop icon (Mascot.svelte's onPointerUp sets // investigateOnLand) — a little "investigate" reaction, whether the // landing was immediate or came after a fall. Consumed once. next: (rt) => { if (rt.investigateOnLand) { rt.investigateOnLand = false return 'peck' } return '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 }, // Continuous engagement with the focused task window's active turn — not // a timed pulse like `react` above. Entered/exited directly by // MascotLayer's busy-state callback (see stimuli.ts's attachStimuli, // second callback), which also keeps rt.busyTalking current every time // the phase flips. No `weight` — never auto-picked by the idle selector, // same as dragged/falling/land. busy: { id: 'busy', anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'), tick: () => { // Stationary — just displays whichever sprite busyTalking selects. }, // Only reached if something calls next() on it directly, which nothing // does in practice: MascotLayer forces 'idle' itself the moment // stimuli.ts reports the turn ended. Falling back to 'idle' here is // just a safe default, not the real exit path. next: () => 'idle', minMs: 0, maxMs: 0 } } // ─── 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': { // Wing-beat: every flapCycleMs (dynamic — see scheduleFlap), cut // the descent speed sharply: a real (if losing) attempt at flight // rather than a flat drop. if (now - rt.fallPhaseAt >= rt.flapCycleMs) { rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE) scheduleFlap(rt, now) } rt.vy += GRAVITY * dts // Soft terminal: a fall moving faster than terminal (a hard // downward toss) decays back toward it under drag instead of being // hard-clamped mid-air. if (rt.vy > TERMINAL_VY) { rt.vy = Math.max(TERMINAL_VY, rt.vy - SUPER_TERMINAL_DRAG * dts) } // Toss/drift horizontal velocity decays so it doesn't carry forever, // plus a gentle sideways wobble so even a straight-down drop isn't // perfectly vertical. rt.vx *= Math.max(0, 1 - VX_DECAY_PER_S * dts) const wobble = Math.sin(now / 260) * WOBBLE_VX rt.x += (rt.vx + wobble) * dts rt.y += rt.vy * dts // Face the direction of travel on real sideways tosses. if (Math.abs(rt.vx) > 40) rt.facing = rt.vx > 0 ? 1 : -1 // Ricochet off the surface's side bounds on hard sideways throws // (a gentle drift still just stops at the margin, via clampX). const minX = MARGIN / 2 const maxX = rt.bounds.w - MARGIN / 2 if (rt.x <= minX && rt.vx < -WALL_BOUNCE_MIN_VX) { rt.x = minX rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION } else if (rt.x >= maxX && rt.vx > WALL_BOUNCE_MIN_VX) { rt.x = maxX rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION } const gy = groundY(rt) // Touchdown only while actually descending (vy > 0): a flap // impulse or a post-bounce rise can briefly carry it upward at/below // the ground line (or a window rising underneath can catch up to // it) — those must not read as impacts. if (rt.y >= gy && rt.vy > 0) { rt.y = gy const impact = rt.vy if (impact >= BOUNCE_MIN_VY && rt.bounceCount < BOUNCE_MAX) { // Hard impact: one soft, diminished bounce. The contact squash // renders from squashAt/impactVy in Mascot.svelte; vx keeps // decaying in the air for the second descent. rt.bounceCount++ rt.vy = -impact * BOUNCE_RESTITUTION rt.impactVy = impact * 0.8 rt.squashAt = now } else { rt.vy = 0 rt.impactVy = impact rt.squashAt = now // Carry real sideways momentum into a short friction skid // instead of the old dead stop. const av = Math.abs(rt.vx) rt.vx = av >= SKID_MIN_VX ? Math.sign(rt.vx) * Math.min(av, SKID_ENTRY_MAX) * SKID_KEEP : 0 forceBehavior(rt, 'land', { durationMs: LAND_MS }) } } break } case 'hop': { // A small forward hop — plain projectile integration, no flapping // (too short). If the ground drops out mid-hop (it hopped off a // window edge), hand off to a real fall. rt.vy += GRAVITY * dts rt.x += rt.vx * dts rt.y += rt.vy * dts const gy = groundY(rt) if (rt.vy > 0 && gy - rt.y > 48) { forceBehavior(rt, 'falling') } else if (rt.y >= gy && rt.vy > 0) { rt.y = gy rt.impactVy = rt.vy rt.squashAt = now rt.vy = 0 rt.vx = 0 forceBehavior(rt, 'idle') } break } case 'land': { // Skid: leftover horizontal momentum from a sideways touchdown // (set in the falling→land transition above) decays under friction. if (rt.vx !== 0) { rt.x += rt.vx * dts const dec = SKID_FRICTION * dts rt.vx = Math.abs(rt.vx) <= dec ? 0 : rt.vx - Math.sign(rt.vx) * dec } 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). dragged, // falling, and hop manage their own exits (pointerup / touchdown). if (rt.behavior === 'dragged' || rt.behavior === 'falling' || rt.behavior === 'hop') 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' } /** * Release from a drag: land immediately if already at/below ground, or * start falling otherwise. `rt.vx`/`rt.vy` are expected to already hold the * release's toss velocity (set by Mascot.svelte's onPointerUp from recent * pointer-move samples) — they're carried into `falling`, not reset here. */ export function releaseFromDrag(rt: MascotRuntime): void { const gy = groundY(rt) if (rt.y >= gy) { rt.y = gy rt.vy = 0 rt.vx = 0 rt.impactVy = 0 // set down gently — no squash spring, no feather poof rt.squashAt = performance.now() forceBehavior(rt, 'land', { durationMs: LAND_MS }) } else { 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) }