Updated sprites and pet logic
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
37
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
assets/pets/maple/frames/maple-r01-c01.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r01-c02.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/pets/maple/frames/maple-r01-c03.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r01-c04.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r01-c05.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/pets/maple/frames/maple-r01-c06.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r01-c07.png
Normal file
|
After Width: | Height: | Size: 652 B |
BIN
assets/pets/maple/frames/maple-r01-c08.png
Normal file
|
After Width: | Height: | Size: 652 B |
BIN
assets/pets/maple/frames/maple-r02-c01.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/pets/maple/frames/maple-r02-c02.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
assets/pets/maple/frames/maple-r02-c03.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/pets/maple/frames/maple-r02-c04.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r02-c05.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/pets/maple/frames/maple-r02-c06.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
assets/pets/maple/frames/maple-r02-c07.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/pets/maple/frames/maple-r02-c08.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/pets/maple/frames/maple-r03-c01.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/pets/maple/frames/maple-r03-c02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/pets/maple/frames/maple-r03-c03.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
assets/pets/maple/frames/maple-r03-c04.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/pets/maple/frames/maple-r03-c05.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r03-c06.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/pets/maple/frames/maple-r03-c07.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/pets/maple/frames/maple-r03-c08.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r04-c01.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/pets/maple/frames/maple-r04-c02.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r04-c03.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
assets/pets/maple/frames/maple-r04-c04.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/pets/maple/frames/maple-r04-c05.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
assets/pets/maple/frames/maple-r04-c06.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
assets/pets/maple/frames/maple-r04-c07.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
assets/pets/maple/frames/maple-r04-c08.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
assets/pets/maple/frames/maple-r05-c01.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r05-c02.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/pets/maple/frames/maple-r05-c03.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r05-c04.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r05-c05.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r05-c06.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
assets/pets/maple/frames/maple-r05-c07.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
assets/pets/maple/frames/maple-r05-c08.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
assets/pets/maple/frames/maple-r06-c01.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r06-c02.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/pets/maple/frames/maple-r06-c03.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
assets/pets/maple/frames/maple-r06-c04.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
assets/pets/maple/frames/maple-r06-c05.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/pets/maple/frames/maple-r06-c06.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r06-c07.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/pets/maple/frames/maple-r06-c08.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r07-c01.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/pets/maple/frames/maple-r07-c02.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r07-c03.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r07-c04.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/pets/maple/frames/maple-r07-c05.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/pets/maple/frames/maple-r07-c06.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r07-c07.png
Normal file
|
After Width: | Height: | Size: 807 B |
BIN
assets/pets/maple/frames/maple-r07-c08.png
Normal file
|
After Width: | Height: | Size: 807 B |
BIN
assets/pets/maple/frames/maple-r08-c01.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r08-c02.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/pets/maple/frames/maple-r08-c03.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/pets/maple/frames/maple-r08-c04.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r08-c05.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r08-c06.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r08-c07.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
assets/pets/maple/frames/maple-r08-c08.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
assets/pets/maple/frames/maple-r09-c01.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/pets/maple/frames/maple-r09-c02.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
assets/pets/maple/frames/maple-r09-c03.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/pets/maple/frames/maple-r09-c04.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/pets/maple/frames/maple-r09-c05.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/pets/maple/frames/maple-r09-c06.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/pets/maple/frames/maple-r09-c07.png
Normal file
|
After Width: | Height: | Size: 775 B |
BIN
assets/pets/maple/frames/maple-r09-c08.png
Normal file
|
After Width: | Height: | Size: 775 B |
6
assets/pets/maple/pet.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "maple",
|
||||||
|
"displayName": "Maple",
|
||||||
|
"description": "A tiny brownish-gray cat, fiercely protective and playful.",
|
||||||
|
"spritesheetPath": "spritesheet.webp"
|
||||||
|
}
|
||||||
BIN
assets/pets/maple/spritesheet.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
396
index.js
@@ -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;
|
||||||
next.mode = 'inspect';
|
if (isMovingMode) {
|
||||||
next.mood = 'curious';
|
next.targetX = null;
|
||||||
next.attentionUntil = now + 900 + Math.random() * 900;
|
next.mode = 'idle';
|
||||||
next.nextAttentionAt = now + 4200 + Math.random() * 4200;
|
next.pendingMode = 'inspect';
|
||||||
next.nextDecisionAt = now + 1500 + Math.random() * 900;
|
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 {
|
} 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,
|
||||||
'div',
|
runtimeConfig.debugFrames
|
||||||
{
|
? React.createElement(
|
||||||
className: 'hyperpets-frame',
|
'div',
|
||||||
dangerouslySetInnerHTML: { __html: frame.svg }
|
{ 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'
|
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
@@ -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"
|
||||||
60
scripts/stitch_sprite_sheet.sh
Normal 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"
|
||||||