diff --git a/app.js b/app.js index b396094..e3c3ee6 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { BokehPass } from 'three/addons/postprocessing/BokehPass.js'; +import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; import { LoadingManager } from 'three'; // === COLOR PALETTE === @@ -422,6 +423,149 @@ const bokehPass = new BokehPass(scene, camera, { }); composer.addPass(bokehPass); +// === WARP TUNNEL EFFECT === +// Full-screen post-processing shader triggered when a portal is entered. +// Phases: vortex builds → peak tunnel → white flash → fade out. + +const WARP_DURATION = 2.8; // seconds + +/** @type {{ startTime: number, portalName: string, spin: number }|null} */ +let warpState = null; + +const WarpTunnelShader = { + uniforms: { + tDiffuse: { value: null }, + uTime: { value: 0.0 }, + uProgress:{ value: 0.0 }, // 0 = off, 1 = full vortex + uFlash: { value: 0.0 }, // 0 = off, 1 = white flash + uSpin: { value: 0.0 }, // accumulated angular rotation + }, + vertexShader: /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */` + #define PI 3.14159265359 + uniform sampler2D tDiffuse; + uniform float uTime; + uniform float uProgress; + uniform float uFlash; + uniform float uSpin; + varying vec2 vUv; + + void main() { + vec2 p = vUv - 0.5; + float r = length(p); + float theta = atan(p.y, p.x) + uSpin; + + // Suck scene pixels inward as tunnel builds + float pull = uProgress * 0.22 * max(0.0, 1.0 - r * 2.8); + vec2 wUv = clamp(vUv - p * pull, 0.001, 0.999); + vec4 base = texture2D(tDiffuse, wUv); + + // Depth: 1/r creates the zooming-into-a-tube illusion + float depth = 0.1 / max(r, 0.001); + + // Ring bands racing toward the viewer + float ring = fract(depth - uTime * 2.8 * uProgress); + float rBand = smoothstep(0.0, 0.12, ring) * (1.0 - smoothstep(0.12, 0.26, ring)); + + // 8-arm spiral rotating with time + float spiral = fract(theta * 8.0 / (2.0 * PI) + depth * 0.25 + uTime * 0.9 * uProgress); + float sBand = smoothstep(0.3, 0.42, spiral) * (1.0 - smoothstep(0.58, 0.7, spiral)); + + // Color: cyan / purple sweeping around theta + float hue = fract(theta / (PI * 2.0) + uTime * 0.12); + vec3 cyan = vec3(0.0, 0.82, 1.0); + vec3 purple = vec3(0.62, 0.05, 1.0); + vec3 vColor = mix(cyan, purple, hue); + // Central white-blue glow + vColor = mix(vColor, vec3(0.85, 0.96, 1.0), smoothstep(0.25, 0.0, r) * uProgress); + + // Fade vortex at screen corners + float edgeMask = 1.0 - smoothstep(0.3, 0.5, r); + float vortex = max(rBand * 0.88, sBand * 0.52) * uProgress * edgeMask; + + // Dark tunnel mouth near center + float voidDepth = smoothstep(0.18, 0.0, r) * uProgress * 0.85; + + vec3 col = mix(base.rgb, vColor, vortex * 0.9); + col = mix(col, vec3(0.0, 0.01, 0.06), voidDepth); + + // White flash radiating outward from center + float flash = uFlash * smoothstep(0.45, 0.0, r); + col = mix(col, vec3(1.0), clamp(flash, 0.0, 1.0)); + + gl_FragColor = vec4(col, 1.0); + } + `, +}; + +const warpPass = new ShaderPass(WarpTunnelShader); +warpPass.enabled = false; +composer.addPass(warpPass); + +/** + * Drives the warp tunnel animation each frame; call from animate(). + * @param {number} elapsed + */ +function updateWarpEffect(elapsed) { + if (!warpState) return; + + const t = (elapsed - warpState.startTime) / WARP_DURATION; + + if (t >= 1.0) { + warpState = null; + warpPass.enabled = false; + warpPass.uniforms.uProgress.value = 0; + warpPass.uniforms.uFlash.value = 0; + const ind = document.getElementById('warp-indicator'); + if (ind) ind.classList.remove('visible'); + return; + } + + // Progress envelope: ramp 0→1, hold, ramp 1→0 + let progress; + if (t < 0.32) { + progress = t / 0.32; + } else if (t < 0.62) { + progress = 1.0; + } else { + progress = 1.0 - (t - 0.62) / 0.38; + } + progress = Math.pow(Math.max(0, progress), 0.7); + + // Flash peaks at t ≈ 0.52 + const ft = (t - 0.43) / 0.18; + const flash = ft > 0 && ft < 1 ? (ft < 0.5 ? ft * 2 : 2 - ft * 2) : 0; + + // Spin accelerates with progress + warpState.spin += progress * 0.09; + + warpPass.uniforms.uTime.value = elapsed; + warpPass.uniforms.uProgress.value = progress; + warpPass.uniforms.uFlash.value = flash; + warpPass.uniforms.uSpin.value = warpState.spin; +} + +/** + * Triggers the warp tunnel entrance effect for a named portal. + * @param {string} name - Portal destination name + */ +function triggerWarp(name) { + if (warpState) return; + warpState = { startTime: clock.getElapsedTime(), portalName: name, spin: 0 }; + warpPass.enabled = true; + const ind = document.getElementById('warp-indicator'); + if (ind) { + ind.textContent = `WARPING TO ${name.toUpperCase()}`; + ind.classList.add('visible'); + } +} + // Orbit controls for free camera movement in photo mode const orbitControls = new OrbitControls(camera, renderer.domElement); orbitControls.enableDamping = true; @@ -656,6 +800,119 @@ for (let i = 0; i < RUNE_COUNT; i++) { runeSprites.push({ sprite, baseAngle, floatPhase: (i / RUNE_COUNT) * Math.PI * 2 }); } +// === PORTALS === +// Three floating gateway portals around the platform. Each has a glowing torus +// frame and an animated vortex interior. Click an inner disc to trigger warp. + +const PORTAL_RADIUS = 1.7; + +const PORTAL_DEFS = [ + { name: 'Batcave', angle: -Math.PI * 0.45, dist: 8.5, y: 3.0, color: new THREE.Color(0x4488ff) }, + { name: 'Workshop', angle: Math.PI * 0.45, dist: 8.5, y: 3.0, color: new THREE.Color(0x00ffcc) }, + { name: 'The Void', angle: Math.PI, dist: 8.5, y: 3.0, color: new THREE.Color(0xcc44ff) }, +]; + +// Shared GLSL for portal inner vortex (each portal gets its own material instance) +const PORTAL_VERT = /* glsl */` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } +`; +const PORTAL_FRAG = /* glsl */` + #define PI 3.14159265359 + uniform float uTime; + uniform vec3 uColor; + uniform float uHover; + varying vec2 vUv; + void main() { + vec2 p = vUv - 0.5; + float r = length(p) * 2.0; + if (r > 1.0) { gl_FragColor = vec4(0.0); return; } + float theta = atan(p.y, p.x); + float depth = 0.14 / max(r, 0.05); + // 5-arm spiral moving inward + float spiral = fract(theta * 5.0 / (PI * 2.0) + depth * 0.4 - uTime * 0.55); + float sBand = smoothstep(0.3, 0.45, spiral) * (1.0 - smoothstep(0.55, 0.7, spiral)); + // Ring bands + float ring = fract(depth - uTime * 0.35); + float rBand = smoothstep(0.0, 0.12, ring) * (1.0 - smoothstep(0.2, 0.35, ring)) * 0.55; + float pattern = max(sBand, rBand); + float edgeFade = 1.0 - smoothstep(0.65, 1.0, r); + float centerGlow = smoothstep(0.35, 0.0, r) * 0.45; + float alpha = (pattern * edgeFade + centerGlow) * (0.78 + uHover * 0.22); + vec3 col = uColor * (pattern + centerGlow * 0.6); + col = mix(col, vec3(1.0), centerGlow * 0.3); + gl_FragColor = vec4(col, alpha); + } +`; + +/** + * @type {Array<{mesh: THREE.Mesh, name: string, mat: THREE.ShaderMaterial, glow: THREE.PointLight}>} + */ +const portalObjects = []; + +for (const def of PORTAL_DEFS) { + const group = new THREE.Group(); + const x = Math.cos(def.angle) * def.dist; + const z = Math.sin(def.angle) * def.dist; + group.position.set(x, def.y, z); + group.lookAt(0, def.y, 0); // face the platform center + + // Outer glowing torus frame + const frameMat = new THREE.MeshStandardMaterial({ + color: 0x0a1828, + emissive: def.color, + emissiveIntensity: 0.55, + metalness: 0.9, + roughness: 0.15, + }); + group.add(new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RADIUS, 0.1, 16, 64), frameMat)); + + // Inner vortex disc + const innerMat = new THREE.ShaderMaterial({ + uniforms: { + uTime: { value: 0.0 }, + uColor: { value: def.color.clone() }, + uHover: { value: 0.0 }, + }, + vertexShader: PORTAL_VERT, + fragmentShader: PORTAL_FRAG, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + }); + const innerMesh = new THREE.Mesh(new THREE.CircleGeometry(PORTAL_RADIUS - 0.12, 64), innerMat); + innerMesh.userData.portalName = def.name; + group.add(innerMesh); + + // Soft point light behind the portal + const glow = new THREE.PointLight(def.color.getHex(), 0.8, 7); + glow.position.set(0, 0, -0.5); + group.add(glow); + + // Destination label above the portal + const labelCanvas = document.createElement('canvas'); + labelCanvas.width = 256; labelCanvas.height = 48; + const lctx = labelCanvas.getContext('2d'); + lctx.font = 'bold 22px "Courier New", monospace'; + lctx.fillStyle = '#' + def.color.getHexString(); + lctx.shadowColor = lctx.fillStyle; + lctx.shadowBlur = 14; + lctx.textAlign = 'center'; + lctx.textBaseline = 'middle'; + lctx.fillText(def.name.toUpperCase(), 128, 24); + const labelMat = new THREE.SpriteMaterial({ + map: new THREE.CanvasTexture(labelCanvas), + transparent: true, depthWrite: false, + }); + const labelSprite = new THREE.Sprite(labelMat); + labelSprite.scale.set(3.5, 0.65, 1); + labelSprite.position.set(0, PORTAL_RADIUS + 0.6, 0); + group.add(labelSprite); + + scene.add(group); + portalObjects.push({ mesh: innerMesh, name: def.name, mat: innerMat, glow }); +} + // === ANIMATION LOOP === const clock = new THREE.Clock(); @@ -770,11 +1027,49 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // Animate portal vortex shaders and glow pulse + for (let i = 0; i < portalObjects.length; i++) { + const p = portalObjects[i]; + p.mat.uniforms.uTime.value = elapsed; + p.glow.intensity = 0.65 + Math.sin(elapsed * 1.5 + i * 1.1) * 0.3; + } + + // Drive warp tunnel transition + updateWarpEffect(elapsed); + composer.render(); } animate(); +// === PORTAL RAYCASTER === +// Click on a portal's inner vortex disc to trigger the warp tunnel. + +const portalRaycaster = new THREE.Raycaster(); +const portalClickNdc = new THREE.Vector2(); + +renderer.domElement.addEventListener('click', (e) => { + if (photoMode || overviewMode || warpState) return; + + portalClickNdc.x = (e.clientX / window.innerWidth) * 2 - 1; + portalClickNdc.y = -(e.clientY / window.innerHeight) * 2 + 1; + + portalRaycaster.setFromCamera(portalClickNdc, camera); + const hits = portalRaycaster.intersectObjects(portalObjects.map(p => p.mesh)); + if (hits.length > 0) { + triggerWarp(hits[0].object.userData.portalName); + } +}); + +// 'W' key cycles through portals as a demo trigger +let _warpDemoIdx = 0; +document.addEventListener('keydown', (e) => { + if ((e.key === 'w' || e.key === 'W') && !e.metaKey && !e.ctrlKey && !e.altKey) { + triggerWarp(PORTAL_DEFS[_warpDemoIdx % PORTAL_DEFS.length].name); + _warpDemoIdx++; + } +}); + // === DEBUG MODE === let debugMode = false; diff --git a/index.html b/index.html index 69d6b65..36b70c6 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,7 @@
⚡ SOVEREIGNTY ⚡
+