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
This commit is contained in:
Brian Fertig 2026-07-15 11:51:35 -06:00
parent e9ddebc891
commit bd693e0191
1 changed files with 107 additions and 0 deletions

View File

@ -120,6 +120,9 @@ export default class StarControlGame extends Phaser.Scene {
this.gFx.clear(); this.gFx.clear();
this.shipSprites?.forEach((s) => s?.destroy()); this.shipSprites?.forEach((s) => s?.destroy());
this.shipSprites = null; 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) { 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, a: mk(0), b: mk(1), seed: this.matchSeed,
keep: this.carry, 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. // Real UQM rotation sheets when present; vector chevrons otherwise.
this.shipSprites = this.match.ships.map((ship) => { this.shipSprites = this.match.ships.map((ship) => {
const sheets = [ship.def.sprite, ...(ship.def.forms ?? []).map((f) => f.sprite)] 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); 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 // Battle loop
@ -540,6 +635,14 @@ export default class StarControlGame extends Phaser.Scene {
if (this.view !== 'battle' || !this.match) return; if (this.view !== 'battle' || !this.match) return;
if (this.paused) return; if (this.paused) return;
if (this.intro) {
this.updateIntro(delta);
this.updateCamera(delta);
this.renderWorld(delta);
this.updateHud();
return;
}
if (!this.match.over) { if (!this.match.over) {
for (const side of [0, 1]) { for (const side of [0, 1]) {
if (this.ais[side]) { if (this.ais[side]) {
@ -560,6 +663,10 @@ export default class StarControlGame extends Phaser.Scene {
if (this.deathHoldMs <= 0) { this.resolveBattleEnd(); return; } 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.updateCamera(delta);
this.renderWorld(delta); this.renderWorld(delta);
this.updateHud(); this.updateHud();