Spaces:
Running
Running
Download modules/space_exploration/src/galaxy/galaxy_map_engine.js from CREATORJD/massfront-playtest: direct link, hf CLI and curl.
- Browser
- Download file 34.6 kB
-
https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/modules/space_exploration/src/galaxy/galaxy_map_engine.js
- Command line
-
hf download hf://spaces/CREATORJD/massfront-playtest/modules/space_exploration/src/galaxy/galaxy_map_engine.js
-
curl -L -o galaxy_map_engine.js https://huggingface.co/spaces/CREATORJD/massfront-playtest/resolve/main/modules/space_exploration/src/galaxy/galaxy_map_engine.js
34.6 kB
| /* -------------------------------------------------------------------------- | |
| MASSFRONT — 3D GALAXY MAP ENGINE | |
| Macro-scale star chart with system nodes, cluster nebulae, animated | |
| mass-relay hyperlanes, and an orbit/zoom camera. Drives the "GALAXY MAP" | |
| modal so the player can pick a destination and initiate a relay jump. | |
| Coordinate space: arbitrary "cluster units" — the same as GALAXY_LAYOUT. | |
| Camera lives at a few hundred units so all 4 systems fit in the frustum. | |
| -------------------------------------------------------------------------- */ | |
| import { SHOWCASE_LAYOUT, SHOWCASE_SYSTEMS } from '../systems/showcase_systems.js'; | |
| import { createSeededRandom } from '../core/seeded_random.js'; | |
| import { registerRuntimeRenderer } from '../core/gltf_runtime_loader.js'; | |
| const GALAXY_EXPOSURE = 1.12; | |
| const GALAXY_PORTRAIT_ASPECT = 0.78; | |
| export function clampProjectedLabelX(projectedX, viewportWidth, labelWidth, startClearance = 10, endClearance = 10) { | |
| const width = Math.max(0, Number(viewportWidth) || 0); | |
| const start = Math.min(width, Math.max(0, Number(startClearance) || 0)); | |
| const end = Math.min(width - start, Math.max(0, Number(endClearance) || 0)); | |
| const visibleWidth = width - start - end; | |
| const measuredWidth = Math.max(0, Number(labelWidth) || 0); | |
| if (measuredWidth >= visibleWidth) return start + visibleWidth * 0.5; | |
| const halfWidth = measuredWidth * 0.5; | |
| return Math.min(width - end - halfWidth, Math.max(start + halfWidth, projectedX)); | |
| } | |
| function appendExtrudedPolygon(vertices, indices, polygon, depth) { | |
| const base = vertices.length / 3; | |
| const count = polygon.length; | |
| for (const [x, y] of polygon) vertices.push(x, y, depth * 0.5); | |
| for (const [x, y] of polygon) vertices.push(x, y, -depth * 0.5); | |
| for (let i = 1; i < count - 1; i++) indices.push(base, base + i, base + i + 1); | |
| for (let i = 1; i < count - 1; i++) indices.push(base + count, base + count + i + 1, base + count + i); | |
| for (let i = 0; i < count; i++) { | |
| const next = (i + 1) % count; | |
| indices.push(base + i, base + count + i, base + count + next); | |
| indices.push(base + i, base + count + next, base + next); | |
| } | |
| } | |
| function radialPoint(angle, radius, tangent = 0) { | |
| const c = Math.cos(angle), s = Math.sin(angle); | |
| return [c * radius - s * tangent, s * radius + c * tangent]; | |
| } | |
| function createStellarPointMaterial(pointScale, size, opacity, maxSize, additive = false) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uPointScale: { value: pointScale }, | |
| uSize: { value: size }, | |
| uOpacity: { value: opacity }, | |
| uMaxSize: { value: maxSize } | |
| }, | |
| vertexShader: ` | |
| attribute vec3 color; | |
| varying vec3 vColor; | |
| uniform float uPointScale; | |
| uniform float uSize; | |
| uniform float uMaxSize; | |
| void main() { | |
| vColor = color; | |
| vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); | |
| gl_PointSize = clamp(uSize * uPointScale / max(1.0, -viewPosition.z), 0.65, uMaxSize); | |
| gl_Position = projectionMatrix * viewPosition; | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vColor; | |
| uniform float uOpacity; | |
| void main() { | |
| float radius = length(gl_PointCoord - vec2(0.5)); | |
| float core = 1.0 - smoothstep(0.08, 0.49, radius); | |
| float fringe = 1.0 - smoothstep(0.34, 0.5, radius); | |
| float alpha = (core * 0.78 + fringe * 0.22) * uOpacity; | |
| if (alpha < 0.01) discard; | |
| gl_FragColor = vec4(vColor * (0.88 + core * 0.32), alpha); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending | |
| }); | |
| } | |
| // These are modeled navigation beacons, not spheres with a torus marker. Each | |
| // theater has a separate silhouette assembled from authored extruded profiles. | |
| function createSystemBeaconGeometry(systemId) { | |
| const vertices = []; | |
| const indices = []; | |
| const polygon = (points, depth = 2.4) => appendExtrudedPolygon(vertices, indices, points, depth); | |
| if (systemId === 'aelos') { | |
| polygon(Array.from({ length: 9 }, (_, i) => radialPoint(i * Math.PI * 2 / 9, i % 2 ? 5.2 : 6.8)), 3.4); | |
| for (let arm = 0; arm < 3; arm++) { | |
| const a = arm * Math.PI * 2 / 3 - Math.PI / 2; | |
| polygon([ | |
| radialPoint(a, 5.6, -2.0), radialPoint(a, 13.0, -3.6), radialPoint(a, 18.8, -1.4), | |
| radialPoint(a, 21.0, 0), radialPoint(a, 18.8, 1.4), radialPoint(a, 13.0, 3.6), radialPoint(a, 5.6, 2.0) | |
| ], 2.1); | |
| polygon([ | |
| radialPoint(a + 0.22, 10.0, -0.7), radialPoint(a + 0.22, 16.2, -0.7), | |
| radialPoint(a + 0.22, 17.6, 0), radialPoint(a + 0.22, 16.2, 0.7), radialPoint(a + 0.22, 10.0, 0.7) | |
| ], 3.0); | |
| } | |
| } else if (systemId === 'veyra') { | |
| const arcs = [[-2.86, -1.78], [-1.36, -0.32], [0.08, 1.03], [1.48, 2.43]]; | |
| for (const [start, end] of arcs) { | |
| const points = []; | |
| const segments = 6; | |
| for (let i = 0; i <= segments; i++) points.push(radialPoint(start + (end - start) * i / segments, 18.8)); | |
| for (let i = segments; i >= 0; i--) points.push(radialPoint(start + (end - start) * i / segments, 14.5)); | |
| polygon(points, 2.0); | |
| } | |
| polygon([[-10.5, -1.4], [-3.0, -3.6], [11.5, 2.0], [5.2, 3.8]], 3.4); | |
| polygon([[-3.6, 10.8], [-1.5, 4.0], [4.4, -11.8], [1.0, -6.2]], 2.7); | |
| polygon([[-3.4, -3.0], [0, -5.2], [4.4, -1.2], [3.2, 3.8], [-1.8, 5.0], [-5.0, 1.4]], 4.1); | |
| } else { | |
| const core = [[-6.8, -3.8], [-2.4, -7.0], [3.8, -5.8], [7.2, -0.8], [4.4, 5.8], [-1.6, 7.5], [-7.5, 2.0]]; | |
| polygon(core, 4.0); | |
| const arms = [-1.72, -0.52, 0.62, 1.86, 2.82]; | |
| arms.forEach((a, i) => polygon([ | |
| radialPoint(a, 5.6, -1.7), radialPoint(a + (i % 2 ? -0.12 : 0.1), 14.0, -2.2), | |
| radialPoint(a + (i % 2 ? 0.08 : -0.16), 22.0, -0.7), radialPoint(a, 24.0, 0), | |
| radialPoint(a + (i % 2 ? -0.08 : 0.14), 21.5, 0.8), radialPoint(a, 12.8, 2.3), radialPoint(a, 5.6, 1.6) | |
| ], 1.9 + (i % 2) * 0.8)); | |
| polygon([[-13.8, -9.2], [-8.0, -10.7], [15.2, 8.4], [10.2, 10.8]], 2.8); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| geometry.computeVertexNormals(); | |
| geometry.computeBoundingSphere(); | |
| geometry.name = `${systemId}_AuthoredNavigationBeacon`; | |
| return geometry; | |
| } | |
| function createSelectionReticleGeometry() { | |
| const vertices = []; | |
| const indices = []; | |
| for (let i = 0; i < 4; i++) { | |
| const a = i * Math.PI * 0.5 + Math.PI * 0.25; | |
| appendExtrudedPolygon(vertices, indices, [ | |
| radialPoint(a, 23.0, -4.0), radialPoint(a, 28.0, -6.3), radialPoint(a, 31.0, -2.0), | |
| radialPoint(a, 29.0, 0), radialPoint(a, 31.0, 2.0), radialPoint(a, 28.0, 6.3), radialPoint(a, 23.0, 4.0), | |
| radialPoint(a, 25.5, 0) | |
| ], 1.2); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); | |
| geometry.setIndex(indices); | |
| geometry.computeVertexNormals(); | |
| geometry.name = 'AuthoredSystemSelectionReticle'; | |
| return geometry; | |
| } | |
| // A phase corridor is a real indexed surface with twin luminous rails and a | |
| // restrained central navigation field. The previous THREE.Line depended on a | |
| // UV attribute that setFromPoints() never created, so its endpoint fade was | |
| // zero along the entire route on hardware WebGL. | |
| function createPhaseCorridorGeometry(curve, name) { | |
| const segments = 80; | |
| const acrossSteps = [-1, -0.72, -0.46, 0, 0.46, 0.72, 1]; | |
| const positions = []; | |
| const routeU = []; | |
| const across = []; | |
| const indices = []; | |
| const up = new THREE.Vector3(0, 1, 0); | |
| const fallback = new THREE.Vector3(1, 0, 0); | |
| const side = new THREE.Vector3(); | |
| for (let i = 0; i <= segments; i++) { | |
| const u = i / segments; | |
| const point = curve.getPoint(u); | |
| const tangent = curve.getTangent(u).normalize(); | |
| side.crossVectors(tangent, up); | |
| if (side.lengthSq() < 0.0001) side.copy(fallback); | |
| else side.normalize(); | |
| for (const lane of acrossSteps) { | |
| positions.push( | |
| point.x + side.x * lane * 7.5, | |
| point.y + side.y * lane * 7.5, | |
| point.z + side.z * lane * 7.5 | |
| ); | |
| routeU.push(u); | |
| across.push(lane); | |
| } | |
| } | |
| for (let i = 0; i < segments; i++) { | |
| const row = i * acrossSteps.length; | |
| const next = row + acrossSteps.length; | |
| for (let lane = 0; lane < acrossSteps.length - 1; lane++) { | |
| indices.push(row + lane, next + lane, next + lane + 1); | |
| indices.push(row + lane, next + lane + 1, row + lane + 1); | |
| } | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geometry.setAttribute('aRouteU', new THREE.Float32BufferAttribute(routeU, 1)); | |
| geometry.setAttribute('aAcross', new THREE.Float32BufferAttribute(across, 1)); | |
| geometry.setIndex(indices); | |
| geometry.computeBoundingSphere(); | |
| geometry.name = `${name}_AuthoredPhaseCorridor`; | |
| return geometry; | |
| } | |
| export class GalaxyMapEngine { | |
| constructor(mountElement, labelsLayer, options = {}) { | |
| this.mount = mountElement; | |
| this.labelsLayer = labelsLayer; | |
| this.data = options.data || SHOWCASE_SYSTEMS; | |
| this.layout = options.layout || SHOWCASE_LAYOUT; | |
| this.onSystemClick = options.onSystemClick || null; | |
| this.onSystemHover = options.onSystemHover || null; | |
| this.currentSystemId = options.currentSystemId || 'aelos'; | |
| this.systemStatuses = options.systemStatuses || {}; | |
| this.seed = String(options.seed == null ? 'massfront-space-showcase' : options.seed); | |
| this.selectedId = this.currentSystemId; | |
| this._running = true; | |
| this._disposed = false; | |
| this._externalLoop = options.externalLoop === true; | |
| this.scene = new THREE.Scene(); | |
| this.scene.background = new THREE.Color(0x000208); | |
| // Light fog so distant systems fade out instead of clipping hard. | |
| this.scene.fog = new THREE.Fog(0x000208, 700, 1800); | |
| this._ownsRenderer = !options.renderer; | |
| this.renderer = options.renderer || new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); | |
| /* KTX2 needs a live context to probe which compressed formats this GPU | |
| actually supports; until a renderer registers, Basis textures stay off. */ | |
| registerRuntimeRenderer(this.renderer); | |
| if (this._ownsRenderer) { | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| this.renderer.setSize(this.mount.clientWidth || 400, this.mount.clientHeight || 500); | |
| this.renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| this.renderer.toneMappingExposure = GALAXY_EXPOSURE; | |
| this.renderer.outputEncoding = THREE.sRGBEncoding; | |
| this.mount.appendChild(this.renderer.domElement); | |
| this.renderer.domElement.style.cssText = 'width:100%;height:100%;display:block;'; | |
| } | |
| this.inputElement = options.inputElement || this.renderer.domElement; | |
| this.camera = new THREE.PerspectiveCamera(45, (this.mount.clientWidth || 400) / (this.mount.clientHeight || 500), 0.1, 50000); | |
| this.camera.position.set(0, 250, 600); | |
| this.camera.lookAt(0, 0, 0); | |
| // Low-emission modeled beacons need directional form lighting. This keeps | |
| // their extruded silhouettes and side walls legible instead of clipping | |
| // every faction color to white through emissive-only ACES tonemapping. | |
| this.scene.add(new THREE.HemisphereLight(0x2a6687, 0x03050a, 0.82)); | |
| const chartKey = new THREE.DirectionalLight(0xd7efff, 1.55); | |
| chartKey.position.set(240, 380, 520); | |
| this.scene.add(chartKey); | |
| const chartRim = new THREE.DirectionalLight(0xff865d, 0.58); | |
| chartRim.position.set(-420, -90, -260); | |
| this.scene.add(chartRim); | |
| // System node registry: id -> authored beacon record. | |
| this.systemNodes = {}; | |
| this.hyperlaneMaterials = []; | |
| this.routeMeshes = []; | |
| this.pointMaterials = []; | |
| this.shaderMats = []; | |
| this._buildStarfield(); | |
| // Cluster identity comes from authored system light, route color, and | |
| // labels. Full-screen haze sprites washed out the actual 3D star chart. | |
| this._buildHyperlanes(); | |
| this._buildSystemNodes(); | |
| this._buildActiveRing(); | |
| this._buildLabels(); | |
| // Camera state | |
| this._framingProfile = ''; | |
| this._camYaw = 0; | |
| this._camPitch = 0; | |
| this._camDist = 600; | |
| this._camTarget = new THREE.Vector3(); | |
| this._applyDefaultFraming(this.mount.clientWidth || 400, this.mount.clientHeight || 500, true); | |
| // Smoothing for orbit/zoom (so dragging feels weighty). | |
| this._camYawTarget = this._camYaw; | |
| this._camPitchTarget = this._camPitch; | |
| this._camDistTarget = this._camDist; | |
| this._isDragging = false; | |
| this._dragMode = 'orbit'; | |
| this._lastX = 0; | |
| this._lastY = 0; | |
| this._raycaster = new THREE.Raycaster(); | |
| this._pointer = new THREE.Vector2(); | |
| this._bindInput(); | |
| this._lastTime = performance.now(); | |
| this._loop = this._loop.bind(this); | |
| if (!this._externalLoop) this._raf = requestAnimationFrame(this._loop); | |
| } | |
| // ------------------------------------------------------------- | |
| // SCENE BUILDERS | |
| // ------------------------------------------------------------- | |
| _buildStarfield() { | |
| const random = createSeededRandom(this.seed, 'galaxy-starfield'); | |
| const count = 5200; | |
| const geo = new THREE.BufferGeometry(); | |
| const pos = new Float32Array(count * 3); | |
| const col = new Float32Array(count * 3); | |
| for (let i = 0; i < count; i++) { | |
| const u = random(), v = random(); | |
| const theta = u * 2 * Math.PI, phi = Math.acos(2 * v - 1); | |
| const r = 4000 + random() * 4000; | |
| const sinPhi = Math.sin(phi); | |
| pos[i * 3 + 0] = r * sinPhi * Math.cos(theta); | |
| pos[i * 3 + 1] = r * Math.cos(phi); | |
| pos[i * 3 + 2] = r * sinPhi * Math.sin(theta); | |
| const cp = random(); | |
| if (cp > 0.7) { col[i * 3] = 0.7; col[i * 3 + 1] = 0.88; col[i * 3 + 2] = 1.0; } | |
| else if (cp > 0.4) { col[i * 3] = 1.0; col[i * 3 + 1] = 0.85; col[i * 3 + 2] = 0.6; } | |
| else { col[i * 3] = 0.95; col[i * 3 + 1] = 0.95; col[i * 3 + 2] = 1.0; } | |
| } | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); | |
| const pointScale = Math.max(1, this.renderer.domElement.height) * 0.5; | |
| const mat = createStellarPointMaterial(pointScale, 5.2, 0.82, 2.6); | |
| this.pointMaterials.push(mat); | |
| this.starPoints = new THREE.Points(geo, mat); | |
| this.scene.add(this.starPoints); | |
| // A deterministic, volumetric stellar disc supplies scale and parallax. | |
| // It is supporting density only; the three authored system nodes remain | |
| // the interactive foreground. One Points draw keeps the phone budget flat. | |
| const dustCount = 18000; | |
| const dustGeo = new THREE.BufferGeometry(); | |
| const dustPos = new Float32Array(dustCount * 3); | |
| const dustCol = new Float32Array(dustCount * 3); | |
| for (let i = 0; i < dustCount; i++) { | |
| // Most navigational dust belongs near the active three-system volume; | |
| // a smaller outer population carries the arms into the distance. | |
| const inner = random() < 0.72; | |
| const radial = Math.pow(random(), inner ? 1.42 : 0.72) * (inner ? 980 : 1850); | |
| const arm = Math.floor(random() * 4); | |
| const angle = arm * Math.PI * 0.5 + radial * 0.0062 + (random() - 0.5) * (0.34 + radial / 2300); | |
| const bulge = radial < 420 ? 1 - radial / 420 : 0; | |
| const vertical = (random() + random() + random() - 1.5) * (36 + radial * 0.035 + bulge * 145); | |
| dustPos[i * 3] = Math.cos(angle) * radial; | |
| dustPos[i * 3 + 1] = vertical; | |
| dustPos[i * 3 + 2] = Math.sin(angle) * radial; | |
| const core = 1 - Math.min(1, radial / 1850); | |
| dustCol[i * 3] = 0.42 + core * 0.48; | |
| dustCol[i * 3 + 1] = 0.58 + core * 0.30; | |
| dustCol[i * 3 + 2] = 0.82 + core * 0.16; | |
| } | |
| dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3)); | |
| dustGeo.setAttribute('color', new THREE.BufferAttribute(dustCol, 3)); | |
| const dustMat = createStellarPointMaterial(pointScale, 1.26, 0.38, 3.2, true); | |
| this.pointMaterials.push(dustMat); | |
| this.galacticDisc = new THREE.Points(dustGeo, dustMat); | |
| this.galacticDisc.rotation.x = -0.13; | |
| this.scene.add(this.galacticDisc); | |
| } | |
| _buildHyperlanes() { | |
| // Each pair (A, B) is drawn once. Use sorted id-tuple as the dedup key. | |
| const drawn = new Set(); | |
| for (const [id, sys] of Object.entries(this.layout.systems)) { | |
| for (const otherId of sys.relays) { | |
| if (!this.layout.systems[otherId]) continue; | |
| const key = [id, otherId].sort().join('::'); | |
| if (drawn.has(key)) continue; | |
| drawn.add(key); | |
| this._createHyperlane(sys.coord, this.layout.systems[otherId].coord, id, otherId); | |
| } | |
| } | |
| } | |
| _createHyperlane(a, b, idA, idB) { | |
| // Quadratic Bézier with a small arch so the corridor doesn't cut through | |
| // the cluster nebula plane. | |
| const len = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); | |
| const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 }; | |
| mid.y += len * 0.04; | |
| const curve = new THREE.QuadraticBezierCurve3( | |
| new THREE.Vector3(a.x, a.y, a.z), | |
| new THREE.Vector3(mid.x, mid.y, mid.z), | |
| new THREE.Vector3(b.x, b.y, b.z) | |
| ); | |
| const routeName = [idA, idB].sort().map(id => id[0].toUpperCase() + id.slice(1)).join('_'); | |
| const geo = createPhaseCorridorGeometry(curve, routeName); | |
| const aColor = this.layout.clusters[this.data[idA]?.cluster]?.color || '#42d8ff'; | |
| const bColor = this.layout.clusters[this.data[idB]?.cluster]?.color || '#ffae45'; | |
| const routeColor = new THREE.Color(aColor).lerp(new THREE.Color(bColor), 0.36); | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uTime: { value: 0 }, | |
| uColor: { value: routeColor } | |
| }, | |
| vertexShader: ` | |
| attribute float aRouteU; | |
| attribute float aAcross; | |
| varying float vU; | |
| varying float vAcross; | |
| void main() { | |
| vU = aRouteU; | |
| vAcross = aAcross; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float uTime; | |
| uniform vec3 uColor; | |
| varying float vU; | |
| varying float vAcross; | |
| void main() { | |
| float edgeRail = pow(smoothstep(0.48, 1.0, abs(vAcross)), 2.4); | |
| float guide = pow(max(0.0, 1.0 - abs(vAcross)), 4.0) * 0.28; | |
| float packet = pow(sin(vU * 76.0 - uTime * 3.4) * 0.5 + 0.5, 11.0); | |
| float secondary = pow(sin(vU * 31.0 + uTime * 1.7) * 0.5 + 0.5, 15.0); | |
| float intensity = 0.08 + guide + edgeRail * 0.62 + packet * (0.28 + edgeRail * 0.62) + secondary * 0.18; | |
| float endFade = smoothstep(0.025, 0.105, vU) * smoothstep(0.025, 0.105, 1.0 - vU); | |
| intensity *= endFade; | |
| gl_FragColor = vec4(uColor * intensity * 2.3, min(0.86, intensity)); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: THREE.AdditiveBlending, | |
| side: THREE.DoubleSide | |
| }); | |
| const corridor = new THREE.Mesh(geo, mat); | |
| corridor.name = routeName; | |
| corridor.renderOrder = 1; | |
| this.scene.add(corridor); | |
| this.routeMeshes.push(corridor); | |
| this.hyperlaneMaterials.push(mat); | |
| this.shaderMats.push(mat); | |
| } | |
| _buildSystemNodes() { | |
| this.systemGroup = new THREE.Group(); | |
| this.systemGroup.name = 'AuthoredNavigationBeacons'; | |
| this.scene.add(this.systemGroup); | |
| for (const [id, sys] of Object.entries(this.layout.systems)) { | |
| const data = this.data[id]; | |
| if (!data) continue; | |
| const cluster = this.layout.clusters[data.cluster]; | |
| const color = cluster ? new THREE.Color(cluster.color) : new THREE.Color(0x00f0ff); | |
| const coreGeo = createSystemBeaconGeometry(id); | |
| const coreMat = new THREE.MeshStandardMaterial({ | |
| color: color.clone().multiplyScalar(0.32), | |
| emissive: color.clone().multiplyScalar(0.62), | |
| emissiveIntensity: id === 'karak' ? 1.05 : 0.82, | |
| roughness: 0.3, | |
| metalness: 0.78, | |
| transparent: true, | |
| opacity: 0.96 | |
| }); | |
| const core = new THREE.Mesh(coreGeo, coreMat); | |
| core.position.set(sys.coord.x, sys.coord.y, sys.coord.z); | |
| core.scale.setScalar(1.12); | |
| core.userData.systemId = id; | |
| core.userData.beaconPhase = id === 'aelos' ? 0 : (id === 'veyra' ? 2.1 : 4.3); | |
| core.renderOrder = 2; | |
| this.systemGroup.add(core); | |
| // A second authored profile provides physical depth and a readable | |
| // interior light layer without a billboard or canvas-generated halo. | |
| const accentGeo = createSystemBeaconGeometry(id); | |
| accentGeo.scale(0.62, 0.62, 1.45); | |
| const accentMat = new THREE.MeshBasicMaterial({ | |
| color: color.clone().lerp(new THREE.Color(0xffffff), 0.22), | |
| transparent: true, | |
| opacity: 0.34, | |
| depthWrite: false, | |
| blending: THREE.AdditiveBlending, | |
| side: THREE.DoubleSide | |
| }); | |
| const accent = new THREE.Mesh(accentGeo, accentMat); | |
| accent.position.copy(core.position); | |
| accent.scale.setScalar(1.12); | |
| accent.userData.systemId = id; | |
| accent.userData.pickProxy = true; | |
| accent.renderOrder = 3; | |
| this.systemGroup.add(accent); | |
| this.systemNodes[id] = { core, accent, color, data }; | |
| } | |
| } | |
| _buildActiveRing() { | |
| // Four modeled targeting brackets replace the old flat RingGeometry. | |
| const geo = createSelectionReticleGeometry(); | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms: { | |
| uTime: { value: 0 }, | |
| uColor: { value: new THREE.Color(0x00f0ff) } | |
| }, | |
| vertexShader: ` | |
| varying float vDepth; | |
| void main() { | |
| vDepth = position.z; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float uTime; | |
| uniform vec3 uColor; | |
| varying float vDepth; | |
| void main() { | |
| float t = uTime * 1.6; | |
| float pulse = sin(t * 2.0) * 0.5 + 0.5; | |
| float edge = 0.78 + abs(vDepth) * 0.16; | |
| float intensity = (0.45 + pulse * 0.55) * edge; | |
| gl_FragColor = vec4(uColor * intensity * 2.2, intensity); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: THREE.AdditiveBlending, | |
| side: THREE.DoubleSide | |
| }); | |
| this.activeRing = new THREE.Mesh(geo, mat); | |
| this.scene.add(this.activeRing); | |
| this.shaderMats.push(mat); | |
| this._updateActiveRingPosition(); | |
| } | |
| _updateActiveRingPosition() { | |
| if (!this.activeRing || !this.selectedId) return; | |
| const sys = this.layout.systems[this.selectedId]; | |
| if (sys) { | |
| this.activeRing.position.set(sys.coord.x, sys.coord.y - 1, sys.coord.z); | |
| // Face the modeled brackets toward the camera each frame. | |
| this.activeRing.lookAt(this.camera.position); | |
| } | |
| } | |
| _buildLabels() { | |
| this._labelElements = new Map(); | |
| if (!this.labelsLayer) return; | |
| this.labelsLayer.replaceChildren(); | |
| for (const [id, sys] of Object.entries(this.layout.systems)) { | |
| const data = this.data[id]; | |
| if (!data || !sys) continue; | |
| const label = document.createElement('div'); | |
| label.className = 'galaxy-system-label'; | |
| label.dataset.id = id; | |
| const title = document.createElement('b'); | |
| title.textContent = data.name.toUpperCase(); | |
| const cluster = document.createElement('span'); | |
| cluster.textContent = data.cluster; | |
| const status = document.createElement('em'); | |
| label.append(title, cluster, status); | |
| this.labelsLayer.appendChild(label); | |
| this._labelElements.set(id, { label, status }); | |
| } | |
| } | |
| _applyDefaultFraming(w, h, force = false) { | |
| const portrait = w / Math.max(1, h) < GALAXY_PORTRAIT_ASPECT; | |
| const profile = portrait ? 'portrait' : 'landscape'; | |
| if (!force && profile === this._framingProfile) return; | |
| this._framingProfile = profile; | |
| if (portrait) { | |
| this.camera.fov = 50; | |
| this._camYaw = 2.16; | |
| this._camPitch = 0.22; | |
| this._camDist = 1000; | |
| this._camTarget.set(0, -90, 20); | |
| } else { | |
| this.camera.fov = 45; | |
| this._camYaw = 1.05; | |
| this._camPitch = 0.25; | |
| this._camDist = 580; | |
| this._camTarget.set(60, 0, 20); | |
| } | |
| this._camYawTarget = this._camYaw; | |
| this._camPitchTarget = this._camPitch; | |
| this._camDistTarget = this._camDist; | |
| this.camera.updateProjectionMatrix(); | |
| } | |
| // ------------------------------------------------------------- | |
| // PUBLIC API | |
| // ------------------------------------------------------------- | |
| setSelected(id) { | |
| if (!this.layout.systems[id]) return; | |
| this.selectedId = id; | |
| this._updateActiveRingPosition(); | |
| } | |
| flyToSystem(id) { | |
| // Smoothly recenter the camera target on the system, then trigger the | |
| // entry point's transit flow via the click callback. | |
| const sys = this.layout.systems[id]; | |
| if (sys) { | |
| this._camTarget.set(sys.coord.x, sys.coord.y, sys.coord.z); | |
| } | |
| } | |
| setCurrentSystem(id) { | |
| this.currentSystemId = id; | |
| this.selectedId = id; | |
| this._updateActiveRingPosition(); | |
| } | |
| setSystemStatuses(statuses) { | |
| this.systemStatuses = statuses || {}; | |
| this._updateLabels(); | |
| } | |
| resize(w, h) { | |
| if (this._disposed) return; | |
| this.camera.aspect = w / h; | |
| this._applyDefaultFraming(w, h); | |
| this.camera.updateProjectionMatrix(); | |
| if (this._ownsRenderer) this.renderer.setSize(w, h); | |
| const pointScale = Math.max(1, this.renderer.domElement.height) * 0.5; | |
| for (const material of this.pointMaterials) material.uniforms.uPointScale.value = pointScale; | |
| } | |
| pause() { | |
| this._running = false; | |
| if (this._raf) cancelAnimationFrame(this._raf); | |
| this._raf = 0; | |
| } | |
| resume() { | |
| if (this._disposed || this._running) return; | |
| this._running = true; | |
| this._lastTime = performance.now(); | |
| if (!this._externalLoop) this._raf = requestAnimationFrame(this._loop); | |
| } | |
| dispose() { | |
| if (this._disposed) return; | |
| this.pause(); | |
| this._disposed = true; | |
| const dom = this.inputElement; | |
| dom.removeEventListener('pointerdown', this._onDown); | |
| dom.removeEventListener('wheel', this._onWheel); | |
| dom.removeEventListener('pointermove', this._onHover); | |
| window.removeEventListener('pointermove', this._onMove); | |
| window.removeEventListener('pointerup', this._onUp); | |
| window.removeEventListener('pointercancel', this._onUp); | |
| if (this._ownsRenderer && this.renderer.domElement.parentNode) { | |
| this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); | |
| } | |
| if (this._ownsRenderer) this.renderer.dispose(); | |
| this.scene.traverse(obj => { | |
| if (obj.geometry) obj.geometry.dispose(); | |
| if (obj.material) { | |
| if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose()); | |
| else obj.material.dispose(); | |
| } | |
| }); | |
| if (this.labelsLayer) this.labelsLayer.replaceChildren(); | |
| this._labelElements?.clear(); | |
| this.systemNodes = {}; | |
| this.routeMeshes.length = 0; | |
| this.pointMaterials.length = 0; | |
| this.shaderMats.length = 0; | |
| } | |
| // ------------------------------------------------------------- | |
| // INPUT | |
| // ------------------------------------------------------------- | |
| _bindInput() { | |
| const dom = this.inputElement; | |
| this._onDown = (ev) => { | |
| ev.preventDefault(); | |
| this._lastX = ev.clientX; | |
| this._lastY = ev.clientY; | |
| if (ev.button === 0) { | |
| // Left button: pick first | |
| const hit = this._pickSystem(ev); | |
| if (hit) { | |
| this.setSelected(hit); | |
| if (this.onSystemClick) this.onSystemClick(hit); | |
| this._isDragging = false; | |
| return; | |
| } | |
| this._dragMode = 'orbit'; | |
| } else if (ev.button === 1 || ev.button === 2) { | |
| // Middle/right: pan | |
| this._dragMode = 'pan'; | |
| } | |
| this._isDragging = true; | |
| }; | |
| dom.addEventListener('pointerdown', this._onDown); | |
| this._onMove = (ev) => { | |
| if (!this._isDragging) return; | |
| const dx = (ev.clientX - this._lastX) / 200; | |
| const dy = (ev.clientY - this._lastY) / 200; | |
| this._lastX = ev.clientX; | |
| this._lastY = ev.clientY; | |
| if (this._dragMode === 'orbit') { | |
| this._camYawTarget += dx; | |
| this._camPitchTarget = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, this._camPitchTarget + dy)); | |
| } else if (this._dragMode === 'pan') { | |
| const right = new THREE.Vector3(); | |
| const up = new THREE.Vector3(); | |
| this.camera.getWorldDirection(right); | |
| right.cross(this.camera.up).normalize(); | |
| up.copy(this.camera.up); | |
| const panSpeed = this._camDist * 0.0015; | |
| this._camTarget.addScaledVector(right, -dx * panSpeed); | |
| this._camTarget.addScaledVector(up, dy * panSpeed); | |
| } | |
| }; | |
| this._onUp = () => { this._isDragging = false; }; | |
| window.addEventListener('pointermove', this._onMove); | |
| window.addEventListener('pointerup', this._onUp); | |
| window.addEventListener('pointercancel', this._onUp); | |
| // Wheel zoom | |
| this._onWheel = (ev) => { | |
| ev.preventDefault(); | |
| const factor = ev.deltaY > 0 ? 1.12 : (1 / 1.12); | |
| this._camDistTarget = Math.max(150, Math.min(1800, this._camDistTarget * factor)); | |
| }; | |
| dom.addEventListener('wheel', this._onWheel, { passive: false }); | |
| // Hover for label highlighting | |
| this._onHover = (ev) => { | |
| if (this._isDragging) return; | |
| const id = this._pickSystem(ev); | |
| if (this.onSystemHover) this.onSystemHover(id); | |
| }; | |
| dom.addEventListener('pointermove', this._onHover); | |
| } | |
| _pickSystem(ev) { | |
| const rect = this.inputElement.getBoundingClientRect(); | |
| this._pointer.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; | |
| this._pointer.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; | |
| this._raycaster.setFromCamera(this._pointer, this.camera); | |
| const hits = this._raycaster.intersectObjects(this.systemGroup.children, true); | |
| if (hits.length > 0) { | |
| return hits[0].object.userData.systemId || null; | |
| } | |
| return null; | |
| } | |
| // ------------------------------------------------------------- | |
| // LABELS (HTML overlay positioned by projection) | |
| // ------------------------------------------------------------- | |
| _updateLabels() { | |
| if (!this.labelsLayer || !this._labelElements) return; | |
| const w = this.renderer.domElement.clientWidth; | |
| const h = this.renderer.domElement.clientHeight; | |
| const compact = w <= 640 || (w <= 900 && h > w); | |
| let startClearance = 10; | |
| let endClearance = 10; | |
| if (compact && window.visualViewport) { | |
| const layerRect = this.labelsLayer.getBoundingClientRect(); | |
| const visibleLeft = window.visualViewport.offsetLeft; | |
| const visibleRight = visibleLeft + window.visualViewport.width; | |
| startClearance += Math.max(0, visibleLeft - layerRect.left); | |
| endClearance += Math.max(0, layerRect.right - visibleRight); | |
| } | |
| for (const [id, sys] of Object.entries(this.layout.systems)) { | |
| const data = this.data[id]; | |
| if (!data) continue; | |
| const record = this._labelElements.get(id); | |
| if (!record) continue; | |
| const v = new THREE.Vector3(sys.coord.x, sys.coord.y, sys.coord.z); | |
| v.project(this.camera); | |
| const sx = (v.x * 0.5 + 0.5) * w; | |
| const sy = (-(v.y * 0.5) + 0.5) * h; | |
| const visible = v.z > -1 && v.z < 1 && sx > -80 && sx < w + 80 && sy > -80 && sy < h + 80; | |
| record.label.style.display = visible ? 'grid' : 'none'; | |
| if (!visible) continue; | |
| const isCurrent = id === this.currentSystemId; | |
| const isSelected = id === this.selectedId; | |
| const front = this.systemStatuses[id] || {}; | |
| record.label.className = `galaxy-system-label${isCurrent ? ' current' : ''}${isSelected ? ' selected' : ''}`; | |
| record.label.dataset.frontState = front.state || 'unknown'; | |
| record.status.textContent = [isCurrent ? 'CURRENT' : '', front.shortLabel || ''].filter(Boolean).join(' · '); | |
| // Projection still owns the beacon relationship. On compact viewports only, | |
| // move the translated label center far enough inward to keep its live text | |
| // inside the visible/safe-area gutter; the WebGL pick coordinate is untouched. | |
| const labelX = compact | |
| ? clampProjectedLabelX(sx, w, record.label.offsetWidth, startClearance, endClearance) | |
| : sx; | |
| record.label.style.left = `${labelX}px`; | |
| record.label.style.top = `${sy + 34}px`; | |
| } | |
| } | |
| // ------------------------------------------------------------- | |
| // CAMERA + RENDER LOOP | |
| // ------------------------------------------------------------- | |
| _updateCamera() { | |
| // Smooth toward targets | |
| this._camYaw += (this._camYawTarget - this._camYaw) * 0.18; | |
| this._camPitch += (this._camPitchTarget - this._camPitch) * 0.18; | |
| this._camDist += (this._camDistTarget - this._camDist) * 0.18; | |
| const sinY = Math.sin(this._camYaw), cosY = Math.cos(this._camYaw); | |
| const sinP = Math.sin(this._camPitch), cosP = Math.cos(this._camPitch); | |
| this.camera.position.set( | |
| this._camTarget.x + sinY * cosP * this._camDist, | |
| this._camTarget.y + sinP * this._camDist, | |
| this._camTarget.z + cosY * cosP * this._camDist | |
| ); | |
| this.camera.lookAt(this._camTarget); | |
| } | |
| _loop(now) { | |
| if (!this._running || this._disposed) return; | |
| this.renderFrame(now); | |
| if (this._running && !this._externalLoop) this._raf = requestAnimationFrame(this._loop); | |
| } | |
| renderFrame(now = performance.now()) { | |
| if (!this._running || this._disposed) return; | |
| this._lastTime = now; | |
| for (const m of this.shaderMats) { | |
| if (m.uniforms && m.uniforms.uTime) m.uniforms.uTime.value = now * 0.001; | |
| } | |
| for (const node of Object.values(this.systemNodes)) { | |
| const phase = node.core.userData.beaconPhase || 0; | |
| node.core.rotation.z = now * 0.00008 * (node.data.id === 'karak' ? -1 : 1) + phase; | |
| node.accent.rotation.z = -now * 0.00013 + phase * 0.5; | |
| node.accent.material.opacity = 0.23 + (Math.sin(now * 0.0018 + phase) * 0.5 + 0.5) * 0.17; | |
| } | |
| this._updateCamera(); | |
| this._updateActiveRingPosition(); | |
| if (this.activeRing) { | |
| const pulse = 1 + Math.sin(now * 0.0024) * 0.045; | |
| this.activeRing.scale.setScalar(pulse); | |
| this.activeRing.rotation.z += 0.0018; | |
| } | |
| this._updateLabels(); | |
| this.renderer.toneMappingExposure = GALAXY_EXPOSURE; | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |