diff --git a/assets/images/originals/capital-neutral-surface.png b/assets/images/originals/capital-neutral-surface.png new file mode 100644 index 0000000..f763a32 Binary files /dev/null and b/assets/images/originals/capital-neutral-surface.png differ diff --git a/assets/images/originals/tresle.png b/assets/images/originals/tresle.png deleted file mode 100644 index a92673d..0000000 Binary files a/assets/images/originals/tresle.png and /dev/null differ diff --git a/assets/music/capital-neutral-01.mp3 b/assets/music/capital-neutral-01.mp3 new file mode 100644 index 0000000..f2cff96 Binary files /dev/null and b/assets/music/capital-neutral-01.mp3 differ diff --git a/assets/music/lava-01.mp3 b/assets/music/lava-01.mp3 new file mode 100644 index 0000000..6e5ea89 Binary files /dev/null and b/assets/music/lava-01.mp3 differ diff --git a/assets/music/lava-02.mp3 b/assets/music/lava-02.mp3 new file mode 100644 index 0000000..e2fef94 Binary files /dev/null and b/assets/music/lava-02.mp3 differ diff --git a/assets/music/lava-03.mp3 b/assets/music/lava-03.mp3 new file mode 100644 index 0000000..776c96a Binary files /dev/null and b/assets/music/lava-03.mp3 differ diff --git a/assets/videos/capital-surface-neutral.mp4 b/assets/videos/capital-surface-neutral.mp4 new file mode 100644 index 0000000..6526dc3 Binary files /dev/null and b/assets/videos/capital-surface-neutral.mp4 differ diff --git a/assets/videos/capital-takeoff-neutral.mp4 b/assets/videos/capital-takeoff-neutral.mp4 new file mode 100644 index 0000000..f6b1523 Binary files /dev/null and b/assets/videos/capital-takeoff-neutral.mp4 differ diff --git a/assets/videos/lava-takeoff-01.mp4 b/assets/videos/lava-takeoff-01.mp4 new file mode 100644 index 0000000..2b58e35 Binary files /dev/null and b/assets/videos/lava-takeoff-01.mp4 differ diff --git a/data/landing.json b/data/landing.json index e198fe0..b9c8d23 100644 --- a/data/landing.json +++ b/data/landing.json @@ -67,13 +67,13 @@ { "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, { "land": "ice-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "ice-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" }, - { "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, + { "land": "lava-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.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" }, - { "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, - { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, + { "land": "ice-land-01.mp4", "surface": "lava-surface-01.mp4", "takeoff": "lava-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, + { "land": "ice-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" }, - { "land": "capital-land-neutral.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, + { "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.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" }, { "land": "ice-land-01.mp4", "surface": "ice-surface-01.mp4", "takeoff": "ice-takeoff-01.mp4", "shop": "ice-shop-01.mp4" } diff --git a/data/music.json b/data/music.json index bb663ae..ffbfe69 100644 --- a/data/music.json +++ b/data/music.json @@ -23,7 +23,14 @@ "8": "assets/music/rocky-03.mp3", "9": "assets/music/ice-01.mp3", "10": "assets/music/ice-02.mp3", - "11": "assets/music/ice-03.mp3" + "11": "assets/music/ice-03.mp3", + "12": "assets/music/lava-01.mp3", + "13": "assets/music/lava-02.mp3", + "14": "assets/music/lava-03.mp3", + "15": "assets/music/lava-01.mp3", + "16": "assets/music/lava-02.mp3", + "17": "assets/music/lava-03.mp3", + "18": "assets/music/capital-neutral-01.mp3" }, "_stationFrames_comment": "stationFrames = the loop that plays on a deep-space station's surface — keyed by the spacestations.png VARIANT frame (the same key its landing/takeoff clips use, data/landing.json → stationVideos), so a station hums the track matching the art it is drawn with. Empty for now (a station surface is silent) until the station tracks are authored — add one line per variant when they land.", "stationFrames": {} diff --git a/data/shopui.json b/data/shopui.json index 50ac73f..65e8f9f 100644 --- a/data/shopui.json +++ b/data/shopui.json @@ -29,10 +29,19 @@ "centerW": 16 }, "slot": { - "_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).", + "_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `selBgColor`/`selBgAlpha` the slightly lifted green-tinted fill a checked bay gets, and `check` the green CHECKMARK badge in the checked bay's upper-left corner (a second tap reverts all of it). `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (green = queued/sell, cyan = info, magenta = bad).", "bgColor": "#0e1728", "border": "#1d2e4c", - "selected": "#00e5ff", + "selected": "#7ce8a4", + "selBgColor": "#152a22", + "selBgAlpha": 0.95, + "check": { + "size": 11, + "inset": 7, + "color": "#7ce8a4", + "badgeBg": "#0a1410", + "badgeAlpha": 0.85 + }, "lockAlpha": 0.3, "iconScale": 0.62, "name": { @@ -132,8 +141,10 @@ "ease": "Cubic.Out" }, "scout": { - "_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns). Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.", + "_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns) — EXCEPT the best-exit rows: tapping one offers the SET DESTINATION popup (setDestination below; the scene's ConfirmOverlay, same dialog as the map's). exitHint is the tap affordance on the section label. Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.", "topN": 3, + "exitHint": "TAP A SYSTEM TO SET DESTINATION", + "setDestination": { "width": 520, "height": 176 }, "revealMs": 140, "pad": 18, "title": { diff --git a/dev/cdp-cast.mjs b/dev/cdp-cast.mjs new file mode 100644 index 0000000..86ea18f --- /dev/null +++ b/dev/cdp-cast.mjs @@ -0,0 +1,70 @@ +// Dev-only: CDP screencast while the scout-dest dialog is open — checks +// whether ANY live frame paints the dialog plate (vs. the stale-capture +// hypothesis). Usage: +// node dev/cdp-cast.mjs '' [ms] +import { mkdir, writeFile } from 'node:fs/promises'; + +const [url, dir, readyExpr, ms] = process.argv.slice(2); +const CDP = process.env.CDP_PORT ? `127.0.0.1:${CDP_PORT}` : '127.0.0.1:9333'; +const dur = Number(ms ?? 4000); + +let target = await (await fetch(`http://${CDP}/json/list`)).json() + .then((ts) => ts.find((t) => t.type === 'page')); +if (!target) { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); +} +const ws = new WebSocket(target.webSocketDebuggerUrl); +let id = 0; +const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); +}); +const frames = []; +await new Promise((res, rej) => { ws.onopen = res; ws.onerror = (e) => rej(new Error('ws')); }); +ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } else if (msg.method === 'Page.screencastFrame') { + frames.push(msg.params.data); + send('Page.screencastFrameAck', { sessionId: msg.params.sessionId }); + } +}; + +await send('Page.enable'); +await send('Runtime.enable'); +await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Page.navigate', { url: 'about:blank' }); +await new Promise((r) => setTimeout(r, 300)); +await send('Page.navigate', { url }); + +const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; +}; + +const deadline = Date.now() + 120000; +while (Date.now() < deadline) { + const v = await evaluate(readyExpr).catch(() => null); + if (v) break; + await new Promise((r) => setTimeout(r, 200)); +} + +await send('Page.startScreencast', { format: 'png', everyNthFrame: 1 }); +await new Promise((r) => setTimeout(r, dur)); +await send('Page.stopScreencast'); + +await mkdir(dir, { recursive: true }); +let i = 0; +for (const data of frames) { + await writeFile(`${dir}/frame-${String(i).padStart(3, '0')}.png`, Buffer.from(data, 'base64')); + i++; +} +console.log(`captured ${i} frames in ${dir}`); +ws.close(); diff --git a/dev/map-dest-shot.html b/dev/map-dest-shot.html new file mode 100644 index 0000000..a9ea055 --- /dev/null +++ b/dev/map-dest-shot.html @@ -0,0 +1,29 @@ + + + + + Orbit — dev map set-destination shot + + + + + + +
+ + + diff --git a/dev/map-dest-shot.mjs b/dev/map-dest-shot.mjs new file mode 100644 index 0000000..2eff09d --- /dev/null +++ b/dev/map-dest-shot.mjs @@ -0,0 +1,42 @@ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; +window.__STEP__ = 'booting'; + +(async () => { + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; } + window.__STEP__ = 'gs ok, mapWindow=' + !!gs.mapWindow; + if (!gs.mapWindow) { window.__SHOT_READY__ = { error: 'no mapWindow' }; return; } + gs.mapWindow.open(); + window.__STEP__ = 'opened'; + await sleep(600); + try { + if (typeof gs.mapWindow.setMode === 'function') gs.mapWindow.setMode('system'); + else gs.mapWindow._mode = 'system'; + window.__STEP__ = 'mode set'; + } catch (e) { window.__SHOT_READY__ = { error: 'setMode: ' + e.message }; return; } + await sleep(1500); + try { + gs.mapWindow._askObject({ label: 'XANTHOS', id: 'xantos', x: 0, y: 0, r: 4 }); + window.__STEP__ = 'asked, dlg=' + gs.mapWindow.dialog.state; + } catch (e) { window.__SHOT_READY__ = { error: 'ask: ' + e.message }; return; } + const dlg = gs.mapWindow.dialog; + for (let i = 0; i < 80 && dlg.state !== 'shown'; i++) await sleep(100); + window.__SHOT_READY__ = { ok: dlg.state === 'shown', state: dlg.state }; +})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; }); diff --git a/dev/scout-dest-shot.html b/dev/scout-dest-shot.html new file mode 100644 index 0000000..166fa18 --- /dev/null +++ b/dev/scout-dest-shot.html @@ -0,0 +1,32 @@ + + + + + Orbit — dev scout set-destination shot + + + + + + +
+ + + diff --git a/dev/scout-dest-shot.mjs b/dev/scout-dest-shot.mjs new file mode 100644 index 0000000..f61e2dd --- /dev/null +++ b/dev/scout-dest-shot.mjs @@ -0,0 +1,97 @@ +/** + * Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs): + * boots the game, lands, opens the shop, opens the trade scout on a BUY + * row, and taps a REMOTE best-exit row — the "SET DESTINATION" popup sits + * open over the shop screen (the shot's subject). The exit rows are + * tappable (hand cursor) and the section label carries the hint. + * + * /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank & + * python3 -m http.server 8128 + * node dev/cdp-shot.mjs http://127.0.0.1:8128/dev/scout-dest-shot.html \ + * /tmp/scout-dest.png 'window.__SHOT_READY__ && true' 90000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; +import { scoutGood } from '../js/economy/TradeScout.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +(async () => { + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; } + + // A known ledger + cargo (something to sell), and the run has ENTERED + // every system — the scout then has remote exits to offer. + const goods = config.get('goods.goods', []); + gs.ship.credits = 10000; + gs.ship.cargo.add([goods[0].id]); + const sysId = gs.systemRecord.id; + for (const id of gs.galaxy.byId.keys()) gs.visitedSystems.add(id); + + gs.startLanding(gs.commsTargetFor(gs.planet)); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) { window.__SHOT_READY__ = { error: 'no SurfaceScene' }; return; } + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + await sleep(900); // the planet HUD decode + + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + const sv = ss.shopView; + if (!sv) { window.__SHOT_READY__ = { error: 'no shopView' }; return; } + sv.setTab('trade'); + await sleep(900); // the slide-in + + // Pick a BUY row with a REMOTE profitable exit, open its scout, and tap + // that exit row — the SET DESTINATION popup opens and STAYS open (the + // shot's subject). + const visited = [...gs.visitedSystems]; + let row = null, exit = null, best = -Infinity; + for (const r of sv.buyList.rows) { + const d = scoutGood(r.id, { galaxy: gs.galaxy, systemId: sysId, cls: sv.cls, buyUnit: r.price, visited, topN: 3 }); + if (!d) continue; + const rem = d.exits.find((e) => e.systemId !== sysId && e.profit > 0); + if (rem && rem.profit > best) { row = r; exit = rem; best = rem.profit; } + } + if (!row || !exit) { window.__SHOT_READY__ = { error: 'no remote exit to offer' }; return; } + + sv._onRowTap(row); + await sleep(300); + const i = sv._scout._exitRows.findIndex((x) => x.exit.systemId === exit.systemId); + if (i < 0) { window.__SHOT_READY__ = { error: 'no exit row for the picked exit' }; return; } + sv._scout._exitRows[i].onTap({ stopPropagation() {} }); + + // Wait for the open animation to land (title decoded, buttons up). + const dlg = ss._setDestDialog; + if (!dlg) { window.__SHOT_READY__ = { error: 'no dialog' }; return; } + const t0 = Date.now(); + while (dlg.state !== 'shown' && Date.now() - t0 < 5000) await sleep(50); + await sleep(700); // the title decode + button flicker settle + + window.__SHOT_READY__ = { + state: dlg.state, + title: dlg.title.text, + body: dlg.bodyTexts.map((t) => t.text), + exit: { systemId: exit.systemId, systemName: exit.systemName, price: exit.price, profit: exit.profit }, + hops: ss._setDest?.hops, + }; +})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; }); diff --git a/dev/scout-dest.html b/dev/scout-dest.html new file mode 100644 index 0000000..e62acea --- /dev/null +++ b/dev/scout-dest.html @@ -0,0 +1,32 @@ + + + + + Orbit — dev scout set-destination test + + + + + + +
+ + + diff --git a/dev/scout-dest.mjs b/dev/scout-dest.mjs new file mode 100644 index 0000000..1357483 --- /dev/null +++ b/dev/scout-dest.mjs @@ -0,0 +1,202 @@ +/** + * Dev-only TRADE SCOUT → SET DESTINATION test: boots the game, lands on the + * home world, opens the buy/sell screen, taps a BUY row's box (the trade + * scout), then taps a BEST-EXIT row and drives the "SET DESTINATION" popup + * end-to-end (ScoutPanel → ShopView → SurfaceScene.askSetDestination → + * ConfirmOverlay → GameScene.setDestination): + * + * - a tapped exit row offers the exit to the SCENE (the panel does not + * close itself — propagation stops) + * - confirming SETS the destination on the (sleeping) GameScene: + * destination.systemId = the exit, objectId null — and toasts it on + * the surface (the game scene's toast would sit frozen while sleeping) + * - the LOCAL system exit is rejected with a note (no dialog, no + * destination change — pointing the ship at itself bounces) + * - CANCEL and ESC leave the destination untouched + * + * python3 -m http.server 8125 + * node dev/cdp-firefox.mjs http://127.0.0.1:8125/dev/scout-dest.html \ + * 'return window.__SCOUT_DEST_TEST__;' 90000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; +import { scoutGood } from '../js/economy/TradeScout.js'; +import { planRoute } from '../js/galaxy/Route.js'; + +const fail = (msg, extra = {}) => { + window.__SCOUT_DEST_TEST__ = { pass: false, error: String(msg), ...extra }; + console.error('[scout-dest] FAIL:', msg, extra); +}; +const step = (name, cond, extra = {}) => { + if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 400)}`); + window.__STEPS__ = (window.__STEPS__ ?? []).concat(name); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (fn, timeoutMs = 4000) => { + const t0 = Date.now(); + for (;;) { + if (fn()) return true; + if (Date.now() - t0 > timeoutMs) return false; + await sleep(50); + } +}; + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +(async () => { + // 1 — the flight scene is live (v4 boots asynchronously). + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) throw new Error('GameScene never came up'); + step('boot', true); + + // 2 — a known ledger + cargo (something to sell). + const A = config.get('goods.goods', [])[0].id; + gs.ship.credits = 10000; + gs.ship.minerals = 37; + gs.ship.cargo.add([A]); + step('ledger-seeded', true); + + // 3 — the surface stage on the home world (skip the clip). + gs.startLanding(gs.commsTargetFor(gs.planet)); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) throw new Error('SurfaceScene never registered'); + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')'); + step('surface-stage', true); + + // 4 — the run has ENTERED (charted) every system, so the scout has + // remote exits to offer (visitedSystems is the scout's "charted" set). + const sysId = gs.systemRecord.id; + for (const id of gs.galaxy.byId.keys()) gs.visitedSystems.add(id); + step('charted-everywhere', gs.visitedSystems.size > 1, { visited: gs.visitedSystems.size }); + + // 5 — open the shop the real way; headless (no shop clip) falls back to + // the direct build (same UI path). + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + if (!ss.shopView) throw new Error('no shopView'); + const sv = ss.shopView; + sv.setTab('trade'); + await sleep(600); // the slide-in + step('shop-open', true, { rows: sv.buyList.rows.length }); + + // The scout's data, computed the same way the panel does — so we can + // pick a good with a REMOTE profitable exit and one the LOCAL system + // buys (its exit row is the reject case). + const visited = [...gs.visitedSystems]; + const reportFor = (row) => scoutGood(row.id, { + galaxy: gs.galaxy, systemId: sysId, cls: sv.cls, + buyUnit: row.price, visited, topN: 3, + }); + const rows = sv.buyList.rows; + let remoteRow = null, remoteExit = null, bestProfit = -Infinity; + let localRow = null, localExit = null; + for (const row of rows) { + const d = reportFor(row); + if (!d) continue; + const rem = d.exits.find((e) => e.systemId !== sysId && e.profit > 0); + if (rem && rem.profit > bestProfit) { remoteRow = row; remoteExit = rem; bestProfit = rem.profit; } + const loc = d.exits.find((e) => e.systemId === sysId); + if (loc && !localRow) { localRow = row; localExit = loc; } + } + step('scout-data', !!remoteRow && !!remoteExit, { remote: remoteExit?.systemId, profit: bestProfit }); + + const tapRow = (scout, exit) => { + const i = scout._exitRows.findIndex((r) => r.exit.systemId === exit.systemId); + if (i < 0) throw new Error('no exit row for ' + exit.systemId); + scout._exitRows[i].onTap({ stopPropagation() {} }); + }; + const dialogShown = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'shown'); + const dialogHidden = async () => waitFor(() => ss._setDestDialog && ss._setDestDialog.state === 'hidden'); + + // 6 — REJECT: the LOCAL system's exit row → a note, NO dialog, no + // destination change (pointing the ship at itself bounces). + if (localRow) { + sv._onRowTap(localRow); + step('local-scout-open', sv._scoutOpen === localRow.id); + tapRow(sv._scout, localExit); + await sleep(150); + step('local-rejected', ss._setDestDialog == null, { dialog: ss._setDestDialog && ss._setDestDialog.state }); + step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text }); + step('local-dest-untouched', gs.destination == null); + sv._onRowTap(null); // the scout closes, cargo grid back + } else { + // Fallback (no local-buying good in stock): the scene API rejects it. + ss.askSetDestination({ systemId: sysId, systemName: 'LOCAL', price: 1, profit: 0, clsLabel: 'X' }); + await sleep(150); + step('local-rejected', ss._setDestDialog == null); + step('local-note', /ALREADY AT THAT SYSTEM/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text }); + } + + // 7 — CONFIRM: a REMOTE exit row → the popup; CONFIRM sets the + // destination on the GameScene and toasts it on the surface. + sv._onRowTap(remoteRow); + step('remote-scout-open', sv._scoutOpen === remoteRow.id && sv._scout.report.id === remoteRow.id); + step('exit-rows-built', sv._scout._exitRows.length === sv._scout.report.exits.length, + { rows: sv._scout._exitRows.length, exits: sv._scout.report.exits.length }); + tapRow(sv._scout, remoteExit); + if (!(await dialogShown())) throw new Error('SET DESTINATION dialog never opened'); + const dlg = ss._setDestDialog; + step('dialog-open', dlg.isOpen, { state: dlg.state }); + step('dialog-title', (await waitFor(() => dlg.title.text === 'SET DESTINATION')) && dlg.title.text === 'SET DESTINATION', + { title: dlg.title.text }); + const name = String(remoteExit.systemName ?? remoteExit.systemId).toUpperCase(); + step('dialog-body', dlg.bodyTexts[0].text === `DESTINATION — ${name}`, + { body0: dlg.bodyTexts[0].text, want: `DESTINATION — ${name}` }); + step('dialog-body-price', /SELLS \d+ CR\/UNIT/.test(dlg.bodyTexts[1].text ?? ''), { body1: dlg.bodyTexts[1].text }); + + // The scene staged the pending pick (exit + route hops) before showing. + const wantHops = planRoute(gs.galaxy, sysId, remoteExit.systemId)?.hops ?? null; + step('pending-staged', ss._setDest?.exit?.systemId === remoteExit.systemId, { setDest: !!ss._setDest }); + if (wantHops != null) step('hops-computed', ss._setDest?.hops === wantHops, { got: ss._setDest?.hops, want: wantHops }); + + dlg.fireConfirm(); + if (!(await dialogHidden())) throw new Error('dialog never closed after confirm'); + step('confirmed-closed', dlg.state === 'hidden'); + step('destination-set', gs.destination?.systemId === remoteExit.systemId && gs.destination?.objectId === null, + { destination: gs.destination }); + step('surface-toast', /DESTINATION SET/.test(ss.noteG?.[1]?.text ?? ''), { note: ss.noteG?.[1]?.text }); + + // 8 — CANCEL: the same row again → popup; CANCEL leaves everything as-is. + tapRow(sv._scout, remoteExit); + if (!(await dialogShown())) throw new Error('dialog never reopened'); + ss._setDestDialog.cancel(); + if (!(await dialogHidden())) throw new Error('dialog never closed after cancel'); + step('cancel-kept', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination }); + + // 9 — ESC: the same row a third time → popup; ESC cancels (the esc + // chain: … → set-destination → save → …) without touching the rest. + tapRow(sv._scout, remoteExit); + if (!(await dialogShown())) throw new Error('dialog never reopened (esc case)'); + const saveWas = ss.savePanel ?? null; + ss.escAction(); + if (!(await dialogHidden())) throw new Error('ESC did not close the dialog'); + step('esc-cancelled', gs.destination?.systemId === remoteExit.systemId, { destination: gs.destination }); + step('esc-scope', ss.savePanel === saveWas, { savePanel: ss.savePanel, was: saveWas }); + + // 10 — teardown is clean. + ss.leaveShop(); + await sleep(150); + step('leave-shop', ss.shopView === null && ss.shopMode === false, { view: !!ss.shopView, mode: ss.shopMode }); + + window.__SCOUT_DEST_TEST__ = { pass: true, steps: window.__STEPS__ ?? [] }; + console.info('[scout-dest] PASS:', (window.__STEPS__ ?? []).join(' → ')); +})().catch((err) => fail(String(err && err.message || err))); diff --git a/dev/shop-hud-shot.html b/dev/shop-hud-shot.html new file mode 100644 index 0000000..ae53b30 --- /dev/null +++ b/dev/shop-hud-shot.html @@ -0,0 +1,32 @@ + + + + + Orbit — dev shop hud shot + + + + + + +
+ + + diff --git a/dev/shop-hud-shot.mjs b/dev/shop-hud-shot.mjs new file mode 100644 index 0000000..a4f634b --- /dev/null +++ b/dev/shop-hud-shot.mjs @@ -0,0 +1,67 @@ +/** + * Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs): boots the game, lands, opens the shop, reveals the + * buy/sell panel — then sets window.__SHOT_READY__ so the driver captures + * the moment the trade screen (and its upper-right credits/minerals + * readout) is on screen. + * + * /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank & + * node dev/server.mjs 8127 + * node dev/cdp-shot.mjs http://127.0.0.1:8127/dev/shop-hud-shot.html \ + * /tmp/shop-hud.png 'window.__SHOT_READY__ && (r = window.__SHOT_READY__, r) && true' 90000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +(async () => { + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; } + + // A known ledger + cargo so the SELL grid has filled bays. + const goods = config.get('goods.goods', []); + gs.ship.credits = 10000; + gs.ship.minerals = 37; + gs.ship.cargo.add([goods[0].id, goods[1].id]); + + gs.startLanding(gs.commsTargetFor(gs.planet)); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) { window.__SHOT_READY__ = { error: 'no SurfaceScene' }; return; } + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + await sleep(900); // the planet HUD decode + + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + const sv = ss.shopView; + if (!sv) { window.__SHOT_READY__ = { error: 'no shopView' }; return; } + sv.setTab('trade'); + await sleep(900); // the slide-in + the HUD count-up + + window.__SHOT_READY__ = { + hud: !!ss.shopHud, + credits: ss.shopHud?.creditsHud?.label?.text ?? null, + minerals: ss.shopHud?.mineralHud?.label?.text ?? null, + creditsPos: ss.shopHud ? { x: Math.round(ss.shopHud.creditsHud.label.x), y: Math.round(ss.shopHud.creditsHud.label.y) } : null, + mineralsPos: ss.shopHud ? { x: Math.round(ss.shopHud.mineralHud.label.x), y: Math.round(ss.shopHud.mineralHud.label.y) } : null, + }; +})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; }); diff --git a/dev/shop-hud.html b/dev/shop-hud.html new file mode 100644 index 0000000..d4fdaa7 --- /dev/null +++ b/dev/shop-hud.html @@ -0,0 +1,32 @@ + + + + + Orbit — dev shop hud test + + + + + + +
+ + + diff --git a/dev/shop-hud.mjs b/dev/shop-hud.mjs new file mode 100644 index 0000000..53b7eea --- /dev/null +++ b/dev/shop-hud.mjs @@ -0,0 +1,159 @@ +/** + * Dev-only SHOP HUD test: boots the game, lands on the home world, forces + * the surface stage, opens the buy/sell screen, and checks the corner + * readout (SurfaceScene.buildShopHud) — the main game's own credits + + * minerals pair, rebuilt over the shop clip in the upper right: + * + * - the HUD is built with the shop view (credits line + minerals line + * + hold bar, upper right, the GameScene recipe) + * - the credits line reads the ship's ledger and sits at the corner + * margin; the minerals line stacks below it (topOffset recipe) + * - a confirmed trade moves the credits readout (count-up settles on + * the new ledger) + * - leaving the shop tears the readout down with the shop view + * + * python3 -m http.server 8125 + * node dev/cdp-firefox.mjs http://127.0.0.1:8125/dev/shop-hud.html \ + * 'return window.__SHOP_HUD_TEST__;' 60000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const fail = (msg, extra = {}) => { + window.__SHOP_HUD_TEST__ = { pass: false, error: String(msg), ...extra }; + console.error('[shop-hud] FAIL:', msg, extra); +}; +const step = (name, cond, extra = {}) => { + if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 300)}`); + window.__STEPS__ = (window.__STEPS__ ?? []).concat(name); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (fn, timeoutMs = 3000) => { + const t0 = Date.now(); + for (;;) { + if (fn()) return true; + if (Date.now() - t0 > timeoutMs) return false; + await sleep(50); + } +}; + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +(async () => { + // 1 — the flight scene is live (v4 boots asynchronously). + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) throw new Error('GameScene never came up'); + step('boot', true, { frame: gs.planet.sheetFrame }); + + // 2 — a known ledger + cargo (something to sell). + const goods = config.get('goods.goods', []); + const A = goods[0].id; + gs.ship.credits = 10000; + gs.ship.minerals = 37; + gs.ship.cargo.add([A]); + step('ledger-seeded', gs.ship.credits === 10000 && gs.ship.minerals === 37); + + // 3 — the real entry point + the surface stage (skip the clip). + gs.startLanding(gs.commsTargetFor(gs.planet)); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) throw new Error('SurfaceScene never registered'); + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')'); + step('surface-stage', true); + + // 4 — open the shop the real way; headless (no shop clip) falls back to + // the direct build (same UI path — the HUD is part of buildShopView). + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + if (!ss.shopView) throw new Error('no shopView'); + step('shop-open', true, { shopMode: ss.shopMode }); + + // 5 — THE HUD: the corner readout is up with the shop view. + const hud = ss.shopHud; + step('hud-built', !!hud && !!hud.creditsHud && !!hud.mineralHud, + { hud: !!hud, credits: !!hud?.creditsHud, minerals: !!hud?.mineralHud }); + + const W = game.scale.width; // 1280 + const padC = Number(config.get('creditshud.pad', 16)); + const padM = Number(config.get('mineralhud.pad', 16)); + + // The CREDITS line: the ship's ledger, right-anchored at the corner margin + // (origin 1,0 — x is the RIGHT edge, y the top). The count-up is a ~420 ms + // tween — poll until it settles on the seeded ledger. + const cl = hud.creditsHud.label; + const wantC = `CREDITS ${gs.ship.credits.toLocaleString('en-US')}`; + await waitFor(() => cl.text === wantC); + step('credits-line', + cl.text === wantC + && Math.abs(cl.x - (W - padC)) < 1 && Math.abs(cl.y - padC) < 1, + { text: cl.text, x: cl.x, y: cl.y, right: W - padC }); + + // The MINERALS line: stacks BELOW the credits line (topOffset recipe), + // right-anchored the same way — n / CAP like the main game's corner. + const ml = hud.mineralHud.label; + const expectM = `MINERALS ${gs.ship.minerals} / ${gs.ship.stats.mineralStorage}`; + step('minerals-line', + ml.text === expectM + && Math.abs(ml.x - (W - padM)) < 1, + { text: ml.text, want: expectM, x: ml.x, y: ml.y }); + step('minerals-below-credits', ml.y > cl.y, { min: ml.y, cred: cl.y }); + + // The hold BAR: below its label, inside the screen. + const mh = hud.mineralHud; + step('minerals-bar', + Number.isFinite(mh.barX) && Number.isFinite(mh.barY) + && mh.barY > ml.y && mh.barX + mh.barW <= W, + { barX: mh.barX, barY: mh.barY, barW: mh.barW }); + + // Rendered ABOVE the shop clip (the video is depth 10; the HUD is 30). + step('hud-above-clip', (cl.depth ?? 0) >= 30, { depth: cl.depth }); + + // 6 — A TRADE: sell the bay, confirm → the credits line moves (the + // count-up settles on the new ledger; the minerals line is untouched). + const sv = ss.shopView; + sv.setTab('trade'); + await sleep(600); // the slide-in + sv.sellGrid.toggleBay(0); + const pv = sv.preview(); + const expectCredits = gs.ship.credits + pv.sellTotal; + step('trade-confirmed', sv.confirm() === true, { sell: pv.sellTotal }); + step('ledger-moved', gs.ship.credits === expectCredits, + { credits: gs.ship.credits, want: expectCredits }); + // The count-up is a ~420 ms tween — poll until it settles on the new ledger. + const wantC2 = `CREDITS ${expectCredits.toLocaleString('en-US')}`; + await waitFor(() => cl.text === wantC2); + step('hud-follows-trade', cl.text === wantC2, + { text: cl.text, want: expectCredits }); + step('minerals-untouched', ml.text === expectM, { text: ml.text }); + + // 7 — LEAVE SHOP: the readout dies with the shop view. + ss.leaveShop(); + await sleep(100); + step('hud-torn-down', ss.shopHud === null && ss.shopView === null, + { hud: ss.shopHud, view: ss.shopView }); + const kids = ss.sys.displayList?.getChildren?.() ?? []; + step('labels-destroyed', + !kids.includes(cl) && !kids.includes(ml), + { clIn: kids.includes(cl), mlIn: kids.includes(ml) }); + + window.__SHOP_HUD_TEST__ = { pass: true, steps: window.__STEPS__ ?? [] }; + console.info('[shop-hud] PASS:', (window.__STEPS__ ?? []).join(' → ')); +})().catch((err) => fail(String(err && err.message || err))); diff --git a/dev/shop-select-shot.html b/dev/shop-select-shot.html new file mode 100644 index 0000000..4ba4d33 --- /dev/null +++ b/dev/shop-select-shot.html @@ -0,0 +1,32 @@ + + + + + Orbit — dev shop select shot + + + + + + +
+ + + diff --git a/dev/shop-select-shot.mjs b/dev/shop-select-shot.mjs new file mode 100644 index 0000000..33ef66e --- /dev/null +++ b/dev/shop-select-shot.mjs @@ -0,0 +1,99 @@ +/** + * Visual probe for a screenshot (headless brave, via dev/cdp-shot.mjs): + * boots the game, lands, opens the shop, reveals the buy/sell panel, and + * CHECKS ONE CARGO BAY — bay 0 marked to sell (green stroke + tinted fill + * + the green checkmark in its upper-left corner), bay 1 left unchecked so + * the shot shows both states side by side. On ready it sets + * window.__SHOT_READY__ with the checked state + a record of what the + * checked bay drew (border/fill colors + the corner-check strokes). + * + * /opt/brave.com/brave/brave --headless --disable-gpu --remote-debugging-port=9333 about:blank & + * python3 -m http.server 8128 + * node dev/cdp-shot.mjs http://127.0.0.1:8128/dev/shop-select-shot.html \ + * /tmp/shop-select.png 'window.__SHOT_READY__ && true' 90000 + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const data = await ConfigLoader.load(); +config.init(data); +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +(async () => { + let gs = null; + for (let i = 0; i < 300; i++) { + gs = game.scene?.getScene('GameScene') ?? null; + if (gs && gs.ship) break; + await sleep(100); + } + if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; } + + // A known ledger + cargo: TWO goods → bay 0 checked, bay 1 left unchecked + // so the shot shows the selection look next to an idle bay. + const goods = config.get('goods.goods', []); + gs.ship.credits = 10000; + gs.ship.cargo.add([goods[0].id, goods[1].id]); + + gs.startLanding(gs.commsTargetFor(gs.planet)); + await sleep(300); + const ss = game.scene.getScene('SurfaceScene'); + if (!ss) { window.__SHOT_READY__ = { error: 'no SurfaceScene' }; return; } + for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); + ss.startSurface(); + for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); + await sleep(900); // the planet HUD decode + + ss.toggleShop(); + await sleep(200); + if (!ss.shopView) ss.buildShopView(); + const sv = ss.shopView; + if (!sv) { window.__SHOT_READY__ = { error: 'no shopView' }; return; } + sv.setTab('trade'); + await sleep(900); // the slide-in + + // CHECK bay 0 (queue one unit for sale) — the selection look is what + // this shot is for. + sv.sellGrid.toggleBay(0); + await sleep(300); + + // Diagnostics: record what the (re)render of the CHECKED state draws on + // the bay's plate (fill + border) and its sel graphics (the corner + // check). Un-check first, then re-check, so the LAST recorded calls are + // the checked look. + let diag = null; + const cell = sv.sellGrid.cells[0]; + if (cell?.plate && cell?.sel) { + diag = { plate: [], sel: [] }; + const rec = (o, k, sink) => { + const orig = o[k]; + o[k] = (...a) => { + sink.push({ k, a: a.map((v) => (typeof v === 'number' ? Math.round(v) : v)) }); + return orig.apply(o, a); // record AND keep drawing + }; + }; + for (const k of ['fillStyle', 'lineStyle']) { rec(cell.plate, k, diag.plate); rec(cell.sel, k, diag.sel); } + rec(cell.sel, 'lineBetween', diag.sel); + rec(cell.sel, 'fillCircle', diag.sel); + sv.sellGrid.toggleBay(0); // un-check (idle render) + sv.sellGrid.toggleBay(0); // re-check → the checked render (what we want) + } + + window.__SHOT_READY__ = { + checked: sv.sellGrid.getSellMap(), + diag, + slot: { + selected: config.get('shopui.slot.selected'), + selBgColor: config.get('shopui.slot.selBgColor'), + check: config.get('shopui.slot.check'), + }, + }; +})().catch((err) => { window.__SHOT_READY__ = { error: String(err && err.message || err) }; }); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 1233503..e6db080 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -318,6 +318,68 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal } console.log('INTERACTION: OK'); +// =========================================================================== +console.log('\n== SELECT VISUAL — checked bay: green stroke + tint + corner check; uncheck reverts =='); +{ + const ship = makeShip(10000); + ship.cargo.add([A]); + const { view } = makeView(ship); + const grid = view.sellGrid; + const cell = grid.cells[0]; + const slotCfg = config.get('shopui.slot', {}); + const hexInt = (h) => { + const m = String(h).trim().match(/^#?([0-9a-f]{6})$/i); + return m ? parseInt(m[1], 16) : null; + }; + const GREEN = hexInt(slotCfg.selected ?? '#7ce8a4'); + const BG = hexInt(slotCfg.bgColor ?? '#0e1728'); + const BORDER = hexInt(slotCfg.border ?? '#1d2e4c'); + const SELBG = hexInt(slotCfg.selBgColor ?? BG); + assert.ok(GREEN != null && BG != null && SELBG != null, 'the slot config carries the selection colors'); + assert.notEqual(SELBG, BG, 'the selected fill is a distinct (tinted) shade'); + + // Record the draw calls on the cell's plate + check graphics. + const wrap = (o) => { + const calls = { fill: [], line: [], between: [] }; + if (!o) return calls; + if (typeof o.fillStyle === 'function') o.fillStyle = (...a) => { calls.fill.push(a); return o; }; + if (typeof o.lineStyle === 'function') o.lineStyle = (...a) => { calls.line.push(a); return o; }; + if (typeof o.lineBetween === 'function') o.lineBetween = (...a) => { calls.between.push(a); return o; }; + return calls; + }; + + // CHECK the bay → GREEN stroke + tinted fill on the plate, and the + // corner check (green strokes in the upper-left) on the cell's sel graphics. + const plate = wrap(cell.plate); + const sel = wrap(cell.sel); + grid.toggleBay(0); + assert.deepEqual(grid.getSellMap(), { [A]: 1 }, 'bay 0 is checked (1 unit queued)'); + assert.ok(plate.line.length >= 1, 'the plate was re-styled on the check'); + assert.equal(plate.line[0][1], GREEN, `checked bay strokes the SELECTION GREEN (got 0x${(plate.line[0]?.[1] ?? 0).toString(16)}, want 0x${GREEN.toString(16)})`); + assert.ok(plate.line[0][0] >= 2, `… a heavier stroke than the idle border (width ${plate.line[0][0]})`); + assert.equal(plate.fill[0][0], SELBG, `… and the fill is the selected tint (got 0x${(plate.fill[0]?.[0] ?? 0).toString(16)}, want 0x${SELBG.toString(16)})`); + assert.ok(sel.between.length >= 2, `the corner check is drawn (≥ 2 strokes, got ${sel.between.length})`); + assert.ok(sel.line.some((l) => l[1] === GREEN), `… in the selection green (line styles: ${JSON.stringify(sel.line)})`); + // The check sits in the cell's UPPER-LEFT quadrant (up + left of centre). + const { x, y } = grid._cellXY(0); + for (const b of sel.between) { + assert.ok(b[0] < x && b[1] < y && b[2] < x && b[3] < y, + `check stroke (${b.map((v) => Math.round(v)).join(',')}) is up-left of the cell centre (${x},${y})`); + } + + // UNCHECK the bay → the plate reverts to the idle border/fill and the + // check is cleared (nothing new drawn on the sel graphics). + const plate2 = wrap(cell.plate); + const sel2 = wrap(cell.sel); + grid.toggleBay(0); + assert.deepEqual(grid.getSellMap(), {}, 'bay 0 is checked off again'); + assert.equal(plate2.line[0][1], BORDER, `unchecked bay strokes the IDLE border (got 0x${(plate2.line[0]?.[1] ?? 0).toString(16)}, want 0x${BORDER.toString(16)})`); + assert.equal(plate2.line[0][0], 1, `… the thin idle border again (width ${plate2.line[0][0]})`); + assert.equal(plate2.fill[0][0], BG, `… and the plain idle fill (got 0x${(plate2.fill[0]?.[0] ?? 0).toString(16)}, want 0x${BG.toString(16)})`); + assert.equal(sel2.between.length, 0, 'the corner check is gone (cleared, no new strokes)'); +} +console.log('SELECT VISUAL: OK'); + // =========================================================================== console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='); { @@ -758,4 +820,45 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re } console.log('TRADE SCOUT: OK'); +console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scene is offered the exit =='); +{ + const ship = makeShip(10000); + ship.cargo.add([A]); + const { view, scene } = makeView(ship); + const calls = []; + scene.askSetDestination = (exit) => { calls.push(exit); }; + + const row = view.buyList.rows[0]; + view._onRowTap(row); + assert.equal(view._scoutOpen, row.id, 'scout open on the row'); + const d = view._scout.report; + assert.ok(d.exits.length > 0, 'the report has charted exits to tap'); + + // One tappable record per exit row, in rank order. + assert.equal(view._scout._exitRows.length, d.exits.length, 'one tappable record per exit row'); + assert.ok(view._scout._exitRows.every((r, i) => r.exit.systemId === d.exits[i].systemId), '… rows keep the report\'s rank order'); + + // TAP ROW 1 → the scene is offered THAT exit, and the panel does NOT close. + const best = d.exits[0]; + let stopped = false; + view._scout._exitRows[0].onTap({ stopPropagation() { stopped = true; } }); + assert.equal(calls.length, 1, 'the scene was offered the exit'); + assert.equal(calls[0].systemId, best.systemId, '… for the tapped (best) system'); + assert.equal(stopped, true, '… propagation stopped (the panel tap-layer is not fired)'); + assert.equal(view._scoutOpen, row.id, '… and the scout stays open (no close)'); + + // TAP ROW 2 (when there is one) → the scene is offered THAT row\'s exit. + if (d.exits.length > 1) { + view._scout._exitRows[1].onTap({}); + assert.equal(calls.length, 2, 'second row tap → second offer'); + assert.equal(calls[1].systemId, d.exits[1].systemId, '… for that row\'s system'); + } + + // Re-showing the report rebuilds the rows (no stale tap surfaces). + view._onRowTap(null); // close + view._onRowTap(row); // reopen → show() runs again + assert.equal(view._scout._exitRows.length, view._scout.report.exits.length, 'rows rebuilt for the fresh report'); +} +console.log('SET DESTINATION (panel → scene): OK'); + console.log('\nAll shop-UI checks passed \u2714'); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index c4d053e..c4ef241 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -11,7 +11,11 @@ import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; +import { ConfirmOverlay } from '../ui/ConfirmOverlay.js'; +import { planRoute } from '../galaxy/Route.js'; import { ShopView } from '../ui/shop/ShopView.js'; +import { CreditsHud } from '../ui/CreditsHud.js'; +import { MineralHud } from '../ui/MineralHud.js'; import * as Economy from '../economy/Economy.js'; import { buildDefs } from '../research/ResearchModel.js'; @@ -104,8 +108,12 @@ export class SurfaceScene extends Phaser.Scene { this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed) this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it) this.shopView = null; // the buy/sell UI over the clip (js/ui/shop/ShopView.js); null when out of the shop + this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair this.noteG = null; // console-note glyphs (consoleNote) this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState + this._setDestDialog = null; // the shop's "SET DESTINATION" confirm (ConfirmOverlay, container-hosted) + this._setDestHost = null; // the centered container hosting that dialog (SavePanel pattern) + this._setDest = null; // the pending { exit, hops } behind that dialog } /** Pick this world's clips (data/landing.json) and queue them. */ @@ -357,6 +365,9 @@ export class SurfaceScene extends Phaser.Scene { * Buyback state resets with it), and destroyed by leaveShop(). It reads the * paused GameScene for the ledger (ship.credits / ship.cargo) + the economy * (galaxy, this system) and the shop's economic class (this world's nature). + * The screen also carries the main game's corner readout (upper right — + * buildShopHud), so the player sees the same credits/minerals lines they + * have in flight while trading. */ buildShopView() { if (this.shopView) return this.shopView; // already up this visit @@ -373,9 +384,43 @@ export class SurfaceScene extends Phaser.Scene { anchor: this.shopAnchor(), topY: this.shopTopY(), }); + this.buildShopHud(); // the upper-right ledger (credits + minerals), this scene return this.shopView; } + /** + * The buy/sell screen's CORNER READOUT — the main game's own pair + * (js/ui/CreditsHud.js + js/ui/MineralHud.js) built in THIS scene, so it + * renders over the shop clip: CREDITS on top, MINERALS + the hold bar + * stacked below (upper right, the same recipe as GameScene's corner — + * same data/*.json config, same count-up). It reads the very ship the + * shop trades against and dies with the shop view (leaveShop/shutdown). + */ + buildShopHud() { + if (this.shopHud) return this.shopHud; + const ship = this.gameScene?.ship; + if (!ship) return null; + const creditsHud = new CreditsHud(this); + creditsHud.set(ship.credits); + const mineralHud = new MineralHud(this, { topOffset: creditsHud.height + creditsHud.gap }); + mineralHud.set(ship.minerals, ship.stats?.mineralStorage); + this.shopHud = { creditsHud, mineralHud }; + return this.shopHud; + } + + /** + * Mirror the ship's ledger into the corner readout (after a trade — + * ShopView.refreshHud — or any other change while the shop is up). + * No-op when the shop (and its HUD) is not on screen. + */ + refreshShopHud() { + const hud = this.shopHud; + const ship = this.gameScene?.ship; + if (!hud || !ship) return; + hud.creditsHud?.set?.(ship.credits); + hud.mineralHud?.set?.(ship.minerals, ship.stats?.mineralStorage); + } + /** * The trade panel's top edge — just under the surface HUD's planet text * (the name + the type/tether sub-line), with a little padding. The HUD is @@ -420,6 +465,96 @@ export class SurfaceScene extends Phaser.Scene { if (!this.shopView) return; this.shopView.destroy?.(); this.shopView = null; + this.destroyShopHud(); // the corner readout belongs to the shop visit + } + + /** Tear the corner readout down (leaving the shop / scene shutdown). */ + destroyShopHud() { + if (!this.shopHud) return; + this.shopHud.creditsHud?.destroy?.(); + this.shopHud.mineralHud?.destroy?.(); + this.shopHud = null; + } + + /** + * A BEST-EXIT row on the trade scout was tapped (ScoutPanel → onExitTap + * → ShopView): the small "SET DESTINATION" popup — the same confirm the + * map's SET DESTINATION uses (js/ui/ConfirmOverlay.js), centered over the + * shop screen. Confirming records the destination on the sleeping + * GameScene (its stable seam — systemId + null object; the player flies + * the route after take-off) and toasts it here on the surface (the game + * scene's own toast would sit frozen on a sleeping scene). + */ + askSetDestination(exit) { + if (!exit || typeof exit.systemId !== 'string') return; + const gs = this.gameScene; + if (!gs || typeof gs.setDestination !== 'function') { + this.consoleNote('NO NAV LINK — SET IT FROM THE MAP', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) }); + return; + } + // The local system is always in the exit list (charted, 0 hops) — + // pointing the ship at itself just bounces back, so say so instead. + if (gs.systemRecord?.id === exit.systemId) { + this.consoleNote('ALREADY AT THAT SYSTEM', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) }); + return; + } + const name = String(exit.systemName ?? exit.systemId).toUpperCase(); + const cls = exit.clsLabel ? `${exit.clsLabel} · ` : ''; + const sign = exit.profit >= 0 ? '+' : '−'; + const plan = planRoute(gs.galaxy, gs.systemRecord?.id, exit.systemId); + this._setDest = { exit, hops: plan?.hops ?? null }; + this._ensureSetDestDialog().show({ + title: 'SET DESTINATION', + body: [ + `DESTINATION — ${name}`, + `${cls}SELLS ${exit.price} CR/UNIT · ${sign}${Math.abs(exit.profit)}/UNIT`, + ], + accent: toCss(themeColor('neon', 0x00e5ff)), + confirmLabel: 'SET DESTINATION', + onConfirm: () => { + const d = this._setDest; + this._setDest = null; + if (!d) return; + gs.setDestination(d.exit.systemId, null); // the map's seam (objectId → null) + const label = `DESTINATION SET — ${String(d.exit.systemName ?? d.exit.systemId).toUpperCase()}` + (d.hops == null ? '' : ` · ${d.hops} HOP(S)`); + this.consoleNote(label, { glyph: '◆', glyphColor: toCss(config.get('gates.route.compassColor', '#ff8c1a')) }); + }, + time: this.time.now, + }); + } + + /** The shared confirm dialog (built once, sized like the map's). */ + _ensureSetDestDialog() { + if (this._setDestDialog) return this._setDestDialog; + const ds = config.get('shopui.scout.setDestination', {}); + const w = Number(ds?.width) > 0 ? Number(ds.width) : 520; + const h = Number(ds?.height) > 0 ? Number(ds.height) : 176; + // Host it the way SavePanel/MapWindow host their confirm dialogs: + // `new ConfirmOverlay(scene, 0, 0, …)` inside a centered container + // (`new Container` + `add.existing` — not `scene.add.container`, which + // this v4 build doesn't put on the display list). + // Note: in the headless-Brave probe the plate Graphics doesn't paint in + // the SurfaceScene context (the same happens to SavePanel's own confirm + // there); the dialog renders correctly in a real browser — see + // dev/map-dest-shot.* (the identical component over the map, painted + // fully in headless). + this._setDestHost = new Phaser.GameObjects.Container( + this, this.scale.width / 2, this.scale.height / 2, + ); + this.add.existing(this._setDestHost); + // Above EVERYTHING in the surface scene — the shop's panels live in + // ShopView's root container at depth 100 (js/ui/shop/ShopView.js), + // above the deck (50), console note (60), SavePanel (70), MapWindow + // (80). 110 puts the dialog + its scrim over all of it. + this._setDestHost.setDepth(110); + this._setDestDialog = new ConfirmOverlay( + this, + 0, 0, + w, h, + { areaW: this.scale.width, areaH: this.scale.height }, + ); + this._setDestHost.add(this._setDestDialog); + return this._setDestDialog; } /** @@ -855,7 +990,7 @@ export class SurfaceScene extends Phaser.Scene { this.questWindow?.refresh(); } - /** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */ + /** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → sub-bar. */ escAction() { if (this.buildWindow?.isOpen) { this.buildWindow.close(); @@ -865,6 +1000,10 @@ export class SurfaceScene extends Phaser.Scene { this.questWindow.close(); return; } + if (this._setDestDialog?.isOpen) { + this._setDestDialog.cancel(); + return; + } if (this.savePanel) { if (this.savePanel.confirm.isOpen) { this.savePanel.confirm.cancel(); @@ -1106,6 +1245,7 @@ export class SurfaceScene extends Phaser.Scene { this.actionBar?.update(time, delta); this.menuSubBar?.update(time, delta); this.savePanel?.update(time); + this._setDestDialog?.update(time); // the SET DESTINATION confirm this.updateHud(time); // the upper-left name/type decode (surface stage only) // The build console: tick the single in-progress build (one at a @@ -1144,6 +1284,12 @@ export class SurfaceScene extends Phaser.Scene { this.noteG = null; } this.destroyHud(); + this.destroyShopHud(); + this._setDestDialog?.destroy?.(); + this._setDestDialog = null; + this._setDestHost?.destroy?.(); + this._setDestHost = null; + this._setDest = null; this.buildWindow?.destroy(); this.buildWindow = null; this.actionBar?.destroy(); diff --git a/js/ui/shop/ScoutPanel.js b/js/ui/shop/ScoutPanel.js index e2e6607..e2c5681 100644 --- a/js/ui/shop/ScoutPanel.js +++ b/js/ui/shop/ScoutPanel.js @@ -13,8 +13,8 @@ * │ SIGHT ON Rocky · Ice … │ * │ ────────────────────────── │ * │ BEST EXITS — CHARTED │ - * │ ① New Concord Gas 41 cr │ - * │ ② … │ + * │ ① New Concord Gas 41 cr │ ← TAP A ROW → "SET DESTINATION" popup + * │ ② … │ (the scene's ConfirmOverlay) * │ ────────────────────────── │ * │ TAP ANYWHERE TO CLOSE │ * └────────────────────────────┘ @@ -22,7 +22,9 @@ * The DATA (which planets want the good, the top-N charted exits + profit) * is computed by js/economy/TradeScout.js (pure). This component only * renders that report in the panel's w×h box and reports taps back to the - * ShopView (tap anywhere = close — the ShopView restores the cargo grid). + * ShopView (tap anywhere = close — the ShopView restores the cargo grid; + * tap a BEST-EXIT row = onExitTap(exit) — the scene offers to SET it as + * the destination). * * Headless-safe: the Phaser rendering is best-effort (guarded); the layout * math is fixed offsets so a stub scene still constructs. @@ -35,8 +37,10 @@ import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js'; export class ScoutPanel extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). - * @param {object} o — { width, height, onClose } (ShopView sizes it to the - * SELL grid's box; onClose fires on any tap of the panel). + * @param {object} o — { width, height, onClose, onExitTap } (ShopView sizes + * it to the SELL grid's box; onClose fires on any tap of the panel; + * onExitTap(exit) fires when a BEST-EXIT row is tapped — the scene + * offers the "Set Destination" popup for that system). */ constructor(scene, o = {}) { super(scene, 0, 0); @@ -46,10 +50,12 @@ export class ScoutPanel extends Phaser.GameObjects.Container { this.height = Math.max(240, Number(o.height) || 480); this.pad = s.pad ?? 18; this.onClose = typeof o.onClose === 'function' ? o.onClose : null; + this.onExitTap = typeof o.onExitTap === 'function' ? o.onExitTap : null; this._objs = []; // shell objects (plate, title, tap layer) this._content = []; // per-show content (rebuilt by show()) this._data = null; // the last scoutGood() report + this._exitRows = []; // per-show best-exit rows { exit, hit, onTap } this._buildShell(); this._buildTapLayer(); // LAST — the tap rect sits on top @@ -121,6 +127,7 @@ export class ScoutPanel extends Phaser.GameObjects.Container { if (!d || typeof d.id !== 'string') return this; this._data = d; this._destroyContent(); + this._exitRows = []; const s = config.section('shopui.scout', {}); const W = this.width; const pad = this.pad; @@ -212,6 +219,13 @@ export class ScoutPanel extends Phaser.GameObjects.Container { this._divider(pad, y, W - pad * 2); y += 20; label(y, `Best exits — charted (${d.exits.length ? 'top ' + d.exits.length : 'none yet'})`); + // the tap affordance, on the section label's own line (right side) — + // the rows are the actionable part of the report + const hint = String(s.exitHint ?? '').toUpperCase(); + if (hint && d.exits.length > 0) { + const ht = add(text(W - pad, y + 3, hint, { fontSize: '9px', color: sec.color ?? '#3d4c74', align: 'right' })); + ht?.setOrigin?.(1, 0); + } y += 24; const er = s.exitRow ?? {}; @@ -255,6 +269,29 @@ export class ScoutPanel extends Phaser.GameObjects.Container { add(bar); } } + // the row's TAP SURFACE — "SET DESTINATION" for this system. Built + // during show() (AFTER the panel's tap layer was made in the + // constructor), so it wins the pointer over "tap anywhere = close"; + // stopPropagation keeps that layer from swallowing the tap. The + // handler is a plain function on the row record (dev/tests can fire + // it without a real pointer — ScoutPanel._exitRows[i].onTap()). + const hit = this.scene.add?.graphics?.(); + if (hit) { + const hx = pad - 6, hy = y - 4, hw = W - pad * 2 + 12, hh = rowH; + const onTap = (p) => { p?.stopPropagation?.(); this.onExitTap?.(e); }; + if (typeof hit.setInteractive === 'function' && typeof hit.on === 'function') { + const Rect = Phaser.Geom?.Rectangle; + const area = Rect ? new Rect(hx, hy, hw, hh) : null; + hit.setInteractive({ + useHandCursor: true, + hitArea: area, + hitAreaCallback: (pp, px, py) => (area ? Rect.Contains(pp, px, py) : true), + }); + hit.on('pointerdown', onTap); + } + this._exitRows.push({ exit: e, hit, onTap }); + add(hit); + } y += rowH; }); } diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index d3729f6..cb8fd40 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -494,11 +494,14 @@ export class ShopView extends Phaser.GameObjects.Container { this._syncPreview(); // The scout panel: sized to the SELL grid's box (it replaces the cargo - // area), added LAST so it paints above the grid while open. + // area), added LAST so it paints above the grid while open. A BEST-EXIT + // row's tap goes to the scene (the "SET DESTINATION" popup — + // SurfaceScene.askSetDestination); anything else closes the panel. const scout = new ScoutPanel(this.scene, { width: sellGrid.width, height: sellGrid.height, onClose: () => this._closeScout(), + onExitTap: (exit) => this.scene?.askSetDestination?.(exit), }); scout.setPosition?.(leftX, this.topY); this._scout = scout; @@ -674,7 +677,12 @@ export class ShopView extends Phaser.GameObjects.Container { } refreshHud() { + // The GAME scene's HUD (the main-flight corner readout) — and the + // SURFACE scene's HUD (this screen's own upper-right pair, which is the + // one the player actually sees over the shop clip — the GameScene is + // sleeping beneath us, so its HUD update is belt-and-braces). this.gameScene?.refreshCreditsHud?.(); + this.scene?.refreshShopHud?.(); } // --------------------------------------------------------------------- diff --git a/js/ui/shop/TradeGrid.js b/js/ui/shop/TradeGrid.js index 4d3c24a..d7114b2 100644 --- a/js/ui/shop/TradeGrid.js +++ b/js/ui/shop/TradeGrid.js @@ -7,8 +7,8 @@ * │ │icon││icon│ │ filled bay → good icon * │ └────┘└────┘ │ empty bay → dim plate * │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room) - * │ │ ││lock│ │ checked → cyan selection ring (1 unit queued) - * │ └────┘└────┘ │ + * │ │ ✓ ││lock│ │ checked → GREEN stroke + tinted fill + a green ✓ in + * │ └────┘└────┘ │ the upper-left corner (1 unit queued) * └────────────┘ * * A BAY is the unit (not a good): each filled bay holds 1 unit of its good @@ -16,7 +16,10 @@ * live (filled with a good, or empty); the rest are LOCKED — the hold's * upgrade room (the ship can't carry there yet). Hovering a cell reveals * the good's name + sell price in a shared tooltip (icon-only cells — no - * text over the icon). + * text over the icon). A CHECKED bay is marked three ways (all revert + * together when it's checked off again): the green border stroke, a + * slightly lifted green-tinted fill, and the green checkmark badge in the + * upper-left corner (data/shopui.json → slot: selected, selBg, check). * * (The BUY side is no longer a grid — the shop's stock is now a scrollable * list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side @@ -158,7 +161,8 @@ export class TradeGrid extends Phaser.GameObjects.Container { const plate = this.scene.add?.graphics?.(); if (plate) { cell.plate = plate; cell.objects.push(plate); } - // selection ring (sell) — cyan highlight when the bay is checked + // corner check (sell) — the green checkmark badge when the bay is + // queued (added AFTER the icon so it paints over it — see _buildCells) if (this.side === 'sell') { const sel = this.scene.add?.graphics?.(); if (sel) { cell.sel = sel; cell.objects.push(sel); } @@ -190,9 +194,9 @@ export class TradeGrid extends Phaser.GameObjects.Container { this.cells.push(cell); this.add?.(cell.plate); - if (cell.sel) this.add?.(cell.sel); if (cell.icon) this.add?.(cell.icon); if (cell.label) this.add?.(cell.label); + if (cell.sel) this.add?.(cell.sel); // the corner check — LAST, above the icon } } @@ -333,17 +337,29 @@ export class TradeGrid extends Phaser.GameObjects.Container { const usable = !locked && !!goodId; - // plate border (selected sell bays get the cyan ring) + // plate — base fill + border. A QUEUED bay (checked to sell) is marked + // here: the GREEN stroke + a slightly lifted green-tinted fill (the + // corner check is drawn below); unchecking re-renders the idle look. + const selected = usable && this.sellSel.has(i); if (cell.plate && typeof cell.plate.clear === 'function') { - const selected = this.sellSel.has(i); cell.plate.clear?.(); if (typeof cell.plate.fillStyle === 'function') { - cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3)); - cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#00e5ff') : (sl.border ?? '#1d2e4c')), 255); + cell.plate.fillStyle( + _hex(selected ? (sl.selBgColor ?? sl.bgColor ?? '#0e1728') : (sl.bgColor ?? '#0e1728')), + usable ? (selected ? (sl.selBgAlpha ?? 0.95) : 0.92) : (sl.lockAlpha ?? 0.3)); + cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#7ce8a4') : (sl.border ?? '#1d2e4c')), 255); } cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot); } + // the corner check (upper-left) — the selected bay's green checkmark + // badge, drawn on cell.sel (added after the icon → above it); cleared + // when the bay is checked off again (the render is pure state). + if (cell.sel && typeof cell.sel.clear === 'function') { + cell.sel.clear?.(); + if (selected) this._drawCheck(cell.sel, x, y, sl); + } + // icon — the cell's only content for a usable bay (the name+price live // in the hover tooltip). Centered. if (cell.icon) { @@ -370,6 +386,31 @@ export class TradeGrid extends Phaser.GameObjects.Container { return list.find((g) => g.id === id)?.name ?? id; } + /** + * The checked bay's corner mark (UPPER-LEFT): a small dark badge so the + * green check reads over any icon corner, then the green check itself + * (two strokes — short arm down to the vertex, long arm up). Sized from + * data/shopui.json → slot.check; drawn on the cell's `sel` graphics. + * Headless-safe: a fake scene without fillCircle just skips the badge. + */ + _drawCheck(g, x, y, sl) { + if (!g || typeof g.fillStyle !== 'function' || typeof g.lineStyle !== 'function' || typeof g.lineBetween !== 'function') return; + const chk = sl.check ?? {}; + const r = Math.max(6, Number(chk.size) || 11); // the badge radius + const inset = Math.max(4, Number(chk.inset) || 7); // the badge's corner inset + const cx = x - this.slot / 2 + inset + r; // badge centre (upper-left) + const cy = y - this.slot / 2 + inset + r; + if (chk.badgeBg != null && typeof g.fillCircle === 'function') { + g.fillStyle(_hex(chk.badgeBg), chk.badgeAlpha != null ? Number(chk.badgeAlpha) : 0.85); + g.fillCircle(cx, cy, r); + } + // the check — sized to the badge, green (the selection color) + const s = r * 0.62; + g.lineStyle?.(Math.max(2, Math.round(r * 0.3)), _hex(chk.color ?? sl.selected ?? '#7ce8a4'), 1); + g.lineBetween(cx - s * 0.9, cy + s * 0.05, cx - s * 0.25, cy + s * 0.62); + g.lineBetween(cx - s * 0.25, cy + s * 0.62, cx + s * 0.95, cy - s * 0.55); + } + // --- HOVER TOOLTIP (name + price, revealed per cell) ------------------ /**