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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.DS_Store

View File

@@ -8,7 +8,7 @@ The first pass includes:
- idle, wander, sleep, cursor-chase, and toy-chase behavior - idle, wander, sleep, cursor-chase, and toy-chase behavior
- bounded toy spawning with cooldowns - bounded toy spawning with cooldowns
- lightweight per-pane overlay rendering - lightweight per-pane overlay rendering
- external cat sprite frames with state transitions between major behaviors - external cat sprite assets with state transitions between major behaviors
## Install locally during development ## Install locally during development
@@ -24,7 +24,11 @@ module.exports = {
maxToys: 1, maxToys: 1,
toyCooldownMs: 20000, toyCooldownMs: 20000,
successToyChance: 0.35, successToyChance: 0.35,
walkSpeed: 0.65 walkSpeed: 0.45,
animationSpeed: 0.5,
sleepMinDurationMs: 30000,
sleepMaxDurationMs: 180000,
debugFrames: false
} }
}, },
localPlugins: [ localPlugins: [
@@ -50,7 +54,11 @@ hyperPets: {
toyCooldownMs: 20000, toyCooldownMs: 20000,
successToyChance: 0.35, successToyChance: 0.35,
chaseCursorChance: 0.04, chaseCursorChance: 0.04,
walkSpeed: 0.65, walkSpeed: 0.45,
animationSpeed: 0.5,
sleepMinDurationMs: 30000,
sleepMaxDurationMs: 180000,
debugFrames: false,
reducedMotion: false, reducedMotion: false,
onlyActivePane: true onlyActivePane: true
} }
@@ -60,11 +68,30 @@ hyperPets: {
- Command-success toy spawning currently uses a prompt-return heuristic from terminal output, not shell integration. - 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 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. - The current animation system uses asset manifests under `assets/pets/`. `cat` is currently aliased to the Maple sprite sheet so existing configs keep working.
## Asset Pipeline ## Asset Pipeline
- Cat frames live in `assets/pets/cat/frames/*.svg`. - 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 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. - Maple lives in `assets/pets/maple/` and is wired as a sprite-sheet asset through `assets/pets/maple.js`.
- The renderer supports both individual SVG frames and sprite-sheet cells behind the same clip API.
- 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`. - 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`.
## Sheet Tools
- Split a uniform sheet into per-frame PNGs:
```bash
bash scripts/split_sprite_sheet.sh assets/pets/maple/spritesheet.webp 192 208 assets/pets/maple/frames maple
```
- Stitch a frame directory back into a sheet:
```bash
bash scripts/stitch_sprite_sheet.sh assets/pets/maple/frames 8 9 /tmp/maple-sheet.png maple
```
## Debugging
- Set `hyperPets.debugFrames` to `true` to show the active clip, frame number, facing direction, sprite-sheet row/column, and a rolling history of the last 5 displayed frames beside each pet.

View File

@@ -1,9 +1,12 @@
'use strict'; 'use strict';
const cat = require('./cat'); const cat = require('./cat');
const maple = require('./maple');
const PET_ASSETS = { const PET_ASSETS = {
cat cat: maple,
maple,
legacyCat: cat
}; };
function getPetAsset(type) { function getPetAsset(type) {

135
assets/pets/maple.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict';
const path = require('path');
const { pathToFileURL } = require('url');
const SHEET_WIDTH = 1536;
const SHEET_HEIGHT = 1872;
const CELL_WIDTH = 192;
const CELL_HEIGHT = 208;
const SHEET_URL = pathToFileURL(path.join(__dirname, 'maple', 'spritesheet.webp')).href;
function cell(row, col, facingBasis) {
return {
sheetUrl: SHEET_URL,
sheetWidth: SHEET_WIDTH,
sheetHeight: SHEET_HEIGHT,
frameX: col * CELL_WIDTH,
frameY: row * CELL_HEIGHT,
frameWidth: CELL_WIDTH,
frameHeight: CELL_HEIGHT,
facingBasis: facingBasis || 'left'
};
}
function rowFrames(row, startCol, count, facingBasis) {
const frames = [];
for (let index = 0; index < count; index += 1) {
frames.push(cell(row, startCol + index, facingBasis));
}
return frames;
}
module.exports = {
width: 48,
height: 52,
clips: {
idle0: {
fps: 1.4,
frames: rowFrames(0, 0, 6, 'left')
},
idle1: {
fps: 1.4,
frames: rowFrames(6, 0, 6, 'left')
},
idle2: {
fps: 1.4,
frames: rowFrames(7, 0, 6, 'left')
},
idle3: {
fps: 1.4,
frames: rowFrames(8, 0, 6, 'left')
},
walkRight: {
fps: 4.4,
frames: rowFrames(1, 0, 8, 'right')
},
walkLeft: {
fps: 4.4,
frames: rowFrames(2, 0, 8, 'left')
},
inspectLeft: {
fps: 2.2,
frames: rowFrames(3, 0, 4, 'left')
},
inspectRight: {
fps: 2.2,
frames: rowFrames(3, 0, 4, 'left')
},
pounceLeft: {
fps: 4,
frames: rowFrames(4, 0, 5, 'left')
},
pounceRight: {
fps: 4,
frames: rowFrames(4, 0, 5, 'left')
},
sleep: {
fps: 1,
frames: [
cell(5, 3, 'left')
]
},
sleepWakeLeft: {
fps: 2.6,
frames: rowFrames(5, 4, 3, 'left'),
nextClip: 'idle0'
},
sleepWakeRight: {
fps: 2.6,
frames: rowFrames(5, 4, 3, 'left'),
nextClip: 'idle0'
},
transitionIdleWalkLeft: {
fps: 3,
frames: rowFrames(2, 0, 3, 'left'),
nextClip: 'walkLeft'
},
transitionIdleWalkRight: {
fps: 3,
frames: rowFrames(1, 0, 3, 'right'),
nextClip: 'walkRight'
},
transitionIdleSleep: {
fps: 2.8,
frames: rowFrames(5, 0, 4, 'left'),
nextClip: 'sleep'
}
},
transitions: {
'idle0->walkLeft': 'transitionIdleWalkLeft',
'idle1->walkLeft': 'transitionIdleWalkLeft',
'idle2->walkLeft': 'transitionIdleWalkLeft',
'idle3->walkLeft': 'transitionIdleWalkLeft',
'idle0->walkRight': 'transitionIdleWalkRight',
'idle1->walkRight': 'transitionIdleWalkRight',
'idle2->walkRight': 'transitionIdleWalkRight',
'idle3->walkRight': 'transitionIdleWalkRight',
'idle0->sleep': 'transitionIdleSleep',
'idle1->sleep': 'transitionIdleSleep',
'idle2->sleep': 'transitionIdleSleep',
'idle3->sleep': 'transitionIdleSleep',
'inspectLeft->walkLeft': 'transitionIdleWalkLeft',
'inspectRight->walkRight': 'transitionIdleWalkRight',
'sleep->idle0': 'sleepWakeLeft',
'sleep->idle1': 'sleepWakeLeft',
'sleep->idle2': 'sleepWakeLeft',
'sleep->idle3': 'sleepWakeLeft',
'sleep->walkLeft': 'sleepWakeLeft',
'sleep->walkRight': 'sleepWakeRight',
'sleep->inspectLeft': 'sleepWakeLeft',
'sleep->inspectRight': 'sleepWakeRight',
'sleep->pounceLeft': 'sleepWakeLeft',
'sleep->pounceRight': 'sleepWakeRight'
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

View File

@@ -0,0 +1,6 @@
{
"id": "maple",
"displayName": "Maple",
"description": "A tiny brownish-gray cat, fiercely protective and playful.",
"spritesheetPath": "spritesheet.webp"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

380
index.js
View File

@@ -10,7 +10,11 @@ const DEFAULT_CONFIG = {
toyCooldownMs: 20000, toyCooldownMs: 20000,
successToyChance: 0.35, successToyChance: 0.35,
chaseCursorChance: 0.04, chaseCursorChance: 0.04,
walkSpeed: 0.65, walkSpeed: 0.45,
animationSpeed: 0.5,
sleepMinDurationMs: 30000,
sleepMaxDurationMs: 180000,
debugFrames: false,
reducedMotion: false, reducedMotion: false,
onlyActivePane: true onlyActivePane: true
}; };
@@ -36,6 +40,10 @@ function normalizeConfig(input) {
next.successToyChance = clamp(Number(next.successToyChance), 0, 1); next.successToyChance = clamp(Number(next.successToyChance), 0, 1);
next.chaseCursorChance = clamp(Number(next.chaseCursorChance), 0, 1); next.chaseCursorChance = clamp(Number(next.chaseCursorChance), 0, 1);
next.walkSpeed = Math.max(Number(next.walkSpeed) || DEFAULT_CONFIG.walkSpeed, 0.2); 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 = Array.isArray(next.petTypes) && next.petTypes.length ? next.petTypes : DEFAULT_CONFIG.petTypes;
next.petTypes = next.petTypes.filter((type) => availablePetTypes.includes(type)); next.petTypes = next.petTypes.filter((type) => availablePetTypes.includes(type));
if (next.petTypes.length === 0) { if (next.petTypes.length === 0) {
@@ -44,20 +52,114 @@ function normalizeConfig(input) {
return next; 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') { if (mode === 'sleep') {
return 'sleep'; return 'sleep';
} }
if (mode === 'inspect') { if (mode === 'inspect') {
return 'inspect'; return facingRight ? 'inspectRight' : 'inspectLeft';
} }
if (mode === 'chase-cursor' || mode === 'chase-toy') { if (mode === 'chase-cursor' || mode === 'chase-toy') {
return 'chase'; return facingRight ? 'walkRight' : 'walkLeft';
} }
if (mode === 'walk') { 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) { function getClipDuration(petType, clipName, reducedMotion) {
@@ -67,7 +169,7 @@ function getClipDuration(petType, clipName, reducedMotion) {
return 1000; 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; return (clip.frames.length / fps) * 1000;
} }
@@ -114,14 +216,21 @@ function resolveAnimationState(pet, desiredClip, now, reducedMotion) {
function getAnimatedFrame(pet, reducedMotion, now) { function getAnimatedFrame(pet, reducedMotion, now) {
const petAsset = getPetAsset(pet.type); const petAsset = getPetAsset(pet.type);
const clipName = pet.clip || 'idle'; const defaultClipName = getDefaultClipName(petAsset);
const clip = petAsset.clips[clipName] || petAsset.clips.idle; const clipName = pet.clip || defaultClipName;
const fps = reducedMotion ? Math.max(clip.fps * 0.6, 1) : clip.fps; const clip = petAsset.clips[clipName] || petAsset.clips[defaultClipName];
const duration = 1000 / fps; const fps = getEffectiveClipFps(clip, reducedMotion);
const elapsed = now - (pet.clipStartedAt || now); const safeIndex = typeof pet.frameIndex === 'number' && clip.frames[pet.frameIndex]
const rawIndex = clip.frames.length === 1 ? 0 : Math.floor(elapsed / duration); ? pet.frameIndex
const safeIndex = ((rawIndex % clip.frames.length) + clip.frames.length) % clip.frames.length; : 0;
return clip.frames[safeIndex] || petAsset.clips.idle.frames[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) { function subscribe(uid, listener) {
@@ -201,6 +310,17 @@ function inspectSessionData(uid, data) {
exports.decorateConfig = (config) => { exports.decorateConfig = (config) => {
runtimeConfig = normalizeConfig(config.hyperPets); 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, { return Object.assign({}, config, {
css: ` css: `
${config.css || ''} ${config.css || ''}
@@ -223,8 +343,6 @@ exports.decorateConfig = (config) => {
.hyperpets-pet { .hyperpets-pet {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
width: 48px;
height: 32px;
transform-origin: center bottom; transform-origin: center bottom;
will-change: transform, left; will-change: transform, left;
} }
@@ -254,13 +372,45 @@ exports.decorateConfig = (config) => {
overflow: visible; overflow: visible;
} }
.hyperpets-frame { .hyperpets-frame {
width: 48px; position: relative;
height: 32px; 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 { .hyperpets-frame svg {
display: block; display: block;
width: 48px; width: 100%;
height: 32px; 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 { .hyperpets-z {
position: absolute; position: absolute;
@@ -297,6 +447,8 @@ exports.decorateTerm = (Term, { React }) => {
this._resizeHandler = this.handleResize.bind(this); this._resizeHandler = this.handleResize.bind(this);
this._mouseHandler = this.handleMouseMove.bind(this); this._mouseHandler = this.handleMouseMove.bind(this);
this._tick = this.tick.bind(this); this._tick = this.tick.bind(this);
this._debugFrameHistory = new Map();
this._debugLastLoggedToken = new Map();
} }
buildInitialState() { buildInitialState() {
@@ -305,6 +457,8 @@ exports.decorateTerm = (Term, { React }) => {
for (let index = 0; index < config.petCount; index += 1) { for (let index = 0; index < config.petCount; index += 1) {
const now = Date.now(); const now = Date.now();
const type = config.petTypes[index % config.petTypes.length] || 'cat'; const type = config.petTypes[index % config.petTypes.length] || 'cat';
const petAsset = getPetAsset(type);
const idleClip = petAsset.clips.idle0 ? randomIdleClip() : getDefaultClipName(petAsset);
pets.push({ pets.push({
id: `pet-${index}`, id: `pet-${index}`,
type, type,
@@ -318,9 +472,15 @@ exports.decorateTerm = (Term, { React }) => {
nextDecisionAt: now + 2200 + Math.random() * 1800, nextDecisionAt: now + 2200 + Math.random() * 1800,
nextAttentionAt: now + 2500 + Math.random() * 2500, nextAttentionAt: now + 2500 + Math.random() * 2500,
attentionUntil: 0, attentionUntil: 0,
pendingMode: null,
pendingModeAt: 0,
pendingFacing: 1,
idleClip,
zOffset: Math.round(Math.random() * 6), zOffset: Math.round(Math.random() * 6),
clip: 'idle', clip: idleClip,
clipStartedAt: now, clipStartedAt: now,
frameIndex: 0,
nextFrameAt: now + (1000 / getEffectiveClipFps(petAsset.clips[idleClip], runtimeConfig.reducedMotion)),
transitionTo: null transitionTo: null
}); });
} }
@@ -337,6 +497,13 @@ exports.decorateTerm = (Term, { React }) => {
componentDidMount() { componentDidMount() {
this._mounted = true; this._mounted = true;
this._container = this.props.termRef || null; this._container = this.props.termRef || null;
refreshRuntimeConfigFromRenderer();
if (runtimeConfig.debugFrames) {
console.log('[hyperpets] HyperPetsLayer mounted', {
uid: this.props.uid,
runtimeConfig
});
}
this.measure(); this.measure();
window.addEventListener('resize', this._resizeHandler); window.addEventListener('resize', this._resizeHandler);
if (this._container) { if (this._container) {
@@ -405,6 +572,8 @@ exports.decorateTerm = (Term, { React }) => {
mode: pet.mode === 'sleep' ? 'idle' : pet.mode, mode: pet.mode === 'sleep' ? 'idle' : pet.mode,
sleepUntil: 0, sleepUntil: 0,
attentionUntil: 0, attentionUntil: 0,
pendingMode: null,
pendingModeAt: 0,
nextAttentionAt: Date.now() + 2200, nextAttentionAt: Date.now() + 2200,
nextDecisionAt: Date.now() + 1600 nextDecisionAt: Date.now() + 1600
})) }))
@@ -486,13 +655,29 @@ exports.decorateTerm = (Term, { React }) => {
const speedScale = runtimeConfig.reducedMotion ? 0.45 : 1; const speedScale = runtimeConfig.reducedMotion ? 0.45 : 1;
const travel = next.vx * runtimeConfig.walkSpeed * dt * speedScale; const travel = next.vx * runtimeConfig.walkSpeed * dt * speedScale;
const attentionActive = next.attentionUntil > now; const attentionActive = next.attentionUntil > now;
const isMovingMode = next.mode === 'walk' || next.mode === 'chase-toy' || next.mode === 'chase-cursor';
if (!active) { if (!active) {
next.mode = 'sleep'; next.mode = 'sleep';
next.sleepUntil = now + 5000; next.sleepUntil = now + randomSleepDuration();
next.mood = 'calm'; next.mood = 'calm';
next.targetX = null; next.targetX = null;
next.attentionUntil = 0; 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) { } else if (targetToy) {
next.targetX = targetToy.x; next.targetX = targetToy.x;
next.mode = 'chase-toy'; next.mode = 'chase-toy';
@@ -509,30 +694,54 @@ exports.decorateTerm = (Term, { React }) => {
next.nextAttentionAt = now + 5000 + Math.random() * 5000; next.nextAttentionAt = now + 5000 + Math.random() * 5000;
next.nextDecisionAt = now + 1800 + Math.random() * 1200; next.nextDecisionAt = now + 1800 + Math.random() * 1200;
} else if (shouldNoticeCursor) { } else if (shouldNoticeCursor) {
next.targetX = cursorTarget; 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.mode = 'inspect';
next.mood = 'curious'; next.mood = 'curious';
next.facing = next.pendingFacing;
next.attentionUntil = now + 900 + Math.random() * 900; next.attentionUntil = now + 900 + Math.random() * 900;
next.nextAttentionAt = now + 4200 + Math.random() * 4200; next.nextAttentionAt = now + 4200 + Math.random() * 4200;
next.nextDecisionAt = now + 1500 + Math.random() * 900; next.nextDecisionAt = now + 1500 + Math.random() * 900;
}
} else { } else {
next.nextAttentionAt = now + 1800 + Math.random() * 2800; next.nextAttentionAt = now + 1800 + Math.random() * 2800;
} }
} else if (now >= next.nextDecisionAt) { } else if (now >= next.nextDecisionAt) {
const roll = Math.random(); const roll = Math.random();
if (roll < 0.2) { if (roll < 0.2) {
next.mode = 'sleep'; if (isMovingMode) {
next.sleepUntil = now + 3500 + Math.random() * 5000; next.mode = 'idle';
next.targetX = null; next.targetX = null;
next.attentionUntil = 0; 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) { } else if (roll < 0.78) {
next.mode = 'walk'; next.mode = 'walk';
next.targetX = clamp(24 + Math.random() * (width - 48), 16, laneMax); next.targetX = clamp(24 + Math.random() * (width - 48), 16, laneMax);
next.attentionUntil = now + 1200 + Math.random() * 1600; next.attentionUntil = now + 1200 + Math.random() * 1600;
next.pendingMode = null;
next.pendingModeAt = 0;
} else { } else {
next.mode = 'idle'; next.mode = 'idle';
next.targetX = null; next.targetX = null;
next.attentionUntil = 0; next.attentionUntil = 0;
next.pendingMode = null;
next.pendingModeAt = 0;
next.idleClip = randomIdleClip();
} }
next.mood = 'calm'; next.mood = 'calm';
next.nextDecisionAt = now + 3200 + Math.random() * 3400; next.nextDecisionAt = now + 3200 + Math.random() * 3400;
@@ -546,14 +755,20 @@ exports.decorateTerm = (Term, { React }) => {
next.mode = 'idle'; next.mode = 'idle';
next.nextDecisionAt = now + 1600 + Math.random() * 1600; next.nextDecisionAt = now + 1600 + Math.random() * 1600;
next.nextAttentionAt = now + 2000 + Math.random() * 2000; 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; const dx = next.targetX - next.x;
if (Math.abs(dx) < 4) { if (Math.abs(dx) < 4) {
next.targetX = null; next.targetX = null;
next.attentionUntil = 0; next.attentionUntil = 0;
next.mode = 'idle'; next.mode = 'idle';
next.pendingMode = null;
next.pendingModeAt = 0;
next.idleClip = randomIdleClip();
} else { } else {
next.facing = dx >= 0 ? 1 : -1; next.facing = dx >= 0 ? 1 : -1;
next.x = clamp(next.x + Math.sign(dx) * travel, 12, laneMax); next.x = clamp(next.x + Math.sign(dx) * travel, 12, laneMax);
@@ -561,6 +776,8 @@ exports.decorateTerm = (Term, { React }) => {
next.mode = 'walk'; next.mode = 'walk';
} }
} }
} else if (typeof next.targetX === 'number') {
next.targetX = null;
} else if (next.mode === 'walk') { } else if (next.mode === 'walk') {
next.mode = 'idle'; next.mode = 'idle';
} }
@@ -569,12 +786,31 @@ exports.decorateTerm = (Term, { React }) => {
next.facing *= -1; next.facing *= -1;
} }
const desiredClip = modeToClip(next.mode); const desiredClip = modeToClip(next);
const animationState = resolveAnimationState(next, desiredClip, now, runtimeConfig.reducedMotion); const animationState = resolveAnimationState(next, desiredClip, now, runtimeConfig.reducedMotion);
const previousClip = next.clip;
next.clip = animationState.clip; next.clip = animationState.clip;
next.clipStartedAt = animationState.clipStartedAt; next.clipStartedAt = animationState.clipStartedAt;
next.transitionTo = animationState.transitionTo; 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 next;
}); });
@@ -598,11 +834,58 @@ exports.decorateTerm = (Term, { React }) => {
} }
renderPet(pet) { 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 = [ const className = [
'hyperpets-pet', 'hyperpets-pet',
pet.mood === 'excited' ? 'excited' : '' pet.mood === 'excited' ? 'excited' : ''
].filter(Boolean).join(' '); ].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( return React.createElement(
'div', 'div',
@@ -611,16 +894,25 @@ exports.decorateTerm = (Term, { React }) => {
className, className,
style: { style: {
left: `${pet.x}px`, left: `${pet.x}px`,
transform: `scaleX(${pet.facing}) translateZ(0)` width: `${petAsset.width}px`,
height: `${petAsset.height}px`
} }
}, },
React.createElement( frameNode,
runtimeConfig.debugFrames
? React.createElement(
'div', 'div',
{ { className: 'hyperpets-debug-label' },
className: 'hyperpets-frame', [
dangerouslySetInnerHTML: { __html: frame.svg } `${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' pet.mode === 'sleep'
? React.createElement('div', { ? React.createElement('div', {
className: 'hyperpets-z', className: 'hyperpets-z',
@@ -631,13 +923,29 @@ exports.decorateTerm = (Term, { React }) => {
} }
render() { render() {
refreshRuntimeConfigFromRenderer();
if (!runtimeConfig.enabled) { if (!runtimeConfig.enabled) {
return null; 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( return React.createElement(
'div', 'div',
{ className: 'hyperpets-layer' }, { className: 'hyperpets-layer' },
debugHud,
React.createElement('div', { className: 'hyperpets-floor' }), React.createElement('div', { className: 'hyperpets-floor' }),
this.state.toys.map((toy) => this.renderToy(toy)), this.state.toys.map((toy) => this.renderToy(toy)),
this.state.pets.map((pet) => this.renderPet(pet)) this.state.pets.map((pet) => this.renderPet(pet))

67
scripts/split_sprite_sheet.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 4 || $# -gt 5 ]]; then
cat <<'EOF'
Usage:
bash scripts/split_sprite_sheet.sh <sheet> <cell_width> <cell_height> <output_dir> [prefix]
Example:
bash scripts/split_sprite_sheet.sh assets/pets/maple/spritesheet.webp 192 208 assets/pets/maple/frames maple
Outputs:
<output_dir>/<prefix>-r01-c01.png
<output_dir>/<prefix>-r01-c02.png
...
Requirements:
- ffmpeg
- ffprobe
EOF
exit 1
fi
sheet=$1
cell_width=$2
cell_height=$3
output_dir=$4
prefix=${5:-frame}
if [[ ! -f "$sheet" ]]; then
echo "error: sheet not found: $sheet" >&2
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
echo "error: ffmpeg and ffprobe are required" >&2
exit 1
fi
sheet_width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$sheet")
sheet_height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$sheet")
if (( sheet_width % cell_width != 0 )); then
echo "error: sheet width $sheet_width is not divisible by cell width $cell_width" >&2
exit 1
fi
if (( sheet_height % cell_height != 0 )); then
echo "error: sheet height $sheet_height is not divisible by cell height $cell_height" >&2
exit 1
fi
cols=$(( sheet_width / cell_width ))
rows=$(( sheet_height / cell_height ))
mkdir -p "$output_dir"
for ((row = 0; row < rows; row++)); do
for ((col = 0; col < cols; col++)); do
x=$(( col * cell_width ))
y=$(( row * cell_height ))
out="$output_dir/${prefix}-r$(printf '%02d' $((row + 1)))-c$(printf '%02d' $((col + 1))).png"
ffmpeg -loglevel error -y -i "$sheet" -frames:v 1 -vf "crop=${cell_width}:${cell_height}:${x}:${y}" "$out"
done
done
echo "split complete: ${rows} rows x ${cols} cols -> $output_dir"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 4 || $# -gt 5 ]]; then
cat <<'EOF'
Usage:
bash scripts/stitch_sprite_sheet.sh <frames_dir> <cols> <rows> <output_sheet> [prefix]
Example:
bash scripts/stitch_sprite_sheet.sh assets/pets/maple/frames 8 9 /tmp/maple-sheet.png maple
Expected input names:
<frames_dir>/<prefix>-r01-c01.png
<frames_dir>/<prefix>-r01-c02.png
...
Requirements:
- ffmpeg
EOF
exit 1
fi
frames_dir=$1
cols=$2
rows=$3
output_sheet=$4
prefix=${5:-frame}
if [[ ! -d "$frames_dir" ]]; then
echo "error: frames directory not found: $frames_dir" >&2
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "error: ffmpeg is required" >&2
exit 1
fi
tmp_dir=$(mktemp -d /tmp/hyperpets-stitch.XXXXXX)
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
index=0
for ((row = 1; row <= rows; row++)); do
for ((col = 1; col <= cols; col++)); do
src="$frames_dir/${prefix}-r$(printf '%02d' "$row")-c$(printf '%02d' "$col").png"
if [[ ! -f "$src" ]]; then
echo "error: missing frame: $src" >&2
exit 1
fi
cp "$src" "$tmp_dir/$(printf '%04d' "$index").png"
index=$(( index + 1 ))
done
done
ffmpeg -loglevel error -y -framerate 1 -i "$tmp_dir/%04d.png" -frames:v 1 -vf "tile=${cols}x${rows}" "$output_sheet"
echo "sheet written: $output_sheet"