From fedf78db13800ea4d947fd8b415e833857c2c675 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:35:16 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Timmy=20mood=20lighting=20=E2=80=94=20a?= =?UTF-8?q?tmosphere=20shifts=20with=20his=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.js | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 2 + style.css | 13 ++++ 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 485320f..1250b5c 100644 --- a/app.js +++ b/app.js @@ -17,6 +17,63 @@ const NEXUS = { } }; +// === 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(); @@ -58,6 +115,86 @@ 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; @@ -352,6 +489,38 @@ function animate() { 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; @@ -363,22 +532,22 @@ function animate() { 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; + 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 - constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // 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 - voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2; + // 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(); @@ -450,6 +619,7 @@ window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => { 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(); } diff --git a/index.html b/index.html index 795f24e..7d003f5 100644 --- a/index.html +++ b/index.html @@ -48,6 +48,8 @@
⚡ SOVEREIGNTY ⚡
+
+
diff --git a/style.css b/style.css index 7dae8ca..7b37c28 100644 --- a/style.css +++ b/style.css @@ -183,3 +183,16 @@ body.photo-mode #overview-indicator { 40% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1); } } + +#mood-indicator { + position: fixed; + bottom: 12px; + right: 14px; + font-family: var(--font-body); + font-size: 10px; + letter-spacing: 0.15em; + color: var(--color-text-muted); + opacity: 0.5; + pointer-events: none; + z-index: 20; +} -- 2.43.0