diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/docs/mascot/README.md b/docs/mascot/README.md index 7e3dfbc..d0404f8 100644 --- a/docs/mascot/README.md +++ b/docs/mascot/README.md @@ -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 diff --git a/plans/2026-07-20-mascot-physics-audit.md b/plans/2026-07-20-mascot-physics-audit.md index 195912d..16959ac 100644 --- a/plans/2026-07-20-mascot-physics-audit.md +++ b/plans/2026-07-20-mascot-physics-audit.md @@ -1,6 +1,13 @@ # 2026-07-20 — Mascot physics/window-interaction audit + improvement plan -**Status:** P0–P2 implemented. P3 ("cool stuff") ideas remain open/undecided. +**Status:** P0–P2 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): diff --git a/web/src/lib/mascot/Mascot.svelte b/web/src/lib/mascot/Mascot.svelte index b8d64cd..7225d26 100644 --- a/web/src/lib/mascot/Mascot.svelte +++ b/web/src/lib/mascot/Mascot.svelte @@ -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(null) let ctx2d: CanvasRenderingContext2D | null = null let timer: ReturnType | 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( + 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 = { + 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} -
+ {#key runtime.bubbleText}
- {runtime.bubbleText} +
+ {typedText}{#if !typingDone}{/if} +
+
-
-
+ {/key} {:else}
{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; + } + } diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte index 7386ef4..a830cd9 100644 --- a/web/src/lib/mascot/MascotLayer.svelte +++ b/web/src/lib/mascot/MascotLayer.svelte @@ -52,6 +52,10 @@ bubbleUntil: 0, blinkUntil: 0, fallPhaseAt: 0, + flapCycleMs: 0, + squashAt: 0, + impactVy: 0, + bounceCount: 0, investigateOnLand: false }) diff --git a/web/src/lib/mascot/behavior.ts b/web/src/lib/mascot/behavior.ts index 1ec5fc2..5778551 100644 --- a/web/src/lib/mascot/behavior.ts +++ b/web/src/lib/mascot/behavior.ts @@ -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 = { 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 = { // 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') diff --git a/web/src/lib/mascot/types.ts b/web/src/lib/mascot/types.ts index 8a49406..61867eb 100644 --- a/web/src/lib/mascot/types.ts +++ b/web/src/lib/mascot/types.ts @@ -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()` —