mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
feat(desktop): add surface-aware pet wander loop
usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render).
This commit is contained in:
parent
964ec680cc
commit
a8f1d9cc76
1 changed files with 407 additions and 0 deletions
407
apps/desktop/src/components/pet/use-pet-roam.ts
Normal file
407
apps/desktop/src/components/pet/use-pet-roam.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
|
||||
|
||||
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"]'
|
||||
|
||||
const WALK_SPEED_PX_S = 58
|
||||
// Downward acceleration for falls between ledges — fast enough to read as a drop.
|
||||
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
|
||||
// Snap distances: "on this ledge" / arrived at a walk target.
|
||||
const GROUND_EPS = 2
|
||||
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
|
||||
|
||||
type Phase = 'pause' | 'walk' | 'fall' | 'jump'
|
||||
|
||||
const rand = (min: number, max: number): number => min + Math.random() * (max - min)
|
||||
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
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
/** True while the user is dragging — the loop yields so it never fights a drag. */
|
||||
isInteracting: () => boolean
|
||||
petW: number
|
||||
petH: number
|
||||
/** Persist the resting position back to React state when the loop settles. */
|
||||
commit: (point: Point) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* re-asserts the DOM position every frame, an incidental parent re-render that
|
||||
* snaps `style` back self-heals within a frame. State is only committed (via
|
||||
* `commit`) when the pet settles, keeping React's `position` in sync once the
|
||||
* loop stops driving it.
|
||||
*
|
||||
* Two signals publish the wander so the canvas/sprite react without a prop
|
||||
* change: `$petMotion` (`run` while walking, `jump` while hopping/falling) flips
|
||||
* the shared `$petState`, and `$petRoamDir` (-1/0/1) lets the floating pet pick
|
||||
* the directional run row + mirror for the travel direction.
|
||||
*/
|
||||
export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, commit }: PetRoamOptions): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
$petMotion.set(null)
|
||||
$petRoamDir.set(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const groundTop = (ledge: Ledge): number => ledge.y - petH
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
const rect = el.getBoundingClientRect()
|
||||
const cur: Point = { x: rect.left, y: rect.top }
|
||||
|
||||
let phase: Phase = 'pause'
|
||||
let pauseUntil = performance.now() + rand(400, 1200)
|
||||
let last = performance.now()
|
||||
let raf = 0
|
||||
|
||||
let walkTargetX = cur.x
|
||||
let curLedge: Ledge | null = null
|
||||
let targetLedge: Ledge | null = null
|
||||
// When set, the current walk is the approach run before a hop to this ledge.
|
||||
let pendingHop: Ledge | null = null
|
||||
// Fall / jump integrators.
|
||||
let fallVel = 0
|
||||
let jumpFromY = 0
|
||||
let jumpElapsed = 0
|
||||
|
||||
const applyDom = () => {
|
||||
el.style.left = `${cur.x}px`
|
||||
el.style.top = `${cur.y}px`
|
||||
}
|
||||
|
||||
// One chokepoint for the wander signals: the pose (drives `$petState`) and
|
||||
// the travel direction (drives the floating pet's directional row + mirror).
|
||||
const signal = (pose: PetState | null, dir: -1 | 0 | 1) => {
|
||||
$petMotion.set(pose)
|
||||
$petRoamDir.set(dir)
|
||||
}
|
||||
|
||||
const beginPause = (now: number) => {
|
||||
phase = 'pause'
|
||||
pauseUntil = now + rand(PAUSE_MIN_MS, PAUSE_MAX_MS)
|
||||
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)
|
||||
curLedge = ledge
|
||||
applyDom()
|
||||
beginPause(now)
|
||||
}
|
||||
|
||||
const beginVertical = (ledge: Ledge) => {
|
||||
targetLedge = ledge
|
||||
|
||||
if (groundTop(ledge) < cur.y - 1) {
|
||||
// Up onto a higher ledge: a quick spring.
|
||||
phase = 'jump'
|
||||
jumpFromY = cur.y
|
||||
jumpElapsed = 0
|
||||
} else {
|
||||
// Down off a ledge: let gravity take it.
|
||||
phase = 'fall'
|
||||
fallVel = 0
|
||||
}
|
||||
|
||||
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]!
|
||||
}
|
||||
|
||||
const planNext = () => {
|
||||
const ledges = snapshotLedges(petW, petH)
|
||||
curLedge = resolveLedge(ledges)
|
||||
const grounded = Math.abs(cur.y - groundTop(curLedge)) <= GROUND_EPS
|
||||
|
||||
if (!grounded) {
|
||||
// Dragged into the air (or a perch vanished): fall to the surface below.
|
||||
beginVertical(curLedge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const reachable = ledges.filter(ledge => ledge !== curLedge && overlapsX(curLedge!, ledge))
|
||||
|
||||
if (reachable.length > 0 && Math.random() < HOP_CHANCE) {
|
||||
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)
|
||||
pendingHop = next
|
||||
walkTargetX = lo + Math.random() * (hi - lo)
|
||||
} else {
|
||||
pendingHop = null
|
||||
walkTargetX = pickStrollTarget(curLedge)
|
||||
}
|
||||
|
||||
phase = 'walk'
|
||||
signal('run', signDir(walkTargetX - cur.x))
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(MAX_DT_S, (now - last) / 1000)
|
||||
last = now
|
||||
|
||||
// Yield to a drag: track the pet so we resume from the drop point, and
|
||||
// reset the idle beat so it doesn't bolt the instant it's let go.
|
||||
if (isInteracting()) {
|
||||
const live = el.getBoundingClientRect()
|
||||
cur.x = live.left
|
||||
cur.y = live.top
|
||||
phase = 'pause'
|
||||
pendingHop = null
|
||||
// Short settle so the pet falls right after you drop it, not seconds later.
|
||||
pauseUntil = now + DROP_SETTLE_MS
|
||||
signal(null, 0)
|
||||
raf = requestAnimationFrame(step)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 'pause': {
|
||||
if (now >= pauseUntil) {
|
||||
planNext()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'walk': {
|
||||
const remaining = walkTargetX - cur.x
|
||||
const stepDist = WALK_SPEED_PX_S * dt
|
||||
|
||||
if (Math.abs(remaining) <= Math.max(ARRIVE_EPS, stepDist)) {
|
||||
cur.x = walkTargetX
|
||||
applyDom()
|
||||
|
||||
if (pendingHop) {
|
||||
const next = pendingHop
|
||||
pendingHop = null
|
||||
beginVertical(next)
|
||||
} else {
|
||||
beginPause(now)
|
||||
}
|
||||
} else {
|
||||
cur.x += Math.sign(remaining) * stepDist
|
||||
applyDom()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'fall': {
|
||||
if (!targetLedge) {
|
||||
beginPause(now)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
fallVel += GRAVITY_PX_S2 * dt
|
||||
cur.y += fallVel * dt
|
||||
|
||||
if (cur.y >= groundTop(targetLedge)) {
|
||||
settleOn(targetLedge, now)
|
||||
} else {
|
||||
applyDom()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'jump': {
|
||||
if (!targetLedge) {
|
||||
beginPause(now)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
jumpElapsed += dt * 1000
|
||||
const t = Math.min(1, jumpElapsed / JUMP_DUR_MS)
|
||||
cur.y = jumpFromY + (groundTop(targetLedge) - jumpFromY) * easeOutCubic(t)
|
||||
|
||||
if (t >= 1) {
|
||||
settleOn(targetLedge, now)
|
||||
} else {
|
||||
applyDom()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
signal(null, 0)
|
||||
// Hand the final position back to React so its `style` matches the DOM once
|
||||
// the loop stops re-asserting it.
|
||||
commit({ ...cur })
|
||||
}
|
||||
}, [enabled, petW, petH, containerRef, isInteracting, commit])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue