From fe1ca27bc201787e60f967c4f26de89af21c4664 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:33:30 -0400 Subject: [PATCH] feat: add Timmy's animated crystal avatar (#204) Adds a floating translucent teal dodecahedron as Timmy's physical form in the Nexus. The avatar pulses gently while idle and brightens/spins faster when chat is active (triggered by incoming chat-message events or typing in input/textarea elements). - DodecahedronGeometry with MeshPhysicalMaterial (teal crystal, transmission) - EdgesGeometry wireframe overlay for crystal facet lines - Inner PointLight that pulses in sync with emissive intensity - Smooth blend between idle and chat-active animation states - Hovers near the future Batcave terminal position (-2, 3.5, -4) Fixes #204 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/app.js b/app.js index 485320f..28cd35e 100644 --- a/app.js +++ b/app.js @@ -14,6 +14,7 @@ const NEXUS = { constellationLine: 0x334488, constellationFade: 0x112244, accent: 0x4488ff, + timmyCrystal: 0x00e5cc, } }; @@ -340,8 +341,68 @@ window.addEventListener('resize', () => { composer.setSize(window.innerWidth, window.innerHeight); }); +// === TIMMY'S AVATAR === +// Floating translucent teal crystal dodecahedron — Timmy's physical presence in the Nexus. +// Positioned near where the Batcave terminal will be (negative-Z, slightly left of centre). + +const TIMMY_BASE_POS = new THREE.Vector3(-2, 3.5, -4); + +const timmyGeo = new THREE.DodecahedronGeometry(0.85, 0); + +const timmyMat = new THREE.MeshPhysicalMaterial({ + color: new THREE.Color(NEXUS.colors.timmyCrystal), + emissive: new THREE.Color(NEXUS.colors.timmyCrystal), + emissiveIntensity: 0.35, + transparent: true, + opacity: 0.55, + roughness: 0.0, + metalness: 0.1, + transmission: 0.4, + thickness: 0.3, + side: THREE.DoubleSide, + depthWrite: false, +}); +const timmyMesh = new THREE.Mesh(timmyGeo, timmyMat); + +// Crystal facet edge lines +const timmyEdgeGeo = new THREE.EdgesGeometry(timmyGeo); +const timmyEdgeMat = new THREE.LineBasicMaterial({ + color: 0x80fff5, + transparent: true, + opacity: 0.65, +}); +const timmyEdges = new THREE.LineSegments(timmyEdgeGeo, timmyEdgeMat); + +// Inner glow point light +const timmyLight = new THREE.PointLight(NEXUS.colors.timmyCrystal, 0.7, 8); + +const timmyGroup = new THREE.Group(); +timmyGroup.add(timmyMesh); +timmyGroup.add(timmyEdges); +timmyGroup.add(timmyLight); +timmyGroup.position.copy(TIMMY_BASE_POS); +scene.add(timmyGroup); + +// Chat activity state — brightens + spins faster when conversation is happening +let chatActive = false; +let chatActiveTimer = /** @type {ReturnType|null} */ (null); +let timmyChatBlend = 0; // 0 = idle, 1 = fully active (smoothly interpolated) +let timmyRotY = 0; // accumulated Y rotation + +const CHAT_ACTIVE_DURATION = 4000; + +/** + * Activates heightened avatar state for a short duration. + */ +function activateTimmyChatMode() { + chatActive = true; + if (chatActiveTimer) clearTimeout(chatActiveTimer); + chatActiveTimer = setTimeout(() => { chatActive = false; }, CHAT_ACTIVE_DURATION); +} + // === ANIMATION LOOP === const clock = new THREE.Clock(); +let timmyLastTime = 0; /** * Main animation loop — called each frame via requestAnimationFrame. @@ -384,6 +445,35 @@ function animate() { orbitControls.update(); } + // Timmy's avatar animation + const delta = elapsed - timmyLastTime; + timmyLastTime = elapsed; + + // Smooth blend toward target state + timmyChatBlend += ((chatActive ? 1 : 0) - timmyChatBlend) * 0.04; + + // Rotation: gentle idle spin → fast chat spin + const rotSpeed = 0.3 + timmyChatBlend * 1.6; + timmyRotY += rotSpeed * delta; + timmyGroup.rotation.y = timmyRotY; + timmyGroup.rotation.x = Math.sin(elapsed * 0.7) * 0.15; + + // Hover bob + timmyGroup.position.y = TIMMY_BASE_POS.y + Math.sin(elapsed * 0.9) * 0.22; + + // Emissive pulse: calm throb → bright flicker + const idleEmissive = 0.28 + Math.sin(elapsed * 1.3) * 0.12; + const chatEmissive = 1.1 + Math.sin(elapsed * 4.0) * 0.35; + timmyMat.emissiveIntensity = idleEmissive + (chatEmissive - idleEmissive) * timmyChatBlend; + + // Inner light pulse + const idleLight = 0.5 + Math.sin(elapsed * 1.3) * 0.25; + const chatLight = 2.2 + Math.sin(elapsed * 4.0) * 0.7; + timmyLight.intensity = idleLight + (chatLight - idleLight) * timmyChatBlend; + + // Edge glow opacity + timmyEdgeMat.opacity = 0.45 + Math.sin(elapsed * 1.3) * 0.2 + timmyChatBlend * 0.45; + // Animate floating commit banners const FADE_DUR = 1.5; commitBanners.forEach(banner => { @@ -450,11 +540,20 @@ window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => { window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => { console.log('Chat message:', event.detail); + activateTimmyChatMode(); if (typeof event.detail?.text === 'string' && event.detail.text.toLowerCase().includes('sovereignty')) { triggerSovereigntyEasterEgg(); } }); +// Avatar reacts to chat typing as well +document.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => { + const target = /** @type {HTMLElement} */ (e.target); + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { + activateTimmyChatMode(); + } +}); + // === SOVEREIGNTY EASTER EGG === const SOVEREIGNTY_WORD = 'sovereignty'; let sovereigntyBuffer = ''; -- 2.43.0