Add escort delivery touchdown animation and on-screen witness check

- Replace the instant "delivered" completion with a two-phase flow:
  phase `delivering` (convoy breaks off, glides beside the destination
  world, fades out) followed by the seal/payout on fade close.
- The delivery now requires the destination world to be visible in the
  camera's world rect (`_escortObjectOnScreen`) rather than merely within
  a distance radius.
- Add `_runEscortDelivery` (time-driven ease-out approach + alpha/scale
  fade, frame-rate independent) and `_sealEscortDelivery` (tear down
  convoy, close phase, complete quest, toast).
- Extend `normalizeEscort` to accept the new `delivering` phase.
- `_escortDestinationObject` now returns the world's rendered radius so
  the landing slot can anchor just off the planet/station disc.
- Add `escortArrivingToast` UI string for the "convoy inbound" moment.
- Update escort-flow test to assert the delivering → delivered sequence
  and add a standalone repro script (`dev/escort-delivery-repro.mjs`).
This commit is contained in:
Brian Fertig 2026-09-23 21:39:59 -06:00
parent 0a53931f16
commit 5810560b18
5 changed files with 393 additions and 19 deletions

View File

@ -200,7 +200,7 @@
}
},
"escort": {
"_comment": "The ESCORT category's rules (the third live type — the model: js/quests/SideQuests.js → escortDestinationFor / deriveEscortPlan; the ship: js/combat/TransportShip.js + data/combat.json → transport; the scene: GameScene._establishEscort + updateEscort). An escort is a CONVOY JOB: a neutral TRANSPORT (its own ship class — ~2.5x the fighter's size, far heavier armor, a heavy lance) plus 1–2 escort fighters stands at a single RENDEZVOUS waypoint in the posting's own system (waypoints [1,1] — the route builder's shared placement law, the 'escort' fork). The player flies to the waypoint within visitRadius and the convoy LINKS (phase 'rendezvous' → 'transit') — from then on the transport + fighters hold their escort ring around the player through EVERY remaining leg, re-forming across jumps (the route record carries the transport's armor — a signed contract is a signed contract: a jump or a save never re-rolls a standing convoy). The DESTINATION is a planet or a free-space station 1–2 gate hops away (destinationHops [min,max], a seeded walk of the jump network — js/galaxy/JumpTravel.js's gate graph, the same links the player can actually fly), fixed at the accept and never re-rolled; the player flies the legs (1–2 jumps) and arrives at the destination object with the convoy intact. ENCOUNTERS — the lane is HOT: while the convoy is linked, the escort director (its own EncounterDirector instance with these tighter numbers — a shorter roll interval, a near-certain chance, a short quiet) fires WAVES of mixed fighter types (bands, by the player's combat level — a weighted roster of enemyTypes ids, validated like the patrol's) with LARGER concurrent counts than a normal encounter (wave.total / maxConcurrent) — one at a time, the wave run's completion the quiet's gate, exactly the patrol contact's law. The TRANSPORT is the job: it fights alongside (its own lance, js/combat/TransportShip.js), it is a legal enemy target (the allies roster — pickEnemyTarget's escort aggro), and if it is DESTROYED the contract fails (phase 'failed', the convoy breaks off) — the player must LAND ON A PLANET to reset it (GameScene.startLanding's escort reset — a fresh rendezvous, a fresh transport, the same destination: retry the run). COMPLETION — the convoy arrives at the destination object (visitRadius of the planet/station in the destination system) with NO enemy fighter on screen (the craftVisible roster rule — the bounty field-clear's law): the contract completes, the reward stands ready to claim. compass + marker = the escort's in-game face: the green next-stop arrow (the RENDEZVOUS waypoint pre-link, then the DESTINATION gate, then the destination object itself) and the in-world beacons (rendezvous = pulsing until linked, then the destination object's mark).",
"_comment": "The ESCORT category's rules (the third live type — the model: js/quests/SideQuests.js → escortDestinationFor / deriveEscortPlan; the ship: js/combat/TransportShip.js + data/combat.json → transport; the scene: GameScene._establishEscort + updateEscort). An escort is a CONVOY JOB: a neutral TRANSPORT (its own ship class — ~2.5x the fighter's size, far heavier armor, a heavy lance) plus 1–2 escort fighters stands at a single RENDEZVOUS waypoint in the posting's own system (waypoints [1,1] — the route builder's shared placement law, the 'escort' fork). The player flies to the waypoint within visitRadius and the convoy LINKS (phase 'rendezvous' → 'transit') — from then on the transport + fighters hold their escort ring around the player through EVERY remaining leg, re-forming across jumps (the route record carries the transport's armor — a signed contract is a signed contract: a jump or a save never re-rolls a standing convoy). The DESTINATION is a planet or a free-space station 1–2 gate hops away (destinationHops [min,max], a seeded walk of the jump network — js/galaxy/JumpTravel.js's gate graph, the same links the player can actually fly), fixed at the accept and never re-rolled; the player flies the legs (1–2 jumps) and arrives at the destination object with the convoy intact. ENCOUNTERS — the lane is HOT: while the convoy is linked, the escort director (its own EncounterDirector instance with these tighter numbers — a shorter roll interval, a near-certain chance, a short quiet) fires WAVES of mixed fighter types (bands, by the player's combat level — a weighted roster of enemyTypes ids, validated like the patrol's) with LARGER concurrent counts than a normal encounter (wave.total / maxConcurrent) — one at a time, the wave run's completion the quiet's gate, exactly the patrol contact's law. The TRANSPORT is the job: it fights alongside (its own lance, js/combat/TransportShip.js), it is a legal enemy target (the allies roster — pickEnemyTarget's escort aggro), and if it is DESTROYED the contract fails (phase 'failed', the convoy breaks off) — the player must LAND ON A PLANET to reset it (GameScene.startLanding's escort reset — a fresh rendezvous, a fresh transport, the same destination: retry the run). COMPLETION — the destination world (planet/free-space station) is VISIBLE ON SCREEN (circleInView against the camera's world rect — the player has flown it into view, not merely near it) with NO enemy fighter up (the craftVisible roster rule — the bounty field-clear's law): the contract enters its DELIVERY (phase 'delivering') — the convoy BREAKS OFF the wing, flies in beside the world, and FADES OUT (the touchdown: the transport + its fighters land, they are through, not destroyed; a fixed time-driven fly-in then fade, GameScene._runEscortDelivery), and on the fade's close the contract completes, the reward stands ready to claim. compass + marker = the escort's in-game face: the green next-stop arrow (the RENDEZVOUS waypoint pre-link, then the DESTINATION gate, then the destination object itself) and the in-world beacons (rendezvous = pulsing until linked, then the destination object's mark).",
"enabled": true,
"minLevel": 3,
"contexts": [
@ -750,6 +750,11 @@
"color": "#7ce8a4",
"text": "CONVOY LINKED — {name} HOLDS YOUR WING THROUGH TO {dest}"
},
"escortArrivingToast": {
"glyph": "✈",
"color": "#7ce8a4",
"text": "CONVOY INBOUND — {name} IS BREAKING OFF TO SET DOWN AT {dest}"
},
"escortArrivedToast": {
"glyph": "✓",
"color": "#67e863",

View File

@ -0,0 +1,217 @@
// dev/escort-delivery-repro.mjs — REPRO: escort delivery with the convoy
// OFF-SCREEN at the destination (the real CombatSystem + real GameScene
// escort law, run in the true per-frame order; node dev/escort-delivery-repro.mjs)
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import './phaser-loader.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fs = await import('node:fs');
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const configData = {};
for (const f of fs.readdirSync(join(__dirname, '../data'))) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(__dirname, '../data', f), 'utf8'));
}
config.init(configData);
const {
sideQuestPostingsFor, escortDestinationFor, SideQuestState,
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href);
// ------------------------------------------------------------------ scene
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); }
}
function makeBody() {
const base = {
velocity: new V2(),
setVelocity(x, y) { this.velocity.set(x, y); },
setCircle() {},
setAllowGravity() {},
};
return new Proxy(base, {
get(t, k) {
if (k in t) return t[k];
return () => {};
},
});
}
const SEED = 77031;
const JOB = 'sq-sys-home-p1-escort-0';
const RECS = {
'sys-home': { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' },
'sys-a': { id: 'sys-a', name: 'Aldrin', faction: null, d: 35, zone: 'mid', type: 'void' },
'sys-b': { id: 'sys-b', name: 'Basalt', faction: null, d: 65, zone: 'far', type: 'void' },
};
const ADJ = {
'sys-home': ['sys-a', 'sys-zenith-excluded'],
'sys-a': ['sys-home', 'sys-b'],
'sys-b': ['sys-a'],
};
const CONTENT = {
'sys-home': { planets: [{ id: 'sys-home-p1', name: 'Hearth One', ordinal: 1, x: 0, y: 0 }], settlements: [] },
'sys-a': { planets: [{ id: 'sys-a-p1', name: 'Aldrin One', ordinal: 1, x: 100, y: 200 }], settlements: [] },
'sys-b': { planets: [], settlements: [{ id: 'sys-b-s1', name: 'Basalt Relay', anchor: { type: 'space' }, x: 300, y: 400 }] },
};
const galaxy = {
seed: SEED,
byId: new Map(Object.entries(RECS).map(([k, v]) => [k, v])),
records: Object.values(RECS),
jumpGatesFor: (id) => (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean),
ensureContent: (id) => CONTENT[id],
contentOf: (id) => CONTENT[id],
};
function makeScene(systemId) {
const view = { left: 0, top: 0, width: 1280, height: 720 };
const rec = RECS[systemId];
const scene = Object.create(GameScene.prototype);
scene.playSfx = () => {};
scene.consoleToast = (msg) => { console.log(' [toast]', msg); };
scene.time = { now: 0 };
scene.galaxy = galaxy;
scene.systemRecord = { ...rec };
scene.systemContent = galaxy.ensureContent(systemId);
scene.systemPlanets = [];
scene.systemStations = [];
scene.systemGates = [];
scene.planet = null;
scene.tetherField = null;
scene.isHomeSystem = false;
scene.ship = { x: 0, y: 0, radius: 16, body: null, stop() {}, setVisible() {} };
scene.scale = { width: 1280, height: 720 };
// Both camera surfaces: GameScene's witness (scrollX/scrollY) and
// CombatSystem's worldView (the on-screen rule).
const syncView = () => {
view.left = view._sx; view.top = view._sy;
cam.worldView = { x: view._sx, y: view._sy, width: 1280, height: 720, left: view._sx, top: view._sy };
};
const cam = { scrollX: 0, scrollY: 0, worldView: null };
Object.defineProperty(cam, 'scrollX', { get: () => view._sx, set: (v) => { view._sx = v; syncView(); } });
Object.defineProperty(cam, 'scrollY', { get: () => view._sy, set: (v) => { view._sy = v; syncView(); } });
view._sx = 0; view._sy = 0; syncView();
scene.cameras = { main: cam };
// Combat scene surface (combat.test.mjs's makeScene shape).
scene.textures = { exists: () => true };
scene.make = { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) };
scene.add = {
existing: (o) => o,
image: (x, y) => ({ x, y, setTint() {}, setAlpha() {}, setVisible() {}, setScale() {}, destroy() {} }),
graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }),
text: () => ({ setText() {}, setDepth() {}, setVisible() {}, destroy() {}, set() { return this; } }),
rectangle: (x, y) => ({ x, y, setFillStyle() {}, setAlpha() {}, destroy() {} }),
};
scene.physics = { add: { existing: (o) => { if (!o.body) o.body = makeBody(); return o; } } };
scene.refreshCreditsHud = () => {};
scene.sideQuestState = new SideQuestState();
scene.claimSideQuest = GameScene.prototype.claimSideQuest;
return scene;
}
const DEST = escortDestinationFor(SEED, JOB, 'sys-home', galaxy);
console.log('destination:', DEST.destSystemId, DEST.destObjectId, 'hops', DEST.hops);
const DEST_REC = RECS[DEST.destSystemId];
const DEST_OBJ = (galaxy.ensureContent(DEST.destSystemId).planets.concat(galaxy.ensureContent(DEST.destSystemId).settlements))
.find((o) => o.id === DEST.destObjectId);
const DEST_X = DEST_OBJ.x + 500; // park the ship in front of the world
const DEST_Y = DEST_OBJ.y;
// ------------------------------------------------------------------ boot
const scene = makeScene('sys-home');
const st = scene.sideQuestState;
const posting = { id: JOB, systemId: 'sys-home', systemName: 'Hearth', category: 'escort',
title: 'Convoy Night', label: 'ESCORT', reward: { credits: 150 } };
st.accept(JOB);
st.setActive(JOB);
scene._establishEscort(JOB, posting);
const combat = new CombatSystem(scene, { ship: scene.ship, onKill: () => {} });
scene.combat = combat;
scene.escortDirector = { state: 'idle', nextRollAt: null, quietUntil: 0, update() { return null; } };
// Frame driver — the true order from GameScene.update:
// time.update → combat.update → (windows) → updateSideQuestRoute → [physics step]
let now = 0;
const DT = 16.666;
function frame() {
now += DT;
scene.time.now = now;
if (scene.combat) scene.combat.update(now, DT); // its ally AI + steering
scene.updateEscort();
// Arcade physics step (the engine integrates body velocity after update).
const dt = DT / 1000;
for (const c of [...combat.allies, ...combat.enemies]) {
if (c && !c.dead && c.body?.velocity) {
c.x += c.body.velocity.x * dt;
c.y += c.body.velocity.y * dt;
}
}
}
const inView = (c) => {
const v = scene.cameras.main.worldView;
return c.x >= v.x - 20 && c.x <= v.x + v.width + 20 && c.y >= v.y - 20 && c.y <= v.y + v.height + 20;
};
function convoy() {
return combat.allies.filter((a) => a && !a.dead && (a._escortCrew || a.isTransport));
}
// ------------------------------------------------------------------ run
// LINK at the rendezvous (origin system).
{
const wp = st.routeFor(JOB).waypoints[0];
scene.ship.x = wp[0]; scene.ship.y = wp[1];
scene.cameras.main.scrollX = wp[0] - 640;
scene.cameras.main.scrollY = wp[1] - 360;
frame();
console.log('after link: phase =', st.routeFor(JOB).escort.phase,
'| convoy =', convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)}`).join(' '));
}
// JUMP to the destination system (scene restart tears the sprites — the
// record re-stands the convoy). Sky clear. The ship parks in front of the
// destination world; the world is ON SCREEN; the convoy is OFF SCREEN
// (far behind the player — the reported condition).
{
scene.systemRecord = { ...DEST_REC };
scene.systemContent = galaxy.ensureContent(DEST.destSystemId);
scene.ship.x = DEST_X; scene.ship.y = DEST_Y;
scene.cameras.main.scrollX = scene.ship.x - 640;
scene.cameras.main.scrollY = scene.ship.y - 360;
// The scene restart: the sprites are gone.
for (const a of combat.allies) a.destroy();
combat.allies.length = 0;
scene._escortTransport = null;
frame(); // the convoy re-forms (transit law)
// Push the convoy OFF-SCREEN (far behind the player).
for (const c of convoy()) { c.x = scene.ship.x - 2200; c.y = scene.ship.y; c.body?.setVelocity?.(0, 0); }
console.log('\ndestination reached — world on screen, sky clear, convoy off-screen:');
console.log(' phase =', st.routeFor(JOB).escort.phase,
'| convoy =', convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)} state=${c.state}`).join(' '));
}
let sealed = false;
let moved = false;
const t0 = convoy().map((c) => [c, c.x, c.y]);
for (let i = 1; i <= 420; i++) { // up to 7 s of frames
frame();
const ph = st.routeFor(JOB).escort.phase;
if (i % 30 === 0 || ph === 'delivering' || ph === 'delivered') {
console.log(` t=${(i * DT / 1000).toFixed(2)}s phase=${ph} convoy=${convoy().map((c) => `${c.isTransport ? 'T' : 'F'}(${Math.round(c.x)},${Math.round(c.y)}) onscreen=${inView(c)} state=${c.state} alpha=${(c.alpha ?? 1).toFixed(2)}`).join(' ')}`);
}
for (const [c, x0, y0] of t0) if (Math.hypot(c.x - x0, c.y - y0) > 5) moved = true;
if (ph === 'delivered') { sealed = true; break; }
}
console.log('\nRESULT: convoy moved =', moved, '| delivered =', sealed, '| isCompleted =', st.isCompleted(JOB));

View File

@ -11,8 +11,10 @@
// system; the player reaches it → the convoy FORMS (the transport +
// 1–2 escort fighters, persistent) and the lane goes hot; a wave
// fires (mixed fighters, the transport's own guns live); the convoy
// RE-FORMS across a jump's scene restart; arrival at the destination
// object with the sky clear DELIVERS (complete + the claim's payout).
// RE-FORMS across a jump's scene restart; the destination world ON
// SCREEN with the sky clear → the convoy breaks off, lands beside the
// world and fades out (the touchdown), and the contract DELIVERS
// (complete + the claim's payout).
//
// THE FAILURE — the transport is LOST → the contract FAILS (the convoy
// breaks off cleanly); the player LANDS → the contract RESETS (a fresh
@ -91,6 +93,11 @@ function makeCraft(kind) {
const c = {
kind,
x: 0, y: 0,
// Render face (the delivery's landing glide reads these — the real
// craft is a Phaser sprite; the fake carries the same surface).
scale: 1, alpha: 1, rotation: 0, artOffset: 0,
body: { setVelocity(x, y) { this.vx = x; this.vy = y; } },
setScale(s) { this.scale = s; return this; },
maxHull: kind === 'transport' ? 1500 : 60,
hull: kind === 'transport' ? 1500 : 60,
maxShields: kind === 'transport' ? 650 : 0,
@ -156,7 +163,7 @@ function makeScene(systemId) {
const scene = Object.create(GameScene.prototype);
scene.playSfx = () => {};
scene.consoleToast = () => {};
scene.time = { currentTime: 0 };
scene.time = { now: 0 };
scene.galaxy = galaxy;
scene.systemRecord = { ...rec };
scene.systemContent = galaxy.ensureContent(systemId);
@ -168,6 +175,11 @@ function makeScene(systemId) {
scene.isHomeSystem = false;
scene.patrolG = { clear() {}, lineStyle() {}, circle() {}, strokeCircle() {}, fillStyle() {}, fillCircle() {} };
scene.ship = { x: 0, y: 0, addCredits() {} };
// The camera's world rect (the delivery's witness — the destination world
// being on screen). Defaults center on the origin; the delivery test
// re-centers it on the ship before the delivery frame.
scene.scale = { width: 1280, height: 720 };
scene.cameras = { main: { scrollX: 0, scrollY: 0 } };
scene.refreshCreditsHud = () => {};
scene._routeFlightEnvelope = () => ({ region: [], anchors: [] });
scene.sideQuestState = new SideQuestState();
@ -250,14 +262,38 @@ console.log('== THE RUN — accept → rendezvous → link → wave → jump →
assert.equal(scene.combat.allies.filter((a) => a._escortCrew).length, route1.escort.escortCount, '…the escort fighters re-form too');
assert.equal(st.isCompleted(JOB), false, '…and it is NOT complete (the sky is not clear)');
// DELIVERY — the last pirate falls; at the destination object the sky is clear.
// DELIVERY — the last pirate falls; the destination world is ON SCREEN
// and the sky is clear → the convoy BREAKS OFF to land (phase
// 'delivering'). It is not complete yet — the touchdown is still flying.
for (const e of scene.combat.enemies) e.dead = true;
scene.combat.active = false;
scene.combat.waveRun = { state: 'done' };
scene.escortDirector.nextComp = null;
// Bring the destination world into the camera's world rect (the ship is
// on it — centre the view on the ship).
scene.cameras.main.scrollX = scene.ship.x - scene.scale.width / 2;
scene.cameras.main.scrollY = scene.ship.y - scene.scale.height / 2;
scene.time.now = 0;
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', 'world on screen + sky clear → the DELIVERY begins (the convoy breaks off to land)');
assert.equal(st.isCompleted(JOB), false, '…but not complete yet (the touchdown is still flying)');
assert.ok(scene._escortTransport && !scene._escortTransport.destroyed, '…the convoy is still there, flying in beside the world');
// THE TOUCHDOWN — the convoy glides in beside the world and fades out
// (a fixed approach + fade). The first landing frame stamps the start;
// a later frame is mid-fade (alpha dropping); on the fade's close the
// contract seals (complete + the claim's payout).
scene.time.now = 1000; // the delivery's first landing frame
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…mid-touchdown, still landing');
scene.time.now = 2600; // approach settled + the fade is running
scene.updateEscort();
assert.equal(st.routeFor(JOB).escort.phase, 'delivering', '…the touchdown is still in flight');
assert.ok(scene._escortTransport && scene._escortTransport.alpha < 1, '…the convoy is fading out beside the world');
scene.time.now = 3350; // approach + fade complete
scene.updateEscort();
const routeF = st.routeFor(JOB);
assert.equal(routeF.escort.phase, 'delivered', 'arrival + sky clear DELIVERS the contract');
assert.equal(routeF.escort.phase, 'delivered', 'the touchdown closes → DELIVERED');
assert.equal(st.isCompleted(JOB), true, '…and it is COMPLETE (ready for the claim)');
assert.ok(!scene._escortTransport || scene._escortTransport.destroyed, 'the convoy stands down (a clean break-off)');

View File

@ -1812,7 +1812,7 @@ export function escortEncounterXp(o = {}) {
* @returns {{phase:string, originSystemId:string, originSystemName:string, destSystemId:string, destSystemName:string, destObjectType:string, destObjectId:string, destObjectName:string, hops:number, transportHull:number, transportShields:number, escortCount:number, name:string}}
*/
function normalizeEscort(raw) {
const PHASES = ['rendezvous', 'transit', 'failed', 'delivered'];
const PHASES = ['rendezvous', 'transit', 'failed', 'delivered', 'delivering'];
const phase = PHASES.includes(raw?.phase) ? raw.phase : 'rendezvous';
const escortCountRange = config.get('sidequests.escort.escortCount', [1, 2]);
const eLo = Math.max(0, Math.floor(Number(Array.isArray(escortCountRange) ? escortCountRange[0] : 1)));

View File

@ -3796,7 +3796,7 @@ export class GameScene extends Phaser.Scene {
// ESCORT transit: the convoy's own director owns the lane — the normal
// director holds (the escort's waves are the combat, one at a time).
const sel = this._escortRoute?.();
if (sel && sel.route.escort?.phase === 'transit') return true;
if (sel && (sel.route.escort?.phase === 'transit' || sel.route.escort?.phase === 'delivering')) return true;
return false;
}
@ -5584,7 +5584,18 @@ export class GameScene extends Phaser.Scene {
const rec =
pool.find((o) => o && String(o.id) === id && id !== '') ??
pool.find((o) => o && String(o.name) === name && name !== '');
if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.y)) return { x: rec.x, y: rec.y, id: rec.id, name: rec.name };
if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.y)) {
// The world's rendered radius — the live entity's disc/keepout when it
// stands here (a planet's disc / a station's keepout), else a fallback.
// The delivery anchors the convoy's landing just off this disc.
let radius = Number(rec.radius) || 0;
if (!radius) {
const planet = (this.systemPlanets ?? []).find((e) => e && (e.name === rec.name || e.discoveryId === rec.id));
const station = (this.systemStations ?? []).find((e) => e && (e.settlement?.id === rec.id || e.discoveryName === rec.name));
radius = Number(planet?.radius) || Number(station?.bound) || Number(station?.radius) || 0;
}
return { x: rec.x, y: rec.y, id: rec.id, name: rec.name, radius: radius > 0 ? radius : 40 };
}
return null;
}
@ -5652,6 +5663,100 @@ export class GameScene extends Phaser.Scene {
try { this.combat._setActive?.(); } catch { /* defensive */ }
}
/** Is the destination world on the player's screen right now? */
_escortObjectOnScreen(obj) {
const cam = this.cameras?.main;
if (!cam || !obj) return false;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
return circleInView(obj.x, obj.y, Number(obj.radius) || 24, view);
}
/**
* THE DELIVERY — the convoy lands beside the destination world. The
* transport + its escort fighters break off the wing, glide in to a slot
* just off the world's disc, and fade out (the touchdown: they are through,
* not destroyed). Time-driven (frame-rate independent): a fixed fly-in,
* then a touch-down fade. On the fade's close the convoy is torn down, the
* phase seals to 'delivered', and the contract completes + pays out.
*/
_runEscortDelivery(id, route) {
const esc = route.escort;
const now = this.time?.now ?? 0;
if (esc.deliverStart == null) esc.deliverStart = now;
const elapsed = Math.max(0, now - Number(esc.deliverStart) || 0);
// THE WORLD TO LAND BESIDE — the anchor sits just off its disc (the
// 'beside the planet' spot). No world (an edge case) — anchor on the ship.
const obj = this._escortDestinationObject(route);
const hasWorld = !!(obj && Number.isFinite(obj.x) && Number.isFinite(obj.y));
const wx = hasWorld ? obj.x : (this.ship?.x ?? 0);
const wy = hasWorld ? obj.y : (this.ship?.y ?? 0);
const wr = hasWorld ? (Number(obj.radius) || 40) : 0;
const ax = wx + (wr > 0 ? wr + 48 : 72);
const ay = wy;
// THE CONVOY TO LAND — the transport + its escort fighters (deduped;
// the transport also rides the allies roster).
const seen = new Set();
const convoy = [];
if (this._escortTransport && !this._escortTransport.dead) { seen.add(this._escortTransport); convoy.push(this._escortTransport); }
for (const a of this.combat?.allies ?? []) {
if (a && !a.dead && (a._escortCrew || a.isTransport || a === this._escortTransport) && !seen.has(a)) {
seen.add(a); convoy.push(a);
}
}
// Nothing left to land (a jump tore the scene down) — seal + pay now.
if (convoy.length === 0) { this._sealEscortDelivery(id, route); return; }
const flyMs = 1500; // the approach
const fadeMs = 800; // the touch-down fade
const totalMs = flyMs + fadeMs;
for (let i = 0; i < convoy.length; i++) {
const a = convoy[i];
if (!Number.isFinite(a._landFromX)) { a._landFromX = a.x; a._landFromY = a.y; a._landBaseScale = Number(a.scale) || 1; }
// Personal slot fanned just off the world (the transport leads the line).
const side = i % 2 ? 1 : -1;
const slotX = ax + (i === 0 ? 0 : side * (28 + i * 26));
const slotY = ay + side * (16 + i * 13);
// Approach — an ease-out glide from where the ship broke off.
const p = Math.min(1, elapsed / flyMs);
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic
a.x = a._landFromX + (slotX - a._landFromX) * e;
a.y = a._landFromY + (slotY - a._landFromY) * e;
if (a.body && typeof a.body.setVelocity === 'function') a.body.setVelocity(0, 0);
// Face the world (aim along the final approach while it still has way to go).
const tX = slotX - a.x, tY = slotY - a.y;
if (tX * tX + tY * tY > 9) a.rotation = Math.atan2(tY, tX) + (a.artOffset || 0);
// Touch-down fade (begins once the approach has settled) + a shrink.
const f = Math.max(0, Math.min(1, (elapsed - flyMs) / fadeMs));
a.alpha = 1 - f;
a.setScale((a._landBaseScale || 1) * (1 - f * 0.5));
}
if (elapsed >= totalMs) this._sealEscortDelivery(id, route);
}
/**
* SEAL THE DELIVERY — the touchdown is done: tear the landed convoy down
* (a stand-down, no kill FX), close the phase, and pay out the claim.
*/
_sealEscortDelivery(id, route) {
const esc = route.escort;
if (esc.phase !== 'delivered') esc.phase = 'delivered';
esc.deliverStart = null;
this._escortBreakConvoy(); // the convoy is through — stand it down (no FX)
this.sideQuestState.setRoute(id, route);
if (!this.sideQuestState.isCompleted(id)) this.sideQuestState.complete(id);
const t = config.get('sidequests.ui.escortArrivedToast', {});
this.playSfx('construct');
this.consoleToast(
String(t.text ?? 'CONVOY DELIVERED — {name} IS THROUGH').replace('{name}', esc.name || 'the convoy'),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
/** The escort wave's SCRIPT (a single rolling wave — escortWaveRoster's roster). */
escortWaveScript(level = 0) {
const r = escortWaveRoster(level);
@ -5673,7 +5778,7 @@ export class GameScene extends Phaser.Scene {
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one at a time
if (!this.escortDirector) return;
const level = Number(this.combat.xp?.level) || 0;
const comp = this.escortDirector.update(this.time?.currentTime ?? 0, {
const comp = this.escortDirector.update(this.time?.now ?? 0, {
allowed: this._escortLaneAllowed(),
live: this.combat.active,
xpLevel: level,
@ -5749,6 +5854,12 @@ export class GameScene extends Phaser.Scene {
if (esc.phase === 'failed' || esc.phase === 'delivered') return; // over (a landing resets / a claim closes)
// DELIVERY — the convoy has broken off to land beside the destination
// world: it flies in beside the planet/station and fades out (the
// touchdown). A fixed time-driven approach + fade; the seal + payout
// land on the fade's close (_sealEscortDelivery).
if (esc.phase === 'delivering') { this._runEscortDelivery(id, route); return; }
// RENDEZVOUS LINK — the ship reaches the convoy's standing point; the convoy
// forms on the wing and the lane goes hot.
if (esc.phase === 'rendezvous' && esc.originSystemId === this.systemRecord.id) {
@ -5788,17 +5899,22 @@ export class GameScene extends Phaser.Scene {
if (this._escortLaneAllowed()) this._fireEscortWave(id, route);
if (esc.destSystemId === this.systemRecord.id) {
const obj = this._escortDestinationObject(route);
const radius = Math.max(1, Number(config.get('sidequests.escort.visitRadius', 460)) || 460);
if (obj && Math.hypot(this.ship.x - obj.x, this.ship.y - obj.y) <= radius && this._escortFieldClear()) {
esc.phase = 'delivered';
// THE WITNESS — the destination world is VISIBLE ON SCREEN (the player
// has flown it into view, not merely near it) and the sky is clear: the
// delivery begins — the convoy breaks off to land beside the world and
// fades out (phase 'delivering'; the seal + payout land on the fade's
// close, _sealEscortDelivery).
if (obj && this._escortObjectOnScreen(obj) && this._escortFieldClear()) {
esc.phase = 'delivering';
esc.deliverStart = null; // stamped on the first delivering frame
this.sideQuestState.setRoute(id, route);
this._escortBreakConvoy(); // the job's done — the convoy stands down
if (!this.sideQuestState.isCompleted(id)) this.sideQuestState.complete(id);
const t = config.get('sidequests.ui.escortArrivedToast', {});
const t = config.get('sidequests.ui.escortArrivingToast', {});
this.playSfx('construct');
this.consoleToast(String(t.text ?? 'CONVOY DELIVERED — {name} IS THROUGH').replace('{name}', esc.name || 'the convoy'), {
glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined,
});
this.consoleToast(
String(t.text ?? 'CONVOY INBOUND — {name} IS SETTING DOWN AT {dest}')
.replace('{name}', esc.name || 'the convoy').replace('{dest}', esc.destObjectName || esc.destSystemName || 'the destination'),
{ glyph: t.glyph ?? '✈', glyphColor: t.color ? toCss(t.color) : undefined },
);
}
}
}