Split the monolithic 5393-line app.js into 32 focused ES modules under modules/ with a thin ~330-line orchestrator. No bundler required — runs in-browser via import maps. Module structure: core/ — scene, ticker, state, theme, audio data/ — gitea, weather, bitcoin, loaders terrain/ — stars, clouds, island effects/ — matrix-rain, energy-beam, lightning, shockwave, rune-ring, gravity-zones panels/ — heatmap, sigil, sovereignty, dual-brain, batcave, earth, agent-board, lora-panel portals/ — portal-system, commit-banners narrative/ — bookshelves, oath, chat utils/ — perlin All files pass node --check. No new dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
8.7 KiB
JavaScript
211 lines
8.7 KiB
JavaScript
// modules/narrative/chat.js — Chat panel, speech bubbles, session export, timelapse
|
|
import * as THREE from 'three';
|
|
import { state } from '../core/state.js';
|
|
import { HEATMAP_ZONES, fetchTimelapseCommits } from '../data/gitea.js';
|
|
import { drawHeatmap } from '../panels/heatmap.js';
|
|
import { triggerShockwave, triggerFireworks, triggerMergeFlash, triggerSovereigntyEasterEgg } from '../effects/shockwave.js';
|
|
|
|
// Speech bubble
|
|
const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5);
|
|
const SPEECH_DURATION = 5.0;
|
|
const SPEECH_FADE_IN = 0.35;
|
|
const SPEECH_FADE_OUT = 0.7;
|
|
|
|
let timmySpeechSprite = null;
|
|
let timmySpeechState = null;
|
|
let _scene, _clock;
|
|
|
|
// Session export
|
|
const sessionLog = [];
|
|
const sessionStart = Date.now();
|
|
|
|
function logMessage(speaker, text) {
|
|
sessionLog.push({ ts: Date.now(), speaker, text });
|
|
}
|
|
|
|
function exportSessionAsMarkdown() {
|
|
const startStr = new Date(sessionStart).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
const lines = ['# Nexus Session Export', '', `**Session started:** ${startStr}`, `**Messages:** ${sessionLog.length}`, '', '---', ''];
|
|
for (const entry of sessionLog) {
|
|
const timeStr = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
lines.push(`### ${entry.speaker} \u2014 ${timeStr}`, '', entry.text, '');
|
|
}
|
|
if (sessionLog.length === 0) { lines.push('*No messages recorded this session.*', ''); }
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `nexus-session-${new Date(sessionStart).toISOString().slice(0, 10)}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
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, 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);
|
|
}
|
|
|
|
function showTimmySpeech(text) {
|
|
if (timmySpeechSprite) {
|
|
_scene.remove(timmySpeechSprite);
|
|
if (timmySpeechSprite.material.map) timmySpeechSprite.material.map.dispose();
|
|
timmySpeechSprite.material.dispose();
|
|
timmySpeechSprite = null; 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);
|
|
timmySpeechSprite = sprite;
|
|
timmySpeechState = { startTime: _clock.getElapsedTime(), sprite };
|
|
}
|
|
|
|
// Timelapse
|
|
const TIMELAPSE_DURATION_S = 30;
|
|
let timelapseActive = false;
|
|
let timelapseRealStart = 0;
|
|
let timelapseProgress = 0;
|
|
let timelapseCommits = [];
|
|
let timelapseWindow = { startMs: 0, endMs: 0 };
|
|
let timelapseNextCommitIdx = 0;
|
|
|
|
function fireTimelapseCommit(commit) {
|
|
const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author));
|
|
if (zone) state.zoneIntensity[zone.name] = Math.min(1.0, (state.zoneIntensity[zone.name] || 0) + 0.4);
|
|
triggerShockwave();
|
|
}
|
|
|
|
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) state.zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
|
|
drawHeatmap();
|
|
}
|
|
|
|
function updateTimelapseHUD(progress, virtualMs) {
|
|
const timelapseClock = document.getElementById('timelapse-clock');
|
|
const timelapseBarEl = document.getElementById('timelapse-bar');
|
|
if (timelapseClock) {
|
|
const d = new Date(virtualMs);
|
|
timelapseClock.textContent = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
}
|
|
if (timelapseBarEl) timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
async function startTimelapse() {
|
|
if (timelapseActive) return;
|
|
timelapseCommits = await fetchTimelapseCommits();
|
|
const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
|
|
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
|
|
timelapseActive = true;
|
|
timelapseRealStart = _clock.getElapsedTime();
|
|
timelapseProgress = 0;
|
|
timelapseNextCommitIdx = 0;
|
|
for (const zone of HEATMAP_ZONES) state.zoneIntensity[zone.name] = 0;
|
|
drawHeatmap();
|
|
const indicator = document.getElementById('timelapse-indicator');
|
|
const btn = document.getElementById('timelapse-btn');
|
|
if (indicator) indicator.classList.add('visible');
|
|
if (btn) btn.classList.add('active');
|
|
}
|
|
|
|
function stopTimelapse() {
|
|
if (!timelapseActive) return;
|
|
timelapseActive = false;
|
|
const indicator = document.getElementById('timelapse-indicator');
|
|
const btn = document.getElementById('timelapse-btn');
|
|
if (indicator) indicator.classList.remove('visible');
|
|
if (btn) btn.classList.remove('active');
|
|
}
|
|
|
|
export function init(scene, clock) {
|
|
_scene = scene;
|
|
_clock = clock;
|
|
|
|
const exportBtn = document.getElementById('export-session');
|
|
if (exportBtn) exportBtn.addEventListener('click', exportSessionAsMarkdown);
|
|
|
|
window.addEventListener('chat-message', (event) => {
|
|
if (typeof event.detail?.text === 'string') {
|
|
logMessage(event.detail.speaker || 'TIMMY', event.detail.text);
|
|
showTimmySpeech(event.detail.text);
|
|
if (event.detail.text.toLowerCase().includes('sovereignty')) triggerSovereigntyEasterEgg();
|
|
if (event.detail.text.toLowerCase().includes('milestone')) triggerFireworks();
|
|
}
|
|
});
|
|
window.addEventListener('milestone-complete', () => { triggerFireworks(); });
|
|
window.addEventListener('pr-notification', (event) => {
|
|
if (event.detail && event.detail.action === 'merged') triggerMergeFlash();
|
|
});
|
|
|
|
// Timelapse bindings
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'l' || e.key === 'L') { if (timelapseActive) stopTimelapse(); else startTimelapse(); }
|
|
if (e.key === 'Escape' && timelapseActive) stopTimelapse();
|
|
});
|
|
const timelapseBtnEl = document.getElementById('timelapse-btn');
|
|
if (timelapseBtnEl) timelapseBtnEl.addEventListener('click', () => { if (timelapseActive) stopTimelapse(); else startTimelapse(); });
|
|
}
|
|
|
|
export function update(elapsed) {
|
|
// Speech bubble animation
|
|
if (timmySpeechState) {
|
|
const age = elapsed - timmySpeechState.startTime;
|
|
let opacity;
|
|
if (age < SPEECH_FADE_IN) opacity = age / SPEECH_FADE_IN;
|
|
else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) opacity = 1.0;
|
|
else if (age < SPEECH_DURATION) opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT;
|
|
else {
|
|
_scene.remove(timmySpeechState.sprite);
|
|
if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose();
|
|
timmySpeechState.sprite.material.dispose();
|
|
timmySpeechSprite = null; timmySpeechState = null; opacity = 0;
|
|
}
|
|
if (timmySpeechState) {
|
|
timmySpeechState.sprite.material.opacity = opacity;
|
|
timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1;
|
|
}
|
|
}
|
|
|
|
// Timelapse tick
|
|
if (timelapseActive) {
|
|
const realElapsed = elapsed - timelapseRealStart;
|
|
timelapseProgress = Math.min(realElapsed / TIMELAPSE_DURATION_S, 1.0);
|
|
const span = timelapseWindow.endMs - timelapseWindow.startMs;
|
|
const virtualMs = timelapseWindow.startMs + span * timelapseProgress;
|
|
while (timelapseNextCommitIdx < timelapseCommits.length && timelapseCommits[timelapseNextCommitIdx].ts <= virtualMs) {
|
|
fireTimelapseCommit(timelapseCommits[timelapseNextCommitIdx]);
|
|
timelapseNextCommitIdx++;
|
|
}
|
|
updateTimelapseHeatmap(virtualMs);
|
|
updateTimelapseHUD(timelapseProgress, virtualMs);
|
|
if (timelapseProgress >= 1.0) stopTimelapse();
|
|
}
|
|
}
|