diff --git a/assets/images/starcontrol/sc-ship-androsynth.png b/assets/images/starcontrol/sc-ship-androsynth.png index 8688716..147012a 100644 Binary files a/assets/images/starcontrol/sc-ship-androsynth.png and b/assets/images/starcontrol/sc-ship-androsynth.png differ diff --git a/assets/images/starcontrol/sc-ship-arilou.png b/assets/images/starcontrol/sc-ship-arilou.png index 1792924..7ce2550 100644 Binary files a/assets/images/starcontrol/sc-ship-arilou.png and b/assets/images/starcontrol/sc-ship-arilou.png differ diff --git a/assets/images/starcontrol/sc-ship-chenjesu.png b/assets/images/starcontrol/sc-ship-chenjesu.png index 4a143d0..771c984 100644 Binary files a/assets/images/starcontrol/sc-ship-chenjesu.png and b/assets/images/starcontrol/sc-ship-chenjesu.png differ diff --git a/assets/images/starcontrol/sc-ship-chmmr.png b/assets/images/starcontrol/sc-ship-chmmr.png index 09b9e95..809f625 100644 Binary files a/assets/images/starcontrol/sc-ship-chmmr.png and b/assets/images/starcontrol/sc-ship-chmmr.png differ diff --git a/assets/images/starcontrol/sc-ship-druuge.png b/assets/images/starcontrol/sc-ship-druuge.png index 4b8a429..c4460b3 100644 Binary files a/assets/images/starcontrol/sc-ship-druuge.png and b/assets/images/starcontrol/sc-ship-druuge.png differ diff --git a/assets/images/starcontrol/sc-ship-earthling.png b/assets/images/starcontrol/sc-ship-earthling.png index 89ae26b..880cb90 100644 Binary files a/assets/images/starcontrol/sc-ship-earthling.png and b/assets/images/starcontrol/sc-ship-earthling.png differ diff --git a/assets/images/starcontrol/sc-ship-ilwrath.png b/assets/images/starcontrol/sc-ship-ilwrath.png index 236fa02..37224e5 100644 Binary files a/assets/images/starcontrol/sc-ship-ilwrath.png and b/assets/images/starcontrol/sc-ship-ilwrath.png differ diff --git a/assets/images/starcontrol/sc-ship-kohrah.png b/assets/images/starcontrol/sc-ship-kohrah.png index e5f2efb..ff5de44 100644 Binary files a/assets/images/starcontrol/sc-ship-kohrah.png and b/assets/images/starcontrol/sc-ship-kohrah.png differ diff --git a/assets/images/starcontrol/sc-ship-melnorme.png b/assets/images/starcontrol/sc-ship-melnorme.png index 6fad9e4..8b4ec26 100644 Binary files a/assets/images/starcontrol/sc-ship-melnorme.png and b/assets/images/starcontrol/sc-ship-melnorme.png differ diff --git a/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png b/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png index 75eb325..d6c90ca 100644 Binary files a/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png and b/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png differ diff --git a/assets/images/starcontrol/sc-ship-mmrnmhrm.png b/assets/images/starcontrol/sc-ship-mmrnmhrm.png index 5968e79..9dc4a64 100644 Binary files a/assets/images/starcontrol/sc-ship-mmrnmhrm.png and b/assets/images/starcontrol/sc-ship-mmrnmhrm.png differ diff --git a/assets/images/starcontrol/sc-ship-mycon.png b/assets/images/starcontrol/sc-ship-mycon.png index 6f8e827..d1022e4 100644 Binary files a/assets/images/starcontrol/sc-ship-mycon.png and b/assets/images/starcontrol/sc-ship-mycon.png differ diff --git a/assets/images/starcontrol/sc-ship-orz.png b/assets/images/starcontrol/sc-ship-orz.png index b0ed1b2..bc68de2 100644 Binary files a/assets/images/starcontrol/sc-ship-orz.png and b/assets/images/starcontrol/sc-ship-orz.png differ diff --git a/assets/images/starcontrol/sc-ship-pkunk.png b/assets/images/starcontrol/sc-ship-pkunk.png index 6c51e47..b59d8f7 100644 Binary files a/assets/images/starcontrol/sc-ship-pkunk.png and b/assets/images/starcontrol/sc-ship-pkunk.png differ diff --git a/assets/images/starcontrol/sc-ship-shofixti.png b/assets/images/starcontrol/sc-ship-shofixti.png index 609727a..e36d9e2 100644 Binary files a/assets/images/starcontrol/sc-ship-shofixti.png and b/assets/images/starcontrol/sc-ship-shofixti.png differ diff --git a/assets/images/starcontrol/sc-ship-slylandro.png b/assets/images/starcontrol/sc-ship-slylandro.png index 320e59c..6ead2dd 100644 Binary files a/assets/images/starcontrol/sc-ship-slylandro.png and b/assets/images/starcontrol/sc-ship-slylandro.png differ diff --git a/assets/images/starcontrol/sc-ship-spathi.png b/assets/images/starcontrol/sc-ship-spathi.png index bd13f00..8da0e71 100644 Binary files a/assets/images/starcontrol/sc-ship-spathi.png and b/assets/images/starcontrol/sc-ship-spathi.png differ diff --git a/assets/images/starcontrol/sc-ship-supox.png b/assets/images/starcontrol/sc-ship-supox.png index 0da9ad1..c91475b 100644 Binary files a/assets/images/starcontrol/sc-ship-supox.png and b/assets/images/starcontrol/sc-ship-supox.png differ diff --git a/assets/images/starcontrol/sc-ship-syreen.png b/assets/images/starcontrol/sc-ship-syreen.png index 1c743af..964c51c 100644 Binary files a/assets/images/starcontrol/sc-ship-syreen.png and b/assets/images/starcontrol/sc-ship-syreen.png differ diff --git a/assets/images/starcontrol/sc-ship-thraddash.png b/assets/images/starcontrol/sc-ship-thraddash.png index 28b5870..fd8d519 100644 Binary files a/assets/images/starcontrol/sc-ship-thraddash.png and b/assets/images/starcontrol/sc-ship-thraddash.png differ diff --git a/assets/images/starcontrol/sc-ship-umgah.png b/assets/images/starcontrol/sc-ship-umgah.png index 13b7967..9ef42a3 100644 Binary files a/assets/images/starcontrol/sc-ship-umgah.png and b/assets/images/starcontrol/sc-ship-umgah.png differ diff --git a/assets/images/starcontrol/sc-ship-urquan.png b/assets/images/starcontrol/sc-ship-urquan.png index 2c6acc0..05dd04e 100644 Binary files a/assets/images/starcontrol/sc-ship-urquan.png and b/assets/images/starcontrol/sc-ship-urquan.png differ diff --git a/assets/images/starcontrol/sc-ship-utwig.png b/assets/images/starcontrol/sc-ship-utwig.png index d852d30..aeaa8a9 100644 Binary files a/assets/images/starcontrol/sc-ship-utwig.png and b/assets/images/starcontrol/sc-ship-utwig.png differ diff --git a/assets/images/starcontrol/sc-ship-vux.png b/assets/images/starcontrol/sc-ship-vux.png index 723a677..bf658bd 100644 Binary files a/assets/images/starcontrol/sc-ship-vux.png and b/assets/images/starcontrol/sc-ship-vux.png differ diff --git a/assets/images/starcontrol/sc-ship-yehat.png b/assets/images/starcontrol/sc-ship-yehat.png index ae6a578..79d7cb7 100644 Binary files a/assets/images/starcontrol/sc-ship-yehat.png and b/assets/images/starcontrol/sc-ship-yehat.png differ diff --git a/assets/images/starcontrol/sc-ship-zoqfot.png b/assets/images/starcontrol/sc-ship-zoqfot.png index d949ff9..8beee78 100644 Binary files a/assets/images/starcontrol/sc-ship-zoqfot.png and b/assets/images/starcontrol/sc-ship-zoqfot.png differ diff --git a/data/star-control-ships.json b/data/star-control-ships.json index 82d6ce4..79de537 100644 --- a/data/star-control-ships.json +++ b/data/star-control-ships.json @@ -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 } } } diff --git a/src/games/starcontrol/StarControlGame.js b/src/games/starcontrol/StarControlGame.js index 412f625..5cab716 100644 --- a/src/games/starcontrol/StarControlGame.js +++ b/src/games/starcontrol/StarControlGame.js @@ -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; diff --git a/tools/fetchStarControlAssets.js b/tools/fetchStarControlAssets.js index 41f8b8c..35f6841 100644 --- a/tools/fetchStarControlAssets.js +++ b/tools/fetchStarControlAssets.js @@ -10,8 +10,11 @@ // // --ships: for each ship, the GitHub contents API lists // sc2/content/base/ships//, the 16 `-big-000..015.png` rotation -// frames are downloaded, montaged into a 16x1 spritesheet at -// assets/images/starcontrol/sc-ship-.png, and the measured frame +// 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). @@ -100,7 +103,23 @@ function findRotationGroup(listing, preferredBase) { return { base, frames }; } -async function buildSheet(slug, group, suffix = '') { +// 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 }); @@ -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})`); } }