From bd693e01917a959918caba218ecd691b7af3988e Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 15 Jul 2026 11:51:35 -0600 Subject: [PATCH] feat: add UQM-style ship entrance animation to Star Control - Implement cinematic ship entry from off-screen positions along facing direction - Spawn fading ghost afterimages every ~70ms to trace arrival trajectory - Use cubic ease-out for smooth deceleration as ships reach starting positions - Properly clean up ghost sprites during scene shutdown and intro transitions - Continue fading mid-lifetime ghosts after intro ends for clean visual exit --- src/games/starcontrol/StarControlGame.js | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/games/starcontrol/StarControlGame.js b/src/games/starcontrol/StarControlGame.js index 5cab716..f4b5953 100644 --- a/src/games/starcontrol/StarControlGame.js +++ b/src/games/starcontrol/StarControlGame.js @@ -120,6 +120,9 @@ export default class StarControlGame extends Phaser.Scene { this.gFx.clear(); this.shipSprites?.forEach((s) => s?.destroy()); this.shipSprites = null; + this.entryGhosts?.forEach((gh) => gh.obj.destroy()); + this.entryGhosts = []; + this.intro = null; } mkText(x, y, str, size, color = '#e8f4e8', origin = 0.5) { @@ -515,6 +518,30 @@ export default class StarControlGame extends Phaser.Scene { a: mk(0), b: mk(1), seed: this.matchSeed, keep: this.carry, }); + + // Sides with no carried-over ship are brand new to the arena this round + // (both, on the very first battle) — fly them in from off their facing + // direction, UQM-style, leaving a fading trail so the player can spot + // where they're headed before the fight opens. + const enteringSides = this.carry === null ? [0, 1] : [1 - this.carry.side]; + this.entryGhosts = []; + this.intro = enteringSides.length ? { t: 0, duration: 900, sides: enteringSides, from: {}, to: {}, lastGhostAt: {} } : null; + if (this.intro) { + const ENTRY_DIST = 1300; + for (const side of enteringSides) { + const ship = this.match.ships[side]; + const dir = { x: Math.cos(ship.facing), y: Math.sin(ship.facing) }; + this.intro.to[side] = { x: ship.x, y: ship.y, facing: ship.facing }; + this.intro.from[side] = { + x: wrap(ship.x - dir.x * ENTRY_DIST, ARENA_W), + y: wrap(ship.y - dir.y * ENTRY_DIST, ARENA_H), + }; + this.intro.lastGhostAt[side] = -Infinity; + ship.x = ship.px = this.intro.from[side].x; + ship.y = ship.py = this.intro.from[side].y; + } + } + // Real UQM rotation sheets when present; vector chevrons otherwise. this.shipSprites = this.match.ships.map((ship) => { const sheets = [ship.def.sprite, ...(ship.def.forms ?? []).map((f) => f.sprite)] @@ -533,6 +560,74 @@ export default class StarControlGame extends Phaser.Scene { playSound(this, SFX.SCIFI_RISER); } + // Eases entering ships from their off-station spawn point to their real + // starting position/facing, dropping a fading afterimage every ~70ms so + // the player can trace where they're arriving from. + updateIntro(delta) { + const intro = this.intro; + intro.t += delta; + const k = Math.min(1, intro.t / intro.duration); + const ease = 1 - (1 - k) ** 3; + for (const side of intro.sides) { + const ship = this.match.ships[side]; + const from = intro.from[side]; + const to = intro.to[side]; + const nx = wrap(from.x + tdelta(from.x, to.x, ARENA_W) * ease, ARENA_W); + const ny = wrap(from.y + tdelta(from.y, to.y, ARENA_H) * ease, ARENA_H); + ship.x = ship.px = nx; + ship.y = ship.py = ny; + ship.facing = to.facing; + if (intro.t - intro.lastGhostAt[side] >= 70) { + intro.lastGhostAt[side] = intro.t; + this.spawnEntryGhost(side, nx, ny, to.facing); + } + } + this.updateEntryGhosts(delta); + if (k >= 1) { + for (const side of intro.sides) { + const ship = this.match.ships[side]; + const to = intro.to[side]; + ship.x = ship.px = to.x; + ship.y = ship.py = to.y; + } + this.intro = null; + } + } + + spawnEntryGhost(side, x, y, facing) { + const ship = this.match.ships[side]; + const spDef = (ship.fx.formIndex ? ship.def.forms?.[ship.fx.formIndex - 1]?.sprite : null) ?? ship.def.sprite; + const ghost = { side, x, y, facing, age: 0, ttl: 420 }; + if (spDef?.sheet && this.textures.exists(spDef.sheet)) { + const frame = (quantizeFacing(facing) + 4) & 15; + ghost.obj = this.add.sprite(0, 0, spDef.sheet, frame) + .setDepth(D.world + 0.5).setTint(SIDE_COLORS[side].hull); + } else { + ghost.obj = this.add.graphics().setDepth(D.world + 0.5); + } + this.entryGhosts.push(ghost); + } + + updateEntryGhosts(delta) { + if (!this.entryGhosts.length) return; + const z = this.cam.zoom; + this.entryGhosts = this.entryGhosts.filter((gh) => { + gh.age += delta; + if (gh.age >= gh.ttl) { gh.obj.destroy(); return false; } + const alpha = 0.55 * (1 - gh.age / gh.ttl); + const s = this.w2s(gh.x, gh.y); + if (gh.obj instanceof Phaser.GameObjects.Sprite) { + gh.obj.setPosition(s.x, s.y).setScale(z * 4).setAlpha(alpha); + } else { + gh.obj.clear(); + const size = Math.max(9, this.match.ships[gh.side].def.radius * z); + this.drawChevron(gh.obj, s.x, s.y, size, gh.facing, SIDE_COLORS[gh.side].hull, false); + gh.obj.setAlpha(alpha); + } + return true; + }); + } + // ========================================================================= // Battle loop @@ -540,6 +635,14 @@ export default class StarControlGame extends Phaser.Scene { if (this.view !== 'battle' || !this.match) return; if (this.paused) return; + if (this.intro) { + this.updateIntro(delta); + this.updateCamera(delta); + this.renderWorld(delta); + this.updateHud(); + return; + } + if (!this.match.over) { for (const side of [0, 1]) { if (this.ais[side]) { @@ -560,6 +663,10 @@ export default class StarControlGame extends Phaser.Scene { if (this.deathHoldMs <= 0) { this.resolveBattleEnd(); return; } } + // The intro loop stops calling this once it ends, but any ghosts still + // mid-fade need to keep aging (and get destroyed) after that point. + if (this.entryGhosts.length) this.updateEntryGhosts(delta); + this.updateCamera(delta); this.renderWorld(delta); this.updateHud();