diff --git a/the-matrix/js/interaction.js b/the-matrix/js/interaction.js index 39a25d3..7bf20b4 100644 --- a/the-matrix/js/interaction.js +++ b/the-matrix/js/interaction.js @@ -15,6 +15,8 @@ let _canvas = null; let _camera = null; let _timmyGroup = null; let _applySlap = null; +let _meterObjects = []; +let _meterCallback = null; const _raycaster = new THREE.Raycaster(); const _pointer = new THREE.Vector2(); @@ -33,6 +35,11 @@ export function registerSlapTarget(timmyGroup, applyFn) { _applySlap = applyFn; } +export function registerMeterTarget(objects, onClickFn) { + _meterObjects = objects || []; + _meterCallback = onClickFn; +} + export function initInteraction(camera, renderer) { _camera = camera; _canvas = renderer.domElement; @@ -49,9 +56,11 @@ export function disposeInteraction() { _canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true }); _canvas = null; } - _camera = null; - _timmyGroup = null; - _applySlap = null; + _camera = null; + _timmyGroup = null; + _applySlap = null; + _meterObjects = []; + _meterCallback = null; } // ── Internal ────────────────────────────────────────────────────────────────── @@ -71,6 +80,17 @@ function _distPx(sx, sy, screenPos) { return Math.sqrt(dx * dx + dy * dy); } +function _hitTestMeter(clientX, clientY) { + if (!_meterObjects.length || !_meterCallback || !_camera || !_canvas) return false; + const rect = _canvas.getBoundingClientRect(); + _pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1; + _pointer.y = ((clientY - rect.top) / rect.height) * -2 + 1; + _raycaster.setFromCamera(_pointer, _camera); + const hits = _raycaster.intersectObjects(_meterObjects, false); + if (hits.length > 0) { _meterCallback(); return true; } + return false; +} + function _hitTestTimmy(clientX, clientY) { if (!_timmyGroup || !_applySlap || !_camera || !_canvas) return false; @@ -135,7 +155,8 @@ function _onPointerDown(event) { const dt = Date.now() - _tapStart; const moved = Math.hypot(upEvent.clientX - _tapX, upEvent.clientY - _tapY); if (dt < 220 && moved < 18) { - // Short tap — try slap then inspect + // Short tap — try meter, then slap, then inspect + if (_hitTestMeter(upEvent.clientX, upEvent.clientY)) return; if (!_hitTestTimmy(upEvent.clientX, upEvent.clientY)) { _tryAgentInspect(upEvent.clientX, upEvent.clientY); } @@ -150,6 +171,17 @@ function _onPointerDown(event) { // Cast from screen center in FPS mode _pointer.set(0, 0); _raycaster.setFromCamera(_pointer, _camera); + + // Check power meter first + if (_meterObjects.length && _meterCallback) { + const meterHits = _raycaster.intersectObjects(_meterObjects, false); + if (meterHits.length > 0) { + _meterCallback(); + event.stopImmediatePropagation(); + return; + } + } + if (_timmyGroup && _applySlap) { const hits = _raycaster.intersectObject(_timmyGroup, true); if (hits.length > 0) { diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 3c25033..91c65a9 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -7,10 +7,10 @@ import { } from './agents.js'; import { initEffects, updateEffects, disposeEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; -import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js'; +import { initInteraction, disposeInteraction, registerSlapTarget, registerMeterTarget } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initPaymentPanel } from './payment.js'; -import { initSessionPanel } from './session.js'; +import { initSessionPanel, openSessionPanel, syncPowerMeterState } from './session.js'; import { initNostrIdentity } from './nostr-identity.js'; import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js'; import { setEdgeWorkerReady } from './ui.js'; @@ -18,6 +18,14 @@ import { initTimmyId } from './timmy-id.js'; import { AGENT_DEFS } from './agent-defs.js'; import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js'; import { initHudLabels, updateHudLabels, disposeHudLabels } from './hud-labels.js'; +import { + initPowerMeter, + updatePowerMeter, + disposePowerMeter, + getPowerMeterObjects, + triggerPowerMeterClick, + onPowerMeterClick, +} from './power-meter.js'; let running = false; let canvas = null; @@ -29,6 +37,7 @@ function buildWorld(firstInit, stateSnapshot) { initEffects(scene); initAgents(scene); + initPowerMeter(scene, renderer); if (stateSnapshot) applyAgentStates(stateSnapshot); @@ -38,6 +47,10 @@ function buildWorld(firstInit, stateSnapshot) { initInteraction(camera, renderer); registerSlapTarget(getTimmyGroup(), applySlap); + // Power meter click → open session panel + onPowerMeterClick(openSessionPanel); + registerMeterTarget(getPowerMeterObjects(), triggerPowerMeterClick); + // AR floating labels initHudLabels(camera, AGENT_DEFS, TIMMY_WORLD_POS); @@ -50,6 +63,9 @@ function buildWorld(firstInit, stateSnapshot) { warmupEdgeWorker(); onEdgeWorkerReady(() => setEdgeWorkerReady()); void initTimmyId(); + } else { + // On scene rebuild, re-apply session state to the fresh meter + syncPowerMeterState(); } const ac = new AbortController(); @@ -81,6 +97,7 @@ function buildWorld(firstInit, stateSnapshot) { updateEffects(now); updateAgents(now); + updatePowerMeter(now, camera, renderer); updateUI({ fps: currentFps, agentCount: getAgentCount(), @@ -120,6 +137,7 @@ function teardown({ scene, renderer, ac }) { disposeNavigation(); disposeInteraction(); disposeHudLabels(); + disposePowerMeter(); disposeEffects(); disposeAgents(); disposeWorld(renderer, scene); diff --git a/the-matrix/js/power-meter.js b/the-matrix/js/power-meter.js new file mode 100644 index 0000000..3ecc87a --- /dev/null +++ b/the-matrix/js/power-meter.js @@ -0,0 +1,296 @@ +/** + * power-meter.js — 3D Session Power Meter + * + * A glowing orb positioned in the Workshop scene that fills proportionally to + * the session balance. Pulses on payment received, drains on job deduction. + * Clicking the meter (or its HUD label) opens the session panel. + * + * Public API: + * initPowerMeter(scene, renderer) + * updatePowerMeter(time, camera, renderer) + * setPowerMeterBalance(sats, maxSats) + * pulsePowerMeter() + * drainPowerMeter() + * showPowerMeter(visible) + * getPowerMeterObjects() — for raycasting in interaction.js + * triggerPowerMeterClick() — called by interaction.js on hit + * onPowerMeterClick(callback) + * disposePowerMeter() + */ + +import * as THREE from 'three'; + +// Scene position: right side, near desk level, visible from default camera +const METER_POS = new THREE.Vector3(5.0, 1.0, -1.0); +const SPHERE_R = 0.52; + +// Fill colors: empty → mid → full +const COLOR_EMPTY = new THREE.Color(0xff3300); +const COLOR_MID = new THREE.Color(0xffaa00); +const COLOR_FULL = new THREE.Color(0x00ffcc); +const COLOR_PULSE = new THREE.Color(0xffffff); +const COLOR_DRAIN = new THREE.Color(0xff2200); + +let _scene = null; +let _group = null; +let _fillMesh = null; +let _clipPlane = null; +let _glowLight = null; +let _hudEl = null; +let _ringMesh = null; + +let _fillLevel = 0.0; // currently rendered fill (0–1), smoothed +let _targetFill = 0.0; // target, set from balance +let _currentSats = 0; +let _visible = false; + +// Pulse / drain animation timers (decay each frame) +let _pulseT = 0.0; +let _drainT = 0.0; + +const _raycastObjects = []; +let _clickCallback = null; +const _projVec = new THREE.Vector3(); + +// ── Init ────────────────────────────────────────────────────────────────────── + +export function initPowerMeter(scene, renderer) { + _scene = scene; + renderer.localClippingEnabled = true; + + _group = new THREE.Group(); + _group.position.copy(METER_POS); + _group.visible = false; + scene.add(_group); + + // Clipping plane: show fill from bottom up. + // Plane normal (0,-1,0), constant c → keeps points where -y + c ≥ 0 → y ≤ c. + // At fillLevel f: c = R*(2f-1). f=0 → c=-R (nothing shown), f=1 → c=R (all shown). + _clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), -SPHERE_R); + + // Outer shell — transparent glass orb + const outerGeo = new THREE.SphereGeometry(SPHERE_R, 24, 18); + const outerMat = new THREE.MeshStandardMaterial({ + color: 0x8899cc, + transparent: true, + opacity: 0.13, + roughness: 0.05, + metalness: 0.4, + side: THREE.FrontSide, + }); + const outerMesh = new THREE.Mesh(outerGeo, outerMat); + _group.add(outerMesh); + _raycastObjects.push(outerMesh); + + // Equatorial ring glow + const ringGeo = new THREE.TorusGeometry(SPHERE_R, 0.022, 8, 44); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x44aaff, + transparent: true, + opacity: 0.35, + }); + _ringMesh = new THREE.Mesh(ringGeo, ringMat); + _ringMesh.rotation.x = Math.PI / 2; + _group.add(_ringMesh); + + // Fill sphere — clipped from bottom + const fillGeo = new THREE.SphereGeometry(SPHERE_R * 0.91, 24, 18); + const fillMat = new THREE.MeshStandardMaterial({ + color: COLOR_EMPTY.clone(), + emissive: COLOR_EMPTY.clone(), + emissiveIntensity: 0.5, + roughness: 0.25, + metalness: 0.1, + clippingPlanes: [_clipPlane], + clipShadows: true, + }); + _fillMesh = new THREE.Mesh(fillGeo, fillMat); + _group.add(_fillMesh); + _raycastObjects.push(_fillMesh); + + // Lightning bolt icon below the orb + _buildBolt(_group); + + // Point light — intensity tied to fill level + _glowLight = new THREE.PointLight(COLOR_EMPTY.clone(), 0, 5); + _group.add(_glowLight); + + // HTML overlay for balance text (pointer-events: auto so it's clickable) + _hudEl = document.createElement('div'); + _hudEl.id = 'power-meter-hud'; + _hudEl.style.cssText = [ + 'position:fixed;transform:translate(-50%,0)', + 'z-index:20;text-align:center;cursor:pointer', + 'font-family:Courier New,monospace;font-size:11px;letter-spacing:1px', + 'color:#ffe566;text-shadow:0 0 8px #ffaa0088', + 'background:rgba(0,0,0,0.6);padding:2px 8px;border-radius:4px', + 'border:1px solid rgba(255,200,0,0.3)', + 'display:none;user-select:none', + ].join(';'); + _hudEl.addEventListener('click', () => { if (_clickCallback) _clickCallback(); }); + document.body.appendChild(_hudEl); +} + +function _buildBolt(group) { + const mat = new THREE.MeshBasicMaterial({ color: 0xffcc00 }); + + // Upper segment — tilted box + const top = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat); + top.position.set(0.03, -(SPHERE_R + 0.17), 0); + top.rotation.z = -0.32; + group.add(top); + + // Lower segment + const bot = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.2, 0.055), mat.clone()); + bot.position.set(-0.03, -(SPHERE_R + 0.40), 0); + bot.rotation.z = -0.32; + group.add(bot); +} + +// ── Per-frame update ────────────────────────────────────────────────────────── + +export function updatePowerMeter(time, camera, renderer) { + if (!_group || !_fillMesh) return; + + const t = time * 0.001; // seconds + + // Smooth fill animation (faster when far away) + const diff = _targetFill - _fillLevel; + _fillLevel += diff * 0.07; + + // Update clip plane + _clipPlane.constant = SPHERE_R * (2 * _fillLevel - 1); + + // Base fill color + const col = _lerpFillColor(_fillLevel); + + // Pulse overlay (payment received — bright white-yellow flash) + if (_pulseT > 0) { + col.lerp(COLOR_PULSE, _pulseT * 0.8); + _pulseT = Math.max(0, _pulseT - 0.035); + } + + // Drain overlay (job cost — red flash) + if (_drainT > 0) { + col.lerp(COLOR_DRAIN, _drainT * 0.55); + _drainT = Math.max(0, _drainT - 0.045); + } + + _fillMesh.material.color.copy(col); + _fillMesh.material.emissive.copy(col); + + // Pulsing emissive glow + const baseEmit = 0.25 + _fillLevel * 0.6; + const pulseGlow = Math.sin(t * 2.2) * 0.08 + _pulseT * 1.8; + _fillMesh.material.emissiveIntensity = baseEmit + pulseGlow; + + // Ring color matches fill + if (_ringMesh) _ringMesh.material.color.copy(col); + + // Point light + _glowLight.color.copy(col); + _glowLight.intensity = _visible + ? (0.4 + _fillLevel * 2.2 + _pulseT * 3.5) + : 0; + + // Gentle sway + _group.rotation.y = Math.sin(t * 0.45) * 0.07; + + // Project HUD overlay to screen + if (_visible && _hudEl && camera && renderer) { + _projVec.copy(_group.position); + _projVec.y -= SPHERE_R + 0.55; // place label below bolt icon + _projVec.project(camera); + + if (_projVec.z < 1.0) { + const W = renderer.domElement.clientWidth || window.innerWidth; + const H = renderer.domElement.clientHeight || window.innerHeight; + const sx = (_projVec.x * 0.5 + 0.5) * W; + const sy = (_projVec.y * -0.5 + 0.5) * H; + _hudEl.style.left = `${sx}px`; + _hudEl.style.top = `${sy}px`; + _hudEl.style.display = ''; + } else { + _hudEl.style.display = 'none'; + } + } +} + +function _lerpFillColor(f) { + const c = new THREE.Color(); + if (f < 0.5) { + c.lerpColors(COLOR_EMPTY, COLOR_MID, f * 2); + } else { + c.lerpColors(COLOR_MID, COLOR_FULL, (f - 0.5) * 2); + } + return c; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Update balance and recompute fill ratio. + * @param {number} sats current balance + * @param {number} maxSats session deposit max (denominator) + */ +export function setPowerMeterBalance(sats, maxSats) { + _currentSats = sats; + _targetFill = maxSats > 0 ? Math.max(0, Math.min(1, sats / maxSats)) : 0; + if (_hudEl) _hudEl.textContent = `${sats} ⚡`; +} + +/** Bright flash — call when a payment is received. */ +export function pulsePowerMeter() { + _pulseT = 1.0; +} + +/** Red flash — call when a job cost is deducted. */ +export function drainPowerMeter() { + _drainT = 1.0; +} + +/** Show or hide the meter based on session active state. */ +export function showPowerMeter(visible) { + _visible = visible; + if (_group) _group.visible = visible; + if (_hudEl) { + if (!visible) _hudEl.style.display = 'none'; + // If visible, updatePowerMeter will set display on next frame + } +} + +/** Returns meshes used for click raycasting in interaction.js. */ +export function getPowerMeterObjects() { + return _raycastObjects; +} + +/** Called by interaction.js when the meter is clicked in FPS mode. */ +export function triggerPowerMeterClick() { + if (_clickCallback) _clickCallback(); +} + +/** Register callback that opens the session panel. */ +export function onPowerMeterClick(cb) { + _clickCallback = cb; +} + +export function disposePowerMeter() { + if (_group) { + _group.traverse(obj => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; + mats.forEach(m => m.dispose()); + } + }); + _scene?.remove(_group); + _group = null; + } + _hudEl?.remove(); + _hudEl = null; + _fillMesh = null; + _ringMesh = null; + _glowLight = null; + _clipPlane = null; + _raycastObjects.length = 0; +} diff --git a/the-matrix/js/session.js b/the-matrix/js/session.js index b505770..ff02023 100644 --- a/the-matrix/js/session.js +++ b/the-matrix/js/session.js @@ -16,6 +16,12 @@ import { setSpeechBubble, setMood } from './agents.js'; import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js'; import { getOrRefreshToken } from './nostr-identity.js'; import { sentiment } from './edge-worker-client.js'; +import { + setPowerMeterBalance, + pulsePowerMeter, + drainPowerMeter, + showPowerMeter, +} from './power-meter.js'; const API = '/api'; const LS_KEY = 'timmy_session_v1'; @@ -33,6 +39,7 @@ let _pollTimer = null; let _inFlight = false; let _selectedSats = 500; // deposit amount selection let _topupSats = 500; // topup amount selection +let _sessionMaxSats = 500; // max sats for fill ratio (initial deposit) // ── Public API ──────────────────────────────────────────────────────────────── @@ -105,6 +112,18 @@ export function isSessionActive() { return _sessionState === 'active' || _sessionState === 'paused'; } +export function openSessionPanel() { + _openPanel(); +} + +/** Re-sync power meter after scene rebuild. */ +export function syncPowerMeterState() { + showPowerMeter(isSessionActive()); + if (isSessionActive()) { + setPowerMeterBalance(_balanceSats, _sessionMaxSats); + } +} + // Called by ui.js when user submits the input bar while session is active export async function sessionSendHandler(text) { if (!_sessionId || !_macaroon || _inFlight) return; @@ -156,6 +175,7 @@ export async function sessionSendHandler(text) { _sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active'; _saveToStorage(); _applySessionUI(); + drainPowerMeter(); const reply = data.result || data.reason || '…'; setSpeechBubble(reply); @@ -285,11 +305,13 @@ function _startDepositPolling() { const data = await res.json(); if (data.state === 'active') { - _macaroon = data.macaroon; - _balanceSats = data.balanceSats; - _sessionState = 'active'; + _macaroon = data.macaroon; + _balanceSats = data.balanceSats; + _sessionMaxSats = data.balanceSats; // initial deposit becomes the max + _sessionState = 'active'; _saveToStorage(); _applySessionUI(); + pulsePowerMeter(); _closePanel(); // panel auto-closes; user types in input bar appendSystemMessage(`Session active — ${_balanceSats} sats`); return; @@ -401,11 +423,13 @@ function _startTopupPolling() { const data = await res.json(); if (data.balanceSats > prevBalance || data.state === 'active') { + _sessionMaxSats = Math.max(_sessionMaxSats, data.balanceSats); _balanceSats = data.balanceSats; _macaroon = data.macaroon || _macaroon; _sessionState = data.state === 'active' ? 'active' : _sessionState; _saveToStorage(); _applySessionUI(); + pulsePowerMeter(); _setStep('active'); _updateActiveStep(); _setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66'); @@ -486,6 +510,12 @@ function _applySessionUI() { // Show low-balance overlay whenever session exists but balance is too low const lowBal = anyActive && _balanceSats < MIN_BALANCE; + // 3D power meter + showPowerMeter(anyActive); + if (anyActive) { + setPowerMeterBalance(_balanceSats, _sessionMaxSats); + } + // HUD balance: "Balance: X sats ⚡ Top Up" const $hud = document.getElementById('session-hud'); if ($hud) { @@ -526,11 +556,13 @@ function _updateActiveStep() { } function _clearSession() { - _sessionId = null; - _macaroon = null; - _balanceSats = 0; - _sessionState = null; + _sessionId = null; + _macaroon = null; + _balanceSats = 0; + _sessionState = null; + _sessionMaxSats = 500; localStorage.removeItem(LS_KEY); + showPowerMeter(false); setInputBarSessionMode(false); setSessionSendHandler(null); const $hud = document.getElementById('session-hud'); diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index ad1d412..a1ed75f 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -2,6 +2,7 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './age import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; +import { setPowerMeterBalance, pulsePowerMeter, drainPowerMeter } from './power-meter.js'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; @@ -151,6 +152,17 @@ function handleMessage(msg) { break; } + case 'session_balance_update': { + // Server-pushed balance update (e.g. after payment confirmed or job deducted) + if (typeof msg.balanceSats === 'number') { + const maxSats = msg.maxSats ?? 0; + setPowerMeterBalance(msg.balanceSats, maxSats); + if (msg.reason === 'payment') pulsePowerMeter(); + if (msg.reason === 'deduction') drainPowerMeter(); + } + break; + } + case 'agent_count': case 'visitor_count': break;