mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Merge pull request #55400 from NousResearch/bb/pet-roam-calmer
feat(desktop): calmer, more realistic pet roam + split roam modules
This commit is contained in:
commit
33d044c3af
5 changed files with 407 additions and 167 deletions
97
apps/desktop/src/components/pet/roam-behavior.test.ts
Normal file
97
apps/desktop/src/components/pet/roam-behavior.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
97
apps/desktop/src/components/pet/roam-behavior.ts
Normal file
97
apps/desktop/src/components/pet/roam-behavior.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 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
|
||||
// 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)
|
||||
|
||||
return goRight ? fromX + dist : fromX - dist
|
||||
}
|
||||
51
apps/desktop/src/components/pet/roam-geometry.test.ts
Normal file
51
apps/desktop/src/components/pet/roam-geometry.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
130
apps/desktop/src/components/pet/roam-geometry.ts
Normal file
130
apps/desktop/src/components/pet/roam-geometry.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue