Files
the-nexus/app.js
Alexander Whitestone fedf78db13
Some checks failed
CI / validate (pull_request) Failing after 16s
CI / auto-merge (pull_request) Has been skipped
feat: Timmy mood lighting — atmosphere shifts with his state
Derives current mood from live signals (chat activity, PR merge rate,
JS errors) and smoothly lerps all scene lighting toward mood targets:

- content: warm teal ambient, balanced lighting (default)
- busy: brighter scene, faster star rotation, vivid void light
- contemplative: dim and still, slow drifting stars
- error: red tinge across ambient, overhead and void lights

All transitions are smooth (lerp factor 0.008/frame ≈ ~4s cross-fade).
Star rotation speed scales with moodCurrent.starSpeed via a separate
moodElapsed accumulator so contemplative feels genuinely slower.

Gitea PR merge polling runs on load + every 5 min. Error signal clears
after 5 min. Subtle mood label added to bottom-right HUD.

Fixes #207

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

824 lines
26 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,
}
};
// === MOOD TARGETS ===
// Each mood defines target lighting values; the scene lerps toward these each frame.
const MOOD_TARGETS = {
content: {
// Warm teal — Timmy's default, relaxed presence
ambientColor: new THREE.Color(0x0a2028),
ambientIntensity: 1.4,
overheadColor: new THREE.Color(0x8899bb),
overheadIntensity: 0.6,
voidColor: new THREE.Color(0x4488ff),
voidIntensity: 0.5,
starOpacity: 0.9,
starSpeed: 1.0,
constellationBase: 0.12,
bgColor: new THREE.Color(0x000008),
},
busy: {
// Brighter, energised — lots of activity
ambientColor: new THREE.Color(0x102830),
ambientIntensity: 2.2,
overheadColor: new THREE.Color(0xaaccee),
overheadIntensity: 1.1,
voidColor: new THREE.Color(0x2266ff),
voidIntensity: 0.9,
starOpacity: 1.0,
starSpeed: 2.0,
constellationBase: 0.24,
bgColor: new THREE.Color(0x000012),
},
contemplative: {
// Dim and still — low activity, quiet reflection
ambientColor: new THREE.Color(0x050a14),
ambientIntensity: 0.7,
overheadColor: new THREE.Color(0x445566),
overheadIntensity: 0.25,
voidColor: new THREE.Color(0x223355),
voidIntensity: 0.18,
starOpacity: 0.55,
starSpeed: 0.35,
constellationBase: 0.05,
bgColor: new THREE.Color(0x000005),
},
error: {
// Red tinge — something went wrong
ambientColor: new THREE.Color(0x280a0a),
ambientIntensity: 1.7,
overheadColor: new THREE.Color(0xbb4444),
overheadIntensity: 0.9,
voidColor: new THREE.Color(0xff2222),
voidIntensity: 0.75,
starOpacity: 0.85,
starSpeed: 1.3,
constellationBase: 0.2,
bgColor: new THREE.Color(0x080003),
},
};
// === 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);
// === MOOD STATE ===
// Interpolated current mood values — lerped toward MOOD_TARGETS each frame.
const moodCurrent = {
ambientColor: new THREE.Color(0x0a2028),
ambientIntensity: 1.4,
overheadColor: new THREE.Color(0x8899bb),
overheadIntensity: 0.6,
voidColor: new THREE.Color(0x4488ff),
voidIntensity: 0.5,
starOpacity: 0.9,
starSpeed: 1.0,
constellationBase: 0.12,
bgColor: new THREE.Color(0x000008),
};
// Signals that drive mood derivation
const moodSignals = {
/** @type {number[]} */
chatTimestamps: [],
errorTimestamp: 0,
errorActive: false,
recentMerges: 0,
};
// Accumulated time scaled by mood speed (for star rotation)
let moodElapsed = 0;
let prevElapsed = 0;
let lastMoodName = 'content';
/**
* Derives current mood name from live signals.
* @returns {'content'|'busy'|'contemplative'|'error'}
*/
function deriveMood() {
const now = Date.now();
// Prune old chat timestamps (>5 min)
moodSignals.chatTimestamps = moodSignals.chatTimestamps.filter(t => now - t < 300000);
if (moodSignals.errorActive && now - moodSignals.errorTimestamp < 300000) return 'error';
const chatLast2min = moodSignals.chatTimestamps.filter(t => now - t < 120000).length;
if (chatLast2min > 3 || moodSignals.recentMerges >= 2) return 'busy';
if (moodSignals.chatTimestamps.length === 0 && moodSignals.recentMerges === 0) return 'contemplative';
return 'content';
}
/**
* Polls Gitea for merged PRs in the last hour and updates recentMerges signal.
*/
async function pollMerges() {
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=10',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) return;
const pulls = await res.json();
const hourAgo = Date.now() - 3600000;
moodSignals.recentMerges = pulls.filter(
/** @type {(p: any) => boolean} */ p => p.merged && new Date(p.merged_at).getTime() > hourAgo
).length;
} catch {
// Network failure — keep previous value
}
}
// Poll once on load, then every 5 minutes
pollMerges();
setInterval(pollMerges, 300000);
// Track JS errors for error-mood signal
window.addEventListener('error', () => {
moodSignals.errorActive = true;
moodSignals.errorTimestamp = Date.now();
// Auto-clear after 5 minutes
setTimeout(() => { moodSignals.errorActive = false; }, 300000);
});
// === 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();
// === MOOD LIGHTING ===
const delta = elapsed - prevElapsed;
prevElapsed = elapsed;
moodElapsed += delta * moodCurrent.starSpeed;
const currentMoodName = deriveMood();
if (currentMoodName !== lastMoodName) {
lastMoodName = currentMoodName;
const moodLabelEl = document.getElementById('mood-indicator');
if (moodLabelEl) moodLabelEl.textContent = currentMoodName.toUpperCase();
}
const moodTarget = MOOD_TARGETS[currentMoodName];
const LERP = 0.008;
moodCurrent.ambientColor.lerp(moodTarget.ambientColor, LERP);
moodCurrent.ambientIntensity += (moodTarget.ambientIntensity - moodCurrent.ambientIntensity) * LERP;
moodCurrent.overheadColor.lerp(moodTarget.overheadColor, LERP);
moodCurrent.overheadIntensity += (moodTarget.overheadIntensity - moodCurrent.overheadIntensity) * LERP;
moodCurrent.voidColor.lerp(moodTarget.voidColor, LERP);
moodCurrent.voidIntensity += (moodTarget.voidIntensity - moodCurrent.voidIntensity) * LERP;
moodCurrent.starOpacity += (moodTarget.starOpacity - moodCurrent.starOpacity) * LERP;
moodCurrent.constellationBase += (moodTarget.constellationBase - moodCurrent.constellationBase) * LERP;
moodCurrent.bgColor.lerp(moodTarget.bgColor, LERP);
ambientLight.color.copy(moodCurrent.ambientColor);
ambientLight.intensity = moodCurrent.ambientIntensity;
overheadLight.color.copy(moodCurrent.overheadColor);
overheadLight.intensity = moodCurrent.overheadIntensity;
voidLight.color.copy(moodCurrent.voidColor);
starMaterial.opacity = moodCurrent.starOpacity;
scene.background.copy(moodCurrent.bgColor);
// 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 + moodElapsed * 0.01) * rotationScale;
stars.rotation.y = (targetRotY + moodElapsed * 0.015) * rotationScale;
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
// Subtle pulse on constellation opacity, base driven by mood
constellationLines.material.opacity = moodCurrent.constellationBase + 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, base intensity driven by mood
voidLight.intensity = moodCurrent.voidIntensity + Math.sin(elapsed * 1.4) * 0.15;
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;
});
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);
moodSignals.chatTimestamps.push(Date.now());
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();