app.js: 5416 → 528 lines (entry point, animation loop, event wiring) modules/state.js: shared mutable state object modules/constants.js: color palette modules/matrix-rain.js: matrix rain canvas effect modules/scene-setup.js: scene, camera, renderer, lighting, stars modules/platform.js: glass platform, perlin noise, floating island, clouds modules/heatmap.js: commit heatmap modules/sigil.js: Timmy sigil modules/controls.js: mouse, overview, zoom, photo mode modules/effects.js: energy beam, sovereignty meter, rune ring modules/earth.js: holographic earth modules/warp.js: warp tunnel, crystals, lightning modules/dual-brain.js: dual-brain holographic panel modules/audio.js: Web Audio, spatial, portal hums modules/debug.js: debug mode, websocket, session export modules/celebrations.js: easter egg, shockwave, fireworks modules/portals.js: portal loading modules/bookshelves.js: floating bookshelves, spine textures modules/oath.js: The Oath interactive SOUL.md modules/panels.js: agent status board, LoRA panel modules/weather.js: weather system, portal health modules/extras.js: gravity zones, speech, timelapse, bitcoin Largest file: 528 lines (app.js). No file exceeds 1000. All files pass node --check. No refactoring — mechanical split only.
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
// === WARP TUNNEL + CRYSTALS + LIGHTNING + BATCAVE + DUAL-BRAIN ===
|
|
import * as THREE from 'three';
|
|
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
|
import { NEXUS } from './constants.js';
|
|
import { scene, camera, renderer } from './scene-setup.js';
|
|
import { composer } from './controls.js';
|
|
import { zoneIntensity } from './heatmap.js';
|
|
import { S } from './state.js';
|
|
|
|
// === WARP TUNNEL EFFECT ===
|
|
const WarpShader = {
|
|
uniforms: {
|
|
'tDiffuse': { value: null },
|
|
'time': { value: 0.0 },
|
|
'progress': { value: 0.0 },
|
|
'portalColor': { value: new THREE.Color(0x4488ff) },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform sampler2D tDiffuse;
|
|
uniform float time;
|
|
uniform float progress;
|
|
uniform vec3 portalColor;
|
|
varying vec2 vUv;
|
|
|
|
#define PI 3.14159265358979
|
|
|
|
void main() {
|
|
vec2 uv = vUv;
|
|
vec2 center = vec2(0.5, 0.5);
|
|
vec2 dir = uv - center;
|
|
float dist = length(dir);
|
|
float angle = atan(dir.y, dir.x);
|
|
|
|
float intensity = sin(progress * PI);
|
|
|
|
float zoom = 1.0 + intensity * 3.0;
|
|
vec2 zoomedUV = center + dir / zoom;
|
|
|
|
float swirl = intensity * 5.0 * max(0.0, 1.0 - dist * 2.0);
|
|
float twisted = angle + swirl;
|
|
vec2 swirlUV = center + vec2(cos(twisted), sin(twisted)) * dist / (1.0 + intensity * 1.8);
|
|
|
|
vec2 warpUV = mix(zoomedUV, swirlUV, 0.6);
|
|
warpUV = clamp(warpUV, vec2(0.001), vec2(0.999));
|
|
|
|
float aber = intensity * 0.018;
|
|
vec2 aberDir = normalize(dir + vec2(0.001));
|
|
float rVal = texture2D(tDiffuse, clamp(warpUV + aberDir * aber, vec2(0.0), vec2(1.0))).r;
|
|
float gVal = texture2D(tDiffuse, warpUV).g;
|
|
float bVal = texture2D(tDiffuse, clamp(warpUV - aberDir * aber, vec2(0.0), vec2(1.0))).b;
|
|
vec4 color = vec4(rVal, gVal, bVal, 1.0);
|
|
|
|
float numLines = 28.0;
|
|
float lineAngleFrac = fract((angle / (2.0 * PI) + 0.5) * numLines + time * 4.0);
|
|
float lineSharp = pow(max(0.0, 1.0 - abs(lineAngleFrac - 0.5) * 16.0), 3.0);
|
|
float radialFade = max(0.0, 1.0 - dist * 2.2);
|
|
float speedLine = lineSharp * radialFade * intensity * 1.8;
|
|
|
|
float lineAngleFrac2 = fract((angle / (2.0 * PI) + 0.5) * 14.0 - time * 2.5);
|
|
float lineSharp2 = pow(max(0.0, 1.0 - abs(lineAngleFrac2 - 0.5) * 12.0), 3.0);
|
|
float speedLine2 = lineSharp2 * radialFade * intensity * 0.9;
|
|
|
|
float rimDist = abs(dist - 0.08 * intensity);
|
|
float rimGlow = pow(max(0.0, 1.0 - rimDist * 40.0), 2.0) * intensity;
|
|
|
|
color.rgb = mix(color.rgb, portalColor, intensity * 0.45);
|
|
|
|
color.rgb += portalColor * (speedLine + speedLine2);
|
|
color.rgb += vec3(1.0) * rimGlow * 0.8;
|
|
|
|
float bloom = pow(max(0.0, 1.0 - dist / (0.18 * intensity + 0.001)), 2.0) * intensity;
|
|
color.rgb += portalColor * bloom * 2.5 + vec3(1.0) * bloom * 0.6;
|
|
|
|
float vignette = smoothstep(0.5, 0.2, dist) * intensity * 0.5;
|
|
color.rgb *= 1.0 - vignette * 0.4;
|
|
|
|
float flash = smoothstep(0.82, 1.0, progress);
|
|
color.rgb = mix(color.rgb, vec3(1.0), flash);
|
|
|
|
gl_FragColor = color;
|
|
}
|
|
`,
|
|
};
|
|
|
|
export const warpPass = new ShaderPass(WarpShader);
|
|
warpPass.enabled = false;
|
|
composer.addPass(warpPass);
|
|
|
|
export function startWarp(portalMesh) {
|
|
S.isWarping = true;
|
|
S.warpNavigated = false;
|
|
S.warpStartTime = clock.getElapsedTime();
|
|
warpPass.enabled = true;
|
|
warpPass.uniforms['time'].value = 0.0;
|
|
warpPass.uniforms['progress'].value = 0.0;
|
|
|
|
if (portalMesh) {
|
|
S.warpDestinationUrl = portalMesh.userData.destinationUrl || null;
|
|
S.warpPortalColor = portalMesh.userData.portalColor
|
|
? portalMesh.userData.portalColor.clone()
|
|
: new THREE.Color(0x4488ff);
|
|
} else {
|
|
S.warpDestinationUrl = null;
|
|
S.warpPortalColor = new THREE.Color(0x4488ff);
|
|
}
|
|
warpPass.uniforms['portalColor'].value = S.warpPortalColor;
|
|
}
|
|
|
|
// clock is created here and exported
|
|
export const clock = new THREE.Clock();
|
|
|
|
// === FLOATING CRYSTALS & LIGHTNING ARCS ===
|
|
const CRYSTAL_COUNT = 5;
|
|
const CRYSTAL_BASE_POSITIONS = [
|
|
new THREE.Vector3(-4.5, 3.2, -3.8),
|
|
new THREE.Vector3( 4.8, 2.8, -4.0),
|
|
new THREE.Vector3(-5.5, 4.0, 1.5),
|
|
new THREE.Vector3( 5.2, 3.5, 2.0),
|
|
new THREE.Vector3( 0.0, 5.0, -5.5),
|
|
];
|
|
export const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
|
|
|
|
const crystalGroupObj = new THREE.Group();
|
|
scene.add(crystalGroupObj);
|
|
|
|
export const crystals = [];
|
|
|
|
for (let i = 0; i < CRYSTAL_COUNT; i++) {
|
|
const geo = new THREE.OctahedronGeometry(0.35, 0);
|
|
const color = CRYSTAL_COLORS[i];
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: new THREE.Color(color).multiplyScalar(0.6),
|
|
roughness: 0.05,
|
|
metalness: 0.3,
|
|
transparent: true,
|
|
opacity: 0.88,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
const basePos = CRYSTAL_BASE_POSITIONS[i].clone();
|
|
mesh.position.copy(basePos);
|
|
mesh.userData.zoomLabel = 'Crystal';
|
|
crystalGroupObj.add(mesh);
|
|
|
|
const light = new THREE.PointLight(color, 0.3, 6);
|
|
light.position.copy(basePos);
|
|
crystalGroupObj.add(light);
|
|
|
|
crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 });
|
|
}
|
|
|
|
// Lightning arc pool
|
|
export const LIGHTNING_POOL_SIZE = 6;
|
|
const LIGHTNING_SEGMENTS = 8;
|
|
export const LIGHTNING_REFRESH_MS = 130;
|
|
|
|
export const lightningArcs = [];
|
|
export const lightningArcMeta = [];
|
|
|
|
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
|
const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
const mat = new THREE.LineBasicMaterial({
|
|
color: 0x88ccff, transparent: true, opacity: 0.0,
|
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
|
});
|
|
const arc = new THREE.Line(geo, mat);
|
|
scene.add(arc);
|
|
lightningArcs.push(arc);
|
|
lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 });
|
|
}
|
|
|
|
function buildLightningPath(start, end, jagAmount) {
|
|
const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
|
|
for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) {
|
|
const t = s / LIGHTNING_SEGMENTS;
|
|
const x = start.x + (end.x - start.x) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
|
|
const y = start.y + (end.y - start.y) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
|
|
const z = start.z + (end.z - start.z) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
|
|
out[s * 3] = x; out[s * 3 + 1] = y; out[s * 3 + 2] = z;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function totalActivity() {
|
|
const vals = Object.values(zoneIntensity);
|
|
return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
|
|
}
|
|
|
|
export function lerpColor(colorA, colorB, t) {
|
|
const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff;
|
|
const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff;
|
|
const r = Math.round(ar + (br - ar) * t);
|
|
const g = Math.round(ag + (bg - ag) * t);
|
|
const b = Math.round(ab + (bb - ab) * t);
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
|
|
export function updateLightningArcs(elapsed) {
|
|
const activity = totalActivity();
|
|
const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE);
|
|
|
|
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
|
const arc = lightningArcs[i];
|
|
const meta = lightningArcMeta[i];
|
|
if (i >= activeCount) {
|
|
arc.material.opacity = 0;
|
|
meta.active = false;
|
|
continue;
|
|
}
|
|
|
|
const a = Math.floor(Math.random() * CRYSTAL_COUNT);
|
|
let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1));
|
|
if (b >= a) b++;
|
|
|
|
const jagAmount = 0.45 + activity * 0.85;
|
|
const path = buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount);
|
|
const attr = arc.geometry.attributes.position;
|
|
attr.array.set(path);
|
|
attr.needsUpdate = true;
|
|
|
|
arc.material.color.setHex(lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5));
|
|
|
|
const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0);
|
|
arc.material.opacity = base;
|
|
meta.active = true;
|
|
meta.baseOpacity = base;
|
|
meta.srcIdx = a;
|
|
meta.dstIdx = b;
|
|
|
|
crystals[a].flashStartTime = elapsed;
|
|
crystals[b].flashStartTime = elapsed;
|
|
}
|
|
}
|
|
|
|
// === BATCAVE AREA ===
|
|
const BATCAVE_ORIGIN = new THREE.Vector3(-10, 0, -8);
|
|
|
|
export const batcaveGroup = new THREE.Group();
|
|
batcaveGroup.position.copy(BATCAVE_ORIGIN);
|
|
scene.add(batcaveGroup);
|
|
|
|
const batcaveProbeTarget = new THREE.WebGLCubeRenderTarget(128, {
|
|
type: THREE.HalfFloatType,
|
|
generateMipmaps: true,
|
|
minFilter: THREE.LinearMipmapLinearFilter,
|
|
});
|
|
export const batcaveProbe = new THREE.CubeCamera(0.1, 80, batcaveProbeTarget);
|
|
batcaveProbe.position.set(0, 1.2, -1);
|
|
batcaveGroup.add(batcaveProbe);
|
|
|
|
const batcaveFloorMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0d1520, metalness: 0.92, roughness: 0.08, envMapIntensity: 1.4,
|
|
});
|
|
|
|
const batcaveWallMat = new THREE.MeshStandardMaterial({
|
|
color: 0x0a1828, metalness: 0.85, roughness: 0.15,
|
|
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.03),
|
|
envMapIntensity: 1.2,
|
|
});
|
|
|
|
const batcaveConsoleMat = new THREE.MeshStandardMaterial({
|
|
color: 0x060e16, metalness: 0.95, roughness: 0.05, envMapIntensity: 1.6,
|
|
});
|
|
|
|
export const batcaveMetallicMats = [batcaveFloorMat, batcaveWallMat, batcaveConsoleMat];
|
|
export const batcaveProbeTarget_texture = batcaveProbeTarget;
|
|
|
|
const batcaveFloor = new THREE.Mesh(new THREE.BoxGeometry(6, 0.08, 6), batcaveFloorMat);
|
|
batcaveFloor.position.y = -0.04;
|
|
batcaveGroup.add(batcaveFloor);
|
|
|
|
const batcaveBackWall = new THREE.Mesh(new THREE.BoxGeometry(6, 3, 0.1), batcaveWallMat);
|
|
batcaveBackWall.position.set(0, 1.5, -3);
|
|
batcaveGroup.add(batcaveBackWall);
|
|
|
|
const batcaveLeftWall = new THREE.Mesh(new THREE.BoxGeometry(0.1, 3, 6), batcaveWallMat);
|
|
batcaveLeftWall.position.set(-3, 1.5, 0);
|
|
batcaveGroup.add(batcaveLeftWall);
|
|
|
|
const batcaveConsoleBase = new THREE.Mesh(new THREE.BoxGeometry(3, 0.7, 1.2), batcaveConsoleMat);
|
|
batcaveConsoleBase.position.set(0, 0.35, -1.5);
|
|
batcaveGroup.add(batcaveConsoleBase);
|
|
|
|
const batcaveScreenBezel = new THREE.Mesh(new THREE.BoxGeometry(2.6, 1.4, 0.06), batcaveConsoleMat);
|
|
batcaveScreenBezel.position.set(0, 1.4, -2.08);
|
|
batcaveScreenBezel.rotation.x = Math.PI * 0.08;
|
|
batcaveGroup.add(batcaveScreenBezel);
|
|
|
|
const batcaveScreenGlow = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(2.2, 1.1),
|
|
new THREE.MeshBasicMaterial({
|
|
color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.65),
|
|
transparent: true, opacity: 0.82,
|
|
})
|
|
);
|
|
batcaveScreenGlow.position.set(0, 1.4, -2.05);
|
|
batcaveScreenGlow.rotation.x = Math.PI * 0.08;
|
|
batcaveGroup.add(batcaveScreenGlow);
|
|
|
|
const batcaveLight = new THREE.PointLight(NEXUS.colors.accent, 0.9, 14);
|
|
batcaveLight.position.set(0, 2.8, -1);
|
|
batcaveGroup.add(batcaveLight);
|
|
|
|
const batcaveCeilingStrip = new THREE.Mesh(
|
|
new THREE.BoxGeometry(4.2, 0.05, 0.14),
|
|
new THREE.MeshStandardMaterial({
|
|
color: NEXUS.colors.accent,
|
|
emissive: new THREE.Color(NEXUS.colors.accent),
|
|
emissiveIntensity: 1.1,
|
|
})
|
|
);
|
|
batcaveCeilingStrip.position.set(0, 2.95, -1.2);
|
|
batcaveGroup.add(batcaveCeilingStrip);
|
|
|
|
batcaveGroup.traverse(obj => {
|
|
if (obj.isMesh) obj.userData.zoomLabel = 'Batcave';
|
|
});
|