forked from Timmy_Foundation/the-nexus
refactor: honesty pass — every visual element tied to real data or shown as honestly offline (#408)
Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
This commit is contained in:
597
app.js
597
app.js
@@ -60,6 +60,9 @@ const MATRIX_FONT_SIZE = 14;
|
||||
const MATRIX_COL_COUNT = Math.floor(window.innerWidth / MATRIX_FONT_SIZE);
|
||||
const matrixDrops = new Array(MATRIX_COL_COUNT).fill(1);
|
||||
|
||||
// Commit hashes for matrix rain — populated by heatmap fetch, used to inject real data into the rain
|
||||
let _matrixCommitHashes = [];
|
||||
|
||||
function drawMatrixRain() {
|
||||
// Fade previous frame with semi-transparent black overlay (creates the trail)
|
||||
matrixCtx.fillStyle = 'rgba(0, 0, 8, 0.05)';
|
||||
@@ -67,8 +70,27 @@ function drawMatrixRain() {
|
||||
|
||||
matrixCtx.font = `${MATRIX_FONT_SIZE}px monospace`;
|
||||
|
||||
// Tether rain density to commit activity — density range [0.1, 1.0]
|
||||
const activity = typeof totalActivity === 'function' ? totalActivity() : 0;
|
||||
const density = 0.1 + activity * 0.9; // minimum 10% density
|
||||
const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density));
|
||||
|
||||
for (let i = 0; i < matrixDrops.length; i++) {
|
||||
const char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
|
||||
// Only render columns up to density-scaled count (skip inactive ones)
|
||||
if (i >= activeColCount) {
|
||||
// Inactive columns still fade but don't spawn new characters
|
||||
if (matrixDrops[i] * MATRIX_FONT_SIZE > matrixCanvas.height) continue;
|
||||
}
|
||||
|
||||
// Occasionally inject a real commit hash (first 7 chars) instead of katakana
|
||||
let char;
|
||||
if (_matrixCommitHashes.length > 0 && Math.random() < 0.02) {
|
||||
const hash = _matrixCommitHashes[Math.floor(Math.random() * _matrixCommitHashes.length)];
|
||||
char = hash[Math.floor(Math.random() * hash.length)];
|
||||
} else {
|
||||
char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
|
||||
}
|
||||
|
||||
const x = i * MATRIX_FONT_SIZE;
|
||||
const y = matrixDrops[i] * MATRIX_FONT_SIZE;
|
||||
|
||||
@@ -76,8 +98,9 @@ function drawMatrixRain() {
|
||||
matrixCtx.fillStyle = '#aaffaa';
|
||||
matrixCtx.fillText(char, x, y);
|
||||
|
||||
// Reset drop to top with some randomness
|
||||
if (y > matrixCanvas.height && Math.random() > 0.975) {
|
||||
// Reset drop to top — speed influenced by activity
|
||||
const resetThreshold = 0.975 - activity * 0.015; // more activity = faster reset = denser rain
|
||||
if (y > matrixCanvas.height && Math.random() > resetThreshold) {
|
||||
matrixDrops[i] = 0;
|
||||
}
|
||||
matrixDrops[i]++;
|
||||
@@ -166,6 +189,12 @@ const starMaterial = new THREE.PointsMaterial({
|
||||
const stars = new THREE.Points(starGeo, starMaterial);
|
||||
scene.add(stars);
|
||||
|
||||
// Star pulse state — tethered to Bitcoin block events
|
||||
let _starPulseIntensity = 0; // 0 = normal, 1 = peak brightness
|
||||
const STAR_BASE_OPACITY = 0.3;
|
||||
const STAR_PEAK_OPACITY = 1.0;
|
||||
const STAR_PULSE_DECAY = 0.012; // decay per frame (~3 seconds to fade)
|
||||
|
||||
// === CONSTELLATION LINES ===
|
||||
// Connect nearby stars with faint lines, limited to avoid clutter
|
||||
/**
|
||||
@@ -798,6 +827,9 @@ async function updateHeatmap() {
|
||||
if (res.ok) commits = await res.json();
|
||||
} catch { /* silently use zero-activity baseline */ }
|
||||
|
||||
// Feed commit hashes to matrix rain for data-tethered aesthetic
|
||||
_matrixCommitHashes = commits.slice(0, 20).map(c => (c.sha || '').slice(0, 7)).filter(h => h.length > 0);
|
||||
|
||||
const now = Date.now();
|
||||
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
|
||||
|
||||
@@ -1222,8 +1254,10 @@ let energyBeamPulse = 0;
|
||||
|
||||
function animateEnergyBeam() {
|
||||
energyBeamPulse += 0.02;
|
||||
const pulseEffect = Math.sin(energyBeamPulse) * 0.3 + 0.7;
|
||||
energyBeamMaterial.opacity = 0.3 + pulseEffect * 0.4;
|
||||
// Tether beam intensity to active agent count: 0=faint, 1=0.4, 2=0.7, 3+=1.0
|
||||
const agentIntensity = _activeAgentCount === 0 ? 0.1 : Math.min(0.1 + _activeAgentCount * 0.3, 1.0);
|
||||
const pulseEffect = Math.sin(energyBeamPulse) * 0.15 * agentIntensity;
|
||||
energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect;
|
||||
}
|
||||
|
||||
// === RESIZE HANDLER ===
|
||||
@@ -1272,7 +1306,7 @@ sovereigntyGroup.add(scoreArcMesh);
|
||||
const meterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6);
|
||||
sovereigntyGroup.add(meterLight);
|
||||
|
||||
function buildMeterTexture(score, label) {
|
||||
function buildMeterTexture(score, label, assessmentType) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 128;
|
||||
@@ -1282,18 +1316,22 @@ function buildMeterTexture(score, label) {
|
||||
ctx.font = 'bold 52px "Courier New", monospace';
|
||||
ctx.fillStyle = hexStr;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${score}%`, 128, 58);
|
||||
ctx.fillText(`${score}%`, 128, 50);
|
||||
ctx.font = '16px "Courier New", monospace';
|
||||
ctx.fillStyle = '#8899bb';
|
||||
ctx.fillText(label.toUpperCase(), 128, 82);
|
||||
ctx.fillText(label.toUpperCase(), 128, 74);
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.fillStyle = '#445566';
|
||||
ctx.fillText('SOVEREIGNTY', 128, 104);
|
||||
ctx.fillText('SOVEREIGNTY', 128, 94);
|
||||
// "MANUAL ASSESSMENT" label — honest about data source
|
||||
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(sovereigntyScore, sovereigntyLabel),
|
||||
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel, 'MANUAL'),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
@@ -1321,7 +1359,8 @@ async function loadSovereigntyStatus() {
|
||||
scoreArcMat.color.setHex(col);
|
||||
meterLight.color.setHex(col);
|
||||
if (meterSpriteMat.map) meterSpriteMat.map.dispose();
|
||||
meterSpriteMat.map = buildMeterTexture(score, label);
|
||||
const assessmentType = data.assessment_type || 'MANUAL';
|
||||
meterSpriteMat.map = buildMeterTexture(score, label, assessmentType);
|
||||
meterSpriteMat.needsUpdate = true;
|
||||
} catch {
|
||||
// defaults already set above
|
||||
@@ -1331,7 +1370,8 @@ async function loadSovereigntyStatus() {
|
||||
loadSovereigntyStatus();
|
||||
|
||||
// === ENERGY BEAM FOR BATCAVE TERMINAL ===
|
||||
// Vertical energy beam from Batcave terminal area to the sky with animated opacity and pulse effect.
|
||||
// Vertical energy beam from Batcave terminal area — intensity tethered to active agent count.
|
||||
let _activeAgentCount = 0; // updated by agent status fetch
|
||||
const ENERGY_BEAM_RADIUS = 0.2;
|
||||
const ENERGY_BEAM_HEIGHT = 50;
|
||||
const ENERGY_BEAM_Y = 0;
|
||||
@@ -1355,15 +1395,15 @@ scene.add(energyBeam);
|
||||
|
||||
|
||||
// === RUNE RING ===
|
||||
// 12 Elder Futhark rune sprites in a slow-orbiting ring around the center platform.
|
||||
// Rune sprites tethered to portal data — count matches portals, colors from portals.json.
|
||||
|
||||
const RUNE_COUNT = 12;
|
||||
let RUNE_COUNT = 12; // default, updated when portals load
|
||||
const RUNE_RING_RADIUS = 7.0;
|
||||
const RUNE_RING_Y = 1.5; // base height above platform
|
||||
const RUNE_ORBIT_SPEED = 0.08; // radians per second
|
||||
|
||||
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ'];
|
||||
const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; // alternating cyan / magenta
|
||||
const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff']; // fallback, overridden by portal colors
|
||||
|
||||
/**
|
||||
* Creates a canvas texture for a single glowing rune glyph.
|
||||
@@ -1406,35 +1446,56 @@ runeOrbitRingMesh.position.y = RUNE_RING_Y;
|
||||
scene.add(runeOrbitRingMesh);
|
||||
|
||||
/**
|
||||
* @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number}>}
|
||||
* @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>}
|
||||
*/
|
||||
const runeSprites = [];
|
||||
|
||||
for (let i = 0; i < RUNE_COUNT; i++) {
|
||||
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
|
||||
const color = RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length];
|
||||
const texture = createRuneTexture(glyph, color);
|
||||
/**
|
||||
* Rebuilds rune ring from portal data — count matches portals, colors from portals.json.
|
||||
* Falls back to default 12 runes if portals not yet loaded.
|
||||
*/
|
||||
function rebuildRuneRing() {
|
||||
// Remove existing rune sprites
|
||||
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 runeMat = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(runeMat);
|
||||
sprite.scale.set(1.3, 1.3, 1);
|
||||
const portalData = portals.length > 0 ? portals : null;
|
||||
const count = portalData ? portalData.length : RUNE_COUNT;
|
||||
|
||||
const baseAngle = (i / RUNE_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 / RUNE_COUNT) * Math.PI * 2 });
|
||||
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, // bright if online, dim if offline
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Initial build with default count (will be rebuilt when portals load)
|
||||
rebuildRuneRing();
|
||||
|
||||
|
||||
// === HOLOGRAPHIC EARTH ===
|
||||
// A procedural holographic planet Earth slowly rotating above the Nexus.
|
||||
@@ -2116,14 +2177,14 @@ function createDualBrainTexture() {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('BRAIN GAP SCORECARD', 20, 74);
|
||||
|
||||
// Categories
|
||||
// Categories — honest offline state (no scores, empty bars)
|
||||
const categories = [
|
||||
{ name: 'Triage', score: 0.87, status: 'GRADUATED', color: '#00ff88' },
|
||||
{ name: 'Tool Use', score: 0.78, status: 'PROBATION', color: '#ffcc00' },
|
||||
{ name: 'Code Gen', score: 0.62, status: 'SHADOW', color: '#4488ff' },
|
||||
{ name: 'Planning', score: 0.71, status: 'SHADOW', color: '#4488ff' },
|
||||
{ name: 'Communication', score: 0.83, status: 'PROBATION', color: '#ffcc00' },
|
||||
{ name: 'Reasoning', score: 0.55, status: 'CLOUD ONLY', color: '#ff4444' },
|
||||
{ name: 'Triage' },
|
||||
{ name: 'Tool Use' },
|
||||
{ name: 'Code Gen' },
|
||||
{ name: 'Planning' },
|
||||
{ name: 'Communication' },
|
||||
{ name: 'Reasoning' },
|
||||
];
|
||||
|
||||
const barX = 20;
|
||||
@@ -2134,34 +2195,22 @@ function createDualBrainTexture() {
|
||||
for (const cat of categories) {
|
||||
// Category label
|
||||
ctx.font = '13px "Courier New", monospace';
|
||||
ctx.fillStyle = '#ccd6f6';
|
||||
ctx.fillStyle = '#445566';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(cat.name, barX, y + 14);
|
||||
|
||||
// Score value
|
||||
// Score value — dash (no data)
|
||||
ctx.font = 'bold 13px "Courier New", monospace';
|
||||
ctx.fillStyle = cat.color;
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(cat.score.toFixed(2), W - 20, y + 14);
|
||||
ctx.fillText('\u2014', W - 20, y + 14);
|
||||
|
||||
y += 22;
|
||||
|
||||
// Bar background
|
||||
// Bar background only — no fill (zero-width)
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.fillRect(barX, y, barW, barH);
|
||||
|
||||
// Bar fill
|
||||
ctx.fillStyle = cat.color;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillRect(barX, y, barW * cat.score, barH);
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
// Status label on bar
|
||||
ctx.font = '10px "Courier New", monospace';
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(cat.status, barX + 6, y + 14);
|
||||
|
||||
y += barH + 12;
|
||||
}
|
||||
|
||||
@@ -2174,35 +2223,32 @@ function createDualBrainTexture() {
|
||||
|
||||
y += 22;
|
||||
|
||||
// Overall score
|
||||
ctx.font = '12px "Courier New", monospace';
|
||||
ctx.fillStyle = '#556688';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('OVERALL CONVERGENCE', 20, y);
|
||||
|
||||
ctx.font = 'bold 36px "Courier New", monospace';
|
||||
ctx.fillStyle = '#88ccff';
|
||||
// Status text — honest offline
|
||||
ctx.font = 'bold 18px "Courier New", monospace';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('0.73', W / 2, y + 44);
|
||||
ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10);
|
||||
|
||||
// Brain indicators at bottom
|
||||
y += 60;
|
||||
// Cloud brain indicator
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.fillStyle = '#223344';
|
||||
ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32);
|
||||
|
||||
// Brain indicators at bottom — dim (offline)
|
||||
y += 52;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#00ddff';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.fill();
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.fillStyle = '#00ddff';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('CLOUD', W / 2 - 48, y + 12);
|
||||
|
||||
// Local brain indicator
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffaa22';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffaa22';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.fillText('LOCAL', W / 2 + 42, y + 12);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
@@ -2233,13 +2279,13 @@ dualBrainLight.position.set(0, 0.5, 1);
|
||||
dualBrainGroup.add(dualBrainLight);
|
||||
|
||||
// --- Brain Orbs ---
|
||||
// Cloud brain orb (cyan) — positioned left of panel
|
||||
const CLOUD_ORB_COLOR = 0x00ddff;
|
||||
// Cloud brain orb — dim grey (dual-brain offline)
|
||||
const CLOUD_ORB_COLOR = 0x334466;
|
||||
const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
|
||||
const cloudOrbMat = new THREE.MeshStandardMaterial({
|
||||
color: CLOUD_ORB_COLOR,
|
||||
emissive: new THREE.Color(CLOUD_ORB_COLOR),
|
||||
emissiveIntensity: 1.5,
|
||||
emissiveIntensity: 0.1,
|
||||
metalness: 0.3,
|
||||
roughness: 0.2,
|
||||
transparent: true,
|
||||
@@ -2250,17 +2296,17 @@ cloudOrb.position.set(-2.0, 3.0, 0);
|
||||
cloudOrb.userData.zoomLabel = 'Cloud Brain';
|
||||
dualBrainGroup.add(cloudOrb);
|
||||
|
||||
const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.8, 5);
|
||||
const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.15, 5);
|
||||
cloudOrbLight.position.copy(cloudOrb.position);
|
||||
dualBrainGroup.add(cloudOrbLight);
|
||||
|
||||
// Local brain orb (amber) — positioned right of panel
|
||||
const LOCAL_ORB_COLOR = 0xffaa22;
|
||||
// Local brain orb — dim grey (dual-brain offline)
|
||||
const LOCAL_ORB_COLOR = 0x334466;
|
||||
const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
|
||||
const localOrbMat = new THREE.MeshStandardMaterial({
|
||||
color: LOCAL_ORB_COLOR,
|
||||
emissive: new THREE.Color(LOCAL_ORB_COLOR),
|
||||
emissiveIntensity: 1.5,
|
||||
emissiveIntensity: 0.1,
|
||||
metalness: 0.3,
|
||||
roughness: 0.2,
|
||||
transparent: true,
|
||||
@@ -2271,13 +2317,13 @@ localOrb.position.set(2.0, 3.0, 0);
|
||||
localOrb.userData.zoomLabel = 'Local Brain';
|
||||
dualBrainGroup.add(localOrb);
|
||||
|
||||
const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.8, 5);
|
||||
const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.15, 5);
|
||||
localOrbLight.position.copy(localOrb.position);
|
||||
dualBrainGroup.add(localOrbLight);
|
||||
|
||||
// --- Brain Pulse Particle Stream ---
|
||||
// Particles flow from cloud orb → local orb along a curved arc
|
||||
const BRAIN_PARTICLE_COUNT = 120;
|
||||
// Particles OFF — dual-brain system not deployed. Will flow when system comes online.
|
||||
const BRAIN_PARTICLE_COUNT = 0;
|
||||
const brainParticlePositions = new Float32Array(BRAIN_PARTICLE_COUNT * 3);
|
||||
const brainParticlePhases = new Float32Array(BRAIN_PARTICLE_COUNT); // 0..1 progress along arc
|
||||
const brainParticleSpeeds = new Float32Array(BRAIN_PARTICLE_COUNT);
|
||||
@@ -2361,6 +2407,12 @@ function animate() {
|
||||
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
|
||||
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
|
||||
|
||||
// Star brightness pulse — tethered to Bitcoin block events
|
||||
if (_starPulseIntensity > 0) {
|
||||
_starPulseIntensity = Math.max(0, _starPulseIntensity - STAR_PULSE_DECAY);
|
||||
}
|
||||
starMaterial.opacity = STAR_BASE_OPACITY + (STAR_PEAK_OPACITY - STAR_BASE_OPACITY) * _starPulseIntensity;
|
||||
|
||||
constellationLines.rotation.x = stars.rotation.x;
|
||||
constellationLines.rotation.y = stars.rotation.y;
|
||||
|
||||
@@ -2533,17 +2585,28 @@ function animate() {
|
||||
burst.geo.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Animate rune ring — orbit and vertical float
|
||||
// Animate rune ring — orbit and vertical float, brightness tethered to portal status
|
||||
for (const rune of runeSprites) {
|
||||
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
|
||||
rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
|
||||
rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
|
||||
rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4;
|
||||
rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2;
|
||||
// Online portal = bright, offline = dim
|
||||
const baseOpacity = rune.portalOnline ? 0.85 : 0.12;
|
||||
const pulseRange = rune.portalOnline ? 0.15 : 0.03;
|
||||
rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange;
|
||||
}
|
||||
|
||||
// Animate holographic Earth — slow axial rotation, gentle float, glow pulse
|
||||
earthMesh.rotation.y = elapsed * EARTH_ROTATION_SPEED;
|
||||
// Animate holographic Earth — rotation speed tethered to totalActivity()
|
||||
// Idle system = very slow (0.005), active system = faster (0.05)
|
||||
const earthActivity = totalActivity();
|
||||
const targetEarthSpeed = 0.005 + earthActivity * 0.045;
|
||||
// Smooth interpolation — don't jump
|
||||
const _eSmooth = 0.02;
|
||||
const currentEarthSpeed = earthMesh.userData._currentSpeed || EARTH_ROTATION_SPEED;
|
||||
const smoothedEarthSpeed = currentEarthSpeed + (targetEarthSpeed - currentEarthSpeed) * _eSmooth;
|
||||
earthMesh.userData._currentSpeed = smoothedEarthSpeed;
|
||||
earthMesh.rotation.y += smoothedEarthSpeed;
|
||||
earthSurfaceMat.uniforms.uTime.value = elapsed;
|
||||
earthGlowLight.intensity = 0.30 + Math.sin(elapsed * 0.7) * 0.12;
|
||||
earthGroup.position.y = EARTH_Y + Math.sin(elapsed * 0.22) * 0.6;
|
||||
@@ -2603,13 +2666,13 @@ function animate() {
|
||||
Math.sin(elapsed * dualBrainSprite.userData.floatSpeed + dualBrainSprite.userData.floatPhase) * 0.22;
|
||||
dualBrainScanSprite.position.y = dualBrainSprite.position.y;
|
||||
|
||||
// Orb glow pulse
|
||||
const cloudPulse = 1.2 + Math.sin(elapsed * 1.8) * 0.4;
|
||||
const localPulse = 1.2 + Math.sin(elapsed * 1.8 + Math.PI) * 0.4;
|
||||
// Orb glow — dim idle pulse (dual-brain offline)
|
||||
const cloudPulse = 0.08 + Math.sin(elapsed * 0.6) * 0.03;
|
||||
const localPulse = 0.08 + Math.sin(elapsed * 0.6 + Math.PI) * 0.03;
|
||||
cloudOrbMat.emissiveIntensity = cloudPulse;
|
||||
localOrbMat.emissiveIntensity = localPulse;
|
||||
cloudOrbLight.intensity = 0.5 + Math.sin(elapsed * 1.8) * 0.3;
|
||||
localOrbLight.intensity = 0.5 + Math.sin(elapsed * 1.8 + Math.PI) * 0.3;
|
||||
cloudOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6) * 0.05;
|
||||
localOrbLight.intensity = 0.1 + Math.sin(elapsed * 0.6 + Math.PI) * 0.05;
|
||||
|
||||
// Orb hover
|
||||
cloudOrb.position.y = 3.0 + Math.sin(elapsed * 0.9) * 0.15;
|
||||
@@ -2617,33 +2680,27 @@ function animate() {
|
||||
cloudOrbLight.position.y = cloudOrb.position.y;
|
||||
localOrbLight.position.y = localOrb.position.y;
|
||||
|
||||
// Brain pulse particles — flow along a curved arc from cloud → local orb
|
||||
{
|
||||
// Brain pulse particles — OFF (dual-brain system not deployed)
|
||||
// Will be re-enabled with flow rate proportional to convergence delta when system deploys
|
||||
if (BRAIN_PARTICLE_COUNT > 0) {
|
||||
const pos = brainParticleGeo.attributes.position.array;
|
||||
const startX = cloudOrb.position.x;
|
||||
const endX = localOrb.position.x;
|
||||
const arcHeight = 1.2; // peak height of arc above orbs
|
||||
const simRate = 0.73; // simulated learning rate tied to overall score
|
||||
const arcHeight = 1.2;
|
||||
const simRate = 0.73;
|
||||
|
||||
for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) {
|
||||
brainParticlePhases[i] += brainParticleSpeeds[i] * simRate * 0.016;
|
||||
if (brainParticlePhases[i] > 1.0) brainParticlePhases[i] -= 1.0;
|
||||
|
||||
const t = brainParticlePhases[i];
|
||||
// Lerp X between orbs
|
||||
pos[i * 3] = startX + (endX - startX) * t;
|
||||
// Arc Y: parabolic curve peaking at midpoint
|
||||
const midY = (cloudOrb.position.y + localOrb.position.y) / 2 + arcHeight;
|
||||
pos[i * 3 + 1] = cloudOrb.position.y + (midY - cloudOrb.position.y) * 4 * t * (1 - t)
|
||||
+ (localOrb.position.y - cloudOrb.position.y) * t;
|
||||
// Slight Z wobble for volume
|
||||
pos[i * 3 + 2] = Math.sin(t * Math.PI * 4 + elapsed * 2 + i) * 0.12;
|
||||
}
|
||||
brainParticleGeo.attributes.position.needsUpdate = true;
|
||||
|
||||
// Colour lerp from cyan → amber based on progress (approximated via hue shift)
|
||||
const pulseIntensity = 0.6 + Math.sin(elapsed * 2.0) * 0.2;
|
||||
brainParticleMat.opacity = pulseIntensity;
|
||||
brainParticleMat.opacity = 0.6 + Math.sin(elapsed * 2.0) * 0.2;
|
||||
}
|
||||
|
||||
// Scanning line effect — thin horizontal line sweeps down the panel
|
||||
@@ -3633,6 +3690,7 @@ function createPortals() {
|
||||
|
||||
portals.forEach(portal => {
|
||||
|
||||
const isOnline = portal.status === 'online';
|
||||
|
||||
const portalMat = new THREE.MeshBasicMaterial({
|
||||
|
||||
@@ -3642,8 +3700,8 @@ function createPortals() {
|
||||
|
||||
transparent: true,
|
||||
|
||||
|
||||
opacity: 0.7,
|
||||
// Offline portals are dimmed
|
||||
opacity: isOnline ? 0.7 : 0.15,
|
||||
|
||||
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -3705,8 +3763,14 @@ async function loadPortals() {
|
||||
portals = await res.json();
|
||||
console.log('Loaded portals:', portals);
|
||||
createPortals();
|
||||
// Rebuild rune ring to match portal count/colors/status
|
||||
rebuildRuneRing();
|
||||
// Rebuild gravity zones to align with portal positions
|
||||
rebuildGravityZones();
|
||||
// If audio is already running, attach positional hums to the portals now
|
||||
startPortalHums();
|
||||
// Run portal health checks
|
||||
runPortalHealthChecks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load portals:', error);
|
||||
}
|
||||
@@ -4267,18 +4331,99 @@ loadSoulMd().then(lines => { oathLines = lines; });
|
||||
|
||||
// === AGENT STATUS BOARD ===
|
||||
|
||||
const AGENT_STATUS_STUB = {
|
||||
agents: [
|
||||
{ name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3, local: true },
|
||||
{ name: 'gemini', status: 'idle', issue: null, prs_today: 1, local: false },
|
||||
{ name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2, local: false },
|
||||
{ name: 'groq', status: 'idle', issue: null, prs_today: 0, local: false },
|
||||
{ name: 'grok', status: 'dead', issue: null, prs_today: 0, local: false },
|
||||
{ name: 'ollama', status: 'idle', issue: null, prs_today: 0, local: true },
|
||||
]
|
||||
};
|
||||
// Agent status cache — refreshed from Gitea API every 5 minutes
|
||||
let _agentStatusCache = null;
|
||||
let _agentStatusCacheTime = 0;
|
||||
const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dead: '#ff4444' };
|
||||
const GITEA_BASE = 'http://143.198.27.163:3000/api/v1';
|
||||
const GITEA_TOKEN = '81a88f46684e398abe081f5786a11ae9532aae2d';
|
||||
const GITEA_REPOS = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/hermes-agent'];
|
||||
const AGENT_NAMES = ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama'];
|
||||
|
||||
/**
|
||||
* Fetches real agent status from Gitea API — commits + open PRs for each agent.
|
||||
* Results are cached for 5 minutes.
|
||||
* @returns {Promise<{agents: Array}>}
|
||||
*/
|
||||
async function fetchAgentStatusFromGitea() {
|
||||
const now = Date.now();
|
||||
if (_agentStatusCache && (now - _agentStatusCacheTime < AGENT_STATUS_CACHE_MS)) {
|
||||
return _agentStatusCache;
|
||||
}
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
const HOUR_MS = 3600000;
|
||||
const agents = [];
|
||||
|
||||
// Fetch commits from all repos in parallel
|
||||
const allRepoCommits = await Promise.all(GITEA_REPOS.map(async (repo) => {
|
||||
try {
|
||||
const res = await fetch(`${GITEA_BASE}/repos/${repo}/commits?sha=main&limit=30&token=${GITEA_TOKEN}`);
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch { return []; }
|
||||
}));
|
||||
|
||||
// Fetch open PRs from the-nexus
|
||||
let openPRs = [];
|
||||
try {
|
||||
const prRes = await fetch(`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=50&token=${GITEA_TOKEN}`);
|
||||
if (prRes.ok) openPRs = await prRes.json();
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const agentName of AGENT_NAMES) {
|
||||
const nameLower = agentName.toLowerCase();
|
||||
const allCommits = [];
|
||||
|
||||
for (const repoCommits of allRepoCommits) {
|
||||
if (!Array.isArray(repoCommits)) continue;
|
||||
const matching = repoCommits.filter(c =>
|
||||
(c.commit?.author?.name || '').toLowerCase().includes(nameLower)
|
||||
);
|
||||
allCommits.push(...matching);
|
||||
}
|
||||
|
||||
// Determine status based on most recent commit
|
||||
let status = 'dormant';
|
||||
let lastSeen = null;
|
||||
let currentWork = null;
|
||||
|
||||
if (allCommits.length > 0) {
|
||||
allCommits.sort((a, b) =>
|
||||
new Date(b.commit.author.date) - new Date(a.commit.author.date)
|
||||
);
|
||||
const latest = allCommits[0];
|
||||
const commitTime = new Date(latest.commit.author.date).getTime();
|
||||
lastSeen = latest.commit.author.date;
|
||||
currentWork = latest.commit.message.split('\n')[0];
|
||||
|
||||
if (now - commitTime < HOUR_MS) status = 'working';
|
||||
else if (now - commitTime < DAY_MS) status = 'idle';
|
||||
else status = 'dormant';
|
||||
}
|
||||
|
||||
// Count open PRs for this agent
|
||||
const agentPRs = openPRs.filter(pr =>
|
||||
(pr.user?.login || '').toLowerCase().includes(nameLower) ||
|
||||
(pr.head?.label || '').toLowerCase().includes(nameLower)
|
||||
);
|
||||
|
||||
agents.push({
|
||||
name: agentName.toLowerCase(),
|
||||
status,
|
||||
issue: currentWork,
|
||||
prs_today: agentPRs.length,
|
||||
local: nameLower === 'ollama',
|
||||
});
|
||||
}
|
||||
|
||||
_agentStatusCache = { agents };
|
||||
_agentStatusCacheTime = now;
|
||||
return _agentStatusCache;
|
||||
}
|
||||
|
||||
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' };
|
||||
|
||||
/**
|
||||
* Builds a canvas texture for a single agent holo-panel.
|
||||
@@ -4432,48 +4577,44 @@ function rebuildAgentPanels(statusData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches live agent status, falling back to the stub when the endpoint is unavailable.
|
||||
* @returns {Promise<typeof AGENT_STATUS_STUB>}
|
||||
* Fetches live agent status from the Gitea API.
|
||||
* Shows "UNREACHABLE" if the API call fails entirely.
|
||||
* @returns {Promise<{agents: Array}>}
|
||||
*/
|
||||
async function fetchAgentStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/status.json');
|
||||
if (!res.ok) throw new Error('status ' + res.status);
|
||||
return await res.json();
|
||||
return await fetchAgentStatusFromGitea();
|
||||
} catch {
|
||||
return AGENT_STATUS_STUB;
|
||||
return { agents: AGENT_NAMES.map(n => ({
|
||||
name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false,
|
||||
})) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgentBoard() {
|
||||
const data = await fetchAgentStatus();
|
||||
rebuildAgentPanels(data);
|
||||
// Update active agent count for energy beam tethering
|
||||
_activeAgentCount = data.agents.filter(a => a.status === 'working').length;
|
||||
}
|
||||
|
||||
// Initial render, then poll every 30 s
|
||||
// Initial render, then poll every 5 min (matching API cache interval)
|
||||
refreshAgentBoard();
|
||||
setInterval(refreshAgentBoard, 30000);
|
||||
setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS);
|
||||
|
||||
// === LORA ADAPTER STATUS PANEL ===
|
||||
// Holographic panel showing which LoRA fine-tuning adapters are currently active.
|
||||
// Reads from lora-status.json, falls back to stub data when unavailable.
|
||||
// Holographic panel showing LoRA fine-tuning adapter status.
|
||||
// Shows honest empty state when no adapters are deployed.
|
||||
|
||||
const LORA_STATUS_STUB = {
|
||||
adapters: [
|
||||
{ name: 'timmy-voice-v3', base: 'mistral-7b', active: true, strength: 0.85 },
|
||||
{ name: 'nexus-style-v2', base: 'llama-3-8b', active: true, strength: 0.70 },
|
||||
{ name: 'sovereign-tone-v1', base: 'phi-3-mini', active: false, strength: 0.50 },
|
||||
{ name: 'btc-domain-v1', base: 'mistral-7b', active: true, strength: 0.60 },
|
||||
],
|
||||
updated: '',
|
||||
};
|
||||
// No LoRA stub — honest empty state when no adapters are deployed
|
||||
|
||||
const LORA_ACTIVE_COLOR = '#00ff88'; // green — adapter is loaded
|
||||
const LORA_INACTIVE_COLOR = '#334466'; // dim blue — adapter is off
|
||||
|
||||
/**
|
||||
* Builds a canvas texture for the LoRA status panel.
|
||||
* @param {typeof LORA_STATUS_STUB} data
|
||||
* Shows honest empty state when no adapters are deployed.
|
||||
* @param {{ adapters: Array }|null} data
|
||||
* @returns {THREE.CanvasTexture}
|
||||
*/
|
||||
function createLoRAPanelTexture(data) {
|
||||
@@ -4510,14 +4651,6 @@ function createLoRAPanelTexture(data) {
|
||||
ctx.fillStyle = '#664488';
|
||||
ctx.fillText('LoRA ADAPTERS', 14, 38);
|
||||
|
||||
// Active count badge (top-right)
|
||||
const activeCount = data.adapters.filter(a => a.active).length;
|
||||
ctx.font = 'bold 13px "Courier New", monospace';
|
||||
ctx.fillStyle = LORA_ACTIVE_COLOR;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Separator
|
||||
ctx.strokeStyle = '#2a1a44';
|
||||
ctx.lineWidth = 1;
|
||||
@@ -4526,31 +4659,43 @@ function createLoRAPanelTexture(data) {
|
||||
ctx.lineTo(W - 14, 46);
|
||||
ctx.stroke();
|
||||
|
||||
// Adapter rows
|
||||
// Honest empty state — no adapters deployed
|
||||
if (!data || !data.adapters || data.adapters.length === 0) {
|
||||
ctx.font = 'bold 18px "Courier New", monospace';
|
||||
ctx.fillStyle = '#334466';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10);
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.fillStyle = '#223344';
|
||||
ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36);
|
||||
ctx.textAlign = 'left';
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
// If adapters exist in the future, render them
|
||||
const activeCount = data.adapters.filter(a => a.active).length;
|
||||
ctx.font = 'bold 13px "Courier New", monospace';
|
||||
ctx.fillStyle = LORA_ACTIVE_COLOR;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const ROW_H = 44;
|
||||
data.adapters.forEach((adapter, i) => {
|
||||
const rowY = 50 + i * ROW_H;
|
||||
const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR;
|
||||
|
||||
// Status dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
|
||||
// Adapter name
|
||||
ctx.font = 'bold 13px "Courier New", monospace';
|
||||
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
|
||||
ctx.fillText(adapter.name, 36, rowY + 16);
|
||||
|
||||
// Base model (right-aligned)
|
||||
ctx.font = '10px "Courier New", monospace';
|
||||
ctx.fillStyle = '#556688';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(adapter.base, W - 14, rowY + 16);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Strength bar
|
||||
if (adapter.active) {
|
||||
const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5;
|
||||
ctx.fillStyle = '#0a1428';
|
||||
@@ -4559,14 +4704,7 @@ function createLoRAPanelTexture(data) {
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H);
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.font = '9px "Courier New", monospace';
|
||||
ctx.fillStyle = col;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${Math.round(adapter.strength * 100)}%`, W - 14, rowY + 28);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
// Row divider (except after last)
|
||||
if (i < data.adapters.length - 1) {
|
||||
ctx.strokeStyle = '#1a0a2a';
|
||||
ctx.lineWidth = 1;
|
||||
@@ -4589,7 +4727,7 @@ let loraPanelSprite = null;
|
||||
|
||||
/**
|
||||
* (Re)builds the LoRA panel sprite from fresh data.
|
||||
* @param {typeof LORA_STATUS_STUB} data
|
||||
* @param {{ adapters: Array }|null} data
|
||||
*/
|
||||
function rebuildLoRAPanel(data) {
|
||||
if (loraPanelSprite) {
|
||||
@@ -4618,23 +4756,61 @@ function rebuildLoRAPanel(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches live LoRA adapter status, falling back to stub when unavailable.
|
||||
* Renders the LoRA panel with honest empty state — no adapters deployed.
|
||||
*/
|
||||
async function loadLoRAStatus() {
|
||||
try {
|
||||
const res = await fetch('./lora-status.json');
|
||||
if (!res.ok) throw new Error('not found');
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data.adapters)) throw new Error('invalid');
|
||||
rebuildLoRAPanel(data);
|
||||
} catch {
|
||||
rebuildLoRAPanel(LORA_STATUS_STUB);
|
||||
}
|
||||
function loadLoRAStatus() {
|
||||
rebuildLoRAPanel({ adapters: [] });
|
||||
}
|
||||
|
||||
loadLoRAStatus();
|
||||
// Refresh every 60 s so live updates propagate
|
||||
setInterval(loadLoRAStatus, 60000);
|
||||
|
||||
// === PORTAL HEALTH CHECKS ===
|
||||
// Probes portal destination URLs to verify they're actually reachable.
|
||||
// Uses portals.json status as the baseline — since all are currently "offline", this is honest.
|
||||
// Health check runs every 5 minutes to detect if a portal comes online.
|
||||
const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Runs a health check against each portal's destination URL.
|
||||
* Updates portal status and refreshes visuals (runes, gravity zones).
|
||||
*/
|
||||
async function runPortalHealthChecks() {
|
||||
if (portals.length === 0) return;
|
||||
|
||||
for (const portal of portals) {
|
||||
if (!portal.destination?.url) {
|
||||
portal.status = 'offline';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fetch(portal.destination.url, {
|
||||
mode: 'no-cors',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
// Any response at all means the server is up
|
||||
portal.status = 'online';
|
||||
} catch {
|
||||
portal.status = 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh rune ring and gravity zones with updated portal statuses
|
||||
rebuildRuneRing();
|
||||
rebuildGravityZones();
|
||||
|
||||
// Update portal mesh visuals — dim offline portals
|
||||
for (const child of portalGroup.children) {
|
||||
const portalId = child.name.replace('portal-', '');
|
||||
const portalData = portals.find(p => p.id === portalId);
|
||||
if (portalData) {
|
||||
const isOnline = portalData.status === 'online';
|
||||
child.material.opacity = isOnline ? 0.7 : 0.15;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule recurring health checks
|
||||
setInterval(runPortalHealthChecks, PORTAL_HEALTH_CHECK_MS);
|
||||
|
||||
// === WEATHER SYSTEM — Lempster NH ===
|
||||
// Fetches real current weather from Open-Meteo (no API key required).
|
||||
@@ -4771,15 +4947,20 @@ function updateWeatherHUD(wx) {
|
||||
*/
|
||||
async function fetchWeather() {
|
||||
try {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('weather fetch failed');
|
||||
const data = await res.json();
|
||||
const cur = data.current;
|
||||
const code = cur.weather_code;
|
||||
const { condition, icon } = weatherCodeToLabel(code);
|
||||
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon };
|
||||
const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50;
|
||||
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover };
|
||||
applyWeatherToScene(weatherState);
|
||||
// Tether cloud layer density to real weather cloudcover
|
||||
const cloudOpacity = 0.05 + (cloudcover / 100) * 0.55; // range [0.05, 0.60]
|
||||
cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7; // range [0.3, 1.0]
|
||||
cloudMaterial.opacity = cloudOpacity;
|
||||
updateWeatherHUD(weatherState);
|
||||
} catch {
|
||||
// Silently use defaults — no weather data available
|
||||
@@ -4793,12 +4974,13 @@ setInterval(fetchWeather, WEATHER_REFRESH_MS);
|
||||
|
||||
// === GRAVITY ANOMALY ZONES ===
|
||||
// Areas where particles defy gravity and float upward.
|
||||
// Each zone has a glowing floor ring and a rising particle stream.
|
||||
// Tethered to portal positions and status — active portals have stronger anomalies.
|
||||
|
||||
const GRAVITY_ANOMALY_FLOOR = 0.2; // Y where particles respawn (ground level)
|
||||
const GRAVITY_ANOMALY_CEIL = 16.0; // Y where particles wrap back to floor
|
||||
|
||||
const GRAVITY_ZONES = [
|
||||
// Default zones — replaced when portals load
|
||||
let GRAVITY_ZONES = [
|
||||
{ x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 },
|
||||
{ x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 },
|
||||
{ x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 },
|
||||
@@ -4867,6 +5049,51 @@ const gravityZoneObjects = GRAVITY_ZONES.map((zone) => {
|
||||
return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities };
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds gravity anomaly zones to align with portal positions.
|
||||
* Active/online portals get stronger anomaly; offline portals get weaker effect.
|
||||
*/
|
||||
function rebuildGravityZones() {
|
||||
if (portals.length === 0) return;
|
||||
|
||||
// Update existing zone positions/intensities to match portal data
|
||||
for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) {
|
||||
const portal = portals[i];
|
||||
const gz = gravityZoneObjects[i];
|
||||
const isOnline = portal.status === 'online';
|
||||
const portalColor = new THREE.Color(portal.color);
|
||||
|
||||
// Reposition ring and disc to portal position
|
||||
gz.ring.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.05, portal.position.z);
|
||||
gz.disc.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.04, portal.position.z);
|
||||
|
||||
// Update zone reference for particle respawn
|
||||
gz.zone.x = portal.position.x;
|
||||
gz.zone.z = portal.position.z;
|
||||
gz.zone.color = portalColor.getHex();
|
||||
|
||||
// Update colors
|
||||
gz.ringMat.color.copy(portalColor);
|
||||
gz.discMat.color.copy(portalColor);
|
||||
gz.points.material.color.copy(portalColor);
|
||||
|
||||
// Offline portals: reduced opacity/intensity
|
||||
gz.ringMat.opacity = isOnline ? 0.4 : 0.08;
|
||||
gz.discMat.opacity = isOnline ? 0.04 : 0.01;
|
||||
gz.points.material.opacity = isOnline ? 0.7 : 0.15;
|
||||
|
||||
// Reposition particles around portal
|
||||
const pos = gz.geo.attributes.position.array;
|
||||
for (let j = 0; j < gz.zone.particleCount; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * gz.zone.radius;
|
||||
pos[j * 3] = gz.zone.x + Math.cos(angle) * r;
|
||||
pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
|
||||
}
|
||||
gz.geo.attributes.position.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === TIMMY SPEECH BUBBLE ===
|
||||
// When Timmy sends a chat message, a glowing floating text sprite appears near
|
||||
// his avatar position above the platform. Fades in quickly, holds for 5 s total,
|
||||
@@ -5149,6 +5376,8 @@ async function fetchBlockHeight() {
|
||||
// Force reflow so animation restarts
|
||||
void blockHeightDisplay.offsetWidth;
|
||||
blockHeightDisplay.classList.add('fresh');
|
||||
// Pulse stars — chain heartbeat
|
||||
_starPulseIntensity = 1.0;
|
||||
}
|
||||
|
||||
lastKnownBlockHeight = height;
|
||||
|
||||
Reference in New Issue
Block a user