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:
@@ -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';
|
||||
|
||||
@@ -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); }
|
||||
|
||||
237
modules/narrative/bookshelves.js
Normal file
237
modules/narrative/bookshelves.js
Normal 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
184
modules/narrative/chat.js
Normal 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
260
modules/narrative/oath.js
Normal 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);
|
||||
}
|
||||
164
modules/portals/commit-banners.js
Normal file
164
modules/portals/commit-banners.js
Normal 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;
|
||||
}
|
||||
157
modules/portals/portal-system.js
Normal file
157
modules/portals/portal-system.js
Normal 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
147
modules/terrain/clouds.js
Normal 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
338
modules/terrain/island.js
Normal 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
115
modules/terrain/stars.js
Normal 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;
|
||||
}
|
||||
34
modules/utils/canvas-utils.js
Normal file
34
modules/utils/canvas-utils.js
Normal 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
36
modules/utils/geometry.js
Normal 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
48
modules/utils/perlin.js
Normal 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
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user