feat(web): mascot physics juice (bounce, skid, spring squash, hop) + typewriter speech bubble
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,7 +768,10 @@
|
||||
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
|
||||
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'}"
|
||||
>
|
||||
<div
|
||||
bind:this={bubbleTrackEl}
|
||||
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
|
||||
@@ -586,10 +782,11 @@
|
||||
style={marqueeDistance !== 0
|
||||
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
|
||||
: ''}
|
||||
>{runtime.bubbleText}</span>
|
||||
>{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"></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>
|
||||
{/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>
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
bubbleUntil: 0,
|
||||
blinkUntil: 0,
|
||||
fallPhaseAt: 0,
|
||||
flapCycleMs: 0,
|
||||
squashAt: 0,
|
||||
impactVy: 0,
|
||||
bounceCount: 0,
|
||||
investigateOnLand: false
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()` —
|
||||
|
||||
Reference in New Issue
Block a user