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
This commit is contained in:
Brian Fertig 2026-09-08 13:18:48 -06:00
parent 9e7d7c1257
commit bc8d5359c7
8 changed files with 228 additions and 32 deletions

View File

@ -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.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.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.<kind>.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.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.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.<kind>.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,

View File

@ -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.');

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -29,6 +29,7 @@
* builds: BuildState.toJSON() | null,
* quests: QuestState.toJSON() | null, // held + claimed quest ids
* totalMined: number, // lifetime asteroid mining
* depletedClusters: ["<sysId>:<clusterId>"], // 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 "<systemId>:<clusterId>"), 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);
}

View File

@ -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

View File

@ -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'));

View File

@ -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);
}
}