Files
the-nexus/modules/narrative/chat.js
Perplexity Computer 675b61d65e
All checks were successful
CI / validate (pull_request) Successful in 14s
CI / auto-merge (pull_request) Successful in 0s
refactor: modularize app.js into ES module architecture
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>
2026-03-24 18:12:53 +00:00

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