diff --git a/assets/images/originals/lava-shop-01.png b/assets/images/originals/lava-shop-01.png new file mode 100644 index 0000000..bbdd72d Binary files /dev/null and b/assets/images/originals/lava-shop-01.png differ diff --git a/assets/images/originals/lava-shop-03.png b/assets/images/originals/lava-shop-03.png new file mode 100644 index 0000000..600198e Binary files /dev/null and b/assets/images/originals/lava-shop-03.png differ diff --git a/data/landing.json b/data/landing.json index 8cea058..83f46ba 100644 --- a/data/landing.json +++ b/data/landing.json @@ -81,7 +81,7 @@ { "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, - { "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, + { "land": "arid-land-03.mp4", "surface": "arid-surface-03.mp4", "takeoff": "arid-takeoff-03.mp4", "shop": "arid-shop-03.mp4" }, { "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "capital-shop-neutral.mp4", "bar": "capital-bar-neutral.mp4" }, { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, diff --git a/data/shipScreen.json b/data/shipScreen.json index b8c443f..96e6b3a 100644 --- a/data/shipScreen.json +++ b/data/shipScreen.json @@ -360,6 +360,26 @@ "exitsTitle": "BEST EXITS", "exitsEmpty": "NO CHARTED EXIT YET \u2014 EVERY CAPITAL BUYS EVERYTHING", "exitMeta": "{hops} HOPS", + "hereTag": "HERE", + "exitCue": "TAP TO SET DESTINATION", + "exitHint": "TAP AN EXIT TO SET IT AS YOUR DESTINATION", + "exitHover": { + "_comment": "the row's hover state (the tap surface lights up: the plate fill, its neon edge + glow)", + "fill": "#0c2740", + "fillAlpha": 0.85 + }, + "setDestination": { + "_comment": "The BEST-EXIT confirm (js/ui/ConfirmOverlay.js, hosted by the console so it always paints above it) — the same SET DESTINATION dialog the planet shop's scout and the map's SYSTEM tab use. {object}/{system} name the exit's WORLD and its system, {class} its economic class, {price} the CR/unit it pays, {sign}{profit} the per-unit margin against `baseline`, {hops} the jump distance. line2NoHops is the row without a charted hop count.", + "title": "SET DESTINATION", + "confirmLabel": "SET DESTINATION", + "cancelLabel": "CANCEL", + "accent": "#00e5ff", + "width": 520, + "height": 176, + "line1": "DESTINATION \u2014 {object} \u00b7 {system}", + "line2": "{class}SELLS {price} CR/UNIT \u00b7 {sign}{profit}/UNIT \u00b7 {hops} HOPS", + "line2NoHops": "{class}SELLS {price} CR/UNIT \u00b7 {sign}{profit}/UNIT" + }, "profitBar": { "_comment": "the per-unit margin rail under each exit row \u2014 scaled against the list's best row, so goods of different value compare the same way", "track": "#14324f", diff --git a/dev/ship-cargo-dest.test.mjs b/dev/ship-cargo-dest.test.mjs new file mode 100644 index 0000000..b221d2e --- /dev/null +++ b/dev/ship-cargo-dest.test.mjs @@ -0,0 +1,370 @@ +/** + * SHIP console → CARGO HOLDS → BEST EXITS tap test (dev tool, Node, no + * browser): + * + * node dev/ship-cargo-dest.test.mjs + * + * The bug this guards: the bay manifest listed a held good's charted exits + * as pure TEXT — clicking a destination did nothing, so the player could + * never set a route from the ship console. The rows are now tap surfaces and + * CONFIRM routes the ship through the map's own seam (GameScene.setDestination), + * exactly like the planet shop's scout panel (js/ui/shop/ScoutPanel.js → + * SurfaceScene.askSetDestination). + * + * Asserted with the REAL js/ui/ShipWindow.js over the real data files + a real + * generated galaxy (a stub scene — phaser-loader.mjs swaps in dev/phaser-stub.mjs): + * + * - HOSTING: the confirm is a ConfirmOverlay CHILD of the window at depth 30 + * (above the plate / feed / glitch layers) — it can never be hidden behind + * the console it belongs to, which is what "not visible to the player" was; + * - THE ROWS: every charted exit row is tappable and carries onTap(); the row + * for the system the ship is IN reads HERE and has no tap surface; + * - THE ASK: tapping a row opens the dialog with data/shipScreen.json → + * cargo.detail.setDestination's title / SET DESTINATION / CANCEL labels and + * body lines built from the exit (world, system, class, price, margin); + * - CONFIRM: fires onSetDestination(systemId, objectId) for that exit's own + * WORLD once the collapse lands — and only then; + * - CANCEL / scrim: closes with no destination written; + * - CLOSE: taking the console down takes the dialog with it (no orphan scrim + * swallowing clicks); + * - A repaint while the dialog is up (the 250ms telemetry poll) never drops + * it. + */ +process.env.NODE_ENV = 'dev'; + +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) +import { readdirSync, readFileSync } from 'node:fs'; + +const { config } = await import('../js/config/Config.js'); +const data = {}; +for (const f of readdirSync(new URL('../data', import.meta.url))) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8')); +} +config.init(data); + +const Phaser = (await import('../js/vendor/phaser.js')).default; + +const { CargoHold } = await import('../js/economy/CargoHold.js'); +const { Galaxy } = await import('../js/galaxy/Galaxy.js'); +const { scoutGood } = await import('../js/economy/TradeScout.js'); +const { ShipWindow } = await import('../js/ui/ShipWindow.js'); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +// --------------------------------------------------------------------------- +// A headless scene: just enough for the console + ConfirmOverlay to build. +// --------------------------------------------------------------------------- +function stubObject(extra = {}) { + return { + setOrigin() { return this; }, + setAlpha(a) { this.alpha = a; return this; }, + setScale() { return this; }, + setTint() { return this; }, + setPosition(x, y) { this.x = x; this.y = y; return this; }, + setStrokeStyle() { return this; }, + setSize(w, h) { this.width = w; this.height = h; return this; }, + setBlendMode() { return this; }, + setInteractive(opts) { this._interactive = opts; this._events = this._events || {}; return this; }, + setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; }, + on(ev, fn) { (this._events ||= {})[ev] = [...(this._events?.[ev] ?? []), fn]; return this; }, + once() { return this; }, + off() { return this; }, + emit(ev, ...a) { (this._events?.[ev] ?? []).forEach((fn) => fn(...a)); return this; }, + setText(s) { this.text = String(s); return this; }, + setColor() { return this; }, + setFontSize(px) { this._fontSize = px; return this; }, + setWordWrap() { return this; }, + setWordWrapWidth(w) { this._wrapW = w; return this; }, + setLineSpacing() { return this; }, + setPadding() { return this; }, + setScrollFactor() { return this; }, + setDepth(d) { this.depth = d; return this; }, + set() { return this; }, + setVisible(v) { this.visible = v; return this; }, + setDisplaySize(w, h) { this.width = w; this.height = h; return this; }, + setVolume() { return this; }, + setLoop() { return this; }, + play() { return this; }, + stop() { return this; }, + resume() { return this; }, + pause() { return this; }, + fillStyle() { return this; }, + lineStyle() { return this; }, + fillPoints() { return this; }, + strokePoints() { return this; }, + lineBetween() { return this; }, + rect() { return this; }, + fillRect() { return this; }, + strokeRect() { return this; }, + lineTo() { return this; }, + moveTo() { return this; }, + strokeCircle() { return this; }, + fillCircle() { return this; }, + fillRoundedRect() { return this; }, + strokeRoundedRect() { return this; }, + fillGradientRect() { return this; }, + setTexture() { return this; }, + beginPath() { return this; }, + closePath() { return this; }, + arc() { return this; }, + fill() { return this; }, + stroke() { return this; }, + clear() { return this; }, + destroy() { this.destroyed = true; }, + ...extra, + }; +} + +// One clock for the whole harness (the dialog's open/close animations are +// driven by it — a fresh clock per window would run time BACKWARDS). +const clock = { now: 0 }; + +function makeScene() { + const scene = { + scale: { width: 1280, height: 720, on() { return this; } }, + add: { + text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style, width: 64, height: 18 }), + image: (x, y, key, frame) => stubObject({ x, y, key, frame }), + graphics: () => stubObject({}), + circle: (x, y, r) => stubObject({ x, y, r }), + rectangle: (x, y, w, h, key, alpha) => stubObject({ x, y, width: w, height: h, key, alpha }), + video: () => stubObject({}), + tileSprite: (x, y, w, h, key) => stubObject({ x, y, width: w, height: h, key }), + existing: (o) => o, + }, + tweens: { + add: (t) => { + const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; }; + if (Array.isArray(t?.targets)) t.targets.forEach(apply); + else if (t?.targets) apply(t.targets); + t.onComplete?.(); // the console's close tween finishes instantly headless + return { destroy() {}, stop() {} }; + }, + killTweensOf() {}, + }, + time: clock, // SHARED so every window sees one monotonic clock + input: { pointer: {}, on() { return this; }, off() { return this; } }, + events: { on() { return this; }, off() { return this; }, once() { return this; }, emit() { return this; } }, + // true = "already baked": the console skips its document.createElement + // texture authoring (there is no DOM headless). + textures: { exists: () => true, addCanvas() {} }, + cameras: { main: { setScroll() { return this; }, flash() { return this; } } }, + game: { loop: clock }, + playSfx() {}, + }; + scene.add.container = (x, y) => new Phaser.GameObjects.Container(scene, x, y); + return scene; +} + +// --------------------------------------------------------------------------- +// The run: a real galaxy with several charted systems + a hold carrying goods. +// --------------------------------------------------------------------------- +const CFG = config.section('shipScreen.cargo.detail', {}); +const ASK = CFG.setDestination ?? {}; +const galaxy = Galaxy.create('orbit-ship-cargo-dest'); +const localSystemId = galaxy.homeSystemId; +// TradeScout only ranks exits among systems the run has ENTERED — chart a few. +const visited = [localSystemId, ...[...galaxy.byId.keys()].filter((id) => id !== localSystemId).slice(0, 12)]; + +function snapshot(items) { + const hold = new CargoHold(Math.max(items.length, 4)); + if (items.length) hold.add(items); + return { + live: { hull: 100, hullMax: 100, shields: 60, shieldsMax: 60 }, + ship: { + stats: { ...data.ship.stats, cargoHold: Math.max(items.length, 4), mineralStorage: 100 }, + minerals: 12, + credits: 2500, + cargo: { used: hold.used, capacity: hold.capacity, items: [...hold.items.map((i) => i.id ?? i)] }, + }, + level: null, + playerClass: null, + }; +} + +// The good with the most charted exits (the manifest needs a real list). +const goods = config.get('goods.goods', []); +let bestGood = null; +let bestReport = null; +for (const g of goods) { + const rep = scoutGood(g.id, { + galaxy, + systemId: localSystemId, + buyUnit: Number(g.baseValue) || 0, + visited, + topN: Math.max(1, Number(CFG.topN) || 3), + }); + const remote = (rep?.exits ?? []).filter((e) => e.systemId !== localSystemId).length; + if (!bestReport || remote > (bestReport.exits.filter((e) => e.systemId !== localSystemId).length)) { + bestReport = rep; + bestGood = g; + } +} +check(`the fixture has charted exits (${bestReport.exits.length} rows for ${bestGood.id})`, bestReport.exits.length >= 2); + +function makeWindow({ isLocalSystem } = {}) { + const scene = makeScene(); + const calls = []; + const snap = snapshot([bestGood.id]); + const win = new ShipWindow(scene, { + getSnapshot: () => snap, + // GameScene.shipScreenScoutReport — the same call the console makes live. + getScoutReport: (goodId) => { + const g = goods.find((x) => x.id === goodId); + return scoutGood(goodId, { + galaxy, + systemId: localSystemId, + buyUnit: Number(g?.baseValue) || 0, + visited, + topN: Math.max(1, Number(CFG.topN) || 3), + }); + }, + onSetDestination: (systemId, objectId) => calls.push({ systemId, objectId }), + isLocalSystem: isLocalSystem ?? ((id) => id === localSystemId), + }); + win._snap = snap; + win.openTab('cargo'); + win._selectedBay = 0; // what tapping a filled bay does + win._buildBody(snap); + return { scene, win, calls }; +} + +/** Advance the shared clock, driving the dialog every frame (what the console + * does through ShipWindow.update). */ +function pump(win, ms, stepMs = 16) { + for (let elapsed = 0; elapsed <= ms; elapsed += stepMs) { + clock.now += stepMs; + win.dialog.update(clock.now); + } +} + +// The rows a window draws come from its OWN scoutGood() call, so rows are +// matched by system id (the fixture is deterministic — same galaxy, same +// charted list, same ranking). +const remote0 = bestReport.exits.find((e) => e.systemId !== localSystemId); +const rowBy = (win, sysId) => win._exitRows.find((r) => r && r.exit?.systemId === sysId); + +// =========================================================================== +console.log('== HOSTING — the confirm is a child of the console, above its chrome =='); +{ + const { win } = makeWindow(); + check('the window owns a ConfirmOverlay', !!win.dialog && win.dialog.constructor.name === 'ConfirmOverlay'); + check('it is a CHILD of the window container (fades + dies with it)', win.children?.includes(win.dialog) === true); + check('depth 30 inside the console (plate/feed/glitch are all below it)', win.dialog.depth === 30); + check( + 'the dialog is sized from data (cargo.detail.setDestination, capped to the plate)', + win.dialog.baseW === Math.min(ASK.width, win.geo.plateW - 30) && win.dialog.baseH === ASK.height + ); + check('the scrim spans the whole screen', win.dialog.areaW === win.scene.scale.width && win.dialog.areaH === win.scene.scale.height); + check('it starts hidden and inert', win.dialog.state === 'hidden' && win.isDialogOpen === false); +} + +console.log('\n== THE ROWS — every charted exit is a tap surface, HERE is not =='); +{ + const { win } = makeWindow(); + const rows = win._exitRows.filter(Boolean); + check(`one row per drawn exit (${rows.length})`, rows.length === Math.min(bestReport.exits.length, Number(CFG.topN) || 3)); + check( + 'a remote exit row is tappable (onTap + a hit area)', + typeof rowBy(win, remote0.systemId)?.onTap === 'function' && !!rowBy(win, remote0.systemId)?.hit + ); + // The row for the system the ship is IN: HERE, and inert (pointing the ship + // at itself is the dead end SurfaceScene's scout guards the same way). + const { win: winHere } = makeWindow({ isLocalSystem: (id) => id === remote0.systemId }); + const rec = rowBy(winHere, remote0.systemId); + check(`the local row (${remote0.systemName}) reads HERE`, rec?.here === true); + check('HERE has no tap surface (never a destination)', rec?.onTap === null); + const hint = win._kids.filter((k) => typeof k.text === 'string').map((k) => k.text); + check( + `the rows announce themselves ("${String(CFG.exitHint).toUpperCase()}")`, + hint.includes(String(CFG.exitHint).toUpperCase()) && hint.includes(String(CFG.exitCue).toUpperCase()) || hint.some((t) => t.includes('TAP')) + ); +} + +console.log('\n== THE ASK — a tap opens SET DESTINATION / CANCEL =='); +{ + const { win, calls } = makeWindow(); + rowBy(win, remote0.systemId).onTap(); + const d = win.dialog; + check('the dialog is up', d.state !== 'hidden' && win.isDialogOpen === true); + pump(win, 400); + check(`title decodes to "${ASK.title}"`, d.title.text === String(ASK.title)); + check(`confirm label "${String(ASK.confirmLabel).toUpperCase()}"`, d.confirmBtn.labelText.text === String(ASK.confirmLabel).toUpperCase()); + check(`cancel label "${String(ASK.cancelLabel).toUpperCase()}"`, d.cancelBtn.labelText.text === String(ASK.cancelLabel).toUpperCase()); + const body = d.bodyTexts.map((t) => t.text); + console.log(' ', JSON.stringify(body)); + check('line 1 names the exit WORLD + system', body[0] === `DESTINATION — ${String(remote0.objectName ?? remote0.systemName).toUpperCase()} · ${String(remote0.systemName).toUpperCase()}`); + check('line 2 prices it (sell price + per-unit margin)', body[1].includes(`${remote0.price} CR/UNIT`) && body[1].includes(`${Math.abs(remote0.profit)}/UNIT`)); + check('the plate is on screen inside the scrim', d.state === 'shown' || d.state === 'opening'); + + // no destination written while the player has not answered + check('nothing is set before the answer', calls.length === 0); +} + +console.log('\n== CONFIRM — the seam gets that exit\'s system + world =='); +{ + const { win, calls } = makeWindow(); + rowBy(win, remote0.systemId).onTap(); + pump(win, 400); + win.dialog.fireConfirm(); // the CONFIRM button's own action + check('the seam has NOT fired mid-animation', calls.length === 0); + pump(win, 400); + check('CONFIRM fires onSetDestination once', calls.length === 1); + check(`with the exit's system (${calls[0]?.systemId})`, calls[0]?.systemId === remote0.systemId); + check(`+ the exit's world (${String(calls[0]?.objectId)})`, calls[0]?.objectId === (remote0.objectId ?? null)); + check('the dialog is down again', win.isDialogOpen === false && win.dialog.state === 'hidden'); +} + +console.log('\n== CANCEL / SCRIM — no destination =='); +{ + const { win, calls } = makeWindow(); + rowBy(win, remote0.systemId).onTap(); + pump(win, 400); + win.dialog.cancel(); // the CANCEL button, the scrim's press, or ESC + pump(win, 400); + check('CANCEL writes nothing', calls.length === 0); + check('the dialog is down', win.isDialogOpen === false && win.dialog.state === 'hidden'); + + // a second tap asks again (the dialog instance is reusable) + rowBy(win, remote0.systemId).onTap(); + pump(win, 400); + check('a later row still asks', win.isDialogOpen === true); +} + +console.log('\n== CLOSE — the dialog goes with the console =='); +{ + const { win, calls } = makeWindow(); + rowBy(win, remote0.systemId).onTap(); + pump(win, 400); + win.close(); + check('closing cancels an open confirm', win.dialog.state === 'closing' || win.dialog.state === 'hidden'); + pump(win, 400); + check('nothing is left up to swallow clicks', win.isDialogOpen === false && win.dialog.state === 'hidden'); + check('and nothing was set', calls.length === 0); +} + +console.log('\n== REPAINT — the telemetry poll never drops the ask =='); +{ + const { win } = makeWindow(); + rowBy(win, remote0.systemId).onTap(); + pump(win, 300); + const before = win.dialog.bodyTexts.map((t) => t.text); + // The 250 ms telemetry poll: ShipWindow.update() on the SAME clock, with the + // dialog mid-animation (it owns the screen — the window must not drop it). + clock.now += 1000; + win.update(clock.now); + check('the dialog survives a body repaint', win.isDialogOpen === true); + pump(win, 400); + check('still the same ask', win.dialog.bodyTexts.map((t) => t.text).join('|') === before.join('|')); + win.dialog.fireConfirm(); + pump(win, 400); + check('and it still works', true); +} + +console.log(failures === 0 ? '\nSHIP cargo exit confirm: ALL PASS' : `\n${failures} FAILURE(S)`); +process.exit(failures ? 1 : 0); diff --git a/dev/ship-shot.mjs b/dev/ship-shot.mjs index f44915d..b0610e5 100644 --- a/dev/ship-shot.mjs +++ b/dev/ship-shot.mjs @@ -182,6 +182,64 @@ try { await sleep(700); } + // ---- TAP A BEST EXIT (?tapExit= | ?clickExit=&answer=confirm|cancel) + // The manifest's rows are tap surfaces: pressing one asks the player to lock + // that exit in, and CONFIRM routes the ship through GameScene.setDestination. + // ?tapExit — the row's own press seam (what its hit area calls), so the + // dialog + routing run without needing a live pointer. + // ?clickExit — a REAL pointerdown/up dispatched on the canvas at the row's + // rect, which is the thing that used to do nothing: it proves + // Phaser's hit-test reaches the row (Phaser reads `button`, so + // MouseEvent is enough). + let exitTap = 'n/a'; + const rowIdx = q.has('clickExit') ? Number(q.get('clickExit')) : q.has('tapExit') ? Number(q.get('tapExit')) : null; + if (rowIdx !== null && win?.isOpen) { + const rec = (win._exitRows ?? []).filter(Boolean)[Number.isFinite(rowIdx) ? rowIdx : 0]; + const before = { sys: s.destination?.systemId ?? null, obj: s.destination?.objectId ?? null }; + if (!rec) { + exitTap = `row ${rowIdx}: none drawn`; + } else if (q.has('clickExit')) { + // A REAL mouse press on the row. Phaser binds classic mouse events on the + // canvas and recomputes the pointer's world coords per loop step, so the + // move needs a frame before the down (dev/comms-click.mjs's lesson). + const cv = s.game.canvas; + const r = cv.getBoundingClientRect(); + const kx = r.width / s.game.scale.width; + const ky = r.height / s.game.scale.height; + const cx = r.left + (rec.hit.x + rec.hit.width / 2) * kx; + const cy = r.top + (rec.hit.y + rec.hit.height / 2) * ky; + const mk = (type, buttons) => + new MouseEvent(type, { bubbles: true, composed: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0, buttons }); + cv.dispatchEvent(mk('mousemove', 0)); + s.game.loop.step(performance.now()); + cv.dispatchEvent(mk('mousedown', 1)); + cv.dispatchEvent(mk('mouseup', 0)); + s.game.loop.step(performance.now()); + await sleep(700); // the decode-in + exitTap = `CLICKED ${rec.exit?.objectName ?? rec.exit?.systemName} at scene (${Math.round(rec.hit.x + rec.hit.width / 2)},${Math.round(rec.hit.y + rec.hit.height / 2)}) · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}${rec.here ? ' (HERE — inert)' : ''}`; + } else if (typeof rec.onTap !== 'function') { + exitTap = `${rec.exit?.systemName}: no tap surface (HERE)`; + } else { + rec.onTap(); + await sleep(700); + exitTap = `tapped ${rec.exit?.objectName ?? rec.exit?.systemName} · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}`; + } + const answer = q.get('answer'); + if (answer === 'confirm' && win.isDialogOpen) { + win.dialog.fireConfirm(); + await sleep(900); // the collapse — the seam fires when it lands + // The destination is the ONLY stored state — the path derives from it + // (GameScene.routePlan, js/galaxy/Route.js), so check both. + const plan = s.routePlan?.() ?? null; + exitTap += + ` → CONFIRM dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · registry=${s.registry.get('destination')?.systemId ?? '—'} · console=${win.isOpen ? 'OPEN' : 'closed'} · route=${plan?.hops ?? 0} hop(s), ${s.routeEdgeKeys?.().length ?? 0} lane(s)`; + } else if (answer === 'cancel' && win.isDialogOpen) { + win.dialog.cancel(); + await sleep(600); + exitTap += ` → CANCEL dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · dialog=${win.isDialogOpen ? 'OPEN' : 'closed'}`; + } + } + // ---- live refresh while open (?mutate=1) ------------------------------- // The console polls the scene's snapshot seam on its own beat; changing the // hold through the run's OWN api (a buy landing in a bay, ore aboard) must @@ -222,6 +280,7 @@ try { `minerals ${s.ship.minerals}/${s.ship.stats.mineralStorage} · credits ${s.ship.credits}`, `locked tab: ${locked}`, `live update: ${liveUpdate}`, + `exit tap: ${exitTap}`, ]; setReport(lines); window.__SHIP_SHOT = { ready: true, lines, errors }; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index aa6f368..44c7c72 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1384,6 +1384,18 @@ export class GameScene extends Phaser.Scene { this.shipWindow = new ShipWindow(this, { getSnapshot: () => this.shipScreenSnapshot(), getScoutReport: (goodId) => this.shipScreenScoutReport(goodId), + // A BEST-EXIT row on the bay manifest was CONFIRMED (the console's own + // SET DESTINATION dialog, js/ui/ConfirmOverlay.js): the exit's system + + // its WORLD become the route target through the map's seam. The console + // closes on the way out (what MapWindow does), so the DESTINATION SET + // toast and the orange compass are actually on screen for it. + onSetDestination: (systemId, objectId) => { + this.shipWindow?.close(); + this.setDestination(systemId, objectId); + }, + // The row for the system the ship is IN can never be a destination — the + // manifest marks it HERE and leaves it inert. + isLocalSystem: (systemId) => !!systemId && systemId === this.systemRecord?.id, onLocked: () => this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), { glyph: '✕', @@ -7071,8 +7083,13 @@ export class GameScene extends Phaser.Scene { this.questWindow.close(); return; } - // The ship console (depth 80) — the same contract. + // The ship console (depth 80) — the same contract, and its bay-manifest + // confirm sits on top: ESC cancels the dialog, not the console. if (this.shipWindow && this.shipWindow.isOpen) { + if (this.shipWindow.isDialogOpen) { + this.shipWindow.dialog.cancel(); + return; + } this.shipWindow.close(); return; } diff --git a/js/ui/ShipWindow.js b/js/ui/ShipWindow.js index 895a067..b10525f 100644 --- a/js/ui/ShipWindow.js +++ b/js/ui/ShipWindow.js @@ -39,7 +39,11 @@ * CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay) * + the aggregate mineral load; a filled bay opens its manifest * with the charted exits (js/economy/TradeScout.js), the same - * report the planet shop's scout panel renders. + * report the planet shop's scout panel renders. A row is + * TAPPABLE: it asks (the map console's ConfirmOverlay, hosted by + * this window so it always paints above the console) and CONFIRM + * sets that exit's world as the destination through the scene's + * onSetDestination seam — the shop scout's contract * * The window is a VIEW. Every number comes from the scene's snapshot seam * (GameScene.shipScreenSnapshot — ship.stats, the live combat session, the XP @@ -57,6 +61,7 @@ import { fontStack, themeColor } from '../utils/Theme.js'; import { ScrambleDecode } from '../utils/Decode.js'; import { playSfxOn } from '../utils/Sfx.js'; import { CyberShape } from './CyberShape.js'; +import { ConfirmOverlay } from './ConfirmOverlay.js'; import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js'; import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js'; @@ -133,6 +138,13 @@ function brackets(g, x, y, w, h, o = {}) { g.lineBetween(x2, y2, x2, y2 - len); } +/** A data template: every `{key}` the map carries is filled in (an unknown + * placeholder is left alone, so a typo in data never prints "undefined"). */ +function tpl(str, map = {}) { + return String(str ?? '').replace(/\{(\w+)\}/g, (m, k) => + (map[k] === undefined || map[k] === null ? m : String(map[k]))); +} + /** A filled diamond marker (group bullets, the journey's nodes). */ function diamond(g, x, y, r, color, alpha = 1) { if (!(r > 0)) return; @@ -157,6 +169,13 @@ export class ShipWindow extends Phaser.GameObjects.Container { * @param {(goodId: string) => object|null} [o.getScoutReport] — a held * good's trade report (TradeScout.scoutGood over the run's chart), shown * by the bay manifest's BEST EXITS list + * @param {(systemId: string, objectId: string|null) => void} [o.onSetDestination] + * a BEST-EXIT row's confirm landed (the manifest's SET DESTINATION dialog) + * — the scene records the route target (GameScene.setDestination, the same + * seam the map console's SYSTEM tab uses) + * @param {(systemId: string) => boolean} [o.isLocalSystem] — is this the + * system the ship is IN right now (a local exit row can never be a + * destination, so it reads as HERE and stays inert) */ constructor(scene, o = {}) { super(scene, 0, 0); @@ -169,6 +188,8 @@ export class ShipWindow extends Phaser.GameObjects.Container { this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null; this.onChooseClass = typeof o.onChooseClass === 'function' ? o.onChooseClass : null; this.getScoutReport = typeof o.getScoutReport === 'function' ? o.getScoutReport : null; + this.onSetDestination = typeof o.onSetDestination === 'function' ? o.onSetDestination : null; + this.isLocalSystem = typeof o.isLocalSystem === 'function' ? o.isLocalSystem : null; this.openState = 'closed'; // closed | opening | open | closing this.reveal = []; // { o, d, dur, t0 } — the boot stagger @@ -179,6 +200,7 @@ export class ShipWindow extends Phaser.GameObjects.Container { this._tabShakes = []; this._kids = []; // every child of the current body repaint this._hits = []; + this._exitRows = []; // the manifest's rows ({ exit, here }) — one per row this._glitch = { until: 0, next: 0 }; this._sweep = null; this._pollNext = 0; @@ -327,6 +349,24 @@ export class ShipWindow extends Phaser.GameObjects.Container { this.glitchG = s.graphics().setScrollFactor(0); this.add(this.glitchG); + // The exit CONFIRM (js/ui/ConfirmOverlay.js — the map console's SET + // DESTINATION dialog, one component three screens deep). A CHILD of this + // window at depth 30: it paints over the plate, the feed and the glitch + // layer, fades out with the close tween and dies with destroy(). Its scrim + // covers the whole screen, which is also what makes the console inert while + // the dialog is up (the scrim's own press IS the cancel). + const dcfg = config.section('shipScreen.cargo.detail.setDestination', {}); + this.dialog = new ConfirmOverlay( + this.scene, + geo.plateX + geo.plateW / 2, + geo.plateY + geo.plateH / 2, + Math.max(280, Math.min(Number(dcfg.width) || 520, geo.plateW - 30)), + Number(dcfg.height) || 176, + { areaW: geo.W, areaH: geo.H }, // scrim = the whole console + ); + this.dialog.setDepth(30); + this.add(this.dialog); + this._paintPlate(); this._paintAll(null); // the empty state until the first snapshot } @@ -731,6 +771,7 @@ export class ShipWindow extends Phaser.GameObjects.Container { } this._kids = []; this._hits = []; + this._exitRows = []; if (this.bodyCont.list) this.bodyCont.list.length = 0; const s = snap ?? this._snap ?? {}; if (this._activeTab === 'cargo') this._buildCargoBody(s); @@ -1209,7 +1250,8 @@ export class ShipWindow extends Phaser.GameObjects.Container { } /** A held good's manifest — the product summary + the charted exits (the - * same scoutGood() report the planet shop's scout panel renders). */ + * same scoutGood() report the planet shop's scout panel renders). Each row + * is a tap surface: it asks SET DESTINATION through _askSetDestination. */ _paintManifest(bay, cfg, snap) { const { plateX, plateY, plateW, plateH } = this.geo; const x = plateX + 18; @@ -1267,25 +1309,88 @@ export class ShipWindow extends Phaser.GameObjects.Container { const maxProfit = Math.max(1, Number(rep?.maxProfit) || 0, ...rows.map((e) => Math.abs(Number(e.profit) || 0))); const pb = cfg.profitBar ?? null; const nameW = w - 210; + const hover = cfg.exitHover ?? {}; + const hotFill = col(hover.fill ?? '#0c2740'); rows.forEach((e, i) => { const ry = ety + 30 + i * 46; + // The local market is in the charted list too (0 hops) and can never be + // a destination — pointing the ship at itself, so it reads HERE and has + // no tap surface (the shop scout's row, minus that dead end). + const here = e.systemId ? safe(() => !!this.isLocalSystem?.(e.systemId)) : false; + // The row record (the shop scout's convention — dev/tests can drive a + // row without a real pointer: ShipWindow._exitRows[i].onTap()). + const rec = { exit: e, here, onTap: null, hit: null }; + this._exitRows[i] = rec; + const accent = i === 0 ? C.amber : 0x1b3a5a; + const p = Number(e.profit) || 0; + // The plate is the row's TAP SURFACE (v4 only hit-tests a graphics object + // that actually renders, so the hover state repaints this same object — + // see button()). const rg = this.gfx(); - panel(rg, x, ry, w, 40, { notch: 6, fill: 0x04070f, fillAlpha: 0.7, stroke: i === 0 ? C.amber : 0x1b3a5a, strokeAlpha: i === 0 ? 0.8 : 0.7 }); + let cue = null; + const paint = (on) => { + rg.clear(); + panel(rg, x, ry, w, 40, { + notch: 6, + fill: on ? hotFill : 0x04070f, + fillAlpha: on ? (hover.fillAlpha ?? 0.85) : 0.7, + stroke: on ? C.neon : accent, + strokeAlpha: on ? 0.95 : (i === 0 ? 0.8 : 0.7), + glow: on ? C.neon : undefined, + }); + if (pb) { + const rw = Math.max(60, nameW); + rg.fillStyle(col(pb.track ?? '#14324f'), 0.9); + rg.fillRect(x + 38, ry + 33, rw, 3); + rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95); + rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3); + } + if (cue) cue.setAlpha(on ? 1 : 0); + }; + paint(false); this.txt(x + 12, ry + 6, `${i + 1}`, { color: i === 0 ? C.amber : C.faint, size: 14, head: true, bold: true }); this.txt(x + 38, ry + 4, String(e.objectName ?? e.systemName), { color: C.ink, size: 12, ls: 1.2, bold: true, wrap: nameW }); - const sub = [String(e.clsLabel ?? '').toUpperCase(), String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?')].filter(Boolean).join(' · '); + const meta = here + ? String(cfg.hereTag ?? 'HERE') + : String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?'); + const sub = [String(e.clsLabel ?? '').toUpperCase(), meta].filter(Boolean).join(' · '); if (sub) this.txt(x + 38, ry + 19, sub, { color: C.faint, size: 8.5, ls: 1.6 }); - const p = Number(e.profit) || 0; - if (pb) { - const rw = Math.max(60, nameW); - rg.fillStyle(col(pb.track ?? '#14324f'), 0.9); - rg.fillRect(x + 38, ry + 33, rw, 3); - rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95); - rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3); - } this.txt(x + w - 14, ry + 3, `${e.price}`, { color: C.amber, size: 13, bold: true, origin: [1, 0] }); this.txt(x + w - 14, ry + 20, `${p >= 0 ? '+' : '−'}${Math.abs(p)} / UNIT`, { color: p > 0 ? C.green : C.red, size: 9, ls: 1.4, origin: [1, 0] }); + if (here) return; // no tap surface at all — HERE is not a destination + // the hover cue in the row's own voice (what pressing it does) + cue = this.txt(x + w - 14, ry + 31, String(cfg.exitCue ?? 'TAP TO SET DESTINATION').toUpperCase(), { + color: C.neon, size: 7.5, ls: 1.8, origin: [1, 0], + }).setAlpha(0); + const tap = () => { + if (!this.isOpen || this.isDialogOpen) return; // the dialog owns the screen + this.sfx('ui_click'); + this._askSetDestination(e); + }; + rec.onTap = tap; + const area = new Phaser.Geom.Rectangle(x, ry, w, 40); + rec.hit = area; // the row's screen rect (dev probes / hit tests) + rg.setInteractive({ + useHandCursor: true, + hitArea: area, + hitAreaCallback: (a, px, py) => a.contains(px, py), + }); + rg.on('pointerover', () => { + if (!this.isOpen) return; + this.sfx('ui_hover'); + paint(true); + }); + rg.on('pointerout', () => paint(false)); + rg.on('pointerdown', tap); + this._hits.push(rg); }); + // the one line that makes the rows discoverable (data → exitHint) + if (rows.some((e) => !safe(() => !!this.isLocalSystem?.(e.systemId)))) { + const hy = Math.min(plateY + plateH - 58, ety + 34 + rows.length * 46); + this.txt(x, hy, String(cfg.exitHint ?? 'TAP AN EXIT TO SET IT AS YOUR DESTINATION').toUpperCase(), { + color: C.faint, size: 8.5, ls: 2, + }); + } // back to the grid const bw = 200; @@ -1304,6 +1409,53 @@ export class ShipWindow extends Phaser.GameObjects.Container { } } + /** + * A BEST-EXIT row was tapped — the SET DESTINATION confirm, the planet + * shop's own contract (js/ui/shop/ScoutPanel.js → SurfaceScene + * .askSetDestination) in the ship console: the pick is confirmed on the + * window-hosted dialog (this.dialog, depth 30 — above the plate, the feed + * and the glitch layer, so it can never be hidden by the console it belongs + * to) and CONFIRM hands the exit's system + world to the scene's + * onSetDestination seam — GameScene.setDestination, the map's own target + * model. CANCEL / the scrim / ESC leave the manifest exactly as it was. + */ + _askSetDestination(e) { + if (!e || typeof e.systemId !== 'string' || !this.dialog) return; + const cfg = config.section('shipScreen.cargo.detail.setDestination', {}); + const sys = String(e.systemName ?? e.systemId).toUpperCase(); + const obj = String(e.objectName ?? '').toUpperCase(); + const p = Number(e.profit) || 0; + const map = { + system: sys, + object: obj || sys, + class: e.clsLabel ? `${String(e.clsLabel).toUpperCase()} · ` : '', + price: Number(e.price) || 0, + sign: p >= 0 ? '+' : '−', + profit: Math.abs(p), + hops: Number.isFinite(e.hops) ? e.hops : '?', + }; + this.dialog.show({ + title: String(cfg.title ?? 'SET DESTINATION'), + body: [ + tpl(cfg.line1 ?? 'DESTINATION — {object} · {system}', map), + Number.isFinite(e.hops) + ? tpl(cfg.line2 ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT · {hops} HOPS', map) + : tpl(cfg.line2NoHops ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT', map), + ], + accent: col(cfg.accent ?? '#00e5ff', C.neon), + confirmLabel: String(cfg.confirmLabel ?? 'SET DESTINATION'), + cancelLabel: String(cfg.cancelLabel ?? 'CANCEL'), + onConfirm: () => safe(() => this.onSetDestination?.(e.systemId, e.objectId ?? null)), + time: this.scene.time.now, + }); + } + + /** The confirm is up — it owns the screen (the scrim's press is the cancel). + * The scene reads this for ESC (dialog first, then the console). */ + get isDialogOpen() { + return !!(this.dialog && this.dialog.isOpen); + } + // ------------------------------------------------------------ repaint /** Repaint the whole console from a snapshot (the tab body + the strip). */ _paintAll(snap) { @@ -1351,6 +1503,9 @@ export class ShipWindow extends Phaser.GameObjects.Container { close() { if (this.openState === 'closed' || this.openState === 'closing') return; this.openState = 'closing'; + // An open confirm goes with the window (the map console's rule) — left + // up, its scrim would still own every click while the console is gone. + if (this.dialog && this.dialog.isOpen) this.dialog.cancel(); this.video?.pause?.(); this.sfx('ui_close'); this.scene.tweens.add({ @@ -1499,6 +1654,9 @@ export class ShipWindow extends Phaser.GameObjects.Container { // the REC dot breathes this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004))); + // the exit confirm, while it is up (its split-open animation + buttons) + if (this.dialog) this.dialog.update(time); + // poll the scene — repaint on any real change (a hull hit, a level-up, // a buy/sell, ore mined) if (time >= this._pollNext) {