← back to Dw War Room
src/scene/CommandTable.ts
271 lines
// ═══════════════════════════════════════════════
// DW War Room 3D — Command Table
// Central holographic table in the war room
// ═══════════════════════════════════════════════
import * as THREE from 'three';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface CommandTableHandle {
animate(elapsed: number): void;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Build a flat torus lying horizontal (rotated -90° on X). */
function horizontalTorus(
radius: number,
tube: number,
radialSeg: number,
tubularSeg: number,
color: number,
opacity: number,
additive = false,
): THREE.Mesh {
const geo = new THREE.TorusGeometry(radius, tube, radialSeg, tubularSeg);
const mat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
return mesh;
}
// ─── Main factory ────────────────────────────────────────────────────────────
export function createCommandTable(scene: THREE.Scene): CommandTableHandle {
const group = new THREE.Group();
group.name = 'command-table';
// ── 1. Table base — dark cylinder ────────────────────────────────────────
const baseGeo = new THREE.CylinderGeometry(6, 7, 0.3, 64);
const baseMat = new THREE.MeshPhongMaterial({
color: 0x0a0a1e,
emissive: 0x1a1a3e,
emissiveIntensity: 0.4,
transparent: true,
opacity: 0.92,
});
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.y = 0.15;
group.add(base);
// ── 2. Animated outer ring (purple) ──────────────────────────────────────
const outerRing = horizontalTorus(6.2, 0.08, 8, 96, 0x8b5cf6, 0.75, true);
outerRing.position.y = 0.35;
group.add(outerRing);
// ── 3. Secondary outer glow ring (same radius, wider tube, very faint) ───
const outerGlow = horizontalTorus(6.2, 0.22, 8, 64, 0x8b5cf6, 0.12, true);
outerGlow.position.y = 0.35;
group.add(outerGlow);
// ── 4. Inner ring (blue) ─────────────────────────────────────────────────
const innerRing = horizontalTorus(4, 0.05, 8, 64, 0x3b82f6, 0.5, true);
innerRing.position.y = 0.36;
group.add(innerRing);
// ── 5. Mid ring (blue, thin) ─────────────────────────────────────────────
const midRing = horizontalTorus(5.1, 0.035, 8, 64, 0x3b82f6, 0.3, true);
midRing.position.y = 0.36;
group.add(midRing);
// ── 6. Edge glow ring — bright emissive at table edge ────────────────────
const edgeGlowGeo = new THREE.TorusGeometry(6.85, 0.06, 8, 96);
const edgeGlowMat = new THREE.MeshStandardMaterial({
color: 0x8b5cf6,
emissive: 0x8b5cf6,
emissiveIntensity: 2.5,
transparent: true,
opacity: 0.55,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const edgeGlow = new THREE.Mesh(edgeGlowGeo, edgeGlowMat);
edgeGlow.rotation.x = -Math.PI / 2;
edgeGlow.position.y = 0.33;
group.add(edgeGlow);
// ── 7. Animated grid ─────────────────────────────────────────────────────
const gridHelper = new THREE.GridHelper(12, 20, 0x3b3366, 0x1a1a3e);
gridHelper.position.y = 0.32;
// GridHelper material is transparent by default — boost it slightly
const gridMats = Array.isArray(gridHelper.material)
? gridHelper.material
: [gridHelper.material];
gridMats.forEach((m) => {
(m as THREE.LineBasicMaterial).transparent = true;
(m as THREE.LineBasicMaterial).opacity = 0.55;
(m as THREE.LineBasicMaterial).blending = THREE.AdditiveBlending;
});
group.add(gridHelper);
// ── 8. Holographic projection cone ───────────────────────────────────────
// Open-ended cone pointing upward, wide base at table level, tip at top
const coneGeo = new THREE.ConeGeometry(5.5, 9, 64, 1, true);
const coneMat = new THREE.MeshBasicMaterial({
color: 0x8b5cf6,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const cone = new THREE.Mesh(coneGeo, coneMat);
// Base of cone sits at table surface (y=0.35), tip points up to y≈9.35
cone.position.y = 0.35 + 9 / 2; // y = 4.85
group.add(cone);
// Second cone pass — slightly smaller, more opaque for depth
const coneInnerGeo = new THREE.ConeGeometry(3.2, 7, 48, 1, true);
const coneInnerMat = new THREE.MeshBasicMaterial({
color: 0x3b82f6,
transparent: true,
opacity: 0.045,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const coneInner = new THREE.Mesh(coneInnerGeo, coneInnerMat);
coneInner.position.y = 0.35 + 7 / 2; // y = 3.85
group.add(coneInner);
// ── 9. Pulsing energy core — sphere at y=2 ───────────────────────────────
const coreGeo = new THREE.SphereGeometry(0.22, 32, 32);
const coreMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0x8b5cf6,
emissiveIntensity: 3.0,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.y = 2;
group.add(core);
// Core halo — larger diffuse sphere around the core
const haloGeo = new THREE.SphereGeometry(0.55, 24, 24);
const haloMat = new THREE.MeshBasicMaterial({
color: 0x8b5cf6,
transparent: true,
opacity: 0.12,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const halo = new THREE.Mesh(haloGeo, haloMat);
halo.position.y = 2;
group.add(halo);
// ── 10. Data particle column ─────────────────────────────────────────────
const PARTICLE_COUNT = 200;
const particlePositions = new Float32Array(PARTICLE_COUNT * 3);
// Random speeds per particle (stored separately; not in geometry)
const particleSpeeds = new Float32Array(PARTICLE_COUNT);
const particleAngles = new Float32Array(PARTICLE_COUNT);
const particleRadii = new Float32Array(PARTICLE_COUNT);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * 4.5 + 0.5; // 0.5 – 5.0 from center
const startY = Math.random() * 8; // scattered initial heights
particleAngles[i] = angle;
particleRadii[i] = radius;
particleSpeeds[i] = 0.4 + Math.random() * 1.2; // units/s
particlePositions[i * 3] = Math.cos(angle) * radius;
particlePositions[i * 3 + 1] = startY;
particlePositions[i * 3 + 2] = Math.sin(angle) * radius;
}
const particleGeo = new THREE.BufferGeometry();
particleGeo.setAttribute(
'position',
new THREE.BufferAttribute(particlePositions, 3),
);
const particleMat = new THREE.PointsMaterial({
color: 0x8b5cf6,
size: 0.05,
transparent: true,
opacity: 0.7,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const particles = new THREE.Points(particleGeo, particleMat);
group.add(particles);
// ── Finalize group ────────────────────────────────────────────────────────
scene.add(group);
// ─── Animate closure ────────────────────────────────────────────────────
// elapsed is in milliseconds (matching the convention used in WarRoom.ts)
let lastElapsed = 0;
return {
animate(elapsed: number): void {
const t = elapsed / 1000; // seconds
const dt = (elapsed - lastElapsed) / 1000;
lastElapsed = elapsed;
// — Outer ring: slow clockwise rotation on Y axis —
outerRing.rotation.z += 0.004;
outerGlow.rotation.z -= 0.002; // counter-rotate for layered feel
// — Inner/mid rings: gentle counter-rotation —
innerRing.rotation.z -= 0.006;
midRing.rotation.z += 0.003;
// — Animated grid: very slow rotation —
gridHelper.rotation.y += 0.001;
// — Edge glow: pulse brightness via emissiveIntensity —
const edgePulse = 1.8 + Math.sin(t * 1.8) * 0.7;
(edgeGlowMat as THREE.MeshStandardMaterial).emissiveIntensity = edgePulse;
// — Energy core: scale + emissive pulse —
const corePulse = 0.85 + Math.sin(t * 2.4) * 0.25;
core.scale.setScalar(corePulse);
(coreMat as THREE.MeshStandardMaterial).emissiveIntensity =
2.5 + Math.sin(t * 2.4) * 1.5;
halo.scale.setScalar(0.9 + Math.sin(t * 2.4 + 0.5) * 0.35);
(haloMat as THREE.MeshBasicMaterial).opacity =
0.08 + Math.sin(t * 2.4) * 0.06;
// — Holographic cones: very slow counter-rotations on Y —
cone.rotation.y += 0.0015;
coneInner.rotation.y -= 0.002;
// — Data particles: rise upward, respawn at bottom —
const posAttr = particleGeo.attributes['position'] as THREE.BufferAttribute;
const posArray = posAttr.array as Float32Array;
const COLUMN_TOP = 8.5;
for (let i = 0; i < PARTICLE_COUNT; i++) {
posArray[i * 3 + 1] += particleSpeeds[i] * dt;
if (posArray[i * 3 + 1] > COLUMN_TOP) {
// Respawn at table surface with fresh random position
const newAngle = Math.random() * Math.PI * 2;
const newRadius = Math.random() * 4.5 + 0.5;
particleAngles[i] = newAngle;
particleRadii[i] = newRadius;
posArray[i * 3] = Math.cos(newAngle) * newRadius;
posArray[i * 3 + 1] = 0.35 + Math.random() * 0.5; // just above table
posArray[i * 3 + 2] = Math.sin(newAngle) * newRadius;
}
}
posAttr.needsUpdate = true;
},
};
}