Files
HyperPets/index.js

1015 lines
32 KiB
JavaScript
Raw Permalink 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.45,
animationSpeed: 0.5,
sleepMinDurationMs: 30000,
sleepMaxDurationMs: 180000,
debugFrames: false,
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.animationSpeed = Math.max(Number(next.animationSpeed) || DEFAULT_CONFIG.animationSpeed, 0.15);
next.sleepMinDurationMs = Math.max(Number(next.sleepMinDurationMs) || DEFAULT_CONFIG.sleepMinDurationMs, 1000);
next.sleepMaxDurationMs = Math.max(Number(next.sleepMaxDurationMs) || DEFAULT_CONFIG.sleepMaxDurationMs, next.sleepMinDurationMs);
next.debugFrames = Boolean(next.debugFrames);
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 findHyperPetsConfig(value, depth) {
if (!value || typeof value !== 'object' || depth > 4) {
return null;
}
if (value.hyperPets && typeof value.hyperPets === 'object') {
return value.hyperPets;
}
const keys = Object.keys(value);
for (let index = 0; index < keys.length; index += 1) {
const nested = value[keys[index]];
if (nested && typeof nested === 'object') {
const found = findHyperPetsConfig(nested, depth + 1);
if (found) {
return found;
}
}
}
return null;
}
function refreshRuntimeConfigFromRenderer() {
try {
if (typeof window === 'undefined') {
return runtimeConfig;
}
const candidates = [
window.__hyperpetsRuntimeConfig,
window.config,
window.config && window.config.getConfig ? window.config.getConfig() : null,
window.store && window.store.getState ? window.store.getState() : null
];
for (let index = 0; index < candidates.length; index += 1) {
const found = findHyperPetsConfig(candidates[index], 0);
if (found) {
const normalized = normalizeConfig(found);
runtimeConfig = normalized;
window.__hyperpetsRuntimeConfig = normalized;
return normalized;
}
}
} catch (error) {
console.warn('[hyperpets] failed to refresh renderer config', error);
}
return runtimeConfig;
}
function randomIdleClip() {
const variants = ['idle0', 'idle1', 'idle2', 'idle3'];
return variants[Math.floor(Math.random() * variants.length)];
}
function randomSleepDuration() {
const min = runtimeConfig.sleepMinDurationMs;
const max = runtimeConfig.sleepMaxDurationMs;
if (max <= min) {
return min;
}
return min + Math.round(Math.random() * (max - min));
}
function isIdleClipName(clipName) {
return typeof clipName === 'string' && /^idle\d*$/.test(clipName);
}
function getDefaultClipName(petAsset) {
if (petAsset.clips.idle0) {
return 'idle0';
}
if (petAsset.clips.idle) {
return 'idle';
}
return Object.keys(petAsset.clips)[0];
}
function getEffectiveClipFps(clip, reducedMotion) {
const baseFps = clip.fps * runtimeConfig.animationSpeed;
return reducedMotion ? Math.max(baseFps * 0.6, 1) : Math.max(baseFps, 1);
}
function modeToClip(pet) {
const mode = pet.mode;
const facingRight = pet.facing >= 0;
const petAsset = getPetAsset(pet.type);
if (mode === 'sleep') {
return 'sleep';
}
if (mode === 'inspect') {
return facingRight ? 'inspectRight' : 'inspectLeft';
}
if (mode === 'chase-cursor' || mode === 'chase-toy') {
return facingRight ? 'walkRight' : 'walkLeft';
}
if (mode === 'walk') {
return facingRight ? 'walkRight' : 'walkLeft';
}
if (pet.idleClip && petAsset.clips[pet.idleClip]) {
return pet.idleClip;
}
if (isIdleClipName(pet.clip) && petAsset.clips[pet.clip]) {
return pet.clip;
}
return getDefaultClipName(petAsset);
}
function getClipDuration(petType, clipName, reducedMotion) {
const petAsset = getPetAsset(petType);
const clip = petAsset.clips[clipName];
if (!clip) {
return 1000;
}
const fps = getEffectiveClipFps(clip, reducedMotion);
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 defaultClipName = getDefaultClipName(petAsset);
const clipName = pet.clip || defaultClipName;
const clip = petAsset.clips[clipName] || petAsset.clips[defaultClipName];
const fps = getEffectiveClipFps(clip, reducedMotion);
const safeIndex = typeof pet.frameIndex === 'number' && clip.frames[pet.frameIndex]
? pet.frameIndex
: 0;
const frame = clip.frames[safeIndex] || petAsset.clips[defaultClipName].frames[0];
return {
clipName,
fps,
frame,
frameIndex: safeIndex,
frameCount: clip.frames.length
};
}
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);
try {
if (typeof window !== 'undefined') {
window.__hyperpetsRuntimeConfig = runtimeConfig;
}
} catch (error) {
// Ignore window access errors during non-renderer evaluation.
}
console.log('[hyperpets] decorateConfig', {
input: config.hyperPets || null,
normalized: runtimeConfig
});
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;
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 {
position: relative;
overflow: hidden;
transform-origin: center bottom;
}
.hyperpets-frame-debug {
outline: 1px dashed rgba(80, 220, 255, 0.7);
background-color: rgba(80, 220, 255, 0.06);
}
.hyperpets-frame svg {
display: block;
width: 100%;
height: 100%;
}
.hyperpets-debug-label {
position: absolute;
left: -12px;
bottom: 56px;
min-width: 150px;
padding: 4px 6px;
border-radius: 6px;
background: rgba(8, 12, 18, 0.86);
color: #9ee7ff;
font: 10px/1.35 Menlo, Monaco, monospace;
white-space: pre;
pointer-events: none;
}
.hyperpets-debug-hud {
position: absolute;
top: 8px;
left: 8px;
max-width: min(420px, calc(100% - 16px));
padding: 6px 8px;
border-radius: 8px;
background: rgba(8, 12, 18, 0.9);
color: #9ee7ff;
font: 11px/1.4 Menlo, Monaco, monospace;
white-space: pre-wrap;
pointer-events: none;
z-index: 20;
}
.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);
this._debugFrameHistory = new Map();
this._debugLastLoggedToken = new Map();
}
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';
const petAsset = getPetAsset(type);
const idleClip = petAsset.clips.idle0 ? randomIdleClip() : getDefaultClipName(petAsset);
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,
pendingMode: null,
pendingModeAt: 0,
pendingFacing: 1,
idleClip,
zOffset: Math.round(Math.random() * 6),
clip: idleClip,
clipStartedAt: now,
frameIndex: 0,
nextFrameAt: now + (1000 / getEffectiveClipFps(petAsset.clips[idleClip], runtimeConfig.reducedMotion)),
transitionTo: null
});
}
return {
width: 640,
height: 220,
active: this.props.isTermActive !== false,
pets,
toys: []
};
}
componentDidMount() {
this._mounted = true;
this._container = this.props.termRef || null;
refreshRuntimeConfigFromRenderer();
if (runtimeConfig.debugFrames) {
console.log('[hyperpets] HyperPetsLayer mounted', {
uid: this.props.uid,
runtimeConfig
});
}
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,
pendingMode: null,
pendingModeAt: 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;
const isMovingMode = next.mode === 'walk' || next.mode === 'chase-toy' || next.mode === 'chase-cursor';
if (!active) {
next.mode = 'sleep';
next.sleepUntil = now + randomSleepDuration();
next.mood = 'calm';
next.targetX = null;
next.attentionUntil = 0;
next.pendingMode = null;
next.pendingModeAt = 0;
} else if (next.pendingMode && next.mode === 'idle' && now >= next.pendingModeAt) {
next.mode = next.pendingMode;
next.pendingMode = null;
next.pendingModeAt = 0;
next.targetX = null;
next.facing = next.pendingFacing || next.facing;
if (next.mode === 'sleep') {
next.sleepUntil = now + randomSleepDuration();
next.attentionUntil = 0;
} else if (next.mode === 'inspect') {
next.attentionUntil = now + 1600 + Math.random() * 1400;
next.nextDecisionAt = now + 1800 + Math.random() * 1200;
}
} 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.pendingFacing = cursorTarget >= next.x ? 1 : -1;
if (isMovingMode) {
next.targetX = null;
next.mode = 'idle';
next.pendingMode = 'inspect';
next.pendingModeAt = now + 900;
next.attentionUntil = 0;
next.nextDecisionAt = now + 900;
} else {
next.targetX = null;
next.mode = 'inspect';
next.mood = 'curious';
next.facing = next.pendingFacing;
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) {
if (isMovingMode) {
next.mode = 'idle';
next.targetX = null;
next.attentionUntil = 0;
next.pendingMode = 'sleep';
next.pendingModeAt = now + 900;
} else {
next.mode = 'sleep';
next.sleepUntil = now + randomSleepDuration();
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;
next.pendingMode = null;
next.pendingModeAt = 0;
} else {
next.mode = 'idle';
next.targetX = null;
next.attentionUntil = 0;
next.pendingMode = null;
next.pendingModeAt = 0;
next.idleClip = randomIdleClip();
}
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;
next.pendingMode = null;
next.pendingModeAt = 0;
next.idleClip = randomIdleClip();
}
if (typeof next.targetX === 'number' && (next.mode === 'walk' || next.mode === 'chase-toy' || next.mode === 'chase-cursor')) {
const dx = next.targetX - next.x;
if (Math.abs(dx) < 4) {
next.targetX = null;
next.attentionUntil = 0;
next.mode = 'idle';
next.pendingMode = null;
next.pendingModeAt = 0;
next.idleClip = randomIdleClip();
} 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 (typeof next.targetX === 'number') {
next.targetX = null;
} else if (next.mode === 'walk') {
next.mode = 'idle';
}
if (next.x <= 12 || next.x >= laneMax) {
next.facing *= -1;
}
const desiredClip = modeToClip(next);
const animationState = resolveAnimationState(next, desiredClip, now, runtimeConfig.reducedMotion);
const previousClip = next.clip;
next.clip = animationState.clip;
next.clipStartedAt = animationState.clipStartedAt;
next.transitionTo = animationState.transitionTo;
const petAsset = getPetAsset(next.type);
const defaultClipName = getDefaultClipName(petAsset);
const activeClip = petAsset.clips[next.clip] || petAsset.clips[defaultClipName];
const frameDuration = 1000 / getEffectiveClipFps(activeClip, runtimeConfig.reducedMotion);
if (previousClip !== next.clip) {
next.frameIndex = 0;
next.nextFrameAt = now + frameDuration;
} else if (now >= (next.nextFrameAt || 0)) {
const lastIndex = Math.max(activeClip.frames.length - 1, 0);
const currentIndex = typeof next.frameIndex === 'number' ? next.frameIndex : 0;
next.frameIndex = lastIndex === 0 ? 0 : currentIndex + 1;
if (next.frameIndex > lastIndex) {
next.frameIndex = activeClip.nextClip ? lastIndex : 0;
}
next.nextFrameAt = now + frameDuration;
}
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 petAsset = getPetAsset(pet.type);
const frameInfo = getAnimatedFrame(pet, runtimeConfig.reducedMotion, Date.now());
const frame = frameInfo.frame;
const desiredFacing = pet.facing >= 0 ? 'right' : 'left';
const basisFacing = frame.facingBasis || 'left';
const flipX = desiredFacing !== basisFacing;
const frameRow = frame.frameY != null ? Math.round(frame.frameY / frame.frameHeight) + 1 : null;
const frameCol = frame.frameX != null ? Math.round(frame.frameX / frame.frameWidth) + 1 : null;
const debugToken = frameRow != null && frameCol != null
? `${frameInfo.clipName}:${frameInfo.frameIndex + 1}/${frameInfo.frameCount}@r${frameRow}c${frameCol}`
: `${frameInfo.clipName}:${frameInfo.frameIndex + 1}/${frameInfo.frameCount}@svg`;
const existingHistory = this._debugFrameHistory.get(pet.id) || [];
const nextHistory = existingHistory.length > 0 && existingHistory[existingHistory.length - 1] === debugToken
? existingHistory
: existingHistory.concat(debugToken).slice(-5);
this._debugFrameHistory.set(pet.id, nextHistory);
if (runtimeConfig.debugFrames) {
const previousToken = this._debugLastLoggedToken.get(pet.id);
if (previousToken !== debugToken) {
this._debugLastLoggedToken.set(pet.id, debugToken);
console.log(
`[hyperpets] ${pet.id} mode=${pet.mode} facing=${desiredFacing} basis=${basisFacing} clip=${frameInfo.clipName} frame=${frameInfo.frameIndex + 1}/${frameInfo.frameCount}` +
(frameRow != null && frameCol != null ? ` cell=r${frameRow}c${frameCol}` : ' cell=svg')
);
}
}
const className = [
'hyperpets-pet',
pet.mood === 'excited' ? 'excited' : ''
].filter(Boolean).join(' ');
const frameNode = frame.svg
? React.createElement('div', {
className: 'hyperpets-frame',
style: {
width: `${petAsset.width}px`,
height: `${petAsset.height}px`,
transform: `scaleX(${flipX ? -1 : 1}) translateZ(0)`
},
dangerouslySetInnerHTML: { __html: frame.svg }
})
: React.createElement('div', {
className: `hyperpets-frame${runtimeConfig.debugFrames ? ' hyperpets-frame-debug' : ''}`,
style: {
width: `${petAsset.width}px`,
height: `${petAsset.height}px`,
transform: `scaleX(${flipX ? -1 : 1}) translateZ(0)`,
backgroundImage: `url("${frame.sheetUrl}")`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${(frame.sheetWidth / frame.frameWidth) * petAsset.width}px ${(frame.sheetHeight / frame.frameHeight) * petAsset.height}px`,
backgroundPosition: `-${(frame.frameX / frame.frameWidth) * petAsset.width}px -${(frame.frameY / frame.frameHeight) * petAsset.height}px`
}
});
return React.createElement(
'div',
{
key: pet.id,
className,
style: {
left: `${pet.x}px`,
width: `${petAsset.width}px`,
height: `${petAsset.height}px`
}
},
frameNode,
runtimeConfig.debugFrames
? React.createElement(
'div',
{ className: 'hyperpets-debug-label' },
[
`${frameInfo.clipName} ${frameInfo.frameIndex + 1}/${frameInfo.frameCount}`,
`face ${desiredFacing} basis ${basisFacing}`,
frameRow != null && frameCol != null
? `cell r${frameRow} c${frameCol}`
: 'svg-frame',
`hist ${nextHistory.join(' | ')}`
].join('\n')
)
: null,
pet.mode === 'sleep'
? React.createElement('div', {
className: 'hyperpets-z',
style: { left: '30px', bottom: `${26 + pet.zOffset}px` }
}, 'z')
: null
);
}
render() {
refreshRuntimeConfigFromRenderer();
if (!runtimeConfig.enabled) {
return null;
}
const debugHud = runtimeConfig.debugFrames
? React.createElement(
'div',
{ className: 'hyperpets-debug-hud' },
this.state.pets.map((pet) => {
const history = this._debugFrameHistory.get(pet.id) || [];
return [
`${pet.id} mode=${pet.mode} facing=${pet.facing >= 0 ? 'right' : 'left'}`,
history.length ? `hist ${history.join(' | ')}` : 'hist (waiting for frames)'
].join('\n');
}).join('\n\n')
)
: null;
return React.createElement(
'div',
{ className: 'hyperpets-layer' },
debugHud,
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
}));
}
};
};