Integrate combat into the main game with auto-fire and a combat bar

- Add CombatSystem: owns the hostile pack, tracers, player armor/energy,
  auto-fire (nearest-in-range), swept-hit collisions, rams, respawn — all
  driven by data/combat.json `game.*` / `energy` sections
- Add CombatBar (Diablo 4 style): vertical shields + hull gauges on the
  left, energy gauge on the right, eight skill seats (keys 1–8) in the
  middle; shown in place of the action deck while combat is live
- `?combat` now boots the normal game with the pack spawning when the
  player starts (mouse steers, cannon auto-fires); the standalone WASD
  range remains at `/combat` / `combat.html`
- GameScene wires the system into its update loop, solid constraints, and
  click-through gate; exposes `window.__ORBIT_COMBAT__` for DevTools
- Extend combat tests to cover CombatSystem lifecycle (spawn, auto-fire,
  hits, rams, armor, respawn, cast stub, destroy)
This commit is contained in:
Brian Fertig 2026-09-15 22:03:07 -06:00
parent 15fe08a926
commit 1d0622a3d5
7 changed files with 985 additions and 36 deletions

View File

@ -28,15 +28,29 @@ node dev/server.mjs 8080
> Must be served over **http(s)** — opening `index.html` via `file://` won't
> work, because the game uses ES modules and `fetch`es its JSON config.
**Combat sandbox** (the training range — where combat feel is tuned before
it ships in the real game): the dev server also maps **`/combat`** →
`combat.html`, and any server can boot it with **`index.html?combat`**.
One fixed arena: you (WASD/arrows + SPACE) against a pack of the enemy
craft (`assets/images/ships-enemy.png`). Every combat number — thrust,
speeds, hulls, shields, the lance, rams, respawns — lives in
**`data/combat.json`**; `R` resets the range, `P` freezes it (positions
hold while you poke `window.__COMBAT__ = { reset, spawnEnemy, player,
kills }` in DevTools).
**Combat** (tuned before it ships in the real game) has two fronts, and
every combat number — thrust, speeds, hulls, shields, the lance, rams,
respawns, the bar — lives in **`data/combat.json`**:
- **The training range** (a standalone arena — where the feel is tuned):
the dev server maps **`/combat`** → `combat.html` (any server can too).
One fixed arena: you (WASD/arrows + SPACE) against a pack of the enemy
craft (`assets/images/ships-enemy.png`). `R` resets the range, `P`
freezes it (positions hold while you poke
`window.__COMBAT__ = { reset, spawnEnemy, player, kills }` in DevTools).
- **In the main game** (the integration stand-in — what players will
eventually feel): **`index.html?combat`** boots the *normal* game, and
when the player starts, a pack of hostiles spawns around the ship.
The mouse steers; while a hostile is in range the ship **auto-fires**
at the nearest one (the cannon tracks — `js/combat/CombatSystem.js`).
While combat is live the action deck **swaps for the combat bar**
(`js/ui/CombatBar.js`) — Diablo 4 style: vertical shields + hull on
the left, the ENERGY gauge on the right, eight skill seats wearing
keys **1–8** in the middle (the skills land later — for now they're
empty seats; a press lands on the system's `cast()` stub). Wipe the
pack and the deck comes back. Poke the live fight in DevTools via
`window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn,
damagePlayer, cast }`.
## Current state — v0.2: a seedable galaxy
@ -197,7 +211,7 @@ orbit/
│ ├── menu.json # menu text, colors, button layout, seed panel
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── combat.json # the COMBAT SANDBOX (combat.html / /combat / ?combat): player feel, enemy craft + AI + lance, rams, respawns, arena
│ ├── combat.json # COMBAT: the range (player feel, enemy craft + AI + lance, rams, respawns, arena) + the main-game demo (game.*, energy, the bar)
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
@ -217,16 +231,16 @@ orbit/
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
│ ├── main.js # entry point: load config → boot Phaser (or route ?combat / /combat to the sandbox)
│ ├── main.js # entry point: load config → boot Phaser (?combat boots the normal game with the combat demo live)
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── combat/ # the COMBAT SANDBOX: CombatScene (js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
│ ├── combat/ # COMBAT: CombatSystem (the main-game pack — auto-fire, tracers, rams, armor, energy), the range (CombatScene, js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow, BuildWindow
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, CombatBar (the combat-mode deck), DiscoveryCompass (reusable), ResearchWindow, BuildWindow
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser
@ -322,7 +336,7 @@ node dev/perimeter-shimmer.test.mjs # the READY row's dual-shimmer rim: perimete
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
node dev/music.test.mjs # the shared music voice: guards + music.json contract
node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life (real data/combat.json)
node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life, CombatSystem (pack, auto-fire, hits, rams, armor, respawn) — real data/combat.json
```
`dev/test-game.html` boots straight into the GameScene (no menu click),

View File

@ -1,7 +1,23 @@
{
"_comment": "COMBAT SANDBOX — the standalone combat range (combat.html, the dev server's /combat, or index.html?combat). A full-screen arena where the player ship (ART + flight stats from data/ship.json, COMBAT feel from player.*) fights a pack of enemy ships (enemy.* — assets/images/ships-enemy.png). Every number here is SANDBOX tuning: iterate in the range first, then port the survivors into the real game (the main game already loads enemy.texture — GameScene.preload — so the texture is ready when combat ships in). The range is js/combat/CombatScene.js; the pure rules (armor damage, orbit steering) are js/combat/Combat.js (tested: dev/combat.test.mjs).",
"_comment": "COMBAT — all combat tuning, one file. Two contexts share the numbers: the standalone range (combat.html / the dev server's /combat — the WASD arena, js/combat/CombatScene.js) and the MAIN GAME (index.html?combat — js/combat/CombatSystem.js drops the pack into the normal game: click-to-fly, the deck swaps for the combat bar, the ship auto-fires at the nearest hostile). Pure rules (armor damage, orbit steering) are js/combat/Combat.js (tested: dev/combat.test.mjs).",
"enabled": true,
"game": {
"_comment": "COMBAT IN THE MAIN GAME (the ?combat boot). spawnCount = hostiles that appear around the ship when the player starts (spawnRadius px out — the real spawn SCHEDULES, places + waves, land with the port); autoFire = the ship's guns — while a hostile is within range the ship fires at the NEAREST one (the mouse steers, the cannon tracks; the cannon's own numbers are player.weapon below).",
"spawnCount": 3,
"spawnRadius": 340,
"autoFire": {
"enabled": true,
"range": 800
}
},
"energy": {
"_comment": "The player's ENERGY — the skill resource (the combat bar's right gauge). The eight skills (keys 1-8, the bar's middle seats) will spend it; for now it just sits full. regen = energy/s recovered (0 = static).",
"max": 100,
"regen": 0
},
"player": {
"_comment": "The combat feel of YOUR ship in the range (the art, hull stats, and ship name still come from data/ship.json). WASD/arrows fly, SPACE/J fires (hold = auto-fire). thrust px/s^2, maxSpeed px/s, drag 1/s (exponential coast decay), rotSpeed rad/s (keyboard turn rate), brake = the S/Down reverse-throttle as a fraction of thrust. weapon.* = the player's cannon: cooldown s between shots, bulletSpeed px/s, damage per hit, spread rad of aim jitter, bulletLife s before a shot fizzles, color/size the tracer's glow.",
"thrust": 1500,
@ -55,11 +71,13 @@
},
"playerShip": {
"_comment": "Your armor in the range (the real game's ship reads data/ship.json → stats). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max.",
"_comment": "Your armor (both contexts — the real ship's stats.hullIntegrity/shields replace this when combat ships for real). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max. respawn = when your hull breaks you come back (respawnDelay s) instead of the run ending — a stand-in until the real death flow lands.",
"hull": 100,
"shields": 40,
"shieldRegenDelay": 4,
"shieldRegenRate": 14
"shieldRegenRate": 14,
"respawn": true,
"respawnDelay": 2.0
},
"arena": {
@ -82,5 +100,24 @@
"_comment": "The range console chrome (colors fall back to data/theme.json). title = the corner label; hints = the control line at the bottom.",
"title": "COMBAT // SANDBOX",
"hints": "WASD / arrows — fly · space — fire · R — reset range · P — pause"
},
"bar": {
"_comment": "The COMBAT BAR (js/ui/CombatBar.js) — shown in place of the action deck while combat is live (the deck hides, the bar takes its place; when the pack is wiped it's the other way around). geometry = the bar's screen rect (margin/height mirror actionbar's, so it sits in the deck's place). Left = the vertical SHIELDS + HULL gauges; right = the vertical ENERGY gauge; middle = slot.count skill seats wearing keys 1-8 (the skills land later — for now they are empty seats). Colors fall back to data/theme.json where sensible.",
"height": 92,
"margin": { "left": 20, "right": 20, "bottom": 12 },
"colors": {
"bg": "#070d18",
"bgAlpha": 0.92,
"edge": "#22405f",
"shield": "#00e5ff",
"hull": "#7dff8a",
"energy": "#ffd84d",
"slotBg": "#0b1322",
"slotBorder": "#22405f",
"key": "#5c7196"
},
"gauge": { "width": 14, "gap": 8, "pad": 12, "labelSize": 11 },
"slot": { "count": 8, "size": 64, "gap": 10, "keySize": 12 }
}
}

View File

@ -7,10 +7,12 @@
* from js/combat/ — the pure rules (Combat.js: armor damage, render
* heading, orbit-ring step), the hostile craft (EnemyShip.js: config
* scaling + north-facing art offset, shields→hull damage, the
* standoff-ring AI, the lance decision), and the tracer (Bullet.js:
* standoff-ring AI, the lance decision), the tracer (Bullet.js:
* velocity from angle/speed, world radius, one-shot consume, life
* expiry) — then asserts the behavior the sandbox (js/combat/CombatScene.js)
* is built on.
* expiry), and the system (CombatSystem.js: the pack's lifecycle,
* the auto-fire, swept hits, rams, the player's armor + respawn,
* the energy) — then asserts the behavior the range
* (js/combat/CombatScene.js) and the main game (GameScene) are built on.
*
* All numbers come from the real data/*.json (data/combat.json included),
* so the assertions track the shipped config, not a private copy.
@ -77,11 +79,29 @@ const PhaserStub = {
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
function makeImg(x = 0, y = 0, key = '__dot') {
const o = {
x, y, key, scale: 1, alpha: 1, visible: true, active: true,
setScale(s) { this.scale = s; return this; },
setAlpha(a) { this.alpha = a; return this; },
setTint() { return this; },
setBlendMode() { return this; },
setDepth() { return this; },
setAngle() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.active = false; this.destroyed = true; },
};
return o;
}
function makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: { existing: (o) => o },
add: {
existing: (o) => o,
image: (x, y, key) => makeImg(x, y, key),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
@ -105,6 +125,7 @@ const { applyDamage, headingOf, orbitStep } = await import(
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
let failures = 0;
const check = (label, cond) => {
@ -226,6 +247,97 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('bullet: life expiry consumes', b2.dead === true);
}
// ------------------------------------------------------------- combat system
{
const scene = makeScene({ withSheet: true });
// A stand-in player ship: position + radius + body (the system reads
// exactly that — Ship's full API isn't needed for the rules).
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {}; // Ship API the system leans on (brake + hide on death)
const events = { down: 0, up: 0, kills: 0, active: [] };
const combat = new CombatSystem(scene, {
ship,
onPlayerDown: () => events.down++,
onPlayerUp: () => events.up++,
onKill: () => events.kills++,
onActiveChange: (a) => events.active.push(a),
});
check('system: starts with full armor + full energy',
combat.hull === 100 && combat.shields === 40 && combat.energy === 100 && combat.maxEnergy === 100);
// The pack: game.spawnCount hostiles around the ship, combat live.
combat.spawnPack(0, 0);
check('system: pack spawns (game.spawnCount = 3)', combat.enemies.length === 3);
check('system: combat is active with the pack up', combat.active === true);
check('system: onActiveChange fired true', events.active[0] === true);
// AUTO-FIRE: the nearest hostile is within game.autoFire.range → a
// player tracer is born (the scene's world steps it in the real game).
let t = 1000;
combat.update(t, 16);
const playerShots = combat.bullets.filter((b) => b.owner === 'player');
check('system: auto-fires at a hostile in range', playerShots.length >= 1);
t += 16;
const before = playerShots.length;
combat.update(t, 16);
check('system: auto-fire honors the weapon cooldown',
combat.bullets.filter((b) => b.owner === 'player').length === before);
// Out of range → no auto-fire (the cannon waits for contact).
for (const e of combat.enemies) { e.x = 5000; e.y = 5000; }
combat.nextFireAt = 0;
combat.update(t + 16, 16);
check('system: no auto-fire out of range',
combat.bullets.filter((b) => b.owner === 'player').length === before);
for (const e of combat.enemies) { e.x = 100; e.y = 0; }
// Swept hit: a player tracer on the enemy's segment kills it (damage
// over the enemy's hull) → the kill bookkeeping + kill event fire.
const target = combat.nearestEnemy(ship.x, ship.y);
combat.spawnBullet(target.x - 18, 0, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
combat.update(t + 32, 16);
check('system: player tracer kills (swept hit)', target.dead === true && combat.kills === 1 && events.kills === 1);
// The enemy's respawn (enemy.respawn = true): down, then back.
check('system: killed enemy is on its respawn clock', target._respawnPending === true && target.respawnAt > 0);
combat.update(target.respawnAt + 10, 16);
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
// RAM: the ship inside the enemy's combined radius → both take damage.
const ram = combat.enemies[0];
ship.x = 0; ship.y = 0;
ram.x = 20; ram.y = 0;
const armorBefore = combat.hull + combat.shields;
combat.update(t + 64, 16);
check('system: ram damages the player (shields first)',
combat.hull + combat.shields < armorBefore);
// PLAYER DEATH + RESPAWN (playerShip.respawn = true, 2 s).
combat.damagePlayer(9999);
check('system: hull broke → player down', combat.playerDown === true && events.down === 1);
const respawnAt = combat.respawnAt;
combat.update(respawnAt + 10, 16);
check('system: player reformed (full armor)', !combat.playerDown && combat.hull === 100 && combat.shields === 40 && events.up === 1);
// cast(): the skill stub — no crash, no energy change (skills land later).
const energyBefore = combat.energy;
combat.cast(3);
check('system: cast is a safe no-op for now', combat.energy === energyBefore);
// Active flips off when the pack is gone (respawn clocks cleared).
for (const e of combat.enemies) { e.dead = true; e._respawnPending = false; e.respawnAt = 0; }
combat._setActive();
check('system: combat over → active off + event', combat.active === false && events.active[events.active.length - 1] === false);
// destroy(): the pack + tracers are gone.
combat.destroy();
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

517
js/combat/CombatSystem.js Normal file
View File

@ -0,0 +1,517 @@
import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { Bullet } from './Bullet.js';
import { Fx } from './Fx.js';
import { applyDamage } from './Combat.js';
const TAU = Math.PI * 2;
/**
* COMBAT AS A SYSTEM — the portable half of the combat sandbox, built to
* live INSIDE an existing scene (the main game: js/scenes/GameScene.js)
* instead of owning one (the range: js/combat/CombatScene.js, which
* inlines the same rules for its fixed arena).
*
* It owns, per scene:
* - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*)
* - every tracer (Bullet — one-shot, life-limited)
* - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn)
* - the player's ENERGY (→ energy: the skills' resource — they spend it later)
* - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the
* ship fires at the NEAREST one — the mouse steers,
* the cannon tracks; the cannon's numbers are → player.weapon)
* - the collision pass (swept bullet hits + rams — the main game's
* hand-rolled circle-check style, no arcade colliders)
*
* The scene supplies the player ship (a Ship, or anything with
* .x/.y/.radius/.body/.stop()/.setVisible()) and calls
* update(time, delta) from its own update loop — the same "actors in
* update" contract GameScene already runs on (the engine steps the
* arcade world itself).
*
* Lifecycle: spawnPack(cx, cy) starts the fight (the ?combat boot does
* this around the ship when the player starts); active flips on the
* first hostile / back off when the pack is wiped and not reforming —
* the scene uses onActiveChange to swap the action deck for the
* combat bar.
*/
export class CombatSystem {
/**
* @param {Phaser.Scene} scene a scene with arcade physics + add/textures
* @param {object} [o]
* ship the player ship (null = no player to hit/fire for)
* onActiveChange (active) => {} — combat live / over (the bar swap)
* onPlayerDamage (amount, armor) => {} — each hit on the player
* onPlayerDown () => {} — the player's hull broke
* onPlayerUp () => {} — the player reformed
* onKill (enemy) => {} — a hostile went down
*/
constructor(scene, o = {}) {
this.scene = scene;
this.ship = o.ship ?? null;
this.onActiveChange = typeof o.onActiveChange === 'function' ? o.onActiveChange : null;
this.onPlayerDamage = typeof o.onPlayerDamage === 'function' ? o.onPlayerDamage : null;
this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null;
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
this.enemies = [];
this.bullets = [];
this.kills = 0;
// The player's life (playerShip.* — both contexts share it).
const p = config.section('combat.playerShip', {});
this.maxHull = Math.max(1, Number(p.hull) || 100);
this.maxShields = Math.max(0, Number(p.shields) || 0);
this.hull = this.maxHull;
this.shields = this.maxShields;
this.playerRespawn = p.respawn !== false;
this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000;
this.playerDown = false;
this.respawnAt = 0;
this.lastHitAt = -1e9;
// ENERGY — the skill resource (→ energy). Skills (keys 1-8, the bar's
// middle seats) spend it; for now it just sits full, and the bar
// shows it.
const en = config.section('combat.energy', {});
this.maxEnergy = Math.max(0, Number(en.max) || 100);
this.energy = this.maxEnergy;
this.energyRegen = Math.max(0, Number(en.regen) || 0);
// Main-game combat (→ game).
const g = config.section('combat.game', {});
this.spawnCount = Math.max(0, Number(g.spawnCount) || 3);
this.spawnRadius = Math.max(60, Number(g.spawnRadius) || 340);
this.autoFire = {
enabled: g.autoFire?.enabled !== false,
range: Math.max(0, Number(g.autoFire?.range) || 800),
};
// The rams' restitution (the range's arena bounce reads as a general
// "what a collision keeps of the speed" fraction — combat.arena.bounce).
this.restitution = Math.min(1, Math.max(0, Number(config.get('combat.arena.bounce', 0.9)) || 0.9));
this.ramUntil = 0;
this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp
this.fx = new Fx(scene);
this._active = false;
}
// ------------------------------------------------------------- the pack
/** Combat is live when a hostile is up, or one is on its reform clock. */
get active() {
return this.enemies.some(
(e) => !e.dead || (e._respawnPending && e.respawnAt > 0),
);
}
/** Fire the state change at the scene (bar swap) when it flips. */
_setActive() {
const a = this.active;
if (a === this._active) return;
this._active = a;
if (this.onActiveChange) this.onActiveChange(a);
}
/**
* Spawn the starting pack in a ring around (cx, cy) — the ?combat boot
* calls this with the ship's position "when the player starts".
* @returns {EnemyShip[]} the fresh hostiles
*/
spawnPack(cx, cy) {
const out = [];
for (let i = 0; i < this.spawnCount; i++) {
const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6;
const r = this.spawnRadius * (0.9 + Math.random() * 0.2);
out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i));
}
return out;
}
/** One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its bearing. */
spawnEnemy(x, y, index) {
const e = new EnemyShip(this.scene, x, y, index);
e.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
e.orbitAngle = Math.atan2(y - py, x - px);
e.nextFireAt = 1000; // a brief non-shooting grace (ms clock)
this.enemies.push(e);
this._setActive();
return e;
}
// ------------------------------------------------------------ the loop
/**
* Step the combat (call from the scene's update, after the ship).
* @param {number} time game clock (ms — the scene's stepped clock)
* @param {number} delta frame delta (ms)
*/
update(time, delta) {
const dt = Math.min(delta, 64) / 1000;
const player = this.playerDown ? null : this.ship;
// 1) The pack: orbit AI + lances (a dead one is its reform clock).
for (const e of this.enemies) {
if (e.dead) {
if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time);
continue;
}
const wantsFire = e.update(time, delta, player);
if (wantsFire && player) this.fireEnemy(e, player, time);
}
// 2) AUTO-FIRE: the nearest live hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own.
if (
player &&
this.autoFire.enabled &&
this.autoFire.range > 0 &&
time >= this.nextFireAt
) {
const target = this.nearestEnemy(player.x, player.y);
if (target && Math.hypot(target.x - player.x, target.y - player.y) <= this.autoFire.range) {
this.firePlayerAt(player, target, time);
}
}
// 3) Tracers: age + cull (no walls here — the arena is the tether range).
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);
}
// 4) Collisions: your tracers × the pack, their lances × you, rams.
this.collide(delta, player);
// 5) The player's armor bookkeeping: shield + energy regen, respawn.
if (!this.playerDown) {
const p = config.section('combat.playerShip', {});
if (this.shields < this.maxShields && this.maxShields > 0) {
const grace = (Number(p.shieldRegenDelay) || 4) * 1000;
const rate = Math.max(0, Number(p.shieldRegenRate) || 14);
if (time - this.lastHitAt >= grace) {
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
}
}
if (this.energy < this.maxEnergy) {
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
}
} else if (this.playerRespawn && this.respawnAt > 0 && time >= this.respawnAt) {
this.revivePlayer(time);
}
this.fx.update(delta);
}
/** The nearest live hostile to (x, y), or null. */
nearestEnemy(x, y) {
let best = null;
let bestD = Infinity;
for (const e of this.enemies) {
if (e.dead) continue;
const d = Math.hypot(e.x - x, e.y - y);
if (d < bestD) {
bestD = d;
best = e;
}
}
return best;
}
// --------------------------------------------------------------- firing
/**
* The ship's auto-fire at a hostile (the sandbox's manual cannon, minus
* the key): one tracer from the nose, toward the target, spread-jittered.
* The cooldown lives here (nextFireAt) — the weapon's numbers are
* combat.player.weapon (shared with the range).
*/
firePlayerAt(player, target, time) {
const wpn = config.section('combat.player.weapon', {});
this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
const a =
Math.atan2(target.y - player.y, target.x - player.x) +
(Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
const nose = (Number(player.radius) || 12) + 8;
const x = player.x + Math.cos(a) * nose;
const y = player.y + Math.sin(a) * nose;
this.spawnBullet(x, y, a, wpn, 'player');
this.fx.flash(x, y, wpn.color ?? '#00e5ff', 26, 110);
}
/** A hostile's lance at the player (aimed at the ship, spread-jittered). */
fireEnemy(e, player, time) {
const wpn = e.wpn ?? {};
const toPlayer = Math.atan2(player.y - e.y, player.x - e.x);
const a = toPlayer + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
const nose = e.hitRadius + 10;
this.spawnBullet(
e.x + Math.cos(a) * nose,
e.y + Math.sin(a) * nose,
a,
wpn,
'enemy',
);
this.fx.flash(e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, wpn.color ?? '#ff2d6f', 28, 100);
}
spawnBullet(x, y, angle, wpn, owner) {
const b = new Bullet(this.scene, {
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;
}
// ------------------------------------------------------------- collisions
/**
* Swept hit check (the sandbox's anti-tunneling rule): tracers run
* ~700 px/s — at 30 fps that's ~23 px/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;
}
/** The collision pass: your tracers × the pack, their lances × you, rams. */
collide(delta, player) {
// Your tracers × hostiles.
if (player) {
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, '#ff9db4', 34, 130);
if (killed) this.killEnemy(e, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
}
// Their lances × you.
if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
if (this.hitsSwept(b, player, Number(player.radius) || 12, delta)) {
this.damagePlayer(b.damage);
this.fx.flash(b.x, b.y, '#9db4ff', 30, 130);
if (b.consume()) this.bullets.splice(i, 1);
}
}
}
// Rams (you × the pack) — push apart, bounce, damage both (grace-limited).
if (player) {
for (const e of this.enemies) {
if (!e.dead) this.ramPair(player, e);
}
}
}
/** The scene's clock (ms) — used for the kill bookkeeping's timestamps. */
_now() {
return this.scene.time?.now ?? Date.now();
}
/**
* A ship-ship collision: push apart, exchange normal velocity (bounce),
* and deal ram damage to BOTH (grace-limited — enemy.ram*).
*/
ramPair(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const gap = (Number(a.radius) || 12) + 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;
if (a.body && b.body) {
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.restitution) * 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._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, '#ffffff', 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, t);
}
}
// ----------------------------------------------------- damage / lifecycle
/** Hit the player's armor (shields → hull). Kills when the hull breaks. */
damagePlayer(n) {
if (this.playerDown) return;
this.lastHitAt = this._now();
const armor = { hull: this.hull, shields: this.shields };
const { dead } = applyDamage(armor, n);
this.hull = armor.hull;
this.shields = armor.shields;
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#ffffff', 60, 140);
if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields });
if (dead) this.killPlayer();
}
/** The player's hull broke: down (and reformed on the clock, if armed). */
killPlayer() {
this.playerDown = true;
this.hull = 0;
if (this.ship) {
this.fx.explode(this.ship.x, this.ship.y, '#7ce8ff');
this.ship.stop();
this.ship.setVisible(false);
}
if (this.onPlayerDown) this.onPlayerDown();
if (this.playerRespawn) {
this.respawnAt = this._now() + this.playerRespawnDelay;
}
}
/** The clock ran out — the player is back, full armor, in the same seat. */
revivePlayer(time) {
this.playerDown = false;
this.hull = this.maxHull;
this.shields = this.maxShields;
this.lastHitAt = 0;
this.respawnAt = 0;
if (this.ship) this.ship.setVisible(true);
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, '#7ce8ff', 80, 300);
if (this.onPlayerUp) this.onPlayerUp();
}
/**
* A hostile went down: the kill FX + bookkeeping, and (when armed) the
* reform clock. Idempotent via _killFx — damage() flips e.dead first,
* so the dead flag can't be the "already counted" guard.
*/
killEnemy(e, time) {
if (e._killFx) return;
e._killFx = true;
this.kills += 1;
this.fx.explode(e.x, e.y, '#ff5d6f');
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
const respawn = config.get('combat.enemy.respawn', true) !== false;
e._respawnPending = respawn;
e.respawnAt = time + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000;
if (this.onKill) this.onKill(e);
this._setActive();
}
/** The reform clock ran out — the craft is back on a ring edge. */
reviveEnemy(e, time) {
e._killFx = false;
e._respawnPending = false;
e.respawnAt = 0;
const a = Math.random() * TAU;
const r = Math.max(120, e.orbitRadius * (0.8 + Math.random() * 0.5));
const px = this.ship ? this.ship.x : e.x;
const py = this.ship ? this.ship.y : e.y;
e.x = px + Math.cos(a) * r;
e.y = py + Math.sin(a) * r;
e.orbitAngle = a;
e.revive();
e.nextFireAt = time + 900;
e.setVisible(true);
this._setActive();
}
// ------------------------------------------------------------- the bar
/**
* A skill slot press (keys 1-8 / the bar's middle seats). The skills
* land later — for now the seats are empty: the bar calls this, and
* this is where one will spend energy and do the thing.
* @param {number} index 0-7 (slot 1-8's index)
*/
cast(index) {
// (the skill registry lands with the real combat port)
void index;
}
/** A snapshot for the combat bar (shields/hull left, energy right). */
stats() {
return {
hull: this.hull,
maxHull: this.maxHull,
shields: this.shields,
maxShields: this.maxShields,
energy: this.energy,
maxEnergy: this.maxEnergy,
playerDown: this.playerDown,
};
}
/** Tear the combat down (scene destroy). */
destroy() {
for (const e of this.enemies) e.destroy();
for (const b of this.bullets) if (!b.dead) b.destroy();
this.enemies = [];
this.bullets = [];
this.fx.clear();
this.ship = null;
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
this.onPlayerUp = this.onKill = null;
}
}

View File

@ -8,7 +8,6 @@ import { SurfaceScene } from './scenes/SurfaceScene.js';
import { installBuildDiag } from './dev/BuildDiag.js';
import { installNavDiag } from './dev/NavDiag.js';
import { awaitFonts } from './utils/Fonts.js';
import { bootCombat } from './combat/main.js';
/**
* Orbit — entry point.
@ -53,12 +52,12 @@ async function boot() {
}
// ---------------------------------------------------------------------
// Boot — the game, or the COMBAT SANDBOX (data/combat.json + js/combat/):
// a standalone training range, reachable without touching the main game:
// /combat (dev server alias → combat.html, dev/server.mjs)
// index.html?combat (this file — ?combat=1 works too)
// The sandbox boots ONLY CombatScene. Tune the feel in data/combat.json;
// port the survivors into the real game when combat ships.
// Boot. `?combat` boots this SAME normal game with the combat demo live
// (GameScene._wantsCombatDemo: the hostile pack spawns when the player
// starts, the deck swaps for the combat bar while combat is live —
// js/combat/CombatSystem.js + js/ui/CombatBar.js). The standalone range
// is its own page: combat.html (or the dev server's /combat —
// js/combat/main.js → the WASD arena).
function showBootError(err) {
console.error(err);
@ -69,10 +68,4 @@ function showBootError(err) {
document.body.appendChild(pre);
}
const loc = typeof location !== 'undefined' ? location : null;
const wantsCombat = loc && (
/[?&]combat(\b|=)/.test(loc.search) || /\/combat\/?$/.test(loc.pathname)
);
if (wantsCombat) bootCombat().catch(showBootError);
else boot().catch(showBootError);
boot().catch(showBootError);

View File

@ -33,6 +33,8 @@ import { pickFxSystem } from '../galaxy/FxSystems.js';
import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { CombatBar } from '../ui/CombatBar.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
@ -908,6 +910,55 @@ export class GameScene extends Phaser.Scene {
})
: null;
// ---- COMBAT (the ?combat boot) — the hostile pack + the combat bar --
// data/combat.json (game.*): when the player starts, spawnCount
// hostiles appear in a ring around the ship (spawnRadius px). The
// mouse steers; while a hostile is in range the ship's cannon
// AUTO-FIRES at the nearest one (game.autoFire — the cannon's numbers
// are combat.player.weapon, shared with the range). While combat is
// LIVE the deck steps aside for the combat bar (js/ui/CombatBar.js):
// shields + hull left, ENERGY right, eight skill seats (keys 1-8) in
// the middle — the skills land later. The real spawn SCHEDULES
// (places, waves) replace spawnPack when combat ships for real.
this.combat = null;
this.combatBar = null;
if (this._wantsCombatDemo()) {
this.combatBar = new CombatBar(this, {
onCast: (i) => this.combat?.cast(i),
});
this.combatBar.setVisible(false);
this.combat = new CombatSystem(this, {
ship: this.ship,
onActiveChange: (active) => this._setCombatBar(active),
});
this.combat.spawnPack(this.ship.x, this.ship.y);
if (typeof window !== 'undefined') {
// Tuning handle: poke the pack / the armor / the bar from the console.
window.__ORBIT_COMBAT__ = {
system: this.combat,
bar: this.combatBar,
player: () => (this.combat ? this.combat.stats() : null),
kills: () => this.combat?.kills ?? 0,
spawn: (n = 1) => {
if (!this.combat || !this.ship) return;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
this.combat.spawnEnemy(
this.ship.x + Math.cos(a) * 420,
this.ship.y + Math.sin(a) * 420,
100 + i,
);
}
},
damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
cast: (i) => this.combat?.cast(i),
};
}
console.info(
'[orbit] ?combat — hostile pack live (data/combat.json, js/combat/CombatSystem.js)',
);
}
// ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up --
// The sub-bar folds up out of the Menu button (Save Game / Load Game
// [grayed while no saves exist] / Return to Main Menu); both save
@ -1432,7 +1483,10 @@ export class GameScene extends Phaser.Scene {
this.menuSubBar.close();
return;
}
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
if (this.actionBar && this.actionBar.visible && this.actionBar.contains(pointer.x, pointer.y)) return;
// The combat bar (in the deck's place while combat is live) — a
// press on it is the bar's (a skill slot); no fly-here behind it.
if (this.combatBar && this.combatBar.visible && this.combatBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return;
if (this.hudTitleContains(pointer.x, pointer.y)) {
this.toggleHud(); // the system name toggles the dossier details
@ -1661,6 +1715,13 @@ export class GameScene extends Phaser.Scene {
let touched = false;
for (const s of this.solids) {
if (s.constrainShip(this.ship, this.ship.radius)) touched = true;
// The combat pack (the ?combat demo) keeps the same keep-out as the
// ship — the solids push the hostiles off their rims too.
if (this.combat) {
for (const e of this.combat.enemies) {
if (!e.dead) s.constrainShip(e, e.hitRadius);
}
}
}
const tether = this.tetherField.constrainShip(this.ship);
if (tether) {
@ -1673,6 +1734,30 @@ export class GameScene extends Phaser.Scene {
}
}
/**
* The ?combat boot (a dev URL parameter, like ?fx= / ?type=): a NORMAL
* game — but with the combat demo live from the start (the pack spawns
* when the player starts, see create()). The standalone range is
* combat.html / the dev server's /combat.
* @returns {boolean}
*/
_wantsCombatDemo() {
if (config.get('combat.enabled', true) === false) return false;
if (typeof location === 'undefined') return false;
return new URLSearchParams(location.search).has('combat');
}
/**
* The action-deck swap (combat mode, Diablo 4 style — one bar in the
* same place, depending on the mode): combat LIVE → the combat bar up
* and the deck down; combat over → back to the deck.
* @param {boolean} active
*/
_setCombatBar(active) {
if (this.actionBar) this.actionBar.setVisible(!active);
if (this.combatBar) this.combatBar.setVisible(active);
}
/**
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
@ -2096,6 +2181,14 @@ export class GameScene extends Phaser.Scene {
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
this.questTracker?.update(_time); // the tracker: live checklist + priority
if (!_commsPaused) this.ship.update(_time, delta);
// COMBAT (the ?combat demo): the pack's AI + lances, the ship's
// auto-fire, the tracers, the rams, the player's armor — frozen by an
// ACK comm's pause, like the ship.
if (this.combat && !_commsPaused) {
this.combat.update(_time, delta);
this.combatBar?.setStats(this.combat);
}
this.engineFx?.update(_time, delta); // burn fades even through a pause
// Session time (saved with the game) — capped so a backgrounded tab
// can't fast-forward it.
@ -5862,6 +5955,8 @@ export class GameScene extends Phaser.Scene {
this.nebulaLightning?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
this.combat?.destroy();
this.combatBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.miningPopup?.destroy();

181
js/ui/CombatBar.js Normal file
View File

@ -0,0 +1,181 @@
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';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The COMBAT BAR — the main game's combat-mode action bar (data/combat.json
* → bar). SHOWN IN PLACE OF THE ACTION DECK while combat is live
* (GameScene swaps them: the deck hides, this takes its place, and back
* when the pack is wiped):
*
* [ SHD | HUL ] [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ] [ ENR ]
*
* left — the player's vertical SHIELDS + HULL gauges
* middle — the eight SKILL seats, wearing keys 1-8. The skills land
* later — for now they are empty seats (a press lands on
* o.onCast(index); the system's cast() is the no-op stub).
* right — the player's vertical ENERGY gauge (the skills' resource)
*
* Built the way ActionBar is (the deck's pattern, verified on this
* Giedi build): children created with scene.add.*, parented into the
* container with this.add(), each setScrollFactor(0) so they sit over
* the world at any zoom.
*/
export class CombatBar extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene a scale/texture-equipped scene (GameScene)
* @param {object} [o]
* onCast (index) => {} — a skill seat was pressed (index 0-7)
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
// v4 quirk (same note as ActionBar): register the directly-built
// container on the scene's display list or it never renders.
this.scene.add.existing(this);
this.setScrollFactor(0); // UI — pinned to the screen, not the world
this.setDepth(50); // the deck's depth
this.onCast = typeof o?.onCast === 'function' ? o.onCast : null;
const bar = config.section('combat.bar', {});
const { width: W, height: H } = scene.scale;
const mL = Number(bar.margin?.left) || 20;
const mR = Number(bar.margin?.right) || 20;
const mB = Number(bar.margin?.bottom) || 12;
const barH = Number(bar.height) || 92;
const x0 = mL;
const y0 = H - mB - barH;
this.rect = { x: x0, y: y0, w: W - mL - mR, h: barH };
const c = bar.colors ?? {};
const panel = toColor(c.bg, themeColor('panel', 0x0b1322));
const edge = toColor(c.edge, themeColor('neon', 0x00e5ff));
const shieldC = toColor(c.shield, 0x00e5ff);
const hullC = toColor(c.hull, 0x7dff8a);
const energyC = toColor(c.energy, 0xffd84d);
const slotBg = toColor(c.slotBg, 0x0b1322);
const slotEdge = toColor(c.slotBorder, 0x22405f);
const keyC = toColor(c.key, 0x5c7196);
const alpha = Number(bar.bgAlpha) || 0.92;
// ---- the panel (the deck's footprint — margin/height mirror it) ----
this.add(this._rect(x0, y0, this.rect.w, barH, panel, alpha));
this.add(this._rect(x0, y0, this.rect.w, 2, edge, 0.4)); // top edge
// ---- the gauges (vertical; fills grow UP from the bottom) ----------
const g = bar.gauge ?? {};
const gaugeW = Number(g.width) || 14;
const gaugeGap = Number(g.gap) || 8;
const pad = Number(g.pad) || 12;
const labelPx = Number(g.labelSize) || 11;
const gaugeH = barH - pad * 2 - labelPx - 4;
const gaugeTop = y0 + pad;
const mkGauge = (cx, color, label) => {
this.add(this._rect(cx - gaugeW / 2, gaugeTop, gaugeW, gaugeH, slotBg, 1));
// The fill: a bottom-anchored rect whose scaleY IS the level.
const fill = scene.add.rectangle(cx, gaugeTop + gaugeH, gaugeW - 4, gaugeH - 4, color, 1);
fill.setOrigin(0.5, 1);
fill.setScrollFactor(0);
this.add(fill);
const labelT = scene.add
.text(cx, y0 + barH - 11, label, {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${labelPx}px`,
color: toCss(themeColor('faint', 0x8391ad)),
letterSpacing: 1,
})
.setOrigin(0.5, 0.5)
.setScrollFactor(0);
this.add(labelT);
return { fill, frac: 1 };
};
const leftX = x0 + pad + gaugeW / 2;
this.gShield = mkGauge(leftX, shieldC, 'SHD');
this.gHull = mkGauge(leftX + gaugeW + gaugeGap, hullC, 'HUL');
const rightX = x0 + this.rect.w - pad - gaugeW / 2;
this.gEnergy = mkGauge(rightX, energyC, 'ENR');
// ---- the skill seats (middle; keys 1-8; the skills land later) -----
const s = bar.slot ?? {};
const count = Math.max(0, Number(s.count) || 8);
const size = Number(s.size) || 64;
const gap = Number(s.gap) || 10;
const keyPx = Number(s.keySize) || 12;
const totalW = count * size + (count - 1) * gap;
let sx = W / 2 - totalW / 2 + size / 2;
const sy = y0 + barH / 2;
this.slots = [];
for (let i = 0; i < count; i++) {
const bg = scene.add.rectangle(sx - size / 2, sy - size / 2, size, size, slotBg, 1);
bg.setScrollFactor(0);
this.add(bg);
this.add(this._rect(sx - size / 2, sy - size / 2, size, 1, slotEdge, 0.9)); // top lip
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
if (this.onCast) this.onCast(i);
});
const keyT = scene.add
.text(sx - size / 2 + 6, sy - size / 2 + 5, String(i + 1), {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: `${keyPx}px`,
color: toCss(keyC),
})
.setOrigin(0, 0.5)
.setScrollFactor(0);
this.add(keyT);
this.slots.push({ x: sx, bg, keyT });
sx += size + gap;
}
}
/**
* A solid rectangle, created on the scene (the scene.add.* factory is
* what puts it on the display list) — the bar's panel/chrome primitive.
* The caller parents it into the container with this.add(rect).
*/
_rect(x, y, w, h, color, alpha = 1) {
const r = this.scene.add.rectangle(x, y, w, h, color, alpha);
r.setScrollFactor(0);
return r;
}
/**
* Feed the gauges. Accepts a CombatSystem (uses its stats()) or a
* plain snapshot { hull, maxHull, shields, maxShields, energy, maxEnergy }.
*/
setStats(src) {
if (!src) return;
const s = typeof src.stats === 'function' ? src.stats() : src;
const frac = (v, m) => (m > 0 ? Math.max(0, Math.min(1, v / m)) : 0);
this._gauge(this.gShield, frac(s.shields, s.maxShields));
this._gauge(this.gHull, frac(s.hull, s.maxHull));
this._gauge(this.gEnergy, frac(s.energy, s.maxEnergy));
}
_gauge(g, f) {
g.frac = f;
g.fill.setScale(1, Math.max(0.001, f));
}
/** Is (px, py) inside the bar's footprint? (GameScene's click gate.) */
contains(px, py) {
const m = 6; // a little slop, like the deck's
return (
px >= this.rect.x - m &&
px <= this.rect.x + this.rect.w + m &&
py >= this.rect.y - m &&
py <= this.rect.y + this.rect.h + m
);
}
/** Tear down (scene destroy) — the children go with the scene anyway. */
destroy() {
this.onCast = null;
super.destroy();
}
}