- Restructure flat static prototype into pnpm workspace monorepo - apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection - apps/docs: design docs and prototypes - apps/site: landing page - packages/ui: shared Button and Panel primitives - services/spacetimedb: backend module (9 tables, 11 reducers) - Archive legacy static files to archive/legacy-static/ - Game loop: connect, undock, target, approach, dock, mine, sell - Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
1430 lines
60 KiB
JavaScript
1430 lines
60 KiB
JavaScript
window.GDD = window.GDD || {};
|
|
|
|
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
Seeded PRNG (xoshiro128**)
|
|
═══════════════════════════════════════════════ */
|
|
function createRng(seed) {
|
|
let s0 = seed >>> 0 || 1;
|
|
let s1 = (seed * 1103515245 + 12345) >>> 0;
|
|
let s2 = (s1 * 1103515245 + 12345) >>> 0;
|
|
let s3 = (s2 * 1103515245 + 12345) >>> 0;
|
|
function next() {
|
|
const t = s0;
|
|
const r = (t << 9 | t >>> 23);
|
|
s0 = s1; s1 = s2; s2 = s3;
|
|
s3 ^= t ^ s3;
|
|
s0 ^= (s0 << 3 | s0 >>> 29);
|
|
s1 ^= (s1 << 21 | s1 >>> 11);
|
|
s2 ^= (s2 << 7 | s2 >>> 25);
|
|
return ((r + ((s0 ^ s3) >>> 0)) >>> 0) / 4294967296;
|
|
}
|
|
return {
|
|
next,
|
|
range(a, b) { return a + next() * (b - a); },
|
|
int(a, b) { return Math.floor(a + next() * (b - a)); },
|
|
pick(arr) { return arr[Math.floor(next() * arr.length)]; },
|
|
gaussian(mean, sigma) {
|
|
const u1 = next() || 0.0001;
|
|
const u2 = next();
|
|
return mean + sigma * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
},
|
|
};
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
Spiral Galaxy Generation Algorithm
|
|
Inspired by https://vercidium.com/blog/random-galaxy-generation-with-c-and-opengl/
|
|
|
|
Key ideas:
|
|
- Systems placed along spiral arms
|
|
- Arms curve (bend) proportional to distance from center
|
|
- Gravity pushes density toward center
|
|
- Height variance via sin wave for 3D depth
|
|
- Security decreases with distance from center
|
|
- Each arm is a faction's territory
|
|
═══════════════════════════════════════════════ */
|
|
function generateGalaxy(seed, overrides = {}) {
|
|
const rng = createRng(seed);
|
|
|
|
// ── Galaxy shape parameters ──
|
|
const armCount = overrides.armCount ?? 4;
|
|
const galaxySize = overrides.galaxySize ?? 300;
|
|
const armSpread = overrides.armSpread ?? 0.42;
|
|
const rotationStr = overrides.rotationStrength ?? 2.8;
|
|
const gravity = overrides.gravity ?? 1.4;
|
|
const heightMag = overrides.heightMagnitude ?? 15;
|
|
const heightFreq = overrides.heightFrequency ?? 0.018;
|
|
const conformChance = 0.72;
|
|
|
|
// ── Faction/arm colors ──
|
|
const factionDefs = [
|
|
{ id: 'caldari', name: 'Caldari State', faction: 'Caldari', color: '#38bdf8', armColor: 0x38bdf8 },
|
|
{ id: 'minmatar', name: 'Minmatar Republic', faction: 'Minmatar', color: '#ef4444', armColor: 0xef4444 },
|
|
{ id: 'amarr', name: 'Amarr Empire', faction: 'Amarr', color: '#f59e0b', armColor: 0xf0a030 },
|
|
{ id: 'gallente', name: 'Gallente Federation', faction: 'Gallente', color: '#a855f7', armColor: 0xa855f7 },
|
|
];
|
|
|
|
const starColors = { O: '#9bb0ff', B: '#aabfff', A: '#cad7ff', F: '#f8f7ff', G: '#fff4ea', K: '#ffd2a1', M: '#ffcc6f' };
|
|
const starWeights = { O: 0.01, B: 0.03, A: 0.06, F: 0.1, G: 0.15, K: 0.2, M: 0.45 };
|
|
|
|
const oreBySec = [
|
|
{ sec: 0.8, ores: ['Veldspar', 'Scordite'] },
|
|
{ sec: 0.5, ores: ['Veldspar', 'Scordite', 'Pyroxeres'] },
|
|
{ sec: 0.1, ores: ['Scordite', 'Pyroxeres', 'Kernite', 'Omber'] },
|
|
{ sec: -0.1, ores: ['Kernite', 'Omber', 'Jaspet', 'Hemorphite'] },
|
|
{ sec: -0.5, ores: ['Jaspet', 'Hemorphite', 'Arkonor'] },
|
|
];
|
|
|
|
function pickStarType() {
|
|
const r = rng.next();
|
|
let acc = 0;
|
|
for (const [type, w] of Object.entries(starWeights)) {
|
|
acc += w;
|
|
if (r < acc) return type;
|
|
}
|
|
return 'M';
|
|
}
|
|
|
|
function getOresForSec(sec) {
|
|
for (let i = oreBySec.length - 1; i >= 0; i--) {
|
|
if (sec >= oreBySec[i].sec) return oreBySec[i].ores;
|
|
}
|
|
return oreBySec[oreBySec.length - 1].ores;
|
|
}
|
|
|
|
// ── Spiral arm position calculator ──
|
|
function spiralPosition(armIdx, distFactor) {
|
|
const baseAngle = (2 * Math.PI / armCount) * armIdx;
|
|
const distance = Math.pow(distFactor, 0.6) * galaxySize;
|
|
const spreadAngle = armSpread * (Math.PI / armCount) * (0.4 + distFactor * 0.8);
|
|
const angleOffset = rng.gaussian(0, spreadAngle);
|
|
const bendAngle = distFactor * rotationStr;
|
|
const angle = baseAngle + angleOffset + bendAngle;
|
|
|
|
const x = distance * Math.cos(angle);
|
|
const z = distance * Math.sin(angle);
|
|
const y = heightMag * Math.sin(distance * heightFreq) * (1 - distFactor * 0.3)
|
|
+ rng.gaussian(0, 2 + distFactor * 4);
|
|
|
|
return { x, y, z, distance, angle: baseAngle + bendAngle };
|
|
}
|
|
|
|
// ── Security from distance ──
|
|
function securityFromDist(distFactor) {
|
|
// Center (0) → 1.0, Edge (1) → -1.0
|
|
const sec = 1.0 - distFactor * 2.0;
|
|
return Math.round(Math.max(-1, Math.min(1, sec + rng.range(-0.05, 0.05))) * 100) / 100;
|
|
}
|
|
|
|
// ── Build data structures ──
|
|
const regions = [];
|
|
const constellations = [];
|
|
const systems = [];
|
|
const stargates = [];
|
|
const stations = [];
|
|
const belts = [];
|
|
const agents = [];
|
|
|
|
// Core region — shared high-sec hub at center
|
|
const coreRegion = {
|
|
id: 'core', name: 'Core Worlds', faction: 'Concord',
|
|
factionColor: '#22d3ee', secMin: 0.8, secMax: 1.0,
|
|
color: '#22c55e', center: { x: 0, y: 0, z: 0 }, systemIds: [],
|
|
};
|
|
regions.push(coreRegion);
|
|
|
|
// Core systems (5-8 systems near center)
|
|
const coreConstCount = 2;
|
|
for (let ci = 0; ci < coreConstCount; ci++) {
|
|
const constId = `core_c${ci}`;
|
|
const cx = rng.gaussian(0, 15);
|
|
const cy = rng.gaussian(0, 3);
|
|
const cz = rng.gaussian(0, 15);
|
|
const con = { id: constId, regionId: 'core', x: cx, y: cy, z: cz, systemIds: [] };
|
|
constellations.push(con);
|
|
|
|
const sysCount = rng.int(3, 5);
|
|
for (let si = 0; si < sysCount; si++) {
|
|
const sx = cx + rng.gaussian(0, 8);
|
|
const sy = cy + rng.gaussian(0, 2);
|
|
const sz = cz + rng.gaussian(0, 8);
|
|
const sec = Math.round((0.8 + rng.next() * 0.2) * 100) / 100;
|
|
const starType = pickStarType();
|
|
const sysId = `${constId}_s${si}`;
|
|
const sysName = `COR-${rng.int(100, 999)}`;
|
|
|
|
const sys = {
|
|
id: sysId, name: sysName, regionId: 'core', constellationId: constId,
|
|
x: sx, y: sy, z: sz,
|
|
security: sec, starType, starColor: starColors[starType],
|
|
planetCount: rng.int(2, 7), faction: 'Concord',
|
|
armIndex: -1, distFactor: 0,
|
|
};
|
|
systems.push(sys);
|
|
con.systemIds.push(sysId);
|
|
coreRegion.systemIds.push(sysId);
|
|
|
|
// Core systems get lots of stations and services
|
|
const stationCount = rng.int(2, 4);
|
|
for (let sti = 0; sti < stationCount; sti++) {
|
|
const services = ['Market', 'Refinery', 'Factory', 'Fitting'];
|
|
if (rng.next() > 0.4) services.push('Insurance');
|
|
if (rng.next() > 0.5) services.push('Clone Bay');
|
|
stations.push({
|
|
id: `${sysId}_stn${sti}`, systemId: sysId,
|
|
name: `${sysName} Station ${String.fromCharCode(65 + sti)}`,
|
|
services,
|
|
});
|
|
if (rng.next() > 0.3) {
|
|
agents.push({
|
|
stationId: `${sysId}_stn${sti}`,
|
|
specialty: rng.pick(['Kill', 'Courier', 'Mining', 'Survey', 'Trade']),
|
|
faction: 'Concord',
|
|
});
|
|
}
|
|
}
|
|
|
|
const beltCount = rng.int(1, 3);
|
|
const ores = getOresForSec(sec);
|
|
for (let bi = 0; bi < beltCount; bi++) {
|
|
belts.push({ id: `${sysId}_belt${bi}`, systemId: sysId, oreType: rng.pick(ores) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Arm regions ──
|
|
for (let armIdx = 0; armIdx < armCount; armIdx++) {
|
|
const fDef = factionDefs[armIdx % factionDefs.length];
|
|
const region = {
|
|
id: fDef.id, name: fDef.name, faction: fDef.faction,
|
|
factionColor: fDef.color,
|
|
secMin: -1.0, secMax: 0.8,
|
|
color: fDef.color,
|
|
center: { x: 0, y: 0, z: 0 }, // computed below
|
|
systemIds: [],
|
|
armIndex: armIdx,
|
|
};
|
|
|
|
// Each arm has 2-4 constellations at different distances
|
|
const constCount = rng.int(2, 4);
|
|
let armCx = 0, armCy = 0, armCz = 0;
|
|
|
|
for (let ci = 0; ci < constCount; ci++) {
|
|
// Distribute constellations along the arm at increasing distances
|
|
const distBase = 0.15 + (ci / constCount) * 0.75;
|
|
const distFactor = distBase + rng.range(-0.05, 0.05);
|
|
const pos = spiralPosition(armIdx, distFactor);
|
|
|
|
const constId = `${fDef.id}_c${ci}`;
|
|
const con = {
|
|
id: constId, regionId: fDef.id,
|
|
x: pos.x, y: pos.y, z: pos.z,
|
|
systemIds: [],
|
|
armIndex: armIdx,
|
|
distFactor: distFactor,
|
|
};
|
|
constellations.push(con);
|
|
|
|
const sysCount = rng.int(3, 6);
|
|
for (let si = 0; si < sysCount; si++) {
|
|
// Systems cluster around the constellation center on the arm
|
|
const sysDistFactor = distFactor + rng.gaussian(0, 0.04);
|
|
const sysPos = spiralPosition(armIdx, Math.max(0.08, Math.min(0.98, sysDistFactor)));
|
|
const sec = securityFromDist(sysDistFactor);
|
|
const starType = pickStarType();
|
|
const sysId = `${constId}_s${si}`;
|
|
const sysName = `${fDef.faction.substring(0, 3).toUpperCase()}-${rng.int(100, 999)}`;
|
|
|
|
const sys = {
|
|
id: sysId, name: sysName, regionId: fDef.id, constellationId: constId,
|
|
x: sysPos.x, y: sysPos.y, z: sysPos.z,
|
|
security: sec, starType, starColor: starColors[starType],
|
|
planetCount: rng.int(1, 6), faction: fDef.faction,
|
|
armIndex: armIdx, distFactor: sysDistFactor,
|
|
};
|
|
systems.push(sys);
|
|
con.systemIds.push(sysId);
|
|
region.systemIds.push(sysId);
|
|
armCx += sysPos.x; armCy += sysPos.y; armCz += sysPos.z;
|
|
|
|
// Stations — more in high-sec, fewer in null
|
|
let stationCount;
|
|
if (sec >= 0.8) stationCount = rng.int(2, 4);
|
|
else if (sec >= 0.5) stationCount = rng.int(1, 3);
|
|
else if (sec >= 0.1) stationCount = rng.int(1, 2);
|
|
else if (sec >= 0) stationCount = rng.int(0, 2);
|
|
else stationCount = rng.int(0, 1);
|
|
|
|
for (let sti = 0; sti < stationCount; sti++) {
|
|
const services = ['Market'];
|
|
if (rng.next() > 0.3) services.push('Refinery');
|
|
if (rng.next() > 0.4) services.push('Factory');
|
|
if (rng.next() > 0.3) services.push('Fitting');
|
|
if (rng.next() > 0.5) services.push('Insurance');
|
|
stations.push({
|
|
id: `${sysId}_stn${sti}`, systemId: sysId,
|
|
name: `${sysName} Station ${String.fromCharCode(65 + sti)}`,
|
|
services,
|
|
});
|
|
if (rng.next() > 0.4) {
|
|
agents.push({
|
|
stationId: `${sysId}_stn${sti}`,
|
|
specialty: rng.pick(['Kill', 'Courier', 'Mining', 'Survey', 'Trade']),
|
|
faction: fDef.faction,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Belts
|
|
const beltCount = sec >= 0.8 ? rng.int(1, 3) : sec >= 0.5 ? rng.int(2, 4) : rng.int(2, 5);
|
|
const ores = getOresForSec(sec);
|
|
for (let bi = 0; bi < beltCount; bi++) {
|
|
belts.push({ id: `${sysId}_belt${bi}`, systemId: sysId, oreType: rng.pick(ores) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute arm center
|
|
const armSysCount = region.systemIds.length || 1;
|
|
region.center = { x: armCx / armSysCount, y: armCy / armSysCount, z: armCz / armSysCount };
|
|
regions.push(region);
|
|
}
|
|
|
|
// ── System lookup map for O(1) access ──
|
|
const sysMap = new Map();
|
|
systems.forEach(s => sysMap.set(s.id, s));
|
|
|
|
// ── Build stargate graph ──
|
|
if (systems.length < 2) return { regions, constellations, systems, stargates, stations, belts, agents, seed, sysMap, dustPositions: new Float32Array(0), dustColors: new Float32Array(0), dustCount: 0, params: { armCount, galaxySize, armSpread, rotationStr, gravity, heightMag, heightFreq } };
|
|
|
|
// 1) MST for connectivity (Prim's — uses sysMap for O(1) lookup)
|
|
const inMST = new Set([systems[0].id]);
|
|
const mstEdges = [];
|
|
const distSq = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2;
|
|
|
|
while (inMST.size < systems.length) {
|
|
let bestDist = Infinity, bestA = null, bestB = null;
|
|
for (const aId of inMST) {
|
|
const a = sysMap.get(aId);
|
|
for (const b of systems) {
|
|
if (inMST.has(b.id)) continue;
|
|
const d = distSq(a, b);
|
|
if (d < bestDist) { bestDist = d; bestA = aId; bestB = b.id; }
|
|
}
|
|
}
|
|
if (bestB) { inMST.add(bestB); mstEdges.push([bestA, bestB]); }
|
|
}
|
|
|
|
mstEdges.forEach(([a, b]) => stargates.push({ from: a, to: b, type: 'mst' }));
|
|
|
|
// 2) Intra-constellation extras
|
|
constellations.forEach(con => {
|
|
if (con.systemIds.length < 3) return;
|
|
const extras = rng.int(1, Math.min(3, con.systemIds.length - 1));
|
|
for (let i = 0; i < extras; i++) {
|
|
const a = rng.pick(con.systemIds);
|
|
const b = rng.pick(con.systemIds.filter(id => id !== a));
|
|
const exists = stargates.some(g => (g.from === a && g.to === b) || (g.from === b && g.to === a));
|
|
if (!exists) stargates.push({ from: a, to: b, type: 'intra' });
|
|
}
|
|
});
|
|
|
|
// Pre-compute sorted arm constellations (avoid repeated filter+sort)
|
|
const armConstsByArm = new Map();
|
|
for (let armIdx = 0; armIdx < armCount; armIdx++) {
|
|
armConstsByArm.set(armIdx, constellations.filter(c => c.armIndex === armIdx).sort((a, b) => a.distFactor - b.distFactor));
|
|
}
|
|
|
|
// 3) Intra-arm connections
|
|
for (let armIdx = 0; armIdx < armCount; armIdx++) {
|
|
const armConsts = armConstsByArm.get(armIdx);
|
|
for (let i = 0; i < armConsts.length - 1; i++) {
|
|
const a = rng.pick(armConsts[i].systemIds);
|
|
const b = rng.pick(armConsts[i + 1].systemIds);
|
|
const exists = stargates.some(g => (g.from === a && g.to === b) || (g.from === b && g.to === a));
|
|
if (!exists) stargates.push({ from: a, to: b, type: 'arm-link' });
|
|
}
|
|
}
|
|
|
|
// 4) Core hub
|
|
const coreSys = coreRegion.systemIds;
|
|
for (let armIdx = 0; armIdx < armCount; armIdx++) {
|
|
const armConsts = armConstsByArm.get(armIdx);
|
|
if (armConsts.length > 0 && coreSys.length > 0) {
|
|
const borderA = rng.pick(armConsts[0].systemIds);
|
|
const borderB = rng.pick(coreSys);
|
|
const exists = stargates.some(g => (g.from === borderA && g.to === borderB) || (g.from === borderB && g.to === borderA));
|
|
if (!exists) stargates.push({ from: borderA, to: borderB, type: 'hub' });
|
|
}
|
|
}
|
|
|
|
// 5) Cross-arm choke points
|
|
for (let armIdx = 0; armIdx < armCount; armIdx++) {
|
|
const nextArm = (armIdx + 1) % armCount;
|
|
const armA = regions.find(r => r.armIndex === armIdx);
|
|
const armB = regions.find(r => r.armIndex === nextArm);
|
|
if (armA && armB && armA.systemIds.length > 0 && armB.systemIds.length > 0) {
|
|
const borderA = rng.pick(armA.systemIds);
|
|
const borderB = rng.pick(armB.systemIds);
|
|
const exists = stargates.some(g => (g.from === borderA && g.to === borderB) || (g.from === borderB && g.to === borderA));
|
|
if (!exists) stargates.push({ from: borderA, to: borderB, type: 'choke' });
|
|
}
|
|
}
|
|
|
|
// 6) High-sec shortcuts
|
|
const highSecSys = systems.filter(s => s.security >= 0.5);
|
|
const shortcutCount = Math.floor(highSecSys.length * 0.15);
|
|
for (let i = 0; i < shortcutCount; i++) {
|
|
const a = rng.pick(highSecSys);
|
|
const b = rng.pick(highSecSys.filter(s => s.id !== a.id));
|
|
const exists = stargates.some(g => (g.from === a.id && g.to === b.id) || (g.from === b.id && g.to === a.id));
|
|
if (!exists) stargates.push({ from: a.id, to: b.id, type: 'shortcut' });
|
|
}
|
|
|
|
// ── Connectivity check ──
|
|
const adj = {};
|
|
systems.forEach(s => { adj[s.id] = []; });
|
|
stargates.forEach(g => { adj[g.from].push(g.to); adj[g.to].push(g.from); });
|
|
const visited = new Set();
|
|
const queue = [systems[0].id];
|
|
visited.add(systems[0].id);
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift();
|
|
for (const nb of (adj[cur] || [])) {
|
|
if (!visited.has(nb)) { visited.add(nb); queue.push(nb); }
|
|
}
|
|
}
|
|
const connected = visited.size === systems.length;
|
|
|
|
const chokeSet = new Set();
|
|
stargates.filter(g => g.type === 'choke').forEach(g => { chokeSet.add(g.from); chokeSet.add(g.to); });
|
|
|
|
const starterSystem = systems.find(s => s.security >= 0.95) || systems.find(s => s.security >= 0.8) || systems[0];
|
|
|
|
function findPath(startId, endId) {
|
|
const prev = {};
|
|
const vis = new Set([startId]);
|
|
const q = [startId];
|
|
while (q.length > 0) {
|
|
const cur = q.shift();
|
|
if (cur === endId) break;
|
|
for (const nb of (adj[cur] || [])) {
|
|
if (!vis.has(nb)) { vis.add(nb); prev[nb] = cur; q.push(nb); }
|
|
}
|
|
}
|
|
const path = [];
|
|
let c = endId;
|
|
while (c) { path.unshift(c); c = prev[c]; }
|
|
return path[0] === startId ? path : [];
|
|
}
|
|
|
|
// ── Generate background dust as flat typed arrays ──
|
|
// No intermediate objects — writes directly to Float32Arrays
|
|
const dustCount = 8000;
|
|
const armColorDefs = factionDefs.map(f => f.armColor);
|
|
const armR = armColorDefs.map(c => ((c >> 16) & 0xFF) / 255);
|
|
const armG = armColorDefs.map(c => ((c >> 8) & 0xFF) / 255);
|
|
const armB = armColorDefs.map(c => (c & 0xFF) / 255);
|
|
const dustPositions = new Float32Array(dustCount * 3);
|
|
const dustColors = new Float32Array(dustCount * 3);
|
|
const TWO_PI = 2 * Math.PI;
|
|
const armAngleStep = TWO_PI / armCount;
|
|
|
|
for (let i = 0; i < dustCount; i++) {
|
|
let distance, angle;
|
|
const isCore = rng.next() < 0.12;
|
|
|
|
if (isCore) {
|
|
distance = Math.pow(rng.next(), gravity * 1.5) * galaxySize * 0.25;
|
|
angle = rng.next() * TWO_PI;
|
|
const brightness = 0.4 + rng.next() * 0.6;
|
|
dustPositions[i * 3] = distance * Math.cos(angle);
|
|
dustPositions[i * 3 + 1] = rng.gaussian(0, 2);
|
|
dustPositions[i * 3 + 2] = distance * Math.sin(angle);
|
|
dustColors[i * 3] = brightness;
|
|
dustColors[i * 3 + 1] = brightness * 0.95;
|
|
dustColors[i * 3 + 2] = brightness * 1.1;
|
|
} else {
|
|
const onArm = rng.next() < conformChance;
|
|
const armIdx = rng.int(0, armCount);
|
|
const distFactor = Math.pow(rng.next(), gravity);
|
|
distance = distFactor * galaxySize;
|
|
const distRatio = distFactor;
|
|
const baseAngle = armAngleStep * armIdx;
|
|
const spreadAngle = armSpread * (Math.PI / armCount) * (0.4 + distRatio * 0.9);
|
|
const bendAngle = distRatio * rotationStr;
|
|
|
|
if (onArm) {
|
|
const angleOffset = rng.gaussian(0, spreadAngle);
|
|
angle = baseAngle + angleOffset + bendAngle;
|
|
const brightness = 0.3 + (1 - distRatio) * 0.7;
|
|
const iR = (1 - distRatio) * 0.3;
|
|
dustPositions[i * 3] = distance * Math.cos(angle);
|
|
dustPositions[i * 3 + 1] = heightMag * Math.sin(distance * heightFreq) * (1 - distRatio * 0.3) + rng.gaussian(0, 1.5 + distRatio * 3);
|
|
dustPositions[i * 3 + 2] = distance * Math.sin(angle);
|
|
dustColors[i * 3] = Math.min(1, armR[armIdx] * brightness + iR);
|
|
dustColors[i * 3 + 1] = Math.min(1, armG[armIdx] * brightness + iR * 0.83);
|
|
dustColors[i * 3 + 2] = Math.min(1, armB[armIdx] * brightness + iR * 0.67);
|
|
} else {
|
|
angle = rng.next() * TWO_PI;
|
|
const brightness = 0.08 + (1 - distRatio) * 0.15;
|
|
dustPositions[i * 3] = distance * Math.cos(angle);
|
|
dustPositions[i * 3 + 1] = heightMag * Math.sin(distance * heightFreq) * (1 - distRatio * 0.3) + rng.gaussian(0, 2 + distRatio * 4);
|
|
dustPositions[i * 3 + 2] = distance * Math.sin(angle);
|
|
dustColors[i * 3] = brightness;
|
|
dustColors[i * 3 + 1] = brightness * 0.95;
|
|
dustColors[i * 3 + 2] = brightness * 1.1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
regions, constellations, systems, stargates, stations, belts, agents, seed,
|
|
connected, chokeSet, starterSystem, findPath, sysMap,
|
|
params: { armCount, galaxySize, armSpread, rotationStr, gravity, heightMag, heightFreq },
|
|
dustPositions, dustColors, dustCount,
|
|
};
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
Helper: Security → Color
|
|
═══════════════════════════════════════════════ */
|
|
function secColor(sec) {
|
|
if (sec >= 0.8) return 0x22c55e;
|
|
if (sec >= 0.5) return 0x86efac;
|
|
if (sec >= 0.1) return 0xf0a030;
|
|
if (sec >= 0) return 0xfb923c;
|
|
if (sec >= -0.4) return 0xef4444;
|
|
return 0xa855f7;
|
|
}
|
|
|
|
function secColorCSS(sec) {
|
|
if (sec >= 0.8) return '#22c55e';
|
|
if (sec >= 0.5) return '#86efac';
|
|
if (sec >= 0.1) return '#f0a030';
|
|
if (sec >= 0) return '#fb923c';
|
|
if (sec >= -0.4) return '#ef4444';
|
|
return '#a855f7';
|
|
}
|
|
|
|
function factionColor(faction) {
|
|
switch (faction) {
|
|
case 'Caldari': return 0x38bdf8;
|
|
case 'Gallente': return 0xa855f7;
|
|
case 'Minmatar': return 0xef4444;
|
|
case 'Amarr': return 0xf0a030;
|
|
case 'Concord': return 0x22d3ee;
|
|
default: return 0x94a3b8;
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════
|
|
Galaxy Demo — Three.js 3D (Spiral Galaxy)
|
|
═══════════════════════════════════════════════ */
|
|
function GalaxyDemo() {
|
|
const containerRef = useRef(null);
|
|
const sceneRef = useRef(null);
|
|
const [seed, setSeed] = useState(42);
|
|
const [inputSeed, setInputSeed] = useState('42');
|
|
const [galaxy, setGalaxy] = useState(null);
|
|
const [hoveredSystem, setHoveredSystem] = useState(null);
|
|
const [selectedSystem, setSelectedSystem] = useState(null);
|
|
const [pathStart, setPathStart] = useState(null);
|
|
const [pathEnd, setPathEnd] = useState(null);
|
|
const [currentPath, setCurrentPath] = useState([]);
|
|
const [showGates, setShowGates] = useState(true);
|
|
const [showLabels, setShowLabels] = useState(true);
|
|
const [showDust, setShowDust] = useState(true);
|
|
const [viewMode, setViewMode] = useState('security');
|
|
const [autoRotate, setAutoRotate] = useState(true);
|
|
const [galaxyParams, setGalaxyParams] = useState({
|
|
armCount: 4,
|
|
rotationStrength: 2.8,
|
|
armSpread: 0.42,
|
|
gravity: 1.4,
|
|
});
|
|
|
|
// Refs for Three.js objects we need to update without re-render
|
|
const galaxyRef = useRef(null);
|
|
const selectedSystemRef = useRef(null);
|
|
const hoveredSystemRef = useRef(null);
|
|
const pathStartRef = useRef(null);
|
|
const pathEndRef = useRef(null);
|
|
const currentPathRef = useRef([]);
|
|
const viewModeRef = useRef('security');
|
|
const showGatesRef = useRef(true);
|
|
const showLabelsRef = useRef(true);
|
|
const showDustRef = useRef(true);
|
|
const autoRotateRef = useRef(true);
|
|
|
|
// Keep refs in sync with state
|
|
useEffect(() => { selectedSystemRef.current = selectedSystem; }, [selectedSystem]);
|
|
useEffect(() => { hoveredSystemRef.current = hoveredSystem; }, [hoveredSystem]);
|
|
useEffect(() => { pathStartRef.current = pathStart; }, [pathStart]);
|
|
useEffect(() => { pathEndRef.current = pathEnd; }, [pathEnd]);
|
|
useEffect(() => { currentPathRef.current = currentPath; }, [currentPath]);
|
|
useEffect(() => { viewModeRef.current = viewMode; }, [viewMode]);
|
|
useEffect(() => { showGatesRef.current = showGates; }, [showGates]);
|
|
useEffect(() => { showLabelsRef.current = showLabels; }, [showLabels]);
|
|
useEffect(() => { showDustRef.current = showDust; }, [showDust]);
|
|
useEffect(() => { autoRotateRef.current = autoRotate; }, [autoRotate]);
|
|
|
|
// Generate galaxy data
|
|
useEffect(() => {
|
|
const g = generateGalaxy(seed, galaxyParams);
|
|
setGalaxy(g);
|
|
setSelectedSystem(null);
|
|
setHoveredSystem(null);
|
|
setPathStart(null);
|
|
setPathEnd(null);
|
|
setCurrentPath([]);
|
|
galaxyRef.current = g;
|
|
}, [seed, galaxyParams]);
|
|
|
|
// ── Three.js scene setup ──
|
|
useEffect(() => {
|
|
const TH = window.GDD.THREE;
|
|
const T = window.THREE;
|
|
if (!T || !containerRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
|
|
// Renderer
|
|
const renderer = TH.createRenderer(container, { clearColor: 0x020508 });
|
|
|
|
// Scene
|
|
const scene = new T.Scene();
|
|
scene.fog = new T.FogExp2(0x020508, 0.0004);
|
|
|
|
// Camera — elevated view of the disk
|
|
const camera = new T.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.5, 8000);
|
|
camera.position.set(0, 320, 420);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
// Orbit controller
|
|
const orbit = new TH.OrbitController(camera, renderer.domElement, new T.Vector3(0, 0, 0));
|
|
orbit.distance = 520;
|
|
orbit.minDistance = 15;
|
|
orbit.maxDistance = 2000;
|
|
orbit.panButton = 2;
|
|
orbit.rotateSpeed = 0.5;
|
|
|
|
// Lighting
|
|
TH.setupSpaceLighting(scene);
|
|
|
|
// Background star field — distant
|
|
const starField = TH.createStarField(5000, 4000);
|
|
scene.add(starField);
|
|
|
|
// ── Scene groups ──
|
|
const dustGroup = new T.Group();
|
|
scene.add(dustGroup);
|
|
|
|
const coreGlowGroup = new T.Group();
|
|
scene.add(coreGlowGroup);
|
|
|
|
const systemGroup = new T.Group();
|
|
scene.add(systemGroup);
|
|
|
|
const gateGroup = new T.Group();
|
|
scene.add(gateGroup);
|
|
|
|
const labelGroup = new T.Group();
|
|
scene.add(labelGroup);
|
|
|
|
const highlightGroup = new T.Group();
|
|
scene.add(highlightGroup);
|
|
|
|
// ── Raycasting ──
|
|
const raycaster = new T.Raycaster();
|
|
const mouse = new T.Vector2();
|
|
const clickMouse = new T.Vector2();
|
|
|
|
let systemMeshes = [];
|
|
// Shared geometries — created once, reused across all systems
|
|
const sharedCoreGeo = new T.SphereGeometry(2.2, 10, 10);
|
|
const sharedInnerGeo = new T.SphereGeometry(1.0, 6, 6);
|
|
// Shared glow texture — one canvas, reused for all system glow sprites
|
|
const glowCanvas = document.createElement('canvas');
|
|
glowCanvas.width = 32; glowCanvas.height = 32;
|
|
const gCtx = glowCanvas.getContext('2d');
|
|
const gGrad = gCtx.createRadialGradient(16, 16, 0, 16, 16, 16);
|
|
gGrad.addColorStop(0, 'rgba(255,255,255,0.8)');
|
|
gGrad.addColorStop(0.2, 'rgba(255,255,255,0.4)');
|
|
gGrad.addColorStop(0.5, 'rgba(255,255,255,0.1)');
|
|
gGrad.addColorStop(1, 'rgba(255,255,255,0)');
|
|
gCtx.fillStyle = gGrad;
|
|
gCtx.fillRect(0, 0, 32, 32);
|
|
const sharedGlowTexture = new T.CanvasTexture(glowCanvas);
|
|
|
|
let currentSysMap = null; // cached lookup for O(1) system access
|
|
|
|
// ── Build the 3D scene ──
|
|
// Set of shared resources that must NOT be disposed during scene rebuild
|
|
const sharedResources = new Set([sharedCoreGeo, sharedInnerGeo, sharedGlowTexture, sharedRingGeo, sharedHoverRingGeo]);
|
|
|
|
function disposeGroup(group, disposeSharedGeo) {
|
|
while (group.children.length > 0) {
|
|
const child = group.children[0];
|
|
group.remove(child);
|
|
// Recursively dispose children (e.g., Group → Mesh/Sprite)
|
|
if (child.children && child.children.length > 0) {
|
|
disposeGroup(child, false);
|
|
}
|
|
// Only dispose non-shared geometry and materials
|
|
if (child.geometry && (disposeSharedGeo || !sharedResources.has(child.geometry))) {
|
|
child.geometry.dispose();
|
|
}
|
|
if (child.material) {
|
|
// Only dispose textures that aren't shared
|
|
if (child.material.map && !sharedResources.has(child.material.map)) {
|
|
child.material.map.dispose();
|
|
}
|
|
child.material.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildScene(galaxy) {
|
|
// Dispose scene groups — pass true only for groups that own their own geometry
|
|
disposeGroup(systemGroup, false); // uses shared geometry
|
|
disposeGroup(gateGroup, true); // owns its geometry
|
|
disposeGroup(labelGroup, true); // owns its canvas textures
|
|
disposeGroup(highlightGroup, false); // uses shared geometry/textures
|
|
disposeGroup(dustGroup, true); // owns its geometry
|
|
disposeGroup(coreGlowGroup, true); // owns its canvas textures
|
|
|
|
systemMeshes = [];
|
|
currentSysMap = galaxy.sysMap;
|
|
if (!galaxy) return;
|
|
|
|
// ── Galaxy dust — use pre-built typed arrays directly (no copy) ──
|
|
const dustGeo = new T.BufferGeometry();
|
|
dustGeo.setAttribute('position', new T.BufferAttribute(galaxy.dustPositions, 3));
|
|
dustGeo.setAttribute('color', new T.BufferAttribute(galaxy.dustColors, 3));
|
|
const dustMat = new T.PointsMaterial({
|
|
size: 1.8,
|
|
vertexColors: true,
|
|
transparent: true,
|
|
opacity: 0.55,
|
|
sizeAttenuation: true,
|
|
blending: T.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
dustGroup.add(new T.Points(dustGeo, dustMat));
|
|
|
|
// ── Central core glow ──
|
|
// Large outer glow
|
|
const outerGlow = TH.createGlowSprite(0xfff8e0, 120);
|
|
outerGlow.position.set(0, 0, 0);
|
|
coreGlowGroup.add(outerGlow);
|
|
|
|
// Medium warm glow
|
|
const midGlow = TH.createGlowSprite(0xffcc66, 60);
|
|
midGlow.position.set(0, 0, 0);
|
|
coreGlowGroup.add(midGlow);
|
|
|
|
// Bright inner core
|
|
const innerGlow = TH.createGlowSprite(0xffffff, 30);
|
|
innerGlow.position.set(0, 0, 0);
|
|
coreGlowGroup.add(innerGlow);
|
|
|
|
// ── Arm nebula sprites ──
|
|
const armColorDefs = [
|
|
{ color: 0x38bdf8, r: 0x38, g: 0xb5, b: 0xf8 },
|
|
{ color: 0xef4444, r: 0xef, g: 0x44, b: 0x44 },
|
|
{ color: 0xf0a030, r: 0xf0, g: 0xa0, b: 0x30 },
|
|
{ color: 0xa855f7, r: 0xa8, g: 0x55, b: 0xf7 },
|
|
];
|
|
const { armCount, galaxySize, rotationStr } = galaxy.params;
|
|
for (let ai = 0; ai < armCount; ai++) {
|
|
const ac = armColorDefs[ai % armColorDefs.length];
|
|
// Place nebula sprites at 3 distances along each arm
|
|
for (let di = 0; di < 3; di++) {
|
|
const df = 0.25 + di * 0.25;
|
|
const dist = df * galaxySize;
|
|
const baseAngle = (2 * Math.PI / armCount) * ai;
|
|
const bendAngle = df * rotationStr;
|
|
const angle = baseAngle + bendAngle;
|
|
const x = dist * Math.cos(angle);
|
|
const z = dist * Math.sin(angle);
|
|
const y = galaxy.params.heightMag * Math.sin(dist * galaxy.params.heightFreq) * (1 - df * 0.3);
|
|
|
|
const nebula = TH.createGlowSprite(ac.color, 40 + di * 15);
|
|
nebula.position.set(x, y, z);
|
|
nebula.material.opacity = 0.04;
|
|
coreGlowGroup.add(nebula);
|
|
}
|
|
}
|
|
|
|
// ── System spheres (shared geometry + per-system materials) ──
|
|
// Each system gets its own material so viewMode color changes don't corrupt other systems
|
|
galaxy.systems.forEach(sys => {
|
|
const group = new T.Group();
|
|
group.position.set(sys.x, sys.y, sys.z);
|
|
group.userData = { systemId: sys.id };
|
|
|
|
// Core sphere — clickable, shared geometry but own material
|
|
const secHex = secColor(sys.security);
|
|
const coreMat = new T.MeshBasicMaterial({ color: secHex });
|
|
const core = new T.Mesh(sharedCoreGeo, coreMat);
|
|
core.userData = { systemId: sys.id, isSystem: true };
|
|
group.add(core);
|
|
|
|
// Glow sprite — reuse shared canvas texture, own material
|
|
const glowMat = new T.SpriteMaterial({
|
|
map: sharedGlowTexture,
|
|
color: secHex,
|
|
transparent: true,
|
|
blending: T.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
const glow = new T.Sprite(glowMat);
|
|
glow.scale.setScalar(10);
|
|
group.add(glow);
|
|
|
|
// Star color inner dot — shared geometry, own material
|
|
const innerColor = new T.Color(sys.starColor).getHex();
|
|
const innerMat = new T.MeshBasicMaterial({ color: innerColor });
|
|
const inner = new T.Mesh(sharedInnerGeo, innerMat);
|
|
group.add(inner);
|
|
|
|
systemGroup.add(group);
|
|
systemMeshes.push(core);
|
|
|
|
// Label
|
|
const label = TH.createLabel(sys.name, secColorCSS(sys.security), 16);
|
|
label.position.set(sys.x, sys.y + 6, sys.z);
|
|
label.userData = { systemId: sys.id, isLabel: true };
|
|
labelGroup.add(label);
|
|
});
|
|
|
|
// ── Stargate lines (use sysMap for O(1) lookup) ──
|
|
galaxy.stargates.forEach(gate => {
|
|
const sA = currentSysMap.get(gate.from);
|
|
const sB = currentSysMap.get(gate.to);
|
|
if (!sA || !sB) return;
|
|
|
|
let color, opacity;
|
|
if (gate.type === 'mst') { color = 0x0e1a2a; opacity = 0.4; }
|
|
else if (gate.type === 'intra') { color = 0x0f1e30; opacity = 0.35; }
|
|
else if (gate.type === 'arm-link') { color = 0x152535; opacity = 0.3; }
|
|
else if (gate.type === 'choke') { color = 0xf0a030; opacity = 0.35; }
|
|
else if (gate.type === 'hub') { color = 0x22c55e; opacity = 0.25; }
|
|
else { color = 0x101820; opacity = 0.2; }
|
|
|
|
const line = TH.createConnectionLine(
|
|
{ x: sA.x, y: sA.y, z: sA.z },
|
|
{ x: sB.x, y: sB.y, z: sB.z },
|
|
color, opacity
|
|
);
|
|
line.userData = { gateFrom: gate.from, gateTo: gate.to, gateType: gate.type };
|
|
gateGroup.add(line);
|
|
});
|
|
}
|
|
|
|
// ── Update highlights (reuses geometry, uses sysMap for O(1)) ──
|
|
const sharedRingGeo = new T.RingGeometry(5, 6, 24);
|
|
sharedRingGeo.rotateX(-Math.PI / 2);
|
|
const sharedHoverRingGeo = new T.RingGeometry(4, 4.8, 24);
|
|
sharedHoverRingGeo.rotateX(-Math.PI / 2);
|
|
|
|
function updateHighlights() {
|
|
disposeGroup(highlightGroup, false); // uses shared geometry/textures
|
|
const g = galaxyRef.current;
|
|
if (!g) return;
|
|
const sm = currentSysMap;
|
|
if (!sm) return;
|
|
|
|
const selId = selectedSystemRef.current?.id;
|
|
const hovId = hoveredSystemRef.current?.id;
|
|
const pStart = pathStartRef.current;
|
|
const pEnd = pathEndRef.current;
|
|
const path = currentPathRef.current;
|
|
|
|
// Selection ring — shared geometry
|
|
if (selId) {
|
|
const sys = sm.get(selId);
|
|
if (sys) {
|
|
const ringMat = new T.MeshBasicMaterial({
|
|
color: 0x22d3ee, transparent: true, opacity: 0.7, side: T.DoubleSide, depthWrite: false,
|
|
});
|
|
const ring = new T.Mesh(sharedRingGeo, ringMat);
|
|
ring.position.set(sys.x, sys.y, sys.z);
|
|
highlightGroup.add(ring);
|
|
}
|
|
}
|
|
|
|
// Hover ring
|
|
if (hovId && hovId !== selId) {
|
|
const sys = sm.get(hovId);
|
|
if (sys) {
|
|
const ringMat = new T.MeshBasicMaterial({
|
|
color: 0xf1f5f9, transparent: true, opacity: 0.5, side: T.DoubleSide, depthWrite: false,
|
|
});
|
|
const ring = new T.Mesh(sharedHoverRingGeo, ringMat);
|
|
ring.position.set(sys.x, sys.y, sys.z);
|
|
highlightGroup.add(ring);
|
|
}
|
|
}
|
|
|
|
// Path markers — reuse shared glow texture
|
|
if (pStart) {
|
|
const sys = sm.get(pStart);
|
|
if (sys) {
|
|
const mat = new T.SpriteMaterial({ map: sharedGlowTexture, color: 0x22c55e, transparent: true, blending: T.AdditiveBlending, depthWrite: false });
|
|
const marker = new T.Sprite(mat);
|
|
marker.scale.setScalar(20);
|
|
marker.position.set(sys.x, sys.y + 1, sys.z);
|
|
highlightGroup.add(marker);
|
|
}
|
|
}
|
|
if (pEnd) {
|
|
const sys = sm.get(pEnd);
|
|
if (sys) {
|
|
const mat = new T.SpriteMaterial({ map: sharedGlowTexture, color: 0xef4444, transparent: true, blending: T.AdditiveBlending, depthWrite: false });
|
|
const marker = new T.Sprite(mat);
|
|
marker.scale.setScalar(20);
|
|
marker.position.set(sys.x, sys.y + 1, sys.z);
|
|
highlightGroup.add(marker);
|
|
}
|
|
}
|
|
|
|
// Route line
|
|
if (path.length > 1) {
|
|
const points = path.map(id => {
|
|
const sys = sm.get(id);
|
|
return sys ? new T.Vector3(sys.x, sys.y + 2, sys.z) : new T.Vector3();
|
|
});
|
|
const routeLine = TH.createRouteLine(points.map(p => ({ x: p.x, y: p.y, z: p.z })), 0x22d3ee);
|
|
highlightGroup.add(routeLine);
|
|
}
|
|
}
|
|
|
|
// ── Update system colors based on viewMode (uses sysMap for O(1)) ──
|
|
function updateSystemColors() {
|
|
const g = galaxyRef.current;
|
|
if (!g) return;
|
|
const mode = viewModeRef.current;
|
|
const sm = currentSysMap;
|
|
|
|
systemGroup.children.forEach(group => {
|
|
const sysId = group.userData.systemId;
|
|
const sys = sm ? sm.get(sysId) : g.systems.find(s => s.id === sysId);
|
|
if (!sys) return;
|
|
|
|
const core = group.children.find(c => c.userData.isSystem);
|
|
if (!core) return;
|
|
|
|
let color;
|
|
if (mode === 'faction') color = factionColor(sys.faction);
|
|
else if (mode === 'gates') {
|
|
const gateCount = g.stargates.filter(gate => gate.from === sys.id || gate.to === sys.id).length;
|
|
color = gateCount >= 4 ? 0xef4444 : gateCount >= 3 ? 0xf0a030 : gateCount >= 2 ? 0x22d3ee : 0x94a3b8;
|
|
} else {
|
|
color = secColor(sys.security);
|
|
}
|
|
core.material.color.setHex(color);
|
|
});
|
|
}
|
|
|
|
// ── Mouse interaction ──
|
|
function getHoveredSystem(event) {
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const intersects = raycaster.intersectObjects(systemMeshes, false);
|
|
if (intersects.length > 0) {
|
|
const sysId = intersects[0].object.userData.systemId;
|
|
return currentSysMap ? currentSysMap.get(sysId) : (galaxyRef.current?.systems.find(s => s.id === sysId) || null);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
let mouseDownPos = { x: 0, y: 0 };
|
|
|
|
const onMouseDown = (e) => {
|
|
mouseDownPos = { x: e.clientX, y: e.clientY };
|
|
};
|
|
|
|
const onMouseMove = (e) => {
|
|
const hovered = getHoveredSystem(e);
|
|
setHoveredSystem(hovered);
|
|
renderer.domElement.style.cursor = hovered ? 'pointer' : 'default';
|
|
};
|
|
|
|
const onClick = (e) => {
|
|
if (e.button !== 0) return;
|
|
// Ignore if it was a drag
|
|
const dx = e.clientX - mouseDownPos.x;
|
|
const dy = e.clientY - mouseDownPos.y;
|
|
if (Math.sqrt(dx * dx + dy * dy) > 5) return;
|
|
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
clickMouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
clickMouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
raycaster.setFromCamera(clickMouse, camera);
|
|
const intersects = raycaster.intersectObjects(systemMeshes, false);
|
|
if (intersects.length > 0) {
|
|
const sysId = intersects[0].object.userData.systemId;
|
|
const sys = galaxyRef.current?.systems.find(s => s.id === sysId);
|
|
if (sys) {
|
|
setSelectedSystem(sys);
|
|
orbit.flyTo(sys.x, sys.y, sys.z, 60);
|
|
}
|
|
} else {
|
|
setSelectedSystem(null);
|
|
}
|
|
};
|
|
|
|
const onContextMenu = (e) => {
|
|
e.preventDefault();
|
|
// Ignore if it was a drag
|
|
const dx = e.clientX - mouseDownPos.x;
|
|
const dy = e.clientY - mouseDownPos.y;
|
|
if (Math.sqrt(dx * dx + dy * dy) > 5) return;
|
|
|
|
const hovered = getHoveredSystem(e);
|
|
if (!hovered || !galaxyRef.current) return;
|
|
|
|
const g = galaxyRef.current;
|
|
const ps = pathStartRef.current;
|
|
|
|
if (!ps) {
|
|
setPathStart(hovered.id);
|
|
setPathEnd(null);
|
|
setCurrentPath([]);
|
|
} else if (ps !== hovered.id) {
|
|
setPathEnd(hovered.id);
|
|
const path = g.findPath(ps, hovered.id);
|
|
setCurrentPath(path);
|
|
} else {
|
|
setPathStart(null);
|
|
setPathEnd(null);
|
|
setCurrentPath([]);
|
|
}
|
|
};
|
|
|
|
renderer.domElement.addEventListener('mousedown', onMouseDown);
|
|
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
|
renderer.domElement.addEventListener('click', onClick);
|
|
renderer.domElement.addEventListener('contextmenu', onContextMenu);
|
|
|
|
// ── Resize handling ──
|
|
const onResize = () => {
|
|
TH.handleResize(renderer, camera, container);
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// ── Animation loop ──
|
|
let animId;
|
|
let lastTime = performance.now();
|
|
let prevHighlightState = '';
|
|
|
|
function animate() {
|
|
animId = requestAnimationFrame(animate);
|
|
const now = performance.now();
|
|
const dt = (now - lastTime) / 1000;
|
|
lastTime = now;
|
|
|
|
// Auto-rotate (slow orbit)
|
|
if (autoRotateRef.current && !orbit.isDragging && !orbit._flyActive) {
|
|
orbit.theta += dt * 0.03;
|
|
}
|
|
|
|
orbit.update(dt);
|
|
|
|
// Only rebuild highlights when state actually changed
|
|
const stateKey = [
|
|
selectedSystemRef.current?.id,
|
|
hoveredSystemRef.current?.id,
|
|
pathStartRef.current,
|
|
pathEndRef.current,
|
|
currentPathRef.current.join(','),
|
|
].join('|');
|
|
if (stateKey !== prevHighlightState) {
|
|
prevHighlightState = stateKey;
|
|
updateHighlights();
|
|
}
|
|
|
|
// Toggle visibility
|
|
gateGroup.visible = showGatesRef.current;
|
|
labelGroup.visible = showLabelsRef.current;
|
|
dustGroup.visible = showDustRef.current;
|
|
|
|
// Subtle dust cloud rotation (very cheap — just rotates the group matrix)
|
|
if (dustGroup.visible && dustGroup.children.length > 0) {
|
|
dustGroup.rotation.y += dt * 0.002;
|
|
}
|
|
|
|
// Core glow pulse
|
|
const pulse = 1.0 + Math.sin(now * 0.001) * 0.08;
|
|
coreGlowGroup.children.forEach(sprite => {
|
|
if (sprite.isSprite && sprite.scale) {
|
|
const baseScale = sprite.userData.baseScale || sprite.scale.x;
|
|
sprite.userData.baseScale = baseScale;
|
|
sprite.scale.setScalar(baseScale * pulse);
|
|
}
|
|
});
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
sceneRef.current = { buildScene, updateSystemColors, renderer, orbit };
|
|
|
|
animate();
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animId);
|
|
window.removeEventListener('resize', onResize);
|
|
renderer.domElement.removeEventListener('mousedown', onMouseDown);
|
|
renderer.domElement.removeEventListener('mousemove', onMouseMove);
|
|
renderer.domElement.removeEventListener('click', onClick);
|
|
renderer.domElement.removeEventListener('contextmenu', onContextMenu);
|
|
orbit.dispose();
|
|
renderer.dispose();
|
|
if (container.contains(renderer.domElement)) {
|
|
container.removeChild(renderer.domElement);
|
|
}
|
|
sceneRef.current = null;
|
|
};
|
|
}, []); // Mount once
|
|
|
|
// ── Rebuild 3D scene when galaxy changes ──
|
|
useEffect(() => {
|
|
if (sceneRef.current && galaxy) {
|
|
sceneRef.current.buildScene(galaxy);
|
|
sceneRef.current.updateSystemColors();
|
|
}
|
|
}, [galaxy]);
|
|
|
|
// ── Update system colors when viewMode changes ──
|
|
useEffect(() => {
|
|
if (sceneRef.current) {
|
|
sceneRef.current.updateSystemColors();
|
|
}
|
|
}, [viewMode]);
|
|
|
|
// ── Seed handlers ──
|
|
const handleSeedSubmit = useCallback(() => {
|
|
const parsed = parseInt(inputSeed, 10);
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
setSeed(parsed);
|
|
} else {
|
|
let hash = 0;
|
|
for (let i = 0; i < inputSeed.length; i++) {
|
|
hash = ((hash << 5) - hash) + inputSeed.charCodeAt(i);
|
|
hash = hash >>> 0;
|
|
}
|
|
setSeed(hash || 1);
|
|
}
|
|
}, [inputSeed]);
|
|
|
|
const randomSeed = useCallback(() => {
|
|
const newSeed = Math.floor(Math.random() * 999999) + 1;
|
|
setSeed(newSeed);
|
|
setInputSeed(String(newSeed));
|
|
}, []);
|
|
|
|
// ── Selected system details ──
|
|
const selectedDetails = useMemo(() => {
|
|
if (!galaxy || !selectedSystem) return null;
|
|
const sys = selectedSystem;
|
|
const sysStations = galaxy.stations.filter(s => s.systemId === sys.id);
|
|
const sysBelts = galaxy.belts.filter(b => b.systemId === sys.id);
|
|
const sysGates = galaxy.stargates.filter(g => g.from === sys.id || g.to === sys.id);
|
|
const connectedSystems = sysGates.map(g => {
|
|
const targetId = g.from === sys.id ? g.to : g.from;
|
|
return galaxy.sysMap ? galaxy.sysMap.get(targetId) : galaxy.systems.find(s => s.id === targetId);
|
|
}).filter(Boolean);
|
|
return { ...sys, sysStations, sysBelts, sysGates, connectedSystems };
|
|
}, [galaxy, selectedSystem]);
|
|
|
|
// ── Stats ──
|
|
const stats = useMemo(() => {
|
|
if (!galaxy) return null;
|
|
const totalGates = galaxy.stargates.length;
|
|
const mstGates = galaxy.stargates.filter(g => g.type === 'mst').length;
|
|
const chokeGates = galaxy.stargates.filter(g => g.type === 'choke').length;
|
|
const hubGates = galaxy.stargates.filter(g => g.type === 'hub').length;
|
|
const avgGatesPerSys = (totalGates * 2 / galaxy.systems.length).toFixed(1);
|
|
const secDist = { high: 0, low: 0, null: 0, deep: 0 };
|
|
galaxy.systems.forEach(s => {
|
|
if (s.security >= 0.5) secDist.high++;
|
|
else if (s.security >= 0.1) secDist.low++;
|
|
else if (s.security >= -0.4) secDist.null++;
|
|
else secDist.deep++;
|
|
});
|
|
return {
|
|
systems: galaxy.systems.length, constellations: galaxy.constellations.length,
|
|
regions: galaxy.regions.length, gates: totalGates, mstGates, chokeGates, hubGates,
|
|
avgGatesPerSys, stations: galaxy.stations.length, belts: galaxy.belts.length,
|
|
agents: galaxy.agents.length, connected: galaxy.connected, secDist,
|
|
};
|
|
}, [galaxy]);
|
|
|
|
if (!galaxy) return <div style={{ color: 'var(--muted)', padding: '40px' }}>Generating galaxy…</div>;
|
|
|
|
return (
|
|
<div style={{ display: 'flex', height: '100vh', background: '#020508', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
{/* Three.js Container */}
|
|
<div ref={containerRef} style={{ flex: 1, position: 'relative', overflow: 'hidden' }} />
|
|
|
|
{/* HUD overlay - top left: seed + view mode */}
|
|
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10, display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
<div style={{ background: 'rgba(4,8,16,0.9)', border: '1px solid #1c2a3f', borderRadius: 6, padding: '6px 12px', display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<span style={{ color: '#5a6b82', fontSize: 10 }}>SEED</span>
|
|
<input
|
|
type="text" value={inputSeed}
|
|
onChange={e => setInputSeed(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSeedSubmit()}
|
|
style={{ width: 80, background: '#0a0f18', border: '1px solid #1c2a3f', borderRadius: 3, color: '#f0a030', fontSize: 12, padding: '2px 6px', fontFamily: 'inherit', outline: 'none' }}
|
|
/>
|
|
<button onClick={handleSeedSubmit} style={{ background: '#1c2a3f', border: 'none', color: '#94a3b8', fontSize: 10, padding: '3px 8px', borderRadius: 3, cursor: 'pointer' }}>GO</button>
|
|
<button onClick={randomSeed} style={{ background: '#1c2a3f', border: 'none', color: '#22d3ee', fontSize: 10, padding: '3px 8px', borderRadius: 3, cursor: 'pointer' }}>RNG</button>
|
|
</div>
|
|
<div style={{ background: 'rgba(4,8,16,0.9)', border: '1px solid #1c2a3f', borderRadius: 6, padding: '6px 12px', display: 'flex', gap: 6 }}>
|
|
{['security', 'faction', 'gates'].map(mode => (
|
|
<button key={mode} onClick={() => setViewMode(mode)}
|
|
style={{ background: viewMode === mode ? '#1c2a3f' : 'transparent', border: 'none', color: viewMode === mode ? '#f0a030' : '#5a6b82', fontSize: 10, padding: '3px 8px', borderRadius: 3, cursor: 'pointer', textTransform: 'uppercase' }}>
|
|
{mode}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Galaxy parameters overlay - below top bar */}
|
|
<div style={{ position: 'absolute', top: 56, left: 12, zIndex: 10, background: 'rgba(4,8,16,0.9)', border: '1px solid #1c2a3f', borderRadius: 6, padding: '8px 12px', display: 'flex', gap: 12, alignItems: 'center' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<span style={{ color: '#5a6b82', fontSize: 9, whiteSpace: 'nowrap' }}>ARMS</span>
|
|
<input type="range" min={2} max={8} step={1} value={galaxyParams.armCount}
|
|
onChange={e => setGalaxyParams(p => ({ ...p, armCount: parseInt(e.target.value) }))}
|
|
style={{ width: 50, accentColor: '#f0a030' }} />
|
|
<span style={{ color: '#f0a030', fontSize: 9, width: 12 }}>{galaxyParams.armCount}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<span style={{ color: '#5a6b82', fontSize: 9, whiteSpace: 'nowrap' }}>TWIST</span>
|
|
<input type="range" min={0} max={6} step={0.2} value={galaxyParams.rotationStrength}
|
|
onChange={e => setGalaxyParams(p => ({ ...p, rotationStrength: parseFloat(e.target.value) }))}
|
|
style={{ width: 50, accentColor: '#22d3ee' }} />
|
|
<span style={{ color: '#22d3ee', fontSize: 9, width: 20 }}>{galaxyParams.rotationStrength.toFixed(1)}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<span style={{ color: '#5a6b82', fontSize: 9, whiteSpace: 'nowrap' }}>SPREAD</span>
|
|
<input type="range" min={0.1} max={1.0} step={0.05} value={galaxyParams.armSpread}
|
|
onChange={e => setGalaxyParams(p => ({ ...p, armSpread: parseFloat(e.target.value) }))}
|
|
style={{ width: 50, accentColor: '#22c55e' }} />
|
|
<span style={{ color: '#22c55e', fontSize: 9, width: 20 }}>{galaxyParams.armSpread.toFixed(2)}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<span style={{ color: '#5a6b82', fontSize: 9, whiteSpace: 'nowrap' }}>DENSITY</span>
|
|
<input type="range" min={0.5} max={3.0} step={0.1} value={galaxyParams.gravity}
|
|
onChange={e => setGalaxyParams(p => ({ ...p, gravity: parseFloat(e.target.value) }))}
|
|
style={{ width: 50, accentColor: '#a855f7' }} />
|
|
<span style={{ color: '#a855f7', fontSize: 9, width: 20 }}>{galaxyParams.gravity.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls overlay - top right */}
|
|
<div style={{ position: 'absolute', top: 12, right: 360, zIndex: 10, background: 'rgba(4,8,16,0.9)', border: '1px solid #1c2a3f', borderRadius: 6, padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{[
|
|
{ label: 'Gates', val: showGates, set: setShowGates },
|
|
{ label: 'Labels', val: showLabels, set: setShowLabels },
|
|
{ label: 'Dust Cloud', val: showDust, set: setShowDust },
|
|
{ label: 'Auto-Rotate', val: autoRotate, set: setAutoRotate },
|
|
].map(ctrl => (
|
|
<label key={ctrl.label} style={{ display: 'flex', gap: 6, fontSize: 10, color: '#94a3b8', cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={ctrl.val} onChange={e => ctrl.set(e.target.checked)} style={{ accentColor: '#f0a030' }} />
|
|
{ctrl.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* Hint bar */}
|
|
<div style={{ position: 'absolute', bottom: 12, left: 12, right: 360, zIndex: 10, display: 'flex', gap: 16, justifyContent: 'center' }}>
|
|
<span style={{ fontSize: 9, color: '#3a4b62' }}>Click system to inspect</span>
|
|
<span style={{ fontSize: 9, color: '#3a4b62' }}>Right-click to route</span>
|
|
<span style={{ fontSize: 9, color: '#3a4b62' }}>Left-drag to orbit</span>
|
|
<span style={{ fontSize: 9, color: '#3a4b62' }}>Right-drag to pan</span>
|
|
<span style={{ fontSize: 9, color: '#3a4b62' }}>Scroll to zoom</span>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div style={{ width: 340, borderLeft: '1px solid #1c2a3f', background: 'rgba(4,8,16,0.95)', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
{/* Stats header */}
|
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid #1c2a3f' }}>
|
|
<div style={{ fontSize: 13, color: '#f0a030', fontWeight: 600, marginBottom: 8 }}>SPIRAL GALAXY</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
|
|
{stats && [
|
|
{ label: 'Systems', val: stats.systems, color: '#f0a030' },
|
|
{ label: 'Regions', val: stats.regions, color: '#38bdf8' },
|
|
{ label: 'Constellations', val: stats.constellations, color: '#22d3ee' },
|
|
{ label: 'Stargates', val: stats.gates, color: '#94a3b8' },
|
|
{ label: 'MST edges', val: stats.mstGates, color: '#22c55e' },
|
|
{ label: 'Choke gates', val: stats.chokeGates, color: '#ef4444' },
|
|
{ label: 'Hub gates', val: stats.hubGates, color: '#22c55e' },
|
|
{ label: 'Stations', val: stats.stations, color: '#22d3ee' },
|
|
{ label: 'Belts', val: stats.belts, color: '#92400e' },
|
|
{ label: 'NPC Agents', val: stats.agents, color: '#a855f7' },
|
|
{ label: 'Avg gates/sys', val: stats.avgGatesPerSys, color: '#94a3b8' },
|
|
{ label: 'Dust particles', val: galaxy.dustCount || 0, color: '#5a6b82' },
|
|
].map((s, i) => (
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10 }}>
|
|
<span style={{ color: '#4a5b72' }}>{s.label}</span>
|
|
<span style={{ color: s.color, fontWeight: 600 }}>{s.val}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{stats && (
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 4 }}>
|
|
<span style={{ fontSize: 9, padding: '2px 6px', borderRadius: 3, background: '#22c55e15', color: '#22c55e' }}>High: {stats.secDist.high}</span>
|
|
<span style={{ fontSize: 9, padding: '2px 6px', borderRadius: 3, background: '#f0a03015', color: '#f0a030' }}>Low: {stats.secDist.low}</span>
|
|
<span style={{ fontSize: 9, padding: '2px 6px', borderRadius: 3, background: '#ef444415', color: '#ef4444' }}>Null: {stats.secDist.null}</span>
|
|
<span style={{ fontSize: 9, padding: '2px 6px', borderRadius: 3, background: '#a855f715', color: '#a855f7' }}>Deep: {stats.secDist.deep}</span>
|
|
</div>
|
|
)}
|
|
{stats && (
|
|
<div style={{ marginTop: 6, fontSize: 10 }}>
|
|
<span style={{ color: stats.connected ? '#22c55e' : '#ef4444' }}>
|
|
{stats.connected ? '✓ Fully connected' : '✗ Disconnected!'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Region overview */}
|
|
<div style={{ padding: '10px 16px', borderBottom: '1px solid #1c2a3f' }}>
|
|
<div style={{ fontSize: 9, color: '#5a6b82', fontWeight: 600, marginBottom: 6 }}>REGIONS</div>
|
|
{galaxy.regions.map(r => (
|
|
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, marginBottom: 2 }}>
|
|
<span style={{ color: r.factionColor || '#94a3b8' }}>{r.name}</span>
|
|
<span style={{ color: '#5a6b82' }}>{r.systemIds.length} sys</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Selected system detail */}
|
|
<div style={{ flex: 1, padding: '12px 16px', overflowY: 'auto' }}>
|
|
{selectedDetails ? (
|
|
<>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: '#f1f5f9', marginBottom: 8 }}>{selectedDetails.name}</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3, fontSize: 10, marginBottom: 10 }}>
|
|
{[
|
|
{ label: 'Security', val: selectedDetails.security.toFixed(2), color: secColorCSS(selectedDetails.security) },
|
|
{ label: 'Star Type', val: selectedDetails.starType, color: selectedDetails.starColor },
|
|
{ label: 'Faction', val: selectedDetails.faction, color: '#94a3b8' },
|
|
{ label: 'Planets', val: selectedDetails.planetCount, color: '#94a3b8' },
|
|
{ label: 'Stations', val: selectedDetails.sysStations.length, color: '#22d3ee' },
|
|
{ label: 'Belts', val: selectedDetails.sysBelts.length, color: '#92400e' },
|
|
{ label: 'Gates', val: selectedDetails.sysGates.length, color: '#94a3b8' },
|
|
{ label: 'Region', val: selectedDetails.regionId, color: '#5a6b82' },
|
|
].map((row, i) => (
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<span style={{ color: '#4a5b72' }}>{row.label}</span>
|
|
<span style={{ color: row.color, fontWeight: 600 }}>{row.val}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{selectedDetails.sysStations.length > 0 && (
|
|
<div style={{ marginBottom: 10 }}>
|
|
<div style={{ fontSize: 9, color: '#22d3ee', fontWeight: 600, marginBottom: 4 }}>STATIONS</div>
|
|
{selectedDetails.sysStations.map((stn, i) => (
|
|
<div key={i} style={{ fontSize: 10, color: '#8899aa', marginBottom: 2 }}>
|
|
{stn.name} <span style={{ color: '#4a5b72' }}>[{stn.services.join(', ')}]</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedDetails.sysBelts.length > 0 && (
|
|
<div style={{ marginBottom: 10 }}>
|
|
<div style={{ fontSize: 9, color: '#92400e', fontWeight: 600, marginBottom: 4 }}>BELTS</div>
|
|
{selectedDetails.sysBelts.map((belt, i) => (
|
|
<div key={i} style={{ fontSize: 10, color: '#8899aa' }}>
|
|
Belt {i + 1}: <span style={{ color: '#f0a030' }}>{belt.oreType}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedDetails.connectedSystems.length > 0 && (
|
|
<div>
|
|
<div style={{ fontSize: 9, color: '#94a3b8', fontWeight: 600, marginBottom: 4 }}>CONNECTED SYSTEMS</div>
|
|
{selectedDetails.connectedSystems.map((cs, i) => (
|
|
<div key={i} style={{ fontSize: 10, color: '#8899aa', marginBottom: 2, cursor: 'pointer' }}
|
|
onClick={() => {
|
|
setSelectedSystem(cs);
|
|
if (sceneRef.current?.orbit) {
|
|
sceneRef.current.orbit.flyTo(cs.x, cs.y, cs.z, 60);
|
|
}
|
|
}}>
|
|
<span style={{ color: secColorCSS(cs.security) }}>
|
|
{cs.security.toFixed(2)}
|
|
</span>
|
|
{' '}{cs.name}
|
|
<span style={{ color: '#4a5b72' }}> ({cs.starType})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div style={{ color: '#3a4b62', fontSize: 11, textAlign: 'center', paddingTop: 40 }}>
|
|
Click a system to inspect<br /><br />
|
|
<span style={{ fontSize: 9 }}>Right-click two systems to<br />calculate shortest route</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Route info */}
|
|
{currentPath.length > 1 && (
|
|
<div style={{ padding: '10px 16px', borderTop: '1px solid #1c2a3f', background: '#0a0f18' }}>
|
|
<div style={{ fontSize: 10, color: '#22d3ee', fontWeight: 600, marginBottom: 4 }}>
|
|
ROUTE: {currentPath.length - 1} JUMP{currentPath.length - 1 !== 1 ? 'S' : ''}
|
|
</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
|
{currentPath.map((id, i) => {
|
|
const sys = galaxy.sysMap ? galaxy.sysMap.get(id) : galaxy.systems.find(s => s.id === id);
|
|
if (!sys) return null;
|
|
return (
|
|
<React.Fragment key={i}>
|
|
<span
|
|
style={{ fontSize: 9, color: secColorCSS(sys.security), cursor: 'pointer' }}
|
|
onClick={() => {
|
|
setSelectedSystem(sys);
|
|
if (sceneRef.current?.orbit) {
|
|
sceneRef.current.orbit.flyTo(sys.x, sys.y, sys.z, 60);
|
|
}
|
|
}}
|
|
>
|
|
{sys.name}
|
|
</span>
|
|
{i < currentPath.length - 1 && <span style={{ color: '#3a4b62', fontSize: 8 }}>→</span>}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gate legend */}
|
|
<div style={{ padding: '8px 16px', borderTop: '1px solid #1c2a3f' }}>
|
|
<div style={{ fontSize: 9, color: '#3a4b62', display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
<span>MST</span>
|
|
<span>Intra</span>
|
|
<span style={{ color: '#f0a03050' }}>Arm-link</span>
|
|
<span style={{ color: '#f0a03060' }}>Choke</span>
|
|
<span style={{ color: '#22c55e40' }}>Hub</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.GDD.GalaxyDemo = GalaxyDemo;
|