[claude] Timmy speech bubble — floating 3D chat text (#205) #226

Merged
claude merged 1 commits from claude/issue-205 into main 2026-03-24 04:37:48 +00:00

127
app.js
View File

@@ -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 };
}