Stop thrusting ship dead when it hits an obstacle or the tether rim

- 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.
This commit is contained in:
Brian Fertig 2026-09-07 00:21:30 -06:00
parent 75c9f99ecb
commit ebd26ad7ba
9 changed files with 341 additions and 18 deletions

240
dev/contact.test.mjs Normal file
View File

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

View File

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

View File

@ -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;
}
/**

View File

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

View File

@ -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;
}
/**

View File

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

View File

@ -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;
}
/**

View File

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

View File

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