From b7322f946db6ac22353714a136ae3d7950b38745 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 22:51:09 -0500 Subject: [PATCH 1/2] feat(desktop): calmer, more realistic pet roam + split roam modules The floating pet wandered almost constantly: every idle beat picked a new walk and hops fired ~45% of the time, so it read as nervous rather than alive. Make movement the exception, not the default, and split the overgrown roam hook into focused modules. Behavior (per ambient game-AI: GameAIPro ch.36 + idle/wander state machines): - Loaf, don't pace: most decision beats just keep resting (REST_CHANCE 0.62) instead of always re-walking. - Memoryless dwell: pauses now draw from an exponential distribution (mostly short rests, the occasional long loaf) instead of a uniform 1.8-5.2s window, so the cadence never reads as a metronome. - Hops dialed back 0.45 -> 0.2 (the jumpiest, noisiest motion). Structure (no god-file; a hook should own one narrow job): - roam-behavior.ts - what to do & when (dwellMs, chooseMove, pickStrollTarget) + tuning. Pure, rng-injectable. - roam-geometry.ts - where it can stand (snapshotLedges, overlayLedge, resolveLedge, overlapsX, groundTop). DOM measurement + pure ledge math. - use-pet-roam.ts - the physics/RAF loop only. Tests: deterministic, rng-seeded unit coverage for the decision + geometry helpers (behavior contracts, not snapshots). --- .../src/components/pet/roam-behavior.test.ts | 97 +++++++++ .../src/components/pet/roam-behavior.ts | 96 +++++++++ .../src/components/pet/roam-geometry.test.ts | 51 +++++ .../src/components/pet/roam-geometry.ts | 130 ++++++++++++ .../src/components/pet/use-pet-roam.ts | 199 +++--------------- 5 files changed, 406 insertions(+), 167 deletions(-) create mode 100644 apps/desktop/src/components/pet/roam-behavior.test.ts create mode 100644 apps/desktop/src/components/pet/roam-behavior.ts create mode 100644 apps/desktop/src/components/pet/roam-geometry.test.ts create mode 100644 apps/desktop/src/components/pet/roam-geometry.ts diff --git a/apps/desktop/src/components/pet/roam-behavior.test.ts b/apps/desktop/src/components/pet/roam-behavior.test.ts new file mode 100644 index 00000000000..91d65b9b737 --- /dev/null +++ b/apps/desktop/src/components/pet/roam-behavior.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { chooseMove, dwellMs, type DwellRange, HOP_CHANCE, pickStrollTarget, REST_CHANCE, type Rng } from './roam-behavior' +import type { Ledge } from './roam-geometry' + +// Deterministic rng that replays a fixed sequence (last value sticks). +const seq = + (...vals: number[]): Rng => + () => + vals.shift() ?? vals[vals.length - 1] ?? 0 + +const RANGE: DwellRange = { maxMs: 13000, meanMs: 4000, minMs: 1500 } +const ledge = (left: number, right: number, y = 0): Ledge => ({ left, right, y }) + +describe('dwellMs', () => { + it('clamps the degenerate draws to the floor and ceiling', () => { + // rng→0 ⇒ u=1 ⇒ -ln(1)·mean = 0, raised to the floor. + expect(dwellMs(RANGE, () => 0)).toBe(RANGE.minMs) + // rng→~1 ⇒ u→0 ⇒ -ln(u) blows up, capped at the ceiling. + expect(dwellMs(RANGE, () => 1 - 1e-9)).toBe(RANGE.maxMs) + }) + + it('returns the mean at the exponential median point', () => { + // rng = 1 - 1/e ⇒ u = 1/e ⇒ -ln(u) = 1 ⇒ exactly the mean. + expect(dwellMs(RANGE, () => 1 - 1 / Math.E)).toBeCloseTo(RANGE.meanMs, 6) + }) + + it('stays within [min, max] across the whole rng domain', () => { + let state = 0.123456789 + + for (let i = 0; i < 5000; i++) { + state = (state * 9301 + 0.49297) % 1 // cheap deterministic walk + const ms = dwellMs(RANGE, () => state) + expect(ms).toBeGreaterThanOrEqual(RANGE.minMs) + expect(ms).toBeLessThanOrEqual(RANGE.maxMs) + } + }) +}) + +describe('chooseMove', () => { + it('rests whenever the first draw lands under restChance — even where it could hop', () => { + expect(chooseMove(true, seq(0))).toBe('rest') + expect(chooseMove(false, seq(REST_CHANCE - 1e-9))).toBe('rest') + }) + + it('strolls when moving with nowhere to hop', () => { + expect(chooseMove(false, seq(0.99))).toBe('stroll') + }) + + it('hops only when moving, a ledge is reachable, and the second draw says so', () => { + expect(chooseMove(true, seq(0.99, HOP_CHANCE - 1e-9))).toBe('hop') + expect(chooseMove(true, seq(0.99, HOP_CHANCE))).toBe('stroll') + }) + + it('treats restChance as a strict lower bound (boundary stays a move)', () => { + expect(chooseMove(false, seq(REST_CHANCE))).toBe('stroll') + }) + + it('loafs far more than it roams over a long run (the whole point)', () => { + let state = 0.314159 + const rng: Rng = () => (state = (state * 16807 + 0.5) % 1) + let rests = 0 + const N = 20000 + + for (let i = 0; i < N; i++) { + if (chooseMove(true, rng) === 'rest') { + rests++ + } + } + + // ~62% rests; assert the contract (majority loafing), not the exact rate. + expect(rests / N).toBeGreaterThan(0.5) + }) +}) + +describe('pickStrollTarget', () => { + it('collapses to the left edge on a ledge too narrow to walk', () => { + expect(pickStrollTarget(ledge(100, 102), 100, seq(0))).toBe(100) + }) + + it('lands inside the ledge and clears the minimum travel distance', () => { + const wide = ledge(0, 1000) + const from = 500 + const x = pickStrollTarget(wide, from, seq(0.5, 0)) + + expect(x).toBeGreaterThanOrEqual(wide.left) + expect(x).toBeLessThanOrEqual(wide.right) + expect(Math.abs(x - from)).toBeGreaterThanOrEqual(110) // STROLL_MIN_PX + }) + + it('heads toward the side with more room', () => { + // Pinned near the right wall, the roomier side is left. First draw clears the + // rare double-back coin ⇒ it commits to the roomy (left) side ⇒ target < x. + const x = pickStrollTarget(ledge(0, 1000), 950, seq(0.5, 0)) + expect(x).toBeLessThan(950) + }) +}) diff --git a/apps/desktop/src/components/pet/roam-behavior.ts b/apps/desktop/src/components/pet/roam-behavior.ts new file mode 100644 index 00000000000..01b3acf3b7a --- /dev/null +++ b/apps/desktop/src/components/pet/roam-behavior.ts @@ -0,0 +1,96 @@ +/** + * Pure decision helpers for the floating pet's wander — the "what to do & when" + * layer, split out from the geometry (`roam-geometry.ts`) and the RAF/DOM loop + * (`use-pet-roam.ts`) so the *rhythm* of the roam is tunable in one place and + * unit testable (every function takes an injectable `rng`). + * + * The goal is a calm, believable critter rather than a fidgeting one. Two ideas + * from ambient game-AI carry the weight (see GameAIPro ch.36 "Breathing Life + * into Your Background Characters" + standard idle/wander state machines): + * + * 1. **Loaf, don't pace.** A background character that picks a new walk on + * every beat reads as nervous. Most decision beats just keep resting; + * movement is the exception, not the default (`REST_CHANCE`). + * 2. **Memoryless dwell times.** Uniform pauses feel metronomic. An + * exponential dwell — the classic model for idle durations — gives mostly + * short rests with the occasional long loaf, so the cadence never reads as a + * fixed pattern (`dwellMs` / `PAUSE_DWELL`). + */ + +import type { Ledge } from './roam-geometry' + +export type Rng = () => number + +/** What the pet does when a rest beat ends. */ +export type RoamMove = 'rest' | 'stroll' | 'hop' + +export interface DwellRange { + /** Mean of the exponential draw — the "typical" rest length. */ + meanMs: number + /** Floor, so a near-zero draw never produces a jittery micro-pause. */ + minMs: number + /** Ceiling, so a fat-tail draw (or a throttled tab) can't freeze the pet. */ + maxMs: number +} + +// Rest length between beats: mostly short loafs, the occasional long one. +export const PAUSE_DWELL: DwellRange = { maxMs: 13000, meanMs: 4200, minMs: 1500 } +// Most beats the pet just keeps loafing — a critter that re-walks every beat +// reads as nervous, not alive. +export const REST_CHANCE = 0.62 +// When it *does* move, chance it hops to another ledge vs. strolling this one. +export const HOP_CHANCE = 0.2 +// Strolls should cover ground, not shuffle: travel at least this fraction of the +// ledge (or this many px, whichever is larger), up to the room available. +const STROLL_MIN_FRACTION = 0.45 +const STROLL_MIN_PX = 110 +// Bias toward the roomier side so the pet crosses the app instead of pacing one +// spot; the long tail of the coin still lets it double back now and then. +const STROLL_TOWARD_ROOM = 0.85 + +/** + * Exponential (memoryless) dwell time, clamped to `[minMs, maxMs]`. With rng→0 + * this returns `minMs`; with rng→1 it saturates at `maxMs`; in between it's + * `-ln(u)·meanMs`, so short rests dominate and long loafs are rare but possible. + */ +export function dwellMs({ meanMs, minMs, maxMs }: DwellRange, rng: Rng = Math.random): number { + const u = 1 - rng() // map [0,1) → (0,1] so the log stays finite + + return Math.min(maxMs, Math.max(minMs, -Math.log(u) * meanMs)) +} + +/** + * Decide a beat: rest (the common case), or — when the pet is actually going to + * move — hop to a reachable ledge if one exists and the dice say so, else stroll + * the current ledge. `canHop` is false when no neighbouring surface overlaps, so + * the pet never "hops" in place. + */ +export function chooseMove(canHop: boolean, rng: Rng = Math.random): RoamMove { + if (rng() < REST_CHANCE) { + return 'rest' + } + + return canHop && rng() < HOP_CHANCE ? 'hop' : 'stroll' +} + +/** + * A stroll destination (absolute x) on `ledge` that actually goes somewhere: + * lean toward the side with more room and guarantee a decent minimum travel, so + * the pet crosses the app rather than shuffling in place. + */ +export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.random): number { + const span = ledge.right - ledge.left + + if (span <= 4) { + return ledge.left + } + + const roomLeft = fromX - ledge.left + const roomRight = ledge.right - fromX + const goRight = roomRight >= roomLeft ? rng() < STROLL_TOWARD_ROOM : rng() < 1 - STROLL_TOWARD_ROOM + const room = Math.max(0, goRight ? roomRight : roomLeft) + const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX)) + const dist = minDist + rng() * Math.max(0, room - minDist) + + return goRight ? fromX + dist : fromX - dist +} diff --git a/apps/desktop/src/components/pet/roam-geometry.test.ts b/apps/desktop/src/components/pet/roam-geometry.test.ts new file mode 100644 index 00000000000..17ed62515a7 --- /dev/null +++ b/apps/desktop/src/components/pet/roam-geometry.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { GROUND_EPS, groundTop, type Ledge, overlapsX, resolveLedge } from './roam-geometry' + +const ledge = (y: number, left = 0, right = 1000): Ledge => ({ left, right, y }) + +describe('groundTop', () => { + it('sinks the feet by the padding offset so they meet the surface', () => { + // y - petH + FEET_DROP_PX(4) + expect(groundTop(ledge(500), 100)).toBe(404) + }) +}) + +describe('overlapsX', () => { + it('is true only when the walkable ranges share real width', () => { + expect(overlapsX(ledge(0, 0, 100), ledge(0, 50, 200))).toBe(true) + expect(overlapsX(ledge(0, 0, 100), ledge(0, 100, 200))).toBe(false) // touching, not overlapping + expect(overlapsX(ledge(0, 0, 100), ledge(0, 300, 400))).toBe(false) + }) +}) + +describe('resolveLedge', () => { + const floor = ledge(600) + const shelf = ledge(300, 100, 400) + + it('returns the highest surface at or below the feet under the current x', () => { + // Standing on the shelf line, under the shelf's x-span ⇒ the shelf. + const petH = 100 + const onShelf = resolveLedge([floor, shelf], 200, shelf.y - petH, petH) + expect(onShelf).toBe(shelf) + }) + + it('ignores surfaces the pet is not horizontally over', () => { + const petH = 100 + // x=800 is past the shelf ⇒ only the floor qualifies. + const onFloor = resolveLedge([floor, shelf], 800, floor.y - petH, petH) + expect(onFloor).toBe(floor) + }) + + it('falls back to the floor (ledges[0]) when below everything', () => { + const petH = 100 + const below = resolveLedge([floor, shelf], 200, 5000, petH) + expect(below).toBe(floor) + }) + + it('counts a surface within GROUND_EPS of the feet as standing on it', () => { + const petH = 100 + const justAbove = resolveLedge([floor], 10, floor.y - petH - GROUND_EPS + 0.5, petH) + expect(justAbove).toBe(floor) + }) +}) diff --git a/apps/desktop/src/components/pet/roam-geometry.ts b/apps/desktop/src/components/pet/roam-geometry.ts new file mode 100644 index 00000000000..b40376fbdef --- /dev/null +++ b/apps/desktop/src/components/pet/roam-geometry.ts @@ -0,0 +1,130 @@ +/** + * The "where can it stand" layer of the floating pet's wander: it measures the + * live DOM for walkable surfaces and answers pure questions about them. Split + * from the decision logic (`roam-behavior.ts`) and the RAF/DOM loop + * (`use-pet-roam.ts`) so the loop reads as physics, not geometry, and the pure + * helpers (`overlapsX`, `resolveLedge`, `groundTop`) stay unit testable. + */ + +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' + +/** + * A horizontal surface the pet can stand and walk on. `y` is the surface line + * (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the + * whole sprite stays on the ledge. + */ +export interface Ledge { + y: number + left: number + right: number +} + +// Elements the pet can perch on top of, measured fresh each beat. The bottom +// floor is always a ledge; these add app furniture the pet can climb onto (the +// composer, the profile rail). Add a `data-slot` here to grow the playground. +const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]'] + +// A full-width bar pinned to the window bottom (the status bar). When present, +// the pet walks along its TOP edge instead of the window edge, so it stands on +// the bar rather than covering it. +const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]' + +// Sprites carry a few px of transparent padding below the feet; sink the pet by +// this much so the visible feet meet the surface instead of hovering above it. +const FEET_DROP_PX = 4 +// Snap distance: how close the feet must be to count as "on this ledge". +export const GROUND_EPS = 2 + +const vw = (): number => window.innerWidth || 800 +const vh = (): number => window.innerHeight || 600 + +/** The y a pet of height `petH` rests at when standing on `ledge`. */ +export const groundTop = (ledge: Ledge, petH: number): number => ledge.y - petH + FEET_DROP_PX + +/** + * Do the pet's walkable x-ranges on two ledges overlap enough to step across? + * (Pure — the wander uses it to find hop-reachable neighbours.) + */ +export const overlapsX = (from: Ledge, to: Ledge): boolean => + Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2 + +/** + * The highest surface at or below the pet's feet under its current x — i.e. what + * it's standing on, or what it would fall onto. Pure; falls back to the floor + * (always `ledges[0]`) if the pet is somehow below everything. + */ +export function resolveLedge(ledges: Ledge[], x: number, y: number, petH: number): Ledge { + const bottom = y + petH + let best: Ledge | null = null + + for (const ledge of ledges) { + if (x < ledge.left - 2 || x > ledge.right + 2) { + continue + } + + if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) { + best = ledge + } + } + + return best ?? ledges[0]! +} + +/** The bottom ground line: the top of the status bar if it's pinned full-width + * across the window bottom, otherwise the window edge. */ +function floorY(width: number, height: number, petH: number): number { + const bar = document.querySelector(FLOOR_BAR_SELECTOR) + + if (bar) { + const rect = bar.getBoundingClientRect() + + if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) { + return rect.top + } + } + + return height +} + +/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen + * perch element with room above it for the pet to stand. */ +export function snapshotLedges(petW: number, petH: number): Ledge[] { + const width = vw() + const height = vh() + const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }] + + for (const selector of PERCH_SELECTORS) { + const el = document.querySelector(selector) + + if (!el) { + continue + } + + const rect = el.getBoundingClientRect() + const left = Math.max(0, rect.left) + const right = Math.min(width - petW, rect.right - petW) + + // Skip surfaces that are too narrow for the pet, have no headroom above, or + // sit off-screen / flush with the floor (no daylight between them). + if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) { + continue + } + + ledges.push({ left, right, y: rect.top }) + } + + return ledges +} + +/** + * While a full-screen route overlay is up it's the only walkable surface: a + * single ledge at the overlay card's bottom inner edge. The card uses + * `OverlayView`'s equal inset on every side — `titlebar-height + padding` — so + * we derive it from that rather than measuring. + */ +export function overlayLedge(petW: number): Ledge { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 + const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem + + return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset } +} diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index bd4f416180b..24ab9c9f4cb 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -1,34 +1,15 @@ import { type RefObject, useEffect } from 'react' -import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import { $petMotion, $petRoamDir, type PetState } from '@/store/pet' +import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior' +import { GROUND_EPS, groundTop, type Ledge, overlapsX, overlayLedge, resolveLedge, snapshotLedges } from './roam-geometry' + interface Point { x: number y: number } -/** - * A horizontal surface the pet can stand and walk on. `y` is the surface line - * (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the - * whole sprite stays on the ledge. - */ -interface Ledge { - y: number - left: number - right: number -} - -// Elements the pet can perch on top of, measured fresh each beat. The bottom -// floor is always a ledge; these add app furniture the pet can climb onto (the -// composer, the profile rail). Add a `data-slot` here to grow the playground. -const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]'] - -// A full-width bar pinned to the window bottom (the status bar). When present, -// the pet walks along its TOP edge instead of the window edge, so it stands on -// the bar rather than covering it. -const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]' - // Foot-sync: advance this many body-widths per animation loop so the walk reads // as steps, not a glide. Actual px/s is derived from the sprite's loop duration // and on-screen size (see `walkSpeedPxS`). @@ -37,22 +18,10 @@ const STRIDE_PER_LOOP = 0.8 const GRAVITY_PX_S2 = 5200 // Time to spring up onto a higher ledge. const JUMP_DUR_MS = 460 -const PAUSE_MIN_MS = 1800 -const PAUSE_MAX_MS = 5200 // Tiny settle after a drag release before the pet re-plans (and usually falls), // so dropping it in mid-air snaps down promptly instead of hanging for a beat. const DROP_SETTLE_MS = 90 -// Chance a beat hops to another ledge instead of strolling the current one. -const HOP_CHANCE = 0.45 -// Strolls should cover ground, not shuffle: travel at least this fraction of the -// ledge (or this many px, whichever is larger), up to the room available. -const STROLL_MIN_FRACTION = 0.45 -const STROLL_MIN_PX = 110 -// Sprites carry a few px of transparent padding below the feet; sink the pet by -// this much so the visible feet meet the surface instead of hovering above it. -const FEET_DROP_PX = 4 -// Snap distances: "on this ledge" / arrived at a walk target. -const GROUND_EPS = 2 +// Arrived at a walk target. const ARRIVE_EPS = 1.5 // Cap dt so a backgrounded/throttled tab can't teleport the pet on resume. const MAX_DT_S = 0.05 @@ -63,60 +32,6 @@ const rand = (min: number, max: number): number => min + Math.random() * (max - const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3 const signDir = (n: number): -1 | 0 | 1 => (n > 0 ? 1 : n < 0 ? -1 : 0) -function vw(): number { - return window.innerWidth || 800 -} - -function vh(): number { - return window.innerHeight || 600 -} - -/** The bottom ground line: the top of the status bar if it's pinned full-width - * across the window bottom, otherwise the window edge. */ -function floorY(width: number, height: number, petH: number): number { - const bar = document.querySelector(FLOOR_BAR_SELECTOR) - - if (bar) { - const rect = bar.getBoundingClientRect() - - if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) { - return rect.top - } - } - - return height -} - -/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen - * perch element with room above it for the pet to stand. */ -function snapshotLedges(petW: number, petH: number): Ledge[] { - const width = vw() - const height = vh() - const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }] - - for (const selector of PERCH_SELECTORS) { - const el = document.querySelector(selector) - - if (!el) { - continue - } - - const rect = el.getBoundingClientRect() - const left = Math.max(0, rect.left) - const right = Math.min(width - petW, rect.right - petW) - - // Skip surfaces that are too narrow for the pet, have no headroom above, or - // sit off-screen / flush with the floor (no daylight between them). - if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) { - continue - } - - ledges.push({ left, right, y: rect.top }) - } - - return ledges -} - interface PetRoamOptions { /** Run the wander loop (roam opt-in + pet active + in-window + agent at rest). */ enabled: boolean @@ -134,13 +49,13 @@ interface PetRoamOptions { } /** - * Make the floating pet wander the app like a platformer character: it walks - * along surfaces (the window floor, the top of the composer, …), hops up onto - * higher ledges, and drops off them — instead of drifting diagonally through - * empty space. Surfaces are re-measured from the live DOM at the start of every - * beat (`snapshotLedges`), so the pet tracks the composer growing, the sidebar - * opening, the window resizing, and even falls back to the floor when its perch - * disappears. + * Drive the floating pet's wander as a platformer state machine: it loafs, then + * walks along surfaces (the window floor, the top of the composer, …), hops up + * onto higher ledges, and drops off them — instead of drifting through empty + * space. The walkable surfaces come from `roam-geometry` (re-measured from the + * live DOM every beat, so the pet tracks the composer growing, the sidebar + * opening, the window resizing) and the beat-to-beat choices from + * `roam-behavior`; this hook owns only the physics and the DOM writes. * * Movement mutates `el.style.left/top` directly each frame — like the drag * handler — so a steady wander triggers no React re-renders, and because it @@ -180,28 +95,7 @@ export function usePetRoam({ // Pace the stride to the sprite: one body-width per animation loop. const walkSpeedPxS = (petW * STRIDE_PER_LOOP) / (loopMs / 1000) - - const groundTop = (ledge: Ledge): number => ledge.y - petH + FEET_DROP_PX - - // A stroll destination on `ledge` that actually goes somewhere: lean toward - // the side with more room (so the pet crosses the app rather than shuffling - // in place) and guarantee a decent minimum travel distance. - const pickStrollTarget = (ledge: Ledge): number => { - const span = ledge.right - ledge.left - - if (span <= 4) { - return ledge.left - } - - const roomLeft = cur.x - ledge.left - const roomRight = ledge.right - cur.x - const goRight = roomRight >= roomLeft ? Math.random() < 0.85 : Math.random() < 0.15 - const room = Math.max(0, goRight ? roomRight : roomLeft) - const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX)) - const dist = minDist + Math.random() * Math.max(0, room - minDist) - - return goRight ? cur.x + dist : cur.x - dist - } + const restY = (ledge: Ledge): number => groundTop(ledge, petH) // Seed from the live DOM rect so we resume from wherever the pet actually is // (after a drag, reclamp, or activity pause) rather than a stale closure. @@ -237,14 +131,14 @@ export function usePetRoam({ const beginPause = (now: number) => { phase = 'pause' - pauseUntil = now + rand(PAUSE_MIN_MS, PAUSE_MAX_MS) + pauseUntil = now + dwellMs(PAUSE_DWELL) signal(null, 0) commit({ ...cur }) } // Land flush on a ledge, then settle into the next idle beat. const settleOn = (ledge: Ledge, now: number) => { - cur.y = groundTop(ledge) + cur.y = restY(ledge) curLedge = ledge applyDom() beginPause(now) @@ -253,7 +147,7 @@ export function usePetRoam({ const beginVertical = (ledge: Ledge) => { targetLedge = ledge - if (groundTop(ledge) < cur.y - 1) { + if (restY(ledge) < cur.y - 1) { // Up onto a higher ledge: a quick spring. phase = 'jump' jumpFromY = cur.y @@ -267,51 +161,14 @@ export function usePetRoam({ signal('jump', 0) } - // Does the pet, standing at cur.x, have a stretch of `to` it can step across - // to from `from` (their walkable x-ranges overlap)? - const overlapsX = (from: Ledge, to: Ledge): boolean => - Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2 - - // Find the highest surface at or below the pet's feet under its current x — - // i.e. what it's standing on, or what it would fall onto. - const resolveLedge = (ledges: Ledge[]): Ledge => { - const bottom = cur.y + petH - let best: Ledge | null = null - - for (const ledge of ledges) { - if (cur.x < ledge.left - 2 || cur.x > ledge.right + 2) { - continue - } - - if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) { - best = ledge - } - } - - // Floor always spans the clamped x-range, so this only falls back if the - // pet is somehow below everything — drop it to the floor. - return best ?? ledges[0]! - } - - // While an overlay is up, it's the only walkable surface: a single ledge at - // the overlay card's bottom inner edge. The card uses `OverlayView`'s equal - // inset on every side — `titlebar-height + padding` — so derive it from that - // (never measured). - const overlayLedge = (): Ledge => { - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 - const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem - - return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset } - } - - const planNext = () => { + const planNext = (now: number) => { // An open overlay swaps the surface set to just its bottom edge, so the pet // patrols along it; closing it restores the normal surfaces (and the pet // drops to whatever's below). - const ledges = overlayOpen ? [overlayLedge()] : snapshotLedges(petW, petH) - curLedge = resolveLedge(ledges) + const ledges = overlayOpen ? [overlayLedge(petW)] : snapshotLedges(petW, petH) + curLedge = resolveLedge(ledges, cur.x, cur.y, petH) - if (Math.abs(cur.y - groundTop(curLedge)) > GROUND_EPS) { + if (Math.abs(cur.y - restY(curLedge)) > GROUND_EPS) { // Dragged into the air, or the surface moved out from under it: fall. beginVertical(curLedge) @@ -319,8 +176,16 @@ export function usePetRoam({ } const reachable = ledges.filter(ledge => ledge !== curLedge && overlapsX(curLedge!, ledge)) + const move = chooseMove(reachable.length > 0) - if (reachable.length > 0 && Math.random() < HOP_CHANCE) { + if (move === 'rest') { + // Stay put and loaf another beat — movement is the exception. + beginPause(now) + + return + } + + if (move === 'hop') { const next = reachable[Math.floor(Math.random() * reachable.length)]! const lo = Math.max(curLedge.left, next.left) const hi = Math.min(curLedge.right, next.right) @@ -328,7 +193,7 @@ export function usePetRoam({ walkTargetX = lo + Math.random() * (hi - lo) } else { pendingHop = null - walkTargetX = pickStrollTarget(curLedge) + walkTargetX = pickStrollTarget(curLedge, cur.x) } phase = 'walk' @@ -358,7 +223,7 @@ export function usePetRoam({ switch (phase) { case 'pause': { if (now >= pauseUntil) { - planNext() + planNext(now) } break @@ -397,7 +262,7 @@ export function usePetRoam({ fallVel += GRAVITY_PX_S2 * dt cur.y += fallVel * dt - if (cur.y >= groundTop(targetLedge)) { + if (cur.y >= restY(targetLedge)) { settleOn(targetLedge, now) } else { applyDom() @@ -415,7 +280,7 @@ export function usePetRoam({ jumpElapsed += dt * 1000 const t = Math.min(1, jumpElapsed / JUMP_DUR_MS) - cur.y = jumpFromY + (groundTop(targetLedge) - jumpFromY) * easeOutCubic(t) + cur.y = jumpFromY + (restY(targetLedge) - jumpFromY) * easeOutCubic(t) if (t >= 1) { settleOn(targetLedge, now) From 30cd39dc5609a91d5eeee85f78495ffdbecdd157 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 22:53:38 -0500 Subject: [PATCH 2/2] refactor(desktop): collapse stroll-direction coin to a single draw DRY: the roomier-side bias computed its probability two ways (STROLL_TOWARD_ROOM and 1 - STROLL_TOWARD_ROOM). One draw XNOR'd against the roomier side says the same thing more plainly. --- apps/desktop/src/components/pet/roam-behavior.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/pet/roam-behavior.ts b/apps/desktop/src/components/pet/roam-behavior.ts index 01b3acf3b7a..054ceca605a 100644 --- a/apps/desktop/src/components/pet/roam-behavior.ts +++ b/apps/desktop/src/components/pet/roam-behavior.ts @@ -87,7 +87,8 @@ export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.ra const roomLeft = fromX - ledge.left const roomRight = ledge.right - fromX - const goRight = roomRight >= roomLeft ? rng() < STROLL_TOWARD_ROOM : rng() < 1 - STROLL_TOWARD_ROOM + // Usually head to the roomier side; the long tail of the coin doubles back. + const goRight = (rng() < STROLL_TOWARD_ROOM) === (roomRight >= roomLeft) const room = Math.max(0, goRight ? roomRight : roomLeft) const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX)) const dist = minDist + rng() * Math.max(0, room - minDist)