Add comms panel and free-space station entities

- New CommsPanel UI: world-anchored rusty-metal/green-phosphor console that opens on planet or station click, showing name decode, reputation bar (41 marks, -20 to +20), and REQUEST LANDING / LAND / CANCEL buttons
- New Station entity with procedural rendering (deep-space station ring + solar wings, or waypoint beacon), solid collision, and comms-target support
- data/stations.json configures station kinds, sizes, ship clearance; added to manifest
- GameScene wires click-to-fly + panel open together, handles button/outside/ESC close race, ESC priority, and commsAction seam for future landing logic
- Reputation.marksFor() computes lit-mark count for the gauge; covered by new unit tests in dev/reputation.test.mjs
- Dev tooling: comms-click (real-input interaction test), comms-shot (screenshot states), wdshot setup-step logging
This commit is contained in:
Brian Fertig 2026-09-04 19:45:12 -06:00
parent 0c30985588
commit d933914ac3
15 changed files with 1670 additions and 9 deletions

Binary file not shown.

Binary file not shown.

View File

@ -10,6 +10,7 @@
"galaxy.json", "galaxy.json",
"systems.json", "systems.json",
"settlements.json", "settlements.json",
"stations.json",
"reputation.json", "reputation.json",
"naming.json", "naming.json",
"research.json", "research.json",

14
data/stations.json Normal file
View File

@ -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.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s).",
"enabled": true,
"shipClearance": 50,
"kinds": {
"deepSpaceStation": {
"size": 108,
"ringSpeed": 0.08
},
"waypoint": {
"size": 46
}
}
}

41
dev/comms-click.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<!-- Headless probe page for the CommsPanel interaction test (dev only —
not part of the game build). See comms-click.mjs. -->
<html>
<head>
<meta charset="utf-8" />
<base href="../" />
<title>orbit — comms click test</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
<style>
html,
body {
margin: 0;
height: 100%;
background: #04060d;
overflow: hidden;
}
#game {
display: grid;
place-items: center;
}
</style>
</head>
<body>
<div id="game"></div>
<script>
window.__BOOT_ERRORS__ = [];
window.addEventListener('error', (e) =>
window.__BOOT_ERRORS__.push(String(e.message) + ' @ ' + (e.filename || '') + ':' + e.lineno));
window.addEventListener('unhandledrejection', (e) =>
window.__BOOT_ERRORS__.push('reject: ' + ((e.reason && e.reason.stack) || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
<script type="module" src="dev/comms-click.mjs"></script>
</body>
</html>

254
dev/comms-click.mjs Normal file
View File

@ -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;
};

33
dev/comms-shot.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Comms Panel Dev</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/theme.css" />
<style>
html,
body {
margin: 0;
height: 100%;
background: #04060d;
overflow: hidden;
}
#game {
display: grid;
place-items: center;
}
</style>
</head>
<body>
<div id="game"></div>
<script src="lib/phaser.min.js"></script>
<script type="module" src="dev/comms-shot.mjs"></script>
</body>
</html>

156
dev/comms-shot.mjs Normal file
View File

@ -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 redgreen 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;
};

View File

@ -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) { if (failures > 0) {
console.error(`\n${failures} reputation test(s) FAILED`); console.error(`\n${failures} reputation test(s) FAILED`);
process.exit(1); process.exit(1);

View File

@ -122,7 +122,10 @@ try {
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const script = `return (async () => { ${steps[i]} })();`; const script = `return (async () => { ${steps[i]} })();`;
try { 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) { } catch (err) {
console.error(`setup[${i}]: ` + String(err?.message ?? err).slice(0, 200)); console.error(`setup[${i}]: ` + String(err?.message ?? err).slice(0, 200));
} }

View File

@ -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 `reputation` (scale snapshot + stored standings); the scene keeps one
instance in the shared registry (New Game resets it via instance in the shared registry (New Game resets it via
`resetRunState`). A save from before reputation exists loads as fresh `resetRunState`). A save from before reputation exists loads as fresh
all-neutral — old saves keep working. No UI yet: nothing changes or all-neutral — old saves keep working. **The comms panel shows it** (a
shows standing until the influence mechanics arrive. 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, - **Tests**`dev/reputation.test.mjs` (scale, home pinning, clamping,
faction-check order incl. a monkey-patched "factions exist" pass, faction-check order incl. a monkey-patched "factions exist" pass,
save round-trips + legacy/corrupt records, capture/prepare/reset 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 from the generator; save-ready + Node-tested
(js/reputation/Reputation.js, data/reputation.json) (js/reputation/Reputation.js, data/reputation.json)
- [ ] Landing & exploration: settlements become points of interest you - [ ] 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, - [x] The player's loop, laid down as data + seams: research (time-based,
one at a time, gates builds/research) and building (credits + minerals, one at a time, gates builds/research) and building (credits + minerals,
ship/planet/station) data layers (`data/research.json`, ship/planet/station) data layers (`data/research.json`,

200
js/entities/Station.js Normal file
View File

@ -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();
}
}

View File

@ -187,6 +187,23 @@ export class Reputation {
return null; // TODO(factions): see above — the placeholder on purpose. 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(maxmin+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. */ /** Round + clamp to the scale (integer steps); null when not a number. */
_toStanding(value) { _toStanding(value) {
const n = Math.round(Number(value)); const n = Math.round(Number(value));

View File

@ -6,11 +6,12 @@ import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js'; import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js'; import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.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 { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js'; import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { Starfield } from '../visuals/Starfield.js'; import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js'; import { ActionBar } from '../ui/ActionBar.js';
@ -21,6 +22,7 @@ import { consumeRestore } from '../save/SaveData.js';
import { TetherField } from '../tether/TetherField.js'; import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js'; import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js'; import { MiningPopup } from '../ui/MiningPopup.js';
import { CommsPanel } from '../ui/CommsPanel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); 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 // Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters. Ship constraint, click-to-fly clamping // disjoint), then the clusters, then the stations. Ship constraint,
// and autopilot all run against this list. // click-to-fly clamping and autopilot all run against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters]; this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations];
this.planet.discoveryId = 'home'; this.planet.discoveryId = 'home';
this.planet.discoveryName = this.homeWorldName; this.planet.discoveryName = this.homeWorldName;
@ -345,6 +361,20 @@ export class GameScene extends Phaser.Scene {
onAction: (id, rock) => this.miningAction(id, rock), 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 STATE (js/entities/Ship.js): 'normal' is the default — the
// ship is free; 'mining' — the arm's sequence owns the ship. 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 // mining visuals live and die with that state: ANY change out of it
@ -417,6 +447,35 @@ export class GameScene extends Phaser.Scene {
return; 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 // A rock under the cursor: clicking an asteroid opens the mining
// menu (or the stop menu, when this cluster is the beam's target) // menu (or the stop menu, when this cluster is the beam's target)
// — this click does not fly the ship. // — this click does not fly the ship.
@ -426,6 +485,11 @@ export class GameScene extends Phaser.Scene {
return; 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 // Any other click MOVES the ship — which ends the mining state
// (the beam retracts as the ship goes; a mid-reach arm aborts). // (the beam retracts as the ship goes; a mid-reach arm aborts).
// The state exit is signalled via ship.onStateChange above. // 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.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y);
this.hideHint(); 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 // 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). // state (folds, decodes, toasts, the confirm dialog).
this.menuSubBar?.update(_time, delta); this.menuSubBar?.update(_time, delta);
this.savePanel?.update(_time); 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 clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate, // the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.) // after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time); 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.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
this.updateCamera(delta); this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion 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, 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; return out;
} }
@ -1076,6 +1155,123 @@ export class GameScene extends Phaser.Scene {
// 'cancel' — just close. // '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 `<system>-s<n>` 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 * Mining phase changes (Mining onPhase): the scene's share of the
* sequence the ship's STATE, the ship lock, the console calls, the sfx. * sequence the ship's STATE, the ship lock, the console calls, the sfx.
@ -1159,12 +1355,15 @@ export class GameScene extends Phaser.Scene {
hideHint() { hideHint() {
if (!this.hint || !this.hint.active) return; if (!this.hint || !this.hint.active) return;
if (this._hintClosing) return; // a fade-out is already running
this._hintClosing = true;
this.tweens.add({ this.tweens.add({
targets: this.hint, targets: this.hint,
alpha: 0, alpha: 0,
duration: 400, duration: 400,
onComplete: () => { onComplete: () => {
this.hint.destroy(); this._hintClosing = false;
if (this.hint) this.hint.destroy();
this.hint = null; this.hint = null;
}, },
}); });
@ -1235,6 +1434,11 @@ export class GameScene extends Phaser.Scene {
this.miningPopup.close(); this.miningPopup.close();
return; 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; // Beam live (or arm extending) → break the mining (the beam retracts;
// the ship is free again — no fly). // the ship is free again — no fly).
if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) { 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.menuSubBar?.destroy();
this.savePanel?.destroy(); this.savePanel?.destroy();
this.miningPopup?.destroy(); this.miningPopup?.destroy();
this.commsPanel?.destroy();
this.mining?.destroy(); this.mining?.destroy();
this.tetherField?.destroy(); this.tetherField?.destroy();
} }

709
js/ui/CommsPanel.js Normal file
View File

@ -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. Topbottom (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` (01) 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();
}
}