diff --git a/data/combat.json b/data/combat.json index 83079f7..20957c8 100644 --- a/data/combat.json +++ b/data/combat.json @@ -19,12 +19,15 @@ }, "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, + "_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). The flight feel (js/combat/Combat.js → handleFeel, tested): thrust px/s^2 at full throttle, maxSpeed px/s cap, drag 1/s (exponential coast decay — the range OVERRIDES ship.drag with this, so data/combat.json is the one tuning surface), brake = the S/Down reverse-throttle as a fraction of FULL thrust (instant — a responsive stop). rotSpeed rad/s = the SUSTAINED keyboard turn rate; rotApproach 1/s = how fast the turn rate builds (a tap is a nudge, a hold is a full spin); thrustRamp 1/s = how fast the throttle reaches full on W; thrustDecay 1/s = how fast the throttle cuts when W lets go (no lingering kick). 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": 2000, "maxSpeed": 560, - "drag": 1.6, - "rotSpeed": 4.8, - "brake": 0.6, + "drag": 1.8, + "rotSpeed": 6.4, + "rotApproach": 20, + "thrustRamp": 18, + "thrustDecay": 30, + "brake": 0.9, "weapon": { "key": "space", "cooldown": 0.16, diff --git a/dev/combat-flycheck.mjs b/dev/combat-flycheck.mjs new file mode 100644 index 0000000..3431b2c --- /dev/null +++ b/dev/combat-flycheck.mjs @@ -0,0 +1,150 @@ +// Dev-only: the RANGE FLIGHT FEEL CHECK (headless, CDP). +// +// node dev/combat-flycheck.mjs [baseUrl] +// +// Loads the training range (/combat), then drives the SAME key states a +// WASD player would produce (scene.keys.*.isDown — the exact input the +// feel step reads) and measures the response: +// +// thrust — time from rest to 60% of maxSpeed (W) [want < 450 ms] +// turn — time from rest to a 30° heading change (D) [want < 250 ms] +// spin — sustained turn rate once the turn is up [want ≥ rotSpeed] +// brake — time from full speed to < 60 px/s (S) [want < 800 ms] +// +// (A headless tab ticks ~24 fps, so the budgets carry a frame-quantization +// margin — at 60 fps the same run is roughly 100–150 ms faster.) +// +// Prints a JSON summary + a PASS/FAIL line (exit 0 on PASS). Needs the +// dev server (node dev/server.mjs 8080) and a CDP browser on 9333 — the +// same setup as dev/cdp-shot.mjs. +const [url = 'http://127.0.0.1:8080/combat'] = process.argv.slice(2); +const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// The page-side check (runs inside the tab — real scene, real ship, real +// feel step; the ship is made unkillable so a stray lance can't abort it). +const PAGE_CODE = ` +(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + if (!window.game) throw new Error('game not booted'); + const scene = window.game.scene.getScene('CombatScene'); + if (!scene || !scene.ship) throw new Error('CombatScene not ready'); + const ship = scene.ship; + const maxSpeed = (window.__COMBAT_MAXSPEED__ ?? 560); + + // Isolate the FEEL: no pack to ram into, no damage to respawn the ship + // (a respawn resets rotation/position and would poison the numbers). + scene.maxHull = 1e9; scene.hull = 1e9; scene.maxShields = 1e9; scene.shields = 1e9; + scene.enemies.length = 0; + scene.bullets.length = 0; + + const press = (name, isDown) => { scene.keys[name].isDown = isDown; }; + const speed = () => Math.hypot(ship.body.velocity.x, ship.body.velocity.y); + const alive = () => { + if (scene.playerDead) throw new Error('player died during the check (invincibility failed?)'); + }; + + // --- thrust: rest → 60% of maxSpeed ----------------------------------- + ship.stop(); + await sleep(120); + press('up', true); + const t0 = performance.now(); + while (performance.now() - t0 < 2000 && speed() < maxSpeed * 0.6) { await sleep(10); alive(); } + alive(); + const t60 = Math.round(performance.now() - t0); + press('up', false); + await sleep(300); + ship.stop(); + await sleep(150); + + // --- turn: rest → 30°, then the sustained rate ------------------------- + const r0 = ship.rotation; + press('right', true); + const t1 = performance.now(); + while (performance.now() - t1 < 3000 && Math.abs(ship.rotation - r0) < Math.PI / 6) { await sleep(10); alive(); } + alive(); + const t30 = Math.round(performance.now() - t1); + const r1 = ship.rotation; + await sleep(300); + alive(); + const spinRate = (ship.rotation - r1) / 0.3; // rad/s once the turn is up + press('right', false); + await sleep(300); + ship.stop(); + + // --- brake: full speed → < 60 px/s (open space, nose ahead) ----------- + ship.stop(); + scene.feeel.rot = Math.PI / 2; // heading = east (+x), open space ahead + scene.feeel.turnVel = 0; + ship.x = 200; ship.y = 360; ship.rotation = Math.PI / 2; + ship.body.velocity.set(0, 0); + await sleep(120); + press('up', true); + await sleep(1000); // build up to full + alive(); + const vFull = Math.round(speed()); + press('up', false); + press('down', true); + const t2 = performance.now(); + while (performance.now() - t2 < 4000 && speed() > 60) { await sleep(10); alive(); } + alive(); + const tStop = Math.round(performance.now() - t2); + press('down', false); + ship.stop(); + + return { t60, t30, spinRate: Number(spinRate.toFixed(2)), vFull, tStop, maxSpeed }; +})() +`; + +async function main() { + // Open a fresh tab for the range (don't disturb other CDP users). + 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 })); + }); + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } + }; + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = () => rej(new Error('ws error')); + }); + await send('Runtime.enable'); + await sleep(6000); // boot + config + textures + fonts + + const r = await send('Runtime.evaluate', { expression: PAGE_CODE, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 500)); + const s = r.result.value; + ws.close(); + + const ok = s.t60 < 450 && s.t30 < 250 && s.spinRate >= 5.5 && s.tStop < 800 && s.vFull > 400; + console.log(JSON.stringify(s, null, 2)); + console.log( + `flycheck: thrust ${s.t60} ms to 60% · turn ${s.t30} ms to 30° · spin ${s.spinRate} rad/s · ` + + `brake ${s.tStop} ms to stop from ${s.vFull} px/s → ${ok ? 'PASS' : 'FAIL'}`, + ); + // Close the tab again (best effort). + try { await fetch(`http://${CDP}/json/close/${target.id}`); } catch {} + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { + console.error('flycheck error:', e.message); + process.exit(2); +}); diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 74a2510..1b9b1e1 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -120,7 +120,7 @@ for (const f of fs.readdirSync(dataDir)) { config.init(configData); console.log('config loaded from data/:', Object.keys(configData).join(', ')); -const { applyDamage, headingOf, orbitStep } = await import( +const { applyDamage, headingOf, orbitStep, handleFeel } = await import( pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href ); const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href); @@ -169,6 +169,71 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); check('orbitStep: counter-direction', approx(t2.angle, -1)); } +// ------------------------------------------------------------------ feel + +{ + const P = (k, d) => config.get(`combat.player.${k}`, d); + const cfg = { + rotSpeed: P('rotSpeed', 5.6), + rotApproach: P('rotApproach', 16), + thrustRamp: P('thrustRamp', 14), + thrustDecay: P('thrustDecay', 26), + brake: P('brake', 0.8), + }; + check('feel cfg ships: all five knobs present + sane', + cfg.rotSpeed > 0 && cfg.rotApproach > 0 && cfg.thrustRamp > 0 && + cfg.thrustDecay > 0 && cfg.brake > 0 && cfg.brake <= 1); + + // Turn: a key BUILDS the turn rate (a tap is a nudge, a hold a spin) — + // no instant full rate on the first frame, and it settles at ±rotSpeed. + const st = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(st, { right: true }, 1 / 60, cfg); + check('feel: first frame builds turn (no instant full rate)', + st.turnVel > 0 && st.turnVel < cfg.rotSpeed && st.rot > 0); + for (let i = 0; i < 60; i++) handleFeel(st, { right: true }, 1 / 60, cfg); + check('feel: turn settles at rotSpeed (no overshoot)', + st.turnVel <= cfg.rotSpeed && Math.abs(st.turnVel - cfg.rotSpeed) < 0.05); + + const sFlip = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 }; + for (let i = 0; i < 3; i++) handleFeel(sFlip, { left: true }, 1 / 60, cfg); + check('feel: left flips the turn (eases toward -rotSpeed)', sFlip.turnVel < 0); + + const s0 = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 }; + for (let i = 0; i < 120; i++) handleFeel(s0, {}, 1 / 60, cfg); + check('feel: released turn decays to 0', s0.turnVel === 0); + + // Throttle: ramps up toward 1 (bounded), cuts to 0 when released. + const s1 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + for (let i = 0; i < 30; i++) handleFeel(s1, { up: true }, 1 / 60, cfg); + check('feel: throttle ramps up, bounded by 1', s1.throttle > 0.5 && s1.throttle <= 1); + for (let i = 0; i < 60; i++) handleFeel(s1, {}, 1 / 60, cfg); + check('feel: throttle cuts to 0 on release', s1.throttle === 0); + + // Brake: instant while down (a fraction of full thrust), gone on release. + const s2 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(s2, { down: true }, 1 / 60, cfg); + check('feel: brake is instant (no ramp)', s2.brake === cfg.brake); + handleFeel(s2, {}, 1 / 60, cfg); + check('feel: brake drops when released', s2.brake === 0); + + // Frame-rate independence: two half-steps ≈ one full step. + const a = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(a, { right: true, up: true }, 1 / 60, cfg); + const b = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(b, { right: true, up: true }, 1 / 120, cfg); + handleFeel(b, { right: true, up: true }, 1 / 120, cfg); + check('feel: frame-rate independent (≈)', + Math.abs(a.rot - b.rot) < 1e-3 && Math.abs(a.throttle - b.throttle) < 1e-3); + + // dt clamp: a backgrounded tab (dt = 5 s) can't fast-forward the feel. + const c = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(c, { right: true, up: true }, 5, cfg); + const d = { rot: 0, turnVel: 0, throttle: 0, brake: 0 }; + handleFeel(d, { right: true, up: true }, 0.1, cfg); + check('feel: dt clamped at 0.1 s (no tab-switch jump)', + Math.abs(c.rot - d.rot) < 1e-9 && Math.abs(c.throttle - d.throttle) < 1e-9); +} + // ---------------------------------------------------------------- enemy { diff --git a/js/combat/Combat.js b/js/combat/Combat.js index e1156eb..a6aa053 100644 --- a/js/combat/Combat.js +++ b/js/combat/Combat.js @@ -34,6 +34,67 @@ export function applyDamage(armor, amount) { return { shieldHit, hullHit, dead: armor.hull <= 0 }; } +/** + * One step of the combat ship's FLIGHT FEEL (the range's WASD handling — + * js/combat/CombatScene.js): a smoothed turn rate, a ramped throttle, + * and an instant brake. Pure — no Phaser — so it runs in bare Node + * (dev/combat.test.mjs) and the scene just reads the result onto the + * arcade body each frame. + * + * The feel state `st` is: `{ rot, turnVel, throttle, brake }` + * rot — the render heading (rad); the scene assigns it to the ship + * turnVel — the current turn velocity (rad/s), eased toward ±rotSpeed + * throttle — 0..1, eased toward 1 while `up`, toward 0 when released; + * the scene scales the weapon-free thrust by it + * brake — the instant reverse-throttle fraction while `down` (0..1) + * + * `input` is the boolean key state `{ left, right, up, down }`. + * `cfg` (data/combat.json → player): rotSpeed (rad/s sustained turn), + * rotApproach (1/s, how fast turnVel settles — smoothing), thrustRamp + * (1/s, throttle-up responsiveness), thrustDecay (1/s, throttle release), + * brake (fraction of FULL thrust, instant while down). + * Exponential approaches ⇒ frame-rate independent, no overshoot. + * + * @param {{rot?: number, turnVel?: number, throttle?: number, brake?: number}} st + * @param {{left?: boolean, right?: boolean, up?: boolean, down?: boolean}} input + * @param {number} dt seconds (clamped to ≤ 0.1 — a backgrounded tab can't fast-forward the feel) + * @param {object} cfg + * @returns the same `st` object, mutated + */ +export function handleFeel(st, input, dt, cfg) { + const d = Math.min(Math.max(Number(dt) || 0, 0), 0.1); + const cfg2 = cfg ?? {}; + + // Turn: the turn VELOCITY eases toward ±rotSpeed (a smoothed gyroscope — + // the first key-press builds turn, so a tap is a nudge and a hold is + // a full-rate spin). v(t) = T − (T − v₀)·e^(−r·t), and rot integrates + // that EXACTLY (∫₀ᵈ v dt) — so the feel is frame-rate independent by + // construction (two half-steps ≡ one full step, no Euler drift). + const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0); + const T = dir * Math.max(0, Number(cfg2.rotSpeed) || 0); + const r = Math.max(0.1, Number(cfg2.rotApproach) || 12); + const v0 = st.turnVel || 0; + const q = Math.exp(-r * d); + const v1 = T - (T - v0) * q; + st.rot = (st.rot || 0) + (T * d - (T - v0) * (1 - q) / r); + st.turnVel = v1; + if (dir === 0 && Math.abs(st.turnVel) < 1e-4) st.turnVel = 0; + + // Throttle: ramps up toward 1 while `up`; cuts quickly when released + // (no lingering kick after the key lets go). + const want = input.up ? 1 : 0; + const rate = want + ? Math.max(0.1, Number(cfg2.thrustRamp) || 10) + : Math.max(0.1, Number(cfg2.thrustDecay) || 16); + st.throttle = want - (want - (st.throttle || 0)) * Math.exp(-rate * d); + if (want === 0 && st.throttle < 1e-3) st.throttle = 0; + + // Brake: an INSTANT reverse throttle (S/Down) as a fraction of FULL + // thrust — a responsive stop, independent of the ramp. + st.brake = input.down ? Math.max(0, Number(cfg2.brake) || 0) : 0; + return st; +} + /** * 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, diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index b709f86..acffd48 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -11,7 +11,7 @@ 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'; +import { applyDamage, handleFeel } from './Combat.js'; /** * The COMBAT SANDBOX — Orbit's standalone combat range (combat.html / @@ -104,6 +104,16 @@ export class CombatScene extends Phaser.Scene { this.ship = new Ship(this, w * 0.24, h / 2); this.ship.setDepth(10); this.ship.body.setAllowGravity(false); + // The range's drift drag — combat.player.drag OVERRIDES ship.drag + // (data/ship.json), so data/combat.json is the one feel surface. + // (Ship.update reads this.drag every frame — the assignment sticks.) + const cd = Number(config.get('combat.player.drag', 2.4)); + if (Number.isFinite(cd) && cd > 0) this.ship.drag = cd; + // The flight-feel state (js/combat/Combat.js → handleFeel, a PURE + // step exercised by dev/combat.test.mjs): smoothed turn rate, + // ramped throttle, instant brake. The scene assigns it onto the + // arcade body each frame (updatePlayer). + this.feeel = { rot: this.ship.rotation, turnVel: 0, throttle: 0, brake: 0 }; // The pack (spawned on a ring around the screen center). this.enemies = []; @@ -122,16 +132,45 @@ export class CombatScene extends Phaser.Scene { // 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). + // Controls: WASD/arrows + SPACE/J. R = reset, P = pause. + // + // NOTE — this build (Phaser 4.2.1 Giedi): `addKeys({up:'W,UP',…})` + // looks each value up in the KeyCodes map, which has NO 'W,UP' entry — + // every name landed on ONE shared Key with keyCode `undefined`, so the + // old `keys.up.isDown` polling could never see a real key-press + // (WASD was silently dead). The main game's working pattern is the + // event API — the manager emits `keydown-` for real keycodes — + // so drive a plain key-state object from those events. Real Key + // objects are still registered (single names — that form works) so + // the manager captures them (arrows/space must not scroll the page). const kb = this.input.keyboard; - this.keys = kb.addKeys({ - up: 'W,UP', - down: 'S,DOWN', - left: 'A,LEFT', - right: 'D,RIGHT', - fire: 'SPACE,J', - }); + for (const code of ['W', 'A', 'S', 'D', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'J']) { + kb.addKey(code); + } + this.keys = { + up: { isDown: false }, + down: { isDown: false }, + left: { isDown: false }, + right: { isDown: false }, + fire: { isDown: false }, + }; + const bind = (name, code) => { + kb.on(`keydown-${code}`, () => { this.keys[name].isDown = true; }); + kb.on(`keyup-${code}`, () => { this.keys[name].isDown = false; }); + }; + bind('up', 'W'); bind('up', 'UP'); + bind('down', 'S'); bind('down', 'DOWN'); + bind('left', 'A'); bind('left', 'LEFT'); + bind('right', 'D'); bind('right', 'RIGHT'); + bind('fire', 'SPACE'); bind('fire', 'J'); + // Losing focus must not leave a key "held" (wire once — the scene can + // restart in place, and the closure re-reads `this.keys` every blur). + if (typeof window !== 'undefined' && !this.__blurWired) { + this.__blurWired = true; + window.addEventListener('blur', () => { + for (const k of Object.values(this.keys)) k.isDown = false; + }); + } kb.on('keydown-R', () => this.reset()); kb.on('keydown-P', () => this.togglePause()); @@ -195,27 +234,41 @@ export class CombatScene extends Phaser.Scene { // don't thrust this frame), then apply the keyboard on top. ship.update(time, delta); + // The flight feel (combat.player → the PURE step in Combat.js — + // smoothed turn rate + ramped throttle + instant brake; tested by + // dev/combat.test.mjs). The feel state lives on the scene. + const p = config.section('combat.player', {}); 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; + handleFeel( + this.feeel, + { + left: keys.left.isDown, + right: keys.right.isDown, + up: keys.up.isDown, + down: keys.down.isDown, + }, + dt, + p, + ); + ship.rotation = this.feeel.rot; const heading = ship.rotation - ship.artOffset; // world radians (Ship contract) - const thrust = Math.max(0, config.get('combat.player.thrust', 1500)); + const full = Math.max(0, config.get('combat.player.thrust', 1500)); + const thrust = full * this.feeel.throttle; let ax = 0; let ay = 0; let thrusting = false; - if (keys.up.isDown) { + if (thrust > 0) { 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 (this.feeel.brake > 0) { + // The brake is a fraction of FULL thrust (an instant stop). + ax -= Math.cos(heading) * full * this.feeel.brake; + ay -= Math.sin(heading) * full * this.feeel.brake; } - if (thrusting || keys.down.isDown) ship.body.acceleration.set(ax, ay); + if (thrusting || this.feeel.brake > 0) 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));