[claude] Timmy speech bubble — floating 3D chat text (#205) #226
127
app.js
127
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user