Files
the-nexus/modules/extras.js
Timmy Time 764b617a2a
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Staging Smoke Test / smoke-test (push) Failing after 0s
[modularization] Phase 2: Extract data layer — gitea, weather, bitcoin, loaders (#460)
2026-03-24 21:28:03 +00:00

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);
}