// One-time asset/codegen fetcher for Star Control Super Melee. // // node tools/fetchStarControlAssets.js --ships # download + build ship rotation sheets // node tools/fetchStarControlAssets.js --stats # print UQM stat blocks for manual review // // Downloads ship art and stats from the Ur-Quan Masters GitHub mirror // (juj/sc2-uqm). UQM content is CC BY-NC-SA 2.5 — an attribution line ships in // data/star-control-ships.json and on the game's setup screen. This script is // never run by the site; it needs network access plus ImageMagick (`magick`). // // --ships: for each ship, the GitHub contents API lists // sc2/content/base/ships//, the 16 `-big-000..015.png` rotation // frames are downloaded and composed into a 16x1 spritesheet at // assets/images/starcontrol/sc-ship-.png. UQM frames are tightly // cropped per frame (different sizes within one rotation) and aligned by // per-frame hotspots in `-big.ani`, so each frame is composited onto a // uniform transparent cell with its hotspot at the cell centre; the cell // dimensions are written into that ship's `sprite` block in // data/star-control-ships.json (only the sprite block is touched). import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const REPO = 'juj/sc2-uqm'; const CONTENT = 'sc2/content/base/ships'; const SRC = 'sc2/src/uqm/ships'; const OUT_DIR = join(root, 'assets/images/starcontrol'); const JSON_PATH = join(root, 'data/star-control-ships.json'); // slug (our JSON key) -> UQM content directory. Frame prefixes are discovered, // not guessed (e.g. human/cruiser-big-000.png). const CONTENT_DIRS = { androsynth: 'androsynth', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr', druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'kohrah', melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz', pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandro', spathi: 'spathi', supox: 'supox', syreen: 'syreen', thraddash: 'thraddash', umgah: 'umgah', urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfotpik', }; // slug -> the rotation-frame prefix that is the SHIP (several directories // also hold 16-frame weapon animations like spathi's "butt-big-000..015"). const SHIP_BASE = { androsynth: 'guardian', arilou: 'skiff', chenjesu: 'broodhome', chmmr: 'avatar', druuge: 'mauler', earthling: 'cruiser', ilwrath: 'avenger', kohrah: 'marauder', melnorme: 'trader', mmrnmhrm: 'xform', mycon: 'podship', orz: 'nemesis', pkunk: 'fury', shofixti: 'scout', slylandro: 'probe', spathi: 'eluder', supox: 'blade', syreen: 'penetrator', thraddash: 'torch', umgah: 'drone', urquan: 'dreadnought', utwig: 'jugger', vux: 'intruder', yehat: 'terminator', zoqfot: 'stinger', }; // slug -> UQM source directory for --stats. const SRC_DIRS = { androsynth: 'androsyn', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr', druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'blackurq', melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz', pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandr', spathi: 'spathi', supox: 'supox', syreen: 'syreen', thraddash: 'thradd', umgah: 'umgah', urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfot', }; async function ghJson(path) { const res = await fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, { headers: { 'User-Agent': 'fertig-classic-games-asset-fetch' }, }); if (!res.ok) throw new Error(`GitHub API ${res.status} for ${path}`); return res.json(); } async function download(url, dest) { const res = await fetch(url); if (!res.ok) throw new Error(`download ${res.status} for ${url}`); writeFileSync(dest, Buffer.from(await res.arrayBuffer())); } // Finds the rotation-frame prefix: a group of exactly 16 files named // `-big-000.png` .. `-big-015.png` (weapon/captain art doesn't // match this shape; e.g. human's saturn-big has 23 frames and is skipped). function findRotationGroup(listing, preferredBase) { const groups = {}; for (const f of listing) { const m = /^(.+)-big-(\d{3})\.png$/.exec(f.name); if (!m) continue; (groups[m[1]] ??= []).push({ n: Number(m[2]), url: f.download_url }); } const candidates = Object.entries(groups) .filter(([, frames]) => frames.length === 16 && Math.min(...frames.map((fr) => fr.n)) === 0 && Math.max(...frames.map((fr) => fr.n)) === 15); if (candidates.length === 0) return null; let picked = candidates.find(([base]) => base === preferredBase); if (!picked) { console.warn(` preferred base "${preferredBase}" not found; candidates: ${candidates.map(([k]) => k).join(', ')}`); [picked] = candidates; } const [base, frames] = picked; frames.sort((a, b) => a.n - b.n); return { base, frames }; } // Parses a UQM .ani file: `.png ` // per line. Returns { frameNumber: {x, y} } for the rotation frames. function parseAni(text) { const hot = {}; for (const line of text.split('\n')) { const parts = line.trim().split(/\s+/); if (parts.length < 5) continue; const m = /-(\d{3})\.png$/i.exec(parts[0]); if (!m) continue; const x = Number(parts[parts.length - 2]); const y = Number(parts[parts.length - 1]); if (Number.isFinite(x) && Number.isFinite(y)) hot[Number(m[1])] = { x, y }; } return hot; } async function buildSheet(slug, group, aniUrl, suffix = '') { const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`); rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { recursive: true }); const files = []; for (const fr of group.frames) { const dest = join(tmp, `f-${String(fr.n).padStart(3, '0')}.png`); await download(fr.url, dest); files.push(dest); } // Per-frame dims (UQM crops each rotation frame individually). const dimLines = execFileSync('magick', ['identify', '-format', '%w %h\n', ...files]) .toString().trim().split('\n'); const dims = dimLines.map((l) => l.split(' ').map(Number)); // Per-frame hotspots from the .ani; fall back to frame centres if absent. let hotspots = null; if (aniUrl) { try { const res = await fetch(aniUrl); if (res.ok) { const parsed = parseAni(await res.text()); if (group.frames.every((fr) => parsed[fr.n])) hotspots = parsed; } } catch { /* fall through to centre alignment */ } } if (!hotspots) { console.warn(` no usable .ani for ${slug}${suffix}; centring frames`); hotspots = {}; group.frames.forEach((fr, i) => { hotspots[fr.n] = { x: Math.floor(dims[i][0] / 2), y: Math.floor(dims[i][1] / 2) }; }); } // Uniform cell sized so every frame fits with its hotspot at the centre. let halfW = 0; let halfH = 0; group.frames.forEach((fr, i) => { const [w, h] = dims[i]; const { x, y } = hotspots[fr.n]; halfW = Math.max(halfW, x, w - x); halfH = Math.max(halfH, y, h - y); }); const cellW = halfW * 2; const cellH = halfH * 2; const cells = []; group.frames.forEach((fr, i) => { const { x, y } = hotspots[fr.n]; const cell = join(tmp, `c-${String(fr.n).padStart(3, '0')}.png`); execFileSync('magick', [ '-size', `${cellW}x${cellH}`, 'xc:none', files[i], '-geometry', `+${halfW - x}+${halfH - y}`, '-composite', cell, ]); cells.push(cell); }); const sheetName = `sc-ship-${slug}${suffix}`; const out = join(OUT_DIR, `${sheetName}.png`); execFileSync('magick', [...cells, '+append', '-background', 'none', out]); rmSync(tmp, { recursive: true, force: true }); return { sheet: sheetName, frames: 16, frameWidth: cellW, frameHeight: cellH }; } async function fetchShips() { mkdirSync(OUT_DIR, { recursive: true }); const json = JSON.parse(readFileSync(JSON_PATH, 'utf8')); const results = []; for (const [slug, dir] of Object.entries(CONTENT_DIRS)) { if (!json.ships[slug]) { console.warn(`skip ${slug}: not in ships JSON`); continue; } process.stdout.write(`${slug} (${dir}) ... `); try { const listing = await ghJson(`${CONTENT}/${dir}`); const group = findRotationGroup(listing, SHIP_BASE[slug]); if (!group) { console.log('NO 16-frame rotation group found'); continue; } const aniUrl = listing.find((f) => f.name === `${group.base}-big.ani`)?.download_url; json.ships[slug].sprite = await buildSheet(slug, group, aniUrl); console.log(`ok (${group.base}, ${json.ships[slug].sprite.frameWidth}x${json.ships[slug].sprite.frameHeight})`); // Mmrnmhrm's alternate Y-Wing form has its own rotation frames. if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) { const yGroup = findRotationGroup(listing, 'ywing'); if (yGroup?.base === 'ywing') { const yAni = listing.find((f) => f.name === 'ywing-big.ani')?.download_url; json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, yAni, '-y'); console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`); } } results.push(slug); } catch (e) { console.log(`FAILED: ${e.message}`); } } writeFileSync(JSON_PATH, `${JSON.stringify(json, null, 2)}\n`); console.log(`\n${results.length}/${Object.keys(CONTENT_DIRS).length} sheets built; sprite blocks written to data/star-control-ships.json`); } const STAT_DEFINES = [ 'MAX_CREW', 'MAX_ENERGY', 'ENERGY_REGENERATION', 'ENERGY_WAIT', 'MAX_THRUST', 'THRUST_INCREMENT', 'THRUST_WAIT', 'TURN_WAIT', 'SHIP_MASS', 'WEAPON_ENERGY_COST', 'WEAPON_WAIT', 'SPECIAL_ENERGY_COST', 'SPECIAL_WAIT', 'MISSILE_SPEED', 'MISSILE_LIFE', 'MISSILE_HITS', 'MISSILE_DAMAGE', ]; // Prints raw #define blocks for manual review/merge — NOT written to the JSON // (several ships encode stats in code: Chmmr zapsats, Slylandro drive, // Mmrnmhrm forms). async function fetchStats() { for (const [slug, dir] of Object.entries(SRC_DIRS)) { const url = `https://raw.githubusercontent.com/${REPO}/master/${SRC}/${dir}/${dir}.c`; const res = await fetch(url); if (!res.ok) { console.log(`${slug}: fetch failed ${res.status}`); continue; } const text = await res.text(); const out = {}; for (const name of STAT_DEFINES) { const m = new RegExp(`^#define\\s+${name}\\s+(.+)$`, 'm').exec(text); if (m) out[name] = m[1].replace(/\/\*.*?\*\//g, '').trim(); } console.log(`\n=== ${slug} (${dir}.c)`); console.log(JSON.stringify(out, null, 2)); } } const args = process.argv.slice(2); if (args.includes('--stats')) await fetchStats(); else if (args.includes('--ships')) await fetchShips(); else console.log('usage: node tools/fetchStarControlAssets.js --ships | --stats');