fix(web): mascot physics, drag reliability, and speech-bubble polish
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

Audits and fixes ground-teleport/flat-fall/toss-momentum physics bugs,
fixes drag getting stuck via missing pointercancel handling, replaces
sprite-based speech bubbles with real HTML text/emoji bubbles, adds
drag-onto-icon "investigate" reactions and idle chatter, merges the
name badge and reaction bubble into one floating element, and caps the
bubble to one line with a teleprompter-style auto-scroll instead of
ellipsizing overflow text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:23:30 +02:00
parent 7b1dfbc8aa
commit d82095213a
16 changed files with 929 additions and 140 deletions

View File

@@ -29,6 +29,26 @@ 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
// Falling is a series of glide/flap sub-phases, not a flat monotonic drop:
// every FLAP_CYCLE_MS, 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. 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_MS = 550
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
// 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
@@ -38,6 +58,29 @@ 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 {
@@ -119,6 +162,14 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
},
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.
@@ -185,9 +236,15 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
falling: {
id: 'falling',
anim: () => 'fall-flutter',
// 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.vx = 0
rt.fallPhaseAt = 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).
@@ -203,7 +260,17 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
tick: () => {
// Brief squash animation.
},
next: () => 'idle',
// 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
},
@@ -295,12 +362,25 @@ export function stepMascot(
break
}
case 'falling': {
// Wing-beat: every FLAP_CYCLE_MS, cut the descent speed sharply —
// a real (if losing) attempt at flight rather than a flat drop.
if (now - rt.fallPhaseAt >= FLAP_CYCLE_MS) {
rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE)
rt.fallPhaseAt = now
}
rt.vy = Math.min(TERMINAL_VY, rt.vy + GRAVITY * 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
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
rt.vx = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
}
break
@@ -358,15 +438,20 @@ 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. */
/**
* 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
forceBehavior(rt, 'land', { durationMs: LAND_MS })
} else {
rt.vy = 0
forceBehavior(rt, 'falling')
}
}