diff --git a/smogon.build.ts b/smogon.build.ts index d9d83102..914148aa 100644 --- a/smogon.build.ts +++ b/smogon.build.ts @@ -162,6 +162,8 @@ oldgen('bw', async ctx => [ // Smogdex spritesheet. The sheet tool bakes the names parsed from the %f // filenames into the css, hence nameSensitive. The png is declared only so // cwebp has something to read; only the css and the webp are published. +// A sprite's place in the sheet is one grid index, so the css is a rule of +// geometry per region and a single declaration per name. let [, sheetCss, sheetWebp] = rule(minispriteInputs, { display: 'smogdex sheet', @@ -169,6 +171,7 @@ let [, sheetCss, sheetWebp] = rule(minispriteInputs, { deps: [ 'data/lib/index.ts', 'tools/smogdexspritesheet/index.ts', + 'tools/smogdexspritesheet/layout.ts', ], cmds: [ 'node tools/smogdexspritesheet/index.ts --image %o1 --stylesheet %o2 -- %f', diff --git a/tools/smogdexspritesheet/index.ts b/tools/smogdexspritesheet/index.ts index 8d64a6c7..efe58be3 100755 --- a/tools/smogdexspritesheet/index.ts +++ b/tools/smogdexspritesheet/index.ts @@ -5,6 +5,8 @@ import * as fs from 'node:fs/promises'; import * as util from 'node:util'; import * as spritedata from '@smogon/sprite-data/index.ts'; +import {pack, place, stylesheet, type Cell, type Image} from './layout.ts'; + let {values: opts, positionals: srcs} = util.parseArgs({ options: { image: {type: 'string'}, @@ -16,39 +18,66 @@ if (opts.image === undefined || opts.stylesheet === undefined) { throw new Error('usage: --image --stylesheet -- '); } -let run = util.promisify(spritesmith.run); +// spritesmith's types stop at run()/createImages()/processImages(), but the +// engine those sit on is a spec of its own -- createCanvas, addImage, export +// -- and reaching it is what lets the sheet be laid out here instead of by +// spritesmith's packer. +type Canvas = { + addImage(image: Image, x: number, y: number): void, + // An old-style stream, from save-pixels underneath: events, not iteration. + export(opts: {format: string}): NodeJS.ReadableStream, +}; +type Engine = {createCanvas(width: number, height: number): Canvas}; -let result = await run({ - src: srcs +let smith = new spritesmith(); +let engine = (smith as unknown as {engine: Engine}).engine; +let images = await util.promisify(smith.createImages.bind(smith))(srcs); + +let pokemon: Cell[] = []; +let items: Cell[] = []; + +// createImages hands the images back in the order it was given the files. +for (let [i, src] of srcs.entries()) { + let image = images[i]; + if (image === undefined) { + throw new Error(`${src}: no image read`); + } + let parsed = spritedata.parseFilename(path.parse(src).name); + if (parsed.kind === 'i') { + let names = [parsed.name, ...spritedata.ITEM_ALIASES[parsed.name] ?? []]; + items.push({names: names.map(spritedata.smogon), image}); + } else { + // TODO would like to use psid here, mess with it later. + pokemon.push({names: spritedata.iconNames(parsed), image}); + } +} + +let sheet = pack([ + {name: 'pokemon', modifier: null, cells: pokemon}, + {name: 'items', modifier: 'item', cells: items}, +]); + +let canvas = engine.createCanvas(sheet.width, sheet.height); +for (let layout of sheet.layouts) { + for (let [i, cell] of layout.cells.entries()) { + // Flush against the left of its cell, so the x offset is the column + // and nothing else, and centred down it, so a short sprite sits where + // its own box used to. The element is only as wide as the sprite, so + // the rest of the cell stays behind it and no neighbour shows through. + let {x, y} = place(layout, i); + canvas.addImage(cell.image, x, y + Math.floor((layout.cellH - cell.image.height) / 2)); + } +} + +let png = await new Promise((resolve, reject) => { + let chunks: Uint8Array[] = []; + let out = canvas.export({format: 'png'}); + out.on('data', chunk => chunks.push(chunk)); + out.on('end', () => resolve(Buffer.concat(chunks))); + out.on('error', reject); }); -let sprites = new Map; -for (let [filename, sprite] of Object.entries(result.coordinates)) { - let parsed = spritedata.parseFilename(path.parse(filename).name); - if (parsed.kind === 'i') { - sprites.set(spritedata.smogon(parsed.name), sprite); - for (let alias of spritedata.ITEM_ALIASES[parsed.name] ?? []) { - sprites.set(spritedata.smogon(alias), sprite); - } - continue; - } - // TODO would like to use psid here, mess with it later. - for (let name of spritedata.iconNames(parsed)) { - sprites.set(name, sprite); - } -} - -let stylesheet = ''; -for (let [id, sprite] of sprites) { - // webp reference depends on optimization in Tupfile, fix it later, just need to ship - stylesheet += `.sprite-${id} { - background-image: url("./spritesheet.webp"); - background-repeat: no-repeat; - background-position:-${sprite.x}px -${sprite.y}px; - width:${sprite.width}px; - height:${sprite.height}px - }`; -} - -await fs.writeFile(opts.image, result.image, 'binary'); -await fs.writeFile(opts.stylesheet, stylesheet); +await fs.writeFile(opts.image, png); +// The url is rewritten to the stamped name at deploy time; keep it spelled +// exactly this way. +await fs.writeFile(opts.stylesheet, stylesheet(sheet, './spritesheet.webp')); diff --git a/tools/smogdexspritesheet/layout.ts b/tools/smogdexspritesheet/layout.ts new file mode 100644 index 00000000..93754bc8 --- /dev/null +++ b/tools/smogdexspritesheet/layout.ts @@ -0,0 +1,105 @@ + +// The sheet is a stack of uniform grids, one region per grid, each cell the +// size of its region's widest and tallest sprite. A sprite's place in the +// sheet is then its index and nothing else, which is the whole of what the +// stylesheet has to say about it -- the alternative, a tight pack, has to +// spell two coordinates per sprite and costs about five times the css. The +// padding a uniform cell wastes is nearly free: lossless webp encodes +// transparency for almost nothing, and the sheet came out slightly smaller +// than the tight pack it replaced. + +// A decoded sprite is opaque to us beyond its size. +export type Image = {width: number, height: number}; + +// One sprite in the sheet, and every class name that resolves to it: aliases +// share a cell rather than repeating the pixels. +export type Cell = {names: string[], image: Image}; + +// Items are their own region because their cell is 24x24 against pokemon's +// 40x30, and the dex sizes its item columns on that. `modifier` is the class +// the dex adds beside `sprite` to pick a region's geometry; the first region +// is the default and needs none. +export type Region = {name: string, modifier: string | null, cells: Cell[]}; + +export type Layout = Region & {cols: number, cellW: number, cellH: number, y: number}; + +export type Sheet = {layouts: Layout[], width: number, height: number}; + +// Where a cell's box sits in the sheet. The stylesheet says this same thing in +// css, and the two have to agree. +export function place(l: Layout, i: number): {x: number, y: number} { + return { + x: (i % l.cols) * l.cellW, + y: l.y + Math.floor(i / l.cols) * l.cellH, + }; +} + +export function pack(regions: Region[]): Sheet { + let claimed = new Set(); + for (let region of regions) { + for (let cell of region.cells) { + for (let name of cell.names) { + // A name resolves to one cell. Two sprites claiming it -- a -v + // vendor file beside the base name it varies, say -- used to + // mean the last one read won, silently. + if (claimed.has(name)) { + throw new Error(`${name}: claimed by two sprites`); + } + claimed.add(name); + } + } + } + + // Laid out region by region down the sheet. The first sizes the sheet, + // chosen to come out roughly square; the rest fill that width. + let layouts: Layout[] = []; + let width = 0; + let height = 0; + for (let region of regions) { + if (region.cells.length === 0) { + throw new Error(`${region.name}: no sprites`); + } + // Sorted by name so an index means the same thing from one build to + // the next and the rules come out in order, which the compressor + // likes. Compared by code unit rather than locale, so the sheet + // doesn't depend on the machine that built it. + let cells = [...region.cells].sort((a, b) => { + let [x, y] = [a.names[0] ?? '', b.names[0] ?? '']; + return x < y ? -1 : x > y ? 1 : 0; + }); + let cellW = Math.max(...cells.map(c => c.image.width)); + let cellH = Math.max(...cells.map(c => c.image.height)); + let cols = width === 0 + ? Math.ceil(Math.sqrt(cells.length * cellH / cellW)) + : Math.max(1, Math.floor(width / cellW)); + layouts.push({...region, cells, cols, cellW, cellH, y: height}); + width = Math.max(width, cols * cellW); + height += Math.ceil(cells.length / cols) * cellH; + } + return {layouts, width, height}; +} + +// One rule per region carries the geometry, and one declaration per sprite +// says which cell it is. Every constant in the geometry is computed from the +// layout, so the stylesheet and the image can't drift apart. +// +// The dex puts `sprite` on the element beside the `sprite-` it already +// had, and the region's modifier too where there is one. +export function stylesheet(sheet: Sheet, url: string): string { + let css = ''; + for (let l of sheet.layouts) { + let col = `mod(var(--i),${l.cols})`; + let row = `round(down,var(--i)/${l.cols},1)`; + let offset = l.y === 0 ? '' : ` - ${l.y}px`; + let geometry = `background-position:calc(${col}*-${l.cellW}px) ` + + `calc(${row}*-${l.cellH}px${offset});height:${l.cellH}px`; + css += l.modifier === null + ? `.sprite{display:inline-block;background:url("${url}") no-repeat;${geometry};width:calc(var(--w)*1px)}\n` + : `.sprite.${l.modifier}{${geometry}}\n`; + for (let [i, cell] of l.cells.entries()) { + let selector = cell.names.map(n => `.sprite-${n}`).join(','); + css += `${selector}{--i:${i};--w:${cell.image.width}}\n`; + } + } + return css; +} diff --git a/tools/smogdexspritesheet/package.json b/tools/smogdexspritesheet/package.json index 1d5cad7c..14bef29e 100644 --- a/tools/smogdexspritesheet/package.json +++ b/tools/smogdexspritesheet/package.json @@ -1,5 +1,8 @@ { "type": "module", + "scripts": { + "test": "node --test test/*.test.ts" + }, "dependencies": { "@smogon/sprite-data": "workspace:*", "@types/node": "^24.10.7", diff --git a/tools/smogdexspritesheet/test/layout.test.ts b/tools/smogdexspritesheet/test/layout.test.ts new file mode 100644 index 00000000..fb0ecdf2 --- /dev/null +++ b/tools/smogdexspritesheet/test/layout.test.ts @@ -0,0 +1,100 @@ + +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {pack, place, stylesheet, type Cell, type Region} from '../layout.ts'; + +function cells(...sprites: [string[], number, number][]): Cell[] { + return sprites.map(([names, width, height]) => ({names, image: {width, height}})); +} + +function regions(...rs: Region[]): Region[] { + return rs; +} + +// The stylesheet is the only place the layout is written down for anyone else, +// so read the geometry back out of it the way a browser would: pull the two +// calc() expressions apart and work them out for a given --i. +function readGeometry(css: string, selector: string): (i: number) => {x: number, y: number} { + let rule = css.split('\n').find(l => l.startsWith(`${selector}{`)); + assert.ok(rule !== undefined, `no rule for ${selector}`); + let m = rule.match( + /background-position:calc\(mod\(var\(--i\),(\d+)\)\*-(\d+)px\) calc\(round\(down,var\(--i\)\/(\d+),1\)\*-(\d+)px(?: - (\d+)px)?\)/); + assert.ok(m !== null, `unreadable geometry in ${rule}`); + let [cols, cellW, rowCols, cellH, offset] = + [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5] ?? 0)]; + assert.equal(rowCols, cols, 'the two axes disagree about the column count'); + return i => ({ + x: (i % cols) * cellW, + y: Math.floor(i / cols) * cellH + offset, + }); +} + +test('a cell is as big as its region\'s largest sprite', () => { + let sheet = pack(regions( + {name: 'a', modifier: null, cells: cells([['x'], 10, 8], [['y'], 6, 12], [['z'], 4, 4])})); + let [a] = sheet.layouts; + assert.ok(a !== undefined); + assert.equal(a.cellW, 10); + assert.equal(a.cellH, 12); +}); + +test('the css agrees with place() at every index', () => { + let pokemon = cells(...Array.from({length: 41}, (_, i): [string[], number, number] => + [[`p${i}`], 20 + (i % 3) * 10, 20 + (i % 4) * 2])); + let items = cells(...Array.from({length: 45}, (_, i): [string[], number, number] => + [[`i${i}`], 8 + (i % 5), 8 + (i % 6)])); + let sheet = pack(regions( + {name: 'pokemon', modifier: null, cells: pokemon}, + {name: 'items', modifier: 'item', cells: items})); + let css = stylesheet(sheet, './spritesheet.webp'); + + for (let [n, layout] of sheet.layouts.entries()) { + let selector = layout.modifier === null ? '.sprite' : `.sprite.${layout.modifier}`; + let geometry = readGeometry(css, selector); + assert.ok(layout.cells.length > layout.cols, `region ${n} is one row, which tests nothing`); + for (let i = 0; i < layout.cells.length; i++) { + assert.deepEqual(geometry(i), place(layout, i), `${selector} at --i:${i}`); + } + } +}); + +test('every cell lands inside the sheet, and regions do not overlap', () => { + let sheet = pack(regions( + {name: 'a', modifier: null, cells: cells(...Array.from({length: 30}, + (_, i): [string[], number, number] => [[`a${i}`], 12, 9]))}, + {name: 'b', modifier: 'b', cells: cells(...Array.from({length: 30}, + (_, i): [string[], number, number] => [[`b${i}`], 5, 7]))})); + let bottom = 0; + for (let layout of sheet.layouts) { + assert.equal(layout.y, bottom, 'a region starts where the last one ended'); + for (let i = 0; i < layout.cells.length; i++) { + let {x, y} = place(layout, i); + assert.ok(x + layout.cellW <= sheet.width, `cell ${i} runs off the right`); + assert.ok(y + layout.cellH <= sheet.height, `cell ${i} runs off the bottom`); + assert.ok(y >= layout.y, `cell ${i} sits above its region`); + } + bottom = layout.y + Math.ceil(layout.cells.length / layout.cols) * layout.cellH; + } + assert.equal(bottom, sheet.height); +}); + +test('sprites are indexed in name order, aliases sharing one rule', () => { + let sheet = pack(regions({name: 'a', modifier: null, cells: + cells([['pikachu'], 20, 20], [['absol', 'absol-alias'], 10, 10])})); + let css = stylesheet(sheet, './spritesheet.webp'); + assert.ok(css.includes('.sprite-absol,.sprite-absol-alias{--i:0;--w:10}\n')); + assert.ok(css.includes('.sprite-pikachu{--i:1;--w:20}\n')); +}); + +test('two sprites cannot claim one name', () => { + assert.throws(() => pack(regions( + {name: 'a', modifier: null, cells: cells([['dup'], 4, 4])}, + {name: 'b', modifier: 'b', cells: cells([['dup'], 4, 4])})), + /dup: claimed by two sprites/); +}); + +test('an empty region is a broken input, not an empty grid', () => { + assert.throws(() => pack(regions({name: 'nothing', modifier: null, cells: []})), + /nothing: no sprites/); +});