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>
859 lines
27 KiB
JavaScript
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();
|