// modules/narrative/oath.js — Interactive SOUL.md reading with dramatic lighting import * as THREE from 'three'; import { THEME } from '../core/theme.js'; let tomeGroup, tomeGlow, oathSpot; let oathActive = false; let oathLines = []; let oathRevealTimer = null; let _ambientLight, _overheadLight; let AMBIENT_NORMAL, OVERHEAD_NORMAL; let _renderer, _camera; async function loadSoulMd() { try { const res = await fetch('SOUL.md'); if (!res.ok) throw new Error('not found'); const raw = await res.text(); return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, '')); } catch { return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.']; } } function scheduleOathLines(lines, textEl) { let idx = 0; const INTERVAL_MS = 1400; function revealNext() { if (idx >= lines.length || !oathActive) return; const line = lines[idx++]; const span = document.createElement('span'); span.classList.add('oath-line'); if (!line.trim()) span.classList.add('blank'); else span.textContent = line; textEl.appendChild(span); oathRevealTimer = setTimeout(revealNext, line.trim() ? INTERVAL_MS : INTERVAL_MS * 0.4); } revealNext(); } async function enterOath() { if (oathActive) return; oathActive = true; _ambientLight.intensity = 0.04; _overheadLight.intensity = 0.0; oathSpot.intensity = 4.0; const overlay = document.getElementById('oath-overlay'); const textEl = document.getElementById('oath-text'); if (!overlay || !textEl) return; textEl.textContent = ''; overlay.classList.add('visible'); if (!oathLines.length) oathLines = await loadSoulMd(); scheduleOathLines(oathLines, textEl); } function exitOath() { if (!oathActive) return; oathActive = false; if (oathRevealTimer !== null) { clearTimeout(oathRevealTimer); oathRevealTimer = null; } _ambientLight.intensity = AMBIENT_NORMAL; _overheadLight.intensity = OVERHEAD_NORMAL; oathSpot.intensity = 0; const overlay = document.getElementById('oath-overlay'); if (overlay) overlay.classList.remove('visible'); } export function init(scene, ambientLight, overheadLight, renderer, camera) { _ambientLight = ambientLight; _overheadLight = overheadLight; _renderer = renderer; _camera = camera; AMBIENT_NORMAL = ambientLight.intensity; OVERHEAD_NORMAL = overheadLight.intensity; tomeGroup = new THREE.Group(); tomeGroup.position.set(0, 5.8, 0); tomeGroup.userData.zoomLabel = 'The Oath'; const tomeCoverMat = new THREE.MeshStandardMaterial({ color: 0x2a1800, metalness: 0.15, roughness: 0.7, emissive: new THREE.Color(0xffd700).multiplyScalar(0.04) }); const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 }); const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat); tomeGroup.add(tomeBody); const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat); tomePages.position.set(0.02, 0, 0); tomeGroup.add(tomePages); const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 }); const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat); tomeSpine.position.set(-0.52, 0, 0); tomeGroup.add(tomeSpine); tomeGroup.traverse(o => { if (o.isMesh) { o.userData.zoomLabel = 'The Oath'; o.castShadow = true; o.receiveShadow = true; } }); scene.add(tomeGroup); tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5); tomeGlow.position.set(0, 5.4, 0); scene.add(tomeGlow); oathSpot = new THREE.SpotLight(0xffd700, 0, 40, Math.PI / 7, 0.4, 1.2); oathSpot.position.set(0, 22, 0); oathSpot.target.position.set(0, 0, 0); oathSpot.castShadow = true; oathSpot.shadow.mapSize.set(1024, 1024); oathSpot.shadow.camera.near = 1; oathSpot.shadow.camera.far = 50; oathSpot.shadow.bias = -0.002; scene.add(oathSpot); scene.add(oathSpot.target); document.addEventListener('keydown', (e) => { if (e.key === 'o' || e.key === 'O') { if (oathActive) exitOath(); else enterOath(); } if (e.key === 'Escape' && oathActive) exitOath(); }); renderer.domElement.addEventListener('dblclick', (e) => { const mx = (e.clientX / window.innerWidth) * 2 - 1; const my = -(e.clientY / window.innerHeight) * 2 + 1; const tomeRay = new THREE.Raycaster(); tomeRay.setFromCamera(new THREE.Vector2(mx, my), camera); const hits = tomeRay.intersectObjects(tomeGroup.children, true); if (hits.length) { if (oathActive) exitOath(); else enterOath(); } }); loadSoulMd().then(lines => { oathLines = lines; }); } export function update(elapsed) { tomeGroup.position.y = 5.8 + Math.sin(elapsed * 0.6) * 0.18; tomeGroup.rotation.y = elapsed * 0.3; tomeGlow.intensity = 0.3 + Math.sin(elapsed * 1.4) * 0.12; if (oathActive) oathSpot.intensity = 3.8 + Math.sin(elapsed * 0.9) * 0.4; }