Files
Space-Game/archive/legacy-static/js/demos/galaxy.js
francy51 316a44661b Restructure into pnpm monorepo with game shell, docs, and SpacetimeDB backend
- 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
2026-05-31 17:56:56 -04:00

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;