diff --git a/data/manifest.json b/data/manifest.json index 1b33e62..381e2d8 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -21,6 +21,7 @@ "music.json", "scan.json", "signalCompass.json", - "save.json" + "save.json", + "mineralhud.json" ] } diff --git a/data/mineralhud.json b/data/mineralhud.json new file mode 100644 index 0000000..8b443e4 --- /dev/null +++ b/data/mineralhud.json @@ -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 + } +} diff --git a/dev/mineral-hud.test.mjs b/dev/mineral-hud.test.mjs new file mode 100644 index 0000000..fdb561c --- /dev/null +++ b/dev/mineral-hud.test.mjs @@ -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'); diff --git a/dev/mining.test.mjs b/dev/mining.test.mjs index 60953e4..d22bc94 100644 --- a/dev/mining.test.mjs +++ b/dev/mining.test.mjs @@ -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'); } diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs index 43a2ed0..932ceee 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -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; } diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index a5c3609..d1d1220 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -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) diff --git a/js/mining/Mining.js b/js/mining/Mining.js index 47dfd3d..c937070 100644 --- a/js/mining/Mining.js +++ b/js/mining/Mining.js @@ -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; } } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 6ab3466..d89eccf 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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(); diff --git a/js/ui/MineralHud.js b/js/ui/MineralHud.js new file mode 100644 index 0000000..b5cbac3 --- /dev/null +++ b/js/ui/MineralHud.js @@ -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(); + } +}