perf(sprites): ship 192px sprites and a manifest of every sprite

Sprites were shipped at 512x512 but render at 44-64px in the box grid.
Resizing to 192px halves each file (median 15.5KB -> 7.6KB); a full dex
of artwork drops from 16.6MB to 7.6MB.

The sprite build now also writes static/sprites-small/manifest.json,
listing every sprite (all forms, shiny and female variants) with its
size, so the offline worker can save the complete set and report what
is missing. A unit test keeps it in step with the files on disk.
This commit is contained in:
Josh Creek
2026-09-14 16:19:22 +01:00
parent b7d2db4959
commit 721c44dcd0
3178 changed files with 107 additions and 5 deletions
+6 -2
View File
@@ -3,13 +3,16 @@ import path from 'node:path';
import process from 'node:process';
import { mkdir, readdir, rename } from 'node:fs/promises';
import sharp from 'sharp';
import { writeSpriteManifest } from './sprite-manifest.mjs';
const inputDir = process.env.SPRITE_INPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites');
const outputDir =
process.env.SPRITE_OUTPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites-small');
const format = (process.env.SPRITE_FORMAT ?? 'webp').toLowerCase();
const quality = Number(process.env.SPRITE_QUALITY ?? 80);
const maxSize = Number(process.env.SPRITE_MAX_SIZE ?? 0);
// Sprites render at 44-64px in the box grid and up to 192px in the detail view, and every byte is
// downloaded (and cached offline) per sprite, so larger sources only cost users data.
const maxSize = Number(process.env.SPRITE_MAX_SIZE ?? 192);
if (!['png', 'webp'].includes(format)) {
console.error(`Unsupported SPRITE_FORMAT "${format}". Use "png" or "webp".`);
@@ -69,4 +72,5 @@ for (const [index, file] of files.entries()) {
}
}
console.log('Sprite optimization complete.');
const manifest = await writeSpriteManifest(outputDir);
console.log(`Sprite optimization complete. Manifest lists ${manifest.files.length} sprites.`);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Lists every sprite (all forms, shiny and female variants) with its size, so the offline worker can
// save the complete set on request and tell whether anything is still missing.
import path from 'node:path';
import process from 'node:process';
import { readdir, stat, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export const SPRITE_MANIFEST_NAME = 'manifest.json';
async function walk(dir, files = []) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(fullPath, files);
else if (entry.isFile() && entry.name.endsWith('.webp')) files.push(fullPath);
}
return files;
}
/** Builds the manifest for `<spritesDir>/home`, with paths relative to that folder. */
export async function buildSpriteManifest(spritesDir) {
const homeDir = path.join(spritesDir, 'home');
const files = await walk(homeDir);
const entries = await Promise.all(
files.map(async (file) => [
path.relative(homeDir, file).split(path.sep).join('/'),
(await stat(file)).size
])
);
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return { version: 1, files: entries };
}
export async function writeSpriteManifest(spritesDir) {
const manifest = await buildSpriteManifest(spritesDir);
await writeFile(path.join(spritesDir, SPRITE_MANIFEST_NAME), `${JSON.stringify(manifest)}\n`);
return manifest;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const spritesDir =
process.env.SPRITE_OUTPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites-small');
const manifest = await writeSpriteManifest(spritesDir);
const bytes = manifest.files.reduce((total, [, size]) => total + size, 0);
console.log(
`Wrote ${manifest.files.length} sprites (${(bytes / 1048576).toFixed(1)} MB) to ${SPRITE_MANIFEST_NAME}`
);
}