orbit/js/ui/MineralHud.js

227 lines
8.3 KiB
JavaScript

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();
}
}