// === GRAVITY ZONES + SPEECH BUBBLE + TIMELAPSE + BITCOIN === import * as THREE from 'three'; import { scene } from './scene-setup.js'; import { S } from './state.js'; import { clock, totalActivity } from './warp.js'; import { HEATMAP_ZONES, zoneIntensity, drawHeatmap, updateHeatmap } from './heatmap.js'; import { triggerShockwave } from './celebrations.js'; // === GRAVITY ANOMALY ZONES === const GRAVITY_ANOMALY_FLOOR = 0.2; export const GRAVITY_ANOMALY_CEIL = 16.0; 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 }, ]; export const gravityZoneObjects = GRAVITY_ZONES.map((zone) => { const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64); const ringMat = new THREE.MeshBasicMaterial({ color: zone.color, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthWrite: false, }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI / 2; ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z); scene.add(ring); const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64); const discMat = new THREE.MeshBasicMaterial({ color: zone.color, transparent: true, opacity: 0.04, side: THREE.DoubleSide, depthWrite: false, }); const disc = new THREE.Mesh(discGeo, discMat); disc.rotation.x = -Math.PI / 2; disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z); scene.add(disc); const count = zone.particleCount; const positions = new Float32Array(count * 3); const driftPhases = new Float32Array(count); const velocities = new Float32Array(count); for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const r = Math.sqrt(Math.random()) * zone.radius; positions[i * 3] = zone.x + Math.cos(angle) * r; positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR); positions[i * 3 + 2] = zone.z + Math.sin(angle) * r; driftPhases[i] = Math.random() * Math.PI * 2; velocities[i] = 0.03 + Math.random() * 0.04; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const mat = new THREE.PointsMaterial({ color: zone.color, size: 0.10, sizeAttenuation: true, transparent: true, opacity: 0.7, depthWrite: false, }); const points = new THREE.Points(geo, mat); scene.add(points); return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities }; }); // Forward ref to portals let _portalsRef = []; export function setExtrasPortalsRef(ref) { _portalsRef = ref; } export function rebuildGravityZones() { if (_portalsRef.length === 0) return; for (let i = 0; i < Math.min(_portalsRef.length, gravityZoneObjects.length); i++) { const portal = _portalsRef[i]; const gz = gravityZoneObjects[i]; const isOnline = portal.status === 'online'; const portalColor = new THREE.Color(portal.color); 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); gz.zone.x = portal.position.x; gz.zone.z = portal.position.z; gz.zone.color = portalColor.getHex(); gz.ringMat.color.copy(portalColor); gz.discMat.color.copy(portalColor); gz.points.material.color.copy(portalColor); 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; 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 === export const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5); export const SPEECH_DURATION = 5.0; export const SPEECH_FADE_IN = 0.35; export const SPEECH_FADE_OUT = 0.7; function createSpeechBubbleTexture(text) { const W = 512, H = 100; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgba(0, 6, 20, 0.85)'; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = '#66aaff'; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); ctx.strokeStyle = '#2244aa'; ctx.lineWidth = 1; ctx.strokeRect(4, 4, W - 8, H - 8); ctx.font = 'bold 12px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText('TIMMY:', 12, 22); const LINE1_MAX = 42; const LINE2_MAX = 48; ctx.font = '15px "Courier New", monospace'; ctx.fillStyle = '#ddeeff'; if (text.length <= LINE1_MAX) { ctx.fillText(text, 12, 58); } else { ctx.fillText(text.slice(0, LINE1_MAX), 12, 46); const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX); ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = '#aabbcc'; ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76); } return new THREE.CanvasTexture(canvas); } export function showTimmySpeech(text) { if (S.timmySpeechSprite) { scene.remove(S.timmySpeechSprite); if (S.timmySpeechSprite.material.map) S.timmySpeechSprite.material.map.dispose(); S.timmySpeechSprite.material.dispose(); S.timmySpeechSprite = null; S.timmySpeechState = null; } const texture = createSpeechBubbleTexture(text); const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0, depthWrite: false, }); const sprite = new THREE.Sprite(material); sprite.scale.set(8.5, 1.65, 1); sprite.position.copy(TIMMY_SPEECH_POS); scene.add(sprite); S.timmySpeechSprite = sprite; S.timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; } // === TIME-LAPSE MODE === const TIMELAPSE_DURATION_S = 30; let timelapseCommits = []; let timelapseWindow = { startMs: 0, endMs: 0 }; const timelapseIndicator = document.getElementById('timelapse-indicator'); const timelapseClock = document.getElementById('timelapse-clock'); const timelapseBarEl = document.getElementById('timelapse-bar'); const timelapseBtnEl = document.getElementById('timelapse-btn'); async function loadTimelapseData() { try { const res = await fetch( 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50', { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } ); if (!res.ok) throw new Error('fetch failed'); const data = await res.json(); const midnight = new Date(); midnight.setHours(0, 0, 0, 0); timelapseCommits = data .map(c => ({ ts: new Date(c.commit?.author?.date || 0).getTime(), author: c.commit?.author?.name || c.author?.login || 'unknown', message: (c.commit?.message || '').split('\n')[0], hash: (c.sha || '').slice(0, 7), })) .filter(c => c.ts >= midnight.getTime()) .sort((a, b) => a.ts - b.ts); } catch { timelapseCommits = []; } const midnight = new Date(); midnight.setHours(0, 0, 0, 0); timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() }; } export function fireTimelapseCommit(commit) { const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author)); if (zone) { zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4); } triggerShockwave(); } export function updateTimelapseHeatmap(virtualMs) { const WINDOW_MS = 90 * 60 * 1000; const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0])); for (const commit of timelapseCommits) { if (commit.ts > virtualMs) break; const age = virtualMs - commit.ts; if (age > WINDOW_MS) continue; const weight = 1 - age / WINDOW_MS; for (const zone of HEATMAP_ZONES) { if (zone.authorMatch.test(commit.author)) { rawWeights[zone.name] += weight; break; } } } const MAX_WEIGHT = 4; for (const zone of HEATMAP_ZONES) { zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0); } drawHeatmap(); } export function updateTimelapseHUD(progress, virtualMs) { if (timelapseClock) { const d = new Date(virtualMs); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); timelapseClock.textContent = `${hh}:${mm}`; } if (timelapseBarEl) { timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`; } } async function startTimelapse() { if (S.timelapseActive) return; await loadTimelapseData(); S.timelapseActive = true; S.timelapseRealStart = clock.getElapsedTime(); S.timelapseProgress = 0; S.timelapseNextCommitIdx = 0; for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0; drawHeatmap(); if (timelapseIndicator) timelapseIndicator.classList.add('visible'); if (timelapseBtnEl) timelapseBtnEl.classList.add('active'); } export function stopTimelapse() { if (!S.timelapseActive) return; S.timelapseActive = false; if (timelapseIndicator) timelapseIndicator.classList.remove('visible'); if (timelapseBtnEl) timelapseBtnEl.classList.remove('active'); updateHeatmap(); } export { timelapseCommits, timelapseWindow, TIMELAPSE_DURATION_S }; export function initTimelapse() { document.addEventListener('keydown', (e) => { if (e.key === 'l' || e.key === 'L') { if (S.timelapseActive) stopTimelapse(); else startTimelapse(); } if (e.key === 'Escape' && S.timelapseActive) stopTimelapse(); }); if (timelapseBtnEl) { timelapseBtnEl.addEventListener('click', () => { if (S.timelapseActive) stopTimelapse(); else startTimelapse(); }); } } // === BITCOIN BLOCK HEIGHT === export function initBitcoin() { const blockHeightDisplay = document.getElementById('block-height-display'); const blockHeightValue = document.getElementById('block-height-value'); async function fetchBlockHeight() { try { const res = await fetch('https://blockstream.info/api/blocks/tip/height'); if (!res.ok) return; const height = parseInt(await res.text(), 10); if (isNaN(height)) return; if (S.lastKnownBlockHeight !== null && height !== S.lastKnownBlockHeight) { blockHeightDisplay.classList.remove('fresh'); void blockHeightDisplay.offsetWidth; blockHeightDisplay.classList.add('fresh'); S._starPulseIntensity = 1.0; } S.lastKnownBlockHeight = height; blockHeightValue.textContent = height.toLocaleString(); } catch (_) { // Network unavailable } } fetchBlockHeight(); setInterval(fetchBlockHeight, 60000); }