diff --git a/build.config b/build.config index 1bc0e487..5c18c7b9 100644 --- a/build.config +++ b/build.config @@ -2,6 +2,8 @@ MODELS_OPTIPNG=-o7 MODELS_ADVPNG=-z4 -i10 MINISPRITE_OPTIPNG=-o7 MINISPRITE_ADVPNG=-z4 -i100 +SPRITE_OPTIPNG=-o7 +SPRITE_ADVPNG=-z4 -i100 #CONFIG_SPRITESHEET_OPTIPNG=-o7 #CONFIG_SPRITESHEET_ADVPNG=-z4 -i15 #CONFIG_SPRITESHEET_PNGQUANT=256 -s 11 diff --git a/rules/oldgen.ts b/rules/oldgen.ts new file mode 100644 index 00000000..7c9fdced --- /dev/null +++ b/rules/oldgen.ts @@ -0,0 +1,74 @@ + +import * as spritedata from '@smogon/sprite-data/index.ts'; + +import {type Artifact, type CmdSpec, rule} from '../tools/build/artifact.ts'; +import {base, glob} from '../tools/build/helpers.ts'; + +// A source picked for an older-gen collection, and what the sprite built from +// it is called. The two differ because the game is spent on choosing the +// collection: `rb` and `y` are the same Zubat under two palettes, and neither +// output has a game left in its name. Dropping it is not cosmetic. `-g` is +// Gigantamax everywhere the grammar isn't naming a gen 1-4 game, so a name +// that kept it would reach publishedName() as `zubat-gmax`. +export type Front = { + file: string, + name: string, +}; + +// The gen 1-4 sets are each named for one game and sourced from several. `-g` +// spells the games a picture is the copy for, a letter apiece, and a +// collection takes them in preference order: the game it is named for first, +// then whatever drew the formes that game never did. That is how rs/ comes to +// hold Deoxys-Attack, which only FireRed drew, and dp/ the Rotom appliances +// and Giratina-Origin, which only Platinum did. Which layer wins a name is +// settled at copy time, by whoever claims it first. +// +// Gen 5 has no such axis -- there its `-g` really is Gigantamax -- so its sets +// pass no order, and keep every flag they came with. +export function fronts(pats: string | string[], games?: string): Front[] { + let ranked = []; + for (let file of glob(pats)) { + let sn = spritedata.parseFilename(base(file)); + // Backs and shinies go unpublished: the legacy tree kept them in + // subdirectories of their own, under national dex numbers, and + // nothing on the site has ever read one. + if (sn.extra.has('b') || sn.extra.has('s')) { + continue; + } + let rank = 0; + if (games !== undefined) { + let code = sn.extra.get('g') ?? ''; + rank = [...games].findIndex(g => code.includes(g)); + // Another generation's game, or no game at all: not this set's. + if (rank < 0) { + continue; + } + sn.extra.delete('g'); + } + ranked.push({file, name: spritedata.formatFilename(sn), rank}); + } + // Stable, so within one game the glob's own order survives. + return ranked.sort((a, b) => a.rank - b.rank).map(({file, name}) => ({file, name})); +} + +// One rule per front, its output named for what it publishes. Several sources +// can land on one name -- Abra is the same drawing in Ruby and in Emerald -- +// which is fine, since a nominal name is provenance and not identity. +export function forEachFront(files: Front[], spec: CmdSpec, ext: string): Artifact[] { + return files.map(f => rule(f.file, spec, `${f.name}.${ext}`)); +} + +// The CAPs that were playable while Diamond and Pearl were. They were only +// ever drawn in gen 5's style, so dp/ carries them from the gen 5 sources the +// way the legacy set did -- and has to name them, since nothing in a filename +// says a sprite is a CAP. +export let GEN4_CAPS = [ + 'arghonaut', 'colossoil', 'cyclohm', 'fidgit', 'kitsunoh', 'krilowatt', + 'pyroak', 'revenankh', 'stratagem', 'syclant', 'voodoom', +]; + +// Every front of the given species, formes and female slots included. +export function named(files: Front[], names: readonly string[]): Front[] { + let want = new Set(names); + return files.filter(f => want.has(spritedata.parseFilename(f.name).name)); +} diff --git a/rules/publish.ts b/rules/publish.ts index c5fd3fff..a7c6c41a 100644 --- a/rules/publish.ts +++ b/rules/publish.ts @@ -146,3 +146,25 @@ export async function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): await manifest.copy(f, dest, spritedata.smogon(alias)); } } + +// A copy that takes a published name only where nothing before it did. Every +// layered set is built this way: the later sources are backfills, and the +// claim is per published name rather than per filename because the mapping is +// one to many in both directions. One name can be spelled several ways, and +// gen 5 carries a sprite per forme slot, so its six Minior meteors and its two +// Zygarde Power Construct slots all want the name the layer above already +// published, which the manifest would refuse as a duplicate. +export function firstWins(manifest: Manifest, dest: Dest, + opts: NameOpts = {}): (f: Sprite) => Promise { + let seen = new Set(); + return async (f: Sprite): Promise => { + let names = publishedNames(f, opts); + if (names.some(n => seen.has(n))) { + return; + } + for (let name of names) { + seen.add(name); + await manifest.copy(f, dest, name); + } + }; +} diff --git a/smogon.build.ts b/smogon.build.ts index 02559970..d9d83102 100644 --- a/smogon.build.ts +++ b/smogon.build.ts @@ -1,9 +1,10 @@ import {gen10Modelslike} from './rules/modelslike.ts'; -import {Manifest, type Sprite, type Tree, itemspritecopy, publishedNames, spritecopy} from './rules/publish.ts'; -import {forEachRule, rule} from './tools/build/artifact.ts'; +import {GEN4_CAPS, forEachFront, fronts, named} from './rules/oldgen.ts'; +import {Manifest, type Sprite, type Tree, firstWins, itemspritecopy, spritecopy} from './rules/publish.ts'; +import {type CmdSpec, forEachRule, rule} from './tools/build/artifact.ts'; import {compresspng, pad, spriteglob, trimimg} from './tools/build/helpers.ts'; -import {deploy} from './tools/deploy/api.ts'; +import {type DeployCtx, deploy} from './tools/deploy/api.ts'; // The tar root maps onto the served tree: sprites/x is served at // /__assets/sprites/x. The upload rejects a tar whose tree disagrees with the @@ -56,23 +57,7 @@ let xyGen5 = forEachRule('src/sprites/gen5/*.png', [ deploy(async ctx => { let manifest = new Manifest(ctx, TREE); - let seen = new Set(); - // First source wins per published name rather than per filename, because - // the later sources are backfills and one name can be spelled several - // ways. gen 5 carries a sprite per forme slot, so its six Minior meteors - // and its two Zygarde Power Construct slots all want the name the models - // already published, which the manifest would refuse as a duplicate. - let xycopy = async (f: Sprite) => { - let names = publishedNames(f); - if (names.some(n => seen.has(n))) { - return; - } - for (let name of names) { - seen.add(name); - await manifest.copy(f, {dir: 'xy'}, name); - } - }; - + let xycopy = firstWins(manifest, {dir: 'xy'}); for (let f of await ctx.list('src/models')) { await xycopy(f); } @@ -113,6 +98,67 @@ deploy(async ctx => { manifest.links(LINKS); }); +// sprites/rb, rg, y, c, rs, dp, bw: the older-gen full sprites, the front of +// each generation under the name the smogdex, the forum and chatot each +// compose from a dex alias. They read no manifest, which is why these ride +// the LINKS mirror the way xy/ does. + +function trimmed(display: string): CmdSpec { + return {display, cmds: [trimimg(), compresspng({config: 'SPRITE'})]}; +} + +// Red and Blue and Japanese Red and Green are two palettes of one set of +// drawings; Yellow's are the Game Boy Color's, which is a directory of its +// own, the sprites beside it being the Super Game Boy ones. +let gen1 = trimmed('gen 1 sprite %f'); +let rb = forEachFront(fronts('src/sprites/gen1/*.png', 'b'), gen1, 'png'); +let rg = forEachFront(fronts('src/sprites/gen1/*.png', 'g'), gen1, 'png'); +let y = forEachFront(fronts('src/sprites/gen1/gbc/*.png', 'y'), gen1, 'png'); + +// Crystal's animations come at the games' own 56x56 and are published on the +// 60x60 box the legacy set used: the smogdex scales a sprite to fill its +// frame, so trimmed, a Diglett would arrive the size of a Steelix. +let c = forEachFront(fronts('src/sprites/gen2/*.gif'), { + display: 'gen 2 animation %f', + cmds: [ + 'magick convert %f -coalesce -background none -gravity center -extent 60x60 %o', + 'gifsicle -O3 -b %o', + ], +}, 'gif'); + +let rs = forEachFront(fronts('src/sprites/gen3/*.png', 'rfle'), trimmed('gen 3 sprite %f'), 'png'); + +let dp = forEachFront([ + ...fronts('src/sprites/gen4/*.png', 'dph'), + ...named(fronts('src/sprites/gen5/*.png'), GEN4_CAPS), +], trimmed('gen 4 sprite %f'), 'png'); + +function oldgen(dir: string, sources: (ctx: DeployCtx) => Promise): void { + deploy(async ctx => { + let manifest = new Manifest(ctx, TREE); + let copy = firstWins(manifest, {dir}); + for (let f of await sources(ctx)) { + await copy(f); + } + manifest.write(`__meta/${dir}/manifest.json`); + manifest.links(LINKS); + }); +} + +oldgen('rb', async () => rb); +oldgen('rg', async () => rg); +oldgen('y', async () => y); +oldgen('c', async () => c); +oldgen('rs', async () => rs); +oldgen('dp', async () => dp); +// bw/ is gen 5's animations, backfilled from the gen 5-style stills for what +// the games never animated: the CAPs, and everything the Smogon Sprite +// Project has drawn since. The stills are the rule set xy/ already layers on. +oldgen('bw', async ctx => [ + ...(await ctx.list('src/sprites/gen5')).filter(f => f.ext === 'gif'), + ...xyGen5, +]); + // 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.