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.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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue