Cheer, surprised and waving hands moved clear of the hair; Sleipnir's extra legs show; Gjallar's horn has a bell; Huginn's wings separate from his cloak; Void lifted off the dark ground; Tengu's beard and nose cut back. CAST.md is now generated from the trait sheets. PROCESS.md is a working guide for the next agent: style rules, coordinates, layer order, render checks, before/after renders and known weak spots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
7 KiB
JavaScript
111 lines
7 KiB
JavaScript
// Regenerates cast/stickers/<id>/<pose>-<expression>.svg, cast/gallery.html and cast/CAST.md.
|
|
// Usage: node cast/rig/build.mjs (all characters)
|
|
// node cast/rig/build.mjs kiko eve (only these; gallery is always rebuilt from disk)
|
|
import { mkdirSync, writeFileSync, readdirSync, rmSync, existsSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { render, POSES, EXPRESSIONS } from './rig.mjs';
|
|
import { CAST, COMMON_EXPRESSIONS } from './characters.mjs';
|
|
|
|
const CAST_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const OUT = join(CAST_DIR, 'stickers');
|
|
const only = process.argv.slice(2);
|
|
|
|
export function plan(ch) {
|
|
if (ch.full) return Object.keys(POSES).flatMap((p) => EXPRESSIONS.map((e) => [p, e]));
|
|
return COMMON_EXPRESSIONS.map((e) => ['neutral', e]);
|
|
}
|
|
|
|
let count = 0, bytes = 0;
|
|
for (const ch of CAST) {
|
|
if (only.length && !only.includes(ch.id)) continue;
|
|
const dir = join(OUT, ch.id);
|
|
if (existsSync(dir)) rmSync(dir, { recursive: true });
|
|
mkdirSync(dir, { recursive: true });
|
|
for (const [pose, expr] of plan(ch)) {
|
|
const svg = render(ch, pose, expr);
|
|
writeFileSync(join(dir, `${pose}-${expr}.svg`), svg);
|
|
count++; bytes += Buffer.byteLength(svg);
|
|
}
|
|
}
|
|
|
|
// Gallery: lists whatever is on disk, in CAST order.
|
|
const cards = [];
|
|
for (const ch of CAST) {
|
|
const dir = join(OUT, ch.id);
|
|
if (!existsSync(dir)) continue;
|
|
const files = readdirSync(dir).filter((f) => f.endsWith('.svg'));
|
|
const order = (f) => {
|
|
const [p, e] = f.replace('.svg', '').split('-');
|
|
return Object.keys(POSES).indexOf(p) * 100 + EXPRESSIONS.indexOf(e);
|
|
};
|
|
files.sort((a, b) => order(a) - order(b));
|
|
for (const f of files) {
|
|
const [p, e] = f.replace('.svg', '').split('-');
|
|
cards.push(`<figure data-ch="${ch.id}" data-pose="${p}"><img src="stickers/${ch.id}/${f}" alt="${ch.name}, ${p}, ${e}" loading="lazy" width="150" height="161"><figcaption><b>${ch.name}</b> ${p} · ${e}</figcaption></figure>`);
|
|
}
|
|
}
|
|
const chips = CAST.map((c) => `<button type="button" data-f="${c.id}">${c.name}</button>`).join('');
|
|
const poseChips = Object.keys(POSES).map((p) => `<button type="button" data-p="${p}">${p}</button>`).join('');
|
|
const html = `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Swarm Stickers</title>
|
|
<style>
|
|
:root { --ground: #07111a; --orange: #ff8a2a; --blue: #59b7ff; --ink: #e8eef5; --dim: #8aa0b4; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--ground); color: var(--ink); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
header { padding: 28px 16px 8px; max-width: 1360px; margin: 0 auto; }
|
|
h1 { margin: 0 0 4px; font-size: 26px; } h1 span { color: var(--orange); }
|
|
p.lede { margin: 0 0 14px; color: var(--dim); }
|
|
nav { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
|
|
nav button { font: inherit; font-size: 13px; color: var(--ink); background: #10202e; border: 1px solid #1e3446; border-radius: 999px; padding: 4px 11px; cursor: pointer; }
|
|
nav button[aria-pressed="true"] { background: var(--orange); border-color: var(--orange); color: #1a0d02; font-weight: 600; }
|
|
nav.poses button[aria-pressed="true"] { background: var(--blue); border-color: var(--blue); color: #021220; }
|
|
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 22px 14px; padding: 18px 16px 48px; max-width: 1360px; margin: 0 auto; }
|
|
figure { margin: 0; text-align: center; }
|
|
figure img { width: 100%; max-width: 150px; height: auto; display: block; margin: 0 auto;
|
|
filter: drop-shadow(3px 0 0 #fff) drop-shadow(-3px 0 0 #fff) drop-shadow(0 3px 0 #fff) drop-shadow(0 -3px 0 #fff) drop-shadow(0 8px 10px rgba(0,0,0,.45));
|
|
transform: rotate(var(--tilt, -3deg)); transition: transform .2s ease; }
|
|
figure:nth-child(3n) img { --tilt: 3deg; } figure:nth-child(4n+1) img { --tilt: -1deg; }
|
|
figure:hover img { transform: rotate(0deg) scale(1.06); }
|
|
figcaption { font-size: 12px; color: var(--dim); margin-top: 6px; } figcaption b { color: var(--ink); font-weight: 600; }
|
|
[hidden] { display: none !important; }
|
|
@media (prefers-reduced-motion: reduce) { figure img { transition: none; } }
|
|
</style></head><body>
|
|
<header><h1>Swarm <span>stickers</span></h1>
|
|
<p class="lede">${cards.length} stickers, ${CAST.length} characters. Generated by <code>cast/rig/build.mjs</code>; the white die-cut edge is CSS on the image.</p>
|
|
<nav class="chars" aria-label="Character"><button type="button" data-f="" aria-pressed="true">All</button>${chips}</nav>
|
|
<nav class="poses" aria-label="Pose"><button type="button" data-p="" aria-pressed="true">All poses</button>${poseChips}</nav></header>
|
|
<main>
|
|
${cards.join('\n')}
|
|
</main>
|
|
<script>
|
|
const st = { f: '', p: '' };
|
|
try { const u = new URLSearchParams(location.search); st.f = u.get('c') || ''; st.p = u.get('p') || ''; } catch (e) {}
|
|
function apply() {
|
|
for (const fig of document.querySelectorAll('figure')) fig.hidden = (st.f && fig.dataset.ch !== st.f) || (st.p && fig.dataset.pose !== st.p);
|
|
for (const b of document.querySelectorAll('nav.chars button')) b.setAttribute('aria-pressed', String(b.dataset.f === st.f));
|
|
for (const b of document.querySelectorAll('nav.poses button')) b.setAttribute('aria-pressed', String(b.dataset.p === st.p));
|
|
}
|
|
document.querySelector('nav.chars').addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) { st.f = b.dataset.f; apply(); } });
|
|
document.querySelector('nav.poses').addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) { st.p = b.dataset.p; apply(); } });
|
|
apply();
|
|
</script></body></html>
|
|
`;
|
|
writeFileSync(join(CAST_DIR, 'gallery.html'), html);
|
|
// CAST.md is generated from the trait sheets so the two cannot drift apart.
|
|
const md = ['# Cast', '',
|
|
'Generated by `cast/rig/build.mjs` from `cast/rig/characters.mjs`; edit the trait sheet, not this file.',
|
|
'★ marks the characters rendered in every pose and expression. "Look source" is the reference the design',
|
|
'was drawn from (used as inspiration only; every sticker is original), or **invented** where no appearance exists.', '',
|
|
'| Name | What they are | Look source | Palette | Trait sheet |', '|---|---|---|---|---|'];
|
|
for (const c of CAST) {
|
|
const pal = [...new Set([c.skin, c.hair.color, c.outfit.main, c.outfit.trim, c.eyes, c.accent].filter(Boolean))].map((h) => '`' + h + '`').join(' ');
|
|
const acc = (c.acc || []).map((a) => a[0]).join(', ') || 'none';
|
|
const traits = `base ${c.base}; hair ${c.hair.style}; outfit ${c.outfit.shape}${c.cape ? ' + cape' : ''}; acc ${acc}; prop ${c.prop ? c.prop[0] : 'none'}; emblem ${c.emblem ? c.emblem.shape : 'none'}`;
|
|
const src = c.source.startsWith('invented') ? '**' + c.source + '**' : c.source.split('; ').map((x) => '`' + x + '`').join('; ');
|
|
const what = c.role + (c.note ? ' ' + c.note : '');
|
|
md.push(`| ${c.name}${c.full ? ' ★' : ''} | ${what.replace(/\|/g, '/')} | ${src} | ${pal} | ${traits} |`);
|
|
}
|
|
writeFileSync(join(CAST_DIR, 'CAST.md'), md.join('\n') + '\n');
|
|
console.log(`wrote ${count} stickers (${(bytes / 1024).toFixed(1)} KiB), gallery lists ${cards.length}`);
|