Lay the smogdex sheet out on a grid, so the css is an index

Every one of the 2141 rules carried its own background-image,
background-repeat and a pair of coordinates, so the stylesheet shipped at
403,824 bytes -- larger than the 401,390 byte webp it pointed at -- and the
browser parsed six declarations per sprite on every dex page.

Pack the sheet as a stack of uniform grids instead, one region per grid,
each cell the size of its region's largest sprite. A sprite's place in the
sheet is then its index and nothing else, and the css is two rules of
geometry plus `.sprite-<name>{--i:N;--w:W}`. The width stays per sprite so
inline flow doesn't move; the height is the cell's, and the sprite is
centred down it, so it sits where its own box used to.

The padding a uniform cell wastes is nearly free -- lossless webp encodes
transparency for almost nothing -- and the sheet in fact came out slightly
smaller than the tight pack it replaces.

    css   403,824 -> 74,013 raw, 18,249 -> 13,646 brotli
    webp  401,390 -> 399,568

Items are their own region because their cell is 24x24 against pokemon's
40x30, which is what the dex sizes its item columns on. The dex marks which
is which by putting `sprite` on the element beside the `sprite-<name>` it
already had, and `item` too for an item.

Two sprites claiming one name used to mean the last one read won, silently;
it now throws. The layout moves to its own module so it can be tested, and
gets the sheet's first tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-23 17:32:00 -04:00
parent d910d20b26
commit 40289407df
5 changed files with 273 additions and 33 deletions

View File

@@ -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',

View File

@@ -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 <file> --stylesheet <file> -- <sprites...>');
}
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<Buffer>((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'));

View File

@@ -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<string>();
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-<name>` 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;
}

View File

@@ -1,5 +1,8 @@
{
"type": "module",
"scripts": {
"test": "node --test test/*.test.ts"
},
"dependencies": {
"@smogon/sprite-data": "workspace:*",
"@types/node": "^24.10.7",

View File

@@ -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/);
});