WIP: Claude Code progress on #414

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-24 18:22:39 -04:00
parent c0a673038b
commit 75e51619df
13 changed files with 1726 additions and 0 deletions

View File

@@ -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';

View File

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

View File

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

184
modules/narrative/chat.js Normal file
View File

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

260
modules/narrative/oath.js Normal file
View File

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

View File

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

View File

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

147
modules/terrain/clouds.js Normal file
View File

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

338
modules/terrain/island.js Normal file
View File

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

115
modules/terrain/stars.js Normal file
View File

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

View File

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

36
modules/utils/geometry.js Normal file
View File

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

48
modules/utils/perlin.js Normal file
View File

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