Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Co-authored-by: manus <manus@noreply.143.198.27.163> Co-committed-by: manus <manus@noreply.143.198.27.163>
1120 lines
35 KiB
JavaScript
1120 lines
35 KiB
JavaScript
import * as THREE from 'three';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
|
|
|
// ═══════════════════════════════════════════
|
|
// NEXUS v1 — Timmy's Sovereign Home
|
|
// ═══════════════════════════════════════════
|
|
|
|
const NEXUS = {
|
|
colors: {
|
|
primary: 0x4af0c0,
|
|
secondary: 0x7b5cff,
|
|
bg: 0x050510,
|
|
panelBg: 0x0a0f28,
|
|
nebula1: 0x1a0a3e,
|
|
nebula2: 0x0a1a3e,
|
|
gold: 0xffd700,
|
|
danger: 0xff4466,
|
|
gridLine: 0x1a2a4a,
|
|
}
|
|
};
|
|
|
|
// ═══ SOVEREIGN STATE (The Heartbeat) ═══
|
|
const STATE = {
|
|
metrics: {
|
|
fps: 0,
|
|
drawCalls: 0,
|
|
triangles: 0,
|
|
uptime: 0,
|
|
activeLoops: 5,
|
|
cpu: 12,
|
|
mem: 4.2
|
|
},
|
|
agents: {
|
|
timmy: 'RUNNING',
|
|
kimi: 'STANDBY',
|
|
claude: 'ACTIVE',
|
|
perplexity: 'STANDBY'
|
|
},
|
|
thoughts: [
|
|
'ANALYZING WORLD...',
|
|
'SYNCING MEMORY...',
|
|
'WAITING FOR INPUT',
|
|
'SOUL ON BITCOIN'
|
|
],
|
|
lastUpdate: 0,
|
|
pulseRate: 1.0 // Hz
|
|
};
|
|
|
|
// ═══ STATE BROADCASTER ═══
|
|
const Broadcaster = {
|
|
listeners: [],
|
|
subscribe(fn) { this.listeners.push(fn); },
|
|
broadcast() { this.listeners.forEach(fn => fn(STATE)); }
|
|
};
|
|
|
|
// ═══ STATE UPDATER ═══
|
|
function updateSovereignState(elapsed) {
|
|
STATE.metrics.uptime = elapsed;
|
|
|
|
// Simulate some jitter/activity
|
|
if (Math.random() > 0.95) {
|
|
STATE.metrics.cpu = 10 + Math.floor(Math.random() * 15);
|
|
STATE.metrics.activeLoops = 4 + Math.floor(Math.random() * 3);
|
|
|
|
// Random thought shift
|
|
if (Math.random() > 0.7) {
|
|
const newThoughts = [
|
|
'DECENTRALIZING COGNITION',
|
|
'ZAPPING CONTRIBUTORS',
|
|
'MAPPING SPATIAL LOOPS',
|
|
'REFINING LORA WEIGHTS',
|
|
'OBSERVING ALEXANDER',
|
|
'NEXUS INTEGRITY: 100%',
|
|
'HERMES LINK STABLE'
|
|
];
|
|
STATE.thoughts.shift();
|
|
STATE.thoughts.push(newThoughts[Math.floor(Math.random() * newThoughts.length)]);
|
|
}
|
|
|
|
Broadcaster.broadcast();
|
|
}
|
|
}
|
|
|
|
// ═══ STATE ═══
|
|
let camera, scene, renderer, composer;
|
|
let clock, playerPos, playerRot;
|
|
let keys = {};
|
|
let mouseDown = false;
|
|
let batcaveTerminals = [];
|
|
let portalMesh, portalGlow;
|
|
let particles, dustParticles;
|
|
let debugOverlay;
|
|
let frameCount = 0, lastFPSTime = 0, fps = 0;
|
|
let chatOpen = true;
|
|
let loadProgress = 0;
|
|
let performanceTier = 'high'; // 'high' | 'medium' | 'low'
|
|
|
|
// ═══ NAVIGATION SYSTEM ═══
|
|
const NAV_MODES = ['walk', 'orbit', 'fly'];
|
|
let navModeIdx = 0; // default: walk
|
|
|
|
// Orbit state
|
|
const orbitState = {
|
|
target: new THREE.Vector3(0, 2, 0),
|
|
radius: 14,
|
|
theta: Math.PI, // azimuthal (horizontal rotation)
|
|
phi: Math.PI / 6, // polar (vertical tilt, 0=top)
|
|
minR: 3,
|
|
maxR: 40,
|
|
lastX: 0,
|
|
lastY: 0,
|
|
};
|
|
|
|
// Fly state — separate Y so walk and fly share XZ history
|
|
let flyY = 2;
|
|
|
|
// ═══ INIT ═══
|
|
function init() {
|
|
clock = new THREE.Clock();
|
|
playerPos = new THREE.Vector3(0, 2, 12);
|
|
playerRot = new THREE.Euler(0, 0, 0, 'YXZ');
|
|
|
|
// Renderer
|
|
const canvas = document.getElementById('nexus-canvas');
|
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
|
|
// Performance budget — must run before scene objects are created
|
|
performanceTier = detectPerformanceTier();
|
|
updateLoad(20);
|
|
|
|
// Scene
|
|
scene = new THREE.Scene();
|
|
scene.fog = new THREE.FogExp2(0x050510, 0.012);
|
|
|
|
// Camera
|
|
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.copy(playerPos);
|
|
|
|
updateLoad(30);
|
|
|
|
// Build world
|
|
createSkybox();
|
|
updateLoad(40);
|
|
createLighting();
|
|
updateLoad(50);
|
|
createFloor();
|
|
updateLoad(55);
|
|
createBatcaveTerminal();
|
|
updateLoad(70);
|
|
createPortal();
|
|
updateLoad(80);
|
|
createParticles();
|
|
createDustParticles();
|
|
updateLoad(85);
|
|
createAmbientStructures();
|
|
updateLoad(90);
|
|
|
|
// Post-processing
|
|
composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
const bloom = new UnrealBloomPass(
|
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
|
0.6, 0.4, 0.85
|
|
);
|
|
composer.addPass(bloom);
|
|
composer.addPass(new SMAAPass(window.innerWidth, window.innerHeight));
|
|
|
|
updateLoad(95);
|
|
|
|
// Events
|
|
setupControls();
|
|
window.addEventListener('resize', onResize);
|
|
|
|
// Debug overlay ref
|
|
debugOverlay = document.getElementById('debug-overlay');
|
|
|
|
updateLoad(100);
|
|
|
|
// Transition from loading to enter screen
|
|
setTimeout(() => {
|
|
document.getElementById('loading-screen').classList.add('fade-out');
|
|
const enterPrompt = document.getElementById('enter-prompt');
|
|
enterPrompt.style.display = 'flex';
|
|
|
|
enterPrompt.addEventListener('click', () => {
|
|
enterPrompt.classList.add('fade-out');
|
|
document.getElementById('hud').style.display = 'block';
|
|
setTimeout(() => { enterPrompt.remove(); }, 600);
|
|
}, { once: true });
|
|
|
|
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
|
|
}, 600);
|
|
|
|
// Start loop
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
function updateLoad(pct) {
|
|
loadProgress = pct;
|
|
const fill = document.getElementById('load-progress');
|
|
if (fill) fill.style.width = pct + '%';
|
|
}
|
|
|
|
// ═══ PERFORMANCE BUDGET ═══
|
|
function detectPerformanceTier() {
|
|
const isMobile = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) || window.innerWidth < 768;
|
|
const cores = navigator.hardwareConcurrency || 4;
|
|
|
|
if (isMobile) {
|
|
renderer.setPixelRatio(1);
|
|
renderer.shadowMap.enabled = false;
|
|
renderer.toneMappingExposure = 1.0;
|
|
return 'low';
|
|
} else if (cores < 8) {
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
renderer.shadowMap.type = THREE.BasicShadowMap;
|
|
return 'medium';
|
|
} else {
|
|
// M3 Max / high-end desktop — full quality
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
return 'high';
|
|
}
|
|
}
|
|
|
|
function particleCount(base) {
|
|
if (performanceTier === 'low') return Math.floor(base * 0.25);
|
|
if (performanceTier === 'medium') return Math.floor(base * 0.6);
|
|
return base;
|
|
}
|
|
|
|
// ═══ SKYBOX ═══
|
|
function createSkybox() {
|
|
// Procedural nebula skybox using shader
|
|
const skyGeo = new THREE.SphereGeometry(400, 64, 64);
|
|
const skyMat = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uColor1: { value: new THREE.Color(0x0a0520) },
|
|
uColor2: { value: new THREE.Color(0x1a0a3e) },
|
|
uColor3: { value: new THREE.Color(0x0a1a3e) },
|
|
uStarDensity: { value: 0.97 },
|
|
},
|
|
vertexShader: `
|
|
varying vec3 vPos;
|
|
void main() {
|
|
vPos = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor1;
|
|
uniform vec3 uColor2;
|
|
uniform vec3 uColor3;
|
|
uniform float uStarDensity;
|
|
varying vec3 vPos;
|
|
|
|
// Hash and noise
|
|
float hash(vec3 p) {
|
|
p = fract(p * vec3(443.897, 441.423, 437.195));
|
|
p += dot(p, p.yzx + 19.19);
|
|
return fract((p.x + p.y) * p.z);
|
|
}
|
|
|
|
float noise(vec3 p) {
|
|
vec3 i = floor(p);
|
|
vec3 f = fract(p);
|
|
f = f * f * (3.0 - 2.0 * f);
|
|
return mix(
|
|
mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
|
|
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
|
|
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
|
|
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
|
|
f.z
|
|
);
|
|
}
|
|
|
|
float fbm(vec3 p) {
|
|
float v = 0.0;
|
|
float a = 0.5;
|
|
for (int i = 0; i < 5; i++) {
|
|
v += a * noise(p);
|
|
p *= 2.0;
|
|
a *= 0.5;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
void main() {
|
|
vec3 dir = normalize(vPos);
|
|
|
|
// Nebula clouds
|
|
float n1 = fbm(dir * 3.0 + uTime * 0.02);
|
|
float n2 = fbm(dir * 5.0 - uTime * 0.015 + 100.0);
|
|
float n3 = fbm(dir * 2.0 + uTime * 0.01 + 200.0);
|
|
|
|
vec3 col = uColor1;
|
|
col = mix(col, uColor2, smoothstep(0.3, 0.7, n1));
|
|
col = mix(col, uColor3, smoothstep(0.4, 0.8, n2) * 0.5);
|
|
|
|
// Nebula glow regions
|
|
float glow = pow(n1 * n2, 2.0) * 1.5;
|
|
col += vec3(0.15, 0.05, 0.25) * glow;
|
|
col += vec3(0.05, 0.15, 0.25) * pow(n3, 3.0);
|
|
|
|
// Stars
|
|
float starField = hash(dir * 800.0);
|
|
float stars = step(uStarDensity, starField) * (0.5 + 0.5 * hash(dir * 1600.0));
|
|
// Twinkling
|
|
float twinkle = 0.7 + 0.3 * sin(uTime * 2.0 + hash(dir * 400.0) * 6.28);
|
|
col += vec3(stars * twinkle);
|
|
|
|
// Big bright stars
|
|
float bigStar = step(0.998, starField);
|
|
col += vec3(0.8, 0.9, 1.0) * bigStar * twinkle;
|
|
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}
|
|
`,
|
|
side: THREE.BackSide,
|
|
});
|
|
const sky = new THREE.Mesh(skyGeo, skyMat);
|
|
sky.name = 'skybox';
|
|
scene.add(sky);
|
|
}
|
|
|
|
// ═══ LIGHTING ═══
|
|
function createLighting() {
|
|
// Ambient
|
|
const ambient = new THREE.AmbientLight(0x1a1a3a, 0.4);
|
|
scene.add(ambient);
|
|
|
|
// Main directional (moonlight feel)
|
|
const dirLight = new THREE.DirectionalLight(0x4466aa, 0.6);
|
|
dirLight.position.set(10, 20, 10);
|
|
dirLight.castShadow = renderer.shadowMap.enabled;
|
|
const shadowRes = performanceTier === 'high' ? 2048 : performanceTier === 'medium' ? 1024 : 512;
|
|
dirLight.shadow.mapSize.set(shadowRes, shadowRes);
|
|
dirLight.shadow.camera.near = 0.5;
|
|
dirLight.shadow.camera.far = 80;
|
|
dirLight.shadow.camera.left = -30;
|
|
dirLight.shadow.camera.right = 30;
|
|
dirLight.shadow.camera.top = 30;
|
|
dirLight.shadow.camera.bottom = -30;
|
|
scene.add(dirLight);
|
|
|
|
// Teal accent from below terminal
|
|
const tealLight = new THREE.PointLight(NEXUS.colors.primary, 2, 30, 1.5);
|
|
tealLight.position.set(0, 1, -5);
|
|
scene.add(tealLight);
|
|
|
|
// Purple accent
|
|
const purpleLight = new THREE.PointLight(NEXUS.colors.secondary, 1.5, 25, 1.5);
|
|
purpleLight.position.set(-8, 3, -8);
|
|
scene.add(purpleLight);
|
|
|
|
// Portal glow light
|
|
const portalLight = new THREE.PointLight(0xff6600, 2, 20, 1.5);
|
|
portalLight.position.set(15, 4, -10);
|
|
scene.add(portalLight);
|
|
}
|
|
|
|
// ═══ FLOOR ═══
|
|
function createFloor() {
|
|
// Main hexagonal-feel platform using a flat circle
|
|
const platGeo = new THREE.CylinderGeometry(25, 25, 0.3, 6);
|
|
const platMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0a0f1a,
|
|
roughness: 0.8,
|
|
metalness: 0.3,
|
|
});
|
|
const platform = new THREE.Mesh(platGeo, platMat);
|
|
platform.position.y = -0.15;
|
|
platform.receiveShadow = true;
|
|
scene.add(platform);
|
|
|
|
// Grid lines on the floor
|
|
const gridHelper = new THREE.GridHelper(50, 50, NEXUS.colors.gridLine, NEXUS.colors.gridLine);
|
|
gridHelper.material.opacity = 0.15;
|
|
gridHelper.material.transparent = true;
|
|
gridHelper.position.y = 0.02;
|
|
scene.add(gridHelper);
|
|
|
|
// Glowing edge ring
|
|
const ringGeo = new THREE.RingGeometry(24.5, 25.2, 6);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: NEXUS.colors.primary,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
ring.rotation.x = Math.PI / 2;
|
|
ring.position.y = 0.05;
|
|
scene.add(ring);
|
|
}
|
|
|
|
// ═══ BATCAVE TERMINAL ═══
|
|
function createBatcaveTerminal() {
|
|
const terminalGroup = new THREE.Group();
|
|
terminalGroup.position.set(0, 0, -8);
|
|
|
|
const panels = [
|
|
{ id: 'command', title: 'NEXUS COMMAND', color: NEXUS.colors.primary, rot: -0.4, x: -6, y: 3 },
|
|
{ id: 'queue', title: 'DEV QUEUE', color: NEXUS.colors.gold, rot: -0.2, x: -3, y: 3 },
|
|
{ id: 'metrics', title: 'METRICS', color: NEXUS.colors.secondary, rot: 0, x: 0, y: 3 },
|
|
{ id: 'thoughts',title: 'THOUGHTS', color: NEXUS.colors.primary, rot: 0.2, x: 3, y: 3 },
|
|
{ id: 'agents', title: 'AGENT STATUS', color: NEXUS.colors.gold, rot: 0.4, x: 6, y: 3 },
|
|
];
|
|
|
|
panels.forEach(data => {
|
|
createTerminalPanel(terminalGroup, data);
|
|
});
|
|
|
|
scene.add(terminalGroup);
|
|
}
|
|
|
|
function createTerminalPanel(parent, data) {
|
|
const { x, y, rot, title, color, id } = data;
|
|
const w = 2.8, h = 3.5;
|
|
const group = new THREE.Group();
|
|
group.position.set(x, y, 0);
|
|
group.rotation.y = rot;
|
|
|
|
// Panel background (glassy)
|
|
const bgGeo = new THREE.PlaneGeometry(w, h);
|
|
const bgMat = new THREE.MeshPhysicalMaterial({
|
|
color: NEXUS.colors.panelBg,
|
|
transparent: true,
|
|
opacity: 0.6,
|
|
roughness: 0.1,
|
|
metalness: 0.5,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const bg = new THREE.Mesh(bgGeo, bgMat);
|
|
group.add(bg);
|
|
|
|
// Border
|
|
const borderMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
|
|
const border = new THREE.Mesh(new THREE.PlaneGeometry(w + 0.05, h + 0.05), borderMat);
|
|
border.position.z = -0.01;
|
|
group.add(border);
|
|
|
|
// Canvas for text
|
|
const textCanvas = document.createElement('canvas');
|
|
textCanvas.width = 512;
|
|
textCanvas.height = 640;
|
|
const ctx = textCanvas.getContext('2d');
|
|
|
|
const textTexture = new THREE.CanvasTexture(textCanvas);
|
|
textTexture.minFilter = THREE.LinearFilter;
|
|
const textMat = new THREE.MeshBasicMaterial({
|
|
map: textTexture,
|
|
transparent: true,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
});
|
|
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
|
|
textMesh.position.z = 0.01;
|
|
group.add(textMesh);
|
|
|
|
// Update function for this specific panel
|
|
const updatePanel = (state) => {
|
|
ctx.clearRect(0, 0, 512, 640);
|
|
|
|
// Header
|
|
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
|
|
ctx.font = 'bold 32px "Orbitron", sans-serif';
|
|
ctx.fillText(title, 20, 45);
|
|
ctx.fillRect(20, 55, 472, 2);
|
|
|
|
ctx.font = '20px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = '#a0b8d0';
|
|
|
|
let lines = [];
|
|
if (id === 'command') {
|
|
lines = [
|
|
`> STATUS: NOMINAL`,
|
|
`> UPTIME: ${state.metrics.uptime.toFixed(1)}s`,
|
|
`> HARNESS: STABLE`,
|
|
`> MODE: SOVEREIGN`
|
|
];
|
|
} else if (id === 'queue') {
|
|
lines = ['> ISSUE #4: CORE', '> ISSUE #5: PORTAL', '> ISSUE #6: TERMINAL', '> ISSUE #39: HEART'];
|
|
} else if (id === 'metrics') {
|
|
lines = [
|
|
`> CPU: ${state.metrics.cpu}%`,
|
|
`> MEM: ${state.metrics.mem}GB`,
|
|
`> LOOPS: ${state.metrics.activeLoops}`,
|
|
`> FPS: ${state.metrics.fps}`
|
|
];
|
|
} else if (id === 'thoughts') {
|
|
lines = state.thoughts.map(t => `> ${t}`);
|
|
} else if (id === 'agents') {
|
|
lines = Object.entries(state.agents).map(([name, status]) => `> ${name.toUpperCase()}: ${status}`);
|
|
}
|
|
|
|
lines.forEach((line, i) => {
|
|
let fillColor = '#a0b8d0';
|
|
if (line.includes('RUNNING') || line.includes('ACTIVE')) fillColor = '#4af0c0';
|
|
ctx.fillStyle = fillColor;
|
|
ctx.fillText(line, 20, 100 + i * 40);
|
|
});
|
|
|
|
textTexture.needsUpdate = true;
|
|
};
|
|
|
|
// Initial draw
|
|
updatePanel(STATE);
|
|
Broadcaster.subscribe(updatePanel);
|
|
|
|
// Scanline effect overlay
|
|
const scanGeo = new THREE.PlaneGeometry(w, h);
|
|
const scanMat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uColor: { value: new THREE.Color(color) },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
uniform vec3 uColor;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
float scanline = sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5;
|
|
scanline = pow(scanline, 8.0);
|
|
float sweep = smoothstep(0.0, 0.02, abs(fract(vUv.y - uTime * 0.1) - 0.5));
|
|
sweep = 1.0 - (1.0 - sweep) * 0.3;
|
|
float alpha = scanline * 0.04 + (1.0 - sweep) * 0.08;
|
|
gl_FragColor = vec4(uColor, alpha);
|
|
}
|
|
`,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const scanMesh = new THREE.Mesh(scanGeo, scanMat);
|
|
scanMesh.position.z = 0.02;
|
|
group.add(scanMesh);
|
|
|
|
parent.add(group);
|
|
batcaveTerminals.push({ group, scanMat, borderMat });
|
|
}
|
|
|
|
// ═══ PORTAL ═══
|
|
function createPortal() {
|
|
const portalGroup = new THREE.Group();
|
|
portalGroup.position.set(15, 0, -10);
|
|
portalGroup.rotation.y = -0.5;
|
|
|
|
// Portal ring
|
|
const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
|
|
const torusMat = new THREE.MeshStandardMaterial({
|
|
color: 0xff6600,
|
|
emissive: 0xff4400,
|
|
emissiveIntensity: 1.5,
|
|
roughness: 0.2,
|
|
metalness: 0.8,
|
|
});
|
|
portalMesh = new THREE.Mesh(torusGeo, torusMat);
|
|
portalMesh.position.y = 3.5;
|
|
portalGroup.add(portalMesh);
|
|
|
|
// Inner swirl
|
|
const swirlGeo = new THREE.CircleGeometry(2.8, 64);
|
|
const swirlMat = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
side: THREE.DoubleSide,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform float uTime;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vec2 c = vUv - 0.5;
|
|
float r = length(c);
|
|
float a = atan(c.y, c.x);
|
|
float swirl = sin(a * 3.0 + r * 10.0 - uTime * 3.0) * 0.5 + 0.5;
|
|
float swirl2 = sin(a * 5.0 - r * 8.0 + uTime * 2.0) * 0.5 + 0.5;
|
|
float mask = smoothstep(0.5, 0.1, r);
|
|
vec3 col = mix(vec3(1.0, 0.3, 0.0), vec3(1.0, 0.6, 0.1), swirl);
|
|
col = mix(col, vec3(1.0, 0.8, 0.3), swirl2 * 0.3);
|
|
float alpha = mask * (0.5 + 0.3 * swirl);
|
|
gl_FragColor = vec4(col, alpha);
|
|
}
|
|
`,
|
|
});
|
|
portalGlow = new THREE.Mesh(swirlGeo, swirlMat);
|
|
portalGlow.position.y = 3.5;
|
|
portalGroup.add(portalGlow);
|
|
|
|
// Label
|
|
const labelCanvas = document.createElement('canvas');
|
|
labelCanvas.width = 512;
|
|
labelCanvas.height = 64;
|
|
const lctx = labelCanvas.getContext('2d');
|
|
lctx.font = 'bold 32px "Orbitron", sans-serif';
|
|
lctx.fillStyle = '#ff8844';
|
|
lctx.textAlign = 'center';
|
|
lctx.fillText('◈ MORROWIND', 256, 42);
|
|
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide });
|
|
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.5), labelMat);
|
|
labelMesh.position.y = 7;
|
|
portalGroup.add(labelMesh);
|
|
|
|
// Base pillars
|
|
for (let side of [-1, 1]) {
|
|
const pillarGeo = new THREE.CylinderGeometry(0.2, 0.3, 7, 8);
|
|
const pillarMat = new THREE.MeshStandardMaterial({
|
|
color: 0x1a1a2e,
|
|
roughness: 0.5,
|
|
metalness: 0.7,
|
|
emissive: 0xff4400,
|
|
emissiveIntensity: 0.1,
|
|
});
|
|
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
|
|
pillar.position.set(side * 3, 3.5, 0);
|
|
pillar.castShadow = true;
|
|
portalGroup.add(pillar);
|
|
}
|
|
|
|
scene.add(portalGroup);
|
|
}
|
|
|
|
// ═══ PARTICLES ═══
|
|
function createParticles() {
|
|
const count = particleCount(1500);
|
|
const geo = new THREE.BufferGeometry();
|
|
const positions = new Float32Array(count * 3);
|
|
const colors = new Float32Array(count * 3);
|
|
const sizes = new Float32Array(count);
|
|
|
|
const c1 = new THREE.Color(NEXUS.colors.primary);
|
|
const c2 = new THREE.Color(NEXUS.colors.secondary);
|
|
const c3 = new THREE.Color(NEXUS.colors.gold);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
positions[i * 3] = (Math.random() - 0.5) * 60;
|
|
positions[i * 3 + 1] = Math.random() * 20;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 60;
|
|
|
|
const t = Math.random();
|
|
const col = t < 0.5 ? c1.clone().lerp(c2, t * 2) : c2.clone().lerp(c3, (t - 0.5) * 2);
|
|
colors[i * 3] = col.r;
|
|
colors[i * 3 + 1] = col.g;
|
|
colors[i * 3 + 2] = col.b;
|
|
|
|
sizes[i] = 0.02 + Math.random() * 0.06;
|
|
}
|
|
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
|
|
|
const mat = new THREE.ShaderMaterial({
|
|
uniforms: { uTime: { value: 0 } },
|
|
vertexShader: `
|
|
attribute float size;
|
|
attribute vec3 color;
|
|
varying vec3 vColor;
|
|
uniform float uTime;
|
|
void main() {
|
|
vColor = color;
|
|
vec3 pos = position;
|
|
pos.y += sin(uTime * 0.5 + position.x * 0.5) * 0.3;
|
|
pos.x += sin(uTime * 0.3 + position.z * 0.4) * 0.2;
|
|
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
|
|
gl_PointSize = size * 300.0 / -mv.z;
|
|
gl_Position = projectionMatrix * mv;
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
varying vec3 vColor;
|
|
void main() {
|
|
float d = length(gl_PointCoord - 0.5);
|
|
if (d > 0.5) discard;
|
|
float alpha = smoothstep(0.5, 0.1, d);
|
|
gl_FragColor = vec4(vColor, alpha * 0.7);
|
|
}
|
|
`,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
|
|
particles = new THREE.Points(geo, mat);
|
|
scene.add(particles);
|
|
}
|
|
|
|
function createDustParticles() {
|
|
const count = particleCount(500);
|
|
const geo = new THREE.BufferGeometry();
|
|
const positions = new Float32Array(count * 3);
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
positions[i * 3] = (Math.random() - 0.5) * 40;
|
|
positions[i * 3 + 1] = Math.random() * 15;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 40;
|
|
}
|
|
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
|
|
const mat = new THREE.PointsMaterial({
|
|
color: 0x8899bb,
|
|
size: 0.03,
|
|
transparent: true,
|
|
opacity: 0.3,
|
|
depthWrite: false,
|
|
});
|
|
|
|
dustParticles = new THREE.Points(geo, mat);
|
|
scene.add(dustParticles);
|
|
}
|
|
|
|
// ═══ AMBIENT STRUCTURES ═══
|
|
function createAmbientStructures() {
|
|
// Crystal formations around the edges
|
|
const crystalMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x3355aa,
|
|
roughness: 0.1,
|
|
metalness: 0.2,
|
|
transmission: 0.6,
|
|
thickness: 2,
|
|
emissive: 0x1122aa,
|
|
emissiveIntensity: 0.3,
|
|
});
|
|
|
|
const positions = [
|
|
{ x: -18, z: -15, s: 1.5, ry: 0.3 },
|
|
{ x: -20, z: -10, s: 1, ry: 0.8 },
|
|
{ x: -15, z: -18, s: 2, ry: 1.2 },
|
|
{ x: 18, z: -15, s: 1.8, ry: 2.1 },
|
|
{ x: 20, z: -12, s: 1.2, ry: 0.5 },
|
|
{ x: -12, z: 18, s: 1.3, ry: 1.8 },
|
|
{ x: 14, z: 16, s: 1.6, ry: 0.9 },
|
|
];
|
|
|
|
positions.forEach(p => {
|
|
const geo = new THREE.ConeGeometry(0.4 * p.s, 2.5 * p.s, 5);
|
|
const crystal = new THREE.Mesh(geo, crystalMat.clone());
|
|
crystal.position.set(p.x, 1.25 * p.s, p.z);
|
|
crystal.rotation.y = p.ry;
|
|
crystal.rotation.z = (Math.random() - 0.5) * 0.3;
|
|
crystal.castShadow = true;
|
|
scene.add(crystal);
|
|
});
|
|
|
|
// Floating rune stones
|
|
for (let i = 0; i < 5; i++) {
|
|
const angle = (i / 5) * Math.PI * 2;
|
|
const r = 10;
|
|
const geo = new THREE.OctahedronGeometry(0.4, 0);
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: NEXUS.colors.primary,
|
|
emissive: NEXUS.colors.primary,
|
|
emissiveIntensity: 0.5,
|
|
});
|
|
const stone = new THREE.Mesh(geo, mat);
|
|
stone.position.set(Math.cos(angle) * r, 5 + Math.sin(i * 1.3) * 1.5, Math.sin(angle) * r);
|
|
stone.name = 'runestone_' + i;
|
|
scene.add(stone);
|
|
}
|
|
|
|
// Central pedestal / nexus core
|
|
const coreGeo = new THREE.IcosahedronGeometry(0.6, 2);
|
|
const coreMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x4af0c0,
|
|
emissive: 0x4af0c0,
|
|
emissiveIntensity: 2,
|
|
roughness: 0,
|
|
metalness: 1,
|
|
transmission: 0.3,
|
|
thickness: 1,
|
|
});
|
|
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
core.position.set(0, 2.5, 0);
|
|
core.name = 'nexus-core';
|
|
scene.add(core);
|
|
|
|
// Core pedestal
|
|
const pedGeo = new THREE.CylinderGeometry(0.8, 1.2, 1.5, 8);
|
|
const pedMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0a0f1a,
|
|
roughness: 0.4,
|
|
metalness: 0.8,
|
|
emissive: 0x1a2a4a,
|
|
emissiveIntensity: 0.3,
|
|
});
|
|
const pedestal = new THREE.Mesh(pedGeo, pedMat);
|
|
pedestal.position.set(0, 0.75, 0);
|
|
pedestal.castShadow = true;
|
|
scene.add(pedestal);
|
|
}
|
|
|
|
// ═══ NAVIGATION MODE ═══
|
|
function cycleNavMode() {
|
|
navModeIdx = (navModeIdx + 1) % NAV_MODES.length;
|
|
const mode = NAV_MODES[navModeIdx];
|
|
|
|
// Sync orbit target/radius from current camera when switching into orbit
|
|
if (mode === 'orbit') {
|
|
const dir = new THREE.Vector3(0, 0, -1).applyEuler(playerRot);
|
|
orbitState.target.copy(playerPos).addScaledVector(dir, orbitState.radius);
|
|
orbitState.target.y = Math.max(0, orbitState.target.y);
|
|
// Recompute angles from current camera → target vector
|
|
const toCamera = new THREE.Vector3().subVectors(playerPos, orbitState.target);
|
|
orbitState.radius = toCamera.length();
|
|
orbitState.theta = Math.atan2(toCamera.x, toCamera.z);
|
|
orbitState.phi = Math.acos(Math.max(-1, Math.min(1, toCamera.y / orbitState.radius)));
|
|
}
|
|
// Sync fly Y from current walk position
|
|
if (mode === 'fly') flyY = playerPos.y;
|
|
|
|
updateNavModeUI(mode);
|
|
}
|
|
|
|
function updateNavModeUI(mode) {
|
|
const el = document.getElementById('nav-mode-label');
|
|
if (el) el.textContent = mode.toUpperCase();
|
|
}
|
|
|
|
// ═══ CONTROLS ═══
|
|
function setupControls() {
|
|
document.addEventListener('keydown', (e) => {
|
|
keys[e.key.toLowerCase()] = true;
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const input = document.getElementById('chat-input');
|
|
if (document.activeElement === input) {
|
|
sendChatMessage();
|
|
} else {
|
|
input.focus();
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
document.getElementById('chat-input').blur();
|
|
}
|
|
// V cycles navigation modes
|
|
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
|
|
cycleNavMode();
|
|
}
|
|
});
|
|
document.addEventListener('keyup', (e) => {
|
|
keys[e.key.toLowerCase()] = false;
|
|
});
|
|
|
|
// Mouse look / orbit drag
|
|
const canvas = document.getElementById('nexus-canvas');
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
if (e.target === canvas) {
|
|
mouseDown = true;
|
|
orbitState.lastX = e.clientX;
|
|
orbitState.lastY = e.clientY;
|
|
}
|
|
});
|
|
document.addEventListener('mouseup', () => { mouseDown = false; });
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!mouseDown) return;
|
|
if (document.activeElement === document.getElementById('chat-input')) return;
|
|
const mode = NAV_MODES[navModeIdx];
|
|
if (mode === 'orbit') {
|
|
const dx = e.clientX - orbitState.lastX;
|
|
const dy = e.clientY - orbitState.lastY;
|
|
orbitState.lastX = e.clientX;
|
|
orbitState.lastY = e.clientY;
|
|
orbitState.theta -= dx * 0.005;
|
|
orbitState.phi = Math.max(0.05, Math.min(Math.PI * 0.85, orbitState.phi + dy * 0.005));
|
|
} else {
|
|
// Walk and Fly: mouse look
|
|
playerRot.y -= e.movementX * 0.003;
|
|
playerRot.x -= e.movementY * 0.003;
|
|
playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x));
|
|
}
|
|
});
|
|
|
|
// Scroll to zoom in orbit mode
|
|
canvas.addEventListener('wheel', (e) => {
|
|
if (NAV_MODES[navModeIdx] === 'orbit') {
|
|
orbitState.radius = Math.max(orbitState.minR, Math.min(orbitState.maxR, orbitState.radius + e.deltaY * 0.02));
|
|
}
|
|
}, { passive: true });
|
|
|
|
// Chat toggle
|
|
document.getElementById('chat-toggle').addEventListener('click', () => {
|
|
chatOpen = !chatOpen;
|
|
document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen);
|
|
});
|
|
document.getElementById('chat-header')?.addEventListener('click', () => {
|
|
chatOpen = !chatOpen;
|
|
document.getElementById('chat-panel').classList.toggle('collapsed', !chatOpen);
|
|
});
|
|
|
|
// Chat send
|
|
document.getElementById('chat-send').addEventListener('click', sendChatMessage);
|
|
}
|
|
|
|
function sendChatMessage() {
|
|
const input = document.getElementById('chat-input');
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
|
|
addChatMessage('user', text);
|
|
input.value = '';
|
|
|
|
// Simulate Timmy response
|
|
setTimeout(() => {
|
|
const responses = [
|
|
'Processing your request through the harness...',
|
|
'I have noted this in my thought stream.',
|
|
'Acknowledged. Routing to appropriate agent loop.',
|
|
'The sovereign space recognizes your command.',
|
|
'Running analysis. Results will appear on the main terminal.',
|
|
'My crystal ball says... yes. Implementing.',
|
|
'Understood, Alexander. Adjusting priorities.',
|
|
];
|
|
const resp = responses[Math.floor(Math.random() * responses.length)];
|
|
addChatMessage('timmy', resp);
|
|
}, 500 + Math.random() * 1000);
|
|
|
|
input.blur();
|
|
}
|
|
|
|
function addChatMessage(type, text) {
|
|
const container = document.getElementById('chat-messages');
|
|
const div = document.createElement('div');
|
|
div.className = `chat-msg chat-msg-${type}`;
|
|
const prefixes = { user: '[ALEXANDER]', timmy: '[TIMMY]', system: '[NEXUS]', error: '[ERROR]' };
|
|
div.innerHTML = `<span class="chat-msg-prefix">${prefixes[type] || '[???]'}</span> ${text}`;
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// ═══ GAME LOOP ═══
|
|
function gameLoop() {
|
|
requestAnimationFrame(gameLoop);
|
|
const delta = Math.min(clock.getDelta(), 0.1);
|
|
const elapsed = clock.elapsedTime;
|
|
|
|
// ─── Sovereign State Update ─────────────────────────────────────
|
|
updateSovereignState(elapsed);
|
|
|
|
// ─── Navigation update ───────────────────────────────────────────
|
|
const mode = NAV_MODES[navModeIdx];
|
|
const chatActive = document.activeElement === document.getElementById('chat-input');
|
|
|
|
if (mode === 'walk') {
|
|
if (!chatActive) {
|
|
const speed = 6 * delta;
|
|
const dir = new THREE.Vector3();
|
|
if (keys['w']) dir.z -= 1;
|
|
if (keys['s']) dir.z += 1;
|
|
if (keys['a']) dir.x -= 1;
|
|
if (keys['d']) dir.x += 1;
|
|
if (dir.length() > 0) {
|
|
dir.normalize().multiplyScalar(speed);
|
|
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), playerRot.y);
|
|
playerPos.add(dir);
|
|
// Clamp to platform
|
|
const maxR = 24;
|
|
const dist = Math.sqrt(playerPos.x * playerPos.x + playerPos.z * playerPos.z);
|
|
if (dist > maxR) { playerPos.x *= maxR / dist; playerPos.z *= maxR / dist; }
|
|
}
|
|
}
|
|
playerPos.y = 2; // fixed eye height in walk mode
|
|
camera.position.copy(playerPos);
|
|
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
|
|
|
|
} else if (mode === 'orbit') {
|
|
// Pan target with WASD
|
|
if (!chatActive) {
|
|
const speed = 8 * delta;
|
|
const pan = new THREE.Vector3();
|
|
if (keys['w']) pan.z -= 1;
|
|
if (keys['s']) pan.z += 1;
|
|
if (keys['a']) pan.x -= 1;
|
|
if (keys['d']) pan.x += 1;
|
|
if (pan.length() > 0) {
|
|
pan.normalize().multiplyScalar(speed);
|
|
pan.applyAxisAngle(new THREE.Vector3(0, 1, 0), orbitState.theta);
|
|
orbitState.target.add(pan);
|
|
orbitState.target.y = Math.max(0, Math.min(20, orbitState.target.y));
|
|
}
|
|
}
|
|
// Position camera on sphere around target
|
|
const r = orbitState.radius;
|
|
camera.position.set(
|
|
orbitState.target.x + r * Math.sin(orbitState.phi) * Math.sin(orbitState.theta),
|
|
orbitState.target.y + r * Math.cos(orbitState.phi),
|
|
orbitState.target.z + r * Math.sin(orbitState.phi) * Math.cos(orbitState.theta)
|
|
);
|
|
camera.lookAt(orbitState.target);
|
|
// Keep playerPos in sync so switching back to walk is smooth
|
|
playerPos.copy(camera.position);
|
|
playerRot.y = orbitState.theta;
|
|
|
|
} else if (mode === 'fly') {
|
|
if (!chatActive) {
|
|
const speed = 8 * delta;
|
|
const forward = new THREE.Vector3(-Math.sin(playerRot.y), 0, -Math.cos(playerRot.y));
|
|
const right = new THREE.Vector3( Math.cos(playerRot.y), 0, -Math.sin(playerRot.y));
|
|
if (keys['w']) playerPos.addScaledVector(forward, speed);
|
|
if (keys['s']) playerPos.addScaledVector(forward, -speed);
|
|
if (keys['a']) playerPos.addScaledVector(right, -speed);
|
|
if (keys['d']) playerPos.addScaledVector(right, speed);
|
|
if (keys['q'] || keys[' ']) flyY += speed;
|
|
if (keys['e'] || keys['shift']) flyY -= speed;
|
|
flyY = Math.max(0.5, Math.min(30, flyY));
|
|
playerPos.y = flyY;
|
|
}
|
|
camera.position.copy(playerPos);
|
|
camera.rotation.set(playerRot.x, playerRot.y, 0, 'YXZ');
|
|
}
|
|
|
|
// Animate skybox
|
|
const sky = scene.getObjectByName('skybox');
|
|
if (sky) sky.material.uniforms.uTime.value = elapsed;
|
|
|
|
// Animate terminal scanlines
|
|
batcaveTerminals.forEach(t => {
|
|
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
|
|
});
|
|
|
|
// Animate portal
|
|
if (portalMesh) {
|
|
portalMesh.rotation.z = elapsed * 0.3;
|
|
portalMesh.rotation.x = Math.sin(elapsed * 0.5) * 0.1;
|
|
}
|
|
if (portalGlow?.material?.uniforms) {
|
|
portalGlow.material.uniforms.uTime.value = elapsed;
|
|
}
|
|
|
|
// Animate particles
|
|
if (particles?.material?.uniforms) {
|
|
particles.material.uniforms.uTime.value = elapsed;
|
|
}
|
|
|
|
// Animate dust
|
|
if (dustParticles) {
|
|
dustParticles.rotation.y = elapsed * 0.01;
|
|
}
|
|
|
|
// Animate runestones
|
|
for (let i = 0; i < 5; i++) {
|
|
const stone = scene.getObjectByName('runestone_' + i);
|
|
if (stone) {
|
|
stone.position.y = 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8;
|
|
stone.rotation.y = elapsed * 0.5 + i;
|
|
stone.rotation.x = elapsed * 0.3 + i * 0.7;
|
|
}
|
|
}
|
|
|
|
// Animate nexus core
|
|
const core = scene.getObjectByName('nexus-core');
|
|
if (core) {
|
|
core.position.y = 2.5 + Math.sin(elapsed * 1.2) * 0.3;
|
|
core.rotation.y = elapsed * 0.4;
|
|
core.rotation.x = elapsed * 0.2;
|
|
// Core pulses in sync with state updates (simulated heartbeat)
|
|
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
|
|
}
|
|
|
|
// Render
|
|
composer.render();
|
|
|
|
// Debug overlay (read AFTER render so counts are populated)
|
|
frameCount++;
|
|
const now = performance.now();
|
|
if (now - lastFPSTime >= 1000) {
|
|
fps = frameCount;
|
|
frameCount = 0;
|
|
lastFPSTime = now;
|
|
// Update state metrics
|
|
STATE.metrics.fps = fps;
|
|
STATE.metrics.drawCalls = renderer.info.render.calls;
|
|
STATE.metrics.triangles = renderer.info.render.triangles;
|
|
}
|
|
if (debugOverlay) {
|
|
debugOverlay.textContent =
|
|
`FPS: ${fps} Draw: ${renderer.info.render.calls} Tri: ${renderer.info.render.triangles} [${performanceTier}]\n` +
|
|
`Pos: ${playerPos.x.toFixed(1)}, ${playerPos.y.toFixed(1)}, ${playerPos.z.toFixed(1)} NAV: ${NAV_MODES[navModeIdx]}`;
|
|
}
|
|
renderer.info.reset();
|
|
}
|
|
|
|
// ═══ RESIZE ═══
|
|
function onResize() {
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
composer.setSize(w, h);
|
|
}
|
|
|
|
// ═══ START ═══
|
|
init();
|