diff --git a/README.md b/README.md
index b1d184e..3ddf8b4 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,16 @@ 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).
+
## Current state — v0.2: a seedable galaxy
- Main menu with **Continue** (resumes the newest save — grayed out while
@@ -187,6 +197,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
│ ├── 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
@@ -202,13 +213,14 @@ orbit/
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
-├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
+├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames), ships-enemy.png (128×128 frames)
├── 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
+│ ├── main.js # entry point: load config → boot Phaser (or route ?combat / /combat to the sandbox)
│ ├── 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
│ ├── 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)
@@ -310,6 +322,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)
```
`dev/test-game.html` boots straight into the GameScene (no menu click),
diff --git a/assets/images/ships-enemy.png b/assets/images/ships-enemy.png
new file mode 100644
index 0000000..accfa7e
Binary files /dev/null and b/assets/images/ships-enemy.png differ
diff --git a/assets/images/ships-enemy.psd b/assets/images/ships-enemy.psd
new file mode 100644
index 0000000..e6fc0e6
Binary files /dev/null and b/assets/images/ships-enemy.psd differ
diff --git a/combat.html b/combat.html
new file mode 100644
index 0000000..72a6d36
--- /dev/null
+++ b/combat.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Orbit // Combat Sandbox
+
+
+
+
+
+
+
+
diff --git a/data/combat.json b/data/combat.json
new file mode 100644
index 0000000..f6721da
--- /dev/null
+++ b/data/combat.json
@@ -0,0 +1,86 @@
+{
+ "_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).",
+ "enabled": true,
+
+ "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,
+ "maxSpeed": 560,
+ "drag": 1.6,
+ "rotSpeed": 4.8,
+ "brake": 0.6,
+ "weapon": {
+ "key": "space",
+ "cooldown": 0.16,
+ "bulletSpeed": 680,
+ "damage": 10,
+ "spread": 0.03,
+ "bulletLife": 1.15,
+ "color": "#00e5ff",
+ "size": 16
+ }
+ },
+
+ "enemy": {
+ "_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s. ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
+ "texture": "assets/images/ships-enemy.png",
+ "frameWidth": 128,
+ "frameHeight": 128,
+ "frame": 0,
+ "artFacing": "north",
+ "size": 54,
+ "hitRadius": 20,
+ "count": 3,
+ "speed": 165,
+ "turn": 3.2,
+ "orbitRadius": 300,
+ "orbitSpeed": 0.6,
+ "hull": 60,
+ "shields": 0,
+ "weapon": {
+ "range": 560,
+ "cooldown": 1.4,
+ "bulletSpeed": 330,
+ "damage": 8,
+ "spread": 0.08,
+ "bulletLife": 2.4,
+ "color": "#ff2d6f",
+ "size": 18
+ },
+ "ramDamage": 10,
+ "ramDamageSelf": 22,
+ "ramGrace": 0.8,
+ "respawn": true,
+ "respawnDelay": 2.5
+ },
+
+ "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.",
+ "hull": 100,
+ "shields": 40,
+ "shieldRegenDelay": 4,
+ "shieldRegenRate": 14
+ },
+
+ "arena": {
+ "_comment": "The range walls. inset = px between the screen edge and the bounce wall; bounce = the fraction of speed a ship keeps off a wall (1 = full mirror bounce). wallColor = the rim line, wallGlow = its neon edge.",
+ "inset": 26,
+ "bounce": 0.9,
+ "wallColor": "#1b2c4a",
+ "wallGlow": "#00e5ff"
+ },
+
+ "sandbox": {
+ "_comment": "Sandbox conveniences — none of this ships with the real game. resetKey rebuilds the whole range (a fresh scene); pauseKey freezes the sim (handy for eyeballing positions via window.__COMBAT__ in the console); respawn = the player comes back (respawnDelay s) instead of dying permanently.",
+ "resetKey": "R",
+ "pauseKey": "P",
+ "respawn": true,
+ "respawnDelay": 1.6
+ },
+
+ "hud": {
+ "_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"
+ }
+}
diff --git a/data/manifest.json b/data/manifest.json
index c1dad1a..24f145c 100644
--- a/data/manifest.json
+++ b/data/manifest.json
@@ -35,6 +35,7 @@
"vignettes.json",
"goods.json",
"economy.json",
- "shopui.json"
+ "shopui.json",
+ "combat.json"
]
}
diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs
new file mode 100644
index 0000000..2d1d473
--- /dev/null
+++ b/dev/combat.test.mjs
@@ -0,0 +1,232 @@
+/**
+ * Combat rules test (dev tool, run with Node — no browser needed):
+ *
+ * node dev/combat.test.mjs
+ *
+ * Stubs just enough of Phaser + a scene to run the REAL combat modules
+ * 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:
+ * velocity from angle/speed, world radius, one-shot consume, life
+ * expiry) — then asserts the behavior the sandbox (js/combat/CombatScene.js)
+ * is 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.
+ */
+import { pathToFileURL } from 'node:url';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TAU = Math.PI * 2;
+
+// Mirror of Phaser.Math.Angle.Wrap — the vendored build's Angle.Wrap is
+// Wrap(value, -π, π): ((v - min) % range + range) % range + min.
+const wrap = (a) => {
+ const min = -Math.PI;
+ const range = Math.PI * 2;
+ return (((a - min) % range) + range) % range + min;
+};
+
+class V2 {
+ constructor(x = 0, y = 0) { this.x = x; this.y = y; }
+ set(x, y) { this.x = x; this.y = y; return this; }
+ length() { return Math.hypot(this.x, this.y); }
+ scale(s) { this.x *= s; this.y *= s; return this; }
+}
+
+class Sprite {
+ constructor(scene, x, y, key, frame) {
+ this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame;
+ this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true;
+ this.active = true; this.body = null;
+ }
+ setScale(s) { this.scale = s; return this; }
+ setTint() { return this; }
+ setBlendMode() { return this; }
+ setDepth() { return this; }
+ setOrigin() { return this; }
+ setAlpha(a) { this.alpha = a; return this; }
+ setVisible(v) { this.visible = v; return this; }
+ destroy() { this.active = false; this.destroyed = true; }
+}
+
+function makeBody() {
+ return {
+ velocity: new V2(),
+ acceleration: new V2(),
+ enable: true,
+ setCircle() { return this; },
+ setAllowGravity() { return this; },
+ setVelocity(x, y) { this.velocity.set(x, y); return this; },
+ };
+}
+
+const PhaserStub = {
+ Physics: { Arcade: { Sprite } },
+ Math: {
+ Angle: {
+ Wrap: wrap,
+ RotateTo: (cur, tgt, step) => (Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step),
+ },
+ },
+ Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
+ BlendModes: { ADD: 'ADD' },
+};
+globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
+
+function makeScene({ withSheet = true } = {}) {
+ return {
+ textures: { exists: () => withSheet },
+ make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
+ add: { existing: (o) => o },
+ physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
+ scale: { width: 1280, height: 720 },
+ time: { now: 0 },
+ };
+}
+
+// The real data/*.json (combat numbers come from data/combat.json).
+const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
+const fs = await import('node:fs');
+const dataDir = join(__dirname, '../data');
+const configData = {};
+for (const f of fs.readdirSync(dataDir)) {
+ if (!f.endsWith('.json') || f === 'manifest.json') continue;
+ configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
+}
+config.init(configData);
+console.log('config loaded from data/:', Object.keys(configData).join(', '));
+
+const { applyDamage, headingOf, orbitStep } = await import(
+ pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
+);
+const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
+const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
+
+let failures = 0;
+const check = (label, cond) => {
+ console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
+ if (!cond) failures++;
+};
+const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
+const E = (k, d) => config.get(`combat.enemy.${k}`, d);
+
+// ------------------------------------------------------------------ armor
+
+{
+ const armor = { shields: 40, hull: 100 };
+ const r1 = applyDamage(armor, 30);
+ check('damage: 30 hits shields only (40→10, hull 100)', armor.shields === 10 && armor.hull === 100 && !r1.dead && r1.shieldHit === 30);
+ const r2 = applyDamage(armor, 50);
+ check('damage: 50 drains shields then hull (0/60)', armor.shields === 0 && armor.hull === 60 && !r2.dead);
+ const r3 = applyDamage(armor, 999);
+ check('damage: overkill kills (hull 0, dead)', armor.hull === 0 && r3.dead);
+ applyDamage(armor, 5);
+ check('damage: dead armor takes no more', armor.hull === 0 && armor.shields === 0);
+ const a2 = { shields: 0, hull: 25 };
+ applyDamage(a2, 9);
+ check('damage: zero shields → hull straight', a2.hull === 16 && !a2.dead);
+ const a3 = { shields: 10, hull: 10 };
+ const r4 = applyDamage(a3, 'nope');
+ check('damage: non-numeric = 0', a3.shields === 10 && a3.hull === 10 && !r4.dead);
+}
+
+{
+ check('headingOf: rotation minus artOffset', approx(headingOf({ rotation: Math.PI / 2, artOffset: Math.PI / 2 }), 0));
+ check('headingOf: no offset', approx(headingOf({ rotation: 1.2 }), 1.2));
+}
+
+{
+ // player at origin, orbiting at radius 100
+ const t = orbitStep(0, 0, 0, 1, 1, 1, 100);
+ check('orbitStep: angle advances dir*speed*dt', approx(t.angle, 1));
+ check('orbitStep: target on the ring', approx(t.x, Math.cos(1) * 100) && approx(t.y, Math.sin(1) * 100));
+ const t2 = orbitStep(0, 0, 0, -1, 2, 0.5, 100);
+ check('orbitStep: counter-direction', approx(t2.angle, -1));
+}
+
+// ---------------------------------------------------------------- enemy
+
+{
+ const scene = makeScene({ withSheet: true });
+ const e = new EnemyShip(scene, 200, 360, 0);
+
+ const fw = E('frameWidth', 128);
+ check('enemy: scale = size / frameWidth', approx(e.scale, E('size', 54) / fw));
+ check('enemy: north-facing art → offset +π/2', approx(e.artOffset, Math.PI / 2));
+ check('enemy: hitRadius from config', e.hitRadius === E('hitRadius', 20));
+ check('enemy: armor from config', e.maxHull === E('hull', 60) && e.maxShields === E('shields', 0));
+
+ // Damage model (shields → hull → dead).
+ e.shields = 30;
+ check('enemy.damage: shields first', e.damage(40) === false && e.shields === 0 && e.hull === E('hull', 60) - 10);
+ check('enemy.damage: killing blow', e.damage(999) === true && e.dead && e.hull === 0);
+ check('enemy.damage: dead is a wall', e.damage(10) === false);
+ e.revive();
+ check('enemy: revive restores armor + liveness', !e.dead && e.hull === e.maxHull && e.shields === e.maxShields);
+
+ // The AI steers toward its point on the standoff ring around the player.
+ const player = { x: 640, y: 360 };
+ e.x = 200; e.y = 360;
+ e.body.velocity.set(0, 0);
+ e.orbitAngle = Math.PI; // "behind" the enemy relative to the player (west side)
+ e.update(0, 16, player);
+ check('enemy AI: velocity heads for the ring (toward the player side)', e.body.velocity.x > 0 && Math.abs(e.body.velocity.y) < Math.abs(e.body.velocity.x));
+
+ // The lance: in range + aimed + off cooldown → fires once.
+ e.nextFireAt = 0;
+ e.x = 840; e.y = 360; // 200 px EAST of the player (range is 560)
+ const toPlayer = Math.PI; // facing west, straight at the player
+ e.rotation = toPlayer + e.artOffset;
+ const fired = e.update(0, 16, player);
+ check('enemy AI: fires when in range + aimed + armed', fired === true);
+ check('enemy AI: cooldown set after firing', e.nextFireAt > 0);
+ const firedAgain = e.update(0, 16, player);
+ check('enemy AI: no instant re-fire', firedAgain === false);
+
+ // Out of range → no fire (even aimed + armed).
+ e.nextFireAt = 0;
+ e.x = 1500; e.y = 360;
+ e.rotation = Math.atan2(360 - 360, 640 - 1500) + e.artOffset;
+ check('enemy AI: out of range, no fire', e.update(0, 16, player) === false);
+
+ // Player down → no fire, keeps orbiting the last position.
+ e.nextFireAt = 0;
+ e.x = 840; e.y = 360;
+ e.rotation = toPlayer + e.artOffset;
+ check('enemy AI: player down, no fire', e.update(0, 16, null) === false);
+}
+
+{
+ // No spritesheet → the procedural wedge (east-facing, offset 0).
+ const scene = makeScene({ withSheet: false });
+ const e = new EnemyShip(scene, 100, 100, 1);
+ check('enemy fallback: east-facing wedge, offset 0', e.artOffset === 0);
+ check('enemy fallback: scale from the wedge width', approx(e.scale, E('size', 54) / 76));
+}
+
+// ---------------------------------------------------------------- bullet
+
+{
+ const scene = makeScene({ withSheet: true });
+ const b = new Bullet(scene, { x: 100, y: 100, angle: 0, speed: 680, damage: 10, life: 1.2, color: '#00e5ff', size: 16, owner: 'player' });
+ check('bullet: velocity = speed along angle', approx(b.body.velocity.x, 680) && approx(b.body.velocity.y, 0));
+ check('bullet: world radius > 0', b.radius > 0 && b.radius < 32);
+ check('bullet: owner + damage carried', b.owner === 'player' && b.damage === 10);
+
+ check('bullet: consume is one-shot', b.consume() === true && b.consume() === false);
+ check('bullet: consume destroys', b.active === false);
+
+ const b2 = new Bullet(scene, { x: 0, y: 0, angle: Math.PI / 2, speed: 330, damage: 8, life: 1.0, color: '#ff2d6f', size: 18, owner: 'enemy' });
+ check('bullet: down-angle velocity', approx(b2.body.velocity.x, 0) && approx(b2.body.velocity.y, 330));
+ b2.update(1000);
+ check('bullet: life expiry consumes', b2.dead === true);
+}
+
+// ------------------------------------------------------------------ done
+
+console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/dev/server.mjs b/dev/server.mjs
index e96e089..0849cb9 100644
--- a/dev/server.mjs
+++ b/dev/server.mjs
@@ -48,6 +48,10 @@ const MIME = {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
let p = decodeURIComponent(url.pathname);
+ // /combat → the combat sandbox page (the standalone training range:
+ // data/combat.json + js/combat/). combat.html is the same page, and
+ // index.html?combat boots it too (js/main.js).
+ if (p === '/combat' || p === '/combat/') p = '/combat.html';
if (p.endsWith('/')) p += 'index.html';
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT + path.sep) && file !== ROOT) {
diff --git a/js/combat/Bullet.js b/js/combat/Bullet.js
new file mode 100644
index 0000000..2bed36c
--- /dev/null
+++ b/js/combat/Bullet.js
@@ -0,0 +1,90 @@
+import Phaser from '../vendor/phaser.js';
+import { toColor } from '../utils/Color.js';
+import { canvasTexture } from '../utils/Textures.js';
+
+const TEX_KEY = '__combat_bullet';
+const TEX_SIZE = 32;
+
+/** A small, bright, soft dot — white, so a tint reads as its color. */
+function drawSoftDot(ctx, size) {
+ const c = size / 2;
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
+ g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
+ g.addColorStop(0.25, 'rgba(255,255,255,0.9)');
+ g.addColorStop(0.55, 'rgba(255,255,255,0.32)');
+ g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, size, size);
+}
+
+/**
+ * A combat tracer: a glowing dot that flies straight, lives for a while,
+ * and carries the damage of the weapon that fired it. The scene (not the
+ * bullet) decides what it hits — the sandbox runs its collisions by hand
+ * (circle checks, like the main game's keep-out constraints) because
+ * this build's collider API is fiddly; a bullet just reports its radius
+ * (this.radius, world px) and owner.
+ *
+ * Tuned per weapon from data/combat.json (player.weapon / enemy.weapon):
+ * speed, damage, life, color, size.
+ */
+export class Bullet extends Phaser.Physics.Arcade.Sprite {
+ static TEXTURE_KEY = TEX_KEY;
+
+ /** Ensure the shared soft-dot texture exists (canvas-generated). */
+ static ensureTexture(scene) {
+ if (scene.textures.exists(TEX_KEY)) return TEX_KEY;
+ canvasTexture(scene, TEX_KEY, TEX_SIZE, TEX_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h)));
+ return TEX_KEY;
+ }
+
+ /**
+ * @param {object} scene the combat scene
+ * @param {object} o { x, y, angle, speed, damage, life, color, size, owner }
+ * angle rad (flight direction), speed px/s, life seconds,
+ * owner 'player' | 'enemy' (whose bullets fly whose way).
+ */
+ constructor(scene, o) {
+ Bullet.ensureTexture(scene);
+ super(scene, o.x, o.y, TEX_KEY);
+ scene.add.existing(this);
+ scene.physics.add.existing(this);
+
+ this.owner = o.owner;
+ this.damage = Math.max(0, o.damage ?? 10);
+ this.life = Math.max(0, o.life ?? 1.2);
+ this.dead = false; // consumed (a hit or the end of life) — consume() is one-shot
+
+ const s = Math.max(0.05, (o.size ?? 16) / TEX_SIZE);
+ this.setScale(s);
+ this.setTint(toColor(o.color, 0xffffff));
+ this.setBlendMode(Phaser.BlendModes.ADD);
+
+ // World-space radius for the scene's circle checks (~ a third of the glow).
+ this.radius = TEX_SIZE * 0.3125 * s; // 10 px in texture space, at scale
+
+ const body = this.body;
+ body.setCircle(10); // texture-space radius (auto-scales with the sprite)
+ body.setAllowGravity(false);
+ body.setVelocity(Math.cos(o.angle) * (o.speed ?? 600), Math.sin(o.angle) * (o.speed ?? 600));
+ }
+
+ /** Age the bullet (ms). Fizzles past its life. */
+ update(deltaMs) {
+ this.life -= deltaMs / 1000;
+ if (this.life <= 0) this.consume();
+ }
+
+ /**
+ * Consume the bullet (a hit, or the end of its life). One-shot: only
+ * the FIRST caller destroys it — the collision pass and the aging pass
+ * can both land in the same frame.
+ * @returns {boolean} true when THIS call consumed it
+ */
+ consume() {
+ if (this.dead) return false;
+ this.dead = true;
+ this.destroy();
+ return true;
+ }
+}
diff --git a/js/combat/Combat.js b/js/combat/Combat.js
new file mode 100644
index 0000000..e1156eb
--- /dev/null
+++ b/js/combat/Combat.js
@@ -0,0 +1,60 @@
+/**
+ * Combat rules — the pure arithmetic shared by the sandbox's actors
+ * (the player's armor, the enemy craft, and whatever system ports out of
+ * the range into the real game). No Phaser, no scene, so it runs in bare
+ * Node: dev/combat.test.mjs exercises exactly this file.
+ *
+ * An armor block is `{ shields, hull }` (both ≥ 0): a hit spends the
+ * shields first, then the hull, and the craft is dead at hull ≤ 0 — the
+ * same rule for the player (data/combat.json → playerShip) and the
+ * hostiles (→ enemy).
+ */
+
+/**
+ * Spend damage off an armor block (mutates it).
+ *
+ * @param {{shields: number, hull: number}} armor the block to hit
+ * @param {number} amount damage (negative/NaN treated as 0)
+ * @returns {{shieldHit: number, hullHit: number, dead: boolean}}
+ * shieldHit/hullHit = how much each layer absorbed; dead = hull ≤ 0.
+ */
+export function applyDamage(armor, amount) {
+ let rem = Math.max(0, Number(amount) || 0);
+
+ const shields = Math.max(0, armor.shields ?? 0);
+ const shieldHit = Math.min(shields, rem);
+ rem -= shieldHit;
+
+ const hull = Math.max(0, armor.hull ?? 0);
+ const hullHit = Math.min(hull, rem);
+ rem -= hullHit;
+
+ armor.shields = shields - shieldHit;
+ armor.hull = hull - hullHit;
+ return { shieldHit, hullHit, dead: armor.hull <= 0 };
+}
+
+/**
+ * The render heading (rad) of a ship sprite: its rotation minus the
+ * art's facing offset (ship.artOffset — the sheet art may not face east,
+ * js/entities/Ship.js). A plain object with a .rotation is fine.
+ */
+export function headingOf(ship) {
+ return ship.rotation - (ship.artOffset || 0);
+}
+
+/**
+ * One step of the standoff-ring AI: advance the craft's orbit angle and
+ * return the point on the ring it should steer to (pure — the craft
+ * does the steering, js/combat/EnemyShip.js).
+ *
+ * @returns {{angle: number, x: number, y: number}} the new orbit angle + target
+ */
+export function orbitStep(px, py, orbitAngle, orbitDir, orbitSpeed, dt, orbitRadius) {
+ const angle = orbitAngle + orbitDir * orbitSpeed * dt;
+ return {
+ angle,
+ x: px + Math.cos(angle) * orbitRadius,
+ y: py + Math.sin(angle) * orbitRadius,
+ };
+}
diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js
new file mode 100644
index 0000000..b709f86
--- /dev/null
+++ b/js/combat/CombatScene.js
@@ -0,0 +1,743 @@
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toColor, toCss } from '../utils/Color.js';
+import { fontStack, themeColor } from '../utils/Theme.js';
+
+// Codebase pattern: a local fallback stack passed into fontStack()
+// (Theme.js has no FONT_FALLBACK export — see js/scenes/MenuScene.js).
+const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
+import { Ship } from '../entities/Ship.js';
+import { EnemyShip } from './EnemyShip.js';
+import { Bullet } from './Bullet.js';
+import { Fx } from './Fx.js';
+import { StarDrift } from './StarDrift.js';
+import { applyDamage } from './Combat.js';
+
+/**
+ * The COMBAT SANDBOX — Orbit's standalone combat range (combat.html /
+ * the dev server's /combat / index.html?combat). One fixed, full-screen
+ * arena where the player ship (ART + flight stats from data/ship.json,
+ * COMBAT feel from data/combat.json → player) fights a pack of the
+ * enemy craft (→ enemy, assets/images/ships-enemy.png) against simple
+ * rules:
+ *
+ * WASD / arrows — fly (keyboard turn + thrust, Ship.update does the
+ * drag/coast), SPACE or J — fire (hold = auto),
+ * R — reset the range, P — pause.
+ *
+ * Deliberately NO economy, no map, no save: this is the place to
+ * iterate the feel before combat ships in the real game. Everything
+ * tunable lives in data/combat.json; the pure rules are in
+ * js/combat/Combat.js (tested by dev/combat.test.mjs).
+ *
+ * Architecture notes (this build — Phaser 4.2 "Giedi" UMD):
+ * - Scenes drive the sim: this.update() steps time + tweens + the
+ * physics world by hand (see js/scenes/GameScene.js), then runs the
+ * actors. Nothing here relies on the engine's scene stepping.
+ * - Collisions are circle checks done HERE (bullets × ships, ram), not
+ * arcade colliders — same hand-rolled approach the main game uses
+ * for its keep-out constraints (GameScene.resolveKeepOut /
+ * resolveCollisions).
+ * - FX is the hand-rolled Fx (js/combat/Fx.js), not the particles
+ * emitter (velocity semantics differ in this build).
+ *
+ * The console exposes `window.__COMBAT__` — { reset, spawnEnemy, player }
+ * — so a designer can drive the range from devtools while it runs.
+ */
+export class CombatScene extends Phaser.Scene {
+ constructor() {
+ super('CombatScene');
+ }
+
+ // ---------------------------------------------------------------- assets
+
+ preload() {
+ const cfg = config.section('combat', {});
+
+ // The player ship sheet (the SAME texture key + geometry as the main
+ // game — GameScene.preload — so the art never drifts between the two).
+ const shipTex = config.get('ship.texture', '');
+ if (shipTex && !this.textures.exists(Ship.TEXTURE_KEY)) {
+ this.load.spritesheet(Ship.TEXTURE_KEY, shipTex, {
+ frameWidth: config.get('ship.frameWidth', 256),
+ frameHeight: config.get('ship.frameHeight', 256),
+ });
+ }
+
+ // The enemy sheet (data/combat.json → enemy.texture).
+ const enemyTex = cfg.enemy?.texture ?? '';
+ if (enemyTex && !this.textures.exists(EnemyShip.TEXTURE_KEY)) {
+ this.load.spritesheet(EnemyShip.TEXTURE_KEY, enemyTex, {
+ frameWidth: cfg.enemy?.frameWidth ?? 128,
+ frameHeight: cfg.enemy?.frameHeight ?? 128,
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------- create
+
+ create() {
+ const w = this.scale.width;
+ const h = this.scale.height;
+ this.w = w;
+ this.h = h;
+
+ this.paused = false;
+ this.playerDead = false;
+ this.kills = 0;
+ this.nextFireAt = 0;
+ this.ramUntil = 0;
+
+ // The arena.
+ this.inset = Math.max(8, config.get('combat.arena.inset', 26));
+ this.wallBounce = Math.max(0, Math.min(1, config.get('combat.arena.bounce', 0.9)));
+ this.starfield = new StarDrift(this);
+ this.starfield.create(w, h);
+ this.drawWalls();
+
+ // The player (the real Ship — same entity as the main game).
+ this.maxHull = Math.max(1, config.get('combat.playerShip.hull', 100));
+ this.maxShields = Math.max(0, config.get('combat.playerShip.shields', 40));
+ this.hull = this.maxHull;
+ this.shields = this.maxShields;
+ this.lastHitAt = -1e9;
+ this.ship = new Ship(this, w * 0.24, h / 2);
+ this.ship.setDepth(10);
+ this.ship.body.setAllowGravity(false);
+
+ // The pack (spawned on a ring around the screen center).
+ this.enemies = [];
+ const count = Math.max(0, Math.round(config.get('combat.enemy.count', 3)));
+ const cx = w / 2;
+ const cy = h / 2;
+ const ring = Math.min(w, h) * 0.34;
+ for (let i = 0; i < count; i++) {
+ const a = (i / Math.max(1, count)) * Math.PI * 2 + 0.7;
+ this.spawnEnemy(cx + Math.cos(a) * ring, cy + Math.sin(a) * ring, i);
+ }
+
+ this.bullets = [];
+ this.fx = new Fx(this);
+
+ // The range console (HUD).
+ this.createHud();
+
+ // Controls: WASD/arrows + SPACE/J. R = reset, P = pause (keydown
+ // events — the same `keydown-XXX` pattern as the main game).
+ const kb = this.input.keyboard;
+ this.keys = kb.addKeys({
+ up: 'W,UP',
+ down: 'S,DOWN',
+ left: 'A,LEFT',
+ right: 'D,RIGHT',
+ fire: 'SPACE,J',
+ });
+ kb.on('keydown-R', () => this.reset());
+ kb.on('keydown-P', () => this.togglePause());
+
+ // The designer console (data/combat.json is the tuning surface).
+ if (typeof window !== 'undefined') {
+ const scene = this; // getters must close over the scene, not `this`
+ window.__COMBAT__ = {
+ reset: () => scene.reset(),
+ spawnEnemy: (x, y) => scene.spawnEnemy(x, y, scene.enemies.length),
+ /** "62 +31 shd" — a quick armor read from devtools. */
+ get player() {
+ return `${Math.ceil(scene.hull)}${scene.shields > 0 ? ` +${Math.ceil(scene.shields)} shd` : ''}`;
+ },
+ get kills() { return scene.kills; },
+ };
+ }
+ console.info('orbit — combat sandbox armed · tune data/combat.json · window.__COMBAT__ = { reset, spawnEnemy, player, kills }');
+ }
+
+ // ---------------------------------------------------------------- update
+
+ update(time, delta) {
+ if (this.paused) return; // frozen — the world is paused too (togglePause),
+ // so nothing drifts while we look at it
+
+ // THIS BUILD (Phaser 4.2.1 Giedi): the engine steps the arcade world
+ // and the scene clock itself (plugins' preUpdate — verified: bodies
+ // keep integrating while the scene's update() is paused, and
+ // World.update takes only (time, delta) — no callback). So we do NOT
+ // step the world here (that would double-integrate every body);
+ // the positions below are already this frame's post-integration ones,
+ // and the velocities we set now land on the NEXT frame's step —
+ // the same contract GameScene runs on (actors in update, constraints
+ // in postupdate).
+ this.time.update(time, delta); // idempotent (Clock sets now = time)
+
+ // Actor order: player → enemies → bullets (aging) → collide, so a
+ // point-blank hit lands on THIS frame's positions.
+ this.updatePlayer(time, delta);
+ this.updateEnemies(time, delta);
+ this.updateBullets(delta);
+ this.collide(delta);
+
+ this.fx.update(delta);
+ this.starfield.update(time, delta);
+ this.updateHud(time);
+ }
+
+ // ------------------------------------------------------------ the player
+
+ updatePlayer(time, delta) {
+ if (this.playerDead) {
+ this.maybeRespawn(time);
+ return;
+ }
+ const ship = this.ship;
+ const dt = Math.min(delta, 64) / 1000;
+
+ // Ship.update owns the physics contract (drag, acceleration →
+ // velocity) — call it FIRST (it zeroes the acceleration when we
+ // don't thrust this frame), then apply the keyboard on top.
+ ship.update(time, delta);
+
+ const keys = this.keys;
+ const rot = Math.max(0.2, config.get('combat.player.rotSpeed', 4.8)) * dt;
+ if (keys.left.isDown) ship.rotation -= rot;
+ if (keys.right.isDown) ship.rotation += rot;
+
+ const heading = ship.rotation - ship.artOffset; // world radians (Ship contract)
+ const thrust = Math.max(0, config.get('combat.player.thrust', 1500));
+ let ax = 0;
+ let ay = 0;
+ let thrusting = false;
+ if (keys.up.isDown) {
+ ax += Math.cos(heading) * thrust;
+ ay += Math.sin(heading) * thrust;
+ thrusting = true;
+ }
+ if (keys.down.isDown) {
+ const f = thrust * Math.max(0, config.get('combat.player.brake', 0.6));
+ ax -= Math.cos(heading) * f;
+ ay -= Math.sin(heading) * f;
+ }
+ if (thrusting || keys.down.isDown) ship.body.acceleration.set(ax, ay);
+
+ // Ship's drift branch has no speed cap — enforce the range's.
+ const maxV = Math.max(1, config.get('combat.player.maxSpeed', 560));
+ const v = ship.body.velocity.length();
+ if (v > maxV) ship.body.velocity.scale(maxV / v);
+
+ // Exhaust glow — the engine flare, cheap (a tinted soft dot aft).
+ this.updateThrustGlow(ship, heading, thrusting, dt);
+
+ // Fire (hold = auto; the cooldown lives on the weapon).
+ const wpn = config.section('combat.player.weapon', {});
+ if (keys.fire.isDown && time >= this.nextFireAt) {
+ this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
+ this.firePlayer(heading, wpn);
+ }
+
+ // Shield regen (playerShip.* — shields back up after lastHitAt + delay).
+ const pCfg = config.section('combat.playerShip', {});
+ if (this.shields < this.maxShields && this.maxShields > 0) {
+ const grace = (Number(pCfg.shieldRegenDelay) || 4) * 1000;
+ const rate = Math.max(0, Number(pCfg.shieldRegenRate) || 14);
+ if (time - this.lastHitAt >= grace) {
+ this.shields = Math.min(this.maxShields, this.shields + rate * dt);
+ }
+ }
+ }
+
+ firePlayer(heading, wpn) {
+ const spread = Math.max(0, Number(wpn.spread) || 0);
+ const angle = heading + (Math.random() * 2 - 1) * spread;
+ const nose = this.ship.radius + 8;
+ const x = this.ship.x + Math.cos(heading) * nose;
+ const y = this.ship.y + Math.sin(heading) * nose;
+ this.spawnBullet(x, y, angle, wpn, 'player');
+ this.fx.flash(x, y, wpn.color ?? 0x00e5ff, 30, 110);
+ }
+
+ updateThrustGlow(ship, heading, thrusting, dt) {
+ if (!this.thrustGlow) {
+ this.thrustGlow = this.add
+ .image(0, 0, Fx.ensureDot(this))
+ .setBlendMode(Phaser.BlendModes.ADD)
+ .setDepth(9)
+ .setAlpha(0);
+ }
+ const g = this.thrustGlow;
+ const want = thrusting ? 0.5 : 0;
+ g.alpha += (want - g.alpha) * Math.min(1, 12 * dt);
+ if (g.alpha < 0.02 && !thrusting) {
+ if (g.visible) g.setVisible(false);
+ return;
+ }
+ if (!g.visible) g.setVisible(true);
+ g.x = ship.x - Math.cos(heading) * ship.radius * 0.85;
+ g.y = ship.y - Math.sin(heading) * ship.radius * 0.85;
+ g.setTint(themeColor('neon', 0x00e5ff));
+ g.setScale(30 / 64);
+ }
+
+ // --------------------------------------------------------------- enemies
+
+ spawnEnemy(x, y, index) {
+ const e = new EnemyShip(this, x, y, index);
+ e.setDepth(10);
+ // Anchor its orbit to its current bearing from the player…
+ const px = this.ship ? this.ship.x : this.w / 2;
+ const py = this.ship ? this.ship.y : this.h / 2;
+ e.orbitAngle = Math.atan2(y - py, x - px);
+ // …and grant the pack a brief non-shooting grace.
+ e.nextFireAt = 1000;
+ this.enemies.push(e);
+ return e;
+ }
+
+ updateEnemies(time, delta) {
+ const player = this.playerDead ? null : this.ship;
+ for (const e of this.enemies) {
+ if (e.dead) {
+ // Respawn keeps the range stocked while tuning (enemy.respawn).
+ if (config.get('combat.enemy.respawn', true) && time >= e.respawnAt) this.reviveEnemy(e);
+ continue;
+ }
+ const wantsFire = e.update(time, delta, player);
+ if (wantsFire && !this.playerDead) this.fireEnemy(e);
+ this.wallClamp(e, e.hitRadius);
+ }
+ }
+
+ fireEnemy(e) {
+ const wpn = e.wpn ?? {};
+ const toPlayer = Math.atan2(this.ship.y - e.y, this.ship.x - e.x);
+ const angle = toPlayer + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
+ const nose = e.hitRadius + 10;
+ this.spawnBullet(
+ e.x + Math.cos(toPlayer) * nose,
+ e.y + Math.sin(toPlayer) * nose,
+ angle,
+ wpn,
+ 'enemy',
+ );
+ this.fx.flash(e.x + Math.cos(toPlayer) * nose, e.y + Math.sin(toPlayer) * nose, wpn.color ?? 0xff2d6f, 28, 100);
+ }
+
+ killEnemy(e) {
+ if (e._killFx) return; // idempotent — damage() sets e.dead first, so
+ e._killFx = true; // the dead flag can't be the "already counted" guard
+ this.kills += 1;
+ this.fx.explode(e.x, e.y, 0xff5d6f);
+ e.setVisible(false);
+ if (e.body) {
+ e.body.velocity.x = 0;
+ e.body.velocity.y = 0;
+ }
+ e.respawnAt = this.time.now + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000;
+ }
+
+ reviveEnemy(e) {
+ // Back to a random edge of the ring around the player.
+ e._killFx = false;
+ const a = Math.random() * Math.PI * 2;
+ const r = Math.max(this.inset + 60, e.orbitRadius * (0.8 + Math.random() * 0.5));
+ const px = this.playerDead ? this.w / 2 : this.ship.x;
+ const py = this.playerDead ? this.h / 2 : this.ship.y;
+ e.x = px + Math.cos(a) * r;
+ e.y = py + Math.sin(a) * r;
+ e.orbitAngle = a;
+ e.revive();
+ e.nextFireAt = this.time.now + 900;
+ e.setVisible(true);
+ }
+
+ // --------------------------------------------------------------- bullets
+
+ spawnBullet(x, y, angle, wpn, owner) {
+ const b = new Bullet(this, {
+ x,
+ y,
+ angle,
+ speed: Number(wpn.bulletSpeed) || 600,
+ damage: Number(wpn.damage) || 8,
+ life: Number(wpn.bulletLife) || 1.2,
+ color: wpn.color ?? '#ffffff',
+ size: Number(wpn.size) || 16,
+ owner,
+ });
+ b.setDepth(20);
+ this.bullets.push(b);
+ return b;
+ }
+
+ updateBullets(delta) {
+ for (let i = this.bullets.length - 1; i >= 0; i--) {
+ const b = this.bullets[i];
+ if (b.dead || !b.active) {
+ this.bullets.splice(i, 1);
+ continue;
+ }
+ b.update(delta);
+ if (b.dead) {
+ this.bullets.splice(i, 1);
+ continue;
+ }
+ // Walls clip tracers (a bullet never outlives the arena).
+ if (b.x < this.inset || b.x > this.w - this.inset || b.y < this.inset || b.y > this.h - this.inset) {
+ if (b.consume()) this.bullets.splice(i, 1);
+ }
+ }
+ }
+
+ // ------------------------------------------------------------- collisions
+
+ /** Circle check (world px). */
+ /**
+ * Swept hit check: tracers run ~900 px/s — at 30 fps that's
+ * ~30 px per frame, past a 25 px combined radius, so a point check can
+ * tunnel. Sample the segment the bullet flew this frame (end, middle,
+ * start) against the target circle.
+ */
+ hitsSwept(b, other, otherRadius, dtMs) {
+ const r = b.radius + otherRadius;
+ const r2 = r * r;
+ const vx = b.body ? b.body.velocity.x : 0;
+ const vy = b.body ? b.body.velocity.y : 0;
+ const dt = Math.min(dtMs, 64) / 1000;
+ for (let f = 1; f >= 0; f -= 0.5) {
+ const dx = other.x - (b.x - vx * dt * f);
+ const dy = other.y - (b.y - vy * dt * f);
+ if (dx * dx + dy * dy < r2) return true;
+ }
+ return false;
+ }
+
+ collide(delta) {
+ // Player bullets × enemies.
+ if (!this.playerDead) {
+ for (let i = this.bullets.length - 1; i >= 0; i--) {
+ const b = this.bullets[i];
+ if (b.dead || b.owner !== 'player') continue;
+ for (const e of this.enemies) {
+ if (e.dead) continue;
+ if (this.hitsSwept(b, e, e.hitRadius, delta)) {
+ const killed = e.damage(b.damage);
+ this.fx.flash(b.x, b.y, 0xff9db4, 34, 130);
+ if (killed) this.killEnemy(e);
+ if (b.consume()) this.bullets.splice(i, 1);
+ break;
+ }
+ }
+ }
+ }
+ // Enemy lances × player.
+ for (let i = this.bullets.length - 1; i >= 0; i--) {
+ const b = this.bullets[i];
+ if (b.dead || b.owner !== 'enemy' || this.playerDead) continue;
+ if (this.hitsSwept(b, this.ship, this.ship.radius, delta)) {
+ this.damagePlayer(b.damage);
+ this.fx.flash(b.x, b.y, 0x9db4ff, 30, 130);
+ if (b.consume()) this.bullets.splice(i, 1);
+ }
+ }
+ // Rams (player × enemies) — damage both, bounce both.
+ if (!this.playerDead) {
+ for (const e of this.enemies) {
+ if (e.dead) continue;
+ this.ramPair(this.ship, e);
+ }
+ }
+ }
+
+ /**
+ * A ship-ship collision: push apart, exchange normal velocity (bounce),
+ * and deal ram damage to BOTH (grace-limited — data/combat.json →
+ * enemy.ram*). The player's Ship has no hull of its own here; the
+ * scene's armor (this.hull/shields) is what rams cost.
+ */
+ ramPair(a, b) {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const d = Math.hypot(dx, dy);
+ const gap = a.radius + b.hitRadius;
+ if (d >= gap || d <= 0) return;
+ const nx = dx / d;
+ const ny = dy / d;
+
+ const push = (gap - d) / 2;
+ a.x -= nx * push;
+ a.y -= ny * push;
+ b.x += nx * push;
+ b.y += ny * push;
+
+ const avx = a.body.velocity.x;
+ const avy = a.body.velocity.y;
+ const rvx = b.body.velocity.x - avx;
+ const rvy = b.body.velocity.y - avy;
+ const vn = rvx * nx + rvy * ny;
+ if (vn < 0) {
+ const j = (-(1 + this.wallBounce) * vn) / 2; // equal-mass impulse
+ a.body.velocity.x = avx - j * nx;
+ a.body.velocity.y = avy - j * ny;
+ b.body.velocity.x += j * nx;
+ b.body.velocity.y += j * ny;
+ }
+
+ const t = this.time.now;
+ if (t >= this.ramUntil) {
+ this.ramUntil = t + (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, 0xffffff, 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);
+ }
+ }
+
+ /** The arena walls — clamp + mirror-bounce (arena.bounce). */
+ wallClamp(body, r) {
+ const L = this.inset + r;
+ const R = this.w - this.inset - r;
+ const T = this.inset + r;
+ const B = this.h - this.inset - r;
+ const v = body.body.velocity;
+ if (body.x < L) {
+ body.x = L;
+ if (v.x < 0) v.x *= -this.wallBounce;
+ } else if (body.x > R) {
+ body.x = R;
+ if (v.x > 0) v.x *= -this.wallBounce;
+ }
+ if (body.y < T) {
+ body.y = T;
+ if (v.y < 0) v.y *= -this.wallBounce;
+ } else if (body.y > B) {
+ body.y = B;
+ if (v.y > 0) v.y *= -this.wallBounce;
+ }
+ }
+
+ // ----------------------------------------------------- damage / lifecycle
+
+ damagePlayer(n) {
+ if (this.playerDead) return;
+ this.lastHitAt = this.time.now;
+ const armor = { hull: this.hull, shields: this.shields };
+ const { dead } = applyDamage(armor, n);
+ this.hull = armor.hull;
+ this.shields = armor.shields;
+ this.fx.flash(this.ship.x, this.ship.y, 0xffffff, 60, 140);
+ if (dead) this.killPlayer();
+ }
+
+ killPlayer() {
+ this.playerDead = true;
+ this.hull = 0;
+ this.fx.explode(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff));
+ this.ship.stop();
+ this.ship.setVisible(false);
+ if (config.get('combat.sandbox.respawn', true)) {
+ this.respawnAt = this.time.now + (Number(config.get('combat.sandbox.respawnDelay', 1.6)) || 1.6) * 1000;
+ }
+ }
+
+ maybeRespawn(time) {
+ if (!config.get('combat.sandbox.respawn', true) || time < this.respawnAt) return;
+ this.hull = this.maxHull;
+ this.shields = this.maxShields;
+ this.ship.x = this.w * 0.24;
+ this.ship.y = this.h / 2;
+ this.ship.rotation = 0;
+ this.ship.stop();
+ this.ship.setVisible(true);
+ this.playerDead = false;
+ this.clearBullets();
+ this.fx.flash(this.ship.x, this.ship.y, themeColor('neon', 0x00e5ff), 70, 260);
+ }
+
+ clearBullets() {
+ for (const b of this.bullets) b.consume();
+ this.bullets.length = 0;
+ }
+
+ // ------------------------------------------------------------------ hud
+
+ drawWalls() {
+ const w = this.w;
+ const h = this.h;
+ const inset = this.inset;
+ const faint = toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x1b2c4a);
+ const glow = toColor(config.get('combat.arena.wallGlow', '#00e5ff'), 0x00e5ff);
+
+ const g = this.add.graphics().setDepth(1);
+ g.lineStyle(3, glow, 0.14);
+ g.strokeRect(inset, inset, w - 2 * inset, h - 2 * inset);
+ g.lineStyle(1, faint, 0.95);
+ g.strokeRect(inset, inset, w - 2 * inset, h - 2 * inset);
+
+ // Corner accents (the MenuScene chrome idiom: bright L-brackets).
+ const L = 26;
+ const corners = [
+ [inset, inset, 1, 1],
+ [w - inset, inset, -1, 1],
+ [inset, h - inset, 1, -1],
+ [w - inset, h - inset, -1, -1],
+ ];
+ g.lineStyle(2, glow, 0.55);
+ for (const [cx, cy, sx, sy] of corners) {
+ g.lineBetween(cx + sx * L, cy, cx, cy);
+ g.lineBetween(cx, cy, cx, cy + sy * L);
+ }
+ }
+
+ createHud() {
+ const w = this.w;
+ const h = this.h;
+ const hud = config.section('combat.hud', {});
+ const headerFont = fontStack('header', FONT_FALLBACK);
+ const bodyFont = fontStack('body', FONT_FALLBACK);
+ const ink = themeColor('ink', 0xeaf6ff);
+ const dim = themeColor('dim', 0x8aa0c0);
+ const faint = themeColor('faint', 0x55688a);
+ const neon = themeColor('neon', 0x00e5ff);
+ const amber = themeColor('amber', 0xffc94d);
+
+ const D = 100; // above everything in the arena
+
+ this.add
+ .text(w - 28, 16, hud.title ?? 'COMBAT // SANDBOX', {
+ fontFamily: headerFont, fontSize: '13px', color: toCss(dim, '#8aa0c0'),
+ letterSpacing: 4,
+ })
+ .setOrigin(1, 0).setDepth(D);
+
+ this.killsText = this.add
+ .text(w - 28, 40, 'KILLS 0', {
+ fontFamily: bodyFont, fontSize: '20px', color: toCss(amber, '#ffc94d'),
+ letterSpacing: 2,
+ })
+ .setOrigin(1, 0).setDepth(D);
+
+ // The armor panel (top-left): HULL bar + SHD bar + live numbers.
+ const pw = 330;
+ const ph = 96;
+ const px = 28;
+ const py = 28;
+ this.add
+ .graphics()
+ .setDepth(D)
+ .fillStyle(0x0a1120, 0.72)
+ .fillRoundedRect(px, py, pw, ph, 8)
+ .lineStyle(1, toColor(config.get('combat.arena.wallColor', '#1b2c4a'), 0x22405f), 0.9)
+ .strokeRoundedRect(px, py, pw, ph, 8);
+
+ const lx = px + 16; // label x
+ const bx = px + 16; // bar x (left edge)
+ const bw = 210; // bar width
+ const numX = bx + bw + 14;
+
+ this.add.text(lx, py + 22, 'HULL', {
+ fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
+ }).setOrigin(0, 0.5).setDepth(D);
+ this.hullBarBg = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, 0x101a2c).setDepth(D);
+ this.hullBar = this.add.rectangle(bx + bw / 2, py + 40, bw, 9, amber).setDepth(D);
+ this.hullText = this.add.text(numX, py + 40, '100', {
+ fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
+ }).setOrigin(0, 0.5).setDepth(D);
+
+ this.add.text(lx, py + 62, 'SHD', {
+ fontFamily: bodyFont, fontSize: '11px', color: toCss(faint, '#55688a'), letterSpacing: 2,
+ }).setOrigin(0, 0.5).setDepth(D);
+ this.shdBarBg = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, 0x101a2c).setDepth(D);
+ this.shdBar = this.add.rectangle(bx + bw / 2, py + 80, bw, 9, neon).setDepth(D);
+ this.shdText = this.add.text(numX, py + 80, '40', {
+ fontFamily: bodyFont, fontSize: '15px', color: toCss(ink, '#eaf6ff'),
+ }).setOrigin(0, 0.5).setDepth(D);
+
+ // The control line (bottom-center).
+ this.add
+ .text(w / 2, h - 12, hud.hints ?? '', {
+ fontFamily: bodyFont, fontSize: '12px', color: toCss(faint, '#55688a'), letterSpacing: 1,
+ })
+ .setOrigin(0.5, 1).setDepth(D);
+
+ // Center message (SIGNAL LOST / PAUSED).
+ this.msgText = this.add
+ .text(w / 2, h / 2 - 12, '', {
+ fontFamily: headerFont, fontSize: '44px', color: toCss(neon, '#00e5ff'), letterSpacing: 6,
+ })
+ .setOrigin(0.5).setAlpha(0).setDepth(D);
+ this.msgSub = this.add
+ .text(w / 2, h / 2 + 30, '', {
+ fontFamily: bodyFont, fontSize: '14px', color: toCss(dim, '#8aa0c0'), letterSpacing: 2,
+ })
+ .setOrigin(0.5).setAlpha(0).setDepth(D);
+ }
+
+ setBar(bar, frac) {
+ const w = Math.max(0, Math.min(1, frac)) * 210;
+ bar.width = w;
+ bar.x = 28 + 16 + w / 2; // left-anchored (rectangles are center-origin)
+ }
+
+ updateHud(time) {
+ const hullShown = Math.ceil(this.hull);
+ const shdShown = Math.ceil(this.shields);
+ if (this.lastHullShown !== hullShown) {
+ this.lastHullShown = hullShown;
+ this.hullText.setText(String(hullShown));
+ }
+ if (this.lastShdShown !== shdShown) {
+ this.lastShdShown = shdShown;
+ this.shdText.setText(String(shdShown));
+ }
+ this.setBar(this.hullBar, this.hull / this.maxHull);
+ this.setBar(this.shdBar, this.maxShields > 0 ? this.shields / this.maxShields : 0);
+
+ const killsShown = `KILLS ${this.kills}`;
+ if (this.lastKillsShown !== killsShown) {
+ this.lastKillsShown = killsShown;
+ this.killsText.setText(killsShown);
+ }
+
+ // The center message.
+ let msg = '';
+ let sub = '';
+ if (this.paused) {
+ msg = 'PAUSED';
+ sub = 'P — resume · R — reset range';
+ } else if (this.playerDead) {
+ msg = 'SIGNAL LOST';
+ sub = config.get('combat.sandbox.respawn', true)
+ ? `rebuilding in ${Math.max(0, (this.respawnAt - time) / 1000).toFixed(1)}s · R — reset range`
+ : 'R — reset range';
+ }
+ if (this.msgText) {
+ if (msg) {
+ this.msgText.setText(msg);
+ this.msgSub.setText(sub);
+ if (!this.msgText.visible) {
+ this.msgText.setVisible(true);
+ this.msgSub.setVisible(true);
+ }
+ } else if (this.msgText.visible) {
+ this.msgText.setVisible(false);
+ this.msgSub.setVisible(false);
+ }
+ }
+ }
+
+ // ----------------------------------------------------------------- misc
+
+ togglePause() {
+ this.paused = !this.paused;
+ // The engine steps the arcade world regardless of the scene (this
+ // build — verified in a headless probe), so a true freeze must pause
+ // the world itself; resume restores it.
+ if (this.physics && this.physics.world) {
+ this.physics.world.isPaused = this.paused;
+ }
+ }
+
+ /** Rebuild the whole range (fresh scene — the sandbox reset). */
+ reset() {
+ this.scene.restart();
+ }
+}
diff --git a/js/combat/EnemyShip.js b/js/combat/EnemyShip.js
new file mode 100644
index 0000000..da1ed5c
--- /dev/null
+++ b/js/combat/EnemyShip.js
@@ -0,0 +1,217 @@
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toColor } from '../utils/Color.js';
+import { applyDamage, headingOf, orbitStep } from './Combat.js';
+
+const FALLBACK_W = 76; // the procedural wedge (it faces east)
+const FALLBACK_H = 46;
+
+/**
+ * A sandbox hostile (data/combat.json → enemy): an arcade-physics sprite
+ * that keeps a standoff ring around the player, circles it (direction
+ * alternates per craft), and lances when in range and roughly aimed.
+ * The AI is deliberately dumb — the sandbox (js/combat/CombatScene.js)
+ * is where it gets tuned before combat ships in the real game.
+ *
+ * Art: assets/images/ships-enemy.png — frameWidth×frameHeight frames,
+ * `frame` picks which one (frame 0 = the red triple-boost fighter, art
+ * facing north, filling its frame). The art's facing works exactly like
+ * the player's (js/entities/Ship.js): a constant render offset
+ * (this.artOffset) so heading math stays world-up = -π/2. If the sheet
+ * isn't configured or didn't load, the built-in wedge is drawn instead
+ * (it faces east, offset 0).
+ *
+ * Armor: { shields, hull } per js/combat/Combat.js (shields absorb
+ * first). damage() applies a hit; the scene owns the FX + respawn.
+ */
+export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
+ static TEXTURE_KEY = '__enemy_ship';
+
+ /** World angle (rad) for each direction the sheet art may face. */
+ static FACING_ANGLE = {
+ north: -Math.PI / 2,
+ east: 0,
+ south: Math.PI / 2,
+ west: Math.PI,
+ };
+
+ /**
+ * Ensure a texture exists under TEXTURE_KEY. The spritesheet (loaded
+ * by CombatScene.preload / GameScene.preload) wins; otherwise the
+ * built-in wedge — a blunt V-fin raider, clearly NOT the player dart.
+ */
+ static ensureTexture(scene) {
+ if (scene.textures.exists(EnemyShip.TEXTURE_KEY)) return;
+
+ const w = FALLBACK_W;
+ const h = FALLBACK_H;
+ const hull = toColor('#c8465c', 0xc8465c);
+ const trim = toColor('#f0b7c2', 0xf0b7c2);
+ const g = scene.make.graphics({ add: false });
+ g.fillStyle(hull, 1);
+ g.beginPath();
+ g.moveTo(w - 4, h / 2); // nose (east)
+ g.lineTo(10, 2); // upper fin
+ g.lineTo(22, h / 2); // tail notch
+ g.lineTo(10, h - 2); // lower fin
+ g.closePath();
+ g.fillPath();
+ g.fillStyle(trim, 1);
+ g.fillCircle(Math.round(w - 20), h / 2, 4); // cockpit
+ g.generateTexture(EnemyShip.TEXTURE_KEY, w, h);
+ g.destroy();
+ }
+
+ /**
+ * @param {object} scene the combat scene
+ * @param {number} x spawn
+ * @param {number} y spawn
+ * @param {number} index the craft's slot in the pack (orbit direction alternates)
+ */
+ constructor(scene, x, y, index) {
+ const hasSheet = scene.textures.exists(EnemyShip.TEXTURE_KEY);
+ EnemyShip.ensureTexture(scene);
+ super(scene, x, y, EnemyShip.TEXTURE_KEY, config.get('combat.enemy.frame', 0));
+
+ scene.add.existing(this);
+ scene.physics.add.existing(this);
+
+ this.index = index ?? 0;
+ this.orbitDir = this.index % 2 === 0 ? 1 : -1;
+ this.orbitAngle = Math.random() * Math.PI * 2; // the scene re-anchors it on spawn
+
+ // World size + render scale (the same contract as Ship: size × the
+ // art's footprint = the hull's world length).
+ this.size = Math.max(4, config.get('combat.enemy.size', 54));
+ const frameWidth = hasSheet
+ ? Math.max(1, config.get('combat.enemy.frameWidth', 128))
+ : FALLBACK_W;
+ this.setScale(this.size / frameWidth);
+ const facing = EnemyShip.FACING_ANGLE[config.get('combat.enemy.artFacing', 'east')] ?? 0;
+ this.artOffset = hasSheet ? -facing : 0;
+
+ // A circle body (world px — data/combat.json → enemy.hitRadius):
+ // the scene's collision pass uses it for rams + bullets.
+ this.hitRadius = Math.max(4, config.get('combat.enemy.hitRadius', 20));
+ const body = this.body;
+ body.setCircle(this.hitRadius / this.scale); // texture-space radius (auto-scales)
+ body.setAllowGravity(false);
+
+ // Armor (js/combat/Combat.js rule: shields first, then hull).
+ this.maxHull = Math.max(1, config.get('combat.enemy.hull', 60));
+ this.maxShields = Math.max(0, config.get('combat.enemy.shields', 0));
+ this.hull = this.maxHull;
+ this.shields = this.maxShields;
+ this.dead = false;
+
+ // Chase feel (data/combat.json → enemy).
+ this.speed = Math.max(0, config.get('combat.enemy.speed', 165));
+ this.turn = Math.max(0.1, config.get('combat.enemy.turn', 3.2));
+ this.orbitRadius = Math.max(40, config.get('combat.enemy.orbitRadius', 300));
+ this.orbitSpeed = config.get('combat.enemy.orbitSpeed', 0.6);
+
+ // Lance (data/combat.json → enemy.weapon).
+ this.wpn = config.section('combat.enemy.weapon', {});
+ this.nextFireAt = 0; // ms — the scene grants a short grace on spawn
+
+ // The last player position (orbiting it while the player is down).
+ this.lastPx = x;
+ this.lastPy = y;
+ }
+
+ /** @returns {number} the render heading in world radians */
+ heading() {
+ return headingOf(this);
+ }
+
+ /** Rotate the render toward `angle` at the configured turn rate. */
+ rotateToward(angle, dt) {
+ let delta = Phaser.Math.Angle.Wrap(angle - this.rotation);
+ const step = this.turn * dt;
+ if (Math.abs(delta) <= step) this.rotation = angle;
+ else this.rotation += Math.sign(delta) * step;
+ }
+
+ /**
+ * Per-frame AI. Steers the standoff ring around the player, faces it
+ * when close enough to aim, and asks to fire when lined up.
+ *
+ * @param {number} time game time (ms)
+ * @param {number} delta frame delta (ms)
+ * @param {object|null} player the player ship, or null when the player is down
+ * @returns {boolean} true when this craft wants to fire THIS frame
+ */
+ update(time, delta, player) {
+ if (this.dead) return false;
+ const dt = Math.min(delta, 64) / 1000;
+
+ const px = player ? player.x : this.lastPx;
+ const py = player ? player.y : this.lastPy;
+ if (player) {
+ this.lastPx = px;
+ this.lastPy = py;
+ }
+
+ // The standoff ring: circle the player at orbitRadius.
+ const t = orbitStep(px, py, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, this.orbitRadius);
+ this.orbitAngle = t.angle;
+
+ const dx = t.x - this.x;
+ const dy = t.y - this.y;
+ const d = Math.hypot(dx, dy) || 1;
+ const dist = Math.hypot(px - this.x, py - this.y);
+
+ // Chase speed: full when far off the ring, half when crowding in.
+ const sp = d > this.orbitRadius * 0.9 ? this.speed : this.speed * 0.55;
+
+ // Exponential velocity approach (smooth turns, no overshoot).
+ const k = 1 - Math.exp(-this.turn * dt);
+ const body = this.body;
+ if (body) {
+ body.velocity.x += ((dx / d) * sp - body.velocity.x) * k;
+ body.velocity.y += ((dy / d) * sp - body.velocity.y) * k;
+ }
+
+ // Face the player when close (aiming), else face the motion.
+ const toPlayer = Math.atan2(py - this.y, px - this.x);
+ const want = dist < this.orbitRadius * 1.5
+ ? toPlayer
+ : (body && body.velocity.length() > 4 ? Math.atan2(body.velocity.y, body.velocity.x) : toPlayer);
+ this.rotateToward(want + this.artOffset, dt);
+
+ // Lance? In range, roughly aimed, off cooldown — and only at a live player.
+ const wpn = this.wpn ?? {};
+ const range = Number(wpn.range) || 0;
+ if (player && range > 0 && dist < range && time >= this.nextFireAt) {
+ const lead = Math.abs(Phaser.Math.Angle.Wrap(toPlayer - this.heading()));
+ if (lead < 0.6) {
+ this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Take a hit: shields first, then hull (js/combat/Combat.js).
+ * @param {number} n damage
+ * @returns {boolean} true when this shot killed the craft
+ */
+ damage(n) {
+ if (this.dead) return false;
+ const { dead } = applyDamage(this, n);
+ if (dead) this.dead = true;
+ return dead;
+ }
+
+ /** Bring the craft back to full (the scene re-positions + re-arms it). */
+ revive() {
+ this.hull = this.maxHull;
+ this.shields = this.maxShields;
+ this.dead = false;
+ if (this.body) {
+ this.body.velocity.x = 0;
+ this.body.velocity.y = 0;
+ }
+ }
+}
diff --git a/js/combat/Fx.js b/js/combat/Fx.js
new file mode 100644
index 0000000..0b3629e
--- /dev/null
+++ b/js/combat/Fx.js
@@ -0,0 +1,134 @@
+import Phaser from '../vendor/phaser.js';
+import { toColor } from '../utils/Color.js';
+import { canvasTexture } from '../utils/Textures.js';
+
+const DOT_KEY = '__combat_dot';
+const DOT_SIZE = 64;
+
+function drawSoftDot(ctx, size) {
+ const c = size / 2;
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
+ g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
+ g.addColorStop(0.22, 'rgba(255,255,255,0.85)');
+ g.addColorStop(0.55, 'rgba(255,255,255,0.26)');
+ g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, size, size);
+}
+
+/**
+ * Small hand-rolled FX for the combat range: muzzle flashes, hit glows,
+ * spark streams, and the ship-kill burst. Plain additive images stepped
+ * in update() — deliberately NOT the particles emitter (this build's
+ * emitter velocity semantics are fiddly, see the note in
+ * js/visuals/EngineFlare.js), so the look is deterministic, cheap, and
+ * needs no tween stepping.
+ *
+ * All items are world-space (the arena camera is fixed), depths 19–21
+ * (above ships (10), under the HUD (100)).
+ */
+export class Fx {
+ /** @param {object} scene the combat scene */
+ constructor(scene) {
+ this.scene = scene;
+ this.items = []; // { img, t0, life, kind, ... }
+ Fx.ensureDot(scene);
+ }
+
+ /** The shared white soft dot (tint reads as its color). @returns {string} the key */
+ static ensureDot(scene) {
+ if (scene.textures.exists(DOT_KEY)) return DOT_KEY;
+ canvasTexture(scene, DOT_KEY, DOT_SIZE, DOT_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h)));
+ return DOT_KEY;
+ }
+
+ /** An additive glow: `size` px across at full strength, fading over `life` ms. */
+ flash(x, y, color, size = 40, life = 220) {
+ const img = this.scene
+ .add.image(x, y, DOT_KEY)
+ .setTint(toColor(color, 0xffffff))
+ .setBlendMode(Phaser.BlendModes.ADD)
+ .setDepth(20);
+ this.items.push({ img, t0: 0, life, kind: 'flash', grow: size / DOT_SIZE });
+ return img;
+ }
+
+ /** A growing, fading disc — the shockwave read (a stand-in for a stroke ring). */
+ ring(x, y, color, size = 180, life = 450) {
+ const img = this.scene
+ .add.image(x, y, DOT_KEY)
+ .setTint(toColor(color, 0xffffff))
+ .setBlendMode(Phaser.BlendModes.ADD)
+ .setDepth(19)
+ .setScale(6 / DOT_SIZE)
+ .setAlpha(0.8);
+ this.items.push({ img, t0: 0, life, kind: 'ring', from: 6 / DOT_SIZE, to: size / DOT_SIZE });
+ return img;
+ }
+
+ /** A burst of sparks (small dots on random headings, decaying). */
+ sparks(x, y, color, count = 12, speed = 220, life = 500) {
+ for (let i = 0; i < count; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const sp = speed * (0.4 + Math.random() * 0.8);
+ const s = 4 + Math.random() * 5;
+ const img = this.scene
+ .add.image(x, y, DOT_KEY)
+ .setTint(toColor(color, 0xffffff))
+ .setBlendMode(Phaser.BlendModes.ADD)
+ .setDepth(21)
+ .setScale(s / DOT_SIZE);
+ this.items.push({
+ img,
+ t0: 0,
+ life: life * (0.6 + Math.random() * 0.6),
+ kind: 'spark',
+ vx: Math.cos(a) * sp,
+ vy: Math.sin(a) * sp,
+ });
+ }
+ }
+
+ /** The big one — a ship's death: core flash + colored bloom + wave + sparks. */
+ explode(x, y, color) {
+ this.flash(x, y, 0xffffff, 90, 160);
+ this.flash(x, y, color, 150, 420);
+ this.ring(x, y, color, 200, 480);
+ this.sparks(x, y, color, 22, 300, 700);
+ }
+
+ /** Step every live item (ms). Dead ones are destroyed. */
+ update(deltaMs) {
+ const dt = deltaMs / 1000;
+ for (let i = this.items.length - 1; i >= 0; i--) {
+ const it = this.items[i];
+ it.t0 += deltaMs;
+ const t = Math.min(1, it.t0 / it.life);
+ if (t >= 1) {
+ it.img.destroy();
+ this.items.splice(i, 1);
+ continue;
+ }
+ if (it.kind === 'flash') {
+ it.img.setScale(it.grow * (0.6 + 0.4 * t));
+ it.img.setAlpha(1 - t);
+ } else if (it.kind === 'ring') {
+ it.img.setScale(it.from + (it.to - it.from) * t);
+ it.img.setAlpha(0.8 * (1 - t));
+ } else if (it.kind === 'spark') {
+ it.img.x += it.vx * dt;
+ it.img.y += it.vy * dt;
+ const decay = Math.max(0, 1 - 2.2 * dt);
+ it.vx *= decay;
+ it.vy *= decay;
+ it.img.setAlpha(1 - t);
+ }
+ }
+ }
+
+ /** Drop everything (a range reset). */
+ clear() {
+ for (const it of this.items) it.img.destroy();
+ this.items.length = 0;
+ }
+}
diff --git a/js/combat/StarDrift.js b/js/combat/StarDrift.js
new file mode 100644
index 0000000..14e4ebd
--- /dev/null
+++ b/js/combat/StarDrift.js
@@ -0,0 +1,85 @@
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toColor } from '../utils/Color.js';
+import { canvasTexture } from '../utils/Textures.js';
+
+const STAR_KEY = '__combat_star';
+
+/**
+ * A slow diagonal-drifting starfield for the FIXED combat arena.
+ *
+ * The main game's Starfield (js/visuals/Starfield.js) is camera-driven —
+ * here the camera never moves, so the stars drift on their own and wrap
+ * at the screen edges, with a gentle per-star twinkle. Count + colors
+ * reuse data/game.json → starfield so the range matches the game sky.
+ */
+export class StarDrift {
+ /** @param {object} scene the combat scene */
+ constructor(scene) {
+ this.scene = scene;
+ const cfg = config.get('game.starfield', {});
+ this.enabled = cfg.enabled !== false;
+ this.count = Math.max(0, cfg.count ?? 140);
+ this.colors = (cfg.colors ?? ['#ffffff']).map((c) => toColor(c, 0xffffff));
+ this.speed = 4; // px/s diagonal drift (the arena's ambient current)
+ this.stars = [];
+ this.vx = 0;
+ this.vy = 0;
+ }
+
+ /** Lay the stars down across the screen. */
+ create(w, h) {
+ if (!this.enabled) return;
+ const scene = this.scene;
+ if (!scene.textures.exists(STAR_KEY)) {
+ canvasTexture(scene, STAR_KEY, 8, 8, (ctx, w, h) => {
+ const c = Math.min(w, h) / 2;
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
+ g.addColorStop(0, 'rgba(255,255,255,1)');
+ g.addColorStop(0.6, 'rgba(255,255,255,0.7)');
+ g.addColorStop(1, 'rgba(255,255,255,0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, w, h);
+ });
+ }
+
+ const driftAngle = -0.6; // rad — a slow down-right current
+ this.vx = Math.cos(driftAngle) * this.speed;
+ this.vy = Math.sin(driftAngle) * this.speed;
+
+ for (let i = 0; i < this.count; i++) {
+ const star = scene
+ .add.image(Phaser.Math.FloatBetween(0, w), Phaser.Math.FloatBetween(0, h), STAR_KEY)
+ .setScale(Phaser.Math.FloatBetween(0.25, 1.1))
+ .setAlpha(Phaser.Math.FloatBetween(0.2, 0.8))
+ .setTint(this.colors[i % this.colors.length] ?? 0xffffff)
+ .setDepth(0);
+ star.baseAlpha = star.alpha;
+ star.tw = Phaser.Math.FloatBetween(0.5, 2.2); // twinkle rate (rad/s)
+ star.ph = Math.random() * Math.PI * 2;
+ this.stars.push(star);
+ }
+ }
+
+ /** Drift + wrap + twinkle. @param {number} time game ms @param {number} deltaMs */
+ update(time, deltaMs) {
+ if (!this.enabled || this.stars.length === 0) return;
+ const dt = deltaMs / 1000;
+ const w = this.scene.scale.width;
+ const h = this.scene.scale.height;
+ for (const star of this.stars) {
+ star.x += this.vx * dt;
+ star.y += this.vy * dt;
+ if (star.x < 0) star.x += w;
+ else if (star.x > w) star.x -= w;
+ if (star.y < 0) star.y += h;
+ else if (star.y > h) star.y -= h;
+ star.alpha = star.baseAlpha * (0.72 + 0.28 * Math.sin(time * 0.001 * star.tw + star.ph));
+ }
+ }
+
+ destroy() {
+ for (const star of this.stars) star.destroy();
+ this.stars.length = 0;
+ }
+}
diff --git a/js/combat/main.js b/js/combat/main.js
new file mode 100644
index 0000000..a3b98b4
--- /dev/null
+++ b/js/combat/main.js
@@ -0,0 +1,32 @@
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { ConfigLoader } from '../config/ConfigLoader.js';
+import { createGameConfig } from '../config/GameConfig.js';
+import { CombatScene } from './CombatScene.js';
+import { awaitFonts } from '../utils/Fonts.js';
+
+/**
+ * The combat sandbox boot (combat.html / the dev server's /combat /
+ * index.html?combat): the SAME config manifest as the game (so
+ * data/combat.json, ship.json, theme.json, game.json are all loaded and
+ * consistent), but the scene list is ONLY CombatScene — no menu, no
+ * galaxy, no save. This is the standalone range where combat is tuned
+ * before it ships in the real game (data/combat.json holds every number).
+ */
+export async function bootCombat() {
+ const data = await ConfigLoader.load();
+ config.init(data);
+ console.info('orbit — combat sandbox: config loaded', Object.keys(data).join(', '));
+ await awaitFonts();
+
+ const gameConfig = createGameConfig();
+ gameConfig.scene = [CombatScene];
+ const game = new Phaser.Game(gameConfig);
+
+ window.game = game;
+ window.config = config;
+ console.info(
+ 'orbit — combat sandbox armed · WASD fly · SPACE fire · R reset · P pause · ' +
+ 'tune data/combat.json · window.__COMBAT__ = { reset, spawnEnemy, player, kills }',
+ );
+}
diff --git a/js/main.js b/js/main.js
index fc7fad3..551d71f 100644
--- a/js/main.js
+++ b/js/main.js
@@ -7,6 +7,8 @@ import { GameScene } from './scenes/GameScene.js';
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.
@@ -16,23 +18,6 @@ import { installNavDiag } from './dev/NavDiag.js';
* 3. Build the Phaser game config and attach the scenes
* 4. Boot
*/
-/**
- * Wait for the web fonts (data/theme.json) to be ready before the first
- * draw, so canvas text uses Ethnocentric/Centauri from frame one.
- * Hard timeout so boot can never hang on a flaky asset — we just fall
- * back to the stack's system fonts in that case.
- */
-async function awaitFonts(timeoutMs = 2000) {
- const fonts = globalThis.document?.fonts;
- if (!fonts || typeof fonts.load !== 'function') return;
- const families = ['header', 'body']
- .map((k) => config.get(`theme.fonts.${k}.family`))
- .filter((f) => typeof f === 'string' && f.length > 0);
- if (families.length === 0) return;
- const jobs = families.map((fam) => fonts.load(`16px "${fam}"`).catch(() => {}));
- const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
- await Promise.race([Promise.allSettled(jobs), timeout]);
-}
async function boot() {
const data = await ConfigLoader.load();
@@ -67,11 +52,27 @@ async function boot() {
);
}
-boot().catch((err) => {
+// ---------------------------------------------------------------------
+// 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.
+
+function showBootError(err) {
console.error(err);
const pre = document.createElement('pre');
pre.style.cssText =
'color:#ff9b9b;font:14px/1.6 monospace;padding:2rem;white-space:pre-wrap;margin:0';
pre.textContent = `Failed to start Orbit:\n${(err && (err.stack || err.message)) || 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);
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 8603bc4..5bd4aa9 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -12,6 +12,7 @@ import { canonicalPlanetName } from '../utils/WorldNames.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
+import { EnemyShip } from '../combat/EnemyShip.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
@@ -149,6 +150,22 @@ export class GameScene extends Phaser.Scene {
);
}
+ // The ENEMY ship spritesheet (data/combat.json → enemy.texture):
+ // the hostiles' art for the combat sandbox (js/combat/, combat.html).
+ // Loaded here too — the texture is available in the main game for
+ // when combat ships in; the sandbox is where the numbers get tuned.
+ const enemyTexture = config.get('combat.enemy.texture', '');
+ if (enemyTexture) {
+ this.load.spritesheet(
+ EnemyShip.TEXTURE_KEY,
+ enemyTexture,
+ {
+ frameWidth: config.get('combat.enemy.frameWidth', 128),
+ frameHeight: config.get('combat.enemy.frameHeight', 128),
+ },
+ );
+ }
+
// The asteroid spritesheet (data/asteroids.json → texture): 128×128
// rock frames, one per cluster member (the generator picks frames).
const asteroidTexture = config.get('asteroids.texture', '');
diff --git a/js/utils/Fonts.js b/js/utils/Fonts.js
new file mode 100644
index 0000000..98395ab
--- /dev/null
+++ b/js/utils/Fonts.js
@@ -0,0 +1,22 @@
+import { config } from '../config/Config.js';
+
+/**
+ * Wait for the web fonts (data/theme.json) to be ready before the first
+ * draw, so canvas text uses Ethnocentric/Centauri from frame one.
+ * Hard timeout so boot can never hang on a flaky asset — we just fall
+ * back to the stack's system fonts in that case.
+ *
+ * Shared by the game boot (js/main.js) and the combat sandbox boot
+ * (js/combat/main.js) — the two used to each carry a copy.
+ */
+export async function awaitFonts(timeoutMs = 2000) {
+ const fonts = globalThis.document?.fonts;
+ if (!fonts || typeof fonts.load !== 'function') return;
+ const families = ['header', 'body']
+ .map((k) => config.get(`theme.fonts.${k}.family`))
+ .filter((f) => typeof f === 'string' && f.length > 0);
+ if (families.length === 0) return;
+ const jobs = families.map((fam) => fonts.load(`16px "${fam}"`).catch(() => {}));
+ const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
+ await Promise.race([Promise.allSettled(jobs), timeout]);
+}