241 lines
9.4 KiB
JavaScript
241 lines
9.4 KiB
JavaScript
// 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) ✔`);
|