From 5810560b18eb793e7d313423ef7f1d2dd43e38a9 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 23 Sep 2026 21:39:59 -0600 Subject: [PATCH] Add escort delivery touchdown animation and on-screen witness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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`). --- data/sidequests.json | 7 +- dev/escort-delivery-repro.mjs | 217 ++++++++++++++++++++++++++++++++++ dev/escort-flow.test.mjs | 46 ++++++- js/quests/SideQuests.js | 2 +- js/scenes/GameScene.js | 140 ++++++++++++++++++++-- 5 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 dev/escort-delivery-repro.mjs diff --git a/data/sidequests.json b/data/sidequests.json index 4d28ede..e2369ac 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -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", diff --git a/dev/escort-delivery-repro.mjs b/dev/escort-delivery-repro.mjs new file mode 100644 index 0000000..75bd05e --- /dev/null +++ b/dev/escort-delivery-repro.mjs @@ -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)); diff --git a/dev/escort-flow.test.mjs b/dev/escort-flow.test.mjs index 9ac053d..0ef8966 100644 --- a/dev/escort-flow.test.mjs +++ b/dev/escort-flow.test.mjs @@ -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)'); diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index 4fe4b3f..e7ca297 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -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))); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 8944b9b..aff2c2d 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 }, + ); } } }