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:
parent
75c9f99ecb
commit
ebd26ad7ba
|
|
@ -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) ✔`);
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue