feat(web): mascot physics juice (bounce, skid, spring squash, hop) + typewriter speech bubble
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

This commit is contained in:
2026-07-21 00:06:44 +02:00
parent d82095213a
commit eb3d2de1ca
7 changed files with 509 additions and 50 deletions

View File

@@ -14,10 +14,15 @@
//
// 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.
// 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'
@@ -26,22 +31,52 @@ import type { MascotModel } from './state.svelte'
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 FLAP_CYCLE_MS, a wing-beat impulse briefly cuts the descent speed
// 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. A gentle sine wobble adds
// horizontal drift so the fall isn't perfectly vertical either. See
// 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_MS = 550
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
@@ -116,6 +151,19 @@ function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
// ─── 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,
@@ -211,6 +259,26 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
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',
@@ -240,7 +308,8 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
// glide the rest of the cycle.
anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
enter: (rt) => {
rt.fallPhaseAt = performance.now()
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
@@ -362,13 +431,20 @@ 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) {
// 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)
rt.fallPhaseAt = now
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)
}
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.
@@ -376,12 +452,78 @@ export function stepMascot(
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)
if (rt.y >= gy) {
// 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, 'land', { durationMs: LAND_MS })
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
}
@@ -409,8 +551,9 @@ export function stepMascot(
}
}
// Transition: only self-expiring behaviors (next() consult).
if (rt.behavior === 'dragged' || rt.behavior === 'falling') return
// 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)
@@ -450,6 +593,8 @@ export function releaseFromDrag(rt: MascotRuntime): void {
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')