From ebd26ad7ba002d39b08106bb05ee5cb91c52ecf9 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 7 Sep 2026 00:21:30 -0600 Subject: [PATCH] Stop thrusting ship dead when it hits an obstacle or the tether rim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constrainShip on Planet, Star, Station, JumpGate, and AsteroidCluster now returns a contact boolean (true only when position/velocity/acceleration actually changed) so the scene can distinguish "touched" from "no-op". - GameScene.onPostUpdate reads those flags and, if the ship is in its Shift+click thrust state, calls stop() + setState('normal', 'contact') the first frame any solid or the tether rim touches it — the hold ends and the ship rests on the rim instead of driving through. - Non-thrust ships are left alone by the rule; their existing arrival/brake logic still owns their stop. - dev/contact.test.mjs: headless tests covering no-contact outside the keep-out, contact + push-out + inward-motion strip on approach, no spurious contact while sliding off the rim, the full thrust-into-planet stop-dead-on-rim integration, and that the rule only fires in thrust. - dev/smoke-game.mjs: add a third thrusttest check (c) verifying a Shift+click straight into the planet stops it dead on the rim. --- dev/contact.test.mjs | 240 +++++++++++++++++++++++++++++++++ dev/smoke-game.mjs | 16 ++- js/entities/AsteroidCluster.js | 9 ++ js/entities/JumpGate.js | 8 +- js/entities/Planet.js | 12 ++ js/entities/Ship.js | 22 ++- js/entities/Star.js | 8 ++ js/entities/Station.js | 12 +- js/scenes/GameScene.js | 32 +++-- 9 files changed, 341 insertions(+), 18 deletions(-) create mode 100644 dev/contact.test.mjs diff --git a/dev/contact.test.mjs b/dev/contact.test.mjs new file mode 100644 index 0000000..20be898 --- /dev/null +++ b/dev/contact.test.mjs @@ -0,0 +1,240 @@ +// Headless test for the contact seam that stops a THRUSTING ship (the +// Shift+click throttle lock) when it runs into the world: +// - Planet / AsteroidCluster constrainShip report CONTACT (true) exactly +// when they did something to the ship this frame (pushed it out of the +// keep-out, or stripped inward velocity/acceleration) and false when +// the ship is untouched — the scene's stop rule (GameScene.onPostUpdate: +// `if (touched && ship.state === 'thrust') ship.stop()`) reads those, +// - a thrusting ship driven into a planet stops dead ON its rim, +// - a coasting (non-thrust) ship is untouched by the rule. +// Run: node --import ./dev/phaser-loader.mjs dev/contact.test.mjs +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; + +const { config } = await import('../js/config/Config.js'); +const data = {}; +for (const f of readdirSync(new URL('../data', import.meta.url))) { + if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8')); +} +config.init(data); + +const { GameObject } = await import('./phaser-stub.mjs').then((m) => ({ GameObject: m.default.GameObjects.Container })); + +class V2 { + constructor(x = 0, y = 0) { this.x = x; this.y = y; } + set(x, y) { this.x = x; this.y = y; return this; } + length() { return Math.hypot(this.x, this.y); } + scale(s) { this.x *= s; this.y *= s; return this; } +} + +function makeImage(x, y, key) { + const g = new GameObject(null, x, y); + g.key = key; + return g; +} + +function makeScene() { + return { + time: { now: 0, delayedCall: () => ({ destroy() {} }) }, + add: { + existing: (o) => o, + image: (x, y, key, frame) => makeImage(x, y, key), + graphics: () => ({ clear() {}, destroy() {} }), + circle: (x, y, r, fill, fa) => makeImage(x, y, 'circle'), + text: (x, y, s, st) => makeImage(x, y, 'text'), + }, + make: { graphics: () => ({ clear() {}, destroy() {} }) }, + tweens: { add: (o) => { if (o && typeof o.onComplete === 'function') o.onComplete(); return { destroy() {} }; }, killTweensOf: () => {} }, + textures: { exists: () => true }, + physics: { + add: { + existing: (o) => { + o.body = { + x: 0, y: 0, + velocity: { x: 0, y: 0, set() {}, length: () => 0, scale() {} }, + acceleration: { set() {}, x: 0, y: 0 }, + }; + return o; + }, + }, + }, + ship: null, + }; +} + +const { Ship } = await import('../js/entities/Ship.js'); +const { Planet } = await import('../js/entities/Planet.js'); +const { AsteroidCluster } = await import('../js/entities/AsteroidCluster.js'); + +// The stub scene hands out a body whose vectors don't do arithmetic — +// give the ship a REAL one so update() / stop() behave. +function realShip(scene, x, y) { + const ship = new Ship(scene, x, y); + ship.body = { x, y, velocity: new V2(), acceleration: new V2() }; + return ship; +} + +const dist = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by); +let n = 0; +const ok = (label) => { n += 1; console.log(` ${String(n).padStart(2)}. ${label} ✔`); }; + +// ---------------------------------------------------------------- planet +{ + const scene = makeScene(); + const planet = new Planet(scene, 1000, 0, 0, 'Test World', {}); + scene.ship = null; + const ship = realShip(scene, 0, 0); + scene.ship = ship; + const rim = planet.minCenterDistance(ship.radius); + + // Outside the keep-out, moving inward: untouched this frame (the ship + // has not reached the rim yet) — NO contact. + ship.x = 1000 + rim + 50; + ship.y = 0; + ship.body.velocity.x = -200; + let touched = planet.constrainShip(ship, ship.radius); + assert.equal(touched, false, 'a ship outside the keep-out is untouched'); + assert.equal(dist(ship.x, ship.y, 1000, 0), rim + 50, 'position unchanged'); + assert.equal(ship.body.velocity.x, -200, 'velocity unchanged'); + ok('Planet.constrainShip: no contact outside the keep-out'); + + // INSIDE the keep-out, moving inward: pushed out to the rim, inward + // velocity stripped — CONTACT reported. + ship.x = 1000 + rim - 5; + ship.y = 0; + ship.body.velocity.x = -200; + ship.body.acceleration.x = -900; + touched = planet.constrainShip(ship, ship.radius); + assert.equal(touched, true, 'contact is reported'); + assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'ship pushed out to the rim'); + assert.equal(ship.body.velocity.x, 0, 'inward velocity stripped'); + assert.equal(ship.body.acceleration.x, 0, 'inward acceleration stripped'); + ok('Planet.constrainShip: contact on approach (pushed out, inward motion stripped)'); + + // Riding the rim, moving OUTWARD (already sliding away): untouched — + // no spurious contact (no in/out jitter). + ship.x = 1000 + rim; + ship.y = 0; + ship.body.velocity.x = 300; + ship.body.acceleration.x = 0; + touched = planet.constrainShip(ship, ship.radius); + assert.equal(touched, false, 'a ship sliding outward off the rim is untouched'); + ok('Planet.constrainShip: no contact while sliding off the rim'); +} + +// ------------------------------------------------------- the stop rule +{ + // The full throttle-lock contact, as the scene drives it each frame + // (Ship.update → constraints → the scene's stop rule): thrust straight + // at a planet and the ship must stop dead ON its rim. + const scene = makeScene(); + const planet = new Planet(scene, 1000, 0, 0, 'Test World', {}); + const ship = realShip(scene, 0, 0); + scene.ship = ship; + const rim = planet.minCenterDistance(ship.radius); + ship.setPosition(1000 + rim + 600, 0); + + // The stub has no physics world, so integrate the way Arcade does: + // acceleration → velocity → position, after each ship.update(). + const integrate = (ship) => { + const s = 16.67 / 1000; + ship.body.velocity.x += ship.body.acceleration.x * s; + ship.body.velocity.y += ship.body.acceleration.y * s; + ship.x += ship.body.velocity.x * s; + ship.y += ship.body.velocity.y * s; + }; + + ship.thrustToward(1000, 0); // shift+click ON the planet: committed heading is -x + assert.equal(ship.state, 'thrust'); + + let contact = false; + let frames = 0; + do { + ship.update(16.67, 16.67); // the ship's own frame + integrate(ship); // the world's integration + contact = planet.constrainShip(ship, ship.radius); // the world's constraint + frames += 1; + // GameScene.onPostUpdate's rule: + if (contact && ship.state === 'thrust') { + ship.stop(); + ship.setState('normal', 'contact'); + } + assert.ok(frames < 600, 'the ship never reaches the planet (it stops at the rim)'); + } while (ship.state === 'thrust' && !contact); + + assert.equal(ship.state, 'normal', 'contact ended the hold'); + assert.equal(ship.thrustDir, null, 'the committed heading is cleared'); + assert.equal(ship.body.velocity.x, 0, 'stopped dead'); + assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'resting on the rim'); + ok(`thrusting into a planet stops it dead on the rim (${frames} frames, x=${ship.x.toFixed(1)})`); +} + +// ------------------------------------------------------- asteroid field +{ + const scene = makeScene(); + const ship = realShip(scene, 0, 0); + scene.ship = ship; + const cluster = new AsteroidCluster(scene, { + id: 'c-contact', + name: 'Contact Field', + x: 1000, + y: 0, + bound: 110, + tint: null, + groupSpin: 0, + groupPhase: 0, + debrisPhase: 0, + debrisSpin: 0, + debris: [], + asteroids: [{ frame: 2, x: 0, y: 0, size: 200, spin: 0, phase: 0 }], + }, {}); + const rock = cluster.members[0]; + const rim = rock.radius + cluster.clearance + ship.radius; + + // Outside the rock's keep-out: no contact. + ship.x = 1000 + rim + 40; + ship.y = 0; + ship.body.velocity.x = -150; + let touched = cluster.constrainShip(ship, ship.radius); + assert.equal(touched, false, 'outside the rock keep-out: untouched'); + ok('AsteroidCluster.constrainShip: no contact outside the keep-out'); + + // Inside, closing in: pushed out, inward motion stripped — CONTACT. + ship.x = 1000 + rim - 4; + ship.y = 0; + ship.body.velocity.x = -150; + ship.body.acceleration.x = -500; + touched = cluster.constrainShip(ship, ship.radius); + assert.equal(touched, true, 'contact is reported'); + assert.ok(dist(ship.x, ship.y, 1000, 0) > rim - 0.01, 'pushed out to the rock rim'); + assert.equal(ship.body.velocity.x, 0, 'inward velocity stripped'); + assert.equal(ship.body.acceleration.x, 0, 'inward acceleration stripped'); + ok('AsteroidCluster.constrainShip: contact on approach'); +} + +// ----------------------------------------------------------------- rule +{ + // The same contact, but the ship is NOT thrusting (a plain + // click-to-fly arrival): the scene's rule must leave it alone — its + // arrival/brake logic owns that stop. + const scene = makeScene(); + const planet = new Planet(scene, 1000, 0, 0, 'Test World', {}); + const ship = realShip(scene, 0, 0); + scene.ship = ship; + const rim = planet.minCenterDistance(ship.radius); + ship.setTarget(1000 + rim, 0); // normal mode, flying in + ship.x = 1000 + rim - 3; + ship.y = 0; + ship.body.velocity.x = -120; + const touched = planet.constrainShip(ship, ship.radius); + assert.equal(touched, true, 'contact is reported'); + // The rule (GameScene.onPostUpdate) only fires in the thrust state: + if (touched && ship.state === 'thrust') { + ship.stop(); + ship.setState('normal', 'contact'); + } + assert.equal(ship.state, 'normal', 'a non-thrust ship is not force-stopped by contact'); + ok('the stop rule only fires in the thrust state'); +} + +console.log(`\nAll contact tests passed (${n} groups) ✔`); diff --git a/dev/smoke-game.mjs b/dev/smoke-game.mjs index 4fa0bec..d26994d 100644 --- a/dev/smoke-game.mjs +++ b/dev/smoke-game.mjs @@ -110,7 +110,8 @@ if (params && params.get('thrusttest') === '1') { // 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. + // click point, (b) a plain click steers it to a stop there, and + // (c) thrusting INTO an obstacle stops it dead on the rim. const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const until = async (fn, ms = 25000) => { const t0 = Date.now(); @@ -179,7 +180,18 @@ if (params && params.get('thrusttest') === '1') { 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); + // 3) Shift+click INTO the planet — the ship must run in and STOP + // dead on its rim (contact ends the hold; the world wins). + const rim = s.planet.minCenterDistance(s.ship.radius); + s.ship.setPosition(rim + 400, 0); + s.ship.stop(); + await sleep(300); + fire(s, s.ship.x - 200, 0, true); // straight at the planet + await sleep(3000); + const rimGap = Math.hypot(s.ship.x - s.planet.x, s.ship.y - s.planet.y) - rim; + d.push(`hit-stop: state=${s.ship.state} rim-gap=${rimGap.toFixed(1)} px speed=${s.ship.body.velocity.length().toFixed(1)}`); + const hitOk = s.ship.state === 'normal' && Math.abs(rimGap) < 4 && s.ship.body.velocity.length() < 1; + report(thrustOk && steerOk && hitOk, d); } catch (err) { report(false, ['EXCEPTION: ' + String(err?.stack ?? err)]); } diff --git a/js/entities/AsteroidCluster.js b/js/entities/AsteroidCluster.js index 10caae6..485948e 100644 --- a/js/entities/AsteroidCluster.js +++ b/js/entities/AsteroidCluster.js @@ -206,6 +206,9 @@ export class AsteroidCluster extends Phaser.GameObjects.Container { * planet (ship may reach `clearance` edge-to-edge, never closer), * projected per rock. Cluster rocks may slightly overlap, so the * projection is iterated a few passes until stable — cheap (≤ 8 circles). + * + * @returns {boolean} CONTACT: true when any rock actually touched the + * ship this frame (see Planet.constrainShip). */ constrainShip(ship, shipRadius = 0) { const body = ship.body; @@ -232,6 +235,11 @@ export class AsteroidCluster extends Phaser.GameObjects.Container { } if (!moved) break; } + const touched = + x !== ship.x || y !== ship.y || + vx !== body.velocity.x || vy !== body.velocity.y || + (body.acceleration && + (ax !== body.acceleration.x || ay !== body.acceleration.y)); ship.x = x; ship.y = y; body.velocity.x = vx; @@ -240,6 +248,7 @@ export class AsteroidCluster extends Phaser.GameObjects.Container { body.acceleration.x = ax; body.acceleration.y = ay; } + return touched; } /** diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index c73fe1f..fde3f15 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -264,7 +264,7 @@ export class JumpGate extends Phaser.GameObjects.Container { }; } - /** Hard constraint — push the ship outside the keepout circle. */ + /** Hard constraint — push the ship outside the keepout circle. Returns true when it actually touched the ship (contact — see Planet.constrainShip). */ constrainShip(ship, shipRadius = 0) { const body = ship.body; const r = Planet.resolve( @@ -278,6 +278,11 @@ export class JumpGate extends Phaser.GameObjects.Container { body.acceleration ? body.acceleration.x : 0, body.acceleration ? body.acceleration.y : 0, ); + const touched = + r.x !== ship.x || r.y !== ship.y || + r.vx !== body.velocity.x || r.vy !== body.velocity.y || + (body.acceleration && + (r.ax !== body.acceleration.x || r.ay !== body.acceleration.y)); ship.x = r.x; ship.y = r.y; body.velocity.x = r.vx; @@ -286,6 +291,7 @@ export class JumpGate extends Phaser.GameObjects.Container { body.acceleration.x = r.ax; body.acceleration.y = r.ay; } + return touched; } destroy() { diff --git a/js/entities/Planet.js b/js/entities/Planet.js index 5afd159..447034b 100644 --- a/js/entities/Planet.js +++ b/js/entities/Planet.js @@ -106,6 +106,12 @@ export class Planet extends Phaser.GameObjects.Sprite { * very next physics step. A ship already outside is untouched; one * riding exactly on the circle keeps its position but loses its inward * speed, so contact is clean (no in/out jitter). + * + * @returns {boolean} CONTACT: true when the constraint actually did + * something to the ship this frame (pushed it out of the keep-out circle, + * or stripped inward velocity/acceleration). The scene uses this to stop + * a thrusting ship (the Shift+click throttle lock) the moment it runs + * into the planet. */ constrainShip(ship, shipRadius = 0) { const minDist = this.minCenterDistance(shipRadius); @@ -117,6 +123,11 @@ export class Planet extends Phaser.GameObjects.Sprite { body.acceleration ? body.acceleration.x : 0, body.acceleration ? body.acceleration.y : 0, ); + const touched = + r.x !== ship.x || r.y !== ship.y || + r.vx !== body.velocity.x || r.vy !== body.velocity.y || + (body.acceleration && + (r.ax !== body.acceleration.x || r.ay !== body.acceleration.y)); ship.x = r.x; ship.y = r.y; body.velocity.x = r.vx; @@ -125,6 +136,7 @@ export class Planet extends Phaser.GameObjects.Sprite { body.acceleration.x = r.ax; body.acceleration.y = r.ay; } + return touched; } /** diff --git a/js/entities/Ship.js b/js/entities/Ship.js index 5fc7584..27fbb69 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -6,7 +6,7 @@ import { toColor } from '../utils/Color.js'; * 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. + * somewhere else or it runs into an obstacle (the scene stops it there). * * Art: the spritesheet in data/ship.json → `texture` (frameWidth×frameHeight * frames; `frame` picks which one — frame 0 is the starter ship). The art in @@ -212,8 +212,10 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { * 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. + * stop() brakes it dead. Running into a solid or the tether rim ends it + * too — the scene sees the contact and calls stop() (GameScene + * .onPostUpdate). 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; @@ -235,7 +237,13 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { this.setState('thrust', 'move'); } - /** Stop steering and brake immediately. */ + /** + * Stop steering and brake immediately (target AND the throttle-lock + * heading are cleared, velocity zeroed). The ship's STATE is left as-is + * on purpose — the mining hold (GameScene) needs a parked ship that + * stays in its 'mining' state; a scene that ends a hold sets the state + * itself (see the contact stop in GameScene.onPostUpdate). + */ stop() { this.target = null; this.thrustDir = null; @@ -251,8 +259,10 @@ export class Ship extends Phaser.Physics.Arcade.Sprite { // 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). + // target, which takes over below), brakes it (stop() clears thrustDir, + // dropping the ship to the coast branch), or it runs into a solid / + // the tether rim (the scene sees the contact and calls stop() — + // GameScene.onPostUpdate). if (this.state === 'thrust' && this.thrustDir) { body.acceleration.set( this.thrustDir.x * this.thrust, diff --git a/js/entities/Star.js b/js/entities/Star.js index 8c64b48..fcef258 100644 --- a/js/entities/Star.js +++ b/js/entities/Star.js @@ -82,6 +82,8 @@ export class Star extends Phaser.GameObjects.Image { * Keep a ship out of the star: same plain-circle rule as Planet — * push the center out to `minCenterDistance`, strip the inward part of * velocity and acceleration (the tangential part slides along the rim). + * Returns true when it actually touched the ship (contact — see + * Planet.constrainShip). */ constrainShip(ship, shipRadius = 0) { const body = ship.body; @@ -92,6 +94,11 @@ export class Star extends Phaser.GameObjects.Image { body.acceleration ? body.acceleration.x : 0, body.acceleration ? body.acceleration.y : 0, ); + const touched = + r.x !== ship.x || r.y !== ship.y || + r.vx !== body.velocity.x || r.vy !== body.velocity.y || + (body.acceleration && + (r.ax !== body.acceleration.x || r.ay !== body.acceleration.y)); ship.x = r.x; ship.y = r.y; body.velocity.x = r.vx; @@ -100,6 +107,7 @@ export class Star extends Phaser.GameObjects.Image { body.acceleration.x = r.ax; body.acceleration.y = r.ay; } + return touched; } /** diff --git a/js/entities/Station.js b/js/entities/Station.js index cd52e6b..d6b08da 100644 --- a/js/entities/Station.js +++ b/js/entities/Station.js @@ -169,7 +169,11 @@ export class Station extends Phaser.GameObjects.Container { }; } - /** Hard constraint — push the ship outside the keepout circle (position + velocity + acceleration). */ + /** + * Hard constraint — push the ship outside the keepout circle (position + + * velocity + acceleration). Returns true when it actually touched the + * ship (contact — see Planet.constrainShip). + */ constrainShip(ship, shipRadius = 0) { const body = ship.body; const r = Planet.resolve( @@ -183,6 +187,11 @@ export class Station extends Phaser.GameObjects.Container { body.acceleration ? body.acceleration.x : 0, body.acceleration ? body.acceleration.y : 0, ); + const touched = + r.x !== ship.x || r.y !== ship.y || + r.vx !== body.velocity.x || r.vy !== body.velocity.y || + (body.acceleration && + (r.ax !== body.acceleration.x || r.ay !== body.acceleration.y)); ship.x = r.x; ship.y = r.y; body.velocity.x = r.vx; @@ -191,6 +200,7 @@ export class Station extends Phaser.GameObjects.Container { body.acceleration.x = r.ax; body.acceleration.y = r.ay; } + return touched; } destroy() { diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 9fcb666..56b7766 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -80,7 +80,8 @@ const HUD_AUTO_COLLAPSE_MS = 10000; * star in every other one) plus the system's other worlds scattered * 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. + * and keeps flying the way until you steer it somewhere else, or it runs + * into something (a world, a rock, the tether rim) and stops there. * * Discovery: come within discovery distance (data/game.json) of a world's * edge and it is DISCOVERED (state in this.discovery). Discovered worlds @@ -750,10 +751,12 @@ export class GameScene extends Phaser.Scene { // 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). + // Shift+click re-aims the heading) — or until it runs into a solid / + // the tether rim, where the scene stops it dead (onPostUpdate). 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 runs straight into the line and stops + // there 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 @@ -942,13 +945,26 @@ export class GameScene extends Phaser.Scene { * asteroid clusters) holds the ship back at its clearance — fly close, * never through — and the tether holds the player's range (clamped to * the union boundary, the line shudders where it was hit). + * + * THROTTLE LOCK (Shift+click): a thrusting ship is the one mover that + * deliberately drives INTO those constraints (it never brakes on its + * own) — so the first frame any constraint actually touches it, the + * ship stops dead right there: the hold ends, the world wins. */ onPostUpdate(_time, delta) { + let touched = false; for (const s of this.solids) { - s.constrainShip(this.ship, this.ship.radius); + if (s.constrainShip(this.ship, this.ship.radius)) touched = true; + } + const tether = this.tetherField.constrainShip(this.ship); + if (tether) { + touched = true; + this.onTetherContact(tether); // the line shudders where it was hit + } + if (touched && this.ship.state === 'thrust') { + this.ship.stop(); // brake dead at the obstacle + this.ship.setState('normal', 'contact'); // the hold is over — back to normal } - const contact = this.tetherField.constrainShip(this.ship); - if (contact) this.onTetherContact(contact); } /**