orbit/js/combat/CombatScene.js

882 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
// Codebase pattern: a local fallback stack passed into fontStack()
// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
import { Ship } from '../entities/Ship.js';
import { EnemyShip } from './EnemyShip.js';
import { enemySheetList } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel, pickTarget } from './Combat.js';
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
import { combatSfx, combatSfxEnemyHit, loadCombatSfx } from './CombatSfx.js';
import { combatShake, ensureUiCameras, assignWorld } from './CamShake.js';
import { CombatHud } from './CombatHud.js';
import { CombatBar } from '../ui/CombatBar.js';
/**
* The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
* the dev server's /combat / index.html?combat). One fixed, full-screen
* arena where the player ship (ART + flight stats from data/ship.json,
* COMBAT feel from data/combat.json → player) fights a pack of the
* enemy craft (→ enemy, assets/images/ships-enemy.png) against simple
* rules:
*
* WASD / arrows — fly (keyboard turn + thrust, Ship.update does the
* drag/coast), SPACE or J — fire (hold = auto),
* R — reset the range, P — pause.
*
* Deliberately NO economy, no map, no save: this is the place to
* iterate the feel before combat ships in the real game. Everything
* tunable lives in data/combat.json; the pure rules are in
* js/combat/Combat.js (tested by dev/combat.test.mjs).
*
* Architecture notes (this build — Phaser 4.2 "Giedi" UMD):
* - Scenes drive the sim: this.update() steps time + tweens + the
* physics world by hand (see js/scenes/GameScene.js), then runs the
* actors. Nothing here relies on the engine's scene stepping.
* - Collisions are circle checks done HERE (bullets × ships, ram), not
* arcade colliders — same hand-rolled approach the main game uses
* for its keep-out constraints (GameScene.resolveKeepOut /
* resolveCollisions).
* - FX is the hand-rolled Fx (js/combat/Fx.js), not the particles
* emitter (velocity semantics differ in this build).
*
* The console exposes `window.__COMBAT__` — { reset, spawnEnemy, player }
* — so a designer can drive the range from devtools while it runs.
*/
export class CombatScene extends Phaser.Scene {
constructor() {
super('CombatScene');
}
// ---------------------------------------------------------------- assets
preload() {
const cfg = config.section('combat', {});
// The player ship sheet (the SAME texture key + geometry as the main
// game — GameScene.preload — so the art never drifts between the two).
const shipTex = config.get('ship.texture', '');
if (shipTex && !this.textures.exists(Ship.TEXTURE_KEY)) {
this.load.spritesheet(Ship.TEXTURE_KEY, shipTex, {
frameWidth: config.get('ship.frameWidth', 256),
frameHeight: config.get('ship.frameHeight', 256),
});
}
// The enemy sheets (data/combat.json → enemy.texture + the enemyTypes'
// own textures — EnemyTypes.enemySheetList is the seam, one key per
// source file, EnemyShip.textureKey).
for (const { src, frameWidth, frameHeight } of enemySheetList()) {
if (this.textures.exists(EnemyShip.textureKey(src))) continue;
this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
}
// The combat voices (js/combat/CombatSfx.js — the shared event→sound
// map; the main game loads these same keys itself in GameScene).
loadCombatSfx(this);
}
// ---------------------------------------------------------------- create
create() {
const w = this.scale.width;
const h = this.scale.height;
this.w = w;
this.h = h;
this.paused = false;
this.playerDead = false;
this.kills = 0;
this.nextFireAt = 0;
this.ramUntil = 0;
// The arena.
this.inset = Math.max(8, config.get('combat.arena.inset', 26));
this.wallBounce = Math.max(0, Math.min(1, config.get('combat.arena.bounce', 0.9)));
this.starfield = new StarDrift(this);
this.starfield.create(w, h);
this.drawWalls();
// The player (the real Ship — same entity as the main game).
this.maxHull = Math.max(1, config.get('combat.playerShip.hull', 100));
this.maxShields = Math.max(0, config.get('combat.playerShip.shields', 40));
this.hull = this.maxHull;
this.shields = this.maxShields;
// ENERGY — the skill resource (→ energy; the bar's right orb). The
// range's skills land with the real combat port — for now the orb
// just sits full (regen = energy/s, 0 = static).
const en = config.section('combat.energy', {});
this.maxEnergy = Math.max(1, Number(en.max) || 100);
this.energy = this.maxEnergy;
this.energyRegen = Math.max(0, Number(en.regen) || 0);
this.lastHitAt = -1e9;
this.ship = new Ship(this, w * 0.24, h / 2);
this.ship.setDepth(10);
this.ship.body.setAllowGravity(false);
// The range's drift drag — combat.player.drag OVERRIDES ship.drag
// (data/ship.json), so data/combat.json is the one feel surface.
// (Ship.update reads this.drag every frame — the assignment sticks.)
const cd = Number(config.get('combat.player.drag', 2.4));
if (Number.isFinite(cd) && cd > 0) this.ship.drag = cd;
// The flight-feel state (js/combat/Combat.js → handleFeel, a PURE
// step exercised by dev/combat.test.mjs): smoothed turn rate,
// ramped throttle, instant brake. The scene assigns it onto the
// arcade body each frame (updatePlayer).
this.feeel = { rot: this.ship.rotation, turnVel: 0, throttle: 0, brake: 0 };
// The pack (spawned on a ring around the screen center).
this.enemies = [];
const count = Math.max(0, Math.round(config.get('combat.enemy.count', 3)));
const cx = w / 2;
const cy = h / 2;
const ring = Math.min(w, h) * 0.34;
for (let i = 0; i < count; i++) {
const a = (i / Math.max(1, count)) * Math.PI * 2 + 0.7;
this.spawnEnemy(cx + Math.cos(a) * ring, cy + Math.sin(a) * ring, i);
}
this.bullets = [];
// The heat-seeker roster (js/combat/Weapons.js steps + hits them; the
// missiles themselves live in js/combat/Missile.js).
this.missiles = [];
this.fx = new Fx(this);
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
// holding the white hit-flash state is re-asserted back to its
// normal colors no matter how the stick got there.
this.fx.purgeRoster = () => [this.ship, ...this.enemies];
// The range console (HUD).
this.createHud();
// The READABILITY LAYER (js/combat/CombatHud.js — shared with the
// main game): the lock on the current target (the nearest hostile —
// the same pickTarget rule the main game's auto-fire runs), the
// threat markers, the off-screen arrows (rare — the arena is one
// screen), the kill feed, and the HUD gauge flash on player hits.
this.hud = new CombatHud(this, {
getEnemies: () => this.enemies,
getTarget: () =>
this.playerDead || !this.ship ? null : pickTarget(this.enemies, this.ship.x, this.ship.y),
// No scrolling camera — the arena is a fixed full screen.
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
});
// Controls: WASD/arrows + SPACE/J. R = reset, P = pause.
//
// NOTE — this build (Phaser 4.2.1 Giedi): `addKeys({up:'W,UP',…})`
// looks each value up in the KeyCodes map, which has NO 'W,UP' entry —
// every name landed on ONE shared Key with keyCode `undefined`, so the
// old `keys.up.isDown` polling could never see a real key-press
// (WASD was silently dead). The main game's working pattern is the
// event API — the manager emits `keydown-<NAME>` for real keycodes —
// so drive a plain key-state object from those events. Real Key
// objects are still registered (single names — that form works) so
// the manager captures them (arrows/space must not scroll the page).
const kb = this.input.keyboard;
for (const code of ['W', 'A', 'S', 'D', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'J']) {
kb.addKey(code);
}
this.keys = {
up: { isDown: false },
down: { isDown: false },
left: { isDown: false },
right: { isDown: false },
fire: { isDown: false },
};
const bind = (name, code) => {
kb.on(`keydown-${code}`, () => { this.keys[name].isDown = true; });
kb.on(`keyup-${code}`, () => { this.keys[name].isDown = false; });
};
bind('up', 'W'); bind('up', 'UP');
bind('down', 'S'); bind('down', 'DOWN');
bind('left', 'A'); bind('left', 'LEFT');
bind('right', 'D'); bind('right', 'RIGHT');
bind('fire', 'SPACE'); bind('fire', 'J');
// Losing focus must not leave a key "held" (wire once — the scene can
// restart in place, and the closure re-reads `this.keys` every blur).
if (typeof window !== 'undefined' && !this.__blurWired) {
this.__blurWired = true;
window.addEventListener('blur', () => {
for (const k of Object.values(this.keys)) k.isDown = false;
});
}
kb.on('keydown-R', () => this.reset());
kb.on('keydown-P', () => this.togglePause());
// The designer console (data/combat.json is the tuning surface).
if (typeof window !== 'undefined') {
const scene = this; // getters must close over the scene, not `this`
window.__COMBAT__ = {
reset: () => scene.reset(),
spawnEnemy: (x, y) => scene.spawnEnemy(x, y, scene.enemies.length),
/** "62 +31 shd" — a quick armor read from devtools. */
get player() {
return `${Math.ceil(scene.hull)}${scene.shields > 0 ? ` +${Math.ceil(scene.shields)} shd` : ''}`;
},
get kills() { return scene.kills; },
};
}
console.info('orbit — combat sandbox armed · tune data/combat.json · window.__COMBAT__ = { reset, spawnEnemy, player, kills }');
// The UI split (js/visuals/UiCameras.js): the bar, the HUD text, and
// the gauge flashes live on the untouched `fx-ui` pass, so the world
// camera's shake kicks the ARENA — the HUD stays perfectly still.
// (Idempotent; null-safe for camera-less scenes.)
ensureUiCameras(this);
}
// ---------------------------------------------------------------- update
update(time, delta) {
if (this.paused) return; // frozen — the world is paused too (togglePause),
// so nothing drifts while we look at it
// THIS BUILD (Phaser 4.2.1 Giedi): the engine steps the arcade world
// and the scene clock itself (plugins' preUpdate — verified: bodies
// keep integrating while the scene's update() is paused, and
// World.update takes only (time, delta) — no callback). So we do NOT
// step the world here (that would double-integrate every body);
// the positions below are already this frame's post-integration ones,
// and the velocities we set now land on the NEXT frame's step —
// the same contract GameScene runs on (actors in update, constraints
// in postupdate).
this.time.update(time, delta); // idempotent (Clock sets now = time)
// Actor order: player → enemies → bullets (aging) → collide, so a
// point-blank hit lands on THIS frame's positions.
this.updatePlayer(time, delta);
this.updateEnemies(time, delta);
this.updateBullets(delta);
// The heat-seekers (js/combat/Weapons.js): age the fuses + home on the
// player (null while it's down — the shots fly true). The arena walls
// eat them, like the tracers.
stepMissiles(this, delta, this.playerDead ? null : this.ship, { inset: this.inset, w: this.w, h: this.h });
this.collide(delta);
this.fx.update(delta);
this.starfield.update(time, delta);
this.hud.update(time); // the readability layer (lock/threat/feed/flash)
this.updateHud(time);
}
// ------------------------------------------------------------ the player
updatePlayer(time, delta) {
if (this.playerDead) {
this.maybeRespawn(time);
return;
}
const ship = this.ship;
const dt = Math.min(delta, 64) / 1000;
// Ship.update owns the physics contract (drag, acceleration →
// velocity) — call it FIRST (it zeroes the acceleration when we
// don't thrust this frame), then apply the keyboard on top.
ship.update(time, delta);
// The flight feel (combat.player → the PURE step in Combat.js —
// smoothed turn rate + ramped throttle + instant brake; tested by
// dev/combat.test.mjs). The feel state lives on the scene.
const p = config.section('combat.player', {});
const keys = this.keys;
handleFeel(
this.feeel,
{
left: keys.left.isDown,
right: keys.right.isDown,
up: keys.up.isDown,
down: keys.down.isDown,
},
dt,
p,
);
ship.rotation = this.feeel.rot;
const heading = ship.rotation - ship.artOffset; // world radians (Ship contract)
const full = Math.max(0, config.get('combat.player.thrust', 1500));
const thrust = full * this.feeel.throttle;
let ax = 0;
let ay = 0;
let thrusting = false;
if (thrust > 0) {
ax += Math.cos(heading) * thrust;
ay += Math.sin(heading) * thrust;
thrusting = true;
}
if (this.feeel.brake > 0) {
// The brake is a fraction of FULL thrust (an instant stop).
ax -= Math.cos(heading) * full * this.feeel.brake;
ay -= Math.sin(heading) * full * this.feeel.brake;
}
if (thrusting || this.feeel.brake > 0) ship.body.acceleration.set(ax, ay);
// Ship's drift branch has no speed cap — enforce the range's.
const maxV = Math.max(1, config.get('combat.player.maxSpeed', 560));
const v = ship.body.velocity.length();
if (v > maxV) ship.body.velocity.scale(maxV / v);
// Exhaust glow — the engine flare, cheap (a tinted soft dot aft).
this.updateThrustGlow(ship, heading, thrusting, dt);
// Fire (hold = auto; the cooldown lives on the weapon).
const wpn = config.section('combat.player.weapon', {});
if (keys.fire.isDown && time >= this.nextFireAt) {
this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
this.firePlayer(heading, wpn);
}
// Shield regen (playerShip.* — shields back up after lastHitAt + delay).
const pCfg = config.section('combat.playerShip', {});
if (this.shields < this.maxShields && this.maxShields > 0) {
const grace = (Number(pCfg.shieldRegenDelay) || 4) * 1000;
const rate = Math.max(0, Number(pCfg.shieldRegenRate) || 14);
if (time - this.lastHitAt >= grace) {
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
}
}
// Energy regen (energy.* — the bar's right orb; static when regen 0).
if (this.energy < this.maxEnergy && this.energyRegen > 0) {
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
}
}
firePlayer(heading, wpn) {
const spread = Math.max(0, Number(wpn.spread) || 0);
const angle = heading + (Math.random() * 2 - 1) * spread;
const nose = this.ship.radius + 8;
const x = this.ship.x + Math.cos(heading) * nose;
const y = this.ship.y + Math.sin(heading) * nose;
this.spawnBullet(x, y, angle, wpn, 'player');
this.fx.muzzle(x, y, angle, wpn.color ?? 0x00e5ff, Number(wpn.muzzle) || 46);
combatSfx(this, 'cannon_fire'); // the mass-cannon voice (the enemy lances keep the blaster ping)
}
updateThrustGlow(ship, heading, thrusting, dt) {
if (!this.thrustGlow) {
this.thrustGlow = this.add
.image(0, 0, Fx.ensureDot(this))
.setBlendMode(Phaser.BlendModes.ADD)
.setDepth(9)
.setAlpha(0);
assignWorld(this, this.thrustGlow); // world pass (the UI pass must not double-draw)
}
const g = this.thrustGlow;
const want = thrusting ? 0.5 : 0;
g.alpha += (want - g.alpha) * Math.min(1, 12 * dt);
if (g.alpha < 0.02 && !thrusting) {
if (g.visible) g.setVisible(false);
return;
}
if (!g.visible) g.setVisible(true);
g.x = ship.x - Math.cos(heading) * ship.radius * 0.85;
g.y = ship.y - Math.sin(heading) * ship.radius * 0.85;
g.setTint(themeColor('neon', 0x00e5ff));
g.setScale(30 / 64);
}
// --------------------------------------------------------------- enemies
spawnEnemy(x, y, index) {
const e = new EnemyShip(this, x, y, index);
e.setDepth(10);
// Anchor its orbit to its current bearing from the player…
const px = this.ship ? this.ship.x : this.w / 2;
const py = this.ship ? this.ship.y : this.h / 2;
e.orbitAngle = Math.atan2(y - py, x - px);
// …and grant the pack a brief non-shooting grace.
e.nextFireAt = 1000;
this.enemies.push(e);
return e;
}
updateEnemies(time, delta) {
const player = this.playerDead ? null : this.ship;
for (const e of this.enemies) {
if (e.dead) {
// Respawn keeps the range stocked while tuning (the craft's own
// type config — the base enemy here; wave ships never respawn).
if (e.respawn !== false && time >= e.respawnAt) this.reviveEnemy(e);
continue;
}
const wantsFire = e.update(time, delta, player);
if (wantsFire && !this.playerDead) this.fireEnemy(e);
this.wallClamp(e, e.hitRadius);
}
}
/**
* A hostile's weapon volley — the SHARED pattern dispatch (js/combat/Weapons.js)
* both combat contexts run: the base craft's 'lance' plus the seven new
* types' patterns (spray / burst / rapid / ring / missile / combo —
* data/combat.json → enemyTypes, weapon.pattern).
*/
fireEnemy(e) {
return firePattern(this, e, this.ship, e.wpn ?? {});
}
killEnemy(e) {
if (e._killFx) return; // idempotent — damage() sets e.dead first, so
e._killFx = true; // the dead flag can't be the "already counted" guard
// Settle any pending white hit-flash NOW — the craft dies in its
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
this.kills += 1;
this.fx.explode(e.x, e.y, 0xff5d6f);
combatShake(this, 'kill'); // the world kick (the bar stays still)
combatSfx(this, 'kill');
if (this.hud) this.hud.kill('HOSTILE DESTROYED'); // the kill feed
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
e.respawnAt = this.time.now + (e.respawnDelayMs ?? 0);
}
reviveEnemy(e) {
// Back to a random edge of the ring around the player.
e._killFx = false;
const a = Math.random() * Math.PI * 2;
const r = Math.max(this.inset + 60, e.orbitRadius * (0.8 + Math.random() * 0.5));
const px = this.playerDead ? this.w / 2 : this.ship.x;
const py = this.playerDead ? this.h / 2 : this.ship.y;
e.x = px + Math.cos(a) * r;
e.y = py + Math.sin(a) * r;
e.orbitAngle = a;
e.revive();
e.nextFireAt = this.time.now + 900;
e.setVisible(true);
}
// --------------------------------------------------------------- bullets
spawnBullet(x, y, angle, wpn, owner) {
const b = new Bullet(this, {
x,
y,
angle,
speed: Number(wpn.bulletSpeed) || 600,
damage: Number(wpn.damage) || 8,
life: Number(wpn.bulletLife) || 1.2,
color: wpn.color ?? '#ffffff',
size: Number(wpn.size) || 16,
boltLength: Number(wpn.boltLength) || 0, // 0 = Bullet's default (size × 3)
boltWidth: Number(wpn.boltWidth) || 0,
owner,
});
b.setDepth(20);
this.bullets.push(b);
return b;
}
updateBullets(delta) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || !b.active) {
this.bullets.splice(i, 1);
continue;
}
b.update(delta);
if (b.dead) {
this.bullets.splice(i, 1);
continue;
}
// Walls clip tracers (a bullet never outlives the arena).
if (b.x < this.inset || b.x > this.w - this.inset || b.y < this.inset || b.y > this.h - this.inset) {
if (b.consume()) {
this.bullets.splice(i, 1);
combatSfx(this, 'impact', { vol: 0.5 }); // the tracer dies on the wall
}
}
}
}
// ------------------------------------------------------------- collisions
/** Circle check (world px). */
/**
* Swept hit check: tracers run ~900 px/s — at 30 fps that's
* ~30 px per frame, past a 25 px combined radius, so a point check can
* tunnel. Sample the segment the bullet flew this frame (end, middle,
* start) against the target circle.
*/
hitsSwept(b, other, otherRadius, dtMs) {
const r = b.radius + otherRadius;
const r2 = r * r;
const vx = b.body ? b.body.velocity.x : 0;
const vy = b.body ? b.body.velocity.y : 0;
const dt = Math.min(dtMs, 64) / 1000;
for (let f = 1; f >= 0; f -= 0.5) {
const dx = other.x - (b.x - vx * dt * f);
const dy = other.y - (b.y - vy * dt * f);
if (dx * dx + dy * dy < r2) return true;
}
return false;
}
collide(delta) {
// Player bullets × enemies.
if (!this.playerDead) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'player') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
// THE IMPACT READ (js/combat/Fx.js → enemyHit): core flash,
// sparks along the bolt's travel, the craft's white flash,
// and the layer's cue (a cyan shield arc / orange shards).
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfx(this, 'cannon_hit'); // the mass-cannon hit voice (the shield/hull cues ride on)
combatSfxEnemyHit(this, preShields, b.damage);
if (killed) this.killEnemy(e);
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
}
// Enemy lances × player.
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy' || this.playerDead) continue;
if (this.hitsSwept(b, this.ship, this.ship.radius, delta)) {
// The shield was struck from the laser's incoming bearing (the
// side facing its source — the travel's opposite).
this.damagePlayer(b.damage, { x: b.x, y: b.y, bearing: b.angle0 + Math.PI });
if (b.consume()) this.bullets.splice(i, 1);
}
}
// Heat-seekers × player (js/combat/Weapons.js): the missile's damage
// lands from its incoming bearing; the hit blooms and consumes it.
if (!this.playerDead) missilesHitPlayer(this, this.ship, this.ship.radius, delta);
// Rams (player × enemies) — damage both, bounce both.
if (!this.playerDead) {
for (const e of this.enemies) {
if (e.dead) continue;
this.ramPair(this.ship, e);
}
}
}
/**
* A ship-ship collision: push apart, exchange normal velocity (bounce),
* and deal ram damage to BOTH (grace-limited — data/combat.json →
* enemy.ram*). The player's Ship has no hull of its own here; the
* scene's armor (this.hull/shields) is what rams cost.
*/
ramPair(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const gap = a.radius + b.hitRadius;
if (d >= gap || d <= 0) return;
const nx = dx / d;
const ny = dy / d;
const push = (gap - d) / 2;
a.x -= nx * push;
a.y -= ny * push;
b.x += nx * push;
b.y += ny * push;
const avx = a.body.velocity.x;
const avy = a.body.velocity.y;
const rvx = b.body.velocity.x - avx;
const rvy = b.body.velocity.y - avy;
const vn = rvx * nx + rvy * ny;
if (vn < 0) {
const j = (-(1 + this.wallBounce) * vn) / 2; // equal-mass impulse
a.body.velocity.x = avx - j * nx;
a.body.velocity.y = avy - j * ny;
b.body.velocity.x += j * nx;
b.body.velocity.y += j * ny;
}
const t = this.time.now;
if (t >= this.ramUntil) {
this.ramUntil = t + (b.ramGraceMs ?? 800);
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const bearing = Math.atan2(b.y - a.y, b.x - a.x); // the side that faced the rammer
this.fx.flash(midX, midY, 0xffffff, 46, 160);
this.fx.sparks(midX, midY, FX_COLORS.hull, 8, 260, 520, bearing, 0.7);
this.fx.sparks(midX, midY, 0xffffff, 4, 200, 380, bearing, 0.6);
this.fx.craftFlash(b);
const preShields = b.shields;
const ramSelf = b.ramDamageSelf ?? 0;
this.damagePlayer(b.ramDamage ?? 0, { bearing }); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this, preShields, ramSelf);
const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b);
}
}
/** The arena walls — clamp + mirror-bounce (arena.bounce). */
wallClamp(body, r) {
const L = this.inset + r;
const R = this.w - this.inset - r;
const T = this.inset + r;
const B = this.h - this.inset - r;
const v = body.body.velocity;
if (body.x < L) {
body.x = L;
if (v.x < 0) v.x *= -this.wallBounce;
} else if (body.x > R) {
body.x = R;
if (v.x > 0) v.x *= -this.wallBounce;
}
if (body.y < T) {
body.y = T;
if (v.y < 0) v.y *= -this.wallBounce;
} else if (body.y > B) {
body.y = B;
if (v.y > 0) v.y *= -this.wallBounce;
}
}
// ----------------------------------------------------- damage / lifecycle
/**
* Hit the player's armor (shields → hull). Kills when the hull breaks.
* `o.bearing` (rad from the ship toward the threat — the laser's
* incoming side, or the ram's heading) steers the shield read;
* omitted (a bare call) defaults to straight up.
*/
damagePlayer(n, o = {}) {
if (this.playerDead) return;
this.lastHitAt = this.time.now;
const armor = { hull: this.hull, shields: this.shields };
const preShields = this.shields;
const { dead, shieldHit, hullHit } = applyDamage(armor, n);
this.hull = armor.hull;
this.shields = armor.shields;
const broke = preShields > 0 && this.shields <= 0;
const kind = shieldHit > 0 ? 'shield' : 'hull';
const bearing = Number.isFinite(o.bearing) ? o.bearing : -Math.PI / 2;
if (shieldHit > 0) {
// THE SHIELD READ — the sphere struck from the bearing, the hit
// region highlighted (js/combat/Fx.js → shieldImpact).
this.fx.shieldImpact(this.ship, bearing, shieldHit);
}
if (broke) this.fx.shieldBreak(this.ship); // the collapse cue
if (hullHit > 0) {
// The armor taking it: a hot bloom + the craft's white flash.
this.fx.flash(this.ship.x, this.ship.y, FX_COLORS.hull, 60, 140);
this.fx.craftFlash(this.ship);
}
if (shieldHit > 0 || hullHit > 0) {
this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
}
// The player-hit voices (one place: lances AND rams funnel here).
combatSfx(this, 'impact');
if (shieldHit > 0) combatSfx(this, 'shield');
if (hullHit > 0) combatSfx(this, 'hull');
// The world kick (the UI pass stays still — js/combat/CamShake.js).
combatShake(this, broke ? 'break' : 'hit');
// The player-hit read on the gauges (the layer(s) that took it).
if (this.hud) {
if (shieldHit > 0) this.hud.flash('shield');
if (hullHit > 0) this.hud.flash('hull');
}
if (dead) this.killPlayer();
}
killPlayer() {
this.playerDead = true;
this.hull = 0;
this.fx.releaseCraft(this.ship); // out in normal colors, never mid-flash
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
combatSfx(this, 'down');
this.ship.stop();
this.ship.setVisible(false);
if (config.get('combat.sandbox.respawn', true)) {
this.respawnAt = this.time.now + (Number(config.get('combat.sandbox.respawnDelay', 1.6)) || 1.6) * 1000;
}
}
maybeRespawn(time) {
if (!config.get('combat.sandbox.respawn', true) || time < this.respawnAt) return;
this.hull = this.maxHull;
this.shields = this.maxShields;
this.energy = this.maxEnergy;
this.ship.x = this.w * 0.24;
this.ship.y = this.h / 2;
this.ship.rotation = 0;
this.ship.stop();
settleCraftTint(this.ship); // never reappear holding a white hit-flash
this.ship.setVisible(true);
this.playerDead = false;
this.clearBullets();
this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
combatSfx(this, 'respawn');
}
clearBullets() {
for (const b of this.bullets) b.consume();
this.bullets.length = 0;
}
// ------------------------------------------------------------------ hud
drawWalls() {
const w = this.w;
const h = this.h;
const inset = this.inset;
const faint = toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x1b2c4a);
const glow = toColor(config.get('combat.arena.wallGlow', '#00e5ff'), 0x00e5ff);
const g = this.add.graphics().setDepth(1);
g.lineStyle(3, glow, 0.14);
g.strokeRect(inset, inset, w - 2 * inset, h - 2 * inset);
g.lineStyle(1, faint, 0.95);
g.strokeRect(inset, inset, w - 2 * inset, h - 2 * inset);
// Corner accents (the MenuScene chrome idiom: bright L-brackets).
const L = 26;
const corners = [
[inset, inset, 1, 1],
[w - inset, inset, -1, 1],
[inset, h - inset, 1, -1],
[w - inset, h - inset, -1, -1],
];
g.lineStyle(2, glow, 0.55);
for (const [cx, cy, sx, sy] of corners) {
g.lineBetween(cx + sx * L, cy, cx, cy);
g.lineBetween(cx, cy, cx, cy + sy * L);
}
}
createHud() {
const w = this.w;
const h = this.h;
const hud = config.section('combat.hud', {});
const headerFont = fontStack('header', FONT_FALLBACK);
const bodyFont = fontStack('body', FONT_FALLBACK);
const dim = themeColor('dim', 0x8aa0c0);
const faint = themeColor('faint', 0x55688a);
const neon = themeColor('neon', 0x00e5ff);
const amber = themeColor('amber', 0xffc94d);
const D = 100; // above everything in the arena
this.add
.text(w - 28, 16, hud.title ?? 'COMBAT // SANDBOX', {
fontFamily: headerFont, fontSize: '13px', color: toCss(dim, '#8aa0c0'),
letterSpacing: 4,
})
.setOrigin(1, 0).setScrollFactor(0).setDepth(D);
this.killsText = this.add
.text(w - 28, 40, 'KILLS 0', {
fontFamily: bodyFont, fontSize: '20px', color: toCss(amber, '#ffc94d'),
letterSpacing: 2,
})
.setOrigin(1, 0).setScrollFactor(0).setDepth(D);
// The combat bar (bottom-center orb strip — js/ui/CombatBar.js, the
// SAME component the main game swaps in for the action deck): the
// hull + shield orbs, the eight skill seats (keys 1-8, the skills
// land with the real combat port — for now empty seats), and the
// energy orb. Its orbs are the readability layer's flash targets
// (the same shared flash the main game's bar gets).
this.combatBar = new CombatBar(this, {
onCast: () => {}, // no skills on the range yet — the seats are visual
});
// The control line (top-left — the bottom-center is the bar's now).
this.add
.text(28, 22, hud.hints ?? '', {
fontFamily: bodyFont, fontSize: '12px', color: toCss(faint, '#55688a'), letterSpacing: 1,
})
.setOrigin(0, 0.5).setScrollFactor(0).setDepth(D);
// Center message (SIGNAL LOST / PAUSED).
this.msgText = this.add
.text(w / 2, h / 2 - 12, '', {
fontFamily: headerFont, fontSize: '44px', color: toCss(neon, '#00e5ff'), letterSpacing: 6,
})
.setOrigin(0.5).setScrollFactor(0).setAlpha(0).setDepth(D);
this.msgSub = this.add
.text(w / 2, h / 2 + 30, '', {
fontFamily: bodyFont, fontSize: '14px', color: toCss(dim, '#8aa0c0'), letterSpacing: 2,
})
.setOrigin(0.5).setScrollFactor(0).setAlpha(0).setDepth(D);
}
updateHud(time) {
// The combat bar's orbs (the liquid drains as the values drop):
// hull + shield left, energy right.
this.combatBar?.setStats({
hull: this.hull,
maxHull: this.maxHull,
shields: this.shields,
maxShields: this.maxShields,
energy: this.energy,
maxEnergy: this.maxEnergy,
});
const killsShown = `KILLS ${this.kills}`;
if (this.lastKillsShown !== killsShown) {
this.lastKillsShown = killsShown;
this.killsText.setText(killsShown);
}
// The center message.
let msg = '';
let sub = '';
if (this.paused) {
msg = 'PAUSED';
sub = 'P — resume · R — reset range';
} else if (this.playerDead) {
msg = 'SIGNAL LOST';
sub = config.get('combat.sandbox.respawn', true)
? `rebuilding in ${Math.max(0, (this.respawnAt - time) / 1000).toFixed(1)}s · R — reset range`
: 'R — reset range';
}
if (this.msgText) {
if (msg) {
this.msgText.setText(msg);
this.msgSub.setText(sub);
if (!this.msgText.visible) {
this.msgText.setVisible(true);
this.msgSub.setVisible(true);
}
} else if (this.msgText.visible) {
this.msgText.setVisible(false);
this.msgSub.setVisible(false);
}
}
}
// ----------------------------------------------------------------- misc
togglePause() {
this.paused = !this.paused;
// The engine steps the arcade world regardless of the scene (this
// build — verified in a headless probe), so a true freeze must pause
// the world itself; resume restores it.
if (this.physics && this.physics.world) {
this.physics.world.isPaused = this.paused;
}
}
/** Rebuild the whole range (fresh scene — the sandbox reset). */
reset() {
this.scene.restart();
}
}