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:
parent
4b8ddae8d9
commit
15fe08a926
17
README.md
17
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),
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"vignettes.json",
|
||||
"goods.json",
|
||||
"economy.json",
|
||||
"shopui.json"
|
||||
"shopui.json",
|
||||
"combat.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }',
|
||||
);
|
||||
}
|
||||
39
js/main.js
39
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);
|
||||
|
|
|
|||
|
|
@ -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', '');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
Loading…
Reference in New Issue