diff --git a/app.js b/app.js index 3f9b2cd..3c5494f 100644 --- a/app.js +++ b/app.js @@ -509,6 +509,30 @@ function animate() { sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; } + // Animate Timmy speech bubble — fade in, hold, fade out + if (timmySpeechState) { + const age = elapsed - timmySpeechState.startTime; + let opacity; + if (age < SPEECH_FADE_IN) { + opacity = age / SPEECH_FADE_IN; + } else if (age < SPEECH_DURATION - SPEECH_FADE_OUT) { + opacity = 1.0; + } else if (age < SPEECH_DURATION) { + opacity = (SPEECH_DURATION - age) / SPEECH_FADE_OUT; + } else { + scene.remove(timmySpeechState.sprite); + if (timmySpeechState.sprite.material.map) timmySpeechState.sprite.material.map.dispose(); + timmySpeechState.sprite.material.dispose(); + timmySpeechSprite = null; + timmySpeechState = null; + opacity = 0; + } + if (timmySpeechState) { + timmySpeechState.sprite.material.opacity = opacity; + timmySpeechState.sprite.position.y = TIMMY_SPEECH_POS.y + Math.sin(elapsed * 1.1) * 0.1; + } + } + composer.render(); } @@ -551,8 +575,11 @@ window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => { 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(); + if (typeof event.detail?.text === 'string') { + showTimmySpeech(event.detail.text); + if (event.detail.text.toLowerCase().includes('sovereignty')) { + triggerSovereigntyEasterEgg(); + } } }); @@ -922,3 +949,99 @@ async function refreshAgentBoard() { // Initial render, then poll every 30 s refreshAgentBoard(); setInterval(refreshAgentBoard, 30000); + +// === TIMMY SPEECH BUBBLE === +// When Timmy sends a chat message, a glowing floating text sprite appears near +// his avatar position above the platform. Fades in quickly, holds for 5 s total, +// then fades out. Only the most recent message is shown. + +const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5); +const SPEECH_DURATION = 5.0; // total seconds visible (including fades) +const SPEECH_FADE_IN = 0.35; +const SPEECH_FADE_OUT = 0.7; + +/** @type {THREE.Sprite|null} */ +let timmySpeechSprite = null; + +/** @type {{ startTime: number, sprite: THREE.Sprite }|null} */ +let timmySpeechState = null; + +/** + * Builds a canvas texture for a Timmy speech bubble. + * @param {string} text + * @returns {THREE.CanvasTexture} + */ +function createSpeechBubbleTexture(text) { + const W = 512, H = 100; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + // Semi-transparent dark background + ctx.fillStyle = 'rgba(0, 6, 20, 0.85)'; + ctx.fillRect(0, 0, W, H); + + // Neon blue glow border + ctx.strokeStyle = '#66aaff'; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + + // Inner subtle border + ctx.strokeStyle = '#2244aa'; + ctx.lineWidth = 1; + ctx.strokeRect(4, 4, W - 8, H - 8); + + // "TIMMY:" label + ctx.font = 'bold 12px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.fillText('TIMMY:', 12, 22); + + // Message text — truncate to two lines if needed + const LINE1_MAX = 42; + const LINE2_MAX = 48; + ctx.font = '15px "Courier New", monospace'; + ctx.fillStyle = '#ddeeff'; + + if (text.length <= LINE1_MAX) { + ctx.fillText(text, 12, 58); + } else { + ctx.fillText(text.slice(0, LINE1_MAX), 12, 46); + const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX); + ctx.font = '13px "Courier New", monospace'; + ctx.fillStyle = '#aabbcc'; + ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76); + } + + return new THREE.CanvasTexture(canvas); +} + +/** + * Shows a floating speech bubble near Timmy's avatar. + * Immediately replaces any existing bubble. + * @param {string} text + */ +function showTimmySpeech(text) { + if (timmySpeechSprite) { + scene.remove(timmySpeechSprite); + if (timmySpeechSprite.material.map) timmySpeechSprite.material.map.dispose(); + timmySpeechSprite.material.dispose(); + timmySpeechSprite = null; + timmySpeechState = null; + } + + const texture = createSpeechBubbleTexture(text); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(8.5, 1.65, 1); + sprite.position.copy(TIMMY_SPEECH_POS); + scene.add(sprite); + + timmySpeechSprite = sprite; + timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; +}