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.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice) 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; if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice) 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; this.scene.playSfx?.('ui_window'); // the window whoosh (the scene is the voice) // UPPERCASE IS THE PANEL'S DISPLAY CONVENTION — the data payload must // keep the world's canonical name: it rides the landing handoff // (onAction → GameScene.startLanding → SurfaceScene) as the key for // name-keyed world state (build records, tether labels). Corrupting // the casing there makes the surface read its own home world as // "nothing installed, no tether". const display = String(o.name ?? 'UNKNOWN').toUpperCase(); this.settled = !!o.settled; this.rep = Math.round(o.reputation ?? 0); this.canLand = o.canLand !== false; this._name = display; // 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. `name` // stays the canonical spelling from `...o` (display casing above is // for the decode text only). this.lastTarget = { ...o, 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(display, this.scene.time.now + 160, decodeDur(display.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(); } }