Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
d2706b18c2 feat: session power meter — 3D balance visualizer
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 <noreply@anthropic.com>
2026-03-23 21:23:20 -04:00
3 changed files with 353 additions and 0 deletions

205
app.js
View File

@@ -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) {

View File

@@ -95,6 +95,27 @@
</div>
</div>
<!-- Session Power Meter HUD (top-right) -->
<div id="power-meter-hud" class="power-meter-hud">
<div class="pm-header">
<span class="pm-icon"></span>
<span class="pm-title">SESSION POWER</span>
<span class="pm-badge">SOVEREIGN</span>
</div>
<div class="pm-bar-track">
<div class="pm-bar" id="pm-bar"></div>
</div>
<div class="pm-stats">
<span id="pm-pct" class="pm-pct">100%</span>
<span id="pm-credits" class="pm-credits">10,000 CR</span>
</div>
<div id="pm-warn" class="pm-warn" style="display:none;">
<span class="pm-warn-icon"></span>
<span>Low power — top up session</span>
</div>
<div class="pm-tagline">Fund once · Ask many models</div>
</div>
<!-- Minimap / Controls hint -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat

127
style.css
View File

@@ -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;