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

@@ -1 +1 @@
0.8.0
0.9.0

View File

@@ -16,13 +16,13 @@
**Status of this Model:** the subsystem it describes is **implemented**
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
Views below are marked **Implemented** where the code matches; a small
number of requirements (distinct adult art, reaction speech-bubble
rendering, a squash frame on `land`) remain **Planned** as polish
items. The corresponding implementation plan is
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
number of requirements (distinct adult art, a true round radial menu)
remain **Planned** as polish items. The corresponding implementation plan
is [plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
which carries a deviation note at the top covering the changes made
during implementation (hatch-on-naming, PNG-sheet art, button-column
radial menu, 60fps loop).
radial menu, 60fps loop), and the physics audit/follow-up is
[plans/2026-07-20-mascot-physics-audit.md](../../plans/2026-07-20-mascot-physics-audit.md).
## Views in this model
@@ -94,7 +94,7 @@ Traced from the original feature request. Status reflects the
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name; the egg → chick transition fires on first naming, not on a timed incubation | User request | Implemented |
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Implemented |
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Implemented |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented (bubble overlay rendering still Planned) |
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented |
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Implemented |
| MASC-10 (NFR) | The mascot's game loop SHALL run via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings); runs at ~60fps (relaxed from 30fps for smoother drag/fall — see plan deviation note) | Codebase convention | Implemented |
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Implemented |
@@ -220,14 +220,17 @@ stateDiagram-v2
wander --> idle
idle --> peck : weighted random pick
peck --> idle
idle --> hop : weighted random pick
hop --> idle : touchdown\n(off-edge mid-hop hands to falling)
idle --> sleep : weighted random pick
sleep --> idle
wander --> falling : y below ground\n(off a dragged edge, etc.)
idle --> dragged : pointerdown + move\npast 5px threshold
wander --> dragged : pointerdown + move
sleep --> dragged : pointerdown + move\n(interrupts sleep)
dragged --> falling : pointerup, released mid-air
falling --> land : y reaches ground
dragged --> falling : pointerup, released mid-air\n(toss velocity from pointer history)
falling --> falling : hard impact\n(one diminished bounce)
falling --> land : y reaches ground\n(sideways momentum -> skid)
land --> idle
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
@@ -244,6 +247,19 @@ returns null past `behaviorUntil` — see
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
for the concrete weights.
**Physics feel (implemented 2026-07-20, second pass):** the fall is a
losing attempt at flight, not a drop — wing-beat impulses on a
speed-scaled, jittered flap cycle (panic flapping) shave the descent;
falls faster than terminal velocity (hard downward tosses) decay back
under drag instead of clamping; hard impacts bounce once, squash via a
damped-spring render layer scaled by impact speed, and poof a burst of
feather pixels; sideways momentum becomes a friction skid on touchdown
and ricochets off the surface's side bounds mid-fall; the sprite
stretches along its motion in the air and tilts into horizontal velocity
(fall, drag, and skid); walking bobs at step frequency. All of it is
tuning in `behavior.ts` plus the pure render layer in `Mascot.svelte`'s
`updateJuice()` — no new assets, no new states beyond `hop`.
### 4.2 Tamagotchi lifecycle (long-lived state)
```mermaid

View File

@@ -1,6 +1,13 @@
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
**Status:** P0P2 implemented. P3 ("cool stuff") ideas remain open/undecided.
**Status:** P0P2 implemented. P3 partially implemented: the physics-feel
round shipped (panic-flap cycle with speed scaling + jitter, soft
terminal-velocity drag, one-bounce impact restitution, landing skid, wall
ricochet, squash-and-stretch impact spring, air/drag tilt, walk bob,
impact feather-poof particles, and a new idle-selectable `hop` behavior —
all in `behavior.ts` + `Mascot.svelte`'s render layer, no new assets).
The remaining P3 feature ideas (investigate badges, startle-and-flee, a
"home" spot, round radial menu v2, distinct adult art) stay open.
**Verification of the fixes** (re-ran this document's own instrumented
tests against the fixed code):

View File

@@ -86,6 +86,26 @@
const DT_CLAMP_MS = 100
const LIFECYCLE_TICK_MS = 1000
// ── Juice (render-layer feel) ────────────────────────────────────────
// None of this touches the physics state — it only reshapes how the
// physics READS on screen. Computed each tick into the juice* $state
// below and composed into the sprite's CSS transform / drawn onto the
// canvas:
// - Squash-and-stretch: a damped-spring squash on every ground impact
// (depth scaled by impactVy), plus an in-air stretch proportional
// to fall speed (volume roughly preserved: sx opposes sy).
// - Air tilt: the sprite leans into horizontal motion while falling,
// being dragged, or skidding.
// - Walk bob: a small vertical step bounce while wandering.
// - Feather poof: a burst of tiny pixels on hard impacts.
const SPRING_WINDOW_S = 0.45 // how long the impact squash spring oscillates
const SPRING_FREQ_HZ = 3
const SPRING_DECAY = 6.5
const MAX_SQUASH_DEPTH = 0.32
const POOF_MIN_VY = 280 // impact speed below which no feather/dust poof spawns
const FEATHER_GRAVITY = 550 // px/s^2 — floaty, lighter than the mascot
const FEATHER_TINTS = ['#ffffff', '#fef3c7', '#fde68a']
let canvas = $state<HTMLCanvasElement | null>(null)
let ctx2d: CanvasRenderingContext2D | null = null
let timer: ReturnType<typeof setTimeout> | 0 = 0
@@ -101,6 +121,27 @@
let dragSamples: { t: number; x: number; y: number }[] = []
// Wiggle phase for the egg (render-time only, not persisted).
let wigglePhase = 0
// Juice render state, recomputed every tick (see the constants above).
let juiceSx = $state(1)
let juiceSy = $state(1)
let juiceTilt = $state(0) // deg
let juiceBob = $state(0) // px, <= 0 (raises the sprite)
let lastDtMs = 16 // last tick's dt, reused by the feather integrator in renderSprite
// Feather-poof particles: plain array, integrated + painted imperatively
// on the canvas (no reactivity needed). Positions are surface coords so
// the poof hangs where the impact happened, not on the sprite.
interface Feather {
x: number
y: number
vx: number
vy: number
born: number
life: number
size: number
tint: string
}
let feathers: Feather[] = []
let lastSquashAt = 0 // last squashAt we spawned a poof for
// Snapshot of the current window manager state, refreshed by
// subscription. Read inside the tick to compute the ground line at the
// mascot's x — the highest non-minimized window top edge beneath it,
@@ -206,7 +247,7 @@
// solid "ride along"; anything bigger (a window snapping to a new
// position, a resize) catches up over a couple of frames instead
// of jumping.
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged') {
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' && runtime.behavior !== 'hop') {
if (newGround - prevGroundY > GROUND_DROP_FALL_PX && runtime.y >= prevGroundY - 1) {
forceBehavior(runtime, 'falling')
} else if (runtime.y !== newGround) {
@@ -217,6 +258,8 @@
runtime.groundY = newGround
prevGroundY = newGround
stepMascot(runtime, model, now, dt)
lastDtMs = dt
updateJuice(now, dt)
// Reaction bubble expiry: the bubble is a real DOM element now (see
// the template), driven reactively by bubbleText/bubbleUntil — this
// is the one place that clears it once its time is up.
@@ -224,6 +267,12 @@
runtime.bubbleText = null
runtime.bubbleUntil = 0
}
// Typewriter advance (the reset on line change lives in an effect —
// see the "Speech-bubble feel" block below).
if (runtime.bubbleText && typedLen < bubbleLen) {
const perChar = Math.min(TYPE_CHAR_MS, TYPE_MAX_MS / bubbleLen)
typedLen = Math.min(bubbleLen, typedLen + dt / perChar)
}
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
if (lastLifecycle === 0) lastLifecycle = now
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
@@ -240,6 +289,69 @@
}
}
/**
* Recompute the render-layer feel (see the "Juice" constants above):
* the impact squash spring / in-air stretch, the air tilt, the walk
* bob, and the feather-poof spawn. Runs every tick, after stepMascot,
* so it always reads the freshest physics state. Pure render layer —
* it never writes back into the physics.
*/
function updateJuice(now: number, dt: number): void {
// Squash-and-stretch. The spring wins near an impact; otherwise an
// airborne body stretches along its motion.
const st = (now - runtime.squashAt) / 1000
if (runtime.squashAt > 0 && st < SPRING_WINDOW_S) {
const depth = Math.min(MAX_SQUASH_DEPTH, runtime.impactVy / 1100)
const s = depth * Math.exp(-SPRING_DECAY * st) * Math.cos(st * Math.PI * 2 * SPRING_FREQ_HZ)
juiceSx = 1 + s * 0.75
juiceSy = 1 - s
} else if (runtime.behavior === 'falling' || runtime.behavior === 'hop') {
const stretch = Math.min(0.15, Math.abs(runtime.vy) / 1600)
juiceSx = 1 - stretch * 0.6
juiceSy = 1 + stretch
} else {
juiceSx = 1
juiceSy = 1
}
// Air tilt: lean into horizontal motion (falling toss, mid-drag swing,
// landing skid). Smoothed so it eases in/out instead of snapping.
let tiltTarget = 0
if (runtime.behavior === 'falling') {
tiltTarget = Math.max(-14, Math.min(14, runtime.vx * 0.02))
} else if (runtime.behavior === 'dragged' && moved && dragSamples.length > 1) {
tiltTarget = Math.max(-16, Math.min(16, tossVelocity().vx * 0.018))
} else if (runtime.behavior === 'land') {
tiltTarget = Math.max(-8, Math.min(8, runtime.vx * 0.012))
}
juiceTilt += (tiltTarget - juiceTilt) * Math.min(1, dt / 110)
// Walk bob: two footsteps per walk-anim loop (4 frames @ 8fps = 500ms).
juiceBob =
runtime.behavior === 'wander' ? -Math.abs(Math.sin((now * Math.PI) / 250)) * 1.5 : 0
// Feather poof: a new, hard-enough impact spawns a burst at the feet.
if (runtime.squashAt !== lastSquashAt) {
lastSquashAt = runtime.squashAt
if (runtime.impactVy >= POOF_MIN_VY) spawnFeathers(runtime.impactVy)
}
}
/** Burst of tiny feather/dust pixels at the mascot's feet; count scales with impact speed. */
function spawnFeathers(impact: number): void {
const n = Math.min(9, 4 + Math.floor(impact / 90))
const now = performance.now()
for (let i = 0; i < n; i++) {
feathers.push({
x: runtime.x + (Math.random() - 0.5) * 8,
y: runtime.y - Math.random() * 2,
vx: (Math.random() - 0.5) * 140,
vy: -(40 + Math.random() * 120),
born: now,
life: 450 + Math.random() * 300,
size: Math.random() < 0.5 ? 1 : 2,
tint: FEATHER_TINTS[Math.floor(Math.random() * FEATHER_TINTS.length)]
})
}
}
function renderSprite(): void {
if (!canvas || !ctx2d) return
const now = performance.now()
@@ -258,6 +370,29 @@
facing: runtime.facing,
wiggle: wigglePhase
})
// Feather poof: integrate + paint after the sprite frame (drawFrame
// clears the canvas each frame). Canvas coords: the sprite's feet are
// at (CANVAS_W/2, CANVAS_H), so a particle's offset from the mascot is
// all that's needed — the canvas itself is positioned at the mascot.
if (feathers.length > 0) {
const dtS = lastDtMs / 1000
feathers = feathers.filter((f) => now - f.born < f.life)
for (const f of feathers) {
f.vy += FEATHER_GRAVITY * dtS
f.vx *= Math.max(0, 1 - 2.5 * dtS)
f.x += f.vx * dtS
f.y += f.vy * dtS
ctx2d.globalAlpha = Math.max(0, 1 - (now - f.born) / f.life)
ctx2d.fillStyle = f.tint
ctx2d.fillRect(
Math.round(CANVAS_W / 2 + (f.x - runtime.x)),
Math.round(CANVAS_H - (runtime.y - f.y)),
f.size,
f.size
)
}
ctx2d.globalAlpha = 1
}
}
// Single loop: tick physics + draw sprite + reschedule. (The previous
@@ -471,8 +606,11 @@
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
// position via `transform: translate3d` (compositor-friendly, no
// layout reflow) rather than `left`/`top` so motion stays smooth at
// high refresh rates. A squash transform is composed in during the
// `land` behavior (brief scaleY(0.82)) to sell the impact.
// high refresh rates. Composed on top of the translation are the juice
// channels (see updateJuice): the squash-and-stretch impact spring /
// in-air stretch (scale), the air tilt (rotate), and the walk bob
// (added to ty). transform-origin is bottom center, so squashes and
// tilts pivot at the feet.
//
// `ty` is clamped to >= 0: the canvas reserves headroom above the
// sprite for the reaction bubble, and when the mascot's ground is near
@@ -505,8 +643,9 @@
const PILL_HEIGHT = 18
const BUBBLE_HEIGHT = 40 // card + its downward-pointing tail
const tagY = $derived(Math.max(0, spriteTopY - TAG_GAP - (runtime.bubbleText ? BUBBLE_HEIGHT : PILL_HEIGHT)))
const squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
const transform = $derived(
`translate3d(${tx}px, ${ty + juiceBob}px, 0) rotate(${juiceTilt}deg) scale(${juiceSx}, ${juiceSy})`
)
// Bubble text marquee: the bubble is capped at one line (no wrap) and a
// fixed max width, so text longer than that would normally need
@@ -521,12 +660,66 @@
let marqueeDuration = $state(0)
const MARQUEE_PX_PER_S = 45 // travel speed while sliding (excludes the hold time at each end)
// ── Speech-bubble feel ───────────────────────────────────────────────
// Typewriter reveal (advanced in tick), pop-in spring entrance, a
// blinking block cursor while typing, and per-reaction tone colors.
// Pure presentation — the underlying state is still just
// bubbleText/bubbleUntil.
const TYPE_CHAR_MS = 28 // per-character typewriter pace…
const TYPE_MAX_MS = 1400 // …but long lines speed up so typing always fits the display window
let typedLen = $state(0)
$effect(() => {
// Re-measure whenever the visible bubble text changes.
// Restart the typewriter whenever the line changes. Svelte flushes
// effects before paint, so a new line never flashes fully-typed for
// a frame.
void runtime.bubbleText
typedLen = 0
})
// Code-point count/slicing: emoji are multi-unit UTF-16 ('❤️' is 2),
// and slicing mid-surrogate renders a broken glyph for a frame.
const bubbleLen = $derived(runtime.bubbleText ? Array.from(runtime.bubbleText).length : 0)
const typingDone = $derived(!runtime.bubbleText || typedLen >= bubbleLen)
const typedText = $derived(
runtime.bubbleText
? Array.from(runtime.bubbleText)
.slice(0, Math.ceil(typedLen))
.join('')
: null
)
// Tiny lines (a lone emoji) get emphasized with a bigger size.
const isShortBubble = $derived(bubbleLen > 0 && bubbleLen <= 2)
// Tone: color the bubble by the reaction currently playing (the field
// is reactAnim — 'react-alarm' → 'alarm'). Only while the react
// behavior is active, so a stale reactAnim doesn't tint later bubbles
// (icon-investigate, idle chatter).
type BubbleTone = 'alarm' | 'eureka' | 'think' | 'happy'
const bubbleTone = $derived<BubbleTone | null>(
runtime.behavior === 'react' && runtime.reactAnim?.startsWith('react-')
? (runtime.reactAnim.slice(6) as BubbleTone)
: null
)
// card = full treatment (border + text + shadow tint); border = just
// the border color, for the little tail diamond.
const TONE: Record<BubbleTone, { card: string; border: string }> = {
alarm: { card: 'border-red-400/70 text-red-500 dark:text-red-300 shadow-red-500/25', border: 'border-red-400/70' },
eureka: { card: 'border-amber-400/70 text-amber-600 dark:text-amber-300 shadow-amber-500/25', border: 'border-amber-400/70' },
think: { card: 'border-sky-400/60 text-sky-600 dark:text-sky-300', border: 'border-sky-400/60' },
happy: { card: 'border-rose-400/70 text-rose-500 dark:text-rose-300 shadow-rose-500/25', border: 'border-rose-400/70' }
}
$effect(() => {
// Re-measure whenever the visible bubble text changes, and again when
// the typewriter finishes — while typing, the marquee stays off (the
// partially-typed line would measure wrong and start sliding
// mid-reveal).
void runtime.bubbleText
void typingDone
const track = bubbleTrackEl
const textEl = bubbleTextEl
if (!track || !textEl) {
if (!track || !textEl || !typingDone) {
marqueeDistance = 0
marqueeDuration = 0
return
@@ -575,21 +768,25 @@
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
>
{#if runtime.bubbleText}
<div class="relative max-w-40 rounded-xl border bg-popover px-2.5 py-1 text-sm leading-none text-popover-foreground shadow-md">
{#key runtime.bubbleText}
<div
bind:this={bubbleTrackEl}
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
class="bubble-pop relative max-w-40 rounded-xl border bg-popover px-2.5 py-1 leading-[1.25] shadow-md {bubbleTone ? TONE[bubbleTone].card : 'text-popover-foreground'} {bubbleTone === 'alarm' ? 'bubble-shake' : ''} {isShortBubble ? 'text-base' : 'text-sm'}"
>
<span
bind:this={bubbleTextEl}
class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
style={marqueeDistance !== 0
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
: ''}
>{runtime.bubbleText}</span>
<div
bind:this={bubbleTrackEl}
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
>
<span
bind:this={bubbleTextEl}
class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
style={marqueeDistance !== 0
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
: ''}
>{typedText}{#if !typingDone}<span class="bubble-cursor"></span>{/if}</span>
</div>
<div class="absolute -bottom-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-r border-b bg-popover {bubbleTone ? TONE[bubbleTone].border : ''}"></div>
</div>
<div class="absolute -bottom-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-r border-b bg-popover"></div>
</div>
{/key}
{:else}
<div class="whitespace-nowrap rounded-full bg-popover/40 px-1.5 py-0.5 text-[10px] font-normal text-popover-foreground/75">
{model.name}
@@ -620,4 +817,71 @@
transform: translateX(0);
}
}
/* Pop-in entrance: fast overshoot-and-settle spring, pivoting at the
tail (bottom center) so the bubble grows out of the mascot's head.
Replays on every line change via the {#key} block in the template. */
.bubble-pop {
animation: bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
transform-origin: bottom center;
}
@keyframes bubble-pop {
0% {
transform: scale(0.3);
opacity: 0;
}
55% {
transform: scale(1.1);
opacity: 1;
}
75% {
transform: scale(0.97);
}
100% {
transform: scale(1);
}
}
/* Alarm shake: runs right after the pop finishes (the two animations
stack on the same element — the later shake wins on transform while
it runs, then the pop's settled scale(1) fill takes back over). */
.bubble-shake {
animation:
bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both,
bubble-shake 380ms 360ms ease-in-out;
}
@keyframes bubble-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-3px);
}
40% {
transform: translateX(3px);
}
60% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
}
/* Blinking block cursor shown while the typewriter is mid-line. */
.bubble-cursor {
display: inline-block;
width: 2px;
height: 1em;
margin-left: 1px;
vertical-align: text-bottom;
background: currentColor;
animation: bubble-caret 0.75s steps(1) infinite;
}
@keyframes bubble-caret {
50% {
opacity: 0;
}
}
</style>

View File

@@ -52,6 +52,10 @@
bubbleUntil: 0,
blinkUntil: 0,
fallPhaseAt: 0,
flapCycleMs: 0,
squashAt: 0,
impactVy: 0,
bounceCount: 0,
investigateOnLand: false
})

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')

View File

@@ -40,7 +40,7 @@ export interface AnimDef {
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
export type BehaviorId =
| 'egg' | 'idle' | 'wander' | 'peck' | 'sleep'
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
| 'dragged' | 'falling' | 'land' | 'react'
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
@@ -123,10 +123,33 @@ export interface MascotRuntime {
blinkUntil: number
/**
* performance.now() ms marking the start of the current flap/glide
* sub-phase within a `falling` behavior — see FLAP_CYCLE_MS/FLAP_BURST_MS
* in behavior.ts. Reset each time `falling` is entered.
* sub-phase within a `falling` behavior — see flapCycleMs below and
* scheduleFlap() in behavior.ts. Reset each time `falling` is entered.
*/
fallPhaseAt: number
/**
* Current flap-cycle length (ms) while `falling`: the interval between
* wing-beat impulses. Dynamically shortened by descent speed (panic
* flapping) plus random jitter so the rhythm never reads metronomic.
*/
flapCycleMs: number
/**
* performance.now() ms of the last ground impact (a landing or a
* bounce contact). Drives the squash-and-stretch impact spring in
* Mascot.svelte and the feather-poof particle spawn. 0 = never landed.
*/
squashAt: number
/**
* Descent speed (px/s) at the moment of the last ground impact —
* scales the squash-spring depth and the feather-poof size.
* 0 = a gentle set-down (no squash, no poof).
*/
impactVy: number
/**
* Bounces so far in the current fall. Reset to 0 by `falling.enter()`;
* capped by BOUNCE_MAX in behavior.ts.
*/
bounceCount: number
/**
* Set (by Mascot.svelte's onPointerUp) when a drag is released on top of
* a desktop icon. Consumed once by the `land` BehaviorDef's `next()` —