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:
parent
15fe08a926
commit
1d0622a3d5
42
README.md
42
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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
21
js/main.js
21
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue