Files
the-nexus/app.js
Alexander Whitestone 6d14f5da0a
Some checks failed
CI / validate (pull_request) Failing after 11s
CI / auto-merge (pull_request) Has been skipped
feat: journal wall — Alexander's notes visible in the Nexus
Adds a parchment-textured 3D wall panel in a quiet corner of the scene
(-9, 3.5, -2.5) displaying a preview of journal entries from journal.json.
Clicking the panel opens a scrollable HTML overlay styled as aged parchment
with handwritten-style Georgia serif text. Entries load from journal.json
with fallback defaults. Edge glow and warm accent light pulse gently in the
animation loop. Overlay closes via button, backdrop click, or Escape.

Files: app.js, style.css, index.html, journal.json (new)

Fixes #209

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:37:53 -04:00

859 lines
27 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
import { LoadingManager } from 'three';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
bg: 0x000008,
starCore: 0xffffff,
starDim: 0x8899cc,
constellationLine: 0x334488,
constellationFade: 0x112244,
accent: 0x4488ff,
}
};
// === ASSET LOADER ===
const loadedAssets = new Map();
const loadingManager = new THREE.LoadingManager(() => {
document.getElementById('loading-bar').style.width = '100%';
document.getElementById('loading').style.display = 'none';
animate();
});
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
const progress = (itemsLoaded / itemsTotal) * 100;
document.getElementById('loading-bar').style.width = `${progress}%`;
};
// Simulate loading a texture for demonstration
const textureLoader = new THREE.TextureLoader(loadingManager);
textureLoader.load('placeholder-texture.jpg', (texture) => {
loadedAssets.set('placeholder-texture', texture);
});
// === SCENE SETUP ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 6, 11);
// === LIGHTING ===
// Required for MeshStandardMaterial / MeshPhysicalMaterial used on the platform.
const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4);
scene.add(ambientLight);
const overheadLight = new THREE.PointLight(0x8899bb, 0.6, 60);
overheadLight.position.set(0, 25, 0);
scene.add(overheadLight);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// === STAR FIELD ===
const STAR_COUNT = 800;
const STAR_SPREAD = 400;
const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars
const starPositions = [];
const starGeo = new THREE.BufferGeometry();
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));
}
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
const starMaterial = new THREE.PointsMaterial({
color: NEXUS.colors.starCore,
size: 0.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
const stars = new THREE.Points(starGeo, starMaterial);
scene.add(stars);
// === CONSTELLATION LINES ===
// Connect nearby stars with faint lines, limited to avoid clutter
/**
* Builds constellation line segments connecting nearby stars.
* @returns {THREE.LineSegments}
*/
function buildConstellationLines() {
const linePositions = [];
const MAX_CONNECTIONS_PER_STAR = 3;
const connectionCount = new Array(STAR_COUNT).fill(0);
for (let i = 0; i < STAR_COUNT; i++) {
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
// Find nearest neighbors
const neighbors = [];
for (let j = i + 1; j < STAR_COUNT; j++) {
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
const dist = starPositions[i].distanceTo(starPositions[j]);
if (dist < CONSTELLATION_DISTANCE) {
neighbors.push({ j, dist });
}
}
// Sort by distance and connect closest ones
neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist);
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
for (const { j } of toConnect) {
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: NEXUS.colors.constellationLine,
transparent: true,
opacity: 0.18,
});
return new THREE.LineSegments(lineGeo, lineMat);
}
const constellationLines = buildConstellationLines();
scene.add(constellationLines);
// === GLASS PLATFORM ===
// Central floating platform with transparent glass-floor sections revealing the void (star field) below.
const glassPlatformGroup = new THREE.Group();
// Dark metallic frame material
const platformFrameMat = new THREE.MeshStandardMaterial({
color: 0x0a1828,
metalness: 0.9,
roughness: 0.1,
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06),
});
// Outer solid rim (flat ring)
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
platformRim.rotation.x = -Math.PI / 2;
glassPlatformGroup.add(platformRim);
// Raised border torus for visible 3-D thickness
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
borderTorus.rotation.x = Math.PI / 2;
glassPlatformGroup.add(borderTorus);
// Glass tile material — highly transmissive to reveal the void below
const glassTileMat = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(NEXUS.colors.accent),
transparent: true,
opacity: 0.09,
roughness: 0.0,
metalness: 0.0,
transmission: 0.92,
thickness: 0.06,
side: THREE.DoubleSide,
depthWrite: false,
});
// Edge glow — bright accent outline on each tile
const glassEdgeBaseMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.accent,
transparent: true,
opacity: 0.55,
});
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;
const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
const tileEdgeGeo = new THREE.EdgesGeometry(tileGeo);
/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */
const glassEdgeMaterials = [];
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;
// Transparent glass tile
const tile = new THREE.Mesh(tileGeo, glassTileMat.clone());
tile.rotation.x = -Math.PI / 2;
tile.position.set(x, 0, z);
glassPlatformGroup.add(tile);
// Glowing edge lines
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);
glassEdgeMaterials.push({ mat, distFromCenter });
}
}
// Void shimmer — faint point light below the glass, emphasising the infinite depth
const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14);
voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
// === MOUSE-DRIVEN ROTATION ===
let mouseX = 0;
let mouseY = 0;
let targetRotX = 0;
let targetRotY = 0;
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
// === OVERVIEW MODE (Tab — bird's-eye view of the whole Nexus) ===
let overviewMode = false;
let overviewT = 0; // 0 = normal view, 1 = overview
const NORMAL_CAM = new THREE.Vector3(0, 6, 11);
const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); // overhead; tiny Z offset avoids gimbal lock
const overviewIndicator = document.getElementById('overview-indicator');
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
overviewMode = !overviewMode;
if (overviewMode) {
overviewIndicator.classList.add('visible');
} else {
overviewIndicator.classList.remove('visible');
}
}
});
// === PHOTO MODE ===
let photoMode = false;
// Post-processing composer for depth of field (always-on, subtle)
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bokehPass = new BokehPass(scene, camera, {
focus: 5.0,
aperture: 0.00015,
maxblur: 0.004,
});
composer.addPass(bokehPass);
// Orbit controls for free camera movement in photo mode
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.enabled = false;
const photoIndicator = document.getElementById('photo-indicator');
const photoFocusDisplay = document.getElementById('photo-focus');
/**
* Updates the photo mode focus distance display.
*/
function updateFocusDisplay() {
if (photoFocusDisplay) {
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'p' || e.key === 'P') {
photoMode = !photoMode;
document.body.classList.toggle('photo-mode', photoMode);
orbitControls.enabled = photoMode;
if (photoIndicator) {
photoIndicator.classList.toggle('visible', photoMode);
}
if (photoMode) {
// Enhanced DoF in photo mode
bokehPass.uniforms['aperture'].value = 0.0003;
bokehPass.uniforms['maxblur'].value = 0.008;
// Sync orbit target to current look-at
orbitControls.target.set(0, 0, 0);
orbitControls.update();
updateFocusDisplay();
} else {
// Restore subtle ambient DoF
bokehPass.uniforms['aperture'].value = 0.00015;
bokehPass.uniforms['maxblur'].value = 0.004;
}
}
// Adjust focus with [ ] while in photo mode
if (photoMode) {
const focusStep = 0.5;
if (e.key === '[') {
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
updateFocusDisplay();
} else if (e.key === ']') {
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
updateFocusDisplay();
}
}
});
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
/**
* Main animation loop — called each frame via requestAnimationFrame.
* @returns {void}
*/
function animate() {
// Only start animation after assets are loaded
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Smooth camera transition for overview mode
const targetT = overviewMode ? 1 : 0;
overviewT += (targetT - overviewT) * 0.04;
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 0, 0);
// Slow auto-rotation — suppressed during overview and photo mode
const rotationScale = photoMode ? 0 : (1 - overviewT);
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
// Subtle pulse on constellation opacity
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
// Glass platform — ripple edge glow outward from centre
for (const { mat, distFromCenter } of glassEdgeMaterials) {
const phase = elapsed * 1.1 - distFromCenter * 0.18;
mat.opacity = 0.25 + Math.sin(phase) * 0.22;
}
// Pulse the void light below
voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2;
if (photoMode) {
orbitControls.update();
}
// Animate floating commit banners
const FADE_DUR = 1.5;
commitBanners.forEach(banner => {
const ud = banner.userData;
if (ud.spawnTime === null) {
if (elapsed < ud.startDelay) return;
ud.spawnTime = elapsed;
}
const age = elapsed - ud.spawnTime;
let opacity;
if (age < FADE_DUR) {
opacity = age / FADE_DUR;
} else if (age < ud.lifetime - FADE_DUR) {
opacity = 1;
} else if (age < ud.lifetime) {
opacity = (ud.lifetime - age) / FADE_DUR;
} else {
ud.spawnTime = elapsed + 3;
opacity = 0;
}
banner.material.opacity = opacity * 0.85;
banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4;
});
// Journal wall — gentle edge glow pulse
if (journalWallMesh) {
const em = journalWallMesh.userData.edgeMat;
if (em) em.opacity = 0.35 + Math.sin(elapsed * 0.7) * 0.2;
const pl = journalWallMesh.userData.panelLight;
if (pl) pl.intensity = 0.45 + Math.sin(elapsed * 0.9) * 0.2;
}
composer.render();
}
animate();
// === DEBUG MODE ===
let debugMode = false;
document.getElementById('debug-toggle').addEventListener('click', () => {
debugMode = !debugMode;
document.getElementById('debug-toggle').style.backgroundColor = debugMode
? 'var(--color-text-muted)'
: 'var(--color-secondary)';
console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`);
if (debugMode) {
// Example: Visualize all collision boxes and light sources
// Replace with actual logic when available
document.querySelectorAll('.collision-box').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red');
document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow');
} else {
document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => {
el.style.outline = 'none';
});
}
});
// === WEBSOCKET CLIENT ===
import { wsClient } from './ws-client.js';
wsClient.connect();
window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => {
console.log('Chat message:', event.detail);
if (typeof event.detail?.text === 'string' && event.detail.text.toLowerCase().includes('sovereignty')) {
triggerSovereigntyEasterEgg();
}
});
// === SOVEREIGNTY EASTER EGG ===
const SOVEREIGNTY_WORD = 'sovereignty';
let sovereigntyBuffer = '';
let sovereigntyBufferTimer = /** @type {ReturnType<typeof setTimeout>|null} */ (null);
const sovereigntyMsg = document.getElementById('sovereignty-msg');
/**
* Triggers the sovereignty Easter egg: stars pulse gold, message flashes.
*/
function triggerSovereigntyEasterEgg() {
// Flash constellation lines gold
const originalLineColor = constellationLines.material.color.getHex();
constellationLines.material.color.setHex(0xffd700);
constellationLines.material.opacity = 0.9;
// Stars burst gold
const originalStarColor = starMaterial.color.getHex();
const originalStarOpacity = starMaterial.opacity;
starMaterial.color.setHex(0xffd700);
starMaterial.opacity = 1.0;
// Show overlay message
if (sovereigntyMsg) {
sovereigntyMsg.classList.remove('visible');
// Force reflow so animation restarts
void sovereigntyMsg.offsetWidth;
sovereigntyMsg.classList.add('visible');
}
// Animate gold fade-out over 2.5s
const startTime = performance.now();
const DURATION = 2500;
function fadeBack() {
const t = Math.min((performance.now() - startTime) / DURATION, 1);
const eased = t * t; // ease in: slow start, fast end
// Interpolate star color back
const goldR = 1.0, goldG = 0.843, goldB = 0;
const origColor = new THREE.Color(originalStarColor);
starMaterial.color.setRGB(
goldR + (origColor.r - goldR) * eased,
goldG + (origColor.g - goldG) * eased,
goldB + (origColor.b - goldB) * eased
);
starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased;
// Interpolate line color back
const origLineColor = new THREE.Color(originalLineColor);
constellationLines.material.color.setRGB(
1.0 + (origLineColor.r - 1.0) * eased,
0.843 + (origLineColor.g - 0.843) * eased,
0 + origLineColor.b * eased
);
if (t < 1) {
requestAnimationFrame(fadeBack);
} else {
// Restore originals exactly
starMaterial.color.setHex(originalStarColor);
starMaterial.opacity = originalStarOpacity;
constellationLines.material.color.setHex(originalLineColor);
if (sovereigntyMsg) sovereigntyMsg.classList.remove('visible');
}
}
requestAnimationFrame(fadeBack);
}
// Detect 'sovereignty' typed anywhere on the page (cheat-code style)
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key.length !== 1) {
// Non-printable key resets buffer
sovereigntyBuffer = '';
return;
}
sovereigntyBuffer += e.key.toLowerCase();
// Keep only the last N chars needed
if (sovereigntyBuffer.length > SOVEREIGNTY_WORD.length) {
sovereigntyBuffer = sovereigntyBuffer.slice(-SOVEREIGNTY_WORD.length);
}
if (sovereigntyBuffer === SOVEREIGNTY_WORD) {
sovereigntyBuffer = '';
triggerSovereigntyEasterEgg();
}
// Reset buffer after 3s of inactivity
if (sovereigntyBufferTimer) clearTimeout(sovereigntyBufferTimer);
sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 3000);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});
// === COMMIT BANNERS ===
const commitBanners = [];
/**
* Creates a canvas texture for a commit banner.
* @param {string} hash - Short commit hash
* @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);
}
/**
* Fetches recent commits and spawns floating banner sprites.
*/
async function initCommitBanners() {
let commits;
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
commits = data.map(/** @type {(c: any) => {hash: string, message: string}} */ c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}));
} catch {
commits = [
{ 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' },
];
// Load commit banners after assets are ready
initCommitBanners();
}
const spreadX = [-7, -3.5, 0, 3.5, 7];
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];
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);
sprite.position.set(
spreadX[i % spreadX.length],
spreadY[i % spreadY.length],
spreadZ[i % spreadZ.length]
);
sprite.userData = {
baseY: spreadY[i % spreadY.length],
floatPhase: (i / commits.length) * Math.PI * 2,
floatSpeed: 0.25 + i * 0.07,
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: /** @type {number|null} */ (null),
};
scene.add(sprite);
commitBanners.push(sprite);
});
}
initCommitBanners();
// === JOURNAL WALL ===
/** @type {THREE.Mesh|null} */
let journalWallMesh = null;
/**
* Draws the parchment canvas preview for the journal wall.
* @param {Array<{date: string, text: string}>} entries
* @returns {THREE.CanvasTexture}
*/
function createJournalTexture(entries) {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 768;
const ctx = canvas.getContext('2d');
// Parchment base
ctx.fillStyle = '#c8a55e';
ctx.fillRect(0, 0, 512, 768);
// Aged vignette
const vignette = ctx.createRadialGradient(256, 384, 80, 256, 384, 420);
vignette.addColorStop(0, 'rgba(200, 165, 94, 0)');
vignette.addColorStop(1, 'rgba(80, 45, 5, 0.35)');
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, 512, 768);
// Outer border
ctx.strokeStyle = '#7a5c0e';
ctx.lineWidth = 7;
ctx.strokeRect(10, 10, 492, 748);
ctx.lineWidth = 1.5;
ctx.strokeRect(20, 20, 472, 728);
// Title
ctx.font = 'bold italic 28px Georgia, serif';
ctx.fillStyle = '#3b1c00';
ctx.textAlign = 'center';
ctx.fillText("Alexander's Journal", 256, 62);
// Title underline
ctx.beginPath();
ctx.moveTo(50, 78);
ctx.lineTo(462, 78);
ctx.strokeStyle = '#7a5c0e';
ctx.lineWidth = 1.2;
ctx.stroke();
// Preview entries
let y = 108;
const maxY = 710;
for (let i = 0; i < Math.min(5, entries.length); i++) {
const entry = entries[i];
if (y > maxY) break;
// Date
ctx.font = 'bold 12px Georgia, serif';
ctx.fillStyle = '#5a3010';
ctx.textAlign = 'left';
ctx.fillText(entry.date, 38, y);
y += 22;
// Body text — word-wrapped
ctx.font = 'italic 13px Georgia, serif';
ctx.fillStyle = '#3b1c00';
const words = entry.text.split(' ');
let line = '';
for (const word of words) {
const test = line + word + ' ';
if (ctx.measureText(test).width > 436 && line !== '') {
ctx.fillText(line.trim(), 38, y);
y += 19;
line = word + ' ';
if (y > maxY - 40) break;
} else {
line = test;
}
}
if (line && y <= maxY - 40) {
ctx.fillText(line.trim(), 38, y);
y += 19;
}
y += 14; // entry gap
}
// Click hint at bottom
ctx.font = '11px Georgia, serif';
ctx.fillStyle = '#7a5c0e';
ctx.textAlign = 'center';
ctx.fillText('[ click to read ]', 256, 742);
return new THREE.CanvasTexture(canvas);
}
/**
* Initialises the journal wall — loads entries, builds 3D mesh, wires up overlay.
*/
async function initJournalWall() {
let entries = [];
try {
const res = await fetch('./journal.json');
if (res.ok) entries = await res.json();
} catch { /* fall through to defaults */ }
if (!Array.isArray(entries) || entries.length === 0) {
entries = [
{ date: '2024-03-24', text: 'The Nexus takes shape. Whatever comes next, this place is real.' },
];
}
// Build 3D parchment panel
const texture = createJournalTexture(entries);
const geo = new THREE.PlaneGeometry(3.6, 5.4);
const mat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide });
journalWallMesh = new THREE.Mesh(geo, mat);
journalWallMesh.name = 'journalWall';
journalWallMesh.position.set(-9, 3.5, -2.5);
journalWallMesh.rotation.y = Math.PI / 3;
scene.add(journalWallMesh);
// Gold edge glow frame
const edgeGeo = new THREE.EdgesGeometry(geo);
const edgeMat = new THREE.LineBasicMaterial({ color: 0xd4a030, transparent: true, opacity: 0.55 });
const edgeFrame = new THREE.LineSegments(edgeGeo, edgeMat);
journalWallMesh.add(edgeFrame);
journalWallMesh.userData.edgeMat = edgeMat;
// Warm accent light casting onto the panel
const panelLight = new THREE.PointLight(0xd4903a, 0.6, 12);
panelLight.position.set(-7.5, 4.5, 0.5);
scene.add(panelLight);
journalWallMesh.userData.panelLight = panelLight;
// === OVERLAY INTERACTION ===
const overlay = document.getElementById('journal-overlay');
const entriesContainer = document.getElementById('journal-entries');
const closeBtn = document.getElementById('journal-close');
// Populate overlay with entries
if (entriesContainer) {
for (const entry of entries) {
const div = document.createElement('div');
div.className = 'journal-entry';
const dateEl = document.createElement('div');
dateEl.className = 'journal-entry-date';
dateEl.textContent = entry.date;
const textEl = document.createElement('div');
textEl.className = 'journal-entry-text';
textEl.textContent = entry.text;
div.appendChild(dateEl);
div.appendChild(textEl);
entriesContainer.appendChild(div);
}
}
function openJournal() {
if (overlay) {
overlay.setAttribute('aria-hidden', 'false');
overlay.classList.add('visible');
}
}
function closeJournal() {
if (overlay) {
overlay.setAttribute('aria-hidden', 'true');
overlay.classList.remove('visible');
}
}
if (closeBtn) closeBtn.addEventListener('click', closeJournal);
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeJournal();
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay && overlay.classList.contains('visible')) {
closeJournal();
}
});
// Raycaster — click the 3D panel to open overlay
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
renderer.domElement.addEventListener('click', (e) => {
if (photoMode) return;
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(journalWallMesh);
if (hits.length > 0) openJournal();
});
}
initJournalWall();