Add Shift+click throttle lock for committed-heading flight

- Ship gains a `thrust` state and `thrustToward()` that commits to the heading toward the click point and holds full throttle past it, until a plain click or stop steers out.
- GameScene pointer handler branches on `pointer.event.shiftKey`: Shift+click locks the heading (raw aim), plain click still flies to the clamped stop point.
- Mining teardown no longer stomps the ship state when the player has already moved it into `thrust`/`normal`.
- Update hint text and add unit tests (tests 7–10) plus a live-scene smoke harness (`?thrusttest=1`) verifying past-click flight, steer-back arrival, stop braking, and mining-exit signaling.
This commit is contained in:
Brian Fertig 2026-09-07 00:06:57 -06:00
parent be6e9fc558
commit 75c9f99ecb
5 changed files with 290 additions and 22 deletions

View File

@ -4,7 +4,7 @@
"width": 1280,
"height": 720,
"backgroundColor": "#04060d",
"hintText": "click anywhere to fly · click a rock to mine · the tether holds your range",
"hintText": "click anywhere to fly · shift+click to hold the heading · click a rock to mine · the tether holds your range",
"markerColor": "#41c7ff",
"physics": {
"default": "arcade"

View File

@ -7,9 +7,14 @@
* loop from js/entities/Ship.js, then asserts the flight feel:
* arrives and stops, respects maxSpeed, tracks its heading, stops facing
* the direction it was traveling, can be re-targeted mid-flight, coasts
* to rest, hard-brakes on stop(). Heading assertions account for the
* ship's art render offset (ship.artOffset data/ship.json artFacing),
* which is a presentation concern, not flight behavior.
* to rest, hard-brakes on stop() and the Shift+click THROTTLE LOCK:
* thrustToward() commits to a heading and keeps flying it (past the
* point, full throttle) until a plain setTarget/stop steers the ship
* out of its 'thrust' state.
*
* Heading assertions account for the ship's art render offset
* (ship.artOffset data/ship.json artFacing), which is a presentation
* concern, not flight behavior.
*
* The harness integrates accelerationvelocityposition the way the
* Arcade physics world does (after the scene's update).
@ -249,5 +254,78 @@ const integrate = (ship) => {
}
}
// --- Test 7: the Shift+click THROTTLE LOCK (thrustToward) --------------
{
// Commits to a heading and keeps flying it — PAST the click point,
// full throttle — with no stop in sight.
const ship = new Ship(scene, 100, 100);
ship.thrustToward(400, 100); // straight right
check('thrustToward enters its thrust state with a unit heading',
ship.state === 'thrust' && ship.target === null &&
Math.abs(Math.hypot(ship.thrustDir.x, ship.thrustDir.y) - 1) < 1e-9 &&
ship.thrustDir.x > 0.999 && Math.abs(ship.thrustDir.y) < 1e-9);
let maxSpeedSeen = 0;
for (let t = 0; t < 120; t++) { // ~2 s
ship.update(t * dt, dt);
integrate(ship);
maxSpeedSeen = Math.max(maxSpeedSeen, ship.body.velocity.length());
}
const passed = ship.x - 400;
check(`keeps flying the heading PAST the click point (x=${ship.x.toFixed(1)}, passed by ${passed.toFixed(1)}px)`,
passed > 100 && ship.y < 101 && ship.y > 99);
check(`throttle-locked speed is up and capped (max=${maxSpeedSeen.toFixed(1)} / cap ${ship.maxSpeed})`,
maxSpeedSeen > 200 && maxSpeedSeen <= ship.maxSpeed * 1.05 + 0.001);
const hd = wrap(ship.rotation - (ship.artOffset || 0));
check(`stays on the committed heading (heading=${hd.toFixed(3)} rad, want ~0)`, Math.abs(hd) < 0.1);
check('still holding the throttle (state=thrust, dir kept)',
ship.state === 'thrust' && ship.thrustDir !== null);
}
// --- Test 8: a plain click (setTarget) steers out of the hold ------------
{
const ship = new Ship(scene, 100, 100);
ship.thrustToward(2000, 100);
for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); }
const target = { x: 700, y: 500 };
ship.setTarget(target.x, target.y);
check('setTarget drops the thrust hold and takes the ship to the new point',
ship.state === 'normal' && ship.thrustDir === null && ship.target !== null);
let arrived = false;
for (let t = 0; t < 60 * 30; t++) {
ship.update(t * dt, dt);
integrate(ship);
if (ship.target === null && ship.body.velocity.length() === 0) { arrived = true; break; }
}
const d = Math.hypot(ship.x - target.x, ship.y - target.y);
check(`arrives at the steered-to point and stops (dist=${d.toFixed(2)})`, arrived && d <= 8.5);
}
// --- Test 9: stop() brakes the hold dead ---------------------------------
{
const ship = new Ship(scene, 300, 300);
ship.thrustToward(900, 300);
for (let t = 0; t < 30; t++) { ship.update(t * dt, dt); integrate(ship); }
ship.stop();
check('stop() clears the hold and kills the velocity',
ship.thrustDir === null && ship.target === null && ship.body.velocity.length() === 0);
for (let t = 0; t < 60; t++) { ship.update(t * dt, dt); integrate(ship); }
check('after stop() the ship coasts to rest (no re-thrust)',
ship.body.velocity.length() < 1);
}
// --- Test 10: thrusting out of 'mining' signals the scene -----------------
{
const ship = new Ship(scene, 0, 0);
const changes = [];
ship.onStateChange = (next, prev) => changes.push([next, prev]);
ship.setState('mining', 'mining');
const baseline = changes.length; // the setup call above already fired once
ship.thrustToward(500, 500);
check('thrustToward from the mining state fires the scene teardown signal',
ship.state === 'thrust' && changes.length === baseline + 1 &&
changes[changes.length - 1][0] === 'thrust' &&
changes[changes.length - 1][1] === 'mining');
}
console.log(failures === 0 ? '\nAll ship behavior tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -14,6 +14,10 @@
* ?near=<px> fly the ship to <px> (edge-to-edge) from the first
* system planet screenshots the discovery moment
* (rim ping + toast) with the world in view.
* ?thrusttest=1 run the Shift+click THROTTLE LOCK flow end-to-end in
* the live scene (synthetic pointerdowns with
* pointer.event.shiftKey set) and paint the verdict
* on the canvas (window.__thrustResult for CDP).
* ?tethers=x,y,level;x,y,level add dev tethers (union-boundary tests)
* ?homeLevel=<n> set the home tether's level (bigger/smaller range)
* ?seed=<seed> use a DETERMINISTIC dev galaxy (same seed same
@ -101,6 +105,91 @@ if (params && params.get('report') === '1') {
}, 3000);
}
if (params && params.get('thrusttest') === '1') {
// The Shift+click THROTTLE LOCK, end-to-end in the LIVE scene: fire
// synthetic pointerdown events through the scene's own input handler
// (pointer.event carries the shiftKey, as a real press would), then
// check the ship (a) keeps flying the committed heading PAST the
// click point, and (b) a plain click steers it to a stop there.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 25000) => {
const t0 = Date.now();
for (;;) {
const v = fn();
if (v) return v;
if (Date.now() - t0 > ms) return null;
await sleep(200);
}
};
const fire = (s, wx, wy, shift) => {
const pointer = s.input.activePointer;
pointer.event = { shiftKey: !!shift }; // the DOM press (with modifiers)
pointer.x = 100; pointer.y = 100; // screen space (clear of deck/panels)
pointer.worldX = wx; pointer.worldY = wy;
pointer.downElement = s.game.canvas;
s.input.emit('pointerdown', pointer, []);
};
const stopAim = (s, wx, wy) => {
let aim = { x: wx, y: wy };
for (const sol of s.solids) aim = sol.aimPoint(aim.x, aim.y, s.ship.radius);
return s.tetherField.clampPoint(aim.x, aim.y);
};
setTimeout(async () => {
const s = game.scene.getScene('GameScene');
const report = (ok, details) => {
window.__thrustResult = { ok, details };
if (!s) return;
const text = `THRUST TEST ${ok ? 'PASS \u2714' : 'FAIL \u2718'}\n${details.join('\n')}`;
s.add.text(16, 690, text, {
fontFamily: 'monospace',
fontSize: '14px',
color: ok ? '#7ce8a4' : '#ff2d6f',
align: 'left',
}).setOrigin(0, 1).setScrollFactor(0).setDepth(9000);
};
try {
const d = [];
// Wait for the scene to finish create() (asset preload first —
// the videos in particular can be slow in headless).
const ready = await until(() => (s && s.ship && s.input && s.input.activePointer && s.tetherField ? s : null));
if (!ready) { report(false, ['scene never became ready (ship/input/tether missing)']); return; }
// Park the ship at a known spot, clear of the central body.
const px = s.planet.radius + 320;
s.ship.setPosition(px, 0);
s.ship.stop();
s.cameras.main.setScroll(px - s.scale.width / 2, -s.scale.height / 2);
await sleep(400);
const sx = s.ship.x;
// 1) Shift+click 500 px ahead — the ship must KEEP FLYING that
// way, past the click point, at full throttle.
const thrustPoint = { x: sx + 500, y: 0 };
if (s.rockAt(thrustPoint.x, thrustPoint.y)) { report(false, ['SKIP: thrust click point is on a rock']); return; }
if (s.worldObjectAt(thrustPoint.x, thrustPoint.y)) { report(false, ['SKIP: thrust click point is on a world']); return; }
fire(s, thrustPoint.x, thrustPoint.y, true);
await sleep(2500);
const past = s.ship.x - thrustPoint.x;
d.push(`thrust: state=${s.ship.state} x=${s.ship.x.toFixed(1)} (past click by ${past.toFixed(1)} px, speed=${s.ship.body.velocity.length().toFixed(0)})`);
const thrustOk = s.ship.state === 'thrust' && past > 80 && s.ship.y < 2 && s.ship.y > -2;
// 2) Plain click somewhere else — the ship must fly THERE and
// stop (the hold ends exactly like a normal re-target).
const stopPoint = { x: s.ship.x - 300, y: s.ship.y + 380 };
const aim = stopAim(s, stopPoint.x, stopPoint.y);
fire(s, stopPoint.x, stopPoint.y, false);
await sleep(5000);
const dist = Math.hypot(s.ship.x - aim.x, s.ship.y - aim.y);
d.push(`steer-back: state=${s.ship.state} dist=${dist.toFixed(1)} px speed=${s.ship.body.velocity.length().toFixed(1)}`);
const steerOk = s.ship.state === 'normal' && dist < 20 && s.ship.body.velocity.length() < 5;
report(thrustOk && steerOk, d);
} catch (err) {
report(false, ['EXCEPTION: ' + String(err?.stack ?? err)]);
}
}, 300);
// Watchdog: a verdict MUST land — never let the poller time out silent.
setTimeout(() => {
if (!window.__thrustResult) window.__thrustResult = { ok: false, details: ['watchdog: no verdict within 40 s'] };
}, 40000);
}
if (nearParam) {
const px = Number(nearParam) || 400;
setTimeout(() => {

View File

@ -3,7 +3,10 @@ import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
/**
* The player's ship: an arcade-physics sprite that flies to wherever you click.
* The player's ship: an arcade-physics sprite that flies to wherever you click
* and, with the Shift+click THROTTLE LOCK (thrustToward, the 'thrust'
* state), keeps flying a committed heading until the player steers it
* somewhere else.
*
* Art: the spritesheet in data/ship.json `texture` (frameWidth×frameHeight
* frames; `frame` picks which one frame 0 is the starter ship). The art in
@ -126,24 +129,31 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.radius = worldSize / 2;
this.target = null;
// THROTTLE LOCK (the Shift+click hold — js/scenes/GameScene.js): the
// committed unit heading the ship keeps flying while it is in its
// 'thrust' state — past the original click point and on. Cleared by
// setTarget() / stop() the instant the player steers the ship back.
this.thrustDir = null;
// SHIP STATE — what the ship is doing right now. 'normal' is the
// default: free to fly wherever the player sends it. 'mining' = the
// arm's sequence owns the ship (js/mining/Mining.js drives it; the
// beam + ore stream only live while the state holds). More states
// will land here (docking, boarding, …). Any change OUT of a state —
// another state, or the player MOVING the ship (setTarget, autopilot)
// default: free to fly wherever the player sends it. 'thrust' = the
// Shift+click throttle lock (full-throttle along the committed
// heading, see thrustToward + update below). 'mining' = the arm's
// sequence owns the ship (js/mining/Mining.js drives it; the beam +
// ore stream only live while the state holds). More states will land
// here (docking, boarding, …). Any change OUT of a state — another
// state, or the player MOVING the ship (setTarget, autopilot)
// — is signalled via onStateChange, and the scene's handler tears
// down whatever that state was doing (ends the mining sequence).
this.state = 'normal'; // 'normal' | 'mining' (later: more)
this.state = 'normal'; // 'normal' | 'thrust' | 'mining' (later: more)
this.onStateChange = null; // (next, prev, reason) => void — the scene installs
}
/**
* Change the ship's state ('normal' | 'mining' | ). A no-op when the
* state is unchanged. On a real change, onStateChange fires the scene
* ends the mining sequence when the ship leaves 'mining', whichever
* state (or movement) took it out.
* Change the ship's state ('normal' | 'thrust' | 'mining' | ). A no-op
* when the state is unchanged. On a real change, onStateChange fires
* the scene ends the mining sequence when the ship leaves 'mining',
* whichever state (or movement) took it out.
*
* @returns {boolean} true when the state actually changed
*/
@ -189,6 +199,7 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
/** Set the destination to fly to (world coordinates). */
setTarget(x, y) {
this.target = { x, y };
this.thrustDir = null; // steering somewhere else ends the Shift+click hold
// Moving the ship ends whatever non-normal state it was in (mining,
// and later states): the player's movement always wins, so any state
// hands the ship back to 'normal' — the scene's onStateChange handler
@ -196,9 +207,38 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.setState('normal', 'move');
}
/**
* Commit to the heading toward (x, y) and HOLD IT (the Shift+click
* throttle lock): the ship flies that way at full throttle PAST the
* point and on for as long as it stays in its 'thrust' state. A plain
* setTarget() flies somewhere else and stops there (ending the hold);
* stop() brakes it dead. The direction is ship (x, y); a degenerate
* point (right on the hull) keeps the heading it already has.
*/
thrustToward(x, y) {
const dx = x - this.x;
const dy = y - this.y;
const d = Math.hypot(dx, dy);
let nx;
let ny;
if (d > 0.5) {
nx = dx / d;
ny = dy / d;
} else {
// The ship IS the point: keep the heading it already has.
const h = this.rotation - this.artOffset;
nx = Math.cos(h);
ny = Math.sin(h);
}
this.target = null;
this.thrustDir = { x: nx, y: ny };
this.setState('thrust', 'move');
}
/** Stop steering and brake immediately. */
stop() {
this.target = null;
this.thrustDir = null;
this.body.acceleration.set(0, 0);
this.body.velocity.set(0, 0);
}
@ -208,6 +248,39 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
if (!body) return;
const dt = Math.min(delta, 64) / 1000;
// THROTTLE LOCK (the Shift+click hold): full throttle along the
// committed heading — no arrival check, no stopping — until the
// player steers the ship somewhere else (setTarget → 'normal' + a
// target, which takes over below) or brakes it (stop() clears
// thrustDir, dropping the ship to the coast branch).
if (this.state === 'thrust' && this.thrustDir) {
body.acceleration.set(
this.thrustDir.x * this.thrust,
this.thrustDir.y * this.thrust,
);
// The same drag + hard speed cap as the click-to-fly steering.
body.velocity.scale(Math.max(0, 1 - this.drag * dt));
const v = body.velocity.length();
if (v > this.maxSpeed) body.velocity.scale(this.maxSpeed / v);
// Heading follows the actual direction of motion (so it never yaws
// "backwards" while turning); standing still, it points down the
// committed heading. Sprite rotation = heading + artOffset (the
// sheet art may not face east).
const speed = body.velocity.length();
const wanted =
(speed > 10
? Phaser.Math.Angle.Wrap(Math.atan2(body.velocity.y, body.velocity.x))
: Phaser.Math.Angle.Wrap(
Math.atan2(this.thrustDir.y, this.thrustDir.x),
)) + this.artOffset;
const current = this.rotation;
const step = Math.min(Math.abs(wanted - current), this.rotSpeed * dt);
this.rotation = Phaser.Math.Angle.RotateTo(current, wanted, step);
return;
}
if (this.target) {
const dx = this.target.x - this.x;
const dy = this.target.y - this.y;

View File

@ -78,7 +78,9 @@ const HUD_AUTO_COLLAPSE_MS = 10000;
* The game world (v0.3: the current system the central body at the
* origin (the player's home world in the starting system, the system's
* star in every other one) plus the system's other worlds scattered
* around it, in open space). Click anywhere to fly there.
* around it, in open space). Click anywhere to fly there holding
* SHIFT on the click THROTTLE-LOCKS the ship: it commits to that heading
* and keeps flying the way until you steer it somewhere else.
*
* Discovery: come within discovery distance (data/game.json) of a world's
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
@ -744,9 +746,14 @@ export class GameScene extends Phaser.Scene {
// it does not touch the mining state (no move ⇒ a live beam keeps
// running). Any OTHER click moves the ship — which ends the mining
// state (the beam retracts as the ship goes; a mid-reach arm
// aborts). A click BEYOND the player's tether range clamps to the
// union boundary — the target marker lands on the barrier line
// itself.
// aborts). Holding SHIFT on that click = THROTTLE LOCK: the ship
// commits to the heading toward the click point and keeps flying it
// — past the point, full throttle — until the player steers it
// somewhere else (a plain click flies there and stops; another
// Shift+click re-aims the heading). A click BEYOND the player's
// tether range clamps to the union boundary — the target marker
// lands on the barrier line itself (and while throttle-locked, the
// ship drives into the line instead of resting on it).
this.input.on('pointerdown', (pointer) => {
// A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK
// (two quick presses) skips the rest of the clip (the same window as
@ -890,11 +897,29 @@ export class GameScene extends Phaser.Scene {
// The state exit is signalled via ship.onStateChange above.
if (this.mining.isActive) this.mining.stop();
// The stop point a plain click would have taken: off every solid's
// keep-out rim, clamped to the player's tether range.
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const s of this.solids) {
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
}
aim = this.tetherField.clampPoint(aim.x, aim.y);
// SHIFT HELD = THROTTLE LOCK: the ship commits to the heading
// toward the click point (raw — the direction the player pointed,
// not the stop point) and keeps flying that way — past the point
// and on, full throttle — until the player steers it somewhere
// else. The world still owns the motion: solids and the tether
// rim hold it back as it drives into them (postupdate constraint).
// (pointer.event is the DOM pointerdown — the press that just
// landed, with its modifier keys.)
if (pointer.event?.shiftKey) {
this.showTargetMarker(aim.x, aim.y); // the aim feedback
this.ship.thrustToward(pointer.worldX, pointer.worldY);
this.hideHint();
return;
}
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
@ -2003,9 +2028,12 @@ export class GameScene extends Phaser.Scene {
this.setMiningLoop(true); // the beam is live — the hum runs until the sequence ends
} else if (phase === 'stopped') {
this.setMiningLoop(false); // the arm pulls back — the hum ends with it (no one-shot sound)
// Back to normal — a no-op when the ship already left 'mining'
// itself (the player moved it, which ended the sequence).
this.ship.setState('normal', 'mining-ended');
// Back to normal — but ONLY when the ship is still in its mining
// state (the beam-out / full-hold ending: the ship is parked). When
// the player moved the ship out of 'mining' (a click-to-fly, or a
// Shift+click throttle lock), that movement already owns the
// ship's state ('normal' or 'thrust') — don't stomp it.
if (this.ship.state === 'mining') this.ship.setState('normal', 'mining-ended');
}
}