Files
HyperPets/index.js
2026-04-12 20:41:43 -07:00

707 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
}));
}
};
};