Updated sprites and pet logic

This commit is contained in:
2026-05-18 11:56:09 -07:00
parent 4cd0d8c589
commit abf39ce4b8
81 changed files with 658 additions and 50 deletions

396
index.js
View File

@@ -10,7 +10,11 @@ const DEFAULT_CONFIG = {
toyCooldownMs: 20000,
successToyChance: 0.35,
chaseCursorChance: 0.04,
walkSpeed: 0.65,
walkSpeed: 0.45,
animationSpeed: 0.5,
sleepMinDurationMs: 30000,
sleepMaxDurationMs: 180000,
debugFrames: false,
reducedMotion: false,
onlyActivePane: true
};
@@ -36,6 +40,10 @@ function normalizeConfig(input) {
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) {
@@ -44,20 +52,114 @@ function normalizeConfig(input) {
return next;
}
function modeToClip(mode) {
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 'inspect';
return facingRight ? 'inspectRight' : 'inspectLeft';
}
if (mode === 'chase-cursor' || mode === 'chase-toy') {
return 'chase';
return facingRight ? 'walkRight' : 'walkLeft';
}
if (mode === 'walk') {
return 'walk';
return facingRight ? 'walkRight' : 'walkLeft';
}
return 'idle';
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) {
@@ -67,7 +169,7 @@ function getClipDuration(petType, clipName, reducedMotion) {
return 1000;
}
const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps;
const fps = getEffectiveClipFps(clip, reducedMotion);
return (clip.frames.length / fps) * 1000;
}
@@ -114,14 +216,21 @@ function resolveAnimationState(pet, desiredClip, now, reducedMotion) {
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];
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) {
@@ -201,6 +310,17 @@ function inspectSessionData(uid, data) {
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 || ''}
@@ -223,8 +343,6 @@ exports.decorateConfig = (config) => {
.hyperpets-pet {
position: absolute;
bottom: 12px;
width: 48px;
height: 32px;
transform-origin: center bottom;
will-change: transform, left;
}
@@ -254,13 +372,45 @@ exports.decorateConfig = (config) => {
overflow: visible;
}
.hyperpets-frame {
width: 48px;
height: 32px;
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: 48px;
height: 32px;
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;
@@ -297,6 +447,8 @@ exports.decorateTerm = (Term, { React }) => {
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() {
@@ -305,6 +457,8 @@ exports.decorateTerm = (Term, { React }) => {
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,
@@ -318,9 +472,15 @@ exports.decorateTerm = (Term, { React }) => {
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: 'idle',
clip: idleClip,
clipStartedAt: now,
frameIndex: 0,
nextFrameAt: now + (1000 / getEffectiveClipFps(petAsset.clips[idleClip], runtimeConfig.reducedMotion)),
transitionTo: null
});
}
@@ -337,6 +497,13 @@ exports.decorateTerm = (Term, { React }) => {
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) {
@@ -405,6 +572,8 @@ exports.decorateTerm = (Term, { React }) => {
mode: pet.mode === 'sleep' ? 'idle' : pet.mode,
sleepUntil: 0,
attentionUntil: 0,
pendingMode: null,
pendingModeAt: 0,
nextAttentionAt: Date.now() + 2200,
nextDecisionAt: Date.now() + 1600
}))
@@ -486,13 +655,29 @@ exports.decorateTerm = (Term, { React }) => {
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 + 5000;
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';
@@ -509,30 +694,54 @@ exports.decorateTerm = (Term, { React }) => {
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;
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) {
next.mode = 'sleep';
next.sleepUntil = now + 3500 + Math.random() * 5000;
next.targetX = null;
next.attentionUntil = 0;
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;
@@ -546,14 +755,20 @@ exports.decorateTerm = (Term, { React }) => {
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') {
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);
@@ -561,6 +776,8 @@ exports.decorateTerm = (Term, { React }) => {
next.mode = 'walk';
}
}
} else if (typeof next.targetX === 'number') {
next.targetX = null;
} else if (next.mode === 'walk') {
next.mode = 'idle';
}
@@ -569,12 +786,31 @@ exports.decorateTerm = (Term, { React }) => {
next.facing *= -1;
}
const desiredClip = modeToClip(next.mode);
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;
});
@@ -598,11 +834,58 @@ exports.decorateTerm = (Term, { React }) => {
}
renderPet(pet) {
const frame = getAnimatedFrame(pet, runtimeConfig.reducedMotion, Date.now());
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',
@@ -611,16 +894,25 @@ exports.decorateTerm = (Term, { React }) => {
className,
style: {
left: `${pet.x}px`,
transform: `scaleX(${pet.facing}) translateZ(0)`
width: `${petAsset.width}px`,
height: `${petAsset.height}px`
}
},
React.createElement(
'div',
{
className: 'hyperpets-frame',
dangerouslySetInnerHTML: { __html: frame.svg }
}
),
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',
@@ -631,13 +923,29 @@ exports.decorateTerm = (Term, { React }) => {
}
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))