/** * 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');