refactor: improve Star Control ship rendering and ship picker UX

- Update ship sprite frame dimensions to match actual UQM rotation assets
- Parse .ani hotspot files to correctly center tightly-cropped ship frames
- Replace CRT overlay effect with native camera shake for hit/explosion feedback
- Fix camera zoom clamping to prevent ships from going off-screen on 16:9 display
- Correct ship rotation frame mapping (UQM sheets rotate clockwise)
- Add keyboard navigation to ship picker with focus rings and sound feedback
- Support both control clusters for hotseat and single-player ship selection
- Remove unused CRT overlay import and related cleanup code
- Update asset fetcher to handle per-frame hotspot alignment from .ani files
This commit is contained in:
Brian Fertig 2026-07-15 11:45:08 -06:00
parent 3f1d534ec2
commit e9ddebc891
29 changed files with 222 additions and 85 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -55,8 +55,8 @@
"sprite": {
"sheet": "sc-ship-androsynth",
"frames": 16,
"frameWidth": 16,
"frameHeight": 21
"frameWidth": 34,
"frameHeight": 30
}
},
"arilou": {
@ -105,8 +105,8 @@
"sprite": {
"sheet": "sc-ship-arilou",
"frames": 16,
"frameWidth": 17,
"frameHeight": 14
"frameWidth": 22,
"frameHeight": 16
}
},
"chenjesu": {
@ -169,8 +169,8 @@
"sprite": {
"sheet": "sc-ship-chenjesu",
"frames": 16,
"frameWidth": 34,
"frameHeight": 34
"frameWidth": 46,
"frameHeight": 42
}
},
"chmmr": {
@ -229,8 +229,8 @@
"sprite": {
"sheet": "sc-ship-chmmr",
"frames": 16,
"frameWidth": 37,
"frameHeight": 38
"frameWidth": 44,
"frameHeight": 42
}
},
"druuge": {
@ -281,8 +281,8 @@
"sprite": {
"sheet": "sc-ship-druuge",
"frames": 16,
"frameWidth": 15,
"frameHeight": 34
"frameWidth": 52,
"frameHeight": 48
}
},
"earthling": {
@ -337,8 +337,8 @@
"sprite": {
"sheet": "sc-ship-earthling",
"frames": 16,
"frameWidth": 13,
"frameHeight": 35
"frameWidth": 46,
"frameHeight": 38
}
},
"ilwrath": {
@ -390,8 +390,8 @@
"sprite": {
"sheet": "sc-ship-ilwrath",
"frames": 16,
"frameWidth": 38,
"frameHeight": 31
"frameWidth": 50,
"frameHeight": 46
}
},
"kohrah": {
@ -447,8 +447,8 @@
"sprite": {
"sheet": "sc-ship-kohrah",
"frames": 16,
"frameWidth": 22,
"frameHeight": 38
"frameWidth": 42,
"frameHeight": 42
}
},
"melnorme": {
@ -505,8 +505,8 @@
"sprite": {
"sheet": "sc-ship-melnorme",
"frames": 16,
"frameWidth": 31,
"frameHeight": 31
"frameWidth": 36,
"frameHeight": 36
}
},
"mmrnmhrm": {
@ -578,8 +578,8 @@
"sprite": {
"sheet": "sc-ship-mmrnmhrm-y",
"frames": 16,
"frameWidth": 17,
"frameHeight": 24
"frameWidth": 28,
"frameHeight": 26
}
}
],
@ -594,8 +594,8 @@
"sprite": {
"sheet": "sc-ship-mmrnmhrm",
"frames": 16,
"frameWidth": 26,
"frameHeight": 18
"frameWidth": 30,
"frameHeight": 28
}
},
"mycon": {
@ -650,8 +650,8 @@
"sprite": {
"sheet": "sc-ship-mycon",
"frames": 16,
"frameWidth": 26,
"frameHeight": 25
"frameWidth": 32,
"frameHeight": 28
}
},
"orz": {
@ -708,8 +708,8 @@
"sprite": {
"sheet": "sc-ship-orz",
"frames": 16,
"frameWidth": 29,
"frameHeight": 23
"frameWidth": 40,
"frameHeight": 38
}
},
"pkunk": {
@ -768,8 +768,8 @@
"sprite": {
"sheet": "sc-ship-pkunk",
"frames": 16,
"frameWidth": 27,
"frameHeight": 20
"frameWidth": 30,
"frameHeight": 30
}
},
"shofixti": {
@ -822,8 +822,8 @@
"sprite": {
"sheet": "sc-ship-shofixti",
"frames": 16,
"frameWidth": 9,
"frameHeight": 15
"frameWidth": 20,
"frameHeight": 20
}
},
"slylandro": {
@ -876,8 +876,8 @@
"sprite": {
"sheet": "sc-ship-slylandro",
"frames": 16,
"frameWidth": 28,
"frameHeight": 45
"frameWidth": 50,
"frameHeight": 46
}
},
"spathi": {
@ -934,8 +934,8 @@
"sprite": {
"sheet": "sc-ship-spathi",
"frames": 16,
"frameWidth": 28,
"frameHeight": 22
"frameWidth": 38,
"frameHeight": 32
}
},
"supox": {
@ -984,8 +984,8 @@
"sprite": {
"sheet": "sc-ship-supox",
"frames": 16,
"frameWidth": 13,
"frameHeight": 30
"frameWidth": 34,
"frameHeight": 34
}
},
"syreen": {
@ -1039,8 +1039,8 @@
"sprite": {
"sheet": "sc-ship-syreen",
"frames": 16,
"frameWidth": 17,
"frameHeight": 30
"frameWidth": 46,
"frameHeight": 38
}
},
"thraddash": {
@ -1098,8 +1098,8 @@
"sprite": {
"sheet": "sc-ship-thraddash",
"frames": 16,
"frameWidth": 22,
"frameHeight": 22
"frameWidth": 28,
"frameHeight": 28
}
},
"umgah": {
@ -1151,8 +1151,8 @@
"sprite": {
"sheet": "sc-ship-umgah",
"frames": 16,
"frameWidth": 12,
"frameHeight": 14
"frameWidth": 18,
"frameHeight": 16
}
},
"urquan": {
@ -1211,8 +1211,8 @@
"sprite": {
"sheet": "sc-ship-urquan",
"frames": 16,
"frameWidth": 29,
"frameHeight": 33
"frameWidth": 46,
"frameHeight": 44
}
},
"utwig": {
@ -1298,8 +1298,8 @@
"sprite": {
"sheet": "sc-ship-utwig",
"frames": 16,
"frameWidth": 33,
"frameHeight": 31
"frameWidth": 36,
"frameHeight": 36
}
},
"vux": {
@ -1352,8 +1352,8 @@
"sprite": {
"sheet": "sc-ship-vux",
"frames": 16,
"frameWidth": 15,
"frameHeight": 18
"frameWidth": 28,
"frameHeight": 24
}
},
"yehat": {
@ -1415,8 +1415,8 @@
"sprite": {
"sheet": "sc-ship-yehat",
"frames": 16,
"frameWidth": 19,
"frameHeight": 13
"frameWidth": 26,
"frameHeight": 24
}
},
"zoqfot": {
@ -1470,8 +1470,8 @@
"sprite": {
"sheet": "sc-ship-zoqfot",
"frames": 16,
"frameWidth": 23,
"frameHeight": 24
"frameWidth": 30,
"frameHeight": 30
}
}
}

View File

@ -4,10 +4,9 @@ import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import {
ARENA_W, ARENA_H, ZOOM, TUNE, STEP_MS,
mulberry32, wrap, tdelta, tdist, tmid, targetZoom, smoothTowards,
mulberry32, wrap, tdelta, tmid, targetZoom, smoothTowards,
compileShips, createBattle, setInput, stepMatch, quantizeFacing, facingToRad,
} from './StarControlLogic.js';
import { createAI, updateAI } from './StarControlAI.js';
@ -87,18 +86,16 @@ export default class StarControlGame extends Phaser.Scene {
);
this.escHandler = () => this.onEscape();
this.input.keyboard.on('keydown-ESC', this.escHandler);
this.pickKeyHandlers = [];
this.crt = applyArcadeCRTOverlay(this, {
accentTint: 0x74f5a1, scanlineTint: 0x2e8f57,
});
try {
const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.events.once('shutdown', () => {
this.crt.destroy();
this.input.keyboard.off('keydown-ESC', this.escHandler);
this.teardownPickKeys();
});
// Match configuration defaults (setup view edits these).
@ -365,6 +362,7 @@ export default class StarControlGame extends Phaser.Scene {
showShipPick() {
this.view = 'shipPick';
this.clearView();
this.teardownPickKeys();
// AI sides pick instantly.
[0, 1].forEach((side) => {
@ -397,23 +395,68 @@ export default class StarControlGame extends Phaser.Scene {
const perRow = Math.min(remaining.length, 7);
const cardW = 240;
const cardH = 290;
remaining.forEach((entry, n) => {
const cards = remaining.map((entry, n) => {
const row = Math.floor(n / perRow);
const inRow = Math.min(remaining.length - row * perRow, perRow);
const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20);
const y = 420 + row * (cardH + 24);
root.add(this.shipCard(x, y, cardW, cardH, entry, humanSide, () => {
this.current[humanSide] = entry.i;
this.needPick[humanSide] = false;
playSound(this, SFX.EIGHTBIT_CARD);
this.showShipPick(); // next picker (hotseat) or battle
}));
const card = this.shipCard(x, y, cardW, cardH, entry, humanSide,
() => this.confirmShipPick(humanSide, entry));
root.add(card);
return card;
});
root.add(this.mkText(cx, GAME_HEIGHT - 40,
`FLEET VALUE REMAINING — YOU ${fleetPoints(this.SHIPS, remaining.map((e) => e.key))} PTS / ` +
`ENEMY ${fleetPoints(this.SHIPS, this.aliveFleet(1 - humanSide).map((e) => e.key))} PTS`,
22, '#89a6a0'));
this.setupPickKeys(humanSide, remaining, cards);
}
confirmShipPick(side, entry) {
this.current[side] = entry.i;
this.needPick[side] = false;
playSound(this, SFX.EIGHTBIT_CARD);
this.showShipPick(); // next picker (hotseat) or battle
}
// Lets the currently-picking player browse ship cards with the same keys
// they fly with in battle. In hotseat, each side is locked to its own
// cluster (A/D+Z/X for side 0, arrows+comma/period for side 1) so one
// player's keys don't steal the other's turn. Outside hotseat the human
// is always side 0, but readKeys() lets them fly with either cluster —
// so both must work here too.
setupPickKeys(side, remaining, cards) {
if (!cards.length) return;
let idx = 0;
cards[idx].focusRing.setVisible(true);
const move = (delta) => {
cards[idx].focusRing.setVisible(false);
idx = (idx + delta + cards.length) % cards.length;
cards[idx].focusRing.setVisible(true);
playSound(this, SFX.EIGHTBIT_ACTION);
};
const confirm = () => this.confirmShipPick(side, remaining[idx]);
const clusters = this.mode === 'hotseat'
? [side === 0 ? ['A', 'D', 'Z', 'X'] : ['LEFT', 'RIGHT', 'COMMA', 'PERIOD']]
: [['A', 'D', 'Z', 'X'], ['LEFT', 'RIGHT', 'COMMA', 'PERIOD']];
for (const [leftKey, rightKey, fireKey, specialKey] of clusters) {
this.registerPickKey(`keydown-${leftKey}`, () => move(-1));
this.registerPickKey(`keydown-${rightKey}`, () => move(1));
this.registerPickKey(`keydown-${fireKey}`, confirm);
this.registerPickKey(`keydown-${specialKey}`, confirm);
}
}
registerPickKey(evt, fn) {
this.input.keyboard.on(evt, fn);
this.pickKeyHandlers.push({ evt, fn });
}
teardownPickKeys() {
(this.pickKeyHandlers ?? []).forEach(({ evt, fn }) => this.input.keyboard.off(evt, fn));
this.pickKeyHandlers = [];
}
shipCard(x, y, w, h, entry, side, onPick) {
@ -423,16 +466,30 @@ export default class StarControlGame extends Phaser.Scene {
g.fillStyle(0x0c1a14, 0.94).fillRoundedRect(-w / 2, -h / 2, w, h, 10);
g.lineStyle(2, SIDE_COLORS[side].hull, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
c.add(g);
const icon = this.add.graphics();
this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false);
c.add(icon);
const spDef = def.sprite;
if (spDef?.sheet && this.textures.exists(spDef.sheet)) {
// Frame 0 of every UQM rotation sheet faces up.
this.textures.get(spDef.sheet).setFilter(Phaser.Textures.FilterMode.NEAREST);
const iconScale = Math.min(100 / spDef.frameWidth, 100 / spDef.frameHeight);
c.add(this.add.sprite(0, -62, spDef.sheet, 0).setScale(iconScale));
} else {
const icon = this.add.graphics();
this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false);
c.add(icon);
}
c.add(this.mkText(0, 8, def.name.replace(' ', '\n'), 24).setAlign('center'));
const crew = entry.crew ?? def.stats.maxCrew;
c.add(this.mkText(0, 74, `CREW ${crew}/${def.stats.maxCrew}`, 18, crew < def.stats.maxCrew ? '#f4a26c' : '#9adbb2'));
c.add(this.mkText(0, 102, `${def.pointValue} PTS`, 18, '#e7d27b'));
const focusRing = this.add.graphics();
focusRing.lineStyle(3, 0xffffff, 1).strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
focusRing.setVisible(false);
c.add(focusRing);
c.focusRing = focusRing;
c.setSize(w, h);
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => g.lineStyle(3, 0xffffff, 1).strokeRoundedRect(-w / 2, -h / 2, w, h, 10));
c.on('pointerover', () => focusRing.setVisible(true));
c.on('pointerout', () => focusRing.setVisible(false));
c.on('pointerdown', onPick);
return c;
}
@ -498,7 +555,9 @@ export default class StarControlGame extends Phaser.Scene {
if (this.match.over && this.deathHoldMs > 0) {
this.deathHoldMs -= delta;
if (this.deathHoldMs <= 0) this.resolveBattleEnd();
// resolveBattleEnd leaves the battle view (destroying HUD/world
// objects), so this frame must not touch them afterwards.
if (this.deathHoldMs <= 0) { this.resolveBattleEnd(); return; }
}
this.updateCamera(delta);
@ -537,7 +596,7 @@ export default class StarControlGame extends Phaser.Scene {
case 'shipHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 40 + e.dmg * 14, ttl: 300, age: 0 });
this.throttledSfx(SFX.EIGHTBIT_EXPLODE, 120);
this.crt.pulse?.(0.4, 120);
this.cameras.main.shake(120, 0.0016);
break;
case 'shotHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 26, ttl: 200, age: 0 });
@ -556,7 +615,7 @@ export default class StarControlGame extends Phaser.Scene {
case 'glory':
this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse?.(1, 400);
this.cameras.main.shake(400, 0.004);
break;
case 'cloak':
this.throttledSfx(SFX.SCIFI_RISER, 300);
@ -570,7 +629,7 @@ export default class StarControlGame extends Phaser.Scene {
case 'death':
this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse?.(1, 500);
this.cameras.main.shake(500, 0.004);
this.deathFocus = { x: e.x, y: e.y };
break;
case 'battleOver':
@ -651,7 +710,17 @@ export default class StarControlGame extends Phaser.Scene {
const ra = this.renderPos(A);
const rb = this.renderPos(B);
mid = tmid(ra.x, ra.y, rb.x, rb.y);
tz = targetZoom(tdist(ra.x, ra.y, rb.x, rb.y));
const dx = tdelta(ra.x, rb.x, ARENA_W);
const dy = tdelta(ra.y, rb.y, ARENA_H);
tz = targetZoom(Math.hypot(dx, dy));
// The distance bands above assume a roughly square view, but the
// canvas is 16:9, so a mostly-vertical separation can still push a
// ship past the (much closer) top/bottom edge. Clamp per-axis against
// the actual on-screen half-extents so both ships always fit.
const SHIP_MARGIN = 140;
const maxZoomX = (GAME_WIDTH / 2 - SHIP_MARGIN) / Math.max(Math.abs(dx) / 2, 1);
const maxZoomY = (GAME_HEIGHT / 2 - SHIP_MARGIN) / Math.max(Math.abs(dy) / 2, 1);
tz = Math.min(tz, maxZoomX, maxZoomY);
}
const k = 1 - Math.exp(-delta / ZOOM.SMOOTH_MS);
this.cam.zoom = smoothTowards(this.cam.zoom, tz, delta);
@ -763,8 +832,8 @@ export default class StarControlGame extends Phaser.Scene {
}
}
// Ships. UQM sheets: frame 0 faces up, rotating counterclockwise, so the
// frame for quantized facing q (0 = east, clockwise) is (12 - q) & 15.
// Ships. UQM sheets: frame 0 faces up, rotating clockwise, so the
// frame for quantized facing q (0 = east, clockwise) is (q + 4) & 15.
for (const ship of this.match.ships) {
const spr = this.shipSprites?.[ship.side];
if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; }
@ -780,7 +849,7 @@ export default class StarControlGame extends Phaser.Scene {
spr.setVisible(true)
.setPosition(s.x, s.y)
.setScale(z * 4)
.setFrame((12 - q + 16) & 15);
.setFrame((q + 4) & 15);
spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff);
if (thrusting) {
const f = ship.facing;

View File

@ -10,8 +10,11 @@
//
// --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
// frames are downloaded and composed into a 16x1 spritesheet at
// assets/images/starcontrol/sc-ship-<slug>.png. UQM frames are tightly
// cropped per frame (different sizes within one rotation) and aligned by
// per-frame hotspots in `<base>-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).
@ -100,7 +103,23 @@ function findRotationGroup(listing, preferredBase) {
return { base, frames };
}
async function buildSheet(slug, group, suffix = '') {
// Parses a UQM .ani file: `<frame>.png <transparent> <colormap> <hotX> <hotY>`
// 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 });
@ -110,12 +129,59 @@ async function buildSheet(slug, group, suffix = '') {
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', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]);
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
execFileSync('magick', [...cells, '+append', '-background', 'none', out]);
rmSync(tmp, { recursive: true, force: true });
return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) };
return { sheet: sheetName, frames: 16, frameWidth: cellW, frameHeight: cellH };
}
async function fetchShips() {
@ -129,13 +195,15 @@ async function fetchShips() {
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);
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') {
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-y');
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})`);
}
}