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,
|
"enabled": true,
|
||||||
"texture": "assets/images/spacestations.png",
|
"texture": "assets/images/spacestations.png",
|
||||||
"frameWidth": 256,
|
"frameWidth": 256,
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"2": "Research Station"
|
"2": "Research Station"
|
||||||
},
|
},
|
||||||
"shipClearance": 50,
|
"shipClearance": 50,
|
||||||
"compassColor": "#ff4d5e",
|
"compassColor": "#c84dff",
|
||||||
"kinds": {
|
"kinds": {
|
||||||
"deepSpaceStation": {
|
"deepSpaceStation": {
|
||||||
"size": 108,
|
"size": 108,
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ function rig(size) {
|
||||||
const events = [];
|
const events = [];
|
||||||
const phases = [];
|
const phases = [];
|
||||||
const ores = []; // (gained, hold) — the LIVE hold feed (the HUD seam)
|
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, {
|
const mining = new Mining(scene, {
|
||||||
onPhase: (p) => {
|
onPhase: (p) => {
|
||||||
phases.push(p);
|
phases.push(p);
|
||||||
|
|
@ -101,6 +102,7 @@ function rig(size) {
|
||||||
},
|
},
|
||||||
onEvent: (n, d) => events.push([n, d]),
|
onEvent: (n, d) => events.push([n, d]),
|
||||||
onOre: (gained, hold) => ores.push([gained, hold]),
|
onOre: (gained, hold) => ores.push([gained, hold]),
|
||||||
|
onDepleted: (c) => depleted.push(c),
|
||||||
});
|
});
|
||||||
ship.onStateChange = (next, prev) => {
|
ship.onStateChange = (next, prev) => {
|
||||||
if (prev === 'mining' && next !== 'mining') mining.stop();
|
if (prev === 'mining' && next !== 'mining') mining.stop();
|
||||||
|
|
@ -120,7 +122,7 @@ function rig(size) {
|
||||||
mining.state = 'mining'; // fast-forward the arm reach
|
mining.state = 'mining'; // fast-forward the arm reach
|
||||||
mining.beam = fakeBeam();
|
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.mining.state, 'idle');
|
||||||
assert.equal(r.ship.state, 'normal', 'the ship is free again');
|
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.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
|
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.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.ores.at(-1), [7, 85], '… last fragment: +7, hold at 85');
|
||||||
assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 28, pieces: 4 }]);
|
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.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');
|
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');
|
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.');
|
console.log('\nAll mining ore scenarios passed.');
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ const fakeScene = () => ({
|
||||||
systemRecord: { name: system.name },
|
systemRecord: { name: system.name },
|
||||||
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 },
|
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37 },
|
||||||
discovery: new Discovery(540),
|
discovery: new Discovery(540),
|
||||||
|
depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field
|
||||||
tetherField: {
|
tetherField: {
|
||||||
tethers: [
|
tethers: [
|
||||||
{ id: 'home', x: 0, y: 0, level: 1, label: 'Terra' },
|
{ 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: 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: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
|
||||||
check('capture: playtime carried', rec.playTimeMs === 123456);
|
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
|
// A FRESH registry = a new browser session: prepareLoad rebuilds the
|
||||||
// galaxy from the seed and restores the discovery state.
|
// 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
|
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);
|
&& 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: 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',
|
check('prepare: a NEW undiscovered object stays undiscovered',
|
||||||
reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false);
|
reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false);
|
||||||
check('prepare: the live state is staged under the pending key',
|
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
|
prepareLoad(regLegacy, makeRec()); // makeRec's ship has no minerals
|
||||||
check('prepare: a legacy record (no minerals) still loads, field absent',
|
check('prepare: a legacy record (no minerals) still loads, field absent',
|
||||||
regLegacy.get(PENDING_RESTORE_KEY).ship.minerals === undefined);
|
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)
|
// BUILDS state: an in-flight build (remaining time on the loop clock)
|
||||||
// rides the pending restore, like research.
|
// 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 } });
|
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
|
||||||
resetRunState(reg);
|
resetRunState(reg);
|
||||||
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
|
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);
|
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
|
* 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
|
* fragment landing, so a UI can show the hold filling as the rock
|
||||||
* shrinks (not just at the shatter's 'absorbed' event).
|
* 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 = {}) {
|
constructor(scene, o = {}) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null;
|
this.onPhase = typeof o.onPhase === 'function' ? o.onPhase : null;
|
||||||
this.onEvent = typeof o.onEvent === 'function' ? o.onEvent : null;
|
this.onEvent = typeof o.onEvent === 'function' ? o.onEvent : null;
|
||||||
this.onOre = typeof o.onOre === 'function' ? o.onOre : 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.state = 'idle'; // 'idle' | 'extending' | 'mining' | 'retracting'
|
||||||
this.cluster = null;
|
this.cluster = null;
|
||||||
this.member = null;
|
this.member = null;
|
||||||
|
|
@ -277,6 +284,10 @@ export class Mining {
|
||||||
}
|
}
|
||||||
cluster.removeMember(member);
|
cluster.removeMember(member);
|
||||||
this._event('split', { kind: 'absorb', pieces: this.smallPieces, pieceSize });
|
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
|
// 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).
|
// end the run ('stopped' frees the ship; the fragments finish alone).
|
||||||
if (this.beam) {
|
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() {
|
destroy() {
|
||||||
this.beam?.destroy();
|
this.beam?.destroy();
|
||||||
this.beam = null;
|
this.beam = null;
|
||||||
|
|
@ -444,5 +464,6 @@ export class Mining {
|
||||||
this.onPhase = null;
|
this.onPhase = null;
|
||||||
this.onEvent = null;
|
this.onEvent = null;
|
||||||
this.onOre = null;
|
this.onOre = null;
|
||||||
|
this.onDepleted = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
* builds: BuildState.toJSON() | null,
|
* builds: BuildState.toJSON() | null,
|
||||||
* quests: QuestState.toJSON() | null, // held + claimed quest ids
|
* quests: QuestState.toJSON() | null, // held + claimed quest ids
|
||||||
* totalMined: number, // lifetime asteroid mining
|
* totalMined: number, // lifetime asteroid mining
|
||||||
|
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||||
* playTimeMs }
|
* playTimeMs }
|
||||||
*
|
*
|
||||||
* captureState(scene) — GameScene → record (the Save panel calls it)
|
* 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).
|
// requirement's input — the hold is cargo, this is the record).
|
||||||
// A save predating it has no field; the restore stages 0.
|
// A save predating it has no field; the restore stages 0.
|
||||||
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 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 —
|
// The SYSTEM research category's activation keys (which jump gates —
|
||||||
// incl. other systems' return gates — the run has activated; the
|
// incl. other systems' return gates — the run has activated; the
|
||||||
// per-run world state GameScene keeps in its registry-backed set).
|
// 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);
|
registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null);
|
||||||
// Lifetime asteroid mining (a save predating it has no field → 0).
|
// Lifetime asteroid mining (a save predating it has no field → 0).
|
||||||
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 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, {
|
registry.set(PENDING_RESTORE_KEY, {
|
||||||
ship: record.ship,
|
ship: record.ship,
|
||||||
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
tethers: Array.isArray(record.tethers) ? record.tethers : [],
|
||||||
|
|
@ -252,6 +266,7 @@ export function resetRunState(registry) {
|
||||||
registry.set('routeNotice', null);
|
registry.set('routeNotice', null);
|
||||||
registry.set('quests', null);
|
registry.set('quests', null);
|
||||||
registry.set('totalMined', null);
|
registry.set('totalMined', null);
|
||||||
|
registry.set('depletedClusters', null);
|
||||||
registry.set(PENDING_RESTORE_KEY, null);
|
registry.set(PENDING_RESTORE_KEY, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,24 @@ export class GameScene extends Phaser.Scene {
|
||||||
this._pendingRestore = consumeRestore(this.registry);
|
this._pendingRestore = consumeRestore(this.registry);
|
||||||
this.systemRecord = this.galaxy.currentSystem();
|
this.systemRecord = this.galaxy.currentSystem();
|
||||||
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
|
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 —
|
// JUMP GATE ACTIVATION (the SYSTEM research category —
|
||||||
// js/research/SystemCategory.js): the run's activation keys are
|
// js/research/SystemCategory.js): the run's activation keys are
|
||||||
// per-run world state — registry-backed (like discovery: New Game
|
// per-run world state — registry-backed (like discovery: New Game
|
||||||
|
|
@ -673,6 +691,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
// clicked.
|
// clicked.
|
||||||
this.mining = new Mining(this, {
|
this.mining = new Mining(this, {
|
||||||
onPhase: (p) => this.onMiningPhase(p),
|
onPhase: (p) => this.onMiningPhase(p),
|
||||||
|
onDepleted: (c) => this.onClusterDepleted(c),
|
||||||
onEvent: (name, data) => this.onMiningEvent(name, data),
|
onEvent: (name, data) => this.onMiningEvent(name, data),
|
||||||
onOre: (gained) => {
|
onOre: (gained) => {
|
||||||
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
|
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
|
||||||
|
|
@ -1799,9 +1818,9 @@ export class GameScene extends Phaser.Scene {
|
||||||
y: st.y,
|
y: st.y,
|
||||||
radius: st.bound,
|
radius: st.bound,
|
||||||
typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'),
|
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.
|
// compassColor) — the arrow + name tag pick it up.
|
||||||
color: config.get('stations.compassColor', '#ff4d5e'),
|
color: config.get('stations.compassColor', '#c84dff'),
|
||||||
name: st.discoveryName,
|
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).
|
* 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),
|
* 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;
|
if (!systemId || !this.galaxy || systemId === this.systemRecord?.id) return null;
|
||||||
const record = this.galaxy.byId.get(systemId);
|
const record = this.galaxy.byId.get(systemId);
|
||||||
if (!record) return null;
|
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,
|
discovery: this.discovery,
|
||||||
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
|
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
|
||||||
// The DESTINATION object (if this is the destination system and the
|
// 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);
|
const fam = fontStack('body', FONT_FALLBACK);
|
||||||
// Per-target accent: a target may carry its own color (asteroid
|
// Per-target accent: a target may carry its own color (asteroid
|
||||||
// clusters — data/asteroids.json → compassColor, light gray; planets
|
// clusters — data/asteroids.json → compassColor, light gray; planets
|
||||||
// — data/planets.json, green; stations — data/stations.json, red) and
|
// — data/planets.json, green; stations — data/stations.json, bright
|
||||||
// the arrow + chip use it; anything without one keeps the theme's
|
// purple) and the arrow + chip use it; anything without one keeps
|
||||||
// neon cyan.
|
// the theme's neon cyan.
|
||||||
const neon = t.color ? toColor(t.color) : themeColor('neon', 0x00e5ff);
|
const neon = t.color ? toColor(t.color) : themeColor('neon', 0x00e5ff);
|
||||||
const ink = themeColor('ink', 0xeaf6ff);
|
const ink = themeColor('ink', 0xeaf6ff);
|
||||||
const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
|
const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,29 @@ const C = {
|
||||||
bg: themeColor('bg', 0x04060d),
|
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
|
* 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
|
* 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));
|
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.nodeW = nodeW;
|
||||||
this.nodeH = nodeH;
|
this.nodeH = nodeH;
|
||||||
this._paintRows(entry);
|
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 });
|
segs.push({ a: pts[i - 1], b: pts[i], len, at: total });
|
||||||
total += len;
|
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) {
|
_pointAt(edge, u) {
|
||||||
|
|
@ -753,10 +780,26 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
return { x: p[0], y: p[1] };
|
return { x: p[0], y: p[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dashed polyline (the "not yet powered" look). */
|
/** Stroke a polyline. This v4 build's strokePath() takes NO points (the
|
||||||
_dashLine(g, pts, dash = 5, gap = 5) {
|
* argument is dropped) and lineBetween() strokes each segment on its own,
|
||||||
let remaining = dash;
|
* so the reliable multi-segment path is beginPath → moveTo/lineTo →
|
||||||
let drawing = true;
|
* 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++) {
|
for (let i = 1; i < pts.length; i++) {
|
||||||
const [ax, ay] = pts[i - 1];
|
const [ax, ay] = pts[i - 1];
|
||||||
const [bx, by] = pts[i];
|
const [bx, by] = pts[i];
|
||||||
|
|
@ -765,17 +808,18 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
let travelled = 0;
|
let travelled = 0;
|
||||||
while (travelled < segLen) {
|
while (travelled < segLen) {
|
||||||
const take = Math.min(remaining, segLen - travelled);
|
const take = Math.min(remaining, segLen - travelled);
|
||||||
if (drawing) {
|
if (drawing && take > 0) {
|
||||||
const t0 = travelled / segLen;
|
const t0 = travelled / segLen;
|
||||||
const t1 = (travelled + take) / 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;
|
travelled += take;
|
||||||
remaining = drawing ? gap : dash;
|
remaining = drawing ? gap : dash;
|
||||||
drawing = !drawing;
|
drawing = !drawing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g.strokePath(pts);
|
g.strokePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── right: the detail readout ─────────────────────────────────────────────
|
// ── right: the detail readout ─────────────────────────────────────────────
|
||||||
|
|
@ -1035,18 +1079,31 @@ export class ResearchWindow extends Phaser.GameObjects.Container {
|
||||||
const g = entry.edgesG;
|
const g = entry.edgesG;
|
||||||
g.clear();
|
g.clear();
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
|
const offset = entry.dashOff ?? 0;
|
||||||
for (const e of entry.edges) {
|
for (const e of entry.edges) {
|
||||||
const powered = state?.isUnlocked(entry.tree.id, e.parent.id) ?? false;
|
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) {
|
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 {
|
} 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);
|
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;
|
const pg = entry.pulseG;
|
||||||
pg.clear();
|
pg.clear();
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
for (const e of entry.edges) {
|
for (const e of entry.edges) {
|
||||||
if (!state?.isUnlocked(entry.tree.id, e.parent.id)) continue;
|
if (!state?.isUnlocked(entry.tree.id, e.parent.id)) continue;
|
||||||
const u = (time * 0.00045 + e.phase) % 1;
|
const u0 = (time * 0.00045 + e.phase) % 1;
|
||||||
const p = this._pointAt(e, u);
|
for (let k = 3; k >= 1; k--) {
|
||||||
pg.fillStyle(e.accent, 0.8);
|
const u = u0 - k * 0.02;
|
||||||
pg.fillCircle(p.x, p.y, 2.2);
|
if (u < 0) continue;
|
||||||
pg.fillStyle(e.accent, 0.25);
|
const p = this._pointAt(e, u);
|
||||||
pg.fillCircle(p.x, p.y, 5);
|
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