227 lines
8.3 KiB
JavaScript
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();
|
|
}
|
|
}
|