- 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
837 lines
28 KiB
JavaScript
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;
|
|
};
|