diff --git a/modules/core/theme.js b/modules/core/theme.js index d8e1c5e3..fba05228 100644 --- a/modules/core/theme.js +++ b/modules/core/theme.js @@ -15,3 +15,6 @@ export const THEME = { dim: '#a0b8d0' } }; + +// Re-export NEXUS color palette so modules can import from a single theme module +export { NEXUS } from '../constants.js'; diff --git a/modules/core/ticker.js b/modules/core/ticker.js index 333e2c3b..cfdc6a78 100644 --- a/modules/core/ticker.js +++ b/modules/core/ticker.js @@ -8,3 +8,6 @@ export class Ticker { } } export const globalTicker = new Ticker(); + +// Convenience export for modules that do `import { subscribe } from '../core/ticker.js'` +export function subscribe(fn) { globalTicker.subscribe(fn); } diff --git a/modules/narrative/bookshelves.js b/modules/narrative/bookshelves.js new file mode 100644 index 00000000..cc273a7e --- /dev/null +++ b/modules/narrative/bookshelves.js @@ -0,0 +1,237 @@ +/** + * bookshelves.js — Floating bookshelves displaying merged PRs + * + * Category: REAL + * Data source: Gitea pulls API (via data/gitea.js — fetchMergedPRs) + * + * Two bookshelves float behind the island. Each book spine shows a PR number + * and title drawn on a canvas texture. The shelves bob gently in the ticker. + */ + +import * as THREE from 'three'; +import { fetchMergedPRs } from '../data/gitea.js'; +import { NEXUS } from '../constants.js'; + +let _scene = null; + +/** @type {THREE.Group[]} */ +const bookshelfGroups = []; + +// --------------------------------------------------------------------------- +// Spine canvas texture +// --------------------------------------------------------------------------- + +/** + * @param {number} prNum + * @param {string} title + * @param {string} bgColor CSS hex string (e.g. '#0f0818') + * @returns {THREE.CanvasTexture} + */ +function _createSpineTexture(prNum, title, bgColor) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, 128, 512); + + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 3; + ctx.strokeRect(3, 3, 122, 506); + + ctx.font = 'bold 32px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.textAlign = 'center'; + ctx.fillText(`#${prNum}`, 64, 58); + + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(12, 78); + ctx.lineTo(116, 78); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + ctx.save(); + ctx.translate(64, 300); + ctx.rotate(-Math.PI / 2); + const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; + ctx.font = '21px "Courier New", monospace'; + ctx.fillStyle = '#ccd6f6'; + ctx.textAlign = 'center'; + ctx.fillText(displayTitle, 0, 0); + ctx.restore(); + + return new THREE.CanvasTexture(canvas); +} + +// --------------------------------------------------------------------------- +// Bookshelf builder +// --------------------------------------------------------------------------- + +const BOOK_COLORS = [ + '#0f0818', '#080f18', '#0f1108', '#07120e', + '#130c06', '#060b12', '#120608', '#080812', +]; + +/** + * @param {{ prNum: number, title: string }[]} books + * @param {THREE.Vector3} position + * @param {number} rotationY + */ +function _buildBookshelf(books, position, rotationY) { + const group = new THREE.Group(); + group.position.copy(position); + group.rotation.y = rotationY; + + const SHELF_W = books.length * 0.52 + 0.6; + const SHELF_THICKNESS = 0.12; + const SHELF_DEPTH = 0.72; + const ENDPANEL_H = 2.0; + + const shelfMat = new THREE.MeshStandardMaterial({ + color: 0x0d1520, + metalness: 0.6, + roughness: 0.5, + emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02), + }); + + // Horizontal plank + const plank = new THREE.Mesh( + new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), + shelfMat + ); + group.add(plank); + + // End panels + const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); + const leftEnd = new THREE.Mesh(endGeo, shelfMat); + leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(leftEnd); + + const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); + rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(rightEnd); + + // Front glow strip + const glowStrip = new THREE.Mesh( + new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), + new THREE.MeshBasicMaterial({ + color: NEXUS.colors.accent, + transparent: true, + opacity: 0.55, + }) + ); + glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); + group.add(glowStrip); + + // Books + const bookStartX = -(SHELF_W / 2) + 0.36; + books.forEach((book, i) => { + const spineW = 0.34 + (i % 3) * 0.05; + const bookH = 1.35 + (i % 4) * 0.13; + const coverD = 0.58; + + const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; + const spineTex = _createSpineTexture(book.prNum, book.title, bgColor); + + const plainMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(bgColor), + roughness: 0.85, + metalness: 0.05, + }); + const spineMat = new THREE.MeshBasicMaterial({ map: spineTex }); + + // BoxGeometry face order: +X, -X, +Y, -Y, +Z (spine face), -Z + const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; + const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); + const bookMesh = new THREE.Mesh(bookGeo, bookMats); + bookMesh.position.set( + bookStartX + i * 0.5, + SHELF_THICKNESS / 2 + bookH / 2, + 0 + ); + bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; + group.add(bookMesh); + }); + + // Ambient glow under shelf + const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5); + shelfLight.position.set(0, -0.4, 0); + group.add(shelfLight); + + group.userData.zoomLabel = 'PR Archive — Merged Contributions'; + group.userData.baseY = position.y; + group.userData.floatPhase = bookshelfGroups.length * Math.PI; + group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; + + _scene.add(group); + bookshelfGroups.push(group); +} + +// --------------------------------------------------------------------------- +// Module API +// --------------------------------------------------------------------------- + +/** + * @param {THREE.Scene} scene + * @param {object} _state + * @param {object} _theme + */ +export async function init(scene, _state, _theme) { + _scene = scene; + + let prs = await fetchMergedPRs(20); + + if (prs.length === 0) { + prs = [ + { prNum: 324, title: 'Model training status \u2014 LoRA adapters' }, + { prNum: 323, title: 'The Oath \u2014 interactive SOUL.md reading' }, + { prNum: 320, title: 'Hermes session save/load' }, + { prNum: 304, title: 'Session export as markdown' }, + { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, + { prNum: 301, title: 'Warp tunnel effect for portals' }, + { prNum: 296, title: 'Procedural terrain for floating island' }, + { prNum: 294, title: 'Northern lights flash on PR merge' }, + ]; + } + + if (prs.length === 0) return; + + const mid = Math.ceil(prs.length / 2); + + _buildBookshelf( + prs.slice(0, mid), + new THREE.Vector3(-8.5, 1.5, -4.5), + Math.PI * 0.1 + ); + + if (prs.slice(mid).length > 0) { + _buildBookshelf( + prs.slice(mid), + new THREE.Vector3(8.5, 1.5, -4.5), + -Math.PI * 0.1 + ); + } +} + +/** + * @param {number} elapsed Total seconds since start + * @param {number} _delta + */ +export function update(elapsed, _delta) { + for (const group of bookshelfGroups) { + const ud = group.userData; + group.position.y = + ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.12; + } +} + +export function dispose() { + for (const group of bookshelfGroups) { + _scene.remove(group); + } + bookshelfGroups.length = 0; +} diff --git a/modules/narrative/chat.js b/modules/narrative/chat.js new file mode 100644 index 00000000..c2c2b1bb --- /dev/null +++ b/modules/narrative/chat.js @@ -0,0 +1,184 @@ +/** + * chat.js — Speech bubble and NPC dialog system + * + * Category: DATA-TETHERED AESTHETIC + * Data source: Driven by caller-supplied text; bubble is tethered to scene + * and managed by the ticker (no standalone RAF). + * + * Provides a floating sprite speech bubble above Timmy's position. + * Other modules call showSpeech(text) to enqueue a message. + * The update() function handles fade-in, hold, and fade-out lifecycle. + */ + +import * as THREE from 'three'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** World position of the speech bubble sprite */ +export const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5); + +export const SPEECH_FADE_IN = 0.35; +export const SPEECH_FADE_OUT = 0.7; +export const SPEECH_VISIBLE = 5.0; // seconds fully visible +export const SPEECH_DURATION = SPEECH_FADE_IN + SPEECH_VISIBLE + SPEECH_FADE_OUT; + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +let _scene = null; + +/** @type {THREE.Sprite | null} */ +let _sprite = null; + +/** @type {{ startTime: number } | null} */ +let _speechState = null; + +// Total elapsed seconds (updated each tick, used for lifecycle timing) +let _elapsedOnShow = 0; + +// --------------------------------------------------------------------------- +// Canvas texture +// --------------------------------------------------------------------------- + +/** + * @param {string} text + * @returns {THREE.CanvasTexture} + */ +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); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function _removeSprite() { + if (!_sprite) return; + _scene.remove(_sprite); + if (_sprite.material.map) _sprite.material.map.dispose(); + _sprite.material.dispose(); + _sprite = null; + _speechState = null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * @param {THREE.Scene} scene + * @param {object} _state + * @param {object} _theme + */ +export function init(scene, _state, _theme) { + _scene = scene; +} + +/** + * Display a speech bubble with the given text. + * Any currently-visible bubble is immediately replaced. + * + * @param {string} text + */ +export function showSpeech(text) { + // Capture current elapsed so lifecycle starts from now. + // We rely on the fact that update() has been running and _currentElapsed + // tracks wall time. If update hasn't run yet, _currentElapsed is 0 which + // is fine — the delay is imperceptible. + _removeSprite(); + + 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); + + _sprite = sprite; + _speechState = { startTime: _currentElapsed }; +} + +/** Running elapsed counter (updated by update()) */ +let _currentElapsed = 0; + +/** + * @param {number} elapsed Total seconds since start + * @param {number} _delta + */ +export function update(elapsed, _delta) { + _currentElapsed = elapsed; + + if (!_sprite || !_speechState) return; + + const age = elapsed - _speechState.startTime; + + let opacity; + if (age < SPEECH_FADE_IN) { + opacity = age / SPEECH_FADE_IN; + } else if (age < SPEECH_FADE_IN + SPEECH_VISIBLE) { + opacity = 1.0; + } else if (age < SPEECH_DURATION) { + const t = age - SPEECH_FADE_IN - SPEECH_VISIBLE; + opacity = Math.max(0, 1 - t / SPEECH_FADE_OUT); + } else { + // Bubble has fully faded — clean up + _removeSprite(); + return; + } + + _sprite.material.opacity = opacity; +} + +export function dispose() { + _removeSprite(); +} diff --git a/modules/narrative/oath.js b/modules/narrative/oath.js new file mode 100644 index 00000000..be7b0f10 --- /dev/null +++ b/modules/narrative/oath.js @@ -0,0 +1,260 @@ +/** + * oath.js — The Oath display system (SOUL.md reader) + * + * Category: REAL + * Data source: SOUL.md (via data/loaders.js — fetchSoulMd) + * + * Creates a floating tome 3D object above the island. Double-clicking the tome + * or pressing 'O' opens a full-screen overlay that reveals SOUL.md line by + * line. Pressing 'O' or 'Escape' closes it. + * + * Light levels: on enter, scene ambient + overhead lights dim to near-zero + * and a gold spotlight snaps on. On exit, lights restore. + * The module discovers the lights by traversing the scene for them so it does + * not need to import from scene-setup.js. + */ + +import * as THREE from 'three'; +import { fetchSoulMd } from '../data/loaders.js'; + +// --------------------------------------------------------------------------- +// Module-level refs (set during init) +// --------------------------------------------------------------------------- + +let _scene = null; +let _camera = null; +let _renderer = null; + +// Discovered scene lights (found by name during init) +let _ambientLight = null; +let _overheadLight = null; + +// Saved "normal" intensities restored on exit +let _ambientNormal = 1.0; +let _overheadNormal = 1.5; + +// Oath runtime state +let _oathActive = false; +let _oathLines = []; +let _oathRevealTimer = null; + +// 3D objects +let _tomeGroup = null; +let _tomeGlow = null; +let _oathSpot = null; + +// --------------------------------------------------------------------------- +// Tome construction +// --------------------------------------------------------------------------- + +function _buildTome() { + const group = new THREE.Group(); + group.position.set(0, 5.8, 0); + group.userData.zoomLabel = 'The Oath'; + + const coverMat = new THREE.MeshStandardMaterial({ + color: 0x2a1800, + metalness: 0.15, + roughness: 0.7, + emissive: new THREE.Color(0xffd700).multiplyScalar(0.04), + }); + const pagesMat = new THREE.MeshStandardMaterial({ + color: 0xd8ceb0, + roughness: 0.9, + metalness: 0.0, + }); + const spineMat = new THREE.MeshStandardMaterial({ + color: 0xffd700, + metalness: 0.6, + roughness: 0.4, + }); + + const body = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), coverMat); + group.add(body); + + const pages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), pagesMat); + pages.position.set(0.02, 0, 0); + group.add(pages); + + const spine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), spineMat); + spine.position.set(-0.52, 0, 0); + group.add(spine); + + group.traverse(o => { + if (o.isMesh) { + o.userData.zoomLabel = 'The Oath'; + o.castShadow = true; + o.receiveShadow = true; + } + }); + + return group; +} + +// --------------------------------------------------------------------------- +// Light discovery +// --------------------------------------------------------------------------- + +function _discoverLights(scene) { + scene.traverse(obj => { + if (obj.isAmbientLight) { + _ambientLight = obj; + _ambientNormal = obj.intensity; + } + if (obj.isDirectionalLight && !_overheadLight) { + _overheadLight = obj; + _overheadNormal = obj.intensity; + } + }); +} + +// --------------------------------------------------------------------------- +// Oath overlay logic +// --------------------------------------------------------------------------- + +const INTERVAL_MS = 1400; + +function _scheduleOathLines(lines, textEl) { + let idx = 0; + + 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(); +} + +export async function enterOath() { + if (_oathActive) return; + _oathActive = true; + + if (_ambientLight) _ambientLight.intensity = 0.04; + if (_overheadLight) _overheadLight.intensity = 0.0; + if (_oathSpot) _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 === 0) { + _oathLines = await fetchSoulMd(); + } + _scheduleOathLines(_oathLines, textEl); +} + +export function exitOath() { + if (!_oathActive) return; + _oathActive = false; + + if (_oathRevealTimer !== null) { + clearTimeout(_oathRevealTimer); + _oathRevealTimer = null; + } + + if (_ambientLight) _ambientLight.intensity = _ambientNormal; + if (_overheadLight) _overheadLight.intensity = _overheadNormal; + if (_oathSpot) _oathSpot.intensity = 0; + + const overlay = document.getElementById('oath-overlay'); + if (overlay) overlay.classList.remove('visible'); +} + +// --------------------------------------------------------------------------- +// Module API +// --------------------------------------------------------------------------- + +/** + * @param {THREE.Scene} scene + * @param {object} _state + * @param {object} _theme + * @param {{ camera?: THREE.Camera, renderer?: THREE.WebGLRenderer }} [extras] + * Optional camera + renderer refs needed for dblclick raycasting. + * If omitted the dblclick listener is skipped. + */ +export function init(scene, _state, _theme, extras = {}) { + _scene = scene; + _camera = extras.camera || null; + _renderer = extras.renderer || null; + + // Build and add tome + _tomeGroup = _buildTome(); + scene.add(_tomeGroup); + + // Tome glow point light + _tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5); + _tomeGlow.position.set(0, 5.4, 0); + scene.add(_tomeGlow); + + // Oath spotlight (starts off) + _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); + + // Discover ambient + directional lights to save their baseline intensities + _discoverLights(scene); + + // Keyboard listener + document.addEventListener('keydown', e => { + if (e.key === 'o' || e.key === 'O') { + if (_oathActive) exitOath(); else enterOath(); + } + if (e.key === 'Escape' && _oathActive) exitOath(); + }); + + // Double-click raycasting (only if camera + renderer were supplied) + if (_camera && _renderer) { + _renderer.domElement.addEventListener('dblclick', e => { + const mx = (e.clientX / window.innerWidth) * 2 - 1; + const my = -(e.clientY / window.innerHeight) * 2 + 1; + const ray = new THREE.Raycaster(); + ray.setFromCamera(new THREE.Vector2(mx, my), _camera); + const hits = ray.intersectObjects(_tomeGroup.children, true); + if (hits.length) { + if (_oathActive) exitOath(); else enterOath(); + } + }); + } + + // Pre-fetch so the first open is instant + fetchSoulMd().then(lines => { _oathLines = lines; }); +} + +/** + * Animates the tome glow pulse. + * @param {number} elapsed Total seconds since start + * @param {number} _delta + */ +export function update(elapsed, _delta) { + if (_tomeGlow) { + _tomeGlow.intensity = 0.4 + Math.sin(elapsed * 1.4) * 0.15; + } +} + +export function dispose() { + if (_scene) { + if (_tomeGroup) _scene.remove(_tomeGroup); + if (_tomeGlow) _scene.remove(_tomeGlow); + if (_oathSpot) { _scene.remove(_oathSpot); _scene.remove(_oathSpot.target); } + } + if (_oathRevealTimer !== null) clearTimeout(_oathRevealTimer); +} diff --git a/modules/portals/commit-banners.js b/modules/portals/commit-banners.js new file mode 100644 index 00000000..3de5df93 --- /dev/null +++ b/modules/portals/commit-banners.js @@ -0,0 +1,164 @@ +/** + * commit-banners.js — Floating commit banner sprites + * + * Category: REAL + * Data source: Gitea commits API (via data/gitea.js — fetchNexusCommits) + * + * Loads the last N commits and renders each as a floating canvas-texture + * sprite near the island surface. Banners fade in, float gently, then fade + * out and respawn in sequence. + */ + +import * as THREE from 'three'; +import { fetchNexusCommits } from '../data/gitea.js'; + +let _scene = null; + +/** @type {THREE.Sprite[]} */ +const banners = []; + +// --------------------------------------------------------------------------- +// Canvas texture +// --------------------------------------------------------------------------- + +/** + * @param {string} hash 7-char short SHA + * @param {string} message Commit subject line + * @returns {THREE.CanvasTexture} + */ +function _createCommitTexture(hash, message) { + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = 'rgba(0, 0, 16, 0.75)'; + ctx.fillRect(0, 0, 512, 64); + + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, 511, 63); + + ctx.font = 'bold 11px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.fillText(hash, 10, 20); + + ctx.font = '12px "Courier New", monospace'; + ctx.fillStyle = '#ccd6f6'; + const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message; + ctx.fillText(displayMsg, 10, 46); + + return new THREE.CanvasTexture(canvas); +} + +// --------------------------------------------------------------------------- +// Banner lifecycle constants +// --------------------------------------------------------------------------- + +const SPREAD_X = [-7, -3.5, 0, 3.5, 7]; +const SPREAD_Y = [1.0, -1.5, 2.2, -0.8, 1.6]; +const SPREAD_Z = [-1.5, -2.5, -1.0, -2.0, -1.8]; +const FADE_IN_S = 0.5; +const VISIBLE_S = 10.0; +const FADE_OUT_S = 0.8; +const TOTAL_S = FADE_IN_S + VISIBLE_S + FADE_OUT_S; +const FLOAT_AMP = 0.18; // world-units peak-to-peak amplitude + +// --------------------------------------------------------------------------- +// Module API +// --------------------------------------------------------------------------- + +/** + * @param {THREE.Scene} scene + * @param {object} _state + * @param {object} _theme + */ +export async function init(scene, _state, _theme) { + _scene = scene; + + const raw = await fetchNexusCommits(5); + const commits = raw.length > 0 + ? raw.map(c => ({ + hash: c.sha.slice(0, 7), + message: c.commit.message.split('\n')[0], + })) + : [ + { hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' }, + { hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' }, + { hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' }, + { hash: 'm0n1o2p', message: "feat: overview mode bird's-eye view" }, + { hash: 'q3r4s5t', message: 'feat: star field and constellation lines' }, + ]; + + commits.forEach((commit, i) => { + const texture = _createCommitTexture(commit.hash, commit.message); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + }); + + const sprite = new THREE.Sprite(material); + sprite.scale.set(12, 1.5, 1); + + const baseY = SPREAD_Y[i % SPREAD_Y.length]; + sprite.position.set( + SPREAD_X[i % SPREAD_X.length], + baseY, + SPREAD_Z[i % SPREAD_Z.length] + ); + + sprite.userData = { + baseY, + floatPhase: (i / commits.length) * Math.PI * 2, + floatSpeed: 0.25 + i * 0.07, + // Stagger start so banners do not all appear simultaneously + startDelay: i * (TOTAL_S / commits.length), + zoomLabel: `Commit: ${commit.hash}`, + }; + + scene.add(sprite); + banners.push(sprite); + }); +} + +/** + * @param {number} elapsed Total seconds since start + * @param {number} _delta + */ +export function update(elapsed, _delta) { + for (const sprite of banners) { + const ud = sprite.userData; + + // Position within the banner's own cyclic timeline + const localT = (elapsed - ud.startDelay) % TOTAL_S; + + let opacity = 0; + if (localT < 0) { + opacity = 0; + } else if (localT < FADE_IN_S) { + opacity = localT / FADE_IN_S; + } else if (localT < FADE_IN_S + VISIBLE_S) { + opacity = 1; + } else { + const fadeOutT = localT - FADE_IN_S - VISIBLE_S; + opacity = Math.max(0, 1 - fadeOutT / FADE_OUT_S); + } + + sprite.material.opacity = opacity; + + // Vertical float + sprite.position.y = + ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * FLOAT_AMP; + } +} + +export function dispose() { + for (const sprite of banners) { + _scene.remove(sprite); + if (sprite.material.map) sprite.material.map.dispose(); + sprite.material.dispose(); + } + banners.length = 0; +} diff --git a/modules/portals/portal-system.js b/modules/portals/portal-system.js new file mode 100644 index 00000000..e8d74e9d --- /dev/null +++ b/modules/portals/portal-system.js @@ -0,0 +1,157 @@ +/** + * portal-system.js — Portal creation, data loading, and health checks + * + * Category: REAL + Health Check + * Data source: portals.json (via data/loaders.js) + URL probe for online status + * + * Creates torus portal meshes from portals.json descriptors. + * Writes portal data to state.portals so effects modules (rune-ring, + * gravity-zones) can react to it without coupling back to this module. + */ + +import * as THREE from 'three'; +import { fetchPortals } from '../data/loaders.js'; + +let _scene = null; +let _state = null; + +const portalGroup = new THREE.Group(); + +/** @type {THREE.Mesh[]} — individual portal ring meshes, one per portal */ +const portalMeshes = []; + +// Shared geometry for all portal rings +const _portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100); + +// Spin speed in radians per second (gentle idle rotation) +const SPIN_SPEED = 0.18; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function _buildPortalMesh(portal) { + const isOnline = portal.status === 'online'; + + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(portal.color).convertSRGBToLinear(), + transparent: true, + opacity: isOnline ? 0.7 : 0.15, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + + const mesh = new THREE.Mesh(_portalGeo, mat); + mesh.position.set( + portal.position.x, + portal.position.y + 0.5, + portal.position.z + ); + mesh.rotation.y = portal.rotation ? portal.rotation.y : 0; + mesh.rotation.x = Math.PI / 2; + + mesh.name = `portal-${portal.id}`; + mesh.userData.destinationUrl = portal.destination?.url || null; + mesh.userData.portalColor = new THREE.Color(portal.color).convertSRGBToLinear(); + mesh.userData.portalId = portal.id; + mesh.userData.baseRotationY = portal.rotation ? portal.rotation.y : 0; + mesh.userData.spinPhase = Math.random() * Math.PI * 2; + + return mesh; +} + +function _createPortalMeshes(portals) { + // Clear any previous meshes + for (const mesh of portalMeshes) { + portalGroup.remove(mesh); + mesh.material.dispose(); + } + portalMeshes.length = 0; + + for (const portal of portals) { + const mesh = _buildPortalMesh(portal); + portalGroup.add(mesh); + portalMeshes.push(mesh); + } +} + +/** + * Run health checks against each portal's destination URL. + * Updates the portal's status field in state.portals and adjusts mesh opacity. + */ +async function _runHealthChecks(portals) { + const checks = portals.map(async (portal, i) => { + const url = portal.destination?.url; + if (!url) return; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const res = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: controller.signal }); + clearTimeout(timeoutId); + // no-cors always returns opaque — any non-abort means reachable + portals[i].status = 'online'; + } catch { + portals[i].status = 'offline'; + } + + // Update mesh opacity to match health-check result + const mesh = portalMeshes[i]; + if (mesh) { + mesh.material.opacity = portals[i].status === 'online' ? 0.7 : 0.15; + } + }); + + await Promise.allSettled(checks); +} + +// --------------------------------------------------------------------------- +// Module API +// --------------------------------------------------------------------------- + +/** + * @param {THREE.Scene} scene + * @param {object} state Shared state bus — writes state.portals + * @param {object} _theme + */ +export function init(scene, state, _theme) { + _scene = scene; + _state = state; + + scene.add(portalGroup); + + // Load portals asynchronously; do not block init + fetchPortals() + .then(portals => { + _state.portals = portals; + _createPortalMeshes(portals); + // Run health checks after meshes exist so opacity updates are visible + _runHealthChecks(portals); + }) + .catch(err => { + console.error('[portal-system] Failed to load portals:', err); + _state.portals = []; + }); +} + +/** + * Animates portal rings with a gentle idle spin. + * + * @param {number} elapsed Total seconds since start + * @param {number} _delta + */ +export function update(elapsed, _delta) { + for (let i = 0; i < portalMeshes.length; i++) { + const mesh = portalMeshes[i]; + const phase = mesh.userData.spinPhase ?? 0; + // Rotate around the portal's local up axis (world Y) + mesh.rotation.z = mesh.userData.baseRotationY + elapsed * SPIN_SPEED + phase; + } +} + +export function dispose() { + if (_scene) _scene.remove(portalGroup); + _portalGeo.dispose(); + for (const mesh of portalMeshes) mesh.material.dispose(); + portalMeshes.length = 0; +} diff --git a/modules/terrain/clouds.js b/modules/terrain/clouds.js new file mode 100644 index 00000000..2495cecc --- /dev/null +++ b/modules/terrain/clouds.js @@ -0,0 +1,147 @@ +/** + * clouds.js — Weather-tethered procedural cloud layer + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.weather.cloud_cover (0-100 from Open-Meteo) + * + * A volumetric cloud layer beneath the floating island. + * Cloud density is driven by real weather cloud cover data. + */ + +import * as THREE from 'three'; + +const CLOUD_LAYER_Y = -6.0; +const CLOUD_DIMENSIONS = 120; +const CLOUD_THICKNESS = 15; +const CLOUD_OPACITY = 0.6; + +let _state = null; +let _cloudMaterial = null; + +const CloudShader = { + uniforms: { + 'uTime': { value: 0.0 }, + 'uCloudColor': { value: new THREE.Color(0x88bbff) }, + 'uNoiseScale': { value: new THREE.Vector3(0.015, 0.015, 0.015) }, + 'uDensity': { value: 0.8 }, + }, + vertexShader: ` + varying vec3 vWorldPosition; + void main() { + vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uTime; + uniform vec3 uCloudColor; + uniform vec3 uNoiseScale; + uniform float uDensity; + varying vec3 vWorldPosition; + + vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } + vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } + vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } + vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } + float snoise(vec3 v) { + const vec2 C = vec2(1.0/6.0, 1.0/3.0); + const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); + vec3 i = floor(v + dot(v, C.yyy)); + vec3 x0 = v - i + dot(i, C.xxx); + vec3 g = step(x0.yzx, x0.xyz); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + C.xxx; + vec3 x2 = x0 - i2 + C.yyy; + vec3 x3 = x0 - D.yyy; + i = mod289(i); + vec4 p = permute(permute(permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + float n_ = 0.142857142857; + vec3 ns = n_ * D.wyz - D.xzx; + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); + p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; + vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); + m = m * m; + return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); + } + + void main() { + vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(uTime * 0.003, 0.0, uTime * 0.002); + + float noiseVal = snoise(noiseCoord) * 0.500; + noiseVal += snoise(noiseCoord * 2.0) * 0.250; + noiseVal += snoise(noiseCoord * 4.0) * 0.125; + noiseVal /= 0.875; + + float density = smoothstep(0.25, 0.85, noiseVal * 0.5 + 0.5); + density *= uDensity; + + float layerBottom = -13.5; + float yNorm = (vWorldPosition.y - layerBottom) / 15.0; + float fadeFactor = smoothstep(0.0, 0.15, yNorm) * smoothstep(1.0, 0.85, yNorm); + + gl_FragColor = vec4(uCloudColor, density * fadeFactor * 0.6); + if (gl_FragColor.a < 0.04) discard; + } + `, +}; + +/** + * @param {THREE.Scene} scene + * @param {object} state + * @param {object} _theme + */ +export function init(scene, state, _theme) { + _state = state; + + _cloudMaterial = new THREE.ShaderMaterial({ + uniforms: CloudShader.uniforms, + vertexShader: CloudShader.vertexShader, + fragmentShader: CloudShader.fragmentShader, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }); + + const geo = new THREE.BoxGeometry(CLOUD_DIMENSIONS, CLOUD_THICKNESS, CLOUD_DIMENSIONS, 8, 4, 8); + const clouds = new THREE.Mesh(geo, _cloudMaterial); + clouds.position.y = CLOUD_LAYER_Y; + scene.add(clouds); +} + +/** + * @param {number} elapsed - total elapsed time in seconds + * @param {number} _delta + */ +export function update(elapsed, _delta) { + if (!_cloudMaterial) return; + _cloudMaterial.uniforms.uTime.value = elapsed; + + // Tether density to weather cloud cover + if (_state?.weather) { + const cover = (_state.weather.cloud_cover ?? 50) / 100; + _cloudMaterial.uniforms.uDensity.value = 0.3 + cover * 0.7; + } +} diff --git a/modules/terrain/island.js b/modules/terrain/island.js new file mode 100644 index 00000000..02e6ebfe --- /dev/null +++ b/modules/terrain/island.js @@ -0,0 +1,338 @@ +/** + * island.js — Floating island terrain, crystal formations, and glass platform + * + * Category: DATA-TETHERED AESTHETIC (crystals) + STRUCTURAL (island terrain, glass platform) + * Data source: state.totalActivity() (crystal emissive intensity) + * + * The island terrain and glass platform are static geometry. + * Crystal emissive intensity pulses with overall commit activity. + */ + +import * as THREE from 'three'; +import { createPerlinNoise } from '../utils/perlin.js'; + +const ISLAND_RADIUS = 9.5; +const CRYSTAL_MIN_H = 2.05; +const GLASS_TILE_SIZE = 0.85; +const GLASS_TILE_GAP = 0.14; +const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP; +const GLASS_RADIUS = 4.55; + +let _state = null; +/** @type {THREE.MeshStandardMaterial|null} */ +let _crystalMat = null; + +/** + * Build the floating island top surface, crystal spires, and bottom cap. + * + * @param {THREE.Scene} scene + * @param {function} perlin + * @param {number} accentColor + */ +function _buildIsland(scene, perlin, accentColor) { + const SEGMENTS = 96; + const SIZE = ISLAND_RADIUS * 2; + + function islandFBm(nx, nz) { + const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55; + const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55; + const px = nx + wx, pz = nz + wz; + + let h = 0; + h += perlin(px, pz ) * 1.000; + h += perlin(px * 2, pz * 2 ) * 0.500; + h += perlin(px * 4, pz * 4 ) * 0.250; + h += perlin(px * 8, pz * 8 ) * 0.125; + h += perlin(px * 16, pz * 16 ) * 0.063; + h /= 1.938; + + const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0)); + return h * 0.78 + ridge * 0.22; + } + + // ---- Top surface ---- + const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); + geo.rotateX(-Math.PI / 2); + const pos = geo.attributes.position; + const vCount = pos.count; + + const rawHeights = new Float32Array(vCount); + + for (let i = 0; i < vCount; i++) { + const x = pos.getX(i); + const z = pos.getZ(i); + const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS; + + const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10; + const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4)); + + const h = islandFBm(x * 0.15, z * 0.15); + const height = ((h + 1) * 0.5) * edgeFactor * 3.2; + pos.setY(i, height); + rawHeights[i] = height; + } + + geo.computeVertexNormals(); + + const colBuf = new Float32Array(vCount * 3); + for (let i = 0; i < vCount; i++) { + const h = rawHeights[i]; + let r, g, b; + if (h < 0.25) { + r = 0.11; g = 0.09; b = 0.07; + } else if (h < 0.75) { + const t = (h - 0.25) / 0.50; + r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06; + } else if (h < 1.4) { + const t = (h - 0.75) / 0.65; + r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10; + } else if (h < 2.2) { + const t = (h - 1.4) / 0.80; + r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13; + } else { + const t = Math.min(1, (h - 2.2) / 0.9); + r = 0.50 + t * 0.05; + g = 0.39 + t * 0.10; + b = 0.36 + t * 0.28; + } + colBuf[i * 3] = r; + colBuf[i * 3 + 1] = g; + colBuf[i * 3 + 2] = b; + } + geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3)); + + const topMat = new THREE.MeshStandardMaterial({ + vertexColors: true, + roughness: 0.86, + metalness: 0.05, + }); + const topMesh = new THREE.Mesh(geo, topMat); + topMesh.castShadow = true; + topMesh.receiveShadow = true; + + // ---- Crystal spires ---- + _crystalMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(accentColor).multiplyScalar(0.55), + emissive: new THREE.Color(accentColor), + emissiveIntensity: 0.5, + roughness: 0.08, + metalness: 0.25, + transparent: true, + opacity: 0.80, + }); + + /** @type {Array<{sx:number,sz:number,posY:number,rotX:number,rotZ:number,scaleXZ:number,scaleY:number}>} */ + const _spireData = []; + for (let row = -5; row <= 5; row++) { + for (let col = -5; col <= 5; col++) { + const bx = col * 1.75, bz = row * 1.75; + if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue; + + const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4)); + const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2; + if (candidateH < CRYSTAL_MIN_H) continue; + + const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55; + const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55; + if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue; + + const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3); + for (let c = 0; c < clusterSize; c++) { + const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4; + const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22; + const sx = jx + Math.cos(angle) * spread; + const sz = jz + Math.sin(angle) * spread; + const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11; + const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45); + const spireR = spireH * 0.17; + _spireData.push({ + sx, sz, + posY: candidateH + spireH * 0.5, + rotX: perlin(sx * 3 + 1, sz * 3 + 1) * 0.18, + rotZ: perlin(sx * 2, sz * 2) * 0.28, + scaleXZ: spireR, + scaleY: spireH * 2.8, + }); + } + } + } + + const _spireDummy = new THREE.Object3D(); + const spireBaseGeo = new THREE.ConeGeometry(1, 1, 5); + const crystalGroup = new THREE.Group(); + const spireIM = new THREE.InstancedMesh(spireBaseGeo, _crystalMat, _spireData.length); + spireIM.castShadow = true; + spireIM.instanceMatrix.setUsage(THREE.StaticDrawUsage); + for (let i = 0; i < _spireData.length; i++) { + const { sx, sz, posY, rotX, rotZ, scaleXZ, scaleY } = _spireData[i]; + _spireDummy.position.set(sx, posY, sz); + _spireDummy.rotation.set(rotX, 0, rotZ); + _spireDummy.scale.set(scaleXZ, scaleY, scaleXZ); + _spireDummy.updateMatrix(); + spireIM.setMatrixAt(i, _spireDummy.matrix); + } + spireIM.instanceMatrix.needsUpdate = true; + crystalGroup.add(spireIM); + + // ---- Bottom underside ---- + const BOTTOM_SEGS_R = 52; + const BOTTOM_SEGS_V = 10; + const BOTTOM_HEIGHT = 2.6; + const bottomGeo = new THREE.CylinderGeometry( + ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28, + BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true + ); + const bPos = bottomGeo.attributes.position; + for (let i = 0; i < bPos.count; i++) { + const bx = bPos.getX(i); + const bz = bPos.getZ(i); + const by = bPos.getY(i); + const angle = Math.atan2(bz, bx); + const r = Math.sqrt(bx * bx + bz * bz); + + const radDisp = perlin(Math.cos(angle) * 1.6 + 50, Math.sin(angle) * 1.6 + 50) * 0.65; + const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT; + const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9; + + const newR = r + radDisp; + bPos.setX(i, (bx / r) * newR); + bPos.setZ(i, (bz / r) * newR); + bPos.setY(i, by - stalDisp); + } + bottomGeo.computeVertexNormals(); + + const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 }); + const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat); + bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5; + bottomMesh.castShadow = true; + + const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48); + capGeo.rotateX(Math.PI / 2); + const capMesh = new THREE.Mesh(capGeo, bottomMat); + capMesh.position.y = -(BOTTOM_HEIGHT + 0.1); + + const islandGroup = new THREE.Group(); + islandGroup.add(topMesh); + islandGroup.add(crystalGroup); + islandGroup.add(bottomMesh); + islandGroup.add(capMesh); + islandGroup.position.y = -2.8; + scene.add(islandGroup); +} + +/** + * Build the glass tile platform at origin height 0. + * + * @param {THREE.Scene} scene + * @param {number} accentColor + */ +function _buildGlassPlatform(scene, accentColor) { + const glassPlatformGroup = new THREE.Group(); + + const platformFrameMat = new THREE.MeshStandardMaterial({ + color: 0x0a1828, + metalness: 0.9, + roughness: 0.1, + emissive: new THREE.Color(accentColor).multiplyScalar(0.06), + }); + + const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64); + const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat); + platformRim.rotation.x = -Math.PI / 2; + platformRim.castShadow = true; + platformRim.receiveShadow = true; + glassPlatformGroup.add(platformRim); + + const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64); + const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat); + borderTorus.rotation.x = Math.PI / 2; + borderTorus.castShadow = true; + borderTorus.receiveShadow = true; + glassPlatformGroup.add(borderTorus); + + const glassTileMat = new THREE.MeshPhysicalMaterial({ + color: new THREE.Color(accentColor), + transparent: true, + opacity: 0.09, + roughness: 0.0, + metalness: 0.0, + transmission: 0.92, + thickness: 0.06, + side: THREE.DoubleSide, + depthWrite: false, + }); + + const glassEdgeBaseMat = new THREE.LineBasicMaterial({ + color: accentColor, + transparent: true, + opacity: 0.55, + }); + + const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE); + const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo); + + /** @type {Array<{x: number, z: number, distFromCenter: number}>} */ + const _tileSlots = []; + for (let row = -5; row <= 5; row++) { + for (let col = -5; col <= 5; col++) { + const x = col * GLASS_TILE_STEP; + const z = row * GLASS_TILE_STEP; + const distFromCenter = Math.sqrt(x * x + z * z); + if (distFromCenter > GLASS_RADIUS) continue; + _tileSlots.push({ x, z, distFromCenter }); + } + } + + const _tileDummy = new THREE.Object3D(); + const glassTileIM = new THREE.InstancedMesh(tileGeo, glassTileMat, _tileSlots.length); + glassTileIM.instanceMatrix.setUsage(THREE.StaticDrawUsage); + _tileDummy.rotation.x = -Math.PI / 2; + for (let i = 0; i < _tileSlots.length; i++) { + const { x, z } = _tileSlots[i]; + _tileDummy.position.set(x, 0, z); + _tileDummy.updateMatrix(); + glassTileIM.setMatrixAt(i, _tileDummy.matrix); + } + glassTileIM.instanceMatrix.needsUpdate = true; + glassPlatformGroup.add(glassTileIM); + + for (const { x, z } of _tileSlots) { + const mat = glassEdgeBaseMat.clone(); + const edges = new THREE.LineSegments(tileEdgeGeo, mat); + edges.rotation.x = -Math.PI / 2; + edges.position.set(x, 0.002, z); + glassPlatformGroup.add(edges); + } + + const voidLight = new THREE.PointLight(accentColor, 0.5, 14); + voidLight.position.set(0, -3.5, 0); + glassPlatformGroup.add(voidLight); + + scene.add(glassPlatformGroup); + glassPlatformGroup.traverse(obj => { + if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform'; + }); +} + +/** + * @param {THREE.Scene} scene + * @param {object} state + * @param {object} theme + */ +export function init(scene, state, theme) { + _state = state; + const accentColor = theme?.colors?.accent ?? 0x4488ff; + const perlin = createPerlinNoise(); + _buildIsland(scene, perlin, accentColor); + _buildGlassPlatform(scene, accentColor); +} + +/** + * @param {number} _elapsed + * @param {number} _delta + */ +export function update(_elapsed, _delta) { + if (!_crystalMat || !_state) return; + const activity = _state.totalActivity ? _state.totalActivity() : 0; + _crystalMat.emissiveIntensity = 0.3 + activity * 0.7; +} diff --git a/modules/terrain/stars.js b/modules/terrain/stars.js new file mode 100644 index 00000000..31d127a6 --- /dev/null +++ b/modules/terrain/stars.js @@ -0,0 +1,115 @@ +/** + * stars.js — Star field + constellation lines + * + * Category: DATA-TETHERED AESTHETIC + * Data source: state.newBlockDetected, state.starPulseIntensity (Bitcoin block events) + * + * Stars pulse brighter when a new Bitcoin block is found. + * Constellation lines are STRUCTURAL (exempt from data tethering requirement). + */ + +import * as THREE from 'three'; + +const STAR_COUNT = 800; +const STAR_SPREAD = 400; +const CONSTELLATION_DISTANCE = 30; +const BASE_OPACITY = 0.3; +const PEAK_OPACITY = 1.0; +const PULSE_DECAY = 0.012; + +let _state = null; +let _starMaterial = null; +let _pulseIntensity = BASE_OPACITY; + +/** + * @param {THREE.Scene} scene + * @param {object} state + * @param {object} theme + */ +export function init(scene, state, theme) { + _state = state; + + const accentColor = theme?.colors?.starCore ?? 0xffffff; + + const starPositions = []; + const posArray = new Float32Array(STAR_COUNT * 3); + const sizeArray = new Float32Array(STAR_COUNT); + + for (let i = 0; i < STAR_COUNT; i++) { + const x = (Math.random() - 0.5) * STAR_SPREAD; + const y = (Math.random() - 0.5) * STAR_SPREAD; + const z = (Math.random() - 0.5) * STAR_SPREAD; + posArray[i * 3] = x; + posArray[i * 3 + 1] = y; + posArray[i * 3 + 2] = z; + sizeArray[i] = Math.random() * 2.5 + 0.5; + starPositions.push(new THREE.Vector3(x, y, z)); + } + + const starGeo = new THREE.BufferGeometry(); + starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3)); + starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1)); + + _starMaterial = new THREE.PointsMaterial({ + color: accentColor, + size: 0.6, + sizeAttenuation: true, + transparent: true, + opacity: BASE_OPACITY, + }); + + scene.add(new THREE.Points(starGeo, _starMaterial)); + + // Constellation lines (structural — exempt from data tethering) + const linePositions = []; + const MAX_CONNECTIONS = 3; + const connectionCount = new Array(STAR_COUNT).fill(0); + + for (let i = 0; i < STAR_COUNT; i++) { + if (connectionCount[i] >= MAX_CONNECTIONS) continue; + const neighbors = []; + for (let j = i + 1; j < STAR_COUNT; j++) { + if (connectionCount[j] >= MAX_CONNECTIONS) continue; + const dist = starPositions[i].distanceTo(starPositions[j]); + if (dist < CONSTELLATION_DISTANCE) neighbors.push({ j, dist }); + } + neighbors.sort((a, b) => a.dist - b.dist); + for (const { j } of neighbors.slice(0, MAX_CONNECTIONS - connectionCount[i])) { + linePositions.push( + starPositions[i].x, starPositions[i].y, starPositions[i].z, + starPositions[j].x, starPositions[j].y, starPositions[j].z, + ); + connectionCount[i]++; + connectionCount[j]++; + } + } + + const lineGeo = new THREE.BufferGeometry(); + lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3)); + const lineMat = new THREE.LineBasicMaterial({ + color: theme?.colors?.constellationLine ?? 0x334488, + transparent: true, + opacity: 0.18, + }); + scene.add(new THREE.LineSegments(lineGeo, lineMat)); +} + +/** + * @param {number} _elapsed + * @param {number} _delta + */ +export function update(_elapsed, _delta) { + if (!_starMaterial) return; + + // Trigger pulse on new Bitcoin block + if (_state?.newBlockDetected) { + _pulseIntensity = PEAK_OPACITY; + if (_state) _state.newBlockDetected = false; + } + + if (_pulseIntensity > BASE_OPACITY) { + _pulseIntensity = Math.max(BASE_OPACITY, _pulseIntensity - PULSE_DECAY); + } + + _starMaterial.opacity = _pulseIntensity; +} diff --git a/modules/utils/canvas-utils.js b/modules/utils/canvas-utils.js new file mode 100644 index 00000000..db59a074 --- /dev/null +++ b/modules/utils/canvas-utils.js @@ -0,0 +1,34 @@ +// modules/utils/canvas-utils.js — Shared canvas texture creation helpers +import * as THREE from 'three'; + +/** + * Creates a canvas with a dark background and neon border. + * + * @param {number} width + * @param {number} height + * @param {string} bgColor - CSS color string for background + * @param {string} borderColor - CSS color string for border + * @returns {{ canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }} + */ +export function createBorderedCanvas(width, height, bgColor = 'rgba(0,6,20,0.85)', borderColor = '#4488ff') { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = borderColor; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, width - 2, height - 2); + return { canvas, ctx }; +} + +/** + * Wraps a canvas in a THREE.CanvasTexture. + * + * @param {HTMLCanvasElement} canvas + * @returns {THREE.CanvasTexture} + */ +export function canvasToTexture(canvas) { + return new THREE.CanvasTexture(canvas); +} diff --git a/modules/utils/geometry.js b/modules/utils/geometry.js new file mode 100644 index 00000000..3a20861e --- /dev/null +++ b/modules/utils/geometry.js @@ -0,0 +1,36 @@ +// modules/utils/geometry.js — Shared geometry helpers +import * as THREE from 'three'; + +/** + * Creates a flat horizontal ring (lying in the XZ plane). + * + * @param {number} innerR + * @param {number} outerR + * @param {number|THREE.Color} color + * @param {number} [opacity=0.4] + * @returns {{ mesh: THREE.Mesh, mat: THREE.MeshBasicMaterial }} + */ +export function createHorizontalRing(innerR, outerR, color, opacity = 0.4) { + const geo = new THREE.RingGeometry(innerR, outerR, 64); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI / 2; + return { mesh, mat }; +} + +/** + * Creates a thin emissive strip (a very flat box). + * + * @param {number} width + * @param {number|THREE.Color} color + * @param {number} [opacity=0.55] + * @returns {THREE.Mesh} + */ +export function createGlowStrip(width, color, opacity = 0.55) { + const geo = new THREE.BoxGeometry(width, 0.035, 0.035); + const mat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity }); + return new THREE.Mesh(geo, mat); +} diff --git a/modules/utils/perlin.js b/modules/utils/perlin.js new file mode 100644 index 00000000..904d7944 --- /dev/null +++ b/modules/utils/perlin.js @@ -0,0 +1,48 @@ +// modules/utils/perlin.js — Perlin noise generator + +/** + * Creates a seeded Perlin noise function. + * Uses a fixed seed (42) for deterministic terrain generation. + * + * @returns {function(x: number, y: number, z?: number): number} noise function + */ +export function createPerlinNoise() { + const p = new Uint8Array(256); + for (let i = 0; i < 256; i++) p[i] = i; + let seed = 42; + function seededRand() { + seed = (seed * 1664525 + 1013904223) & 0xffffffff; + return (seed >>> 0) / 0xffffffff; + } + for (let i = 255; i > 0; i--) { + const j = Math.floor(seededRand() * (i + 1)); + const tmp = p[i]; p[i] = p[j]; p[j] = tmp; + } + const perm = new Uint8Array(512); + for (let i = 0; i < 512; i++) perm[i] = p[i & 255]; + + function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + function lerp(a, b, t) { return a + t * (b - a); } + function grad(hash, x, y, z) { + const h = hash & 15; + const u = h < 8 ? x : y; + const v = h < 4 ? y : (h === 12 || h === 14) ? x : z; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); + } + + return function noise(x, y, z) { + z = z || 0; + const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255; + x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z); + const u = fade(x), v = fade(y), w = fade(z); + const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z; + const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z; + return lerp( + lerp(lerp(grad(perm[AA], x, y, z ), grad(perm[BA], x-1, y, z ), u), + lerp(grad(perm[AB], x, y-1, z ), grad(perm[BB], x-1, y-1, z ), u), v), + lerp(lerp(grad(perm[AA + 1], x, y, z-1), grad(perm[BA + 1], x-1, y, z-1), u), + lerp(grad(perm[AB + 1], x, y-1, z-1), grad(perm[BB + 1], x-1, y-1, z-1), u), v), + w + ); + }; +}