diff --git a/assets/videos/terran-land-02.mp4 b/assets/videos/terran-land-02.mp4 new file mode 100644 index 0000000..cfbcabc Binary files /dev/null and b/assets/videos/terran-land-02.mp4 differ diff --git a/assets/videos/terran-takeoff-02.mp4 b/assets/videos/terran-takeoff-02.mp4 new file mode 100644 index 0000000..d03aa9b Binary files /dev/null and b/assets/videos/terran-takeoff-02.mp4 differ diff --git a/data/manifest.json b/data/manifest.json index 7008ca3..fc59a36 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -10,6 +10,7 @@ "galaxy.json", "systems.json", "settlements.json", + "stations.json", "reputation.json", "naming.json", "research.json", diff --git a/data/stations.json b/data/stations.json new file mode 100644 index 0000000..f0aaf20 --- /dev/null +++ b/data/stations.json @@ -0,0 +1,14 @@ +{ + "_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); kinds..size = the station's keepout radius (px — the wings/ring extent); kinds..ringSpeed = the deep-space station's ring rotation (rad/s).", + "enabled": true, + "shipClearance": 50, + "kinds": { + "deepSpaceStation": { + "size": 108, + "ringSpeed": 0.08 + }, + "waypoint": { + "size": 46 + } + } +} diff --git a/dev/comms-click.html b/dev/comms-click.html new file mode 100644 index 0000000..49a16d4 --- /dev/null +++ b/dev/comms-click.html @@ -0,0 +1,41 @@ + + + + + + + orbit — comms click test + + + + + + +
+ + + + + diff --git a/dev/comms-click.mjs b/dev/comms-click.mjs new file mode 100644 index 0000000..5081ab0 --- /dev/null +++ b/dev/comms-click.mjs @@ -0,0 +1,254 @@ +/** + * Dev: real-input test for the CommsPanel interaction paths: + * 1. click an object → panel opens + ship retargeted + * 2. click CANCEL → closes + * 3. rep ≤ −4, click REQUEST LANDING → inert (panel stays open) + * 4. click outside → closes + * 5. ESC → closes + * 6. rep > −4, click REQUEST LANDING → action seam fires + closes + * + * Real DOM pointer events hit the canvas (Phaser's input manager), so this + * exercises the full path: scene handler → worldObjectAt → openCommsPanel, + * and the button race-guard (closedByButtonAt) for clicks inside. + * + * python3 -m http.server 8080 + * node dev/wdshot.mjs http://localhost:8080/dev/comms-click.html \ + * 'return window.__CLICK_TEST__();' 90000 9000 + */ +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 data = await ConfigLoader.load(); +config.init(data); + +// The game measures its UI text in the web font — wait for it before the +// scenes build any text (same contract as js/main.js). +const __fonts = globalThis.document?.fonts; +if (__fonts && typeof __fonts.load === 'function') { + const fams = ['header', 'body'] + .map((k) => config.get(`theme.fonts.${k}.family`)) + .filter((f) => typeof f === 'string' && f.length > 0); + if (fams.length > 0) { + await Promise.race([ + Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))), + new Promise((r) => setTimeout(r, 2000)), + ]); + } +} + +// 'VESTRA's starting system has settled + unsettled worlds. +globalThis.__ORBIT_DEV_SEED = 'VESTRA'; +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; // GameScene boots first +if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; + +const game = new Phaser.Game(gameConfig); +window.game = game; + +/* ---- helpers ---------------------------------------------------------- */ + +const scene = () => { + const g = window.game; + if (!g) return null; + const s = g.scene.getScene('GameScene'); + return s && s.sys?.isActive && s.commsPanel ? s : null; +}; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Pump the (rAF-frozen) loop until `ok()` or the budget elapses. */ +async function pump(s, ok, ms = 4000) { + const t0 = Date.now(); + while (!ok() && Date.now() - t0 < ms) { + s.game.loop.step(performance.now()); + await sleep(0); + } + return ok(); +} + +async function ready() { + const t0 = Date.now(); + for (;;) { + const s = scene(); + if (s && s.ship) return s; + if (Date.now() - t0 > 45000) throw new Error('game never became ready'); + await sleep(50); + } +} + +/** A real mouse click at viewport (client) coordinates (v4's input + * manager listens for classic mouse events on the canvas). A leading + * mousemove refreshes the pointer so the mousedown's world coords are + * current (Phaser caches them per frame). + * NOTE: camera placement must use cam.setScroll() — plain .scrollX + * assignment does not stick — and the scene's follow must be off first. + */ +function clickAtClient(clientX, clientY) { + const canvas = document.querySelector('canvas'); + const mk = (type, buttons) => + new MouseEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + view: window, + clientX, + clientY, + button: 0, + buttons, + }); + canvas.dispatchEvent(mk('mousemove', 0)); + // The pointer's world coords are recomputed per frame, not on the event: + // a step between the move and the down refreshes them against the + // camera as it stands (the handler reads pointer.worldX on the down). + window.game.loop.step(performance.now()); + canvas.dispatchEvent(mk('mousedown', 1)); + canvas.dispatchEvent(mk('mouseup', 0)); +} + +/** ESC (Phaser's keyboard plugin reads the legacy keyCode). */ +function pressEscape() { + const ev = new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + composed: true, + cancelable: true, + }); + Object.defineProperty(ev, 'keyCode', { get: () => 27 }); + Object.defineProperty(ev, 'which', { get: () => 27 }); + window.dispatchEvent(ev); +} + +/** World → client coords through the camera + the CSS-scaled canvas rect. */ +function worldToClient(s, wx, wy) { + const cam = s.cameras.main; + const rect = document.querySelector('canvas').getBoundingClientRect(); + const scaleX = rect.width / s.scale.width; + const scaleY = rect.height / s.scale.height; + return { + clientX: rect.left + (wx - cam.scrollX) * scaleX, + clientY: rect.top + (wy - cam.scrollY) * scaleY, + }; +} + +/* ---- the test --------------------------------------------------------- */ + +window.__CLICK_TEST__ = async () => { + const out = { steps: [] }; + const s = await ready(); + const step = (name, pass, extra = {}) => out.steps.push({ name, pass, ...extra }); + const cam = s.cameras.main; + + s.cameraFollowShip = false; + s.hideHint?.(); + s.ship.stop(); + + // A settled system planet (home is PINNED at +20 — the disabled-state + // test needs a place whose standing we can set). + const settledPlanet = () => { + for (const p of s.systemPlanets) { + const rec = (s.systemContent.planets ?? []).find((r) => r.name === p.discoveryName); + if (rec && (s.systemContent.settlements ?? []).some((x) => x.anchor?.type === 'planet' && x.anchor?.ordinal === rec.ordinal)) return p; + } + return null; + }; + const planet = settledPlanet(); + if (!planet) return { steps, allPass: false, note: 'no settled planet in this system' }; + + const frame = (wx, wy, sx, sy) => { + cam.setScroll(wx - sx, wy - sy); // setScroll — .scrollX= does not stick + s.cameraFollowShip = false; + }; + + // ---- 1. Real click ON the home world → fly + panel opens ------------- + const hx = 0, hy = 0, hsx = 640, hsy = 300; + s.ship.x = 700; s.ship.y = 650; + frame(hx, hy, hsx, hsy); + await pump(s, () => !s.commsPanel.isOpen, 1000); + const c1 = worldToClient(s, hx, hy); + clickAtClient(c1.clientX, c1.clientY); + await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000); + step( + 'click object → panel opens + ship retargeted', + s.commsPanel.isOpen && s.ship.target !== null && s.commsPanel.lastTarget?.name === s.planet.discoveryName.toUpperCase(), + { shipTarget: s.ship.target, panelName: s.commsPanel._name }, + ); + + // Button centers in panel-local y: btn1 = +23.5, btn2 = +76.5. + const btn1 = () => worldToClient(s, s.commsPanel.x, s.commsPanel.y + 23.5); + const btn2 = () => worldToClient(s, s.commsPanel.x, s.commsPanel.y + 76.5); + + // ---- 2. Click CANCEL → closes ---------------------------------------- + const b2 = btn2(); + clickAtClient(b2.clientX, b2.clientY); + await pump(s, () => !s.commsPanel.isOpen, 2500); + step('click CANCEL → closes', !s.commsPanel.isOpen); + + // ---- 3. Standing ≤ −4 → REQUEST LANDING is inert --------------------- + const t = s.commsTargetFor(planet); + s.reputation.set(t.key, -8); + frame(planet.x, planet.y, 640, 300); + await pump(s, () => !s.commsPanel.isOpen, 500); + s.openCommsPanel(planet, { worldX: planet.x, worldY: planet.y, x: 640, y: 300 }); + await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999, 4000); + step('standing −8 → REQUEST LANDING disabled', s.commsPanel.btn1.disabled, { rep: s.reputation.standingFor(t.key) }); + const b3 = btn1(); + clickAtClient(b3.clientX, b3.clientY); + await sleep(300); // let the input pass settle + step('click disabled REQUEST LANDING → stays open', s.commsPanel.isOpen); + s.commsPanel.close(); + await pump(s, () => s.commsPanel.state === 'closed', 2000); + + // ---- 4. Click OUTSIDE (open space corner) → closes -------------------- + s.commsPanel.close(); + await pump(s, () => s.commsPanel.state === 'closed', 2000); + frame(hx, hy, 640, 300); // back to the home framing (step 3 moved it) + s.openCommsPanel(s.planet, { worldX: hx, worldY: hy, x: 640, y: 300 }); + await pump(s, () => s.commsPanel.isOpen, 1500); + const corners = [ + { x: -580, y: -240 }, + { x: 580, y: -240 }, + { x: -580, y: 460 }, + { x: 580, y: 460 }, + ]; + const corner = corners.find((p) => s.worldObjectAt(p.x, p.y) === null) ?? null; + if (corner) { + const cc = worldToClient(s, corner.x, corner.y); + clickAtClient(cc.clientX, cc.clientY); + await pump(s, () => !s.commsPanel.isOpen, 2500); + } + step('click outside → closes', !s.commsPanel.isOpen, { corner }); + + // ---- 5. ESC closes ---------------------------------------------------- + s.openCommsPanel(s.planet, { worldX: hx, worldY: hy, x: 640, y: 300 }); + await pump(s, () => s.commsPanel.isOpen, 1500); + pressEscape(); + await pump(s, () => !s.commsPanel.isOpen, 2500); + step('ESC → closes', !s.commsPanel.isOpen); + + // ---- 6. Enabled REQUEST LANDING fires the seam + closes --------------- + // (Home is pinned +20 — canLand is true; the seam logs the action.) + let logged = ''; + const orig = console.info; + console.info = (...a) => { + logged += ' ' + a.join(' '); + orig(...a); + }; + s.openCommsPanel(s.planet, { worldX: hx, worldY: hy, x: 640, y: 300 }); + await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999, 4000); + const b6 = btn1(); + clickAtClient(b6.clientX, b6.clientY); + await pump(s, () => !s.commsPanel.isOpen, 2500); + console.info = orig; + step( + 'click REQUEST LANDING (enabled) → seam fires + closes', + !s.commsPanel.isOpen && logged.includes('request-landing'), + { logged: logged.trim() }, + ); + + out.allPass = out.steps.every((x) => x.pass); + return out; +}; diff --git a/dev/comms-shot.html b/dev/comms-shot.html new file mode 100644 index 0000000..2e9e79c --- /dev/null +++ b/dev/comms-shot.html @@ -0,0 +1,33 @@ + + + + + + Orbit — Comms Panel Dev + + + + + + + +
+ + + + diff --git a/dev/comms-shot.mjs b/dev/comms-shot.mjs new file mode 100644 index 0000000..4db8d7c --- /dev/null +++ b/dev/comms-shot.mjs @@ -0,0 +1,156 @@ +/** + * Dev: screenshot the comms panel — in the real web font, at real panel + * geometry, in the states the player sees it: + * + * home settled (home world, pinned +20 — full red→green bar, + * REQUEST LANDING live) + * planet a settled system world with a standing set (the probe + * pins it at −8: bar lit through the red run, REQUEST + * LANDING grayed — standing ≤ −4) + * unsettled a system world with no settlement (LAND + CANCEL, no bar) + * station a free-space station (settled, neutral 0 — half bar) + * + * It calls the scene's real path (GameScene.openCommsPanel with a + * hand-built pointer), frames the click point at (640, 430) so the + * panel opens UP (the common case), and pumps until the decode + + * bar draw-in are done. + * + * python3 -m http.server 8080 + * node dev/wdshot.mjs http://localhost:8080/dev/comms-shot.html /tmp/comms-home.png \ + * 'window.__COMMS_SHOT__("home"); return window.__COMMS_SHOT__RESULT__;' 40000 5000 + */ +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 data = await ConfigLoader.load(); +config.init(data); + +// The game measures its UI text in the web font (Centauri) — wait for it +// BEFORE the scenes build any text (same contract as js/main.js). +const __fonts = globalThis.document?.fonts; +if (__fonts && typeof __fonts.load === 'function') { + const fams = ['header', 'body'] + .map((k) => config.get(`theme.fonts.${k}.family`)) + .filter((f) => typeof f === 'string' && f.length > 0); + if (fams.length > 0) { + await Promise.race([ + Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))), + new Promise((r) => setTimeout(r, 2000)), + ]); + } +} + +// A deterministic galaxy (same objects every run) + quiet audio in headless. +// 'VESTRA's starting system has everything the panel's states need: +// settled worlds, unsettled worlds, and free-space stations. +globalThis.__ORBIT_DEV_SEED = 'VESTRA'; +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; // GameScene boots first +if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; + +const game = new Phaser.Game(gameConfig); +window.game = game; + +/** + * Frame the chosen object with its click point at screen (640, 430), + * open the comms panel through the scene's real path, pump until the + * name decode + the reputation bar draw-in are done, and report the + * panel's state (name, side, buttons, the lit-mark count). + */ +window.__COMMS_SHOT__ = (which = 'home') => { + const g = window.game; + const s = g.scene.getScene('GameScene'); + if (!s || !s.commsPanel) return { ready: false, note: s ? 'no commsPanel (yet?)' : 'no scene' }; + s.ship.stop(); + s.hideHint?.(); + + // ---- Pick the object ------------------------------------------------- + let obj = null; + let note = ''; + const settledPlanet = () => { + for (const p of s.systemPlanets) { + const rec = (s.systemContent.planets ?? []).find((r) => r.name === p.discoveryName); + if (rec && (s.systemContent.settlements ?? []).some((x) => x.anchor?.type === 'planet' && x.anchor?.ordinal === rec.ordinal)) return p; + } + return null; + }; + const unsettledPlanet = () => { + for (const p of s.systemPlanets) { + const rec = (s.systemContent.planets ?? []).find((r) => r.name === p.discoveryName); + if (rec && !(s.systemContent.settlements ?? []).some((x) => x.anchor?.type === 'planet' && x.anchor?.ordinal === rec.ordinal)) return p; + } + return null; + }; + if (which === 'home') obj = s.planet; + else if (which === 'planet') { + obj = settledPlanet(); + if (obj) { + const t = s.commsTargetFor(obj); + if (t.key) s.reputation.set(t.key, -8); // standing ≤ −4 → REQUEST LANDING grayed + } + note = obj ? '' : 'no settled planet in this system'; + } else if (which === 'unsettled') { + obj = unsettledPlanet(); + note = obj ? '' : 'no unsettled planet in this system'; + } else if (which === 'station') { + obj = s.systemStations?.[0] ?? null; + note = obj ? '' : 'no free-space station in this system'; + } + if (!obj) return { ready: false, note: note || 'no object' }; + + // ---- Frame it: click point at screen (640, 430) → panel opens UP ----- + const wx = obj.x; + const wy = obj.y; + const sx = 640; + const sy = 430; + // Park the ship just off the rim (it was flying there), lower-right. + const rr = obj.radius + 70; + s.ship.x = wx + rr * Math.cos(0.5); + s.ship.y = wy + rr * Math.sin(0.5); + const cam = s.cameras.main; + cam.scrollX = wx - sx; + cam.scrollY = wy - sy; + + // ---- Open through the scene's real path ------------------------------ + s.openCommsPanel(obj, { worldX: wx, worldY: wy, x: sx, y: sy }); + + // Freeze the camera follow (the shot, not the flight, is under test), + // then pump until the decode + bar draw-in + the open tween are done — + // this box freezes rAF (same trick as popup-shot). + s.cameraFollowShip = false; + const done = () => s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null && s.commsPanel.repRevealT0 === null; + for (let i = 0; i < 6000 && !done(); i++) g.loop.step(performance.now()); + cam.scrollX = wx - sx; + cam.scrollY = wy - sy; + + const cp = s.commsPanel; + const r = { + ready: true, + fontStatus: __fonts?.status ?? 'n/a', + which, + note, + object: obj.discoveryName, + side: cp.side, + name: cp.nameText.text, + settled: cp.settled, + H: cp.H, + btn1: cp.btn1.text.text, + btn1Disabled: cp.btn1.disabled, + btn2: cp.btn2.text.text, + repValue: cp.repValue.visible ? cp.repValue.text : null, + rect: cp.rect && { x: Math.round(cp.rect.x), y: Math.round(cp.rect.y), w: cp.rect.w, h: cp.rect.h }, + onScreen: cp.rect + ? (() => { + const cam2 = s.cameras.main; + const lx = cp.rect.x - cam2.scrollX; + const ly = cp.rect.y - cam2.scrollY; + return lx >= -1 && ly >= -1 && lx + cp.rect.w <= s.scale.width + 1 && ly + cp.rect.h <= s.scale.height + 1; + })() + : false, + }; + window.__COMMS_SHOT__RESULT__ = r; + return r; +}; diff --git a/dev/reputation.test.mjs b/dev/reputation.test.mjs index 31cf3d8..d8261a5 100644 --- a/dev/reputation.test.mjs +++ b/dev/reputation.test.mjs @@ -282,6 +282,23 @@ const HOME = config.get('reputation.home', 20); // ---------------------------------------------------------------------- +// ---------------------------------------------------------------------- +// N. The reputation bar (the comms panel's gauge, js/ui/CommsPanel.js) +// ---------------------------------------------------------------------- +{ + const n = MAX - MIN + 1; // one mark per standing on the scale + const bar = new Reputation(); + check('the bar has one mark per standing on the scale (41 for −20…+20)', n === 41); + check('the worst standing lights just the leftmost (red) mark', bar.marksFor(MIN) === 1); + check('the best standing lights the whole bar (through green)', bar.marksFor(MAX) === n); + check('a negative standing lights the left run (−10 → 11 marks)', bar.marksFor(-10) === 11); + check('a positive standing lights past the centre (+5 → 26 marks)', bar.marksFor(5) === 26); + check('0 lights the left half plus the centre mark (21 of 41)', bar.marksFor(0) === 21); + check('out-of-scale standings clamp to the bar ends', bar.marksFor(99) === n && bar.marksFor(-99) === 1); + check('fractional standings round to the integer step', bar.marksFor(4.6) === bar.marksFor(5)); + check('non-numeric input lights nothing (0 marks)', bar.marksFor('famous') === 0); +} + if (failures > 0) { console.error(`\n${failures} reputation test(s) FAILED`); process.exit(1); diff --git a/dev/wdshot.mjs b/dev/wdshot.mjs index 7cf83d8..96d1a1e 100644 --- a/dev/wdshot.mjs +++ b/dev/wdshot.mjs @@ -122,7 +122,10 @@ try { for (let i = 0; i < steps.length; i++) { const script = `return (async () => { ${steps[i]} })();`; try { - await wd('POST', `/session/${sid}/execute/sync`, { script, args: [] }); + const out = await wd('POST', `/session/${sid}/execute/sync`, { script, args: [] }); + if (out?.value !== undefined && out.value !== null) { + console.log('setup[' + i + '] → ' + JSON.stringify(out.value).slice(0, 400)); + } } catch (err) { console.error(`setup[${i}]: ` + String(err?.message ?? err).slice(0, 200)); } diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 9f468f1..ed7bbba 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -289,8 +289,11 @@ The player holds a REPUTATION (standing) on each planet and space station: `reputation` (scale snapshot + stored standings); the scene keeps one instance in the shared registry (New Game resets it via `resetRunState`). A save from before reputation exists loads as fresh - all-neutral — old saves keep working. No UI yet: nothing changes or - shows standing until the influence mechanics arrive. + all-neutral — old saves keep working. **The comms panel shows it** (a + 41-mark bar, −20 → +20, red→green, lit up to the standing — + `Reputation.marksFor()`) and gates REQUEST LANDING at standing ≤ −4; + nothing CHANGES standing yet — the influence mechanics land on the + mutation seams above. - **Tests** — `dev/reputation.test.mjs` (scale, home pinning, clamping, faction-check order incl. a monkey-patched "factions exist" pass, save round-trips + legacy/corrupt records, capture/prepare/reset @@ -387,7 +390,15 @@ The player holds a REPUTATION (standing) on each planet and space station: from the generator; save-ready + Node-tested (js/reputation/Reputation.js, data/reputation.json) - [ ] Landing & exploration: settlements become points of interest you - can approach (the data — kind, anchor, population — is already there) + can approach (the data — kind, anchor, population — is already there). + The comms panel is the door: clicking a planet or space station flies + the ship there AND opens a comms panel at the click — the name decodes + in, the standing bar draws (settled), REQUEST LANDING (gated at standing + ≤ −4) or LAND on an unsettled world + CANCEL (`js/ui/CommsPanel.js`, a + rusty-metal frame around a scanlined green CRT). Free-space stations are + solid objects now (`js/entities/Station.js`, `data/stations.json`). + Button actions are seams (`GameScene.commsAction`) — the landing + sequence lands there - [x] The player's loop, laid down as data + seams: research (time-based, one at a time, gates builds/research) and building (credits + minerals, ship/planet/station) data layers (`data/research.json`, diff --git a/js/entities/Station.js b/js/entities/Station.js new file mode 100644 index 0000000..7490cc0 --- /dev/null +++ b/js/entities/Station.js @@ -0,0 +1,200 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { Planet } from './Planet.js'; + +/** + * A SPACE STATION — a free-space settlement (SystemGenerator: + * `anchor.type === 'space'`, kinds `deepSpaceStation` / `waypoint`) + * rendered as a world object: a weathered hub + slowly turning ring + + * solar wings (deep-space station) or a small nav beacon (waypoint). + * + * Solid like a planet (the ship keeps its clearance — the same plain + * circle rule, GameScene.solids), discoverable (a compass arrow + the + * discovery toast, at the scale of its keepout), and a comms target — + * click it and the comms panel opens there while the ship flies to its + * edge (GameScene.openCommsPanel). + * + * update(time) turns the ring and breathes the beacon — driven by + * GameScene.update, like the asteroid clusters. + */ +export class Station extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {object} settlement — the content record: { id, kind, name, + * anchor: { type: 'space' }, x, y, population, owner } + * @param {object} [o] { depth } + */ + constructor(scene, settlement, o = {}) { + super(scene, settlement.x, settlement.y); + this.scene.add.existing(this); // v4: new'd containers are not on the display list + this.settlement = settlement; + this.kind = settlement.kind ?? 'deepSpaceStation'; + // Discovery bookkeeping (id = the settlement id — the rep key). + this.discoveryId = settlement.id; + this.discoveryName = settlement.name; + + this.size = config.get(`stations.kinds.${this.kind}.size`, this.kind === 'waypoint' ? 46 : 108); + this.radius = this.size; // the keepout circle's radius + this.bound = this.size; // the discovery radius (the compass/toast scale) + this.clearance = config.get('stations.shipClearance', 50); + this.ringSpeed = config.get(`stations.kinds.${this.kind}.ringSpeed`, 0.08); + this.ringBody = null; + this.setDepth(o.depth ?? 5); + + this.build(); + } + + /** Procedural build — the same worn hardware every game. */ + build() { + const scene = this.scene; + const S = this.size; + const g = scene.add.graphics(); + this.add(g); + + if (this.kind === 'waypoint') { + // A small nav beacon: a base, a mast, a breathing light. + g.fillStyle(0x4a3a2c, 1); + g.fillRect(-S * 0.22, S * 0.28, S * 0.44, S * 0.16); // the base + g.fillStyle(0x57493a, 1); + g.fillRect(-S * 0.06, -S * 0.45, S * 0.12, S * 0.78); // the mast + g.fillStyle(0x6e3a1f, 0.5); // a rust band + g.fillRect(-S * 0.06, 0, S * 0.12, S * 0.18); + g.lineStyle(1.5, 0x2c2118, 1); + g.strokeCircle(0, -S * 0.5, S * 0.13); + g.fillStyle(0x3a2f26, 1); + g.fillCircle(0, -S * 0.5, S * 0.1); + } else { + // ---- Deep-space station ----------------------------------------- + const wingX0 = S * 0.24; + const wingX1 = S * 0.85; + const wingH = S * 0.18; + // Solar wings — weathered blue-gray panels in a rusty frame. + for (const dir of [-1, 1]) { + const x0 = dir === 1 ? wingX0 : -wingX1; + const w = wingX1 - wingX0; + g.lineStyle(3, 0x57493a, 1); + g.lineBetween(dir * S * 0.1, 0, dir * wingX0, 0); // the truss + g.fillStyle(0x3f5468, 1); + g.fillRect(x0, -wingH / 2, w, wingH); + g.fillStyle(0x6e3a1f, 0.35); // rust streak on the outer edge + g.fillRect(dir === 1 ? wingX1 - 6 : -wingX1, -wingH / 2, 6, wingH); + g.lineStyle(2, 0x22303e, 1); + g.strokeRect(x0, -wingH / 2, w, wingH); + g.lineStyle(1, 0x22303e, 0.9); + for (let i = 1; i <= 4; i++) { + const x = x0 + (w * i) / 5; + g.lineBetween(x, -wingH / 2, x, wingH / 2); + } + } + // The rotating ring (spokes + pods) — its own container so + // update() can spin it. + this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0); + const rg = scene.add.graphics(); + const ringR = S * 0.48; + rg.lineStyle(S * 0.065, 0x6a5a48, 0.95); + rg.strokeCircle(0, 0, ringR); + rg.lineStyle(S * 0.03, 0x57493a, 1); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2; + rg.lineBetween( + Math.cos(a) * S * 0.12, + Math.sin(a) * S * 0.12, + Math.cos(a) * ringR, + Math.sin(a) * ringR, + ); + rg.fillStyle(0x8a6a48, 1); + rg.fillCircle(Math.cos(a) * ringR, Math.sin(a) * ringR, S * 0.037); + } + this.ringBody.add(rg); + this.add(this.ringBody); + // The hub (over the ring): a weathered core + rust. + const hubR = S * 0.205; + g.fillStyle(0x57493a, 1); + g.fillCircle(0, 0, hubR); + g.lineStyle(2, 0x2c2118, 1); + g.strokeCircle(0, 0, hubR); + g.fillStyle(0x3a2f26, 1); + g.fillCircle(0, 0, hubR * 0.55); + g.fillStyle(0x8a6a48, 1); + g.fillCircle(0, 0, hubR * 0.22); + g.fillStyle(0x6e3a1f, 0.5); + g.fillCircle(hubR * 0.5, -hubR * 0.35, 3); + g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5); + } + + // The beacon light (both kinds) — the thing that says "occupied". + const hy = this.kind === 'waypoint' ? -S * 0.5 : -S * 0.26; + const hue = this.kind === 'waypoint' ? 0xa8ffc4 : 0xff7a5c; + const glow = this.kind === 'waypoint' ? 0x7dffb0 : 0xff5a3c; + this.beaconGlow = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.24 : 7, glow, 0.2); + this.beacon = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.07 : 2.6, hue, 1); + this.add([this.beaconGlow, this.beacon]); + } + + /** The ring turns; the beacon breathes. (GameScene.update drives this.) */ + update(time) { + const t = time / 1000; + if (this.ringBody) this.ringBody.rotation = t * this.ringSpeed; + const pulse = 0.5 + 0.5 * Math.sin(t * 2.6); + this.beaconGlow.setAlpha(0.12 + 0.3 * pulse); + this.beacon.setAlpha(0.6 + 0.4 * pulse); + } + + // ---- SOLID (the same contract as Planet / AsteroidCluster) --------- + + minCenterDistance(shipRadius = 0) { + return this.radius + this.clearance + shipRadius; + } + + /** A point `gap` past the surface toward (wx, wy) — the ship's approach stop. */ + edgePoint(angle, gap, shipRadius = 0) { + const d = this.radius + gap + shipRadius; + return { + x: this.x + Math.cos(angle) * d, + y: this.y + Math.sin(angle) * d, + }; + } + + /** Clamp a target to at least clearance outside the surface (the ship can stop anywhere at the edge). */ + aimPoint(wx, wy, shipRadius = 0) { + const minDist = this.minCenterDistance(shipRadius); + const dx = wx - this.x; + const dy = wy - this.y; + const dist = Math.hypot(dx, dy); + if (dist >= minDist) return { x: wx, y: wy }; + if (dist === 0) return { x: this.x + minDist, y: this.y }; + return { + x: this.x + (dx / dist) * minDist, + y: this.y + (dy / dist) * minDist, + }; + } + + /** Hard constraint — push the ship outside the keepout circle (position + velocity + acceleration). */ + constrainShip(ship, shipRadius = 0) { + const body = ship.body; + const r = Planet.resolve( + this.x, + this.y, + this.minCenterDistance(shipRadius), + ship.x, + ship.y, + body.velocity.x, + body.velocity.y, + body.acceleration ? body.acceleration.x : 0, + body.acceleration ? body.acceleration.y : 0, + ); + ship.x = r.x; + ship.y = r.y; + body.velocity.x = r.vx; + body.velocity.y = r.vy; + if (body.acceleration) { + body.acceleration.x = r.ax; + body.acceleration.y = r.ay; + } + } + + destroy() { + this.removeChildren(); + super.destroy(); + } +} diff --git a/js/reputation/Reputation.js b/js/reputation/Reputation.js index 5f9e72e..570b392 100644 --- a/js/reputation/Reputation.js +++ b/js/reputation/Reputation.js @@ -187,6 +187,23 @@ export class Reputation { return null; // TODO(factions): see above — the placeholder on purpose. } + /** + * How many of the scale's marks are LIT at standing `value` — the + * reputation gauge (the comms panel's bar, js/ui/CommsPanel.js) draws + * one mark per integer on the scale (41 for −20…+20), and the mark + * for standing `v` is lit iff `v <= standing`. So the worst standing + * lights just the leftmost (red) mark, the best lights the whole bar + * (through green), and 0 lights the left half plus the centre. + * + * @param {number} value a standing on the scale (clamped; rounded) + * @returns {number} 1…(max−min+1), or 0 when the input is not numeric + */ + marksFor(value) { + const v = this._toStanding(value); + if (v === null) return 0; + return clamp(v - this.min + 1, 1, this.max - this.min + 1); + } + /** Round + clamp to the scale (integer steps); null when not a number. */ _toStanding(value) { const n = Math.round(Number(value)); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 5ec1ef7..2184471 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6,11 +6,12 @@ import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; -import { Reputation } from '../reputation/Reputation.js'; +import { Reputation, HOME_KEY } from '../reputation/Reputation.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; +import { Station } from '../entities/Station.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; @@ -21,6 +22,7 @@ import { consumeRestore } from '../save/SaveData.js'; import { TetherField } from '../tether/TetherField.js'; import { Mining } from '../mining/Mining.js'; import { MiningPopup } from '../ui/MiningPopup.js'; +import { CommsPanel } from '../ui/CommsPanel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); @@ -188,10 +190,24 @@ export class GameScene extends Phaser.Scene { } } + // Free-space stations (settlements with `anchor.type === 'space'` + // — deep-space stations and waypoints): settlements that float in + // the void, rendered as world objects (js/entities/Station.js). + // Solid (the ship keeps its clearance), discoverable (compass + + // toast), and comms targets — a click opens the comms panel there + // (openCommsPanel below) and the ship flies to the rim. + this.systemStations = []; + if (config.get('stations.enabled', true) !== false) { + for (const s of this.systemContent.settlements ?? []) { + if (s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue; + this.systemStations.push(new Station(this, s, { depth: 5 })); + } + } + // Every solid in the system — worlds first (their keep-out circles are - // disjoint), then the clusters. Ship constraint, click-to-fly clamping - // and autopilot all run against this list. - this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters]; + // disjoint), then the clusters, then the stations. Ship constraint, + // click-to-fly clamping and autopilot all run against this list. + this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations]; this.planet.discoveryId = 'home'; this.planet.discoveryName = this.homeWorldName; @@ -345,6 +361,20 @@ export class GameScene extends Phaser.Scene { onAction: (id, rock) => this.miningAction(id, rock), }); + // COMM PANEL (js/ui/CommsPanel.js) — the starship comms console that + // opens at the player's click on a planet or space station: a rusty + // metal case around a green phosphor readout. The name decodes at + // the top; a SETTLED object (a world hosting a colony / mining + // station / cloud base, or a free-space station) shows a reputation + // bar — 41 marks, −20…+20, red → green, lit up to the standing — + // plus REQUEST LANDING (grayed at standing ≤ −4) and CANCEL; an + // UNSETTLED world shows LAND + CANCEL. The buttons are seams for + // now — the landing sequence wires into commsAction() once it + // exists. + this.commsPanel = new CommsPanel(this, { + onAction: (id, target) => this.commsAction(id, target), + }); + // SHIP STATE (js/entities/Ship.js): 'normal' is the default — the // ship is free; 'mining' — the arm's sequence owns the ship. The // mining visuals live and die with that state: ANY change out of it @@ -417,6 +447,35 @@ export class GameScene extends Phaser.Scene { return; } + // COMM PANEL OPEN (js/ui/CommsPanel.js) — the same contract as the + // mining menu: a click on one of its buttons is the button's (its + // own pointerdown listener); a click INSIDE it is swallowed (no + // fly-here); a click on ANOTHER planet/station moves the panel + // there (and the ship flies to it — comms business, not a world + // click); any other click closes it and that click is consumed. + if (this.commsPanel && this.commsPanel.isOpen) { + if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return; + const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); + if (obj) { + this.openCommsPanel(obj, pointer); + return; + } + this.commsPanel.close(); + return; + } + // A click the panel's OWN buttons just handled (they close it from + // their side first — the two handlers race on event order): it is + // the panel's click, never a fly-here (the same same-frame test as + // the mining menu's button guard below it). + if ( + this.commsPanel && + this.commsPanel.closedByButtonAt !== null && + this.time.now - this.commsPanel.closedByButtonAt < 20 && + this.commsPanel.containsScreen(pointer.x, pointer.y) + ) { + return; + } + // A rock under the cursor: clicking an asteroid opens the mining // menu (or the stop menu, when this cluster is the beam's target) // — this click does not fly the ship. @@ -426,6 +485,11 @@ export class GameScene extends Phaser.Scene { return; } + // A PLANET or STATION under the cursor (a rock click is the mining + // flow above): the ship still flies — to its keep-out rim — AND the + // comms panel opens at the click (js/ui/CommsPanel.js). + const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); + // Any other click MOVES the ship — which ends the mining state // (the beam retracts as the ship goes; a mid-reach arm aborts). // The state exit is signalled via ship.onStateChange above. @@ -439,6 +503,7 @@ export class GameScene extends Phaser.Scene { this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); + if (obj) this.openCommsPanel(obj, pointer); // comms: the panel opens at the click }); // ESC: the topmost open thing closes — the confirm dialog, then the @@ -804,10 +869,12 @@ export class GameScene extends Phaser.Scene { // state (folds, decodes, toasts, the confirm dialog). this.menuSubBar?.update(_time, delta); this.savePanel?.update(_time); + this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker // The clusters are ALIVE: each rock tumbles, the loose group drifts, // the dust orbits. (The keep-out constraint runs in onPostUpdate, // after the physics step has moved the ship.) for (const c of this.asteroidClusters) c.update(_time); + for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) this.updateCamera(delta); this.starfield.update(); // after the camera, so it sees this frame's motion @@ -980,6 +1047,18 @@ export class GameScene extends Phaser.Scene { name: c.discoveryName, }); } + // Space stations are objects too: discoverable, compass arrows, + // autopilot — and comms targets — at the scale of their keepout. + for (const st of this.systemStations) { + out.push({ + id: st.discoveryId, + x: st.x, + y: st.y, + radius: st.bound, + typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'), + name: st.discoveryName, + }); + } return out; } @@ -1076,6 +1155,123 @@ export class GameScene extends Phaser.Scene { // 'cancel' — just close. } + // ================================================================== + // Comms — the planet / station comms panel (js/ui/CommsPanel.js) + // ================================================================== + + /** + * Which comms object (if any) sits under a WORLD point — the home + * world, a system planet, or a space station — hit-tested at the + * scale of its keepout circle (radius + clearance, the flyable rim). + * Nearest wins (they don't overlap). Rocks are NOT comms objects — + * they are the mining flow's (rockAt above). + */ + worldObjectAt(wx, wy) { + let best = null; + let bestD = Infinity; + for (const s of [this.planet, ...this.systemPlanets, ...this.systemStations]) { + const r = s.radius + (s.clearance ?? 0); + const d = Math.hypot(wx - s.x, wy - s.y); + if (d <= r && d < bestD) { + best = s; + bestD = d; + } + } + return best; + } + + /** + * What the comms panel shows for a planet or station: + * - settled — it hosts a settlement (a colony / mining station / + * cloud base anchored to the planet), or IS one (a + * free-space station); the home world is settled by + * definition (the player's homestead) + * - key — the reputation standing key (the anchored planet's + * FIRST settlement id — the stable `-s` id; + * HOME_KEY for home; the station's own id) + * - reputation — the standing on that key (neutral 0 when none) + * - canLand — standing > −4 (the panel grays the button at ≤ −4) + * - kindLabel — a short flavor line under the name + */ + commsTargetFor(obj) { + let key = null; + let settled = false; + let kindLabel = ''; + if (obj === this.planet) { + // The home world: standing pinned at the scale's top. + key = HOME_KEY; + settled = true; + kindLabel = config.get('planets.homeTypeLabel', 'Home World'); + } else if (obj.settlement) { + // A free-space station — settled by definition; key = its id. + key = obj.settlement.id; + settled = true; + kindLabel = config.get(`settlements.kinds.${obj.kind}.label`, 'Station'); + } else { + // A system planet: settled when a settlement is anchored to it + // (anchor.type 'planet', anchor.ordinal = the planet's ordinal). + const rec = (this.systemContent.planets ?? []).find((p) => p.name === obj.discoveryName); + if (rec) { + kindLabel = config.get(`planets.typeLabels.${rec.name}`, rec.name); + const anchored = (this.systemContent.settlements ?? []).filter( + (s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal, + ); + if (anchored.length > 0) { + settled = true; + key = anchored[0].id; + } + } + } + const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0; + return { + name: obj.discoveryName, + settled, + key, + reputation: rep, + canLand: settled && rep > -4, + kindLabel, + }; + } + + /** + * A planet or station was clicked (the ship is already flying to its + * rim — the click-to-fly path did that): the comms panel opens at the + * click — the name decodes in, the reputation bar draws (settled), + * the landing buttons wait. The panel is world-anchored at the click + * point and picks a side (up/down/left/right) that keeps it fully on + * screen (CommsPanel.pickSideAndPlace). + */ + openCommsPanel(obj, pointer) { + const t = this.commsTargetFor(obj); + this.playSfx('construct'); // the panel decodes in + this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, { + name: t.name, + settled: t.settled, + reputation: t.reputation, + litMarks: this.reputation.marksFor(t.reputation), + canLand: t.canLand, + key: t.key, + kindLabel: t.kindLabel, + }); + } + + /** + * The panel's button presses (CommsPanel → onAction). The panel is + * already closed by the time this runs — the actions are SEAMS for + * now; the landing sequence lands here once it exists: + * 'request-landing' — a settled world, standing > −4 + * 'land' — an unsettled world + * 'cancel' — just close (done) + */ + commsAction(id, target) { + this.commsPanel.close(); // the buttons close the panel (like miningAction) + if (!target) return; + // TODO(landing): wire the landing sequence — for now the click is + // acknowledged on the console (the seam the landing logic plugs + // into). + console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`); + } + /** * Mining phase changes (Mining → onPhase): the scene's share of the * sequence — the ship's STATE, the ship lock, the console calls, the sfx. @@ -1159,12 +1355,15 @@ export class GameScene extends Phaser.Scene { hideHint() { if (!this.hint || !this.hint.active) return; + if (this._hintClosing) return; // a fade-out is already running + this._hintClosing = true; this.tweens.add({ targets: this.hint, alpha: 0, duration: 400, onComplete: () => { - this.hint.destroy(); + this._hintClosing = false; + if (this.hint) this.hint.destroy(); this.hint = null; }, }); @@ -1235,6 +1434,11 @@ export class GameScene extends Phaser.Scene { this.miningPopup.close(); return; } + // The comms panel (a planet/station click opened it). + if (this.commsPanel && this.commsPanel.isOpen) { + this.commsPanel.close(); + return; + } // Beam live (or arm extending) → break the mining (the beam retracts; // the ship is free again — no fly). if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) { @@ -1298,6 +1502,7 @@ export class GameScene extends Phaser.Scene { this.menuSubBar?.destroy(); this.savePanel?.destroy(); this.miningPopup?.destroy(); + this.commsPanel?.destroy(); this.mining?.destroy(); this.tetherField?.destroy(); } diff --git a/js/ui/CommsPanel.js b/js/ui/CommsPanel.js new file mode 100644 index 0000000..f9bc977 --- /dev/null +++ b/js/ui/CommsPanel.js @@ -0,0 +1,709 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toCss } from '../utils/Color.js'; +import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; +import { setInteractiveEnabled } from '../utils/Input.js'; + +const FONT = "'Courier New', 'Lucida Console', Consolas, monospace"; + +/** + * The panel's own palette — a physical piece of hardware, not the + * neon-console theme: rusty metal case + green phosphor screen. + */ +const FRAME_BASE = 0x4a3625; // rusted iron +const FRAME_DARK = 0x2c1f14; +const FRAME_LIGHT = 0x8a6a48; // bevel highlight +const RUST = 0x6e3a1f; +const RIVET = 0x241811; +const RIVET_LIGHT = 0x9c7a54; +const SCR_BG = 0x04120a; // phosphor off — near-black green +const SCR_BORDER = 0x1e5c33; +const GLOW = 0x39ff7d; // phosphor +const GRN_BRIGHT = 0xa4ffb8; +const GRN_MID = 0x63d47e; +const GRN_DIM = 0x3f8a52; +const MARK_UNLIT = 0x1c3a27; +const MARK_RED = 0xff4a3a; // the −20 end of the scale +const MARK_GREEN = 0x3dff6e; // the +20 end +const AMBER = 0xffb44d; // the case's status LED +const INK_DISABLED = 0x46584a; + +const lerp = (a, b, t) => a + (b - a) * t; +function lerpColor(a, b, t) { + const r = Math.round(lerp((a >> 16) & 255, (b >> 16) & 255, t)); + const g = Math.round(lerp((a >> 8) & 255, (b >> 8) & 255, t)); + const bl = Math.round(lerp(a & 255, b & 255, t)); + return (r << 16) | (g << 8) | bl; +} + +/** + * The comms panel's button — green-phosphor terminal styling (a bracket + * box + monospace label), not the neon MenuButton: base = thin green + * box, hover = the box lights up, press = an inverted flash, disabled = + * a grayed ghost (the "Request Landing" deny state). Clicks fire on the + * box's own pointerdown (the GameScene handler and the button race on + * event order — CommsPanel.closedByButtonAt reconciles, like + * MiningPopup does). + */ +class TermButton extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {string} label + * @param {number} width + * @param {number} height + * @param {function} onFire fired on a (non-disabled) press + */ + constructor(scene, label, width, height, onFire) { + super(scene, 0, 0); + this.onFire = onFire; + this.hoverOn = false; + this.disabled = false; + this.pressing = false; + // (Not this.w — v4 Containers reserve x/y/z/w for their transform + // vector; a bare .w read-backs 0. .bw/.bh are ours.) + this.bw = width; + this.bh = height; + + this.box = scene.add.graphics(); + this.text = scene.add + .text(0, 0, String(label).toUpperCase(), { + fontFamily: FONT, + fontSize: '12px', + color: toCss(GRN_MID), + letterSpacing: 2, + }) + .setOrigin(0.5); + this.add([this.box, this.text]); + this.scene.add.existing(this); // v4: new'd containers are not on the display list + + // Hit-test the whole rect with an explicit area (independent of the + // Graphics' draw state, so repainting never breaks interaction). + this.box.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height), + hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py), + }); + this.box.on('pointerover', () => this.hover(true)); + this.box.on('pointerout', () => this.hover(false)); + this.box.on('pointerdown', () => { + if (this.disabled) return; + this.press(); + if (typeof onFire === 'function') onFire(); + }); + this.paint(); + } + + setLabel(label) { + this.text.setText(String(label).toUpperCase()); + } + + /** The grayed state (Request Landing at standing ≤ −4): dim paint, no hover, clicks inert. */ + setDisabled(on) { + this.disabled = !!on; + this.hoverOn = false; + setInteractiveEnabled(this.box, !on); + this.paint(); + } + + /** Repaint for the visual state: disabled | pressing | hover | base. */ + paint() { + const g = this.box; + g.clear(); + const w = this.bw; + const h = this.bh; + if (this.disabled) { + g.lineStyle(1, 0x2c4030, 0.55); + g.strokeRect(-w / 2, -h / 2, w, h); + this.text.setColor(toCss(INK_DISABLED)); + return; + } + if (this.pressing) { + g.fillStyle(GRN_BRIGHT, 0.9); + g.fillRect(-w / 2, -h / 2, w, h); + g.lineStyle(1.5, 0xd6ffe0, 1); + g.strokeRect(-w / 2, -h / 2, w, h); + this.text.setColor(toCss(0x06130a)); + return; + } + if (this.hoverOn) { + g.fillStyle(0x0e2a17, 0.6); + g.fillRect(-w / 2, -h / 2, w, h); + g.lineStyle(1.5, 0x54c876, 1); + g.strokeRect(-w / 2, -h / 2, w, h); + this.text.setColor(toCss(GRN_BRIGHT)); + return; + } + g.lineStyle(1, GRN_DIM, 0.8); + g.strokeRect(-w / 2, -h / 2, w, h); + this.text.setColor(toCss(GRN_MID)); + } + + hover(on) { + if (this.disabled) return; + this.hoverOn = on; + this.paint(); + } + + /** Inverted flash + a beat, then restore. */ + press() { + if (this.disabled || this.pressing) return; + this.pressing = true; + this.paint(); + this.scene.time.delayedCall(120, () => { + this.pressing = false; + if (this.active !== false) this.paint(); + }); + } + + destroy() { + this.box?.destroy(); + this.text?.destroy(); + super.destroy(); + } +} + +/** + * The COMM PANEL — a starship communications console that opens right + * where the player clicked a planet or space station (GameScene + * .openCommsPanel): a rusty metal case — rivets, scratches, beveled + * edges, a status LED — around a green phosphor screen (scanlines, a + * faint flicker, a blinking terminal cursor): + * + * ┌──────────────────────────────────────┐ + * │ ESHKAELURA ▌ │ name decodes in (the + * │ · ROCKY WORLD · │ shared scramble) + * │ REPUTATION +00 │ (settled objects only) + * │ ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌░░░░░░ │ 41 marks, −20…+20: red on + * │ ┌──────────────────────────────────┐ │ the left → green on the + * │ │ REQUEST LANDING │ │ right, LIT up to the + * │ └──────────────────────────────────┘ │ standing (the worst mark + * │ ┌──────────────────────────────────┐ │ is red, the best green) + * │ │ CANCEL │ │ Request Landing is a + * │ └──────────────────────────────────┘ │ grayed ghost at standing + * └──────────────────────────────────────┘ ≤ −4; unsettled worlds + * skip the reputation block + * and read LAND / CANCEL + * + * WORLD-ANCHORED (like the mining menu): the panel lives at the click + * point in the world and stays with it as the camera trails. It opens + * on the side (up/down/left/right) that keeps it fully on screen — the + * click's SCREEN position decides (pickSideAndPlace below). + * + * Input contract (driven from GameScene's pointerdown, same as + * MiningPopup): a click ON a button is the button's (its own + * pointerdown); a click INSIDE the panel is swallowed; a click on + * another planet/station MOVES the panel there; any other click closes + * the panel and is consumed (no fly-here). The scene and the buttons + * fire on the same input pass, so closedByButtonAt + containsScreen() + * reconcile the race exactly like the mining menu does. + * + * const cp = new CommsPanel(scene, { onAction: (id, target) => ... }); + * cp.open(wx, wy, screenX, screenY, { + * name, settled, reputation, litMarks, canLand, key, kindLabel, + * }); + * cp.close(); cp.contains(wx, wy); cp.isOpen; cp.update(time); cp.destroy(); + */ +export class CommsPanel extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {object} [o] { onAction?: (id: 'request-landing'|'land'|'cancel', target) => void } + */ + constructor(scene, o = {}) { + super(scene, 0, 0); + this.scene.add.existing(this); // v4: new'd containers are not on the display list + // Above the ship (10) and the tether line (6); under the console + // toasts (45) and the deck (50). + this.setDepth(40); + this.onAction = typeof o.onAction === 'function' ? o.onAction : null; + + // ---- Geometry (px). The case frame wraps a phosphor screen; the + // settled and unsettled layouts differ only in the reputation block. + this.W = 312; + this.framePad = 20; // the metal case's border + this.padTop = 14; + this.padBottom = 14; + this.nameH = 20; + this.kindH = 12; + this.gapSettled = 10; // name block → reputation block + this.repLabelH = 12; + this.repGap = 5; // label row → marks row + this.marksH = 14; + this.gapBtns = 12; // reputation block → buttons + this.gapOpen = 14; // name block → buttons (unsettled) + this.btnH = 30; + this.btnGap = 8; + this.btnW = this.W - this.framePad * 2 - 28; // content width (14 px screen padding each side) + this.marksN = 41; + this.marksPitch = 5; + this.marksW = 4; + this.innerPad = 14; // screen inner padding + + this.H_SETTLED = + this.framePad * 2 + this.padTop + this.nameH + this.kindH + + this.gapSettled + this.repLabelH + this.repGap + this.marksH + this.gapBtns + + this.btnH + this.btnGap + this.btnH + this.padBottom; + this.H_OPEN = + this.framePad * 2 + this.padTop + this.nameH + this.kindH + + this.gapOpen + this.btnH + this.btnGap + this.btnH + this.padBottom; + + // ---- Children (built once; the case redraws on open — H differs). -- + const nameX = -this.W / 2 + this.framePad + this.innerPad; + this.frameG = scene.add.graphics(); + this.marksG = scene.add.graphics(); + this.nameText = scene.add + .text(nameX, 0, '', { + fontFamily: FONT, + fontSize: '15px', + color: toCss(GRN_BRIGHT), + letterSpacing: 1, + }) + .setOrigin(0, 0.5); + this.cursorText = scene.add + .text(0, 0, '▌', { fontFamily: FONT, fontSize: '14px', color: toCss(GRN_BRIGHT) }) + .setOrigin(0, 0.5); + this.kindText = scene.add + .text(nameX, 0, '', { fontFamily: FONT, fontSize: '10px', color: toCss(GRN_DIM), letterSpacing: 2 }) + .setOrigin(0, 0.5); + this.repLabel = scene.add + .text(nameX, 0, 'REPUTATION', { fontFamily: FONT, fontSize: '10px', color: toCss(GRN_DIM), letterSpacing: 2 }) + .setOrigin(0, 0.5); + this.repValue = scene.add + .text(this.W / 2 - this.framePad - this.innerPad, 0, '', { + fontFamily: FONT, + fontSize: '11px', + color: toCss(GRN_MID), + letterSpacing: 1, + }) + .setOrigin(1, 0.5); + this.btn1 = new TermButton(scene, 'REQUEST LANDING', this.btnW, this.btnH, () => this.fire('request-landing')); + this.btn2 = new TermButton(scene, 'CANCEL', this.btnW, this.btnH, () => this.fire('cancel')); + // The phosphor flicker sits ON TOP (screen glass) — it is never + // interactive, so the buttons under it still catch their clicks. + this.flickG = scene.add.graphics(); + + this.add([ + this.frameG, + this.marksG, + this.nameText, + this.cursorText, + this.kindText, + this.repLabel, + this.repValue, + this.btn1, + this.btn2, + this.flickG, + ]); + + this.state = 'closed'; // 'closed' | 'open' | 'closing' + this.rect = null; // WORLD footprint (the scene's click-outside test) + this.side = null; // 'up' | 'down' | 'left' | 'right' + this.lastTarget = null; // the payload open() got (handed to onAction) + this.closedByButtonAt = null; // stamped when a BUTTON fires (race guard) + this.nameDec = null; + this._name = ''; + this.repRevealT0 = null; + this.repFinalLit = 0; + this.settled = false; + this.H = this.H_OPEN; + this.layout(); + this.drawFrame(); + this.setAlpha(0); + setInteractiveEnabled(this.btn1.box, false); + setInteractiveEnabled(this.btn2.box, false); + } + + get isOpen() { + return this.state === 'open'; + } + + /** + * Open at the click point. + * @param {number} wx, wy — the click, WORLD coords (the panel anchors here) + * @param {number} sx, sy — the same click in SCREEN coords (decides the side) + * @param {object} o — { name, settled, reputation, litMarks?, canLand?, key?, kindLabel? } + */ + open(wx, wy, sx, sy, o = {}) { + if (this.scene === null || this.active === false) return; + const name = String(o.name ?? 'UNKNOWN').toUpperCase(); + this.settled = !!o.settled; + this.rep = Math.round(o.reputation ?? 0); + this.canLand = o.canLand !== false; + this._name = name; + this.lastTarget = { + name, + settled: this.settled, + key: o.key ?? null, + reputation: this.rep, + kindLabel: String(o.kindLabel ?? ''), + }; + + // The name decodes in (the console pulls it out of static)… + this.nameText.setText(''); + this.nameDec = new ScrambleDecode(name, this.scene.time.now + 160, decodeDur(name.length)); + this.kindText.setText(o.kindLabel ? `· ${String(o.kindLabel).toUpperCase()} ·` : ''); + + if (this.settled) { + // …then the bar draws itself to the standing (update() drives it). + const repMin = config.get('reputation.min', -20); + this.repFinalLit = + typeof o.litMarks === 'number' ? o.litMarks : Math.round(this.rep - repMin + 1); + this.repFinalLit = Math.max(0, Math.min(this.marksN, this.repFinalLit)); + this.repRevealT0 = this.scene.time.now + 340; + this.marksG.clear(); + this.repLabel.setVisible(true); + this.repValue.setVisible(true); + this.repValue.setText(this.rep > 0 ? `+${this.rep}` : String(this.rep)); + this.marksG.setVisible(true); + this.btn1.setLabel('REQUEST LANDING'); + this.btn1.setDisabled(!this.canLand); + } else { + this.repRevealT0 = null; + this.repFinalLit = 0; + this.repLabel.setVisible(false); + this.repValue.setVisible(false); + this.marksG.setVisible(false); + this.btn1.setLabel('LAND'); + this.btn1.setDisabled(false); + } + this.btn2.setLabel('CANCEL'); + this.btn2.setDisabled(false); + + this.H = this.settled ? this.H_SETTLED : this.H_OPEN; + this.layout(); + this.drawFrame(); + this.pickSideAndPlace(wx, wy, sx, sy); + + this.scene.tweens.killTweensOf(this); + this.state = 'open'; + this.btn1.hover(false); + this.btn2.hover(false); + this.btn1.paint(); + this.btn2.paint(); + setInteractiveEnabled(this.btn1.box, true); + setInteractiveEnabled(this.btn2.box, true); + this.setAlpha(0).setScale(0.92); + this.scene.tweens.add({ + targets: this, + alpha: 1, + scale: 1, + duration: 160, + ease: 'Sine.easeOut', + }); + } + + /** Close (any click outside, a button press, or ESC). */ + close() { + if (this.state !== 'open') return; + this.state = 'closing'; + this.scene.tweens.killTweensOf(this); + setInteractiveEnabled(this.btn1.box, false); + setInteractiveEnabled(this.btn2.box, false); + this.scene.tweens.add({ + targets: this, + alpha: 0, + scale: 0.95, + duration: 120, + ease: 'Sine.easeIn', + onComplete: () => { + if (this.state === 'closing') this.state = 'closed'; + }, + }); + } + + /** + * Pick the side (up / down / left / right) that keeps the panel fully + * on screen, then place it there (LIFT px off the click point). + * Preference: up, down, right, left — the first that fits. A click's + * free axis is clamped so the panel can never overhang an edge on the + * perpendicular (a click near the left edge opening UP still keeps the + * panel's left corner on screen). + */ + pickSideAndPlace(wx, wy, sx, sy) { + const W = this.W; + const H = this.H; + const LIFT = 14; // gap between the click point and the panel edge + const cam = this.scene.cameras.main; + const SW = this.scene.scale.width; + const SH = this.scene.scale.height; + const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + + const fits = { + up: sy - LIFT - H >= 0, + down: sy + LIFT + H <= SH, + left: sx - LIFT - W >= 0, + right: sx + LIFT + W <= SW, + }; + const side = ['up', 'down', 'right', 'left'].find((k) => fits[k]) ?? 'up'; + + let cx, cy; // panel CENTER, screen space + if (side === 'up') { + cx = clamp(sx, W / 2, SW - W / 2); + cy = sy - LIFT - H / 2; + } else if (side === 'down') { + cx = clamp(sx, W / 2, SW - W / 2); + cy = sy + LIFT + H / 2; + } else if (side === 'left') { + cx = sx - LIFT - W / 2; + cy = clamp(sy, H / 2, SH - H / 2); + } else { + cx = sx + LIFT + W / 2; + cy = clamp(sy, H / 2, SH - H / 2); + } + + const px = cam.scrollX + cx; + const py = cam.scrollY + cy; + this.side = side; + this.setPosition(px, py); + this.rect = { x: px - W / 2, y: py - H / 2, w: W, h: H }; + } + + /** + * Lay the children out for the current height. Top→bottom (always): + * name · kind · [REPUTATION + the bar] · button 1 · CANCEL. + */ + layout() { + const F = this.framePad; + const st = -this.H / 2 + F; // the screen's top edge (local) + const lx = -this.W / 2 + F + this.innerPad; // left content edge + + const nameY = st + this.padTop + this.nameH / 2; + this.nameText.setPosition(lx, nameY); + this.cursorText.setPosition(lx + this.nameText.width + 5, nameY); + this.kindText.setPosition(lx, st + this.padTop + this.nameH + this.kindH / 2); + + let next = st + this.padTop + this.nameH + this.kindH; + if (this.settled) { + const labelY = next + this.gapSettled + this.repLabelH / 2; + this.repLabel.setPosition(lx, labelY); + this.repValue.setPosition(this.W / 2 - F - this.innerPad, labelY); + this.marksY = labelY + this.repLabelH / 2 + this.repGap + this.marksH / 2; + next = this.marksY + this.marksH / 2 + this.gapBtns; + } else { + this.marksY = -1e9; // unused (marks hidden) + next += this.gapOpen; + } + const btn1Y = next + this.btnH / 2; + const btn2Y = btn1Y + this.btnH / 2 + this.btnGap + this.btnH / 2; + this.btn1.setPosition(0, btn1Y); + this.btn2.setPosition(0, btn2Y); + + // The flicker glass covers the whole screen area. + this.flickG.clear(); + this.flickG.fillStyle(GLOW, 1); + this.flickG.fillRect(-this.W / 2 + F, st, this.W - F * 2, this.H - F * 2); + this.flickG.setAlpha(0.03); + } + + /** + * Redraw the case + screen for the current size. Everything is drawn + * at deterministic positions — the same worn panel every open. + */ + drawFrame() { + const g = this.frameG; + g.clear(); + const W = this.W; + const H = this.H; + const x0 = -W / 2; + const y0 = -H / 2; + const x1 = W / 2; + const y1 = H / 2; + const F = this.framePad; + + // ---- The case: rusty metal ---------------------------------------- + g.fillStyle(FRAME_BASE, 1); + g.fillRect(x0, y0, W, H); + // Brushed streaks (subtle, deterministic). + for (const f of [0.06, 0.18, 0.31, 0.44, 0.58, 0.69, 0.82, 0.94]) { + g.fillStyle(0x000000, 0.04 + 0.05 * ((f * 13) % 1)); + g.fillRect(x0 + 3, y0 + H * f, W - 6, 1); + } + // Bevel: top + left catch the light, bottom + right fall off. + g.fillStyle(FRAME_LIGHT, 0.5); + g.fillRect(x0, y0, W, 2); + g.fillRect(x0, y0, 2, H); + g.fillStyle(FRAME_DARK, 0.7); + g.fillRect(x0, y1 - 2, W, 2); + g.fillRect(x1 - 2, y0, 2, H); + // Rust blotches (in the case strip only). + const spots = [ + [x1 - 14, y0 + 9, 4, RUST, 0.3], + [x0 + W * 0.3, y0 + 8, 3, FRAME_DARK, 0.4], + [x0 + 11, y1 - 10, 4, RUST, 0.3], + [x0 + W * 0.62, y1 - 8, 5, FRAME_DARK, 0.35], + [x0 + 9, y0 + H * 0.5, 3, RUST, 0.28], + [x1 - 9, y0 + H * 0.74, 3, FRAME_DARK, 0.35], + ]; + for (const [sx, sy, r, c, a] of spots) { + g.fillStyle(c, a); + g.fillCircle(sx, sy, r); + } + // Scratches. + g.lineStyle(1, 0x1c130c, 0.5); + g.lineBetween(x0 + 36, y0 + 6, x0 + 84, y0 + 9); + g.lineBetween(x1 - 88, y1 - 7, x1 - 40, y1 - 10); + g.lineBetween(x0 + 7, y0 + H * 0.34, x0 + 10, y0 + H * 0.34 + 12); + g.lineBetween(x1 - 9, y0 + H * 0.62, x1 - 6, y0 + H * 0.62 - 14); + // Rivets — corners + the two long-edge mids. + const rivets = [ + [x0 + 9, y0 + 9], + [x1 - 9, y0 + 9], + [x0 + 9, y1 - 9], + [x1 - 9, y1 - 9], + [x0 + 9, y0 + H / 2], + [x1 - 9, y0 + H / 2], + ]; + for (const [rx, ry] of rivets) { + g.fillStyle(RIVET, 1); + g.fillCircle(rx, ry, 3.4); + g.fillStyle(RIVET_LIGHT, 0.8); + g.fillCircle(rx - 0.9, ry - 0.9, 1); + } + // The case's status LED (amber — the panel is alive). + g.fillStyle(AMBER, 0.25); + g.fillCircle(x0 + W * 0.5, y0 + F / 2, 5); + g.fillStyle(AMBER, 1); + g.fillCircle(x0 + W * 0.5, y0 + F / 2, 2.2); + + // ---- The screen: dark phosphor sunk into the case ------------------ + const sx0 = x0 + F; + const sy0 = y0 + F; + const sx1 = x1 - F; + const sy1 = y1 - F; + g.fillStyle(0x000000, 0.45); // the recess shadow + g.fillRect(sx0 - 3, sy0 - 3, sx1 - sx0 + 6, sy1 - sy0 + 6); + g.fillStyle(SCR_BG, 1); + g.fillRect(sx0, sy0, sx1 - sx0, sy1 - sy0); + // Scanlines. + g.fillStyle(0x000000, 0.2); + for (let y = sy0 + 1; y < sy1; y += 3) g.fillRect(sx0, y, sx1 - sx0, 1); + // Glass glare — a faint diagonal band from the top-left. + g.fillStyle(0xbfffdd, 0.03); + g.fillTriangle(sx0, sy0, sx1 - 40, sy0, sx0, sy1 - 60); + // Phosphor glow halo + the screen's border. + g.lineStyle(5, GLOW, 0.1); + g.strokeRect(sx0 - 2.5, sy0 - 2.5, sx1 - sx0 + 5, sy1 - sy0 + 5); + g.lineStyle(1.5, SCR_BORDER, 0.9); + g.strokeRect(sx0 + 0.5, sy0 + 0.5, sx1 - sx0 - 1, sy1 - sy0 - 1); + } + + /** + * Redraw the reputation bar. `fraction` (0…1) is the share of the + * FINAL lit count — the bar draws itself in on open (update() drives + * it; 1 = final state). + * + * The bar: one mark per integer on the scale (41, −20…+20), colored + * red (left) → green (right); a mark is LIT when its value ≤ the + * standing (so the lit run always starts at the left end and ends at + * the player's number), unlit marks stay a dim ghost. + */ + drawMarks(fraction = 1) { + const g = this.marksG; + g.clear(); + if (!this.settled) return; + const n = this.marksN; + const litFinal = Math.max(0, Math.min(n, Math.round(this.repFinalLit))); + const lit = Math.round(fraction * litFinal); + const left = -this.W / 2 + this.framePad + this.innerPad; + const y = this.marksY - this.marksH / 2; + for (let i = 0; i < n; i++) { + const col = lerpColor(MARK_RED, MARK_GREEN, i / (n - 1)); + const x = left + i * this.marksPitch; + const isLit = i < lit; + if (isLit) { + g.fillStyle(col, 0.16); // a faint halo under the lit marks + g.fillRect(x - 1.5, y - 1.5, this.marksW + 3, this.marksH + 3); + } + g.fillStyle(isLit ? col : MARK_UNLIT, isLit ? 1 : 0.85); + g.fillRect(x, y, this.marksW, this.marksH); + } + } + + /** + * Per-frame life while open (GameScene.update drives this): the name + * decode + the cursor glued to it, the reputation bar's draw-in, the + * terminal cursor's blink, and the phosphor flicker. + */ + update(time) { + if (this.state === 'closed') return; + + // The name decodes in (the shared scramble effect). + if (this.nameDec) { + this.nameText.setText(this.nameDec.started(time) ? this.nameDec.display(time) : ''); + this.cursorText.setPosition( + -this.W / 2 + this.framePad + this.innerPad + this.nameText.width + 5, + this.nameText.y, + ); + if (this.nameDec.finished(time)) { + this.nameText.setText(this._name); + this.nameDec = null; + this.cursorText.setPosition( + -this.W / 2 + this.framePad + this.innerPad + this.nameText.width + 5, + this.nameText.y, + ); + } + } + // The terminal cursor blinks. + this.cursorText.setAlpha(Math.floor(time / 530) % 2 === 0 ? 1 : 0.12); + + // The reputation bar draws itself in (ease-out, ~380 ms). + if (this.repRevealT0 !== null && this.state === 'open') { + const dur = 380; + const u = (time - this.repRevealT0) / dur; + if (u >= 1) { + this.drawMarks(1); + this.repRevealT0 = null; + } else if (u > 0) { + this.drawMarks(1 - Math.pow(1 - u, 3)); + } + } + + // Phosphor flicker — a slow breathing + a hint of scan instability. + const a = 0.026 + 0.016 * Math.sin(time / 730) + 0.008 * Math.sin(time / 121); + this.flickG.setAlpha(Math.max(0.004, a)); + } + + /** Is (wx, wy) — WORLD coords — over the panel? */ + contains(wx, wy) { + const r = this.rect; + return !!r && wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h; + } + + /** + * Does a SCREEN point (canvas coords, top-left origin) sit inside the + * panel's footprint — buttons included? GameScene uses this to swallow + * the very click that just closed the panel through a button: that + * click landed ON the panel, so it was panel business, not a world + * click. (Same contract as MiningPopup.containsScreen.) + */ + containsScreen(sx, sy) { + const cam = this.scene.cameras.main; + return this.contains(cam.scrollX + sx, cam.scrollY + sy); + } + + /** Fire an action id to the scene (its handler closes the panel). */ + fire(id) { + if (this.state !== 'open') return; + if (typeof this.onAction === 'function') { + this.closedByButtonAt = this.scene.time.now; // this click is the panel's + try { + this.onAction(id, this.lastTarget); + } catch (err) { + console.error('[comms-panel] onAction failed', err); + } + } + } + + destroy() { + this.frameG?.destroy(); + this.marksG?.destroy(); + this.flickG?.destroy(); + this.nameText?.destroy(); + this.cursorText?.destroy(); + this.kindText?.destroy(); + this.repLabel?.destroy(); + this.repValue?.destroy(); + this.btn1?.destroy(); + this.btn2?.destroy(); + super.destroy(); + } +}