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
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 993 B |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||