[claude] Session power meter — 3D balance visualizer (#16) #23
205
app.js
205
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) {
|
||||
|
||||
21
index.html
21
index.html
@@ -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 <span>Mouse</span> look <span>Enter</span> chat
|
||||
|
||||
127
style.css
127
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;
|
||||
|
||||
Reference in New Issue
Block a user