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:
parent
1d0622a3d5
commit
5949bac861
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue