Replace tether HUD with live mineral hold readout bar

- Add MineralHud component (js/ui/MineralHud.js) showing "MINERALS n / N"
  plus a fill bar in the upper-right corner, replacing the old tether
  stack. One tween drives both the counting number and the bar so they
  never drift; a cyan flash ripples on landings; amber at capacity.
- Wire Mining to emit an onOre(gained, hold) seam per extraction tick and
  per fragment landing so the HUD fills live as the rock shrinks, not
  only at the batch 'absorbed' event.
- Remove refreshTetherHud / fmtRange from GameScene; instantiate MineralHud
  in create(), feed it via refreshMineralHud() from ore seams and mining
  events, destroy on shutdown.
- Add data/mineralhud.json config (corner pad, bar geometry, count-up and
  flash tuning) and register it in the manifest.
- Tests: new dev/mineral-hud.test.mjs covering layout, count-up/bar sync,
  superseded-tween guard, capacity/clamping, teardown; extend
  dev/mining.test.mjs to assert onOre ticks; drop tether-HUD checks from
  dev/system-hud.test.mjs; add setOrigin/setScrollFactor no-ops to the
  Phaser stub.
This commit is contained in:
Brian Fertig 2026-09-05 12:12:18 -06:00
parent 520cba85f6
commit ee2e874f41
9 changed files with 554 additions and 107 deletions

View File

@ -21,6 +21,7 @@
"music.json",
"scan.json",
"signalCompass.json",
"save.json"
"save.json",
"mineralhud.json"
]
}

27
data/mineralhud.json Normal file
View File

@ -0,0 +1,27 @@
{
"_comment": "MINERAL HUD — the ship's hold readout in the UPPER-RIGHT corner (the old tether stack's spot): the 'MINERALS n / N' line over a fill bar. The bar IS the hold — fill = minerals aboard (ship.minerals) / capacity (ship.stats.mineralStorage), filling left→right; set() animates the number counting up and the bar sliding with it, and a brief cyan flash ripples over the bar when minerals land; at capacity the fill turns theme 'amber'. pad/lineGap anchor the block to the corner (right margin, top margin); bar = the track (width/height px, border = the 1px frame, inset = how far the fill sits inside it); label = the number's font; countUp = the shared number+bar animation; flash = the landing ripple (alpha at t=0). Palette: neon = live (theme 'neon'), amber = full (theme 'amber'), track* = the command deck's slot colors (data/actionbar.json).",
"pad": 16,
"lineGap": 8,
"label": {
"fontSize": 12,
"letterSpacing": 1
},
"bar": {
"width": 220,
"height": 8,
"border": 1,
"inset": 1
},
"colors": {
"track": "#0b1322",
"trackBorder": "#22405f"
},
"countUp": {
"durationMs": 420,
"ease": "Sine.easeOut"
},
"flash": {
"durationMs": 340,
"alpha": 0.5
}
}

244
dev/mineral-hud.test.mjs Normal file
View File

@ -0,0 +1,244 @@
/**
* Mineral HUD test (dev tool, run with Node no browser needed):
*
* node --import ./dev/phaser-loader.mjs dev/mineral-hud.test.mjs
*
* Runs the REAL MineralHud component (js/ui/MineralHud.js the
* upper-right hold readout that replaced the tether stack) against a
* stubbed scene, and asserts:
* - the corner layout (label right-anchored 16 px from the edge, the
* bar right-aligned under it the old tether stack's spot);
* - set() animates the number counting up AND the bar filling with it
* (ONE tween drives both number and bar can't drift apart);
* - a landing (value UP) ripples the cyan flash; a drop does not;
* - quick-succession landings: a superseded tween landing LATE can't
* snap the readout back to a stale value (the fragment staggers are
* ~70 ms vs a 420 ms count-up they overlap in real play);
* - the fill turns amber (theme 'amber') at capacity;
* - clamping (overfull records, negatives), no-op on unchanged value,
* and clean teardown.
* The stub tweens run instantly by default (final values); the
* overlap scenario switches the harness to a manual clock.
*/
import { readdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const { config } = await import('../js/config/Config.js');
const data = {};
for (const f of readdirSync(new URL('../data', import.meta.url))) {
if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
}
config.init(data);
const { MineralHud } = await import('../js/ui/MineralHud.js');
// ---------------------------------------------------------------------------
// Stub scene — text with deterministic metrics, graphics that RECORD their
// rects/colors, tweens that log themselves (and run instantly, or on
// demand in defer mode).
// ---------------------------------------------------------------------------
class FakeText {
constructor(x, y, str) {
this.x = x;
this.y = y;
this.text = str;
this.width = str.length * 9; // deterministic fake metrics
this.height = 19;
this.origin = [0.5, 0.5];
this.scrollFactor = 1; // the component must pin UI to 0 explicitly
}
setText(s) { this.text = s; return this; }
setOrigin(o, v) { this.origin = [o, v]; return this; }
setScrollFactor(f) { this.scrollFactor = f; return this; }
setDepth() { return this; }
setPosition(x, y) { this.x = x; this.y = y; return this; }
destroy() { this.destroyed = true; return this; }
}
class FakeGraphics {
constructor() {
this.fills = []; // { color, x, y, w, h }
this.strokes = []; // { width, color, x, y, w, h }
this.alpha = 1;
this.scrollFactor = 1;
}
fillStyle(color, alpha) { this._fillColor = color; return this; }
fillRect(x, y, w, h) { this.fills.push({ color: this._fillColor, x, y, w, h }); return this; }
lineStyle(width, color) { this._stroke = { width, color }; return this; }
strokeRect(x, y, w, h) { this.strokes.push({ ...this._stroke, x, y, w, h }); return this; }
clear() { this.fills = []; return this; }
setAlpha(a) { this.alpha = a; return this; }
setScrollFactor(f) { this.scrollFactor = f; return this; }
setDepth() { return this; }
destroy() { this.destroyed = true; return this; }
}
function makeScene() {
const tweens = [];
const scene = {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => new FakeText(x, y, str),
graphics: () => new FakeGraphics(),
},
tweens: {
defer: false, // true = manual clock (the overlap scenario)
add(opts) {
const tw = {
opts,
run() {
const targets = Array.isArray(opts.targets) ? opts.targets : [opts.targets];
for (const tg of targets) {
for (const [k, v] of Object.entries(opts)) {
if (['targets', 'duration', 'ease', 'delay', 'onUpdate', 'onComplete'].includes(k)) continue;
tg[k] = v;
}
}
if (typeof opts.onUpdate === 'function') opts.onUpdate();
if (typeof opts.onComplete === 'function') opts.onComplete();
},
};
tweens.push(tw);
if (!scene.tweens.defer) tw.run();
return tw;
},
killTweensOf: () => {},
},
_tweens: tweens,
};
return scene;
}
// ---------------------------------------------------------------------------
// Layout: the old tether stack's corner (upper-right, 16 px pad), and
// screen-fixed (the camera scrolls the world — the HUD must not ride along)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
assert.equal(hud.label.x, 1280 - 16, 'label right-anchored 16 px from the edge');
assert.equal(hud.label.y, 16, 'label top-anchored 16 px from the top');
assert.deepEqual(hud.label.origin, [1, 0], 'label right/top origin');
const { barW } = hud;
assert.equal(hud.barX, 1280 - 16 - barW, 'bar right-aligned to the corner margin');
assert.equal(hud.barY, 16 + hud.label.height + 8, 'bar sits below the label (lineGap 8)');
assert.ok(hud.trackG.fills.length >= 1, 'the track is drawn');
assert.ok(hud.trackG.strokes.length >= 1, 'the track has its border stroke');
assert.equal(hud.trackG.fills[0].w, 220, 'track width = 220 (config)');
assert.equal(hud.trackG.fills[0].h, 8, 'track height = 8 (config)');
hud.set(0, 250); // the scene's first call: seed the capacity
assert.equal(hud.label.text, 'MINERALS 0 / 250', 'starts empty: 0 / capacity');
assert.equal(hud.fillG.fills.length, 0, 'no fill at zero');
assert.equal(hud.isFull, false);
// Screen-fixed: every scene-level layer pins to the screen (scrollFactor
// 0) — without this the HUD renders in WORLD space, thousands of px off.
assert.equal(hud.label.scrollFactor, 0, 'label is screen-fixed');
assert.equal(hud.trackG.scrollFactor, 0, 'track is screen-fixed');
assert.equal(hud.fillG.scrollFactor, 0, 'fill is screen-fixed');
assert.equal(hud.flashG.scrollFactor, 0, 'flash is screen-fixed');
}
// ---------------------------------------------------------------------------
// set(): number counts up, bar fills with it — ONE tween drives both
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
const before = scene._tweens.length;
assert.equal(hud.set(37, 250), true, 'a change animates');
assert.equal(scene._tweens.length, before + 2, 'two tweens: the count-up and the flash');
const countUp = scene._tweens[before].opts;
assert.equal(countUp.v, 37, 'the number tween lands on 37');
assert.ok(Math.abs(countUp.f - 37 / 250) < 1e-9, 'the bar tween lands on 37/250 of the fill');
assert.equal(countUp.duration, 420, 'count-up duration = 420 (config)');
assert.equal(hud.label.text, 'MINERALS 37 / 250', 'the number readout follows');
const fill = hud.fillG.fills.at(-1);
assert.ok(Math.abs(fill.w - (hud.innerW * 37) / 250) < 1e-9, 'the bar fills left→right to the exact fraction');
assert.equal(fill.x, hud.barX + 1, 'the fill starts at the left inset (1 px inside the border)');
assert.equal(fill.color, 0x00e5ff, 'the live fill is theme neon');
const flash = scene._tweens[before + 1].opts;
assert.equal(flash.targets, hud.flashG, 'the flash tween rides the flash layer');
assert.equal(flash.alpha, 0, '… and fades the ripple to 0');
assert.equal(hud.isFull, false);
// A repeat of the same value is a NO-OP — no re-flash, no re-tween.
const n = scene._tweens.length;
assert.equal(hud.set(37, 250), false, 'unchanged → no animation');
assert.equal(scene._tweens.length, n, '… and nothing was queued');
}
// ---------------------------------------------------------------------------
// Quick-succession landings: a superseded tween must not write stale values
// (shatter fragments land ~70 ms apart vs the 420 ms count-up — the tweens
// OVERLAP in real play; the manual clock simulates the old one landing late)
// ---------------------------------------------------------------------------
{
const scene = makeScene();
scene.tweens.defer = true;
const hud = new MineralHud(scene);
assert.equal(hud.set(1, 250), true);
assert.equal(hud.set(2, 250), true);
// The log reads [countUp₁, flash₁, countUp₂, flash₂] — one pair per set().
const t1 = scene._tweens[0]; // the superseded count-up
const t2 = scene._tweens[2]; // the live count-up
// The engine finishes the NEW tween…
t2.run();
assert.equal(hud.label.text, 'MINERALS 2 / 250');
assert.ok(Math.abs(hud.fillG.fills.at(-1).w - (hud.innerW * 2) / 250) < 1e-9, '… at the settled value');
// …and the OLD one lands late. It must be a no-op (gen guard).
t1.run();
assert.equal(hud.label.text, 'MINERALS 2 / 250', 'a superseded tween can\'t snap the number back');
assert.ok(Math.abs(hud.fillG.fills.at(-1).w - (hud.innerW * 2) / 250) < 1e-9, '… nor the bar');
}
// ---------------------------------------------------------------------------
// Capacity: amber fill (theme 'amber'); drops don't flash; clamping
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
hud.set(250, 250);
assert.equal(hud.isFull, true, 'at capacity the hold is FULL');
assert.equal(hud.fillG.fills.at(-1).color, 0xffc94d, '… and the fill turns theme amber');
assert.equal(hud.label.text, 'MINERALS 250 / 250');
assert.equal(hud.set(99999, 250), false, 'already full → an overfull record is a no-op');
assert.equal(hud.value, 250, '… the value stays clamped at the capacity');
assert.equal(hud.label.text, 'MINERALS 250 / 250', '… and the readout says so');
// A DROP (not a landing) animates but does NOT ripple the flash.
const n = scene._tweens.length;
hud.set(10, 250);
assert.equal(hud.value, 10);
assert.equal(scene._tweens.length, n + 1, 'only the count-up — no flash on a drop');
assert.equal(hud.fillG.fills.at(-1).color, 0x00e5ff, '… and the fill is back to neon (not full)');
assert.equal(hud.set(-5, 250), true);
assert.equal(hud.value, 0, 'a negative record clamps at zero');
assert.equal(hud.fillG.fills.length, 0, '… and the bar is empty (cleared, no fill rect)');
assert.equal(hud.isFull, false);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
{
const scene = makeScene();
const hud = new MineralHud(scene);
hud.set(12, 250);
const { label, trackG, fillG, flashG } = hud;
hud.destroy();
assert.equal(label.destroyed, true);
assert.equal(trackG.destroyed, true);
assert.equal(fillG.destroyed, true);
assert.equal(flashG.destroyed, true);
}
console.log('layout: upper-right corner, screen-fixed (old tether spot): OK');
console.log('set(): count-up + bar fill (one tween, exact fraction): OK');
console.log('overlap: superseded tween is void (no stale snap-back): OK');
console.log('capacity: amber fill + clamping both ends: OK');
console.log('teardown: all layers destroyed: OK');
console.log('\nmineral-hud: all checks passed');

View File

@ -93,12 +93,14 @@ function rig(size) {
const member = cluster.members[0];
const events = [];
const phases = [];
const ores = []; // (gained, hold) — the LIVE hold feed (the HUD seam)
const mining = new Mining(scene, {
onPhase: (p) => {
phases.push(p);
if (p === 'stopped') ship.setState('normal', 'mining-ended'); // the scene's share
},
onEvent: (n, d) => events.push([n, d]),
onOre: (gained, hold) => ores.push([gained, hold]),
});
ship.onStateChange = (next, prev) => {
if (prev === 'mining' && next !== 'mining') mining.stop();
@ -118,7 +120,7 @@ function rig(size) {
mining.state = 'mining'; // fast-forward the arm reach
mining.beam = fakeBeam();
};
return { scene, ship, cluster, member, events, phases, mining, pump, start };
return { scene, ship, cluster, member, events, phases, ores, mining, pump, start };
}
// ---------------------------------------------------------------------------
@ -135,6 +137,9 @@ function rig(size) {
assert.equal(r.member.radius * 2, 42, 'latched rock halved to 42');
assert.equal(r.cluster.members.length, 2, 'sibling rock added');
assert.equal(r.ship.minerals, 43, 'minerals mined before the halving');
assert.equal(r.ores.length, 43, 'LIVE: one ore seam per extracted px (the HUD ticks every second)');
assert.deepEqual(r.ores[0], [1, 1], '… first landing: +1, hold at 1');
assert.deepEqual(r.ores[42], [1, 43], '… last: +1, hold at 43');
assert.deepEqual(r.events.at(-1), ['split', { kind: 'divide', pieces: 2, pieceSize: 42, sibling: r.cluster.members[1] }]);
assert.equal(r.mining.rock.original, 42, 'fresh ledger: original reset');
assert.equal(r.mining.beam.destroyed ?? false, false, 'beam survives the halving (still latched)');
@ -152,6 +157,8 @@ function rig(size) {
r.pump(3); // the four 7 px pieces (~700 px out) ride the 460 px/s beam home
assert.equal(r.ship.minerals, 43 + 14 + 28, 'shattered pieces landed as minerals (capped hold)');
assert.equal(r.ores.length, 43 + 14 + 4, 'LIVE: the landings tick the HUD piece by piece');
assert.deepEqual(r.ores.at(-1), [7, 85], '… last fragment: +7, hold at 85');
assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 28, pieces: 4 }]);
assert.equal(r.events.filter(([n]) => n === 'split').length, 2, 'one crack per break (halving + shatter)');
console.log('A: 128 px rock — halve → shatter → suck in: OK');
@ -170,6 +177,8 @@ function rig(size) {
assert.equal(r.mining.state, 'idle');
r.pump(3);
assert.equal(r.ship.minerals, 22 + 40, '4 × 10 px pieces landed');
assert.equal(r.ores.length, 22 + 4, 'LIVE: 22 ticks + 4 landings');
assert.deepEqual(r.ores.at(-1), [10, 62], '… last fragment: +10, hold at 62');
assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 40, pieces: 4 }]);
assert.equal(r.events.filter(([n]) => n === 'split').length, 1, 'one crack for the shatter');
console.log('B: 64 px rock — direct shatter: OK');
@ -187,6 +196,7 @@ function rig(size) {
assert.equal(r.ship.minerals, r.ship.stats.mineralStorage, 'hold capped');
assert.equal(r.member.radius * 2, 64, 'rock untouched — nothing could be loaded');
assert.equal(r.mining.state, 'retracting', 'the run ended (beam retracts)');
assert.equal(r.ores.length, 0, 'LIVE: nothing loaded → no ore seam fired');
assert.equal(r.events.at(-1)[0], 'storageFull', 'the scene is told the hold is full');
console.log('C: full hold — run ends, rock preserved: OK');
}

View File

@ -13,6 +13,8 @@ class GameObject {
}
add(obj) { this.children.push(obj); return this; }
remove(obj) { this.children = this.children.filter((c) => c !== obj); return this; }
setOrigin() { return this; }
setScrollFactor() { return this; }
setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; }
setScale(v) { this.scale = v; return this; }

View File

@ -372,51 +372,10 @@ check('a manual toggle cancels the auto-fold — still OPEN past 10 s',
check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false);
// ===========================================================================
// 5) Tether readout: UPPER-RIGHT of the screen, right-aligned, stacks DOWN
// 5) The upper-right readout moved: the tether stack is GONE, the
// MINERAL HUD (js/ui/MineralHud.js — hold fill as a bar + counting
// number) owns that corner now. Its own test: dev/mineral-hud.test.mjs
// ===========================================================================
{
scene.scale = { width: 1280, height: 720 };
scene.tetherHudTexts = [];
scene.tetherField = {
tethers: [
{ level: 1, radius: 5120, label: 'Terra' },
{ level: 2, radius: 10240, label: 'Kethral' },
],
};
GameScene.prototype.refreshTetherHud.call(scene);
const lines = scene.tetherHudTexts;
const right = scene.scale.width - 16; // 16 px margin from the right edge
check('one line per tether', lines.length === 2);
check('the lines read level · range · anchor',
lines[0].text === 'TETHER LV 1 · RANGE 5.1K PX · TERRA' &&
lines[1].text === 'TETHER LV 2 · RANGE 10.2K PX · KETHRAL');
check('right-aligned to the screen edge (16 px margin)',
lines.every((t) => t.x === right));
check('top-anchored 16 px from the top, stacking DOWNWARD',
Math.abs(lines[0].y - 16) < 0.001 &&
Math.abs(lines[1].y - (16 + 19 + 4)) < 0.001);
// A third tether appears: the block stays pinned to the top-right corner —
// the new line takes the bottom slot, the old lines keep their place.
scene.tetherField.tethers.push({ level: 1, radius: 2560, label: null });
GameScene.prototype.refreshTetherHud.call(scene);
const after = scene.tetherHudTexts;
check('added tether: new line takes the bottom slot, old lines keep place',
after.length === 3 &&
Math.abs(after[0].y - 16) < 0.001 &&
Math.abs(after[1].y - 39) < 0.001 &&
Math.abs(after[2].y - 62) < 0.001 &&
after.every((t) => t.x === right) &&
after[2].text === 'TETHER LV 1 · RANGE 2.6K PX');
// No deck (disabled): the readout still lives in the upper-right corner —
// it no longer needs the deck as an anchor.
scene.actionBar = null;
GameScene.prototype.refreshTetherHud.call(scene);
check('no deck → still in the upper-right corner',
scene.tetherHudTexts[0].x === right && Math.abs(scene.tetherHudTexts[0].y - 16) < 0.001);
}
// ===========================================================================
// 6) SFX: construct / deconstruct / discovery (data/sfx.json)

View File

@ -58,11 +58,16 @@ export class Mining {
* 'split' { kind: 'absorb' | 'divide', pieces, pieceSize }
* 'absorbed' { gained, pieces } the shattered pieces hit the hull
* 'storageFull' the hold is full; the run ended
* @param {Function} [o.onOre] (gained, hold) => void: minerals just landed
* in the hold fires LIVE, per extraction tick (beam steady) and per
* fragment landing, so a UI can show the hold filling as the rock
* shrinks (not just at the shatter's 'absorbed' event).
*/
constructor(scene, o = {}) {
this.scene = scene;
this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null;
this.onEvent = typeof o.onEvent === 'function' ? o.onEvent : null;
this.onOre = typeof o.onOre === 'function' ? o.onOre : null;
this.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting'
this.cluster = null;
this.member = null;
@ -211,7 +216,7 @@ export class Mining {
const take = Math.min(n, room);
if (take > 0) {
this.rock.size -= take; // the rock shrinks (width = height, px)
ship.addMinerals(take);
this._ore(ship.addMinerals(take), ship); // load the hold (capped) + notify the UI live
}
if (room < n) {
// The hold is full: mining would only grind the rock away.
@ -365,11 +370,13 @@ export class Mining {
for (const p of landed) {
const st = this.batchState[p.batch];
if (!st) {
ship.addMinerals(p.size); // no batch (defensive) — still load them
this._ore(ship.addMinerals(p.size), ship); // no batch (defensive) — still load them
continue;
}
st.landed += ship.addMinerals(p.size);
const added = ship.addMinerals(p.size);
st.landed += added;
st.pieces += 1;
this._ore(added, ship); // the HUD ticks up per landing, not just at the batch event
if (st.pieces >= st.total) {
finished.push({ gained: st.landed, pieces: st.pieces });
delete this.batchState[p.batch];
@ -407,6 +414,14 @@ export class Mining {
}
}
_ore(gained, ship) {
try {
this.onOre?.(gained, ship?.minerals ?? 0);
} catch (err) {
console.error('[mining] onOre handler failed', err);
}
}
_event(name, data) {
try {
this.onEvent?.(name, data);
@ -428,5 +443,6 @@ export class Mining {
this.oreAcc = 0;
this.onPhase = null;
this.onEvent = null;
this.onOre = null;
}
}

View File

@ -17,6 +17,7 @@ import { Station } from '../entities/Station.js';
import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MineralHud } from '../ui/MineralHud.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
@ -43,15 +44,6 @@ const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in
/** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */
const HUD_AUTO_COLLAPSE_MS = 10000;
/** 5120 → "5.1K", 640 → "640" — compact range readout for the HUD. */
function fmtRange(v) {
if (v >= 1000) {
const k = (v / 1000).toFixed(1).replace(/\.0$/, '');
return `${k}K`;
}
return String(Math.round(v));
}
/**
* The game world (v0.3: the current system the player's home world at the
* origin plus the system's other worlds scattered around it, in open space).
@ -284,10 +276,8 @@ export class GameScene extends Phaser.Scene {
// tether list (add/remove/setLevel are the seam the build system will
// use later); where multiple tethers' zones overlap there is no line
// and no wall — the union is the player's space.
this.tetherHudTexts = [];
this.tetherField = new TetherField(this, {
depth: 6, // above planets (5), below the ship (10)
onChange: () => this.refreshTetherHud(),
});
this.tetherField.add(
config.get('tether.homeId', 'home'),
@ -296,7 +286,6 @@ export class GameScene extends Phaser.Scene {
config.get('tether.homeLabel', '') || this.homeWorldName,
);
this.tetherToastAt = null;
this.refreshTetherHud();
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time.
@ -371,9 +360,10 @@ export class GameScene extends Phaser.Scene {
onLoadComplete: () => this.returnToMenu(),
});
// The tether readout anchors above the deck's MENU button — re-render
// it now that the deck exists (the boot pass had nothing to anchor to).
this.refreshTetherHud();
// The MINERALS readout — upper right (the corner the old tether stack
// used to occupy): the hold's fill as a bar + counting number.
this.mineralHud = new MineralHud(this);
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
// Hint (pinned just ABOVE the command deck, not under it)
this.hint = this.add
@ -396,6 +386,7 @@ export class GameScene extends Phaser.Scene {
this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p),
onEvent: (name, data) => this.onMiningEvent(name, data),
onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks
});
this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock),
@ -877,48 +868,6 @@ export class GameScene extends Phaser.Scene {
return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
/**
* Tether readout: one line per tether level, range, anchor. Rebuilt
* whenever the field changes (TetherField.onChange), so a future
* "tether upgrade" build shows up here for free.
*
* Sits in the UPPER-RIGHT of the screen, right-aligned, stacking
* downward clear of the dossier (upper-left) and the toasts
* (top-centre). Multiple tethers grow downward from the top margin.
*/
refreshTetherHud() {
if (!this.tetherField) return;
for (const g of this.tetherHudTexts) g.destroy();
this.tetherHudTexts = [];
const fam = BODY_FONT();
const neon = toCss(themeColor('neon', 0x00e5ff));
const pad = 16; // margin from the screen edge
const made = this.tetherField.tethers.map((t) => {
const label = t.label ? ` · ${String(t.label).toUpperCase()}` : '';
return this.add
.text(0, 0, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, {
fontFamily: fam,
fontSize: '12px',
color: neon,
letterSpacing: 1,
})
.setOrigin(1, 0) // top-right anchor: right edge sticks to the margin
.setScrollFactor(0) // UI — pinned to the screen
.setDepth(30);
});
// Upper-right corner, lines stack downward.
const right = this.scale.width - pad;
let y = pad;
for (const t of made) {
t.setPosition(right, y);
y += t.height + 4;
}
this.tetherHudTexts = made;
}
update(_time, delta) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
// TweenManager — drive them here or delayedCall/addEvent/tweens never
@ -1449,11 +1398,24 @@ export class GameScene extends Phaser.Scene {
);
} else if (name === 'absorbed') {
this.consoleToast(`+${data.gained} MINERALS ABOARD`, { glyph: '\u2295', glyphColor: neon });
this.refreshMineralHud(); // belt & braces — the landings already fed it via onOre
} else if (name === 'storageFull') {
this.consoleToast('MINERAL STORAGE FULL', { glyph: '!', glyphColor: magenta });
this.refreshMineralHud();
}
}
/**
* Mirror the ship's hold into the upper-right readout (js/ui/MineralHud.js).
* A no-op there when the value is unchanged (no re-tween, no re-flash),
* so this is safe to call from every ore seam (per extraction tick, per
* fragment landing, per event).
*/
refreshMineralHud() {
if (!this.mineralHud || !this.ship) return;
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
}
/**
* The mining hum (data/sfx.json mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and
@ -1800,7 +1762,6 @@ export class GameScene extends Phaser.Scene {
if (!t || typeof t.id !== 'string') continue;
this.tetherField.add(t.id, Number(t.x) || 0, Number(t.y) || 0, t.level ?? 1, t.label ?? '');
}
this.refreshTetherHud();
}
this.playTimeMs = Number(r.playTimeMs) || 0;
// The camera was centered on the spawn — recentre on the restored ship.
@ -1818,6 +1779,7 @@ export class GameScene extends Phaser.Scene {
this.savePanel?.destroy();
this.miningPopup?.destroy();
this.commsPanel?.destroy();
this.mineralHud?.destroy();
this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();

226
js/ui/MineralHud.js Normal file
View File

@ -0,0 +1,226 @@
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();
}
}