From 88e7d44e65293b2faeb15916eb633e0a506b0d7d Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 31 Jul 2026 13:09:54 -0700 Subject: [PATCH 1/2] feat(art): let painted frames override a composed pose Every pose is composed in code, which is why one sit covers 28 coats and a new coat costs nine hex values instead of an art pass. The trade is that changing how the pet looks means editing geometry, and there was no way in for hand drawn or generated art at all. src/art-frames.js is the escape hatch: a table of role-letter grids that wins over the composer for the pose and the coats it names, and nothing else. Lookup runs coat name, then build name, then '*', then the composer, so a pack of one frame is a valid pack and a half finished one still runs. Baked grids are stamped THROUGH buildSprite rather than around it, so they pick up outlineHalo(), the eye boxes and the muzzle anchor exactly as a composed pose does. That means the halo is never authored, a patchy outline gets its gaps filled, and drawCat needs no special case. A grid that does not match the pose's canvas is ignored rather than trusted, because the layout maths around it is built on those constants. scripts/import-frames.js turns painted PNGs into that table. It carries its own PNG decoder (colour types 0/2/3/4/6, every filter) rather than adding a dependency, the way pet-sheet.js hand-rolls its encoder. Each cell takes an area majority vote over the pixels covering it, so art that does not land exactly on the grid still imports, and every frame is checked before it is written: canvas size, an eye block either side of the seam eyeBox() splits on, a nose for the whiskers to anchor to, a floor row to stand on. A frame that fails is dropped with a reason instead of shipped. Only the five held poses are importable. The raised-limb activities are parameterised rigs (4 climb frames, up to 45 paw frames, 14 bat frames per coat) and a single still would freeze them mid swing, so the importer refuses them on purpose. The load-bearing test is a round trip: compose a real pose, paint it out to a PNG, read it back through the importer and assert the grid is identical. That covers decode, resample and colour snap against real sprite shapes rather than a hand written fixture. --- eslint.config.js | 6 +- package.json | 3 +- scripts/import-frames.js | 340 +++++++++++++++++++++++++++++++++++++++ scripts/overlay-vm.js | 2 +- src/art-frames.js | 34 ++++ src/index.html | 1 + src/renderer.js | 71 +++++--- tests/art-frames.test.js | 238 +++++++++++++++++++++++++++ 8 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 scripts/import-frames.js create mode 100644 src/art-frames.js create mode 100644 tests/art-frames.test.js diff --git a/eslint.config.js b/eslint.config.js index 17d758a..3953dfc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,8 @@ const sharedOverlay = { PET_SPECIES: 'readonly', SPECIES_IDS: 'readonly', speciesOf: 'readonly', coatsFor: 'readonly', isSpecies: 'readonly', defaultCoatIndex: 'readonly', CAT_COATS: 'readonly', DOG_COATS: 'readonly', BUILDS: 'readonly', TABBY: 'readonly', PATTERN_BUILD: 'readonly', + // art-frames.js provides the generated baked-pose table: + ART_FRAMES: 'readonly', BODY: 'readonly', G: 'readonly', GC: 'readonly', GR: 'readonly', HALO: 'readonly', rgbStr: 'readonly', toRgb: 'readonly', shadeStr: 'readonly', lerpHex: 'readonly', fillPlaceholders: 'readonly', CLIMB_FRAMES: 'readonly', @@ -49,7 +51,7 @@ module.exports = [ { // Node / CommonJS: main process, workers, scripts, tests, configs, template.js files: ['**/*.js'], - ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'], + ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js', 'src/art-frames.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'], languageOptions: { sourceType: 'commonjs', ecmaVersion: 2023, globals: { ...globals.node } }, }, { @@ -76,7 +78,7 @@ module.exports = [ { // cat-sprite.js / patterns.js are dual-loaded: classic + diff --git a/src/renderer.js b/src/renderer.js index 3188b13..65d756a 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -484,12 +484,14 @@ function climbSpriteFor(i, hand, dir) { } return sp; } -let spriteHunt = buildSprite(SPECIES_DEFS.huntCols, SPECIES_DEFS.huntRows, () => SPECIES_DEFS.hunt(buildFor(0, SPECIES_DEFS))); +// Cats share ONE crouch across every coat, so its baked override is looked up at +// index 0: name a coat and only that coat's key can ever match, so key it '*'. +let spriteHunt = posed('hunt', 0, SPECIES_DEFS.huntCols, SPECIES_DEFS.huntRows, () => SPECIES_DEFS.hunt(buildFor(0, SPECIES_DEFS))); let huntSprites = null; // dogs vary the bow by breed (markings); cats share one crouch function huntSpriteFor(i) { return (huntSprites && huntSprites[i]) || spriteHunt; } function buildHuntSprites(D) { return D === null || !isDog() ? null - : D.build.map((b, i) => buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(i, D)))); + : D.build.map((b, i) => posed('hunt', i, D.huntCols, D.huntRows, () => D.hunt(buildFor(i, D)))); } const TW = 24 * CELL, TH = 24 * CELL; // front-facing kneading-cat dims (per-coat sprites built below) // Sit grid is always 24x30, so SW/SH and the mochi bands stay constant across the @@ -568,14 +570,46 @@ const octx = oc.getContext('2d'); octx.imageSmoothingEnabled = false; const HEAD_SRC = 14 * CELL, FEET_SRC = 7 * CELL, MID_SRC = SH - HEAD_SRC - FEET_SRC; -const sprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.sit(buildFor(i, SPECIES_DEFS)))); +// --- baked frames (src/art-frames.js) --------------------------------------- +// A painted frame beats the composer for the pose and the coat it names, and +// everything else keeps composing, so a half finished art pack still runs. Only +// the five HELD poses can be baked: the raised-limb activities are parameterised +// rigs (pawSpriteFor / batSpriteFor / climbSpriteFor quantise a limb angle into a +// handful of frames) and one still would freeze them mid swing. A grid that does +// not match the pose's canvas is ignored rather than trusted, because the layout +// maths around it is built on those constants. +function artGrid(pose, i, cols, rows) { + const all = typeof ART_FRAMES !== 'undefined' ? ART_FRAMES : null; + const byPose = all && all[species] && all[species][pose]; + if (!byPose) return null; + const name = (PATTERNS[i] && PATTERNS[i].name) || ''; + const g = byPose[name] || byPose[PATTERN_BUILD[i]] || byPose['*'] || null; + if (!g || g.COLS !== cols || g.ROWS !== rows || !Array.isArray(g.rows) || g.rows.length !== rows) return null; + return g; +} + +// Stamp a baked grid THROUGH buildSprite so it picks up outlineHalo(), the eye +// boxes and the muzzle anchor exactly as a composed pose does - the halo is never +// painted by hand, and a patchy outline gets its gaps filled for free. +function posed(pose, i, cols, rows, compose) { + const g = artGrid(pose, i, cols, rows); + if (!g) return buildSprite(cols, rows, compose); + return buildSprite(cols, rows, () => { + for (let r = 0; r < rows; r++) { + const row = g.rows[r]; + for (let c = 0; c < cols && c < row.length; c++) if (row[c] !== '.') setCell(c, r, row[c]); + } + }); +} + +const sprites = PATTERN_BUILD.map((b, i) => posed('sit', i, 24, 30, () => SPECIES_DEFS.sit(buildFor(i, SPECIES_DEFS)))); // each coat also gets its own typing (kneading) body, so every breed types differently // one shared front "kneading cat" shape, recoloured per coat (+ tabby stripes / fluffy tufts) -const typeSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 24, () => SPECIES_DEFS.type(buildFor(i, SPECIES_DEFS)))); +const typeSprites = PATTERN_BUILD.map((b, i) => posed('type', i, 24, 24, () => SPECIES_DEFS.type(buildFor(i, SPECIES_DEFS)))); // and a dedicated loaf (resting) body per coat - same 24x30 size as the sit sprite -const loafSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.loaf(buildFor(i, SPECIES_DEFS)))); +const loafSprites = PATTERN_BUILD.map((b, i) => posed('loaf', i, 24, 30, () => SPECIES_DEFS.loaf(buildFor(i, SPECIES_DEFS)))); // and a rear-up "bat the butterfly" body per coat - same 24x30 size as the sit sprite -const rearSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.rear(buildFor(i, SPECIES_DEFS)))); +const rearSprites = PATTERN_BUILD.map((b, i) => posed('rear', i, 24, 30, () => SPECIES_DEFS.rear(buildFor(i, SPECIES_DEFS)))); huntSprites = buildHuntSprites(SPECIES_DEFS); const DEFAULT_PATTERN = Math.max(0, PATTERNS.findIndex((p) => p.name === (species === 'dog' ? 'Golden Retriever' : 'Tuxedo'))); const coatKey = (sp) => (sp === 'dog' ? 'dogPattern' : 'pattern'); @@ -606,11 +640,12 @@ function applyThemes(list) { PATTERN_BUILD.push(build); TABBY.push(!!th.tabby); const D = SPECIES_DEFS, tb = { ...(D.builds[build] || BUILDS[build] || {}), tabby: !!th.tabby }; - sprites.push(buildSprite(24, 30, () => D.sit(tb))); - typeSprites.push(buildSprite(24, 24, () => D.type(tb))); - loafSprites.push(buildSprite(24, 30, () => D.loaf(tb))); - rearSprites.push(buildSprite(24, 30, () => D.rear(tb))); - if (huntSprites) huntSprites.push(buildSprite(D.huntCols, D.huntRows, () => D.hunt(tb))); + const at = PATTERNS.length - 1; // this custom coat's index, for the baked-frame lookup + sprites.push(posed('sit', at, 24, 30, () => D.sit(tb))); + typeSprites.push(posed('type', at, 24, 24, () => D.type(tb))); + loafSprites.push(posed('loaf', at, 24, 30, () => D.loaf(tb))); + rearSprites.push(posed('rear', at, 24, 30, () => D.rear(tb))); + if (huntSprites) huntSprites.push(posed('hunt', at, D.huntCols, D.huntRows, () => D.hunt(tb))); } if (!(patternIndex >= 0 && patternIndex < PATTERNS.length)) patternIndex = DEFAULT_PATTERN; if (forcedPattern) { const i = PATTERNS.findIndex((p) => p.name.toLowerCase().includes(forcedPattern.toLowerCase())); if (i >= 0) patternIndex = i; } @@ -629,15 +664,15 @@ function setSpecies(next, coatIdx) { installTables(SPECIES_DEFS); BASE_PATTERNS = PATTERNS.length; const D = SPECIES_DEFS; - const rebuild = (arr, cols, rows, fn) => { + const rebuild = (pose, arr, cols, rows, fn) => { arr.length = 0; - for (let i = 0; i < D.build.length; i++) arr.push(buildSprite(cols, rows, () => fn(buildFor(i, D)))); + for (let i = 0; i < D.build.length; i++) arr.push(posed(pose, i, cols, rows, () => fn(buildFor(i, D)))); }; - rebuild(sprites, 24, 30, D.sit); - rebuild(typeSprites, 24, 24, D.type); - rebuild(loafSprites, 24, 30, D.loaf); - rebuild(rearSprites, 24, 30, D.rear); - spriteHunt = buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(0, D))); + rebuild('sit', sprites, 24, 30, D.sit); + rebuild('type', typeSprites, 24, 24, D.type); + rebuild('loaf', loafSprites, 24, 30, D.loaf); + rebuild('rear', rearSprites, 24, 30, D.rear); + spriteHunt = posed('hunt', 0, D.huntCols, D.huntRows, () => D.hunt(buildFor(0, D))); huntSprites = buildHuntSprites(D); _palKey = -1; // force a cold-palette rebuild for the new coats // climbImgs is deliberately NOT cleared: those frames decode once at startup and diff --git a/tests/art-frames.test.js b/tests/art-frames.test.js new file mode 100644 index 0000000..254b727 --- /dev/null +++ b/tests/art-frames.test.js @@ -0,0 +1,238 @@ +// Baked pose frames: the PNG -> role grid importer, and the renderer hook that +// lets an imported frame win over the composer. +// +// The load-bearing test is the round trip. A composed pose is painted out to a PNG +// with one flat placeholder colour per role, read back through the importer, and +// compared cell for cell against the grid it came from. That exercises the whole +// chain (PNG decode, area majority vote, colour snap) against real sprite shapes +// rather than a hand written fixture, and it fails loudly if any step drifts. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const imp = require('../scripts/import-frames.js'); +const { loadOverlay } = require('../scripts/overlay-vm.js'); + +const ROLE_RGB = Object.fromEntries(imp.PALETTE); + +// --- a minimal PNG encoder, so the fixtures are real files ------------------ +function crc(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; } +function chunk(t, d) { + const l = Buffer.alloc(4); l.writeUInt32BE(d.length, 0); + const b = Buffer.concat([Buffer.from(t), d]); + const cc = Buffer.alloc(4); cc.writeUInt32BE(crc(b), 0); + return Buffer.concat([l, b, cc]); +} +function encodePng(rgba, w, h) { + const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6; + const stride = w * 4, raw = Buffer.alloc(h * (stride + 1)); + for (let y = 0; y < h; y++) for (let x = 0; x < stride; x++) raw[y * (stride + 1) + 1 + x] = rgba[y * stride + x]; + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} + +// Paint a role grid at `scale` px per cell, flat, transparent where empty. The +// halo is deliberately not painted: outlineHalo() regrows it on import. +function paintGrid(rows, scale) { + const COLS = rows[0].length, ROWS = rows.length, W = COLS * scale, H = ROWS * scale; + const rgba = new Uint8ClampedArray(W * H * 4); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const ch = rows[r][c]; + const rgb = ROLE_RGB[ch]; + if (!rgb) continue; + for (let dy = 0; dy < scale; dy++) for (let dx = 0; dx < scale; dx++) { + const o = ((r * scale + dy) * W + c * scale + dx) * 4; + rgba[o] = rgb[0]; rgba[o + 1] = rgb[1]; rgba[o + 2] = rgb[2]; rgba[o + 3] = 255; + } + } + return encodePng(rgba, W, H); +} + +// A composed pose straight out of the overlay, as rows of role letters with the +// halo dropped (that is what a painted frame would contain). +// Array.from re-wraps as a HOST array: values crossing back from the vm carry the +// context's prototypes, so deepStrictEqual fails its prototype check even when the +// contents match exactly. +function composedRows(h, expr) { + const sp = h.run(`(() => { const s = ${expr}; return { rows: s.grid.map((r) => r.join('')), COLS: s.COLS, ROWS: s.ROWS }; })()`); + return { rows: Array.from(sp.rows, (r) => r.replace(/H/g, '.')), COLS: sp.COLS, ROWS: sp.ROWS }; +} + +function tmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pixelpets-frames-')); +} + +test('a painted pose round trips back to the grid it came from', () => { + const h = loadOverlay(); + const cases = [['cat', 'sit', 'sprites[0]'], ['cat', 'loaf', 'loafSprites[0]'], ['dog', 'sit', 'sprites[0]']]; + for (const [species, pose, expr] of cases) { + h.run(`setSpecies(${JSON.stringify(species)})`); + const want = composedRows(h, expr); + const dir = tmpdir(); + try { + fs.writeFileSync(path.join(dir, `${species}-${pose}.png`), paintGrid(want.rows, 11)); + const { data, ok, skipped } = imp.importDir(dir, {}); + assert.strictEqual(skipped, 0, `${species}/${pose} was skipped`); + assert.strictEqual(ok, 1); + const got = data[species][pose]['*']; + assert.strictEqual(got.COLS, want.COLS); + assert.strictEqual(got.ROWS, want.ROWS); + assert.deepStrictEqual(got.rows, want.rows, `${species}/${pose} did not survive the round trip`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test('a non integer scale still round trips, because generated art never lands on the grid', () => { + const h = loadOverlay(); + const want = composedRows(h, 'sprites[0]'); + const dir = tmpdir(); + try { + // 24x30 cells painted at 11px, then the canvas is what a generator would hand + // back: the same picture at an awkward size. The area vote has to cope. + const png = paintGrid(want.rows, 17); + fs.writeFileSync(path.join(dir, 'cat-sit.png'), png); + const { data } = imp.importDir(dir, {}); + assert.deepStrictEqual(data.cat.sit['*'].rows, want.rows); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the importer decodes indexed and greyscale PNGs, not just RGBA', () => { + // 2x2 indexed, 4 bit depth, with a transparent entry. + const plte = Buffer.from([0xD9, 0xC7, 0xA7, 0x33, 0x30, 0x2E]); + const trns = Buffer.from([255, 0]); + const raw = Buffer.from([0, 0x01, 0, 0x10]); // filter byte + one packed byte per row + const ih = Buffer.alloc(13); ih.writeUInt32BE(2, 0); ih.writeUInt32BE(2, 4); ih[8] = 4; ih[9] = 3; + const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ih), chunk('PLTE', plte), chunk('tRNS', trns), + chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); + const img = imp.decodePng(png); + assert.strictEqual(img.w, 2); + assert.deepStrictEqual([...img.rgba.slice(0, 4)], [0xD9, 0xC7, 0xA7, 255]); + assert.strictEqual(img.rgba[7], 0, 'palette index 1 is transparent via tRNS'); +}); + +test('every PNG filter type decodes', () => { + // Sub / Up / Average / Paeth all have to unfilter correctly or the art arrives + // sheared. Encode a gradient with a mixed filter per row and check it survives. + const w = 4, h = 5, stride = w * 4; + const flat = Buffer.alloc(h * stride); + for (let i = 0; i < flat.length; i += 4) { flat[i] = i & 255; flat[i + 1] = (i * 3) & 255; flat[i + 2] = (i * 7) & 255; flat[i + 3] = 255; } + const raw = Buffer.alloc(h * (stride + 1)); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = y; // filter types 0..4, one per row + const cur = flat.subarray(y * stride, (y + 1) * stride); + const prev = y ? flat.subarray((y - 1) * stride, y * stride) : Buffer.alloc(stride); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? cur[x - 4] : 0, b = prev[x], c = x >= 4 ? prev[x - 4] : 0; + const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + const pae = (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); + const sub = [0, a, b, (a + b) >> 1, pae][y]; + raw[y * (stride + 1) + 1 + x] = (cur[x] - sub) & 255; + } + } + const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6; + const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); + assert.deepStrictEqual([...imp.decodePng(png).rgba], [...flat]); +}); + +test('a face drawn off the eyeBox seam is rejected, not shipped', () => { + // eyeBox() splits at COLS/2. Both eyes on one side means one eye lands in both + // boxes and drawCat paints the pupil as a bar across the muzzle. + const spec = imp.poseSpec('sit', 'cat'); + const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS)); + const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); }; + for (let r = 4; r < spec.ROWS; r++) for (let c = 4; c < 20; c++) put(r, c, 'C'); + put(8, 5, 'E'); put(8, 8, 'E'); // both eyes left of the seam + put(11, 6, 'N'); + const problems = imp.check(rows, spec); + assert.ok(problems.some((p) => p[0] === 'fail' && /either side of column/.test(p[1])), + `expected an eye-seam failure, got ${JSON.stringify(problems)}`); +}); + +test('a frame with no nose is rejected', () => { + const spec = imp.poseSpec('sit', 'cat'); + const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS)); + const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); }; + for (let r = 4; r < spec.ROWS; r++) for (let c = 4; c < 20; c++) put(r, c, 'C'); + put(8, 8, 'E'); put(8, 15, 'E'); + const problems = imp.check(rows, spec); + assert.ok(problems.some((p) => p[0] === 'fail' && /nose/.test(p[1]))); +}); + +test('the dog play bow is checked as a profile pose, one eye only', () => { + assert.strictEqual(imp.poseSpec('hunt', 'dog').eyes, 1); + assert.strictEqual(imp.poseSpec('hunt', 'cat').eyes, 2); + assert.strictEqual(imp.poseSpec('hunt', 'dog').ROWS, 22); + assert.strictEqual(imp.poseSpec('hunt', 'cat').ROWS, 20); +}); + +test('only the held poses are importable, the animation rigs are refused', () => { + for (const pose of ['climb', 'groom', 'ponder', 'play', 'bat']) { + assert.strictEqual(imp.poseSpec(pose, 'cat'), null, `${pose} must not be bakeable`); + } + for (const pose of ['sit', 'type', 'loaf', 'rear', 'hunt']) { + assert.ok(imp.poseSpec(pose, 'cat'), `${pose} must be bakeable`); + } +}); + +test('file names map to a species, a pose and a coat key', () => { + assert.deepStrictEqual(imp.parseName('cat-sit.png'), { species: 'cat', pose: 'sit', key: '*' }); + assert.deepStrictEqual(imp.parseName('cat-sit--Orange Tabby.png'), { species: 'cat', pose: 'sit', key: 'Orange Tabby' }); + assert.deepStrictEqual(imp.parseName('dog-hunt--retriever.png'), { species: 'dog', pose: 'hunt', key: 'retriever' }); + assert.strictEqual(imp.parseName('sit.png'), null); + assert.strictEqual(imp.parseName('rabbit-sit.png'), null); +}); + +test('the emitted module parses and round trips through the lookup shape', () => { + const src = imp.emit({ cat: { sit: { '*': { COLS: 2, ROWS: 2, rows: ['CC', 'WW'] } } }, dog: {} }); + const mod = { exports: {} }; + new Function('module', src)(mod); + assert.deepStrictEqual(mod.exports.ART_FRAMES.cat.sit['*'].rows, ['CC', 'WW']); + assert.deepStrictEqual(mod.exports.ART_FRAMES.dog, {}); +}); + +test('a baked frame beats the composer, and an unbaked coat still composes', () => { + const h = loadOverlay(); + const before = h.run('sprites[0].grid.map((r) => r.join(""))'); + const spec = imp.poseSpec('sit', 'cat'); + + // A deliberately unmistakable body: a solid block with a face on the seam. + const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS)); + const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); }; + for (let r = 6; r < spec.ROWS; r++) for (let c = 6; c < 18; c++) put(r, c, 'C'); + put(9, 9, 'E'); put(9, 14, 'E'); put(12, 11, 'N'); + + h.run(`ART_FRAMES.cat.sit = { 'Orange Tabby': { COLS: ${spec.COLS}, ROWS: ${spec.ROWS}, rows: ${JSON.stringify(rows)} } }`); + h.run("setSpecies('dog'); setSpecies('cat')"); // forces both tables to rebuild + + const baked = h.run('sprites[0].grid.map((r) => r.join(""))'); + const other = h.run('sprites[1].grid.map((r) => r.join(""))'); + assert.notDeepStrictEqual(baked, before, 'the baked frame did not replace the composed one'); + // Every body cell we painted survives; the halo is regrown around it. + for (let r = 0; r < spec.ROWS; r++) for (let c = 0; c < spec.COLS; c++) { + if (rows[r][c] !== '.') assert.strictEqual(baked[r][c], rows[r][c], `cell ${c},${r} was not preserved`); + } + assert.ok(baked.join('').includes('H'), 'outlineHalo() should still have grown a halo'); + assert.ok(baked.join('').includes('O'), 'outlineHalo() should still have drawn the outline'); + assert.ok(other.join('') !== baked.join(''), 'a coat with no baked frame must keep composing'); + + // eyeBox still finds two eyes, which is what drawCat needs to place the pupils. + const eyes = h.run('sprites[0].eyes.map((e) => e.w)'); + assert.ok(eyes[0] > 0 && eyes[1] > 0, 'both eye boxes must survive a baked frame'); +}); + +test('a baked grid on the wrong canvas is ignored rather than trusted', () => { + const h = loadOverlay(); + const before = h.run('sprites[0].grid.map((r) => r.join(""))'); + h.run("ART_FRAMES.cat.sit = { '*': { COLS: 8, ROWS: 8, rows: ['CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC'] } }"); + h.run("setSpecies('dog'); setSpecies('cat')"); + assert.deepStrictEqual(h.run('sprites[0].grid.map((r) => r.join(""))'), before); +}); From c9554d89e2c8a58835ae7f71226571cc19132284 Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 31 Jul 2026 13:10:05 -0700 Subject: [PATCH 2/2] docs: how to paint and import a pose Covers the role grid model, the placeholder palette to paint against, the three things the engine draws on top that must never be authored (the halo, the pupils, the dog tongue), the file naming, and the checks the importer runs. Also spells out which activities are held poses and which are animation rigs, with the frame count behind each rig, because that is the distinction that decides whether generated art is a drop in or a reference to redraw the composer against. --- README.md | 14 ++++- docs/frame-pack.md | 126 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 docs/frame-pack.md diff --git a/README.md b/README.md index 3157244..e098c86 100644 --- a/README.md +++ b/README.md @@ -491,14 +491,26 @@ isolated worker processes. Your IMAP app password is stored encrypted at rest ```powershell npm start # run the cat -npm test # 52 tests: config, poses, petting, audio, site drift +npm test # 81 tests: config, poses, petting, audio, art frames, site drift npm run poses:cat # previews/cat-poses.png (every activity x every coat) npm run poses:dog # the same for all 14 breeds +npm run frames:import -- # import painted PNGs as baked poses npm run demo:all # regenerate the README media (hero, gallery, carousel) npm run hook -- cursor # print a path-filled agent hook config npm run icon # regenerate the tray + app-tile icons ``` +**Painting a pose by hand.** Every pose is composed in code, which is why one +`sit` covers 28 coats and a new coat costs nine hex values instead of an art pass. +The trade is that changing how the pet looks means editing geometry. +`npm run frames:import` is the escape hatch: paint a pose against a placeholder +palette, import it, and it wins over the composer for exactly the coats you name +while everything else keeps composing. Five held poses can be baked (`sit`, +`type`, `loaf`, `rear`, `hunt`); the six raised-limb activities are parameterised +rigs whose limbs sweep through quantised frames, so a still would freeze them. +Palette, naming and the checks the importer runs are in +[docs/frame-pack.md](docs/frame-pack.md). + **Visual QA.** The overlay is GPU-composited, so ordinary screenshots cannot capture it. Poses are reviewed with a one-command contact sheet instead: `npm run poses:cat` renders **every activity across every coat** into one image, diff --git a/docs/frame-pack.md b/docs/frame-pack.md new file mode 100644 index 0000000..5f0f4a1 --- /dev/null +++ b/docs/frame-pack.md @@ -0,0 +1,126 @@ +# Frame pack: painting poses by hand + +Every pose the pet holds is composed in code from grid primitives, which is why one +`sit` covers 28 coats and why a new coat costs nine hex values instead of an art +pass. The trade is that improving how the pet *looks* means editing geometry. + +This is the escape hatch. Paint a pose, import it, and it wins over the composer +for exactly the coats you name. Everything else keeps composing, so a pack of one +frame is a perfectly valid pack. + +## The model: roles, not colours + +A sprite is a grid of **role letters**, one per cell: + +| Letter | Role | Where it goes | +|---|---|---| +| `C` | coat | body, head, legs, the main mass | +| `K` | mark | stripes, saddle, mask, ear backs | +| `W` | white | bib, muzzle, toes, tail tip | +| `X` | patch | tortie and calico blocks, dog brows | +| `I` | inner | inner ear only | +| `N` | nose | nose pad only, anchors the whiskers and the tongue | +| `E` | eye | flat eye block, the engine paints the pupil into it | +| `O` | outline | one cell silhouette band | +| `H` | halo | **generated**, never authored | + +`drawCat` looks the colour up per coat at draw time and applies its own top lit +shading. So the art you import must be **flat**: any shading painted in doubles up +with the engine's and turns muddy. + +Three things are drawn live on top of the frame and must not appear in it: the +**halo**, the **pupils** (they track the cursor), and the dog **tongue**. + +## Painting against the placeholder palette + +Paint with these nine colours and nothing in between. They are never seen; they +exist only so a nearest-colour match can tell the roles apart. + +| Role | Hex | | Role | Hex | +|---|---|---|---|---| +| coat | `#D9C7A7` | | nose | `#B04A57` | +| mark | `#33302E` | | eye | `#4FBF7A` | +| white | `#FBFBF7` | | outline | `#5C534A` | +| patch | `#D2762B` | | tongue | `#E8747F` (engine only) | +| inner | `#EFA9B8` | | | | + +Transparent background. No anti aliasing, no gradients, no dithering. The importer +snaps every pixel to the nearest of the eight paintable colours and takes an area +majority vote per cell, so it survives art that does not land exactly on the grid, +but it cannot rescue a soft edge: it will report the percentage of pixels that were +not a palette colour and you should treat anything over 10 percent as a redraw. + +## What can be baked, and what cannot + +Five poses are **held**: the pet sits in them for whole seconds, and a still frame +is the right shape for them. + +| Pose | Canvas | Notes | +|---|---|---| +| `sit` | 24x30 | the default, also the base for drag, pet, startle, greeting | +| `type` | 24x24 | no forelegs: the kneading paws are drawn over the keycaps | +| `loaf` | 24x30 | cat loaf, dog nose-to-tail curl (a side view, one eye) | +| `rear` | 24x30 | cat rear-up, dog beg. No forelegs: they are drawn live | +| `hunt` | 30x20 cat, 30x22 dog | cat crouch (front on), dog play bow (side view, one eye) | + +The other six activities are **animation rigs**, not stills. Their limbs move with +a quantised parameter, so replacing one with a single frame would freeze it: + +| Activity | Rig | Distinct frames per coat | +|---|---|---| +| rope climb | `climbSpriteFor(i, hand, dir)` | 4 (two grips x two headings) | +| groom, ponder, play | `pawSpriteFor(i, lift, out)` | up to 45 (`PAW_STEPS` 8 by 4 steps out) | +| bat overhead | `batSpriteFor(i, up, ph)` | 14 (two sides x seven arc steps) | + +The importer refuses those poses on purpose. Generated art for them is still worth +having as a **reference to redraw the composer against**, which is how the dog was +built in the first place: iterate the geometry against a rendered contact sheet +until the silhouette reads. + +## Importing + +Name each PNG for the pose it fills, and optionally the coat it belongs to: + +``` +cat-sit.png every cat coat +cat-sit--Orange Tabby.png that one coat, by its PATTERNS name +dog-hunt--retriever.png every breed built on the retriever body +``` + +Then: + +``` +npm run frames:import -- path/to/pngs # writes src/art-frames.js +npm run frames:import -- path/to/pngs --dry # report only, write nothing +``` + +Lookup order is coat name, then build name, then `*`, then the composer. So you can +ship one `*` frame and override a single awkward coat later. + +Every frame is checked before it is written, and a frame that fails is dropped with +a reason rather than shipped: + +- the canvas matches the pose (a wrong size would break the layout maths built on it) +- an eye block sits **either side of the seam `eyeBox()` splits on**, or exactly one + for the two profile poses. A face drawn off centre puts one eye in both boxes and + `drawCat` paints the pupil as a bar across the muzzle. This has bitten the project + twice, once on the cat and once on the dog. +- a nose exists, because the whiskers and the panting tongue anchor to it +- the bottom row is occupied, so the pose stands on the same floor line as the others + +`--force` writes anyway. It is there for experiments, not for shipping. + +## Verifying + +``` +npm test # includes a PNG round trip and the renderer hook +npm run poses:cat # previews/cat-poses.png, every activity x every coat +npm run poses:dog +npm run test:boot +``` + +Then look at the sheets. Reading grid code cannot tell you whether a silhouette +reads as an animal, and the two coats worth checking first are **Black** and +**Slate**: they collapse coat, mark, white and patch into one colour, so anything +that only reads because of a colour change disappears. If the pose survives those, +the silhouette is doing the work.