commit 4cd0d8c5897b6aa60bd149ba0592fbbc333cb8aa Author: jasongranum Date: Sun Apr 12 20:41:43 2026 -0700 Initial Commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce91eb8 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# HyperPets + +Animated pets for [Hyper](https://hyper.is) terminal panes. + +The first pass includes: + +- one or two pets per pane +- idle, wander, sleep, cursor-chase, and toy-chase behavior +- bounded toy spawning with cooldowns +- lightweight per-pane overlay rendering +- external cat sprite frames with state transitions between major behaviors + +## Install locally during development + +1. Put this folder somewhere stable. +2. Add `hyperpets` to `localPlugins` in your Hyper config. + +```js +module.exports = { + config: { + hyperPets: { + petCount: 1, + petTypes: ['cat'], + maxToys: 1, + toyCooldownMs: 20000, + successToyChance: 0.35, + walkSpeed: 0.65 + } + }, + localPlugins: [ + 'hyperpets' + ] +}; +``` + +On macOS the config file is typically: + +`~/Library/Application Support/Hyper/.hyper.js` + +Then reload Hyper with `Cmd+R`. + +## Config + +```js +hyperPets: { + enabled: true, + petCount: 1, + petTypes: ['cat'], + maxToys: 1, + toyCooldownMs: 20000, + successToyChance: 0.35, + chaseCursorChance: 0.04, + walkSpeed: 0.65, + reducedMotion: false, + onlyActivePane: true +} +``` + +## Notes + +- Command-success toy spawning currently uses a prompt-return heuristic from terminal output, not shell integration. +- The plugin is intentionally conservative so it does not flood panes with toys or constant motion. +- The current animation system uses asset manifests under `assets/pets/`, with only `cat` shipped right now. The loader is generic so additional pet types can be added later without changing behavior logic. + +## Asset Pipeline + +- Cat frames live in `assets/pets/cat/frames/*.svg`. +- The cat manifest in `assets/pets/cat.js` maps clips and transitions to those frame files. +- The renderer reads the SVG files once at startup and swaps them by animation clip at runtime. +- To add a new pet type later, create another manifest under `assets/pets/` plus a matching frame directory, then register it in `assets/pets/index.js`. diff --git a/assets/pets/cat.js b/assets/pets/cat.js new file mode 100644 index 0000000..f4bc8fc --- /dev/null +++ b/assets/pets/cat.js @@ -0,0 +1,99 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function loadSvg(name) { + return fs.readFileSync(path.join(__dirname, 'cat', 'frames', `${name}.svg`), 'utf8'); +} + +function frame(name) { + return { + name, + svg: loadSvg(name) + }; +} + +module.exports = { + width: 48, + height: 32, + clips: { + idle: { + fps: 3, + frames: [ + frame('idle-1'), + frame('idle-2'), + frame('idle-3') + ] + }, + walk: { + fps: 10, + frames: [ + frame('walk-1'), + frame('walk-2'), + frame('walk-3'), + frame('walk-4') + ] + }, + inspect: { + fps: 5, + frames: [ + frame('inspect-1'), + frame('inspect-2'), + frame('inspect-3') + ] + }, + chase: { + fps: 12, + frames: [ + frame('chase-1'), + frame('chase-2'), + frame('chase-3'), + frame('chase-4') + ] + }, + sleep: { + fps: 2, + frames: [ + frame('sleep-1'), + frame('sleep-2') + ] + }, + transitionIdleWalk: { + fps: 10, + frames: [ + frame('transition-idle-walk-1'), + frame('transition-idle-walk-2'), + frame('transition-idle-walk-3') + ], + nextClip: 'walk' + }, + transitionWalkSleep: { + fps: 9, + frames: [ + frame('transition-walk-sleep-1'), + frame('transition-walk-sleep-2'), + frame('transition-walk-sleep-3') + ], + nextClip: 'sleep' + }, + transitionSleepIdle: { + fps: 8, + frames: [ + frame('transition-sleep-idle-1'), + frame('transition-sleep-idle-2'), + frame('transition-sleep-idle-3') + ], + nextClip: 'idle' + } + }, + transitions: { + 'idle->walk': 'transitionIdleWalk', + 'inspect->walk': 'transitionIdleWalk', + 'walk->sleep': 'transitionWalkSleep', + 'chase->sleep': 'transitionWalkSleep', + 'sleep->idle': 'transitionSleepIdle', + 'sleep->walk': 'transitionSleepIdle', + 'sleep->inspect': 'transitionSleepIdle' + } +}; diff --git a/assets/pets/cat/frames/chase-1.svg b/assets/pets/cat/frames/chase-1.svg new file mode 100644 index 0000000..9bb5be9 --- /dev/null +++ b/assets/pets/cat/frames/chase-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/chase-2.svg b/assets/pets/cat/frames/chase-2.svg new file mode 100644 index 0000000..6573d43 --- /dev/null +++ b/assets/pets/cat/frames/chase-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/chase-3.svg b/assets/pets/cat/frames/chase-3.svg new file mode 100644 index 0000000..abd99c6 --- /dev/null +++ b/assets/pets/cat/frames/chase-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/chase-4.svg b/assets/pets/cat/frames/chase-4.svg new file mode 100644 index 0000000..6d5b01a --- /dev/null +++ b/assets/pets/cat/frames/chase-4.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/idle-1.svg b/assets/pets/cat/frames/idle-1.svg new file mode 100644 index 0000000..63d71f7 --- /dev/null +++ b/assets/pets/cat/frames/idle-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/idle-2.svg b/assets/pets/cat/frames/idle-2.svg new file mode 100644 index 0000000..fe1ab5a --- /dev/null +++ b/assets/pets/cat/frames/idle-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/idle-3.svg b/assets/pets/cat/frames/idle-3.svg new file mode 100644 index 0000000..7cbcff5 --- /dev/null +++ b/assets/pets/cat/frames/idle-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/inspect-1.svg b/assets/pets/cat/frames/inspect-1.svg new file mode 100644 index 0000000..05ab22b --- /dev/null +++ b/assets/pets/cat/frames/inspect-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/inspect-2.svg b/assets/pets/cat/frames/inspect-2.svg new file mode 100644 index 0000000..174a52b --- /dev/null +++ b/assets/pets/cat/frames/inspect-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/inspect-3.svg b/assets/pets/cat/frames/inspect-3.svg new file mode 100644 index 0000000..7f67be8 --- /dev/null +++ b/assets/pets/cat/frames/inspect-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/sleep-1.svg b/assets/pets/cat/frames/sleep-1.svg new file mode 100644 index 0000000..561c3c3 --- /dev/null +++ b/assets/pets/cat/frames/sleep-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/sleep-2.svg b/assets/pets/cat/frames/sleep-2.svg new file mode 100644 index 0000000..3a0e3db --- /dev/null +++ b/assets/pets/cat/frames/sleep-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-idle-walk-1.svg b/assets/pets/cat/frames/transition-idle-walk-1.svg new file mode 100644 index 0000000..0c13293 --- /dev/null +++ b/assets/pets/cat/frames/transition-idle-walk-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-idle-walk-2.svg b/assets/pets/cat/frames/transition-idle-walk-2.svg new file mode 100644 index 0000000..0f075d5 --- /dev/null +++ b/assets/pets/cat/frames/transition-idle-walk-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-idle-walk-3.svg b/assets/pets/cat/frames/transition-idle-walk-3.svg new file mode 100644 index 0000000..27e49f1 --- /dev/null +++ b/assets/pets/cat/frames/transition-idle-walk-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-sleep-idle-1.svg b/assets/pets/cat/frames/transition-sleep-idle-1.svg new file mode 100644 index 0000000..561c3c3 --- /dev/null +++ b/assets/pets/cat/frames/transition-sleep-idle-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-sleep-idle-2.svg b/assets/pets/cat/frames/transition-sleep-idle-2.svg new file mode 100644 index 0000000..6aff169 --- /dev/null +++ b/assets/pets/cat/frames/transition-sleep-idle-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-sleep-idle-3.svg b/assets/pets/cat/frames/transition-sleep-idle-3.svg new file mode 100644 index 0000000..af3d7d0 --- /dev/null +++ b/assets/pets/cat/frames/transition-sleep-idle-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-walk-sleep-1.svg b/assets/pets/cat/frames/transition-walk-sleep-1.svg new file mode 100644 index 0000000..a76e4a8 --- /dev/null +++ b/assets/pets/cat/frames/transition-walk-sleep-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-walk-sleep-2.svg b/assets/pets/cat/frames/transition-walk-sleep-2.svg new file mode 100644 index 0000000..c8cccf6 --- /dev/null +++ b/assets/pets/cat/frames/transition-walk-sleep-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/transition-walk-sleep-3.svg b/assets/pets/cat/frames/transition-walk-sleep-3.svg new file mode 100644 index 0000000..561c3c3 --- /dev/null +++ b/assets/pets/cat/frames/transition-walk-sleep-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/walk-1.svg b/assets/pets/cat/frames/walk-1.svg new file mode 100644 index 0000000..f5d643f --- /dev/null +++ b/assets/pets/cat/frames/walk-1.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/walk-2.svg b/assets/pets/cat/frames/walk-2.svg new file mode 100644 index 0000000..e75c911 --- /dev/null +++ b/assets/pets/cat/frames/walk-2.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/walk-3.svg b/assets/pets/cat/frames/walk-3.svg new file mode 100644 index 0000000..83cdac6 --- /dev/null +++ b/assets/pets/cat/frames/walk-3.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/cat/frames/walk-4.svg b/assets/pets/cat/frames/walk-4.svg new file mode 100644 index 0000000..2a04d39 --- /dev/null +++ b/assets/pets/cat/frames/walk-4.svg @@ -0,0 +1 @@ + diff --git a/assets/pets/index.js b/assets/pets/index.js new file mode 100644 index 0000000..411913a --- /dev/null +++ b/assets/pets/index.js @@ -0,0 +1,21 @@ +'use strict'; + +const cat = require('./cat'); + +const PET_ASSETS = { + cat +}; + +function getPetAsset(type) { + return PET_ASSETS[type] || PET_ASSETS.cat; +} + +function getAvailablePetTypes() { + return Object.keys(PET_ASSETS); +} + +module.exports = { + PET_ASSETS, + getPetAsset, + getAvailablePetTypes +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..6a56d08 --- /dev/null +++ b/index.js @@ -0,0 +1,706 @@ +'use strict'; + +const { getPetAsset, getAvailablePetTypes } = require('./assets/pets'); + +const DEFAULT_CONFIG = { + enabled: true, + petCount: 1, + petTypes: ['cat'], + maxToys: 1, + toyCooldownMs: 20000, + successToyChance: 0.35, + chaseCursorChance: 0.04, + walkSpeed: 0.65, + reducedMotion: false, + onlyActivePane: true +}; + +const listenersByUid = new Map(); +const sessionStateByUid = new Map(); +let runtimeConfig = DEFAULT_CONFIG; + +const ANSI_RE = /\u001b\[[0-?]*[ -/]*[@-~]/g; +const ERROR_RE = /\b(command not found|not recognized|no such file|error|exception|failed|traceback)\b/i; +const PROMPT_RE = /(?:^|\n)[^\n]{0,80}(?:\$|%|>|❯|➜|λ)\s?$/; + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function normalizeConfig(input) { + const next = Object.assign({}, DEFAULT_CONFIG, input || {}); + const availablePetTypes = getAvailablePetTypes(); + next.petCount = clamp(Number(next.petCount) || 1, 1, 2); + next.maxToys = clamp(Number(next.maxToys) || 1, 0, 3); + next.toyCooldownMs = Math.max(Number(next.toyCooldownMs) || DEFAULT_CONFIG.toyCooldownMs, 3000); + next.successToyChance = clamp(Number(next.successToyChance), 0, 1); + next.chaseCursorChance = clamp(Number(next.chaseCursorChance), 0, 1); + next.walkSpeed = Math.max(Number(next.walkSpeed) || DEFAULT_CONFIG.walkSpeed, 0.2); + next.petTypes = Array.isArray(next.petTypes) && next.petTypes.length ? next.petTypes : DEFAULT_CONFIG.petTypes; + next.petTypes = next.petTypes.filter((type) => availablePetTypes.includes(type)); + if (next.petTypes.length === 0) { + next.petTypes = DEFAULT_CONFIG.petTypes.slice(); + } + return next; +} + +function modeToClip(mode) { + if (mode === 'sleep') { + return 'sleep'; + } + if (mode === 'inspect') { + return 'inspect'; + } + if (mode === 'chase-cursor' || mode === 'chase-toy') { + return 'chase'; + } + if (mode === 'walk') { + return 'walk'; + } + return 'idle'; +} + +function getClipDuration(petType, clipName, reducedMotion) { + const petAsset = getPetAsset(petType); + const clip = petAsset.clips[clipName]; + if (!clip) { + return 1000; + } + + const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps; + return (clip.frames.length / fps) * 1000; +} + +function resolveAnimationState(pet, desiredClip, now, reducedMotion) { + const petAsset = getPetAsset(pet.type); + const currentClip = pet.clip || desiredClip; + const activeClip = petAsset.clips[currentClip] ? currentClip : desiredClip; + const activeDef = petAsset.clips[activeClip]; + const elapsed = now - (pet.clipStartedAt || now); + const activeDuration = getClipDuration(pet.type, activeClip, reducedMotion); + + if (activeDef && activeDef.nextClip && elapsed >= activeDuration) { + const nextClip = pet.transitionTo || activeDef.nextClip; + return { + clip: nextClip, + clipStartedAt: now, + transitionTo: null + }; + } + + if (activeClip === desiredClip) { + return { + clip: activeClip, + clipStartedAt: pet.clipStartedAt || now, + transitionTo: null + }; + } + + const transitionClip = petAsset.transitions[`${activeClip}->${desiredClip}`] || null; + if (transitionClip) { + return { + clip: transitionClip, + clipStartedAt: now, + transitionTo: desiredClip + }; + } + + return { + clip: desiredClip, + clipStartedAt: now, + transitionTo: null + }; +} + +function getAnimatedFrame(pet, reducedMotion, now) { + const petAsset = getPetAsset(pet.type); + const clipName = pet.clip || 'idle'; + const clip = petAsset.clips[clipName] || petAsset.clips.idle; + const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps; + const duration = 1000 / fps; + const elapsed = now - (pet.clipStartedAt || now); + const rawIndex = clip.frames.length === 1 ? 0 : Math.floor(elapsed / duration); + const safeIndex = ((rawIndex % clip.frames.length) + clip.frames.length) % clip.frames.length; + return clip.frames[safeIndex] || petAsset.clips.idle.frames[0]; +} + +function subscribe(uid, listener) { + if (!uid) { + return () => {}; + } + + if (!listenersByUid.has(uid)) { + listenersByUid.set(uid, new Set()); + } + + const listeners = listenersByUid.get(uid); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + listenersByUid.delete(uid); + } + }; +} + +function emit(uid, event) { + const listeners = listenersByUid.get(uid); + if (!listeners) { + return; + } + + listeners.forEach((listener) => { + try { + listener(event); + } catch (error) { + console.error('[hyperpets] listener error', error); + } + }); +} + +function stripAnsi(value) { + return String(value || '').replace(ANSI_RE, ''); +} + +function inspectSessionData(uid, data) { + const clean = stripAnsi(data); + const state = sessionStateByUid.get(uid) || { + tail: '', + promptSeen: false, + lastPrompt: '', + sawError: false, + lastToyAt: 0 + }; + + state.tail = (state.tail + clean).slice(-500); + if (ERROR_RE.test(clean)) { + state.sawError = true; + emit(uid, { type: 'error-output' }); + } + + const promptMatch = state.tail.match(PROMPT_RE); + const promptText = promptMatch ? promptMatch[0] : ''; + if (promptText && promptText !== state.lastPrompt) { + if (state.promptSeen && !state.sawError) { + const now = Date.now(); + const ready = now - state.lastToyAt >= runtimeConfig.toyCooldownMs; + if (ready && Math.random() <= runtimeConfig.successToyChance) { + state.lastToyAt = now; + emit(uid, { type: 'command-success' }); + } + } + + state.promptSeen = true; + state.lastPrompt = promptText; + state.sawError = false; + } + + sessionStateByUid.set(uid, state); +} + +exports.decorateConfig = (config) => { + runtimeConfig = normalizeConfig(config.hyperPets); + return Object.assign({}, config, { + css: ` + ${config.css || ''} + .hyperpets-layer { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 12; + } + .hyperpets-floor { + position: absolute; + left: 0; + right: 0; + bottom: 6px; + height: 42px; + opacity: 0.12; + background: linear-gradient(180deg, transparent 0%, rgba(255,255,255,0.15) 100%); + } + .hyperpets-pet { + position: absolute; + bottom: 12px; + width: 48px; + height: 32px; + transform-origin: center bottom; + will-change: transform, left; + } + .hyperpets-toy { + position: absolute; + bottom: 10px; + width: 12px; + height: 12px; + border-radius: 999px; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08); + } + .hyperpets-toy.ball { + background: #d85f5f; + } + .hyperpets-toy.bone { + width: 16px; + height: 8px; + border-radius: 999px; + background: #dac7a2; + } + .hyperpets-pet.excited { + filter: saturate(1.25); + } + .hyperpets-pet-svg { + width: 48px; + height: 32px; + overflow: visible; + } + .hyperpets-frame { + width: 48px; + height: 32px; + } + .hyperpets-frame svg { + display: block; + width: 48px; + height: 32px; + } + .hyperpets-z { + position: absolute; + bottom: 28px; + font-size: 10px; + opacity: 0.45; + color: rgba(255,255,255,0.75); + } + ` + }); +}; + +exports.middleware = (store) => (next) => (action) => { + if (action && action.type === 'SESSION_ADD_DATA' && action.uid && action.data) { + inspectSessionData(action.uid, action.data); + } + + return next(action); +}; + +exports.decorateTerm = (Term, { React }) => { + class HyperPetsLayer extends React.Component { + constructor(props) { + super(props); + this.state = this.buildInitialState(); + this._mounted = false; + this._unsubscribe = null; + this._raf = null; + this._lastFrameAt = 0; + this._container = null; + this._cursorFrame = null; + this._mouseX = null; + this._mouseInfluenceUntil = 0; + this._resizeHandler = this.handleResize.bind(this); + this._mouseHandler = this.handleMouseMove.bind(this); + this._tick = this.tick.bind(this); + } + + buildInitialState() { + const config = runtimeConfig; + const pets = []; + for (let index = 0; index < config.petCount; index += 1) { + const now = Date.now(); + const type = config.petTypes[index % config.petTypes.length] || 'cat'; + pets.push({ + id: `pet-${index}`, + type, + x: 24 + index * 48, + vx: 0.1 + Math.random() * 0.14, + facing: 1, + mode: 'idle', + mood: 'calm', + targetX: null, + sleepUntil: 0, + nextDecisionAt: now + 2200 + Math.random() * 1800, + nextAttentionAt: now + 2500 + Math.random() * 2500, + attentionUntil: 0, + zOffset: Math.round(Math.random() * 6), + clip: 'idle', + clipStartedAt: now, + transitionTo: null + }); + } + + return { + width: 640, + height: 220, + active: this.props.isTermActive !== false, + pets, + toys: [] + }; + } + + componentDidMount() { + this._mounted = true; + this._container = this.props.termRef || null; + this.measure(); + window.addEventListener('resize', this._resizeHandler); + if (this._container) { + this._container.addEventListener('mousemove', this._mouseHandler, { passive: true }); + } + this._unsubscribe = subscribe(this.props.uid, (event) => this.handlePetEvent(event)); + this._raf = window.requestAnimationFrame(this._tick); + } + + componentWillUnmount() { + this._mounted = false; + if (this._unsubscribe) { + this._unsubscribe(); + } + if (this._raf) { + window.cancelAnimationFrame(this._raf); + } + window.removeEventListener('resize', this._resizeHandler); + if (this._container) { + this._container.removeEventListener('mousemove', this._mouseHandler); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.termRef !== this.props.termRef) { + if (this._container) { + this._container.removeEventListener('mousemove', this._mouseHandler); + } + this._container = this.props.termRef || null; + if (this._container) { + this._container.addEventListener('mousemove', this._mouseHandler, { passive: true }); + this.measure(); + } + } + + if (this.props.cursorFrame !== prevProps.cursorFrame) { + this._cursorFrame = this.props.cursorFrame || null; + } + + if (this.props.isTermActive !== prevProps.isTermActive) { + this.setState({ active: this.props.isTermActive !== false }); + } + } + + handleResize() { + this.measure(); + } + + handleMouseMove(event) { + if (!this._container) { + return; + } + + const bounds = this._container.getBoundingClientRect(); + this._mouseX = clamp(event.clientX - bounds.left, 0, bounds.width); + this._mouseInfluenceUntil = Date.now() + 2200; + } + + handlePetEvent(event) { + if (event.type === 'command-success') { + this.spawnToy(); + } else if (event.type === 'error-output') { + this.setState((prevState) => ({ + pets: prevState.pets.map((pet) => Object.assign({}, pet, { + mood: 'alert', + mode: pet.mode === 'sleep' ? 'idle' : pet.mode, + sleepUntil: 0, + attentionUntil: 0, + nextAttentionAt: Date.now() + 2200, + nextDecisionAt: Date.now() + 1600 + })) + })); + } + } + + measure() { + if (!this.props.termRef) { + return; + } + + const bounds = this.props.termRef.getBoundingClientRect(); + this.setState({ + width: Math.max(140, Math.round(bounds.width || 0)), + height: Math.max(80, Math.round(bounds.height || 0)) + }); + } + + spawnToy() { + if (!this._mounted || runtimeConfig.maxToys <= 0) { + return; + } + + this.setState((prevState) => { + const width = prevState.width; + const keptToyCount = Math.max(runtimeConfig.maxToys - 1, 0); + const nextToys = keptToyCount === 0 ? [] : prevState.toys.slice(-keptToyCount); + const kind = Math.random() > 0.5 ? 'ball' : 'bone'; + const toy = { + id: `toy-${Date.now()}-${Math.round(Math.random() * 9999)}`, + kind, + x: clamp(48 + Math.random() * (width - 96), 12, width - 20), + expiresAt: Date.now() + 12000 + }; + const leadToy = toy; + const pets = prevState.pets.map((pet, index) => { + if (index > 0) { + return pet; + } + return Object.assign({}, pet, { + mode: 'chase-toy', + mood: 'excited', + targetX: leadToy.x, + sleepUntil: 0, + attentionUntil: Date.now() + 2400, + nextAttentionAt: Date.now() + 5000, + nextDecisionAt: Date.now() + 3200 + }); + }); + + return { + toys: nextToys.concat(toy), + pets + }; + }); + } + + tick(timestamp) { + if (!this._mounted) { + return; + } + + const dt = this._lastFrameAt ? Math.min(timestamp - this._lastFrameAt, 40) : 16; + this._lastFrameAt = timestamp; + const now = Date.now(); + + this.setState((prevState) => { + const width = prevState.width; + const active = runtimeConfig.onlyActivePane ? prevState.active : true; + const liveToys = prevState.toys.filter((toy) => toy.expiresAt > now); + const targetToy = liveToys[0] || null; + const cursorTarget = this._cursorFrame ? this._cursorFrame.x : null; + const mouseActive = this._mouseX != null && this._mouseInfluenceUntil > now; + + const pets = prevState.pets.map((pet) => { + const next = Object.assign({}, pet); + const laneMax = Math.max(18, width - 44); + const speedScale = runtimeConfig.reducedMotion ? 0.45 : 1; + const travel = next.vx * runtimeConfig.walkSpeed * dt * speedScale; + const attentionActive = next.attentionUntil > now; + + if (!active) { + next.mode = 'sleep'; + next.sleepUntil = now + 5000; + next.mood = 'calm'; + next.targetX = null; + next.attentionUntil = 0; + } else if (targetToy) { + next.targetX = targetToy.x; + next.mode = 'chase-toy'; + next.mood = 'excited'; + } else if (!attentionActive && now >= next.nextAttentionAt) { + const shouldNoticeMouse = mouseActive && Math.random() < runtimeConfig.chaseCursorChance; + const shouldNoticeCursor = cursorTarget != null && Math.random() < runtimeConfig.chaseCursorChance * 0.65; + + if (shouldNoticeMouse) { + next.targetX = this._mouseX; + next.mode = 'chase-cursor'; + next.mood = 'curious'; + next.attentionUntil = now + 1200 + Math.random() * 1200; + next.nextAttentionAt = now + 5000 + Math.random() * 5000; + next.nextDecisionAt = now + 1800 + Math.random() * 1200; + } else if (shouldNoticeCursor) { + next.targetX = cursorTarget; + next.mode = 'inspect'; + next.mood = 'curious'; + next.attentionUntil = now + 900 + Math.random() * 900; + next.nextAttentionAt = now + 4200 + Math.random() * 4200; + next.nextDecisionAt = now + 1500 + Math.random() * 900; + } else { + next.nextAttentionAt = now + 1800 + Math.random() * 2800; + } + } else if (now >= next.nextDecisionAt) { + const roll = Math.random(); + if (roll < 0.2) { + next.mode = 'sleep'; + next.sleepUntil = now + 3500 + Math.random() * 5000; + next.targetX = null; + next.attentionUntil = 0; + } else if (roll < 0.78) { + next.mode = 'walk'; + next.targetX = clamp(24 + Math.random() * (width - 48), 16, laneMax); + next.attentionUntil = now + 1200 + Math.random() * 1600; + } else { + next.mode = 'idle'; + next.targetX = null; + next.attentionUntil = 0; + } + next.mood = 'calm'; + next.nextDecisionAt = now + 3200 + Math.random() * 3400; + } + + if (next.mode === 'sleep' && now < next.sleepUntil) { + return next; + } + + if (next.mode === 'sleep' && now >= next.sleepUntil) { + next.mode = 'idle'; + next.nextDecisionAt = now + 1600 + Math.random() * 1600; + next.nextAttentionAt = now + 2000 + Math.random() * 2000; + } + + if (typeof next.targetX === 'number') { + const dx = next.targetX - next.x; + if (Math.abs(dx) < 4) { + next.targetX = null; + next.attentionUntil = 0; + next.mode = 'idle'; + } else { + next.facing = dx >= 0 ? 1 : -1; + next.x = clamp(next.x + Math.sign(dx) * travel, 12, laneMax); + if (next.mode !== 'chase-toy' && next.mode !== 'inspect' && next.mode !== 'chase-cursor') { + next.mode = 'walk'; + } + } + } else if (next.mode === 'walk') { + next.mode = 'idle'; + } + + if (next.x <= 12 || next.x >= laneMax) { + next.facing *= -1; + } + + const desiredClip = modeToClip(next.mode); + const animationState = resolveAnimationState(next, desiredClip, now, runtimeConfig.reducedMotion); + next.clip = animationState.clip; + next.clipStartedAt = animationState.clipStartedAt; + next.transitionTo = animationState.transitionTo; + + return next; + }); + + return { + toys: liveToys, + pets + }; + }); + + this._raf = window.requestAnimationFrame(this._tick); + } + + renderToy(toy) { + return React.createElement('div', { + key: toy.id, + className: `hyperpets-toy ${toy.kind}`, + style: { + left: `${toy.x}px` + } + }); + } + + renderPet(pet) { + const frame = getAnimatedFrame(pet, runtimeConfig.reducedMotion, Date.now()); + const className = [ + 'hyperpets-pet', + pet.mood === 'excited' ? 'excited' : '' + ].filter(Boolean).join(' '); + + return React.createElement( + 'div', + { + key: pet.id, + className, + style: { + left: `${pet.x}px`, + transform: `scaleX(${pet.facing}) translateZ(0)` + } + }, + React.createElement( + 'div', + { + className: 'hyperpets-frame', + dangerouslySetInnerHTML: { __html: frame.svg } + } + ), + pet.mode === 'sleep' + ? React.createElement('div', { + className: 'hyperpets-z', + style: { left: '30px', bottom: `${26 + pet.zOffset}px` } + }, 'z') + : null + ); + } + + render() { + if (!runtimeConfig.enabled) { + return null; + } + + return React.createElement( + 'div', + { className: 'hyperpets-layer' }, + React.createElement('div', { className: 'hyperpets-floor' }), + this.state.toys.map((toy) => this.renderToy(toy)), + this.state.pets.map((pet) => this.renderPet(pet)) + ); + } + } + + return class HyperPetsTerm extends React.Component { + constructor(props) { + super(props); + this.state = { + termRef: null, + cursorFrame: null + }; + this._lastCursorSyncAt = 0; + this._lastCursorX = null; + this.handleDecorated = this.handleDecorated.bind(this); + this.handleCursorMove = this.handleCursorMove.bind(this); + } + + handleDecorated(term) { + if (this.props.onDecorated) { + this.props.onDecorated(term); + } + + this.setState({ + termRef: term ? term.termRef : null + }); + } + + handleCursorMove(cursorFrame) { + if (this.props.onCursorMove) { + this.props.onCursorMove(cursorFrame); + } + + const now = Date.now(); + const x = cursorFrame && typeof cursorFrame.x === 'number' ? cursorFrame.x : null; + const movedEnough = x == null || this._lastCursorX == null || Math.abs(x - this._lastCursorX) >= 10; + const staleEnough = now - this._lastCursorSyncAt >= 180; + + if (movedEnough && staleEnough) { + this._lastCursorSyncAt = now; + this._lastCursorX = x; + this.setState({ cursorFrame }); + } + } + + render() { + const customChildren = [] + .concat(this.props.customChildren || []) + .concat(React.createElement(HyperPetsLayer, { + key: 'hyperpets-layer', + uid: this.props.uid, + termRef: this.state.termRef, + cursorFrame: this.state.cursorFrame, + isTermActive: this.props.isTermActive + })); + + return React.createElement(Term, Object.assign({}, this.props, { + onDecorated: this.handleDecorated, + onCursorMove: this.handleCursorMove, + customChildren + })); + } + }; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..27d1613 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "hyperpets", + "version": "0.1.0", + "description": "Animated pet companions for Hyper terminal panes.", + "main": "index.js", + "license": "MIT", + "keywords": [ + "hyper", + "hyper-plugin", + "terminal", + "pets" + ] +}