mirror of
https://github.com/smogon/sprites.git
synced 2026-09-27 11:47:45 -05:00
The forum has carried its default avatars as 702 committed gifs since its initial commit in 2019: bytes in the wrong repo, and the last sprite collection it hadn't moved onto a manifest. The sources are all here, so build them. They are gen 5 animations on a uniform 96x96 box. The box is not decorative: XenForo scales an <img> to fill its avatar container, so a sprite published at its own aspect would arrive stretched. It is also the games' own sprite size, so nothing is scaled up, and the 45 animations that overflow it are cropped to it, which is what the 2019 set did too. The pool is an index, not a set. The forum picks a slot with crc32(username) % count, so a name added, removed or respelled reassigns every user's avatar -- which is why rules/avatars.ts spells the pool out instead of globbing src/sprites/gen5, and why these publish under the PS ids the 2019 set used (`hooh`, not `ho-oh`) rather than the smogon names every other set here publishes. publishedName(sn, psid) spells all but two of them; Arceus-Normal and Swanna-2 were second copies of a picture the pool already carries, and now share its file. What ships is that index rather than a name -> url manifest, because nothing looks an avatar up: __meta/avatars/manifest.json is the directory listing scandir() used to return, as an array of urls. Two things about that listing are the index and so are reproduced rather than tidied away. It sorted filenames with the extension attached, which orders arceus-bug.gif ahead of arceus.gif where the bare names sort the other way, and disagrees with the manifest order at 68 of the 702 slots. And it led with `.` and `..`, which counted toward the divisor and named no avatar: those two rendered a broken image for two of every 704 usernames, and now get Substitute, which the pool already carries, so they cost nothing but the slot. The animations themselves change, unavoidably. The 2019 files came from an earlier generation of these sources: 649 of the 693 that still resolve have a different frame count, and no coalesced frame survives. Same Pokemon, same slot, same framing, re-animated. Manifest grows url(), which write() already composed inline, so a set can publish another projection of the same mapping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
|
|
import * as spritedata from '@smogon/sprite-data/index.ts';
|
|
|
|
import type {Artifact} from '../tools/build/artifact.ts';
|
|
import type {DeployCtx, SrcFile} from '../tools/deploy/api.ts';
|
|
|
|
// Anything with a sprite-id nominal name and an extension: a built artifact
|
|
// or a raw source file.
|
|
export type Sprite = Artifact | SrcFile;
|
|
|
|
// The published tree a stamped set goes into: where it sits in the output, and
|
|
// the url that root is served at. Both live here rather than in a `dir` so
|
|
// that they are written once and everything a set publishes -- the copies, the
|
|
// urls, the mirror -- is spelled from the same place.
|
|
export type Tree = {
|
|
root: string,
|
|
served: string,
|
|
};
|
|
|
|
// One copy this manifest made: where under the tree root it landed, and the
|
|
// un-stamped filename it answers to.
|
|
type Entry = {
|
|
dir: string,
|
|
stamped: string,
|
|
filename: string,
|
|
};
|
|
|
|
// The unhashed name -> served url mapping published beside a stamped set. An
|
|
// entry is the tree's url plus the copy's place in it, so no consumer needs a
|
|
// prefix of its own.
|
|
export class Manifest {
|
|
#ctx: DeployCtx;
|
|
#tree: Tree;
|
|
#entries = new Map<string, Entry>();
|
|
|
|
constructor(ctx: DeployCtx, tree: Tree) {
|
|
this.#ctx = ctx;
|
|
this.#tree = tree;
|
|
}
|
|
|
|
// Queue a copy of `f` under `dir` with a content-stamped name and record
|
|
// the entry that points at it.
|
|
async copy(f: Sprite, {dir, ext}: Dest, name: string): Promise<void> {
|
|
let h = await this.#ctx.hash(f);
|
|
let e = extOf(f, ext);
|
|
// Keyed on the name alone, since a url names the whole path and has
|
|
// nothing to disambiguate with an extension. ActionQueue only dedups
|
|
// final dsts, and hashed dsts differ even where unhashed names
|
|
// collide, so the collision is caught here or not at all.
|
|
if (this.#entries.has(name)) {
|
|
throw new Error(`duplicate sprite name ${name}`);
|
|
}
|
|
let stamped = `${name}-${h}.${e}`;
|
|
this.#entries.set(name, {dir, stamped, filename: `${name}.${e}`});
|
|
this.#ctx.copy(f, `${this.#tree.root}/${dir}/${stamped}`);
|
|
}
|
|
|
|
// The whole url the copy `name` answers to. Exposed on its own for a set
|
|
// that publishes some other projection of this mapping than write()'s.
|
|
url(name: string): string {
|
|
let e = this.#entries.get(name);
|
|
if (e === undefined) {
|
|
throw new Error(`no sprite named ${name}`);
|
|
}
|
|
return `${this.#tree.served}/${e.dir}/${e.stamped}`;
|
|
}
|
|
|
|
write(dst: string): void {
|
|
let sorted: Record<string, string> = {};
|
|
for (let [k] of this.#sorted()) {
|
|
sorted[k] = this.url(k);
|
|
}
|
|
this.#ctx.write(dst, JSON.stringify(sorted, null, 4) + '\n');
|
|
}
|
|
|
|
// The same set again under its un-stamped names, as links to the stamped
|
|
// files. `dst` is a root of its own rather than the tree's, so what a link
|
|
// says is a path and not a name: the two ends land apart, and the reader
|
|
// that follows one composed its half from nothing but the sprite. The
|
|
// tree's own root is not repeated under it, since the mirror is already
|
|
// one set's worth of it.
|
|
links(dst: string): void {
|
|
for (let [, e] of this.#sorted()) {
|
|
this.#ctx.symlink(`${dst}/${e.dir}/${e.filename}`,
|
|
`${this.#tree.root}/${e.dir}/${e.stamped}`);
|
|
}
|
|
}
|
|
|
|
#sorted(): [string, Entry][] {
|
|
return [...this.#entries].sort((a, b) => a[0] < b[0] ? -1 : 1);
|
|
}
|
|
}
|
|
|
|
export type Dest = {
|
|
// Under the tree root, not from the output root.
|
|
dir: string,
|
|
ext?: string,
|
|
};
|
|
|
|
function extOf(f: Sprite, ext?: string): string {
|
|
let result = ext ?? f.ext;
|
|
if (result === null) {
|
|
throw new Error(`Sprite ${f.name} has no extension`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The names a sprite publishes under on the smogon side, or none where it
|
|
// isn't published at all. A set that backfills another needs this before it
|
|
// copies, since the mapping isn't one name per filename in either direction:
|
|
// Meowstic answers to two, and the forme slots the games gave one sprite (the
|
|
// six Minior meteors, Zygarde's Power Construct pair) answer to the same one.
|
|
export type NameOpts = {
|
|
// forumsprites publishes Unknown; no other set does.
|
|
allowUnknown?: boolean,
|
|
// The gen 6 icon sets publish the formes they have no icon for under a
|
|
// borrowed one. See ICON_ALIASES.
|
|
icons?: boolean,
|
|
};
|
|
|
|
export function publishedNames(f: Sprite, opts: NameOpts = {}): string[] {
|
|
let sn = spritedata.parseFilename(f.name);
|
|
|
|
// Skip asymmetrical for now
|
|
if (sn.extra.has('a') || sn.extra.has('b') || sn.extra.has('s')) {
|
|
return [];
|
|
}
|
|
|
|
if (sn.kind === 'x') {
|
|
// Skip these, we don't use Unknown/Substitute
|
|
if (!opts.allowUnknown || sn.name !== 'unknown') {
|
|
return [];
|
|
}
|
|
} else if (sn.kind !== 's') {
|
|
throw new Error(`Not a specie sprite: ${f.name}`);
|
|
}
|
|
|
|
return opts.icons ? spritedata.iconNames(sn) : spritedata.smogonNames(sn);
|
|
}
|
|
|
|
export async function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
|
|
opts: NameOpts = {}): Promise<void> {
|
|
for (let name of publishedNames(f, opts)) {
|
|
await manifest.copy(f, dest, name);
|
|
}
|
|
}
|
|
|
|
// TODO: merge with above
|
|
export async function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): Promise<void> {
|
|
let sn = spritedata.parseFilename(f.name);
|
|
if (sn.kind !== 'i') {
|
|
throw new Error(`Not an item sprite: ${f.name}`);
|
|
}
|
|
await manifest.copy(f, dest, spritedata.smogon(sn.name));
|
|
for (let alias of spritedata.ITEM_ALIASES[sn.name] ?? []) {
|
|
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<void> {
|
|
let seen = new Set<string>();
|
|
return async (f: Sprite): Promise<void> => {
|
|
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);
|
|
}
|
|
};
|
|
}
|