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