710 lines
26 KiB
JavaScript
710 lines
26 KiB
JavaScript
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();
|
||
}
|
||
}
|