import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor, toCss } from '../utils/Color.js'; import { fontStack, themeColor } from '../utils/Theme.js'; /** * MINERAL HUD — the ship's hold readout, UPPER-RIGHT of the screen (the * corner the old tether stack used to occupy): * * MINERALS 37 / 250 * ▓▓▓▓▓▓▓▓░░░░░░░░ ← fill bar, left → right * * The bar IS the hold: its fill is minerals aboard / stats.mineralStorage. * `set(value, max)` animates BOTH the number counting up and the fill * sliding with it (one tween drives both, so they never disagree), and a * brief cyan flash ripples over the bar when minerals land. At capacity * the fill turns AMBER (theme 'amber') — the 'hold full' state; the * matching 'MINERAL STORAGE FULL' toast is the scene's (on the Mining * seam, GameScene.onMiningEvent). * * Config: data/mineralhud.json (corner pad, label/bar sizes, track * colors, count-up + flash tuning). * * Component usage (scenes own one instance, like ActionBar): * * this.mineralHud = new MineralHud(this); * this.mineralHud.set(ship.minerals, ship.stats.mineralStorage); * // …whenever the hold changes: * this.mineralHud.set(ship.minerals, ship.stats.mineralStorage); * shutdown() { this.mineralHud.destroy(); } */ export class MineralHud extends Phaser.GameObjects.Container { /** @param {Phaser.Scene} scene */ constructor(scene) { super(scene, 0, 0); // v4 quirk: a directly-constructed GameObject is NOT added to the // scene's display list — register it or it never renders. this.scene.add.existing(this); this.setScrollFactor(0); // UI — pinned to the screen, not the world this.setDepth(30); // with the dossier and the other corner readouts const hud = config.section('mineralhud', {}); this.pad = hud.pad ?? 16; this.lineGap = hud.lineGap ?? 8; const lab = hud.label ?? {}; const bar = hud.bar ?? {}; const cu = hud.countUp ?? {}; const fl = hud.flash ?? {}; const col = hud.colors ?? {}; this.barW = Math.max(8, bar.width ?? 220); this.barH = Math.max(4, bar.height ?? 8); this.barBorder = Math.max(0, bar.border ?? 1); this.inset = Math.max(this.barBorder, bar.inset ?? 1); // fill sits inside the border this.countUpMs = Math.max(0, cu.durationMs ?? 420); this.countUpEase = cu.ease ?? 'Sine.easeOut'; this.flashMs = Math.max(0, fl.durationMs ?? 340); this.flashAlpha = fl.alpha ?? 0.5; // Palette: neon (theme) for the live state, amber (theme) when the // hold is at capacity; the track borrows the command deck's slot // colors so the corner reads as one console. this.neonInt = themeColor('neon', 0x00e5ff); this.amberInt = themeColor('amber', 0xffc94d); this.trackInt = toColor(col.track, 0x0b1322); this.trackBorderInt = toColor(col.trackBorder, 0x22405f); // State: `value` is settled (where the animation lands); `max` is the // hold's capacity. A set() with the same value is a no-op (no re-flash). this.value = 0; this.max = 1; this._gen = 0; // supersede guard: a new set() voids any in-flight tween const W = scene.scale.width; const right = W - this.pad; const fam = fontStack('body'); // --- The number: MINERALS n / N (right-aligned, top-anchored) ------ this.label = scene.add .text(0, 0, '', { fontFamily: fam, fontSize: `${lab.fontSize ?? 12}px`, color: toCss(this.neonInt), letterSpacing: lab.letterSpacing ?? 1, }) .setOrigin(1, 0) // right edge sticks to the corner margin .setDepth(30) // with the dossier — the scene-level objects render at 0 otherwise .setScrollFactor(0); // screen-fixed — the camera scrolls the world, these don't this.label.setPosition(right, this.pad); // --- The bar (below the label, right-aligned): track + fill + flash - this.barX = right - this.barW; this.barY = this.pad + this.label.height + this.lineGap; this.innerW = this.barW - 2 * this.inset; this.innerH = this.barH - 2 * this.inset; this.trackG = scene.add.graphics().setDepth(30).setScrollFactor(0); this.trackG.fillStyle(this.trackInt, 1).fillRect(this.barX, this.barY, this.barW, this.barH); if (this.barBorder > 0) { this.trackG.lineStyle(this.barBorder, this.trackBorderInt, 1); this.trackG.strokeRect(this.barX, this.barY, this.barW, this.barH); } this.fillG = scene.add.graphics().setScrollFactor(0); // redrawn by drawFill() this.flashG = scene.add.graphics().setScrollFactor(0); // the cyan ripple (alpha-tweened) this.fillG.setDepth(31); this.flashG.setDepth(31); this.drawFill(0); this.label.setText(this.format(0)); } /** True while the hold is at capacity (the amber state). */ get isFull() { return this.max > 0 && this.value >= this.max; } /** The label text for a settled value. */ format(n) { return `MINERALS ${Math.max(0, Math.round(n))} / ${Math.max(1, Math.round(this.max))}`; } /** * Set the hold's fill: `value` minerals aboard, `max` the capacity. * Animates the number counting up and the bar filling with it (both * from the last SETTLED value — so it always reads from where the * player last saw it), flashes the bar when minerals are ADDED, and * holds the amber fill-color at capacity. A no-op when unchanged. * * @returns {boolean} true when the value actually changed */ set(value, max) { if (max !== undefined && max !== null) { const newMax = Math.max(1, Math.round(Number(max) || 1)); if (newMax !== this.max) { // Capacity changed: re-render even if the value didn't, so the // 'n / N' readout and the fraction stay true. this.max = newMax; this.label.setText(this.format(this.value)); this.drawFill(this._frac(this.value)); } } const v = Math.max(0, Math.min(this.max, Math.round(Number(value) || 0))); if (v === this.value) return false; const from = this.value; const up = v > from; this.value = v; // ONE tween drives both, so number and bar can't drift apart. The gen // token voids any SUPERSEDED tween (landings come in quick succession — // fragment staggers ~70 ms vs a 420 ms count-up), so a slow old // onComplete can't snap the readout back to a stale value. const gen = ++this._gen; const live = () => gen === this._gen; const holder = { v: from, f: this._frac(from) }; const toF = this._frac(v); this.scene.tweens.add({ targets: holder, v, f: toF, duration: this.countUpMs, ease: this.countUpEase, onUpdate: () => { if (!live()) return; this.label.setText(this.format(holder.v)); this.drawFill(holder.f); }, onComplete: () => { if (!live()) return; this.label.setText(this.format(v)); // land exactly, whatever the clock did this.drawFill(toF); }, }); if (up) this._flash(); return true; } /** Fraction of the hold for a settled value (0..1 of `max`). */ _frac(v) { return Math.max(0, Math.min(1, Number(v) / this.max)); } /** The bar fill at fraction `f` (0..1 of the inner width), colored. */ drawFill(f) { const frac = Math.max(0, Math.min(1, Number(f) || 0)); const w = this.innerW * frac; this.fillG.clear(); if (w > 0.5) { this.fillG.fillStyle(this.isFull ? this.amberInt : this.neonInt, 1).fillRect( this.barX + this.inset, this.barY + this.inset, w, this.innerH, ); } return w; } /** The brief cyan ripple over the bar (minerals just landed). */ _flash() { if (this.flashMs <= 0) return; this.flashG.clear(); this.flashG.fillStyle(this.neonInt, 1).fillRect( this.barX + this.inset, this.barY + this.inset, this.innerW, this.innerH, ); this.flashG.setAlpha(this.flashAlpha); this.scene.tweens.add({ targets: this.flashG, alpha: 0, duration: this.flashMs, ease: 'Sine.easeOut', }); } /** Tear the readout down (scene shutdown). */ destroy() { this.label?.destroy(); this.trackG?.destroy(); this.fillG?.destroy(); this.flashG?.destroy(); this.label = this.trackG = this.fillG = this.flashG = null; super.destroy(); } }