fix(web): mascot physics, drag reliability, and speech-bubble polish
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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user