Add combat sandbox: standalone arena to tune combat before it ships

- New `combat.html` + `/combat` dev-server route + `index.html?combat`
  boot a fixed arena where the player ship fights a pack of enemy craft;
  all tuning lives in `data/combat.json` (thrust, hulls, shields, lance,
  rams, respawns).
- Add `js/combat/` modules: CombatScene, EnemyShip (standoff-ring AI +
  lance), Bullet, Fx, StarDrift, and pure rules in Combat.js (armor
  damage, orbit step) exercised by the new `dev/combat.test.mjs`.
- Extract shared `awaitFonts` into `js/utils/Fonts.js` so both the game
  and sandbox boots use one copy.
- Preload the enemy spritesheet in GameScene so the texture is ready when
  combat ships in; add `ships-enemy.png` (+ `.psd`) art.
- Expose `window.__COMBAT__` (reset, spawnEnemy, player, kills) for
  devtools tuning; R resets the range, P freezes it.
This commit is contained in:
Brian Fertig 2026-09-15 20:54:33 -06:00
parent 4b8ddae8d9
commit 15fe08a926
18 changed files with 1786 additions and 22 deletions

View File

@ -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),

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

27
combat.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Orbit // Combat Sandbox</title>
<style>
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module">
import { bootCombat } from './js/combat/main.js';
bootCombat().catch((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 the combat sandbox:\n${(err && (err.stack || err.message)) || err}`;
document.body.appendChild(pre);
});
</script>
</body>
</html>

86
data/combat.json Normal file
View File

@ -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"
}
}

View File

@ -35,6 +35,7 @@
"vignettes.json",
"goods.json",
"economy.json",
"shopui.json"
"shopui.json",
"combat.json"
]
}

232
dev/combat.test.mjs Normal file
View File

@ -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);

View File

@ -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) {

90
js/combat/Bullet.js Normal file
View File

@ -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;
}
}

60
js/combat/Combat.js Normal file
View File

@ -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,
};
}

743
js/combat/CombatScene.js Normal file
View File

@ -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();
}
}

217
js/combat/EnemyShip.js Normal file
View File

@ -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;
}
}
}

134
js/combat/Fx.js Normal file
View File

@ -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;
}
}

85
js/combat/StarDrift.js Normal file
View File

@ -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;
}
}

32
js/combat/main.js Normal file
View File

@ -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 }',
);
}

View File

@ -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);

View File

@ -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', '');

22
js/utils/Fonts.js Normal file
View File

@ -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]);
}