feat(web): mascot physics juice (bounce, skid, spring squash, hop) + typewriter speech bubble
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user