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:
parent
0a53931f16
commit
5810560b18
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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)');
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue