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:
parent
0c30985588
commit
d933914ac3
Binary file not shown.
Binary file not shown.
|
|
@ -10,6 +10,7 @@
|
|||
"galaxy.json",
|
||||
"systems.json",
|
||||
"settlements.json",
|
||||
"stations.json",
|
||||
"reputation.json",
|
||||
"naming.json",
|
||||
"research.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.<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 `<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
|
||||
* 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue