diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd4f2b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.DS_Store diff --git a/README.md b/README.md index ce91eb8..d691f4c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The first pass includes: - idle, wander, sleep, cursor-chase, and toy-chase behavior - bounded toy spawning with cooldowns - 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 @@ -24,7 +24,11 @@ module.exports = { maxToys: 1, toyCooldownMs: 20000, successToyChance: 0.35, - walkSpeed: 0.65 + walkSpeed: 0.45, + animationSpeed: 0.5, + sleepMinDurationMs: 30000, + sleepMaxDurationMs: 180000, + debugFrames: false } }, localPlugins: [ @@ -50,7 +54,11 @@ hyperPets: { 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 } @@ -60,11 +68,30 @@ hyperPets: { - 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 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 - 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 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`. + +## 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. diff --git a/assets/pets/index.js b/assets/pets/index.js index 411913a..daa5204 100644 --- a/assets/pets/index.js +++ b/assets/pets/index.js @@ -1,9 +1,12 @@ 'use strict'; const cat = require('./cat'); +const maple = require('./maple'); const PET_ASSETS = { - cat + cat: maple, + maple, + legacyCat: cat }; function getPetAsset(type) { diff --git a/assets/pets/maple.js b/assets/pets/maple.js new file mode 100644 index 0000000..2cd950e --- /dev/null +++ b/assets/pets/maple.js @@ -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' + } +}; diff --git a/assets/pets/maple/frames/maple-r01-c01.png b/assets/pets/maple/frames/maple-r01-c01.png new file mode 100644 index 0000000..a3f632e Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c01.png differ diff --git a/assets/pets/maple/frames/maple-r01-c02.png b/assets/pets/maple/frames/maple-r01-c02.png new file mode 100644 index 0000000..2e1e7ed Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c02.png differ diff --git a/assets/pets/maple/frames/maple-r01-c03.png b/assets/pets/maple/frames/maple-r01-c03.png new file mode 100644 index 0000000..c62a1a0 Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c03.png differ diff --git a/assets/pets/maple/frames/maple-r01-c04.png b/assets/pets/maple/frames/maple-r01-c04.png new file mode 100644 index 0000000..f64edc3 Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c04.png differ diff --git a/assets/pets/maple/frames/maple-r01-c05.png b/assets/pets/maple/frames/maple-r01-c05.png new file mode 100644 index 0000000..ca71b8b Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c05.png differ diff --git a/assets/pets/maple/frames/maple-r01-c06.png b/assets/pets/maple/frames/maple-r01-c06.png new file mode 100644 index 0000000..8625ebe Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c06.png differ diff --git a/assets/pets/maple/frames/maple-r01-c07.png b/assets/pets/maple/frames/maple-r01-c07.png new file mode 100644 index 0000000..c155168 Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c07.png differ diff --git a/assets/pets/maple/frames/maple-r01-c08.png b/assets/pets/maple/frames/maple-r01-c08.png new file mode 100644 index 0000000..c155168 Binary files /dev/null and b/assets/pets/maple/frames/maple-r01-c08.png differ diff --git a/assets/pets/maple/frames/maple-r02-c01.png b/assets/pets/maple/frames/maple-r02-c01.png new file mode 100644 index 0000000..a6a5361 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c01.png differ diff --git a/assets/pets/maple/frames/maple-r02-c02.png b/assets/pets/maple/frames/maple-r02-c02.png new file mode 100644 index 0000000..0be9cd5 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c02.png differ diff --git a/assets/pets/maple/frames/maple-r02-c03.png b/assets/pets/maple/frames/maple-r02-c03.png new file mode 100644 index 0000000..1e68f21 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c03.png differ diff --git a/assets/pets/maple/frames/maple-r02-c04.png b/assets/pets/maple/frames/maple-r02-c04.png new file mode 100644 index 0000000..b459a61 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c04.png differ diff --git a/assets/pets/maple/frames/maple-r02-c05.png b/assets/pets/maple/frames/maple-r02-c05.png new file mode 100644 index 0000000..d2efd46 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c05.png differ diff --git a/assets/pets/maple/frames/maple-r02-c06.png b/assets/pets/maple/frames/maple-r02-c06.png new file mode 100644 index 0000000..7b51024 Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c06.png differ diff --git a/assets/pets/maple/frames/maple-r02-c07.png b/assets/pets/maple/frames/maple-r02-c07.png new file mode 100644 index 0000000..03e3f1c Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c07.png differ diff --git a/assets/pets/maple/frames/maple-r02-c08.png b/assets/pets/maple/frames/maple-r02-c08.png new file mode 100644 index 0000000..1b0e54e Binary files /dev/null and b/assets/pets/maple/frames/maple-r02-c08.png differ diff --git a/assets/pets/maple/frames/maple-r03-c01.png b/assets/pets/maple/frames/maple-r03-c01.png new file mode 100644 index 0000000..d331a5b Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c01.png differ diff --git a/assets/pets/maple/frames/maple-r03-c02.png b/assets/pets/maple/frames/maple-r03-c02.png new file mode 100644 index 0000000..56e403f Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c02.png differ diff --git a/assets/pets/maple/frames/maple-r03-c03.png b/assets/pets/maple/frames/maple-r03-c03.png new file mode 100644 index 0000000..2de6ddc Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c03.png differ diff --git a/assets/pets/maple/frames/maple-r03-c04.png b/assets/pets/maple/frames/maple-r03-c04.png new file mode 100644 index 0000000..fd153b9 Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c04.png differ diff --git a/assets/pets/maple/frames/maple-r03-c05.png b/assets/pets/maple/frames/maple-r03-c05.png new file mode 100644 index 0000000..d462505 Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c05.png differ diff --git a/assets/pets/maple/frames/maple-r03-c06.png b/assets/pets/maple/frames/maple-r03-c06.png new file mode 100644 index 0000000..952613b Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c06.png differ diff --git a/assets/pets/maple/frames/maple-r03-c07.png b/assets/pets/maple/frames/maple-r03-c07.png new file mode 100644 index 0000000..34545f4 Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c07.png differ diff --git a/assets/pets/maple/frames/maple-r03-c08.png b/assets/pets/maple/frames/maple-r03-c08.png new file mode 100644 index 0000000..fb8b322 Binary files /dev/null and b/assets/pets/maple/frames/maple-r03-c08.png differ diff --git a/assets/pets/maple/frames/maple-r04-c01.png b/assets/pets/maple/frames/maple-r04-c01.png new file mode 100644 index 0000000..c0f59a6 Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c01.png differ diff --git a/assets/pets/maple/frames/maple-r04-c02.png b/assets/pets/maple/frames/maple-r04-c02.png new file mode 100644 index 0000000..605543e Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c02.png differ diff --git a/assets/pets/maple/frames/maple-r04-c03.png b/assets/pets/maple/frames/maple-r04-c03.png new file mode 100644 index 0000000..231038f Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c03.png differ diff --git a/assets/pets/maple/frames/maple-r04-c04.png b/assets/pets/maple/frames/maple-r04-c04.png new file mode 100644 index 0000000..8e1d487 Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c04.png differ diff --git a/assets/pets/maple/frames/maple-r04-c05.png b/assets/pets/maple/frames/maple-r04-c05.png new file mode 100644 index 0000000..0c0631e Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c05.png differ diff --git a/assets/pets/maple/frames/maple-r04-c06.png b/assets/pets/maple/frames/maple-r04-c06.png new file mode 100644 index 0000000..0c0631e Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c06.png differ diff --git a/assets/pets/maple/frames/maple-r04-c07.png b/assets/pets/maple/frames/maple-r04-c07.png new file mode 100644 index 0000000..0c0631e Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c07.png differ diff --git a/assets/pets/maple/frames/maple-r04-c08.png b/assets/pets/maple/frames/maple-r04-c08.png new file mode 100644 index 0000000..0c0631e Binary files /dev/null and b/assets/pets/maple/frames/maple-r04-c08.png differ diff --git a/assets/pets/maple/frames/maple-r05-c01.png b/assets/pets/maple/frames/maple-r05-c01.png new file mode 100644 index 0000000..a9126ff Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c01.png differ diff --git a/assets/pets/maple/frames/maple-r05-c02.png b/assets/pets/maple/frames/maple-r05-c02.png new file mode 100644 index 0000000..80cdee1 Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c02.png differ diff --git a/assets/pets/maple/frames/maple-r05-c03.png b/assets/pets/maple/frames/maple-r05-c03.png new file mode 100644 index 0000000..03cde2a Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c03.png differ diff --git a/assets/pets/maple/frames/maple-r05-c04.png b/assets/pets/maple/frames/maple-r05-c04.png new file mode 100644 index 0000000..956c151 Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c04.png differ diff --git a/assets/pets/maple/frames/maple-r05-c05.png b/assets/pets/maple/frames/maple-r05-c05.png new file mode 100644 index 0000000..fa9c0ef Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c05.png differ diff --git a/assets/pets/maple/frames/maple-r05-c06.png b/assets/pets/maple/frames/maple-r05-c06.png new file mode 100644 index 0000000..bb83bfb Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c06.png differ diff --git a/assets/pets/maple/frames/maple-r05-c07.png b/assets/pets/maple/frames/maple-r05-c07.png new file mode 100644 index 0000000..bb83bfb Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c07.png differ diff --git a/assets/pets/maple/frames/maple-r05-c08.png b/assets/pets/maple/frames/maple-r05-c08.png new file mode 100644 index 0000000..bb83bfb Binary files /dev/null and b/assets/pets/maple/frames/maple-r05-c08.png differ diff --git a/assets/pets/maple/frames/maple-r06-c01.png b/assets/pets/maple/frames/maple-r06-c01.png new file mode 100644 index 0000000..e01e07e Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c01.png differ diff --git a/assets/pets/maple/frames/maple-r06-c02.png b/assets/pets/maple/frames/maple-r06-c02.png new file mode 100644 index 0000000..9162b77 Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c02.png differ diff --git a/assets/pets/maple/frames/maple-r06-c03.png b/assets/pets/maple/frames/maple-r06-c03.png new file mode 100644 index 0000000..f1729ef Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c03.png differ diff --git a/assets/pets/maple/frames/maple-r06-c04.png b/assets/pets/maple/frames/maple-r06-c04.png new file mode 100644 index 0000000..3eea911 Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c04.png differ diff --git a/assets/pets/maple/frames/maple-r06-c05.png b/assets/pets/maple/frames/maple-r06-c05.png new file mode 100644 index 0000000..46cb729 Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c05.png differ diff --git a/assets/pets/maple/frames/maple-r06-c06.png b/assets/pets/maple/frames/maple-r06-c06.png new file mode 100644 index 0000000..daa4ecf Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c06.png differ diff --git a/assets/pets/maple/frames/maple-r06-c07.png b/assets/pets/maple/frames/maple-r06-c07.png new file mode 100644 index 0000000..ce1d778 Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c07.png differ diff --git a/assets/pets/maple/frames/maple-r06-c08.png b/assets/pets/maple/frames/maple-r06-c08.png new file mode 100644 index 0000000..a394f2b Binary files /dev/null and b/assets/pets/maple/frames/maple-r06-c08.png differ diff --git a/assets/pets/maple/frames/maple-r07-c01.png b/assets/pets/maple/frames/maple-r07-c01.png new file mode 100644 index 0000000..38b0adf Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c01.png differ diff --git a/assets/pets/maple/frames/maple-r07-c02.png b/assets/pets/maple/frames/maple-r07-c02.png new file mode 100644 index 0000000..8935bf8 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c02.png differ diff --git a/assets/pets/maple/frames/maple-r07-c03.png b/assets/pets/maple/frames/maple-r07-c03.png new file mode 100644 index 0000000..d870be2 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c03.png differ diff --git a/assets/pets/maple/frames/maple-r07-c04.png b/assets/pets/maple/frames/maple-r07-c04.png new file mode 100644 index 0000000..6f4a374 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c04.png differ diff --git a/assets/pets/maple/frames/maple-r07-c05.png b/assets/pets/maple/frames/maple-r07-c05.png new file mode 100644 index 0000000..b0ac231 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c05.png differ diff --git a/assets/pets/maple/frames/maple-r07-c06.png b/assets/pets/maple/frames/maple-r07-c06.png new file mode 100644 index 0000000..8afc643 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c06.png differ diff --git a/assets/pets/maple/frames/maple-r07-c07.png b/assets/pets/maple/frames/maple-r07-c07.png new file mode 100644 index 0000000..dcb9c13 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c07.png differ diff --git a/assets/pets/maple/frames/maple-r07-c08.png b/assets/pets/maple/frames/maple-r07-c08.png new file mode 100644 index 0000000..dcb9c13 Binary files /dev/null and b/assets/pets/maple/frames/maple-r07-c08.png differ diff --git a/assets/pets/maple/frames/maple-r08-c01.png b/assets/pets/maple/frames/maple-r08-c01.png new file mode 100644 index 0000000..35968f9 Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c01.png differ diff --git a/assets/pets/maple/frames/maple-r08-c02.png b/assets/pets/maple/frames/maple-r08-c02.png new file mode 100644 index 0000000..d51aee1 Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c02.png differ diff --git a/assets/pets/maple/frames/maple-r08-c03.png b/assets/pets/maple/frames/maple-r08-c03.png new file mode 100644 index 0000000..3a4349b Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c03.png differ diff --git a/assets/pets/maple/frames/maple-r08-c04.png b/assets/pets/maple/frames/maple-r08-c04.png new file mode 100644 index 0000000..e1e5a7d Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c04.png differ diff --git a/assets/pets/maple/frames/maple-r08-c05.png b/assets/pets/maple/frames/maple-r08-c05.png new file mode 100644 index 0000000..0075735 Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c05.png differ diff --git a/assets/pets/maple/frames/maple-r08-c06.png b/assets/pets/maple/frames/maple-r08-c06.png new file mode 100644 index 0000000..06bc12d Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c06.png differ diff --git a/assets/pets/maple/frames/maple-r08-c07.png b/assets/pets/maple/frames/maple-r08-c07.png new file mode 100644 index 0000000..df8919f Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c07.png differ diff --git a/assets/pets/maple/frames/maple-r08-c08.png b/assets/pets/maple/frames/maple-r08-c08.png new file mode 100644 index 0000000..df8919f Binary files /dev/null and b/assets/pets/maple/frames/maple-r08-c08.png differ diff --git a/assets/pets/maple/frames/maple-r09-c01.png b/assets/pets/maple/frames/maple-r09-c01.png new file mode 100644 index 0000000..c999444 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c01.png differ diff --git a/assets/pets/maple/frames/maple-r09-c02.png b/assets/pets/maple/frames/maple-r09-c02.png new file mode 100644 index 0000000..3b4105a Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c02.png differ diff --git a/assets/pets/maple/frames/maple-r09-c03.png b/assets/pets/maple/frames/maple-r09-c03.png new file mode 100644 index 0000000..147f136 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c03.png differ diff --git a/assets/pets/maple/frames/maple-r09-c04.png b/assets/pets/maple/frames/maple-r09-c04.png new file mode 100644 index 0000000..08e57a6 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c04.png differ diff --git a/assets/pets/maple/frames/maple-r09-c05.png b/assets/pets/maple/frames/maple-r09-c05.png new file mode 100644 index 0000000..7dba9c7 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c05.png differ diff --git a/assets/pets/maple/frames/maple-r09-c06.png b/assets/pets/maple/frames/maple-r09-c06.png new file mode 100644 index 0000000..72c2e82 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c06.png differ diff --git a/assets/pets/maple/frames/maple-r09-c07.png b/assets/pets/maple/frames/maple-r09-c07.png new file mode 100644 index 0000000..9633d48 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c07.png differ diff --git a/assets/pets/maple/frames/maple-r09-c08.png b/assets/pets/maple/frames/maple-r09-c08.png new file mode 100644 index 0000000..9633d48 Binary files /dev/null and b/assets/pets/maple/frames/maple-r09-c08.png differ diff --git a/assets/pets/maple/pet.json b/assets/pets/maple/pet.json new file mode 100644 index 0000000..cda54db --- /dev/null +++ b/assets/pets/maple/pet.json @@ -0,0 +1,6 @@ +{ + "id": "maple", + "displayName": "Maple", + "description": "A tiny brownish-gray cat, fiercely protective and playful.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/assets/pets/maple/spritesheet.webp b/assets/pets/maple/spritesheet.webp new file mode 100644 index 0000000..01979f5 Binary files /dev/null and b/assets/pets/maple/spritesheet.webp differ diff --git a/index.js b/index.js index 6a56d08..62050be 100644 --- a/index.js +++ b/index.js @@ -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)) diff --git a/scripts/split_sprite_sheet.sh b/scripts/split_sprite_sheet.sh new file mode 100755 index 0000000..0d39890 --- /dev/null +++ b/scripts/split_sprite_sheet.sh @@ -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 [prefix] + +Example: + bash scripts/split_sprite_sheet.sh assets/pets/maple/spritesheet.webp 192 208 assets/pets/maple/frames maple + +Outputs: + /-r01-c01.png + /-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" diff --git a/scripts/stitch_sprite_sheet.sh b/scripts/stitch_sprite_sheet.sh new file mode 100644 index 0000000..f7e8441 --- /dev/null +++ b/scripts/stitch_sprite_sheet.sh @@ -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 [prefix] + +Example: + bash scripts/stitch_sprite_sheet.sh assets/pets/maple/frames 8 9 /tmp/maple-sheet.png maple + +Expected input names: + /-r01-c01.png + /-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"