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:
parent
e9ddebc891
commit
bd693e0191
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue