orbit/js/ui/CommsPanel.js

714 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// Carry the FULL payload the scene handed us (it may include landing
// data like `isPlanet`/`frame`) — the scene's onAction gets this back
// verbatim, so nothing the scene needs may be pruned here.
this.lastTarget = {
...o,
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();
}
}