// === ENERGY BEAM + SOVEREIGNTY METER + RUNE RING === import * as THREE from 'three'; import { NEXUS } from './constants.js'; import { scene } from './scene-setup.js'; import { S } from './state.js'; // === ENERGY BEAM === const ENERGY_BEAM_RADIUS = 0.2; const ENERGY_BEAM_HEIGHT = 50; const ENERGY_BEAM_Y = 0; const ENERGY_BEAM_X = -10; const ENERGY_BEAM_Z = -10; const energyBeamGeometry = new THREE.CylinderGeometry(ENERGY_BEAM_RADIUS, ENERGY_BEAM_RADIUS * 2.5, ENERGY_BEAM_HEIGHT, 32, 16, true); export const energyBeamMaterial = new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, emissive: NEXUS.colors.accent, emissiveIntensity: 0.8, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, depthWrite: false }); const energyBeam = new THREE.Mesh(energyBeamGeometry, energyBeamMaterial); energyBeam.position.set(ENERGY_BEAM_X, ENERGY_BEAM_Y + ENERGY_BEAM_HEIGHT / 2, ENERGY_BEAM_Z); scene.add(energyBeam); export function animateEnergyBeam() { S.energyBeamPulse += 0.02; const agentIntensity = S._activeAgentCount === 0 ? 0.1 : Math.min(0.1 + S._activeAgentCount * 0.3, 1.0); const pulseEffect = Math.sin(S.energyBeamPulse) * 0.15 * agentIntensity; energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect; } // === SOVEREIGNTY METER === export const sovereigntyGroup = new THREE.Group(); sovereigntyGroup.position.set(0, 3.8, 0); const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64); const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 }); sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat)); function sovereigntyHexColor(score) { if (score >= 80) return 0x00ff88; if (score >= 40) return 0xffcc00; return 0xff4444; } function buildScoreArcGeo(score) { return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2); } const scoreArcMat = new THREE.MeshBasicMaterial({ color: sovereigntyHexColor(S.sovereigntyScore), transparent: true, opacity: 0.9, }); const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(S.sovereigntyScore), scoreArcMat); scoreArcMesh.rotation.z = Math.PI / 2; sovereigntyGroup.add(scoreArcMesh); export const meterLight = new THREE.PointLight(sovereigntyHexColor(S.sovereigntyScore), 0.7, 6); sovereigntyGroup.add(meterLight); function buildMeterTexture(score, label, assessmentType) { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 128; const ctx = canvas.getContext('2d'); const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444'; ctx.clearRect(0, 0, 256, 128); ctx.font = 'bold 52px "Courier New", monospace'; ctx.fillStyle = hexStr; ctx.textAlign = 'center'; ctx.fillText(`${score}%`, 128, 50); ctx.font = '16px "Courier New", monospace'; ctx.fillStyle = '#8899bb'; ctx.fillText(label.toUpperCase(), 128, 74); ctx.font = '11px "Courier New", monospace'; ctx.fillStyle = '#445566'; ctx.fillText('SOVEREIGNTY', 128, 94); ctx.font = '9px "Courier New", monospace'; ctx.fillStyle = '#334455'; ctx.fillText(assessmentType === 'MANUAL' ? 'MANUAL ASSESSMENT' : 'MANUAL ASSESSMENT', 128, 112); return new THREE.CanvasTexture(canvas); } const meterSpriteMat = new THREE.SpriteMaterial({ map: buildMeterTexture(S.sovereigntyScore, S.sovereigntyLabel, 'MANUAL'), transparent: true, depthWrite: false, }); const meterSprite = new THREE.Sprite(meterSpriteMat); meterSprite.scale.set(3.2, 1.6, 1); sovereigntyGroup.add(meterSprite); scene.add(sovereigntyGroup); sovereigntyGroup.traverse(obj => { if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter'; }); export async function loadSovereigntyStatus() { try { const res = await fetch('./sovereignty-status.json'); if (!res.ok) throw new Error('not found'); const data = await res.json(); const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85)); const label = typeof data.label === 'string' ? data.label : ''; S.sovereigntyScore = score; S.sovereigntyLabel = label; scoreArcMesh.geometry.dispose(); scoreArcMesh.geometry = buildScoreArcGeo(score); const col = sovereigntyHexColor(score); scoreArcMat.color.setHex(col); meterLight.color.setHex(col); if (meterSpriteMat.map) meterSpriteMat.map.dispose(); const assessmentType = data.assessment_type || 'MANUAL'; meterSpriteMat.map = buildMeterTexture(score, label, assessmentType); meterSpriteMat.needsUpdate = true; } catch { // defaults already set } } loadSovereigntyStatus(); // === RUNE RING === let RUNE_COUNT = 12; const RUNE_RING_RADIUS = 7.0; export const RUNE_RING_Y = 1.5; export const RUNE_ORBIT_SPEED = 0.08; const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ']; const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; function createRuneTexture(glyph, color) { const W = 128, H = 128; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, W, H); ctx.shadowColor = color; ctx.shadowBlur = 28; ctx.font = 'bold 78px serif'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(glyph, W / 2, H / 2); return new THREE.CanvasTexture(canvas); } const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64); const runeOrbitRingMat = new THREE.MeshBasicMaterial({ color: 0x224466, transparent: true, opacity: 0.22, }); const runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat); runeOrbitRingMesh.rotation.x = Math.PI / 2; runeOrbitRingMesh.position.y = RUNE_RING_Y; scene.add(runeOrbitRingMesh); /** @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} */ export const runeSprites = []; // portals ref — set from portals module let _portalsRef = []; export function setPortalsRef(ref) { _portalsRef = ref; } export function getPortalsRef() { return _portalsRef; } export function rebuildRuneRing() { for (const rune of runeSprites) { scene.remove(rune.sprite); if (rune.sprite.material.map) rune.sprite.material.map.dispose(); rune.sprite.material.dispose(); } runeSprites.length = 0; const portalData = _portalsRef.length > 0 ? _portalsRef : null; const count = portalData ? portalData.length : RUNE_COUNT; for (let i = 0; i < count; i++) { const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length]; const color = portalData ? portalData[i].color : RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length]; const isOnline = portalData ? portalData[i].status === 'online' : true; const texture = createRuneTexture(glyph, color); const runeMat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: isOnline ? 1.0 : 0.15, depthWrite: false, blending: THREE.AdditiveBlending, }); const sprite = new THREE.Sprite(runeMat); sprite.scale.set(1.3, 1.3, 1); const baseAngle = (i / count) * Math.PI * 2; sprite.position.set( Math.cos(baseAngle) * RUNE_RING_RADIUS, RUNE_RING_Y, Math.sin(baseAngle) * RUNE_RING_RADIUS ); scene.add(sprite); runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline }); } } rebuildRuneRing();