Files
oikos/web/src/lib/mascot/behavior.ts
dtoro e5a81241b7
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
feat(web): operator questions inline in chat, mascot reactions scoped to the focused task
The pending operator-question card now renders inline in ChatThread (the
newest thing in the conversation) instead of in the context rail — it's
part of the chat, not a separate side panel, and the panel's hasContext
gate no longer needs to special-case it.

The desktop mascot's reactions are now entirely about whichever task
window has focus, not fleet-wide events: thinking/talking is a new
continuous `busy` behavior that tracks the focused session's own
streaming state (thinking before any text arrives, talking once it
does — using the previously-unwired peep/talk sprite), eureka fires with
the actual knowledge title that was recorded, happy fires with the
task's own completion summary, and alarmed now means "this task needs
your OK" (an operator question was raised) rather than a fleet-wide
critical/signal event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:46:46 +02:00

641 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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. 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)
}