diff --git a/README.md b/README.md index 3ddf8b4..e35ebfe 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/data/combat.json b/data/combat.json index f6721da..83079f7 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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 } } } diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 2d1d473..74a2510 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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`); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js new file mode 100644 index 0000000..90007c9 --- /dev/null +++ b/js/combat/CombatSystem.js @@ -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; + } +} diff --git a/js/main.js b/js/main.js index 551d71f..a5efce9 100644 --- a/js/main.js +++ b/js/main.js @@ -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); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 5bd4aa9..14f5274 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/ui/CombatBar.js b/js/ui/CombatBar.js new file mode 100644 index 0000000..fa5c0b2 --- /dev/null +++ b/js/ui/CombatBar.js @@ -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(); + } +}