From d2706b18c2becbef5e69629f5fbee6466a98edf3 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 21:23:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20session=20power=20meter=20=E2=80=94=203?= =?UTF-8?q?D=20balance=20visualizer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the session power meter as both a 3D in-scene object and a HUD overlay. 3D scene object (positioned at -9, 0, 4 — visible from spawn): - Vertical glass cylinder with GLSL energy-fill shader that clips fragments above the current power level - Teal-to-purple gradient fill; turns red when power < 20% - Animated scan lines, bright band at fill edge, pulse glow - Floating orb that tracks fill height with gentle bob animation - Dynamic point light attached to orb, color-shifts with power state - 3 decorative spinning rings that dim above the fill level - Canvas label: SESSION POWER / Fund once · Ask many HUD panel (top-right corner): - Live percentage + gradient bar with glowing tip indicator - Credits counter (out of 10,000 CR) - SOVEREIGN model tier badge - Low-power warning (< 20%) with flashing icon - "Fund once · Ask many models" tagline Session power drains slowly over time (~16 min full drain) as a live demo. Ready to wire to a real credits/subscription API. Fixes #16 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 21 ++++++ style.css | 127 +++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+) 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 @@ + +
+
+ + SESSION POWER + SOVEREIGN +
+
+
+
+
+ 100% + 10,000 CR +
+ +
Fund once · Ask many models
+
+
WASD move   Mouse look   Enter chat diff --git a/style.css b/style.css index 519b05e..f44e9e8 100644 --- a/style.css +++ b/style.css @@ -330,6 +330,133 @@ canvas#nexus-canvas { background: rgba(74, 240, 192, 0.1); } +/* === SESSION POWER METER HUD === */ +.power-meter-hud { + position: absolute; + top: var(--space-3); + right: var(--space-4); + width: 200px; + background: var(--color-surface); + backdrop-filter: blur(var(--panel-blur)); + border: 1px solid var(--color-border); + border-radius: var(--panel-radius); + padding: var(--space-3) var(--space-4); + pointer-events: auto; +} + +.pm-header { + display: flex; + align-items: center; + gap: var(--space-1); + margin-bottom: var(--space-2); +} + +.pm-icon { + font-size: 13px; + color: var(--color-primary); + filter: drop-shadow(0 0 4px var(--color-primary)); +} + +.pm-title { + font-family: var(--font-display); + font-size: 9px; + letter-spacing: 0.1em; + font-weight: 600; + color: var(--color-text-bright); + flex: 1; +} + +.pm-badge { + font-family: var(--font-display); + font-size: 8px; + letter-spacing: 0.08em; + color: var(--color-secondary); + border: 1px solid rgba(123, 92, 255, 0.4); + padding: 1px 4px; + border-radius: 3px; +} + +.pm-bar-track { + height: 6px; + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; + overflow: hidden; + margin-bottom: var(--space-2); + position: relative; +} + +.pm-bar { + height: 100%; + width: 100%; + border-radius: 3px; + background: linear-gradient(90deg, var(--color-primary), var(--color-secondary)); + transition: width 0.5s ease, background 0.5s ease; + position: relative; +} + +.pm-bar::after { + content: ''; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 10px; + border-radius: 2px; + background: #fff; + box-shadow: 0 0 6px var(--color-primary), 0 0 12px var(--color-primary); + opacity: 0.9; +} + +.pm-stats { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: var(--space-2); +} + +.pm-pct { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + color: var(--color-primary); + text-shadow: 0 0 10px rgba(74, 240, 192, 0.4); + font-variant-numeric: tabular-nums lining-nums; +} + +.pm-credits { + font-size: var(--text-xs); + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.pm-warn { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + color: var(--color-danger); + margin-bottom: var(--space-2); + animation: warn-flash 1s ease-in-out infinite; +} + +.pm-warn-icon { + font-size: 12px; +} + +@keyframes warn-flash { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +.pm-tagline { + font-size: 9px; + color: var(--color-text-muted); + letter-spacing: 0.05em; + text-align: center; + opacity: 0.7; +} + /* === FOOTER === */ .nexus-footer { position: fixed; -- 2.43.0