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**
|
**Status of this Model:** the subsystem it describes is **implemented**
|
||||||
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
|
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
|
Views below are marked **Implemented** where the code matches; a small
|
||||||
number of requirements (distinct adult art, reaction speech-bubble
|
number of requirements (distinct adult art, a true round radial menu)
|
||||||
rendering, a squash frame on `land`) remain **Planned** as polish
|
remain **Planned** as polish items. The corresponding implementation plan
|
||||||
items. The corresponding implementation plan is
|
is [plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
|
||||||
[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
|
which carries a deviation note at the top covering the changes made
|
||||||
during implementation (hatch-on-naming, PNG-sheet art, button-column
|
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
|
## 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-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-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-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-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-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 |
|
| 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
|
wander --> idle
|
||||||
idle --> peck : weighted random pick
|
idle --> peck : weighted random pick
|
||||||
peck --> idle
|
peck --> idle
|
||||||
|
idle --> hop : weighted random pick
|
||||||
|
hop --> idle : touchdown\n(off-edge mid-hop hands to falling)
|
||||||
idle --> sleep : weighted random pick
|
idle --> sleep : weighted random pick
|
||||||
sleep --> idle
|
sleep --> idle
|
||||||
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
||||||
idle --> dragged : pointerdown + move\npast 5px threshold
|
idle --> dragged : pointerdown + move\npast 5px threshold
|
||||||
wander --> dragged : pointerdown + move
|
wander --> dragged : pointerdown + move
|
||||||
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
||||||
dragged --> falling : pointerup, released mid-air
|
dragged --> falling : pointerup, released mid-air\n(toss velocity from pointer history)
|
||||||
falling --> land : y reaches ground
|
falling --> falling : hard impact\n(one diminished bounce)
|
||||||
|
falling --> land : y reaches ground\n(sideways momentum -> skid)
|
||||||
land --> idle
|
land --> idle
|
||||||
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
||||||
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
|
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)
|
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||||
for the concrete weights.
|
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)
|
### 4.2 Tamagotchi lifecycle (long-lived state)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
|
# 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
|
**Verification of the fixes** (re-ran this document's own instrumented
|
||||||
tests against the fixed code):
|
tests against the fixed code):
|
||||||
|
|||||||
@@ -86,6 +86,26 @@
|
|||||||
const DT_CLAMP_MS = 100
|
const DT_CLAMP_MS = 100
|
||||||
const LIFECYCLE_TICK_MS = 1000
|
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 canvas = $state<HTMLCanvasElement | null>(null)
|
||||||
let ctx2d: CanvasRenderingContext2D | null = null
|
let ctx2d: CanvasRenderingContext2D | null = null
|
||||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||||
@@ -101,6 +121,27 @@
|
|||||||
let dragSamples: { t: number; x: number; y: number }[] = []
|
let dragSamples: { t: number; x: number; y: number }[] = []
|
||||||
// Wiggle phase for the egg (render-time only, not persisted).
|
// Wiggle phase for the egg (render-time only, not persisted).
|
||||||
let wigglePhase = 0
|
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
|
// Snapshot of the current window manager state, refreshed by
|
||||||
// subscription. Read inside the tick to compute the ground line at the
|
// subscription. Read inside the tick to compute the ground line at the
|
||||||
// mascot's x — the highest non-minimized window top edge beneath it,
|
// 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
|
// solid "ride along"; anything bigger (a window snapping to a new
|
||||||
// position, a resize) catches up over a couple of frames instead
|
// position, a resize) catches up over a couple of frames instead
|
||||||
// of jumping.
|
// 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) {
|
if (newGround - prevGroundY > GROUND_DROP_FALL_PX && runtime.y >= prevGroundY - 1) {
|
||||||
forceBehavior(runtime, 'falling')
|
forceBehavior(runtime, 'falling')
|
||||||
} else if (runtime.y !== newGround) {
|
} else if (runtime.y !== newGround) {
|
||||||
@@ -217,6 +258,8 @@
|
|||||||
runtime.groundY = newGround
|
runtime.groundY = newGround
|
||||||
prevGroundY = newGround
|
prevGroundY = newGround
|
||||||
stepMascot(runtime, model, now, dt)
|
stepMascot(runtime, model, now, dt)
|
||||||
|
lastDtMs = dt
|
||||||
|
updateJuice(now, dt)
|
||||||
// Reaction bubble expiry: the bubble is a real DOM element now (see
|
// Reaction bubble expiry: the bubble is a real DOM element now (see
|
||||||
// the template), driven reactively by bubbleText/bubbleUntil — this
|
// the template), driven reactively by bubbleText/bubbleUntil — this
|
||||||
// is the one place that clears it once its time is up.
|
// is the one place that clears it once its time is up.
|
||||||
@@ -224,6 +267,12 @@
|
|||||||
runtime.bubbleText = null
|
runtime.bubbleText = null
|
||||||
runtime.bubbleUntil = 0
|
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.
|
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
|
||||||
if (lastLifecycle === 0) lastLifecycle = now
|
if (lastLifecycle === 0) lastLifecycle = now
|
||||||
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
|
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 {
|
function renderSprite(): void {
|
||||||
if (!canvas || !ctx2d) return
|
if (!canvas || !ctx2d) return
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
@@ -258,6 +370,29 @@
|
|||||||
facing: runtime.facing,
|
facing: runtime.facing,
|
||||||
wiggle: wigglePhase
|
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
|
// Single loop: tick physics + draw sprite + reschedule. (The previous
|
||||||
@@ -471,8 +606,11 @@
|
|||||||
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
|
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
|
||||||
// position via `transform: translate3d` (compositor-friendly, no
|
// position via `transform: translate3d` (compositor-friendly, no
|
||||||
// layout reflow) rather than `left`/`top` so motion stays smooth at
|
// layout reflow) rather than `left`/`top` so motion stays smooth at
|
||||||
// high refresh rates. A squash transform is composed in during the
|
// high refresh rates. Composed on top of the translation are the juice
|
||||||
// `land` behavior (brief scaleY(0.82)) to sell the impact.
|
// 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
|
// `ty` is clamped to >= 0: the canvas reserves headroom above the
|
||||||
// sprite for the reaction bubble, and when the mascot's ground is near
|
// sprite for the reaction bubble, and when the mascot's ground is near
|
||||||
@@ -505,8 +643,9 @@
|
|||||||
const PILL_HEIGHT = 18
|
const PILL_HEIGHT = 18
|
||||||
const BUBBLE_HEIGHT = 40 // card + its downward-pointing tail
|
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 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(
|
||||||
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
|
`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
|
// 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
|
// fixed max width, so text longer than that would normally need
|
||||||
@@ -521,12 +660,66 @@
|
|||||||
let marqueeDuration = $state(0)
|
let marqueeDuration = $state(0)
|
||||||
const MARQUEE_PX_PER_S = 45 // travel speed while sliding (excludes the hold time at each end)
|
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(() => {
|
$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
|
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 track = bubbleTrackEl
|
||||||
const textEl = bubbleTextEl
|
const textEl = bubbleTextEl
|
||||||
if (!track || !textEl) {
|
if (!track || !textEl || !typingDone) {
|
||||||
marqueeDistance = 0
|
marqueeDistance = 0
|
||||||
marqueeDuration = 0
|
marqueeDuration = 0
|
||||||
return
|
return
|
||||||
@@ -575,21 +768,25 @@
|
|||||||
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
|
style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
|
||||||
>
|
>
|
||||||
{#if runtime.bubbleText}
|
{#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
|
<div
|
||||||
bind:this={bubbleTrackEl}
|
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'}"
|
||||||
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
|
|
||||||
>
|
>
|
||||||
<span
|
<div
|
||||||
bind:this={bubbleTextEl}
|
bind:this={bubbleTrackEl}
|
||||||
class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
|
class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
|
||||||
style={marqueeDistance !== 0
|
>
|
||||||
? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
|
<span
|
||||||
: ''}
|
bind:this={bubbleTextEl}
|
||||||
>{runtime.bubbleText}</span>
|
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>
|
||||||
<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>
|
{/key}
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="whitespace-nowrap rounded-full bg-popover/40 px-1.5 py-0.5 text-[10px] font-normal text-popover-foreground/75">
|
<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}
|
{model.name}
|
||||||
@@ -620,4 +817,71 @@
|
|||||||
transform: translateX(0);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -52,6 +52,10 @@
|
|||||||
bubbleUntil: 0,
|
bubbleUntil: 0,
|
||||||
blinkUntil: 0,
|
blinkUntil: 0,
|
||||||
fallPhaseAt: 0,
|
fallPhaseAt: 0,
|
||||||
|
flapCycleMs: 0,
|
||||||
|
squashAt: 0,
|
||||||
|
impactVy: 0,
|
||||||
|
bounceCount: 0,
|
||||||
investigateOnLand: false
|
investigateOnLand: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,15 @@
|
|||||||
//
|
//
|
||||||
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
|
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
|
||||||
// edge, == the taskbar's top edge). When above ground and not dragged,
|
// edge, == the taskbar's top edge). When above ground and not dragged,
|
||||||
// the mascot falls with a slow flutter terminal velocity; on landing,
|
// the mascot falls with a slow flutter terminal velocity — but the fall
|
||||||
// a brief `land` behavior plays, then idle. Dragging is always
|
// is alive: panic-flap wing-beats slow the descent on a speed-scaled,
|
||||||
// honored — pointer code calls forceBehavior('dragged'), which wins
|
// jittered cycle, faster-than-terminal tosses decay under drag, hard
|
||||||
// over any autonomous behavior or non-drag-breaking reaction.
|
// 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 { AnimName, BehaviorId, MascotRuntime } from './types'
|
||||||
import type { MascotModel } from './state.svelte'
|
import type { MascotModel } from './state.svelte'
|
||||||
@@ -26,22 +31,52 @@ import type { MascotModel } from './state.svelte'
|
|||||||
|
|
||||||
const GRAVITY = 1400 // px/s^2 (gentle)
|
const GRAVITY = 1400 // px/s^2 (gentle)
|
||||||
const TERMINAL_VY = 320 // px/s (slow flutter fall)
|
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 WALK_SPEED = 36 // px/s
|
||||||
const MARGIN = 24 // px before the surface edge where wander flips facing
|
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:
|
// 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`
|
// (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
|
// for the FLAP_BURST_MS right after each impulse. The cycle is DYNAMIC —
|
||||||
// horizontal drift so the fall isn't perfectly vertical either. See
|
// 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.
|
// 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_BURST_MS = 160
|
||||||
const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle
|
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 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 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
|
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
|
// Ground tracking: Mascot.svelte's tick() chases the ground line (the top
|
||||||
// of whatever window/surface is beneath the mascot) each frame. A small
|
// of whatever window/surface is beneath the mascot) each frame. A small
|
||||||
// per-tick change (a window being dragged smoothly, with the mascot riding
|
// per-tick change (a window being dragged smoothly, with the mascot riding
|
||||||
@@ -116,6 +151,19 @@ function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
|
|||||||
|
|
||||||
// ─── ground / bounds helpers ─────────────────────────────────────────────
|
// ─── 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 {
|
function groundY(rt: MascotRuntime): number {
|
||||||
// rt.groundY is recomputed each tick by Mascot.svelte from the window
|
// 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,
|
// 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]
|
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: {
|
sleep: {
|
||||||
id: 'sleep',
|
id: 'sleep',
|
||||||
anim: () => 'sleep',
|
anim: () => 'sleep',
|
||||||
@@ -240,7 +308,8 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
// glide the rest of the cycle.
|
// glide the rest of the cycle.
|
||||||
anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
|
anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
|
||||||
enter: (rt) => {
|
enter: (rt) => {
|
||||||
rt.fallPhaseAt = performance.now()
|
rt.bounceCount = 0
|
||||||
|
scheduleFlap(rt, performance.now())
|
||||||
// vx/vy are deliberately NOT reset here — they carry over from
|
// vx/vy are deliberately NOT reset here — they carry over from
|
||||||
// drag-release toss momentum (set by Mascot.svelte's onPointerUp)
|
// drag-release toss momentum (set by Mascot.svelte's onPointerUp)
|
||||||
// when falling starts from a throw, or stay at 0 when it starts from
|
// when falling starts from a throw, or stay at 0 when it starts from
|
||||||
@@ -362,13 +431,20 @@ export function stepMascot(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'falling': {
|
case 'falling': {
|
||||||
// Wing-beat: every FLAP_CYCLE_MS, cut the descent speed sharply —
|
// Wing-beat: every flapCycleMs (dynamic — see scheduleFlap), cut
|
||||||
// a real (if losing) attempt at flight rather than a flat drop.
|
// the descent speed sharply: a real (if losing) attempt at flight
|
||||||
if (now - rt.fallPhaseAt >= FLAP_CYCLE_MS) {
|
// rather than a flat drop.
|
||||||
|
if (now - rt.fallPhaseAt >= rt.flapCycleMs) {
|
||||||
rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE)
|
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,
|
// Toss/drift horizontal velocity decays so it doesn't carry forever,
|
||||||
// plus a gentle sideways wobble so even a straight-down drop isn't
|
// plus a gentle sideways wobble so even a straight-down drop isn't
|
||||||
// perfectly vertical.
|
// perfectly vertical.
|
||||||
@@ -376,12 +452,78 @@ export function stepMascot(
|
|||||||
const wobble = Math.sin(now / 260) * WOBBLE_VX
|
const wobble = Math.sin(now / 260) * WOBBLE_VX
|
||||||
rt.x += (rt.vx + wobble) * dts
|
rt.x += (rt.vx + wobble) * dts
|
||||||
rt.y += rt.vy * 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)
|
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
|
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.vy = 0
|
||||||
rt.vx = 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
|
break
|
||||||
}
|
}
|
||||||
@@ -409,8 +551,9 @@ export function stepMascot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transition: only self-expiring behaviors (next() consult).
|
// Transition: only self-expiring behaviors (next() consult). dragged,
|
||||||
if (rt.behavior === 'dragged' || rt.behavior === 'falling') return
|
// 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
|
if (now < rt.behaviorUntil) return
|
||||||
|
|
||||||
const next = def.next(rt, now)
|
const next = def.next(rt, now)
|
||||||
@@ -450,6 +593,8 @@ export function releaseFromDrag(rt: MascotRuntime): void {
|
|||||||
rt.y = gy
|
rt.y = gy
|
||||||
rt.vy = 0
|
rt.vy = 0
|
||||||
rt.vx = 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 })
|
forceBehavior(rt, 'land', { durationMs: LAND_MS })
|
||||||
} else {
|
} else {
|
||||||
forceBehavior(rt, 'falling')
|
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. */
|
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
|
||||||
export type BehaviorId =
|
export type BehaviorId =
|
||||||
| 'egg' | 'idle' | 'wander' | 'peck' | 'sleep'
|
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
|
||||||
| 'dragged' | 'falling' | 'land' | 'react'
|
| 'dragged' | 'falling' | 'land' | 'react'
|
||||||
|
|
||||||
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
|
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
|
||||||
@@ -123,10 +123,33 @@ export interface MascotRuntime {
|
|||||||
blinkUntil: number
|
blinkUntil: number
|
||||||
/**
|
/**
|
||||||
* performance.now() ms marking the start of the current flap/glide
|
* performance.now() ms marking the start of the current flap/glide
|
||||||
* sub-phase within a `falling` behavior — see FLAP_CYCLE_MS/FLAP_BURST_MS
|
* sub-phase within a `falling` behavior — see flapCycleMs below and
|
||||||
* in behavior.ts. Reset each time `falling` is entered.
|
* scheduleFlap() in behavior.ts. Reset each time `falling` is entered.
|
||||||
*/
|
*/
|
||||||
fallPhaseAt: number
|
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
|
* 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()` —
|
* a desktop icon. Consumed once by the `land` BehaviorDef's `next()` —
|
||||||
|
|||||||
Reference in New Issue
Block a user