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:
parent
9e7d7c1257
commit
bc8d5359c7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue