882 lines
34 KiB
JavaScript
882 lines
34 KiB
JavaScript
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();
|
||
}
|
||
}
|