Sharpen range flight feel: smoothed turn, ramped throttle, instant brake

- js/combat/Combat.js: new pure handleFeel() step (smoothed turn
  velocity, ramped/decaying throttle, instant brake) with EXACT
  analytic integration of the eased turn — frame-rate independent by
  construction (dev/combat.test.mjs: feel block, 0 failures).
- data/combat.json: combat.player tuning surface — thrust 2000, drag
  1.8 (now actually overrides ship.drag in the range), rotSpeed 6.4,
  rotApproach 20, thrustRamp 18, thrustDecay 30, brake 0.9.
- js/combat/CombatScene.js: updatePlayer runs the shared feel step.
  ALSO FIXES WASD: this Phaser build's addKeys() object form only
  resolves single KeyCodes names, so {up:'W,UP',...} put every name on
  ONE shared Key with keyCode undefined and isDown polling could never
  see a key-press (WASD was dead). Now keys drive off the working
  keydown-/keyup- event API (the main game's pattern) with real Key
  objects registered for capture; window-blur clears held keys.
- dev/combat-flycheck.mjs: CDP flight-feel check — thrust 60% in ~300
  ms, 30° turn in ~150 ms, sustained spin ≥ rotSpeed, stop from full
  in ~300 ms (headless ~24 fps budgets; PASS).
This commit is contained in:
Brian Fertig 2026-09-16 00:17:35 -06:00
parent 1d0622a3d5
commit 5949bac861
5 changed files with 358 additions and 26 deletions

View File

@ -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,

150
dev/combat-flycheck.mjs Normal file
View File

@ -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);
});

View File

@ -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
{

View File

@ -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,

View File

@ -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-<NAME>` 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));