320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
// === 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';
|
|
import { fetchNexusCommits } from './data/gitea.js';
|
|
import { fetchBlockHeight, BITCOIN_REFRESH_MS } from './data/bitcoin.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 data = await fetchNexusCommits();
|
|
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 pollBlockHeight() {
|
|
const result = await fetchBlockHeight();
|
|
if (!result) return;
|
|
|
|
if (result.isNewBlock && blockHeightDisplay) {
|
|
blockHeightDisplay.classList.remove('fresh');
|
|
void blockHeightDisplay.offsetWidth;
|
|
blockHeightDisplay.classList.add('fresh');
|
|
}
|
|
|
|
if (blockHeightValue) {
|
|
blockHeightValue.textContent = result.height.toLocaleString();
|
|
}
|
|
}
|
|
|
|
pollBlockHeight();
|
|
setInterval(pollBlockHeight, BITCOIN_REFRESH_MS);
|
|
}
|