diff --git a/data/game.json b/data/game.json index e802c4b..805b29a 100644 --- a/data/game.json +++ b/data/game.json @@ -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" diff --git a/dev/ship-behavior.test.mjs b/dev/ship-behavior.test.mjs index d0ac769..f9cba01 100644 --- a/dev/ship-behavior.test.mjs +++ b/dev/ship-behavior.test.mjs @@ -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 acceleration→velocity→position 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); diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index 36290eb..4fa0bec 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -14,6 +14,10 @@ * ?near= fly the ship to (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= set the home tether's level (bigger/smaller range) * ?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(() => { diff --git a/js/entities/Ship.js b/js/entities/Ship.js index c7eda36..5fc7584 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -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; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index b1738dc..9fcb666 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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'); } }