diff --git a/app.js b/app.js index 60689a0..ad8363d 100644 --- a/app.js +++ b/app.js @@ -35,6 +35,11 @@ let frameCount = 0, lastFPSTime = 0, fps = 0; let chatOpen = true; let loadProgress = 0; +// Session power state +let sessionPower = 1.0; // 0.0 → 1.0 +const SESSION_CREDITS_MAX = 10000; +let sessionPowerMeter = null; // { fillMat, glowMat, orbMesh, orb, lightRef } + // ═══ INIT ═══ function init() { clock = new THREE.Clock(); @@ -77,6 +82,7 @@ function init() { createDustParticles(); updateLoad(85); createAmbientStructures(); + createSessionPowerMeter(); updateLoad(90); // Post-processing @@ -787,6 +793,201 @@ function createAmbientStructures() { scene.add(pedestal); } +// ═══ SESSION POWER METER ═══ +function createSessionPowerMeter() { + const group = new THREE.Group(); + // Place to the left, slightly in front — visible from spawn at (0,2,12) + group.position.set(-9, 0, 4); + group.name = 'power-meter-group'; + + const METER_H = 4.0; + const METER_R = 0.35; + + // --- Outer glass shell --- + const glassGeo = new THREE.CylinderGeometry(METER_R + 0.08, METER_R + 0.08, METER_H, 32, 1, true); + const glassMat = new THREE.MeshPhysicalMaterial({ + color: 0x88ccff, + transparent: true, + opacity: 0.12, + roughness: 0, + metalness: 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + group.add(new THREE.Mesh(glassGeo, glassMat)); + + // Glass rim rings (top + bottom) + for (const yOff of [METER_H / 2, -METER_H / 2]) { + const rimGeo = new THREE.TorusGeometry(METER_R + 0.1, 0.04, 8, 32); + const rimMat = new THREE.MeshStandardMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 1.2, + roughness: 0.2, + metalness: 0.8, + }); + const rim = new THREE.Mesh(rimGeo, rimMat); + rim.rotation.x = Math.PI / 2; + rim.position.y = yOff; + group.add(rim); + } + + // --- Energy fill (shader-driven, clips at uPower) --- + const fillGeo = new THREE.CylinderGeometry(METER_R - 0.02, METER_R - 0.02, METER_H, 32, 64, true); + const fillMat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + uniforms: { + uTime: { value: 0 }, + uPower: { value: 1.0 }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform float uPower; + varying vec2 vUv; + + void main() { + // vUv.y: 0 = bottom, 1 = top + if (vUv.y > uPower) discard; + + float t = (uPower > 0.001) ? (vUv.y / uPower) : 0.0; + + // Teal → purple gradient based on remaining power color + vec3 colLow = vec3(1.0, 0.27, 0.40); // red danger + vec3 colMid = vec3(0.29, 0.94, 0.75); // teal + vec3 colHigh = vec3(0.48, 0.36, 1.00); // purple + + // Blend between danger and normal based on power level + float powerBlend = smoothstep(0.0, 0.25, uPower); + vec3 baseBot = mix(colLow, colMid, powerBlend); + vec3 baseTop = mix(colLow, colHigh, powerBlend); + vec3 col = mix(baseBot, baseTop, t); + + // Scan lines + float scan = sin(vUv.y * 120.0 - uTime * 3.0) * 0.5 + 0.5; + col += col * scan * 0.08; + + // Bright band at fill edge + float edge = smoothstep(0.0, 0.04, uPower - vUv.y); + col += vec3(1.0) * (1.0 - edge) * 0.6; + + // Pulse glow + float pulse = sin(uTime * 2.5) * 0.5 + 0.5; + col += col * pulse * 0.15; + + float alpha = mix(0.55, 0.85, t) * edge; + gl_FragColor = vec4(col, alpha + 0.1); + } + `, + }); + const fillMesh = new THREE.Mesh(fillGeo, fillMat); + group.add(fillMesh); + + // --- Floating orb that tracks fill height --- + const orbGeo = new THREE.SphereGeometry(0.12, 16, 16); + const orbMat = new THREE.MeshStandardMaterial({ + color: NEXUS.colors.primary, + emissive: NEXUS.colors.primary, + emissiveIntensity: 3, + roughness: 0, + metalness: 1, + }); + const orb = new THREE.Mesh(orbGeo, orbMat); + orb.position.set(0, METER_H / 2 - METER_H * (1.0 - sessionPower) - METER_H / 2, 0); + group.add(orb); + + // --- Dynamic point light at orb position --- + const powerLight = new THREE.PointLight(NEXUS.colors.primary, 2.5, 8, 2); + powerLight.position.copy(orb.position); + group.add(powerLight); + + // --- 3 decorative rings (spin, dim above fill) --- + const rings = []; + for (let i = 0; i < 3; i++) { + const rGeo = new THREE.TorusGeometry(METER_R + 0.25 + i * 0.12, 0.025, 6, 32); + const rMat = new THREE.MeshBasicMaterial({ + color: NEXUS.colors.secondary, + transparent: true, + opacity: 0.7, + }); + const ring = new THREE.Mesh(rGeo, rMat); + ring.position.y = -METER_H / 2 + (i + 1) * (METER_H / 4); + ring.rotation.x = Math.PI / 2; + group.add(ring); + rings.push({ mesh: ring, baseY: ring.position.y, index: i }); + } + + // --- Canvas label --- + const lc = document.createElement('canvas'); + lc.width = 256; lc.height = 80; + const lctx = lc.getContext('2d'); + lctx.font = 'bold 20px "Orbitron", sans-serif'; + lctx.fillStyle = '#4af0c0'; + lctx.textAlign = 'center'; + lctx.fillText('SESSION POWER', 128, 28); + lctx.font = '13px "JetBrains Mono", monospace'; + lctx.fillStyle = '#7b5cff'; + lctx.fillText('Fund once · Ask many', 128, 56); + const lTex = new THREE.CanvasTexture(lc); + const lMat = new THREE.MeshBasicMaterial({ map: lTex, transparent: true, side: THREE.DoubleSide, depthWrite: false }); + const label = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 0.7), lMat); + label.position.y = METER_H / 2 + 0.65; + group.add(label); + + scene.add(group); + + sessionPowerMeter = { fillMat, orb, powerLight, rings, group, METER_H }; +} + +function updateSessionPowerMeter(elapsed) { + if (!sessionPowerMeter) return; + const { fillMat, orb, powerLight, rings, METER_H } = sessionPowerMeter; + + fillMat.uniforms.uTime.value = elapsed; + fillMat.uniforms.uPower.value = sessionPower; + + // Orb tracks the fill top edge + const fillTopY = -METER_H / 2 + sessionPower * METER_H; + orb.position.y = fillTopY + Math.sin(elapsed * 3.0) * 0.06; + orb.material.emissive.setHex(sessionPower < 0.2 ? NEXUS.colors.danger : NEXUS.colors.primary); + powerLight.position.y = orb.position.y; + powerLight.color.setHex(sessionPower < 0.2 ? NEXUS.colors.danger : NEXUS.colors.primary); + powerLight.intensity = 1.5 + Math.sin(elapsed * 2.5) * 0.5; + + // Rings: dim those above current fill level, spin at different rates + rings.forEach(({ mesh, baseY, index }) => { + const inFill = baseY < fillTopY; + mesh.material.opacity = inFill ? 0.7 : 0.15; + mesh.rotation.z = elapsed * (0.4 + index * 0.25); + }); + + // HUD update + const pct = Math.round(sessionPower * 100); + const credits = Math.round(sessionPower * SESSION_CREDITS_MAX).toLocaleString(); + const pmPct = document.getElementById('pm-pct'); + const pmBar = document.getElementById('pm-bar'); + const pmCredits = document.getElementById('pm-credits'); + const pmWarn = document.getElementById('pm-warn'); + if (pmPct) pmPct.textContent = pct + '%'; + if (pmCredits) pmCredits.textContent = credits + ' CR'; + if (pmBar) { + pmBar.style.width = pct + '%'; + const isLow = sessionPower < 0.2; + pmBar.style.background = isLow + ? 'linear-gradient(90deg, #ff2244, #ff6644)' + : 'linear-gradient(90deg, #4af0c0, #7b5cff)'; + } + if (pmWarn) pmWarn.style.display = sessionPower < 0.2 ? 'flex' : 'none'; +} + // ═══ CONTROLS ═══ function setupControls() { document.addEventListener('keydown', (e) => { @@ -941,6 +1142,10 @@ function gameLoop() { } } + // Drain session power slowly (full drain in ~16 min for demo) + sessionPower = Math.max(0, sessionPower - delta * 0.001); + updateSessionPowerMeter(elapsed); + // Animate nexus core const core = scene.getObjectByName('nexus-core'); if (core) { diff --git a/index.html b/index.html index 3a2c6ea..36ce31b 100644 --- a/index.html +++ b/index.html @@ -95,6 +95,27 @@ + +