diff --git a/README.md b/README.md index e35ebfe..943bb3a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,48 @@ respawns, the bar — lives in **`data/combat.json`**: `window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn, damagePlayer, cast }`. +- **Feel** — the handling is what the range tunes. Every feel number + (thrust, top speed, drag, turn rate + turn response, throttle ramp/ + decay, brake) lives in `data/combat.json` under `combat.player`, and + both fronts run the same pure `handleFeel` integration + (`js/combat/Combat.js`) — so what you feel in the range is what the + main game ships. Thrust ramps in and decays out, turns ease on, and + the brake kills the velocity instantly. +- **Feedback — VFX** — every combat event has a distinct *visual* cue, + all procedural (`js/combat/Fx.js` — canvas dots / additive glows, no + new art): a tracer + muzzle flash on **firing**, a spark burst + + damage number on **impact**, a cyan flash + damage number on a + **shield hit**, an orange flash + damage number on a **hull hit**, a + big burst + double ring on an **enemy kill**, a DOWN burst when the + **player goes down**, and a REFORMED bloom on **respawn**. Shield + reads cyan and hull reads orange in both the flash and the number, so + a glance tells you which layer took the hit. +- **Feedback — SFX** — the same seven events each get a *distinct* voice + from the existing `data/sfx.json` set (no new sound files), through + one shared event→voice map + playback path (`js/combat/CombatSfx.js`) + so the two fronts can't drift: firing = `scan`, impact = `ui_click`, + shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04` + (one at random), down = `deconstruct`, respawn = `discovery`. Enemy + fire reuses the `scan` voice, quieter. +- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure + rules in `js/combat/Combat.js`) makes the fight *readable* in both + fronts: a rotating **lock reticle** on the current target (the same + target the cannon auto-fires — one rule, one target), pulsing + **threat markers** over every live hostile, **off-screen arrows** at + hostiles outside the camera (the main game's camera follows the ship), + a **kill feed** (newest-first, top-right), and a **gauge flash** — the + combat bar's shields/hull gauge lights the moment it takes the hit. +- **Dev hooks + checkers** — besides the two pokes above, every SFX call + is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the + wiring is verifiable headless in both fronts. CDP checkers drive the + real game and read state back: `node dev/combat-flycheck.mjs` + (flight feel — t60/t30, spin, stop distance, top speed), + `node dev/combat.sfxcheck.mjs` (all seven SFX events fire through the + shared path, in both the range and the main game), and + `node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame + per context through the production paths into `dev/shots/` — the + committed `combat-range.png` + `combat-main.png` are that evidence). + ## Current state — v0.2: a seedable galaxy - Main menu with **Continue** (resumes the newest save — grayed out while @@ -336,7 +378,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, CombatSystem (pack, auto-fire, hits, rams, armor, respawn) — real data/combat.json +node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life, CombatSystem (pack, auto-fire, hits, rams, armor, respawn), ship FEEL (handleFeel), the SFX event→voice map, and the READABILITY rules (pickTarget, feed, off-screen arrows, the shared CombatHud) — real data/combat.json ``` `dev/test-game.html` boots straight into the GameScene (no menu click), diff --git a/dev/combat-shot.mjs b/dev/combat-shot.mjs new file mode 100644 index 0000000..261dd5e --- /dev/null +++ b/dev/combat-shot.mjs @@ -0,0 +1,218 @@ +/** + * Dev-only CDP screenshot harness for the COMBAT FEEDBACK set — drives + * the real game in BOTH combat contexts (the training range `/combat` and + * the main game `index.html?combat`), fires the combat events through the + * PRODUCTION paths (fire / impact / shield+hull / kill / player-hit), and + * captures a labeled frame per context so the new feedback (VFX + the + * readability layer) is on record. + * + * node dev/combat-shot.mjs [outDir] + * + * Requires the dev server (http://127.0.0.1:8080) and a headless + * Chromium-CDP browser on 127.0.0.1:9333 (same as dev/cdp-shot.mjs): + * /opt/brave.com/brave/brave --headless --disable-gpu \ + * --remote-debugging-port=9333 about:blank + * + * Outputs (committed as evidence under the given outDir, default + * dev/shots/): + * combat-range.png — /combat: lock reticle, threat cue, enemy-hit + * flash + damage number, kill burst + feed, + * player-hit flash + gauge flash + * combat-main.png — index.html?combat: the same set in the + * main game, with the real combat bar gauging + * + * Every event goes through the shared production code (CombatSfx / Fx / + * CombatHud), so the frame is a faithful picture of what a player sees. + */ +const fs = await import('node:fs'); +const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; +const BASE = process.env.BASE ?? 'http://127.0.0.1:8080'; +const OUT = process.argv[2] ?? 'dev/shots'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + fs.mkdirSync(OUT, { recursive: true }); + + // Open a fresh page target at `url`. + async function openPage(url) { + let target; + try { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + } catch { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); + ws.onmessage = (m) => { + const x = JSON.parse(m.data); + if (x.id && pending.has(x.id)) { + const { res, rej } = pending.get(x.id); + pending.delete(x.id); + x.error ? rej(new Error(x.error.message)) : res(x.result); + } + }; + await send('Runtime.enable'); + await send('Page.enable'); + await send('Network.enable'); + await send('Network.setCacheDisabled', { cacheDisabled: true }); + await send('Page.navigate', { url }); + const ev = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; + }; + const waitTrue = async (expr, timeoutMs = 45000) => { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + const v = await ev(expr).catch(() => null); + if (v) return v; + await sleep(250); + } + throw new Error('timed out waiting for: ' + expr.slice(0, 60)); + }; + const shot = async (path, delayMs = 110) => { + await sleep(delayMs); // let the last paint land (flash near peak, numbers mid-rise) + const r = await send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path, Buffer.from(r.data, 'base64')); + console.log(` shot: ${path}`); + }; + const close = async () => { + await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); + ws.close(); + }; + return { ev, waitTrue, shot, close, target }; + } + + // ------------------------------------------------------------ the range + { + console.log('range (/combat):'); + const p = await openPage(`${BASE}/combat`); + try { + await p.waitTrue('Boolean(window.game && window.game.scene.getScene("CombatScene"))'); + await sleep(1200); // let the pack settle into its ring + await p.ev(`(async () => { + const s = window.game.scene.getScene('CombatScene'); + // A live target on the current-target rule (the lock + threat cue show it). + const e = s.enemies.find((x) => !x.dead) || (s.spawnEnemy(s.w / 2, s.h / 2, 99), s.enemies[s.enemies.length - 1]); + // Fan the cannon at it through the real fire path (the shared weapon). + const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json(); + const wpn = cfg.player.weapon; + const a = Math.atan2(e.y - s.ship.y, e.x - s.ship.x); + for (let i = -3; i <= 3; i++) s.firePlayer(a + i * 0.05, wpn); + // A guaranteed enemy hit through the real collide path: spawn a + // tracer ON the enemy so the first frame's swept segment crosses it. + const e2 = s.enemies.find((x) => !x.dead); + if (e2) s.spawnBullet(e2.x, e2.y, a, wpn, 'player'); + return true; + })()`); + // Wait for the fan to land (the SFX dev log records an + // impact/shield/hull voice — it stays true once it has fired) so + // the enemy-hit feedback is live, then finish the cue set + capture. + await p.waitTrue(`(() => { + const log = window.__COMBAT_SFX__?.log ?? []; + return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull'); + })()`, 8000).catch(() => null); + await p.ev(`(() => { + const s = window.game.scene.getScene('CombatScene'); + // The kill (burst + kill feed + the thunder voice). + const e = s.enemies.find((x) => !x.dead); + if (e) s.killEnemy(e); + // A player hit (damage number + the bar's gauge flash). + s.shields = 40; s.hull = 100; + s.damagePlayer(8); + return true; + })()`); + await p.shot(`${OUT}/combat-range.png`); + } finally { + await p.close(); + } + } + + // ---------------------------------------------------------- the main + { + console.log('main (index.html?combat):'); + const p = await openPage(`${BASE}/index.html?combat`); + try { + await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)'); + await p.ev('window.game.scene.getScene("MenuScene").startNewGame()'); + await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)'); + // The new-run tutorial comms are 'ack' comms: each owns the screen + // and pauses the sim until the player ACKs. For the combat shot we + // clear the chain through the hub's own finish path (end the active + // one, drop the pending queue, un-pause) so the fight is LIVE — + // the sim ticks, the tracers fly, and the bar's gauge flash is + // visible — instead of frozen behind a comm panel. + await p.ev(`(() => { + const g = window.game.scene.getScene('GameScene'); + const h = g.comms; + if (!h) return true; + if (h.current) { try { h._finish(); } catch (e) {} } + if (Array.isArray(h.queue)) h.queue.length = 0; + h._setPaused(false); + try { h.box?.close(); } catch (e) {} + h.box = null; + return true; + })()`); + // The camera follows the ship smoothly (game.camera.followRate). The + // ship settles at its system rim AFTER create (tether), so the camera + // must catch up — wait until it is centered on the ship, or the + // ship + pack (now re-anchored on it) sit off-screen for the shot. + await p.waitTrue(`(() => { + const g = window.game.scene.getScene('GameScene'); + const cam = g.cameras.main; + const w = g.scale.width, h = g.scale.height; + const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2; + const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2; + return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24; + })()`, 8000).catch(() => null); + await sleep(600); // the pack settles + the lock acquires + any drift damps + await p.ev(`(async () => { + const oc = window.__ORBIT_COMBAT__; + const sys = oc.system; + const t = sys.currentTarget() || sys.enemies.find((x) => !x.dead); + if (!t) return false; + const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json(); + const wpn = cfg.player.weapon; + const nose = (sys.ship.radius || 12) + 8; + const a = Math.atan2(t.y - sys.ship.y, t.x - sys.ship.x); + for (let i = -3; i <= 3; i++) { + const aa = a + i * 0.05; + sys.spawnBullet(sys.ship.x + Math.cos(aa) * nose, sys.ship.y + Math.sin(aa) * nose, aa, wpn, 'player'); + } + // A guaranteed enemy hit through the real collide path: spawn a + // tracer ON the target so the first frame's swept segment crosses it. + const t2 = sys.enemies.find((x) => !x.dead); + if (t2) sys.spawnBullet(t2.x, t2.y, a, wpn, 'player'); + return true; + })()`); + await p.waitTrue(`(() => { + const log = window.__COMBAT_SFX__?.log ?? []; + return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull'); + })()`, 8000).catch(() => null); + await p.ev(`(() => { + const oc = window.__ORBIT_COMBAT__; + const sys = oc.system; + const e = sys.enemies.find((x) => !x.dead); + if (e) { e.shields = 0; e.damage(9999); sys.killEnemy(e, sys._now()); } + sys.shields = 40; sys.hull = 100; + sys.damagePlayer(8); + return true; + })()`); + await p.shot(`${OUT}/combat-main.png`); + } finally { + await p.close(); + } + } + + console.log('combat feedback screenshots: done'); +} + +main().catch((err) => { console.error('combat-shot failed:', err.message); process.exit(1); }); diff --git a/dev/shots/combat-main.png b/dev/shots/combat-main.png new file mode 100644 index 0000000..7a97b86 Binary files /dev/null and b/dev/shots/combat-main.png differ diff --git a/dev/shots/combat-range.png b/dev/shots/combat-range.png new file mode 100644 index 0000000..941c6a8 Binary files /dev/null and b/dev/shots/combat-range.png differ