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

View File

@ -4,10 +4,9 @@ import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import { import {
ARENA_W, ARENA_H, ZOOM, TUNE, STEP_MS, 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, compileShips, createBattle, setInput, stepMatch, quantizeFacing, facingToRad,
} from './StarControlLogic.js'; } from './StarControlLogic.js';
import { createAI, updateAI } from './StarControlAI.js'; import { createAI, updateAI } from './StarControlAI.js';
@ -87,18 +86,16 @@ export default class StarControlGame extends Phaser.Scene {
); );
this.escHandler = () => this.onEscape(); this.escHandler = () => this.onEscape();
this.input.keyboard.on('keydown-ESC', this.escHandler); this.input.keyboard.on('keydown-ESC', this.escHandler);
this.pickKeyHandlers = [];
this.crt = applyArcadeCRTOverlay(this, {
accentTint: 0x74f5a1, scanlineTint: 0x2e8f57,
});
try { try {
const music = this.cache.json.get('music'); const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ } } catch (_) { /* optional */ }
this.events.once('shutdown', () => { this.events.once('shutdown', () => {
this.crt.destroy();
this.input.keyboard.off('keydown-ESC', this.escHandler); this.input.keyboard.off('keydown-ESC', this.escHandler);
this.teardownPickKeys();
}); });
// Match configuration defaults (setup view edits these). // Match configuration defaults (setup view edits these).
@ -365,6 +362,7 @@ export default class StarControlGame extends Phaser.Scene {
showShipPick() { showShipPick() {
this.view = 'shipPick'; this.view = 'shipPick';
this.clearView(); this.clearView();
this.teardownPickKeys();
// AI sides pick instantly. // AI sides pick instantly.
[0, 1].forEach((side) => { [0, 1].forEach((side) => {
@ -397,23 +395,68 @@ export default class StarControlGame extends Phaser.Scene {
const perRow = Math.min(remaining.length, 7); const perRow = Math.min(remaining.length, 7);
const cardW = 240; const cardW = 240;
const cardH = 290; const cardH = 290;
remaining.forEach((entry, n) => { const cards = remaining.map((entry, n) => {
const row = Math.floor(n / perRow); const row = Math.floor(n / perRow);
const inRow = Math.min(remaining.length - row * perRow, perRow); const inRow = Math.min(remaining.length - row * perRow, perRow);
const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20); const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20);
const y = 420 + row * (cardH + 24); const y = 420 + row * (cardH + 24);
root.add(this.shipCard(x, y, cardW, cardH, entry, humanSide, () => { const card = this.shipCard(x, y, cardW, cardH, entry, humanSide,
this.current[humanSide] = entry.i; () => this.confirmShipPick(humanSide, entry));
this.needPick[humanSide] = false; root.add(card);
playSound(this, SFX.EIGHTBIT_CARD); return card;
this.showShipPick(); // next picker (hotseat) or battle
}));
}); });
root.add(this.mkText(cx, GAME_HEIGHT - 40, root.add(this.mkText(cx, GAME_HEIGHT - 40,
`FLEET VALUE REMAINING — YOU ${fleetPoints(this.SHIPS, remaining.map((e) => e.key))} PTS / ` + `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`, `ENEMY ${fleetPoints(this.SHIPS, this.aliveFleet(1 - humanSide).map((e) => e.key))} PTS`,
22, '#89a6a0')); 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) { 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.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); g.lineStyle(2, SIDE_COLORS[side].hull, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
c.add(g); c.add(g);
const icon = this.add.graphics(); const spDef = def.sprite;
this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false); if (spDef?.sheet && this.textures.exists(spDef.sheet)) {
c.add(icon); // 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')); c.add(this.mkText(0, 8, def.name.replace(' ', '\n'), 24).setAlign('center'));
const crew = entry.crew ?? def.stats.maxCrew; 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, 74, `CREW ${crew}/${def.stats.maxCrew}`, 18, crew < def.stats.maxCrew ? '#f4a26c' : '#9adbb2'));
c.add(this.mkText(0, 102, `${def.pointValue} PTS`, 18, '#e7d27b')); 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.setSize(w, h);
c.setInteractive({ useHandCursor: true }); 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); c.on('pointerdown', onPick);
return c; return c;
} }
@ -498,7 +555,9 @@ export default class StarControlGame extends Phaser.Scene {
if (this.match.over && this.deathHoldMs > 0) { if (this.match.over && this.deathHoldMs > 0) {
this.deathHoldMs -= delta; 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); this.updateCamera(delta);
@ -537,7 +596,7 @@ export default class StarControlGame extends Phaser.Scene {
case 'shipHit': case 'shipHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 40 + e.dmg * 14, ttl: 300, age: 0 }); 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.throttledSfx(SFX.EIGHTBIT_EXPLODE, 120);
this.crt.pulse?.(0.4, 120); this.cameras.main.shake(120, 0.0016);
break; break;
case 'shotHit': case 'shotHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 26, ttl: 200, age: 0 }); 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': case 'glory':
this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 }); this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE); playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse?.(1, 400); this.cameras.main.shake(400, 0.004);
break; break;
case 'cloak': case 'cloak':
this.throttledSfx(SFX.SCIFI_RISER, 300); this.throttledSfx(SFX.SCIFI_RISER, 300);
@ -570,7 +629,7 @@ export default class StarControlGame extends Phaser.Scene {
case 'death': case 'death':
this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 }); this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE); 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 }; this.deathFocus = { x: e.x, y: e.y };
break; break;
case 'battleOver': case 'battleOver':
@ -651,7 +710,17 @@ export default class StarControlGame extends Phaser.Scene {
const ra = this.renderPos(A); const ra = this.renderPos(A);
const rb = this.renderPos(B); const rb = this.renderPos(B);
mid = tmid(ra.x, ra.y, rb.x, rb.y); 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); const k = 1 - Math.exp(-delta / ZOOM.SMOOTH_MS);
this.cam.zoom = smoothTowards(this.cam.zoom, tz, delta); 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 // Ships. UQM sheets: frame 0 faces up, rotating clockwise, so the
// frame for quantized facing q (0 = east, clockwise) is (12 - q) & 15. // frame for quantized facing q (0 = east, clockwise) is (q + 4) & 15.
for (const ship of this.match.ships) { for (const ship of this.match.ships) {
const spr = this.shipSprites?.[ship.side]; const spr = this.shipSprites?.[ship.side];
if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; } if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; }
@ -780,7 +849,7 @@ export default class StarControlGame extends Phaser.Scene {
spr.setVisible(true) spr.setVisible(true)
.setPosition(s.x, s.y) .setPosition(s.x, s.y)
.setScale(z * 4) .setScale(z * 4)
.setFrame((12 - q + 16) & 15); .setFrame((q + 4) & 15);
spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff); spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff);
if (thrusting) { if (thrusting) {
const f = ship.facing; const f = ship.facing;

View File

@ -10,8 +10,11 @@
// //
// --ships: for each ship, the GitHub contents API lists // --ships: for each ship, the GitHub contents API lists
// sc2/content/base/ships/<dir>/, the 16 `<base>-big-000..015.png` rotation // sc2/content/base/ships/<dir>/, the 16 `<base>-big-000..015.png` rotation
// frames are downloaded, montaged into a 16x1 spritesheet at // frames are downloaded and composed into a 16x1 spritesheet at
// assets/images/starcontrol/sc-ship-<slug>.png, and the measured frame // 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 // dimensions are written into that ship's `sprite` block in
// data/star-control-ships.json (only the sprite block is touched). // data/star-control-ships.json (only the sprite block is touched).
@ -100,7 +103,23 @@ function findRotationGroup(listing, preferredBase) {
return { base, frames }; 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}`); const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`);
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true }); mkdirSync(tmp, { recursive: true });
@ -110,12 +129,59 @@ async function buildSheet(slug, group, suffix = '') {
await download(fr.url, dest); await download(fr.url, dest);
files.push(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 sheetName = `sc-ship-${slug}${suffix}`;
const out = join(OUT_DIR, `${sheetName}.png`); const out = join(OUT_DIR, `${sheetName}.png`);
execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]); execFileSync('magick', [...cells, '+append', '-background', 'none', out]);
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
rmSync(tmp, { recursive: true, force: true }); 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() { async function fetchShips() {
@ -129,13 +195,15 @@ async function fetchShips() {
const listing = await ghJson(`${CONTENT}/${dir}`); const listing = await ghJson(`${CONTENT}/${dir}`);
const group = findRotationGroup(listing, SHIP_BASE[slug]); const group = findRotationGroup(listing, SHIP_BASE[slug]);
if (!group) { console.log('NO 16-frame rotation group found'); continue; } 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})`); 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. // Mmrnmhrm's alternate Y-Wing form has its own rotation frames.
if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) { if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) {
const yGroup = findRotationGroup(listing, 'ywing'); const yGroup = findRotationGroup(listing, 'ywing');
if (yGroup?.base === '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})`); console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`);
} }
} }