orbit/js/combat/CombatScene.js

797 lines
28 KiB
JavaScript
Raw 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 { Bullet } from './Bullet.js';
import { Fx } from './Fx.js';
import { StarDrift } from './StarDrift.js';
import { applyDamage, handleFeel } from './Combat.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 sheet (data/combat.json → enemy.texture).
const enemyTex = cfg.enemy?.texture ?? '';
if (enemyTex && !this.textures.exists(EnemyShip.TEXTURE_KEY)) {
this.load.spritesheet(EnemyShip.TEXTURE_KEY, enemyTex, {
frameWidth: cfg.enemy?.frameWidth ?? 128,
frameHeight: cfg.enemy?.frameHeight ?? 128,
});
}
}
// ---------------------------------------------------------------- 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;
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 = [];
this.fx = new Fx(this);
// The range console (HUD).
this.createHud();
// 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 }');
}
// ---------------------------------------------------------------- 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);
this.collide(delta);
this.fx.update(delta);
this.starfield.update(time, delta);
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);
}
}
}
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.flash(x, y, wpn.color ?? 0x00e5ff, 30, 110);
}
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);
}
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 (enemy.respawn).
if (config.get('combat.enemy.respawn', true) && 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);
}
}
fireEnemy(e) {
const wpn = e.wpn ?? {};
const toPlayer = Math.atan2(this.ship.y - e.y, this.ship.x - e.x);
const angle = toPlayer + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
const nose = e.hitRadius + 10;
this.spawnBullet(
e.x + Math.cos(toPlayer) * nose,
e.y + Math.sin(toPlayer) * nose,
angle,
wpn,
'enemy',
);
this.fx.flash(e.x + Math.cos(toPlayer) * nose, e.y + Math.sin(toPlayer) * nose, wpn.color ?? 0xff2d6f, 28, 100);
}
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
this.kills += 1;
this.fx.explode(e.x, e.y, 0xff5d6f);
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
e.respawnAt = this.time.now + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000;
}
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,
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);
}
}
}
// ------------------------------------------------------------- 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 killed = e.damage(b.damage);
this.fx.flash(b.x, b.y, 0xff9db4, 34, 130);
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)) {
this.damagePlayer(b.damage);
this.fx.flash(b.x, b.y, 0x9db4ff, 30, 130);
if (b.consume()) this.bullets.splice(i, 1);
}
}
// 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 + (Number(config.get('combat.enemy.ramGrace', 0.8)) || 0.8) * 1000;
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
this.fx.flash(midX, midY, 0xffffff, 46, 160);
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0);
const killed = b.damage(Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0);
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
damagePlayer(n) {
if (this.playerDead) return;
this.lastHitAt = this.time.now;
const armor = { hull: this.hull, shields: this.shields };
const { dead } = applyDamage(armor, n);
this.hull = armor.hull;
this.shields = armor.shields;
this.fx.flash(this.ship.x, this.ship.y, 0xffffff, 60, 140);
if (dead) this.killPlayer();
}
killPlayer() {
this.playerDead = true;
this.hull = 0;
this.fx.explode(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff));
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.ship.x = this.w * 0.24;
this.ship.y = this.h / 2;
this.ship.rotation = 0;
this.ship.stop();
this.ship.setVisible(true);
this.playerDead = false;
this.clearBullets();
this.fx.flash(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff), 70, 260);
}
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 ink = themeColor('ink', 0xeaf6ff);
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).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).setDepth(D);
// The armor panel (top-left): HULL bar + SHD bar + live numbers.
const pw = 330;
const ph = 96;
const px = 28;
const py = 28;
this.add
.graphics()
.setDepth(D)
.fillStyle(0x0a1120, 0.72)
.fillRoundedRect(px, py, pw, ph, 8)
.lineStyle(1, toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x22405f), 0.9)
.strokeRoundedRect(px, py, pw, ph, 8);
const lx = px + 16; // label x
const bx = px + 16; // bar x (left edge)
const bw = 210; // bar width
const numX = bx + bw + 14;
this.add.text(lx, py + 22, 'HULL', {
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D);
this.hullBarBg = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, 0x101a2c).setDepth(D);
this.hullBar = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, amber).setDepth(D);
this.hullText = this.add.text(numX, py + 40, '100', {
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
}).setOrigin(0, 0.5).setDepth(D);
this.add.text(lx, py + 62, 'SHD', {
fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D);
this.shdBarBg = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, 0x101a2c).setDepth(D);
this.shdBar = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, neon).setDepth(D);
this.shdText = this.add.text(numX, py + 80, '40', {
fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
}).setOrigin(0, 0.5).setDepth(D);
// The control line (bottom-center).
this.add
.text(w / 2, h - 12, hud.hints ?? '', {
fontFamily: bodyFont, fontSize: '12px', color: toCss(faint, '#55688a'), letterSpacing: 1,
})
.setOrigin(0.5, 1).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).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).setAlpha(0).setDepth(D);
}
setBar(bar, frac) {
const w = Math.max(0, Math.min(1, frac)) * 210;
bar.width = w;
bar.x = 28 + 16 + w / 2; // left-anchored (rectangles are center-origin)
}
updateHud(time) {
const hullShown = Math.ceil(this.hull);
const shdShown = Math.ceil(this.shields);
if (this.lastHullShown !== hullShown) {
this.lastHullShown = hullShown;
this.hullText.setText(String(hullShown));
}
if (this.lastShdShown !== shdShown) {
this.lastShdShown = shdShown;
this.shdText.setText(String(shdShown));
}
this.setBar(this.hullBar, this.hull / this.maxHull);
this.setBar(this.shdBar, this.maxShields > 0 ? this.shields / this.maxShields : 0);
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();
}
}