From bc8d5359c7e1f16b8e876d082f5ac58d80b73caa Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 8 Sep 2026 13:18:48 -0600 Subject: [PATCH] Add asteroid field depletion and restyle station compass + research edge - Mining now fires `onDepleted` when a cluster's last rock is consumed; GameScene splices the depleted cluster out of world entities, solids, content record, and map chart, then records its id for persistence - SaveData captures/restores `depletedClusters` so mined-out fields stay gone across save/load (legacy saves default to empty set) - Change station compass color from red (#ff4d5e) to bright purple (#c84dff) in data and all UI references - ResearchWindow: thicken tech-tree edges to 3px core + wide glow, add crawling dash animation on unpowered links, endpoint socket dots, and a comet-trail pulse on powered edges --- data/stations.json | 4 +- dev/mining.test.mjs | 24 +++++++- dev/saves.test.mjs | 7 +++ js/mining/Mining.js | 21 +++++++ js/save/SaveData.js | 15 +++++ js/scenes/GameScene.js | 68 +++++++++++++++++++++- js/ui/DiscoveryCompass.js | 6 +- js/ui/ResearchWindow.js | 115 ++++++++++++++++++++++++++++++-------- 8 files changed, 228 insertions(+), 32 deletions(-) diff --git a/data/stations.json b/data/stations.json index 07a55b7..649bc5b 100644 --- a/data/stations.json +++ b/data/stations.json @@ -1,5 +1,5 @@ { - "_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (red — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds..size = the station's keepout radius (px — the wings/ring extent); kinds..ringSpeed = the deep-space station's ring rotation (rad/s, the procedural fallback only). texture/frameWidth/frameHeight = the station spritesheet (frame 0 = top-left); variants = the sheet frames a DEEP-SPACE STATION may wear — the galaxy-wide pass (js/galaxy/StationFrames.js) spreads them across the galaxy (a system avoids what its nearest stars already wear, like the planet frames — js/galaxy/PlanetFrames.js). typeLabels = the comms/HUD label a variant reads as (keyed by sheet frame — the fallback for an unlabeled frame is the kind's label, data/settlements.json → kinds..label). The landing/surface/take-off/shop clips a variant plays come from data/landing.json → stationVideos (same sheet-frame key) — surface/shop are null until they are authored.", + "_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (bright purple — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds..size = the station's keepout radius (px — the wings/ring extent); kinds..ringSpeed = the deep-space station's ring rotation (rad/s, the procedural fallback only). texture/frameWidth/frameHeight = the station spritesheet (frame 0 = top-left); variants = the sheet frames a DEEP-SPACE STATION may wear — the galaxy-wide pass (js/galaxy/StationFrames.js) spreads them across the galaxy (a system avoids what its nearest stars already wear, like the planet frames — js/galaxy/PlanetFrames.js). typeLabels = the comms/HUD label a variant reads as (keyed by sheet frame — the fallback for an unlabeled frame is the kind's label, data/settlements.json → kinds..label). The landing/surface/take-off/shop clips a variant plays come from data/landing.json → stationVideos (same sheet-frame key) — surface/shop are null until they are authored.", "enabled": true, "texture": "assets/images/spacestations.png", "frameWidth": 256, @@ -11,7 +11,7 @@ "2": "Research Station" }, "shipClearance": 50, - "compassColor": "#ff4d5e", + "compassColor": "#c84dff", "kinds": { "deepSpaceStation": { "size": 108, diff --git a/dev/mining.test.mjs b/dev/mining.test.mjs index d22bc94..8fb828a 100644 --- a/dev/mining.test.mjs +++ b/dev/mining.test.mjs @@ -94,6 +94,7 @@ function rig(size) { const events = []; const phases = []; const ores = []; // (gained, hold) — the LIVE hold feed (the HUD seam) + const depleted = []; // the clusters whose last rock was consumed const mining = new Mining(scene, { onPhase: (p) => { phases.push(p); @@ -101,6 +102,7 @@ function rig(size) { }, onEvent: (n, d) => events.push([n, d]), onOre: (gained, hold) => ores.push([gained, hold]), + onDepleted: (c) => depleted.push(c), }); ship.onStateChange = (next, prev) => { if (prev === 'mining' && next !== 'mining') mining.stop(); @@ -120,7 +122,7 @@ function rig(size) { mining.state = 'mining'; // fast-forward the arm reach mining.beam = fakeBeam(); }; - return { scene, ship, cluster, member, events, phases, ores, mining, pump, start }; + return { scene, ship, cluster, member, events, phases, ores, depleted, mining, pump, start }; } // --------------------------------------------------------------------------- @@ -154,6 +156,7 @@ function rig(size) { assert.equal(r.mining.state, 'idle'); assert.equal(r.ship.state, 'normal', 'the ship is free again'); assert.deepEqual(r.events.at(-1), ['split', { kind: 'absorb', pieces: 4, pieceSize: 7 }]); + assert.equal(r.depleted.length, 0, 'a sibling rock remains → the field is NOT depleted'); 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)'); @@ -161,6 +164,7 @@ function rig(size) { 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)'); + assert.equal(r.depleted.length, 0, 'still one rock left → no depletion'); console.log('A: 128 px rock — halve → shatter → suck in: OK'); } @@ -220,4 +224,22 @@ function rig(size) { console.log('D: hold restore API — clamped at both ends: OK'); } +// --------------------------------------------------------------------------- +// Scenario E — depletion: the cluster's LAST rock is consumed → onDepleted +// fires exactly once (with the cluster), at the shatter that empties it. +// A cluster with rocks left never fires (scenarios A/B assert that). +// --------------------------------------------------------------------------- +{ + const r = rig(64); + r.start(); + assert.equal(r.depleted.length, 0, 'nothing mined yet → no depletion'); + r.pump(22); // 22 s → 42 left → shatters; the lone rock was the last one + assert.equal(r.cluster.members.length, 0, 'the field is empty'); + assert.deepEqual(r.depleted, [r.cluster], 'onDepleted fired once, with the cluster'); + assert.equal(r.mining.state, 'idle'); + r.pump(3); // the fragments ride home AFTER the field went — the scene + assert.equal(r.ship.minerals, 22 + 40, 'suck-in still lands after depletion'); + console.log('E: last rock consumed — onDepleted fires once: OK'); +} + console.log('\nAll mining ore scenarios passed.'); diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 4896fc4..558a645 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -59,6 +59,7 @@ const fakeScene = () => ({ systemRecord: { name: system.name }, ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 }, discovery: new Discovery(540), + depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field tetherField: { tethers: [ { id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }, @@ -209,6 +210,7 @@ const makeStorage = (fail = false) => { check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0')); check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost'); check('capture: playtime carried', rec.playTimeMs === 123456); + check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`)); // A FRESH registry = a new browser session: prepareLoad rebuilds the // galaxy from the seed and restores the discovery state. @@ -220,6 +222,8 @@ const makeStorage = (fail = false) => { check('prepare: the rebuilt galaxy is the SAME galaxy', reg.get('galaxy').name === galaxy.name && reg.get('galaxy').byId.get(system.id)?.name === system.name); check('prepare: discovery restored', reg.get('discovery').isDiscovered(system.id, 'pl:0') === true); + check('prepare: depleted fields restored (the field stays gone)', + reg.get('depletedClusters') instanceof Set && reg.get('depletedClusters').has(`${system.id}:c-depleted`)); check('prepare: a NEW undiscovered object stays undiscovered', reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false); check('prepare: the live state is staged under the pending key', @@ -232,6 +236,8 @@ const makeStorage = (fail = false) => { prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals check('prepare: a legacy record (no minerals) still loads, field absent', regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined); + check('prepare: a legacy record (no depletedClusters) stages an empty set', + regLegacy.get('depletedClusters') instanceof Set && regLegacy.get('depletedClusters').size === 0); // BUILDS state: an in-flight build (remaining time on the loop clock) // rides the pending restore, like research. @@ -260,6 +266,7 @@ const makeStorage = (fail = false) => { reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } }); resetRunState(reg); check('reset: discovery cleared (fresh run)', reg.get('discovery') === null); + check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null); check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null); } diff --git a/js/mining/Mining.js b/js/mining/Mining.js index c937070..4538bc1 100644 --- a/js/mining/Mining.js +++ b/js/mining/Mining.js @@ -62,12 +62,19 @@ export class Mining { * 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). + * @param {Function} [o.onDepleted] — (cluster) => void: the cluster's LAST + * rock was consumed (its members are now all gone) — the scene drops the + * field (world + map) and records it. Fires once, from the shatter that + * emptied the cluster, while the beam is still latched (the teardown + * follows); the shattered pieces are scene images, not cluster children, + * so they ride home even after the cluster is destroyed. */ 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.onDepleted = typeof o.onDepleted === 'function' ? o.onDepleted : null; this.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting' this.cluster = null; this.member = null; @@ -277,6 +284,10 @@ export class Mining { } cluster.removeMember(member); this._event('split', { kind: 'absorb', pieces: this.smallPieces, pieceSize }); + // Last rock consumed? The field is DEPLETED — the scene drops it + // (world + map) now, before the teardown below. A sibling rock + // remaining (the halved break) does NOT deplete — the field lives on. + if (cluster.members.length === 0) this._depleted(cluster); // The rock is gone — the beam can't retract from nothing: cut it and // end the run ('stopped' frees the ship; the fragments finish alone). if (this.beam) { @@ -430,6 +441,15 @@ export class Mining { } } + /** The cluster's last rock is gone — the scene drops the field. */ + _depleted(cluster) { + try { + this.onDepleted?.(cluster); + } catch (err) { + console.error('[mining] onDepleted handler failed', err); + } + } + destroy() { this.beam?.destroy(); this.beam = null; @@ -444,5 +464,6 @@ export class Mining { this.onPhase = null; this.onEvent = null; this.onOre = null; + this.onDepleted = null; } } diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 51c030e..e3328df 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -29,6 +29,7 @@ * builds: BuildState.toJSON() | null, * quests: QuestState.toJSON() | null, // held + claimed quest ids * totalMined: number, // lifetime asteroid mining + * depletedClusters: [":"], // fields mined to nothing * playTimeMs } * * captureState(scene) — GameScene → record (the Save panel calls it) @@ -123,6 +124,12 @@ export function captureState(scene, now) { // requirement's input — the hold is cargo, this is the record). // A save predating it has no field; the restore stages 0. totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)), + // DEPLETED asteroid fields — the clusters the run has mined to + // NOTHING (ids keyed ":"), so a load keeps them + // gone: the content regenerates from the seed and GameScene splices + // these out (world + map). A save predating it has no field; the + // restore stages an empty set (old saves load). + depletedClusters: Array.from(scene.depletedClusters ?? []), // The SYSTEM research category's activation keys (which jump gates — // incl. other systems' return gates — the run has activated; the // per-run world state GameScene keeps in its registry-backed set). @@ -217,6 +224,13 @@ export function prepareLoad(registry, record) { registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null); // Lifetime asteroid mining (a save predating it has no field → 0). registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0))); + // DEPLETED asteroid fields (a save predating them has no field → empty + // set — old saves load; GameScene.create() splices them out of the + // regenerated content of the system the run is in). + registry.set( + 'depletedClusters', + new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []), + ); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], @@ -252,6 +266,7 @@ export function resetRunState(registry) { registry.set('routeNotice', null); registry.set('quests', null); registry.set('totalMined', null); + registry.set('depletedClusters', null); registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 0179137..d5ed5d2 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -324,6 +324,24 @@ export class GameScene extends Phaser.Scene { this._pendingRestore = consumeRestore(this.registry); this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); + // DEPLETED FIELDS — asteroid clusters the run has mined to nothing: + // per-run world state, registry-backed like discovery/research + // (js/save/SaveData.js). The galaxy's content regenerates from the + // seed on every load, so the run's depleted ids are spliced OUT of + // the content BEFORE the cluster entities build (below) — a mined-out + // field stays gone across a save, and it is off the map (the chart + + // resource stats read content.asteroids) with the entities gone. + this.depletedClusters = this.registry.get('depletedClusters') ?? null; + if (!this.depletedClusters) { + this.depletedClusters = new Set(); + this.registry.set('depletedClusters', this.depletedClusters); + } + if (this.depletedClusters.size && Array.isArray(this.systemContent?.asteroids)) { + const sysId = this.systemRecord.id; + this.systemContent.asteroids = this.systemContent.asteroids.filter( + (c) => !this.depletedClusters.has(`${sysId}:${c?.id}`), + ); + } // JUMP GATE ACTIVATION (the SYSTEM research category — // js/research/SystemCategory.js): the run's activation keys are // per-run world state — registry-backed (like discovery: New Game @@ -673,6 +691,7 @@ export class GameScene extends Phaser.Scene { // clicked. this.mining = new Mining(this, { onPhase: (p) => this.onMiningPhase(p), + onDepleted: (c) => this.onClusterDepleted(c), onEvent: (name, data) => this.onMiningEvent(name, data), onOre: (gained) => { this.refreshMineralHud(); // live: the hold fills as the rock shrinks @@ -1799,9 +1818,9 @@ export class GameScene extends Phaser.Scene { y: st.y, radius: st.bound, typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'), - // The compass shows stations in red (data/stations.json → + // The compass shows stations in bright purple (data/stations.json → // compassColor) — the arrow + name tag pick it up. - color: config.get('stations.compassColor', '#ff4d5e'), + color: config.get('stations.compassColor', '#c84dff'), name: st.discoveryName, }); } @@ -2469,6 +2488,39 @@ export class GameScene extends Phaser.Scene { } } + /** + * A cluster's LAST rock was consumed (Mining → onDepleted): the field is + * DEPLETED. It drops out of the WORLD (this.asteroidClusters — compass + * arrows, rock hit tests — and this.solids, so the ship may now fly + * through the spot) and out of the CONTENT RECORD (the map's chart + + * resource stats read content.asteroids), and its id joins the run's + * depleted list (a save/load keeps it gone — the content regenerates + * from the seed; js/save/SaveData.js). The shattered pieces already + * flying home are scene images, not cluster children, so they finish + * the ride after destroy(). The field's menu (if still open) closes — + * it would float over the void. + */ + onClusterDepleted(cluster) { + const i = this.asteroidClusters.indexOf(cluster); + if (i !== -1) this.asteroidClusters.splice(i, 1); + const j = this.solids.indexOf(cluster); + if (j !== -1) this.solids.splice(j, 1); + const arr = this.systemContent?.asteroids; + if (Array.isArray(arr)) { + const k = arr.findIndex((c) => c && c.id === cluster.discoveryId); + if (k !== -1) arr.splice(k, 1); + } + this.depletedClusters.add(`${this.systemRecord.id}:${cluster.discoveryId}`); + if (this.miningPopup?.isOpen && this.miningPopup.lastTarget?.cluster === cluster) { + this.miningPopup.close(); + } + cluster.destroy(); + this.consoleToast(`${cluster.discoveryName.toUpperCase()} FIELD DEPLETED`, { + glyph: '\u25cc', + glyphColor: toCss(themeColor('neon', 0x00e5ff)), + }); + } + /** * 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), @@ -3091,7 +3143,17 @@ export class GameScene extends Phaser.Scene { if (!systemId || !this.galaxy || systemId === this.systemRecord?.id) return null; const record = this.galaxy.byId.get(systemId); if (!record) return null; - return systemChartSnapshot(systemId, this.galaxy.contentOf(systemId), { + const content = this.galaxy.contentOf(systemId); + // A field the run depleted stays off the chart: the content may have + // been regenerated since the mining (a load rebuilds the galaxy), so + // the splice is applied here too (the CURRENT system is spliced in + // create() — and is excluded above). + if (content && Array.isArray(content.asteroids) && this.depletedClusters?.size) { + content.asteroids = content.asteroids.filter( + (c) => !this.depletedClusters.has(`${systemId}:${c?.id}`), + ); + } + return systemChartSnapshot(systemId, content, { discovery: this.discovery, isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id), // The DESTINATION object (if this is the destination system and the diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index d82a592..3cd606e 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -179,9 +179,9 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container { const fam = fontStack('body', FONT_FALLBACK); // Per-target accent: a target may carry its own color (asteroid // clusters — data/asteroids.json → compassColor, light gray; planets - // — data/planets.json, green; stations — data/stations.json, red) and - // the arrow + chip use it; anything without one keeps the theme's - // neon cyan. + // — data/planets.json, green; stations — data/stations.json, bright + // purple) and the arrow + chip use it; anything without one keeps + // the theme's neon cyan. const neon = t.color ? toColor(t.color) : themeColor('neon', 0x00e5ff); const ink = themeColor('ink', 0xeaf6ff); const fill = toColor(config.get('theme.colors.panel', '#0a1120')); diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index b96de82..0a9e603 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -70,6 +70,29 @@ const C = { bg: themeColor('bg', 0x04060d), }; +/** Mix a color toward white by `amt` (0..1) — brighter edge cores / pulse heads. */ +const mixWhite = (color, amt) => { + const ch = (sh) => { + const c = (color >> sh) & 255; + return Math.min(255, Math.round(c + (255 - c) * amt)); + }; + return (ch(16) << 16) | (ch(8) << 8) | ch(0); +}; + +/** + * Tech-tree edge styling — thick enough to read at a glance: a 3 px core + * over a wide, low-alpha accent glow (the panel-glow idiom). Powered edges + * are solid and bright (the category's accent); unpowered ones sit dimmer, + * dashed, and their dashes crawl slowly parent → child (update() advances + * the dash offset). The energy pulse keeps its comet ride down the powered + * links, now with a trail so it reads against the 3 px core. + */ +const EDGE_W = 3; // core line width (px) +const EDGE_GLOW_W = 9; // the wide glow stroke under the core +const EDGE_DASH = 6; // unpowered dash rhythm +const EDGE_GAP = 6; +const EDGE_UNPOWERED = 0x527ba6; // core colour of a "not yet powered" link + /** * Draw a cut-corner plate with its top-left at (x, y) into an existing * Graphics. CyberShape.points is centred on (0,0), so translate by the @@ -668,7 +691,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { edges.push(this._makeEdge(parent, child, accent)); } } - const entry = { tree, layout, cont, rowsG, edgesG, pulseG, nodes, edges, accent }; + const entry = { tree, layout, cont, rowsG, edgesG, pulseG, nodes, edges, accent, dashOff: 0 }; this.nodeW = nodeW; this.nodeH = nodeH; this._paintRows(entry); @@ -738,7 +761,11 @@ export class ResearchWindow extends Phaser.GameObjects.Container { segs.push({ a: pts[i - 1], b: pts[i], len, at: total }); total += len; } - return { parent, child, pts, segs, total, accent, phase: Math.random() }; + return { + parent, child, pts, segs, total, accent, phase: Math.random(), + core: mixWhite(accent, 0.45), // powered core — the accent lifted toward white + head: mixWhite(accent, 0.7), // pulse head — near-white + }; } _pointAt(edge, u) { @@ -753,10 +780,26 @@ export class ResearchWindow extends Phaser.GameObjects.Container { return { x: p[0], y: p[1] }; } - /** Dashed polyline (the "not yet powered" look). */ - _dashLine(g, pts, dash = 5, gap = 5) { - let remaining = dash; - let drawing = true; + /** Stroke a polyline. This v4 build's strokePath() takes NO points (the + * argument is dropped) and lineBetween() strokes each segment on its own, + * so the reliable multi-segment path is beginPath → moveTo/lineTo → + * strokePath — the TetherField.strokePass idiom. */ + _strokePolyline(g, pts) { + if (pts.length < 2) return; + g.beginPath(); + g.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]); + g.strokePath(); + } + + /** Dashed polyline (the "not yet powered" look). `offset` shifts the dash + * phase — update() advances it so the dashes crawl parent → child. */ + _dashLine(g, pts, dash = EDGE_DASH, gap = EDGE_GAP, offset = 0) { + const period = dash + gap; + const ph = ((offset % period) + period) % period; + let remaining = ph < dash ? dash - ph : period - ph; + let drawing = ph < dash; + g.beginPath(); for (let i = 1; i < pts.length; i++) { const [ax, ay] = pts[i - 1]; const [bx, by] = pts[i]; @@ -765,17 +808,18 @@ export class ResearchWindow extends Phaser.GameObjects.Container { let travelled = 0; while (travelled < segLen) { const take = Math.min(remaining, segLen - travelled); - if (drawing) { + if (drawing && take > 0) { const t0 = travelled / segLen; const t1 = (travelled + take) / segLen; - g.lineBetween(ax + (bx - ax) * t0, ay + (by - ay) * t0, ax + (bx - ax) * t1, ay + (by - ay) * t1); + g.moveTo(ax + (bx - ax) * t0, ay + (by - ay) * t0); + g.lineTo(ax + (bx - ax) * t1, ay + (by - ay) * t1); } travelled += take; remaining = drawing ? gap : dash; drawing = !drawing; } } - g.strokePath(pts); + g.strokePath(); } // ── right: the detail readout ───────────────────────────────────────────── @@ -1035,18 +1079,31 @@ export class ResearchWindow extends Phaser.GameObjects.Container { const g = entry.edgesG; g.clear(); const state = this.state; + const offset = entry.dashOff ?? 0; for (const e of entry.edges) { const powered = state?.isUnlocked(entry.tree.id, e.parent.id) ?? false; - g.lineStyle(1.5, powered ? 0x1d4a68 : 0x2a4a6e, powered ? 0.9 : 0.4); if (powered) { - g.strokePath(e.pts); + // glow pass: a wide, low-alpha accent under the core (panel-glow idiom) + g.lineStyle(EDGE_GLOW_W, e.accent, 0.14); + this._strokePolyline(g, e.pts); + // core: 3 px, bright — the link reads at a glance + g.lineStyle(EDGE_W, e.core, 0.95); + this._strokePolyline(g, e.pts); } else { - this._dashLine(g, e.pts); + // not yet powered: dimmer dashed core (glow kept faint), the crawl + // offset makes the dashes march parent → child + g.lineStyle(EDGE_GLOW_W, e.accent, 0.05); + this._dashLine(g, e.pts, EDGE_DASH, EDGE_GAP, offset); + g.lineStyle(EDGE_W, EDGE_UNPOWERED, 0.7); + this._dashLine(g, e.pts, EDGE_DASH, EDGE_GAP, offset); + } + // endpoint sockets where the link meets each node plate + for (const p of [e.pts[0], e.pts[e.pts.length - 1]]) { + g.fillStyle(e.accent, powered ? 0.22 : 0.1); + g.fillCircle(p[0], p[1], 5.5); + g.fillStyle(powered ? e.core : 0x6a8cb4, powered ? 1 : 0.75); + g.fillCircle(p[0], p[1], 3); } - g.fillStyle(powered ? 0x2f7ba8 : 0x22405f, 0.9); - g.fillCircle(e.pts[0][0], e.pts[0][1], 2); - const last = e.pts[e.pts.length - 1]; - g.fillCircle(last[0], last[1], 2); } } @@ -1519,18 +1576,30 @@ export class ResearchWindow extends Phaser.GameObjects.Container { this._paintDetail(false); } } - // energy pulses down the powered edges + // the unpowered links keep crawling: advance the dash phase and + // re-stroke (cheap — a handful of edges, 3 segments apiece) + entry.dashOff = (entry.dashOff + (this.scene.time?.deltaMS ?? 16) * 0.01) % (EDGE_DASH + EDGE_GAP); + this._paintEdges(entry); + // energy pulses down the powered edges — a bright head with a short + // comet trail (clamped so the trail never wraps past the parent end) const pg = entry.pulseG; pg.clear(); const state = this.state; for (const e of entry.edges) { if (!state?.isUnlocked(entry.tree.id, e.parent.id)) continue; - const u = (time * 0.00045 + e.phase) % 1; - const p = this._pointAt(e, u); - pg.fillStyle(e.accent, 0.8); - pg.fillCircle(p.x, p.y, 2.2); - pg.fillStyle(e.accent, 0.25); - pg.fillCircle(p.x, p.y, 5); + const u0 = (time * 0.00045 + e.phase) % 1; + for (let k = 3; k >= 1; k--) { + const u = u0 - k * 0.02; + if (u < 0) continue; + const p = this._pointAt(e, u); + pg.fillStyle(e.accent, 0.3 * (1 - k / 4)); + pg.fillCircle(p.x, p.y, 3.6 - k * 0.5); + } + const p = this._pointAt(e, u0); + pg.fillStyle(e.accent, 0.3); + pg.fillCircle(p.x, p.y, 9); + pg.fillStyle(e.head, 1); + pg.fillCircle(p.x, p.y, 3.8); } }