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 { 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 = []; 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-` 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); 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, 'fire'); } 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); } } 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; const mx = e.x + Math.cos(toPlayer) * nose; const my = e.y + Math.sin(toPlayer) * nose; this.spawnBullet(mx, my, angle, wpn, 'enemy'); this.fx.muzzle(mx, my, angle, wpn.color ?? 0xff2d6f, Number(wpn.muzzle) || 50); combatSfx(this, 'fire', { vol: 0.45 }); // the same blaster voice, quieter } 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, 'impact'); 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); } } // 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(); } }