From 15fe08a926ed7a0730da5b5280ab63df9a655b4a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 15 Sep 2026 20:54:33 -0600 Subject: [PATCH] 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. --- README.md | 17 +- assets/images/ships-enemy.png | Bin 0 -> 11350 bytes assets/images/ships-enemy.psd | Bin 0 -> 174998 bytes combat.html | 27 ++ data/combat.json | 86 ++++ data/manifest.json | 3 +- dev/combat.test.mjs | 232 +++++++++++ dev/server.mjs | 4 + js/combat/Bullet.js | 90 ++++ js/combat/Combat.js | 60 +++ js/combat/CombatScene.js | 743 ++++++++++++++++++++++++++++++++++ js/combat/EnemyShip.js | 217 ++++++++++ js/combat/Fx.js | 134 ++++++ js/combat/StarDrift.js | 85 ++++ js/combat/main.js | 32 ++ js/main.js | 39 +- js/scenes/GameScene.js | 17 + js/utils/Fonts.js | 22 + 18 files changed, 1786 insertions(+), 22 deletions(-) create mode 100644 assets/images/ships-enemy.png create mode 100644 assets/images/ships-enemy.psd create mode 100644 combat.html create mode 100644 data/combat.json create mode 100644 dev/combat.test.mjs create mode 100644 js/combat/Bullet.js create mode 100644 js/combat/Combat.js create mode 100644 js/combat/CombatScene.js create mode 100644 js/combat/EnemyShip.js create mode 100644 js/combat/Fx.js create mode 100644 js/combat/StarDrift.js create mode 100644 js/combat/main.js create mode 100644 js/utils/Fonts.js 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 0000000000000000000000000000000000000000..accfa7eec70de6c9b45034f685d80a39e76e24f9 GIT binary patch literal 11350 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 literal 0 HcmV?d00001 diff --git a/assets/images/ships-enemy.psd b/assets/images/ships-enemy.psd new file mode 100644 index 0000000000000000000000000000000000000000..e6fc0e684cb3cb3ff859400e10b61dfc1c14ae61 GIT binary patch literal 174998 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 literal 0 HcmV?d00001 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]); +}