181 lines
8.4 KiB
JavaScript
181 lines
8.4 KiB
JavaScript
// 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/<dir>/, the 16 `<base>-big-000..015.png` rotation
|
|
// frames are downloaded, montaged into a 16x1 spritesheet at
|
|
// assets/images/starcontrol/sc-ship-<slug>.png, and the measured frame
|
|
// 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
|
|
// `<base>-big-000.png` .. `<base>-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 };
|
|
}
|
|
|
|
async function buildSheet(slug, group, 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);
|
|
}
|
|
const sheetName = `sc-ship-${slug}${suffix}`;
|
|
const out = join(OUT_DIR, `${sheetName}.png`);
|
|
execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]);
|
|
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) };
|
|
}
|
|
|
|
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; }
|
|
json.ships[slug].sprite = await buildSheet(slug, group);
|
|
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') {
|
|
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-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');
|