Files
Space-Game/archive/legacy-static/js/lib/three-helpers.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

837 lines
28 KiB
JavaScript

/**
* GDD Three.js Helpers — shared 3D utilities for all demos.
* Requires THREE to be loaded globally before this script.
* Loaded as a regular <script> (not Babel) to guarantee synchronous
* execution before the Babel-processed demo components.
*/
window.GDD = window.GDD || {};
window.GDD.THREE = {};
var TH = window.GDD.THREE;
var T = window.THREE;
if (!T) {
console.error('[GDD] THREE not loaded — 3D demos will not render.');
}
/* ── Renderer factory ── */
TH.createRenderer = function (container, opts = {}) {
const renderer = new T.WebGLRenderer({
antialias: true,
alpha: opts.alpha || false,
powerPreference: 'high-performance',
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setClearColor(opts.clearColor || 0x040810, 1);
renderer.toneMapping = T.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
container.appendChild(renderer.domElement);
renderer.domElement.style.display = 'block';
return renderer;
};
/* ── Resize helper ── */
TH.handleResize = function (renderer, camera, container) {
const w = container.clientWidth;
const h = container.clientHeight;
renderer.setSize(w, h);
if (camera.isPerspectiveCamera) {
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
};
/* ── Star field (Points) ── */
TH.createStarField = function (count = 2000, spread = 2000) {
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const sizes = new Float32Array(count);
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * spread;
positions[i * 3 + 1] = (Math.random() - 0.5) * spread;
positions[i * 3 + 2] = (Math.random() - 0.5) * spread;
const brightness = 0.4 + Math.random() * 0.6;
colors[i * 3] = brightness;
colors[i * 3 + 1] = brightness;
colors[i * 3 + 2] = brightness + Math.random() * 0.15;
sizes[i] = 0.5 + Math.random() * 1.5;
}
const geo = new T.BufferGeometry();
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
geo.setAttribute('color', new T.BufferAttribute(colors, 3));
geo.setAttribute('size', new T.BufferAttribute(sizes, 1));
const mat = new T.PointsMaterial({
size: 1.5,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true,
});
return new T.Points(geo, mat);
};
/* ── Nebula background (soft sphere) ── */
TH.addNebula = function (scene, color = 0x22d3ee, pos = [0, 0, -500], scale = 600) {
const geo = new T.SphereGeometry(1, 16, 16);
const mat = new T.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.015,
side: T.BackSide,
});
const mesh = new T.Mesh(geo, mat);
mesh.position.set(...pos);
mesh.scale.setScalar(scale);
scene.add(mesh);
return mesh;
};
/* ── Ship mesh (wedge shape) ── */
TH.createShipMesh = function (color = 0xc8d6e5, emissive = 0xf0a030, size = 1) {
const s = size;
// Wedge-shaped ship using BufferGeometry
const vertices = new Float32Array([
// Top face
14*s, 0, 0, -8*s, 0, -6*s, -8*s, 0, 6*s,
// Bottom face
14*s, -2*s, 0, -8*s, -2*s, 6*s, -8*s, -2*s, -6*s,
// Front
14*s, 0, 0, 14*s, -2*s, 0, -8*s, -2*s, 6*s,
14*s, 0, 0, -8*s, -2*s, 6*s, -8*s, 0, 6*s,
// Front-right
14*s, 0, 0, -8*s, 0, -6*s, 14*s, -2*s, 0,
14*s, -2*s, 0, -8*s, 0, -6*s, -8*s, -2*s, -6*s,
// Back
-8*s, 0, -6*s, -8*s, 0, 6*s, -8*s, -2*s, 6*s,
-8*s, 0, -6*s, -8*s, -2*s, 6*s, -8*s, -2*s, -6*s,
// Left side
-8*s, 0, 6*s, -8*s, -2*s, 6*s, 14*s, -2*s, 0,
-8*s, 0, 6*s, 14*s, -2*s, 0, 14*s, 0, 0,
// Right side
14*s, 0, 0, 14*s, -2*s, 0, -8*s, -2*s, -6*s,
14*s, 0, 0, -8*s, -2*s, -6*s, -8*s, 0, -6*s,
]);
const geo = new T.BufferGeometry();
geo.setAttribute('position', new T.BufferAttribute(vertices, 3));
geo.computeVertexNormals();
const mat = new T.MeshStandardMaterial({
color,
emissive,
emissiveIntensity: 0.15,
metalness: 0.6,
roughness: 0.3,
flatShading: true,
});
const mesh = new T.Mesh(geo, mat);
mesh.castShadow = true;
return mesh;
};
/* ── Engine glow ── */
TH.createEngineGlow = function (color = 0x22d3ee, intensity = 2, distance = 15) {
const light = new T.PointLight(color, intensity, distance);
return light;
};
TH.createEngineTrail = function (color = 0x22d3ee, particleCount = 30) {
const positions = new Float32Array(particleCount * 3);
const geo = new T.BufferGeometry();
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
const mat = new T.PointsMaterial({
color,
size: 1.5,
transparent: true,
opacity: 0.5,
blending: T.AdditiveBlending,
depthWrite: false,
});
return new T.Points(geo, mat);
};
/* ── Grid plane ── */
TH.createGrid = function (size = 600, divisions = 30, color = 0x0d1520) {
const grid = new T.GridHelper(size, divisions, color, color);
grid.material.transparent = true;
grid.material.opacity = 0.25;
// GridHelper is XZ-plane by default — keep as is
return grid;
};
/* ── Glow sprite (for star systems, explosions) ── */
TH.createGlowSprite = function (color = 0xffffff, size = 5) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, `rgba(255,255,255,0.8)`);
gradient.addColorStop(0.2, `rgba(255,255,255,0.4)`);
gradient.addColorStop(0.5, `rgba(255,255,255,0.1)`);
gradient.addColorStop(1, `rgba(255,255,255,0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
const texture = new T.CanvasTexture(canvas);
const mat = new T.SpriteMaterial({
map: texture,
color,
transparent: true,
blending: T.AdditiveBlending,
depthWrite: false,
});
const sprite = new T.Sprite(mat);
sprite.scale.setScalar(size);
return sprite;
};
/* ── Text label (Sprite with canvas texture) ── */
TH.createLabel = function (text, color = '#d4dce8', fontSize = 24) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`;
const metrics = ctx.measureText(text);
const width = Math.ceil(metrics.width) + 16;
const height = fontSize + 12;
canvas.width = width;
canvas.height = height;
ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(text, width / 2, height / 2);
const texture = new T.CanvasTexture(canvas);
texture.minFilter = T.LinearFilter;
const mat = new T.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
});
const sprite = new T.Sprite(mat);
const aspect = width / height;
sprite.scale.set(aspect * 1.2, 1.2, 1);
return sprite;
};
/* ── Shield sphere ── */
TH.createShield = function (radius = 2.5, color = 0x22d3ee, opacity = 0.08) {
const geo = new T.SphereGeometry(radius, 24, 24);
const mat = new T.MeshBasicMaterial({
color,
transparent: true,
opacity,
side: T.DoubleSide,
depthWrite: false,
});
return new T.Mesh(geo, mat);
};
/* ── Projectile ── */
TH.createProjectile = function (type = 'bullet') {
let mesh;
if (type === 'beam') {
const geo = new T.CylinderGeometry(0.05, 0.05, 1, 4);
geo.rotateX(Math.PI / 2);
const mat = new T.MeshBasicMaterial({ color: 0xef4444, transparent: true, opacity: 0.9 });
mesh = new T.Mesh(geo, mat);
} else if (type === 'missile') {
const geo = new T.SphereGeometry(0.15, 6, 6);
const mat = new T.MeshBasicMaterial({ color: 0xf0a030 });
mesh = new T.Mesh(geo, mat);
} else if (type === 'pulse') {
const geo = new T.RingGeometry(0.5, 1, 32);
const mat = new T.MeshBasicMaterial({ color: 0xa78bfa, transparent: true, opacity: 0.7, side: T.DoubleSide });
mesh = new T.Mesh(geo, mat);
} else {
const geo = new T.SphereGeometry(0.1, 4, 4);
const mat = new T.MeshBasicMaterial({ color: 0xf0a030 });
mesh = new T.Mesh(geo, mat);
}
return mesh;
};
/* ── Impact flash ── */
TH.createImpact = function (color = 0xef4444, size = 2) {
return TH.createGlowSprite(color, size);
};
/* ── Asteroid mesh ── */
TH.createAsteroid = function (size = 1, color = 0x3d2a5c) {
const geo = new T.IcosahedronGeometry(size, 0);
// Perturb vertices for rocky look
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const z = pos.getZ(i);
const noise = 0.7 + Math.random() * 0.6;
pos.setXYZ(i, x * noise, y * noise, z * noise);
}
geo.computeVertexNormals();
const mat = new T.MeshStandardMaterial({
color,
emissive: 0xa78bfa,
emissiveIntensity: 0.05,
flatShading: true,
roughness: 0.8,
metalness: 0.2,
});
return new T.Mesh(geo, mat);
};
/* ── Station mesh ── */
TH.createStation = function (size = 2, color = 0x22d3ee) {
const group = new T.Group();
// Main body
const bodyGeo = new T.BoxGeometry(size * 2, size, size);
const mat = new T.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.1, metalness: 0.7, roughness: 0.3 });
const body = new T.Mesh(bodyGeo, mat);
group.add(body);
// Cross bar
const crossGeo = new T.BoxGeometry(size, size * 0.3, size * 2);
const cross = new T.Mesh(crossGeo, mat);
group.add(cross);
// Glow
const glow = TH.createGlowSprite(color, size * 3);
glow.position.z = -size;
group.add(glow);
return group;
};
/* ── Connection line (for star map) ── */
TH.createConnectionLine = function (from, to, color = 0x1c2a3f, opacity = 0.6) {
const points = [
new T.Vector3(from.x, from.y, from.z || 0),
new T.Vector3(to.x, to.y, to.z || 0),
];
const geo = new T.BufferGeometry().setFromPoints(points);
const mat = new T.LineBasicMaterial({ color, transparent: true, opacity });
return new T.Line(geo, mat);
};
/* ── Raycaster for mouse picking ── */
TH.raycast = function (mouse, camera, objects) {
const raycaster = new T.Raycaster();
raycaster.setFromCamera(mouse, camera);
return raycaster.intersectObjects(objects, true);
};
/* ── Orbit camera (simple, no OrbitControls dependency) ── */
TH.createOrbitCamera = function (target, distance = 50, angle = Math.PI / 4) {
const camera = new T.PerspectiveCamera(60, 1, 0.1, 5000);
camera.position.set(
target.x + Math.cos(angle) * distance,
target.y + Math.sin(angle) * distance * 0.5,
target.z + Math.sin(angle) * distance
);
camera.lookAt(target);
return camera;
};
/* ── Enhanced camera controller (orbit + pan + smooth fly-to) ── */
TH.OrbitController = function (camera, domElement, target = new T.Vector3()) {
this.camera = camera;
this.target = target.clone();
this.distance = camera.position.distanceTo(target);
this.theta = Math.atan2(camera.position.x - target.x, camera.position.z - target.z);
this.phi = Math.acos(Math.min(1, Math.max(-1, (camera.position.y - target.y) / Math.max(0.001, this.distance))));
// Configuration
this.damping = 0.92;
this.dampingFactor = 0.06;
this.rotateSpeed = 0.4;
this.zoomSpeed = 0.8;
this.panSpeed = 0.5;
this.minDistance = 5;
this.maxDistance = 500;
this.minPolarAngle = 0.1;
this.maxPolarAngle = Math.PI - 0.1;
this.enablePan = true;
this.panButton = 2; // right-click
// State
this.isDragging = false;
this.isPanning = false;
this.lastMouse = { x: 0, y: 0 };
this.velocity = { theta: 0, phi: 0 };
this.panVelocity = { x: 0, y: 0 };
// Fly-to animation state
this._flyActive = false;
this._flyStart = { theta: 0, phi: 0, dist: 0, tx: 0, ty: 0, tz: 0 };
this._flyEnd = { theta: 0, phi: 0, dist: 0, tx: 0, ty: 0, tz: 0 };
this._flyT = 0;
this._flyDuration = 1.0; // seconds
const self = this;
const _ctxMenuHandler = function(e) { e.preventDefault(); };
const onDown = (e) => {
// Cancel fly-to on user interaction
self._flyActive = false;
if (e.button === self.panButton && self.enablePan) {
self.isPanning = true;
} else if (e.button === 0) {
self.isDragging = true;
}
self.lastMouse = { x: e.clientX, y: e.clientY };
};
const onUp = () => {
self.isDragging = false;
self.isPanning = false;
};
const onMove = (e) => {
const dx = e.clientX - self.lastMouse.x;
const dy = e.clientY - self.lastMouse.y;
self.lastMouse = { x: e.clientX, y: e.clientY };
if (self.isDragging) {
self.velocity.theta -= dx * 0.005 * self.rotateSpeed;
self.velocity.phi += dy * 0.005 * self.rotateSpeed;
}
if (self.isPanning && self.enablePan) {
// Pan in the camera's local X/Y plane
const panOffset = new T.Vector3();
const right = new T.Vector3();
const up = new T.Vector3(0, 1, 0);
right.crossVectors(self.camera.getWorldDirection(new T.Vector3()), up).normalize();
const actualUp = new T.Vector3().crossVectors(right, self.camera.getWorldDirection(new T.Vector3())).normalize();
const factor = self.distance * 0.002 * self.panSpeed;
panOffset.addScaledVector(right, -dx * factor);
panOffset.addScaledVector(actualUp, dy * factor);
self.target.add(panOffset);
self.panVelocity.x = -dx * factor * 0.3;
self.panVelocity.y = dy * factor * 0.3;
}
};
const onWheel = (e) => {
self._flyActive = false;
const delta = e.deltaY * 0.05 * self.zoomSpeed;
// Exponential zoom so it feels smooth at all distances
self.distance *= (1 + delta / self.distance * 2);
self.distance = Math.max(self.minDistance, Math.min(self.maxDistance, self.distance));
};
// Register event listeners
domElement.addEventListener('mousedown', onDown);
domElement.addEventListener('mouseup', onUp);
domElement.addEventListener('mouseleave', onUp);
domElement.addEventListener('mousemove', onMove);
domElement.addEventListener('wheel', onWheel, { passive: false });
domElement.addEventListener('contextmenu', _ctxMenuHandler);
// Smooth fly-to a world position
this.flyTo = function (x, y, z, dist) {
this._flyStart = {
theta: this.theta,
phi: this.phi,
dist: this.distance,
tx: this.target.x,
ty: this.target.y,
tz: this.target.z,
};
const dx = x - this.target.x;
const dz = z - this.target.z;
const dy = y - this.target.y;
this._flyEnd = {
theta: Math.atan2(dx, dz),
phi: Math.PI / 3, // 60° — good overhead-ish angle
dist: dist || Math.min(60, this.distance * 0.6),
tx: x,
ty: y,
tz: z,
};
this._flyT = 0;
this._flyActive = true;
};
// Easing function (cubic ease-in-out)
const easeInOut = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
this.update = function (dt) {
// Fly-to animation
if (this._flyActive) {
this._flyT += (dt || 0.016) / this._flyDuration;
if (this._flyT >= 1) {
this._flyT = 1;
this._flyActive = false;
}
const t = easeInOut(this._flyT);
this.theta = this._flyStart.theta + (this._flyEnd.theta - this._flyStart.theta) * t;
this.phi = this._flyStart.phi + (this._flyEnd.phi - this._flyStart.phi) * t;
this.distance = this._flyStart.dist + (this._flyEnd.dist - this._flyStart.dist) * t;
this.target.x = this._flyStart.tx + (this._flyEnd.tx - this._flyStart.tx) * t;
this.target.y = this._flyStart.ty + (this._flyEnd.ty - this._flyStart.ty) * t;
this.target.z = this._flyStart.tz + (this._flyEnd.tz - this._flyStart.tz) * t;
} else {
// Damped rotation
if (!this.isDragging) {
this.velocity.theta *= this.damping;
this.velocity.phi *= this.damping;
}
this.theta += this.velocity.theta;
this.phi += this.velocity.phi;
// Damped pan
if (!this.isPanning) {
this.target.x += this.panVelocity.x;
this.target.y += this.panVelocity.y;
this.panVelocity.x *= this.damping;
this.panVelocity.y *= this.damping;
}
}
// Clamp polar angle
this.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, this.phi));
// Clamp distance
this.distance = Math.max(this.minDistance, Math.min(this.maxDistance, this.distance));
this.camera.position.set(
this.target.x + Math.sin(this.theta) * Math.sin(this.phi) * this.distance,
this.target.y + Math.cos(this.phi) * this.distance,
this.target.z + Math.cos(this.theta) * Math.sin(this.phi) * this.distance
);
this.camera.lookAt(this.target);
};
this.dispose = function () {
domElement.removeEventListener('mousedown', onDown);
domElement.removeEventListener('mouseup', onUp);
domElement.removeEventListener('mouseleave', onUp);
domElement.removeEventListener('mousemove', onMove);
domElement.removeEventListener('wheel', onWheel);
domElement.removeEventListener('contextmenu', _ctxMenuHandler);
};
};
/* ── Lighting setup for space scenes ── */
TH.setupSpaceLighting = function (scene) {
const ambient = new T.AmbientLight(0x223344, 0.4);
scene.add(ambient);
const dir = new T.DirectionalLight(0xffffff, 0.6);
dir.position.set(50, 100, 50);
scene.add(dir);
const fill = new T.DirectionalLight(0x22d3ee, 0.15);
fill.position.set(-50, -30, -50);
scene.add(fill);
};
/* ── Warp/dashed line (for routes) ── */
TH.createRouteLine = function (points, color = 0x22d3ee) {
const geo = new T.BufferGeometry().setFromPoints(
points.map(p => new T.Vector3(p.x, p.y, p.z || 0))
);
const mat = new T.LineDashedMaterial({
color,
dashSize: 3,
gapSize: 1.5,
transparent: true,
opacity: 0.6,
});
const line = new T.Line(geo, mat);
line.computeLineDistances();
return line;
};
/* ── Update label text (recreates canvas texture) ── */
TH.updateLabelText = function (sprite, text, color = '#d4dce8', fontSize = 24) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`;
const metrics = ctx.measureText(text);
const width = Math.ceil(metrics.width) + 16;
const height = fontSize + 12;
canvas.width = width;
canvas.height = height;
ctx.font = `${fontSize}px JetBrains Mono, ui-monospace, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.fillText(text, width / 2, height / 2);
if (sprite.material.map) sprite.material.map.dispose();
sprite.material.map = new T.CanvasTexture(canvas);
sprite.material.map.minFilter = T.LinearFilter;
const aspect = width / height;
sprite.scale.set(aspect * 1.2, 1.2, 1);
sprite.material.needsUpdate = true;
};
/* ── Star system sphere (for starmap) ── */
TH.createStarSystem = function (system, scale = 1) {
const group = new T.Group();
group.userData = system;
// Core sphere
const coreGeo = new T.SphereGeometry(1.5 * scale, 12, 12);
const coreMat = new T.MeshBasicMaterial({ color: new T.Color(system.color) });
const core = new T.Mesh(coreGeo, coreMat);
group.add(core);
// Glow
const glow = TH.createGlowSprite(new T.Color(system.color).getHex(), 6 * scale);
group.add(glow);
// Label
const label = TH.createLabel(system.name, '#d4dce8', 20);
label.position.y = 4 * scale;
group.add(label);
group.position.set(system.x * scale, system.y * scale, 0);
return group;
};
/* ── Explosion particles ── */
TH.createExplosion = function (position, color = 0xef4444, count = 20) {
const particles = [];
for (let i = 0; i < count; i++) {
const geo = new T.SphereGeometry(0.1, 4, 4);
const mat = new T.MeshBasicMaterial({ color, transparent: true, opacity: 1 });
const p = new T.Mesh(geo, mat);
p.position.copy(position);
p.userData.velocity = new T.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
);
p.userData.life = 1;
particles.push(p);
}
return particles;
};
/* ── Camera follow (smooth) ── */
TH.followTarget = function (camera, target, offset = { x: 0, y: 30, z: 30 }, smoothing = 0.05) {
const desired = new T.Vector3(
target.x + offset.x,
target.y + offset.y,
target.z + offset.z
);
camera.position.lerp(desired, smoothing);
camera.lookAt(target);
};
/* ── Lock brackets (wireframe targeting indicator) ── */
TH.createLockBrackets = function (size = 3, color = 0xf0a030) {
const group = new T.Group();
const mat = new T.LineBasicMaterial({ color });
const bracketLen = size * 0.3;
const corners = [
{ x: -size, y: size, z: size, dx: 1, dy: 0, dz: 0 }, // top-left-front
{ x: -size, y: size, z: size, dx: 0, dy: 0, dz: -1 },
{ x: size, y: size, z: size, dx: -1, dy: 0, dz: 0 },
{ x: size, y: size, z: size, dx: 0, dy: 0, dz: -1 },
{ x: -size, y: -size, z: size, dx: 1, dy: 0, dz: 0 },
{ x: -size, y: -size, z: size, dx: 0, dy: 1, dz: 0 },
{ x: size, y: -size, z: size, dx: -1, dy: 0, dz: 0 },
{ x: size, y: -size, z: size, dx: 0, dy: 1, dz: 0 },
{ x: -size, y: size, z: -size, dx: 1, dy: 0, dz: 0 },
{ x: -size, y: size, z: -size, dx: 0, dy: -1, dz: 0 },
{ x: size, y: size, z: -size, dx: -1, dy: 0, dz: 0 },
{ x: size, y: size, z: -size, dx: 0, dy: -1, dz: 0 },
{ x: -size, y: -size, z: -size, dx: 1, dy: 0, dz: 0 },
{ x: -size, y: -size, z: -size, dx: 0, dy: 1, dz: 0 },
{ x: size, y: -size, z: -size, dx: -1, dy: 0, dz: 0 },
{ x: size, y: -size, z: -size, dx: 0, dy: 1, dz: 0 },
];
corners.forEach(c => {
const points = [
new T.Vector3(c.x, c.y, c.z),
new T.Vector3(c.x + c.dx * bracketLen, c.y + c.dy * bracketLen, c.z + c.dz * bracketLen),
];
const geo = new T.BufferGeometry().setFromPoints(points);
const line = new T.Line(geo, mat);
group.add(line);
});
return group;
};
/* ═══════════════════════════════════════════
Orbital Physics System
═══════════════════════════════════════════ */
/* ── Orbital body — Keplerian motion ── */
TH.OrbitalBody = function (opts) {
this.mesh = opts.mesh || null;
this.parentPos = opts.parentPos ? opts.parentPos.clone() : new T.Vector3(0, 0, 0);
this.orbitRadius = opts.orbitRadius || 10;
this.eccentricity = Math.min(opts.eccentricity || 0, 0.95);
this.inclination = opts.inclination || 0;
this.phase = opts.phase != null ? opts.phase : Math.random() * Math.PI * 2;
this.period = Math.max(opts.period || 10, 0.1);
this.angularVelocity = (2 * Math.PI) / this.period;
this.children = [];
this._worldPos = new T.Vector3();
};
TH.OrbitalBody.prototype.update = function (dt) {
this.phase += this.angularVelocity * dt;
var theta = this.phase;
var e = this.eccentricity;
var a = this.orbitRadius;
var denom = 1 + e * Math.cos(theta);
if (Math.abs(denom) < 0.001) denom = 0.001;
var r = a * (1 - e * e) / denom;
var x = r * Math.cos(theta);
var z = r * Math.sin(theta);
var y = z * Math.sin(this.inclination);
var zf = z * Math.cos(this.inclination);
this._worldPos.set(
this.parentPos.x + x,
this.parentPos.y + y,
this.parentPos.z + zf
);
if (this.mesh) {
this.mesh.position.copy(this._worldPos);
if (this.mesh.rotation) this.mesh.rotation.y += dt * 0.5;
}
for (var i = 0; i < this.children.length; i++) {
this.children[i].parentPos.copy(this._worldPos);
this.children[i].update(dt);
}
};
TH.OrbitalBody.prototype.getWorldPosition = function () {
return this._worldPos.clone();
};
/* ── Planet mesh with optional atmosphere ── */
TH.createPlanetMesh = function (size, color, atmosphere) {
var group = new T.Group();
var geo = new T.SphereGeometry(size, 24, 24);
var mat = new T.MeshStandardMaterial({
color: new T.Color(color), roughness: 0.7, metalness: 0.15,
});
group.add(new T.Mesh(geo, mat));
if (atmosphere) {
var aGeo = new T.SphereGeometry(size * 1.18, 24, 24);
var aMat = new T.MeshBasicMaterial({
color: new T.Color(atmosphere), transparent: true, opacity: 0.12, side: T.BackSide,
});
group.add(new T.Mesh(aGeo, aMat));
}
return group;
};
/* ── Ring system (Saturn-like) ── */
TH.createRingSystem = function (innerR, outerR, color, opacity) {
var geo = new T.RingGeometry(innerR, outerR, 64);
geo.rotateX(-Math.PI / 2);
var mat = new T.MeshBasicMaterial({
color: new T.Color(color || '#d4a560'), transparent: true, opacity: opacity || 0.25, side: T.DoubleSide,
});
return new T.Mesh(geo, mat);
};
/* ── Orbit trail (elliptical ring) ── */
TH.createOrbitTrail = function (radius, eccentricity, inclination, color, opacity) {
var segments = 96;
var points = [];
var e = eccentricity || 0;
var inc = inclination || 0;
for (var i = 0; i <= segments; i++) {
var theta = (i / segments) * Math.PI * 2;
var d = 1 + e * Math.cos(theta);
if (Math.abs(d) < 0.001) d = 0.001;
var r = radius * (1 - e * e) / d;
var x = r * Math.cos(theta);
var z = r * Math.sin(theta);
points.push(new T.Vector3(x, z * Math.sin(inc), z * Math.cos(inc)));
}
var geo = new T.BufferGeometry().setFromPoints(points);
var mat = new T.LineBasicMaterial({
color: color || 0x1c3a5f, transparent: true, opacity: opacity || 0.2,
});
return new T.Line(geo, mat);
};
/* ── Asteroid belt (orbital particle ring) ── */
TH.createAsteroidBelt = function (innerRadius, outerRadius, count, opts) {
opts = opts || {};
var positions = new Float32Array(count * 3);
var colors = new Float32Array(count * 3);
var orbits = [];
for (var i = 0; i < count; i++) {
var radius = innerRadius + Math.random() * (outerRadius - innerRadius);
var angle = Math.random() * Math.PI * 2;
var inc = (Math.random() - 0.5) * (opts.maxInclination || 0.15);
var ecc = Math.random() * (opts.maxEccentricity || 0.08);
var baseSpeed = (2 * Math.PI) / (opts.basePeriod || 20);
var speed = baseSpeed * Math.pow(innerRadius / Math.max(radius, 0.1), 1.5);
orbits.push({ radius: radius, angle: angle, inc: inc, ecc: ecc, speed: speed });
positions[i * 3] = radius * Math.cos(angle);
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = radius * Math.sin(angle);
var b = 0.3 + Math.random() * 0.4;
colors[i * 3] = b + Math.random() * 0.1;
colors[i * 3 + 1] = b * 0.85;
colors[i * 3 + 2] = b * 0.7;
}
var geo = new T.BufferGeometry();
geo.setAttribute('position', new T.BufferAttribute(positions, 3));
geo.setAttribute('color', new T.BufferAttribute(colors, 3));
var mat = new T.PointsMaterial({
size: opts.size || 0.35, vertexColors: true, transparent: true, opacity: 0.6,
sizeAttenuation: true, blending: T.AdditiveBlending, depthWrite: false,
});
var pts = new T.Points(geo, mat);
pts.userData.orbits = orbits;
return pts;
};
/* ── Update asteroid belt positions ── */
TH.updateAsteroidBelt = function (belt, dt, parentPos) {
var orbits = belt.userData.orbits;
if (!orbits) return;
var pos = belt.geometry.attributes.position.array;
var px = parentPos ? parentPos.x : 0;
var py = parentPos ? parentPos.y : 0;
var pz = parentPos ? parentPos.z : 0;
for (var i = 0; i < orbits.length; i++) {
var o = orbits[i];
o.angle += o.speed * dt;
var d = 1 + o.ecc * Math.cos(o.angle);
if (Math.abs(d) < 0.001) d = 0.001;
var r = o.radius * (1 - o.ecc * o.ecc) / d;
var x = r * Math.cos(o.angle);
var z = r * Math.sin(o.angle);
pos[i * 3] = px + x;
pos[i * 3 + 1] = py + z * Math.sin(o.inc);
pos[i * 3 + 2] = pz + z * Math.cos(o.inc);
}
belt.geometry.attributes.position.needsUpdate = true;
};
/* ── Orbital station mesh ── */
TH.createOrbitalStation = function (size, color) {
var group = new T.Group();
var mat = new T.MeshStandardMaterial({
color: color || 0x22d3ee, emissive: color || 0x22d3ee,
emissiveIntensity: 0.12, metalness: 0.7, roughness: 0.3,
});
group.add(new T.Mesh(new T.BoxGeometry(size * 2, size, size), mat));
group.add(new T.Mesh(new T.BoxGeometry(size, size * 0.3, size * 2), mat));
var panelMat = new T.MeshStandardMaterial({ color: 0x1a3a5c, metalness: 0.8, roughness: 0.2 });
group.add(new T.Mesh(new T.BoxGeometry(size * 3, size * 0.04, size * 0.7), panelMat));
return group;
};