feat(playground): chat speech bubbles + dedupe + self bubble

- Speech bubble appears over the local player's head when they send a chat (fades after 5.5s).
- Speech bubble appears over remote players' heads when their chat arrives via HTTP polling (lastChat / lastChatAt now updated on chat fan-out).
- addChatMessage dedupes by (authorId, body, ts within 2s) so the same message can't appear twice in the chat panel even if multiple transports deliver it.
- handleIncomingChat rejects messages whose name matches our display name (defense-in-depth against echo from server chat ring entries from older selfIds).
This commit is contained in:
Aurora release bot
2026-05-03 22:58:02 -04:00
parent 48536718e5
commit 686d5d1887
3 changed files with 61 additions and 4 deletions

View File

@@ -1765,10 +1765,39 @@ function PlayerAndCamera({
<span>{displayName}</span>
</div>
</Html>
{/* Self chat speech bubble — fades after 5.5s */}
<SelfChatBubble />
</group>
)
}
/** Listens for the local player's chat sends and pops a speech bubble over their head. */
function SelfChatBubble() {
const [bubble, setBubble] = useState<{ text: string; ts: number } | null>(null)
useEffect(() => {
const onChat = (ev: Event) => {
const detail = (ev as CustomEvent).detail as string | undefined
if (!detail) return
setBubble({ text: detail, ts: Date.now() })
}
window.addEventListener('hermes-playground-self-chat-bubble', onChat)
return () => window.removeEventListener('hermes-playground-self-chat-bubble', onChat)
}, [])
useEffect(() => {
if (!bubble) return
const id = window.setTimeout(() => setBubble(null), 5500)
return () => window.clearTimeout(id)
}, [bubble])
if (!bubble) return null
return (
<Html position={[0, 2.85, 0]} center distanceFactor={8}>
<div style={{padding:'4px 10px',background:'rgba(0,0,0,0.85)',color:'white',borderRadius:8,fontSize:12,maxWidth:200,textAlign:'center',border:'1px solid #34d399',boxShadow:'0 0 10px #34d39966'}}>
{bubble.text}
</div>
</Html>
)
}
/* NPC color palette per persona */
const NPC_COLORS: Record<string, string> = {
athena: '#a78bfa', // purple, Sage

View File

@@ -407,10 +407,18 @@ export function usePlaygroundMultiplayer({
for (const p of data.presences || []) {
mergePresence(p as RemotePlayer)
}
// Replay any chat messages we haven't seen
// Replay any chat messages we haven't seen + attach them to the
// matching remote player so a speech bubble appears over their head.
for (const c of data.chats || []) {
if (typeof c.ts === 'number' && c.ts > lastChatTs) lastChatTs = c.ts
onChatRef.current?.(c as ChatWire)
if (c.id && typeof c.text === 'string') {
setRemotePlayers((prev) => {
const cur = prev[c.id]
if (!cur) return prev
return { ...prev, [c.id]: { ...cur, lastChat: c.text, lastChatAt: c.ts || Date.now() } }
})
}
}
// Push count update
setServerCount({ online: data.online, byWorld: data.byWorld, peakToday: data.peakToday })

View File

@@ -286,23 +286,43 @@ export function PlaygroundScreen() {
}, [rpg.state.playerProfile])
function addChatMessage(message: ChatMessage) {
setMessages((prev) => [...prev, message].slice(-40))
setMessages((prev) => {
// Dedupe: if we already have this (author + body + ts within 2s), skip.
const dupe = prev.some(
(m) =>
m.authorId === message.authorId &&
m.body === message.body &&
Math.abs(m.ts - message.ts) < 2000,
)
if (dupe) return prev
return [...prev, message].slice(-40)
})
}
function sendChat(body: string) {
const ts = Date.now()
addChatMessage({
id: `${Date.now()}-${Math.random()}`,
id: `${ts}-${Math.random()}`,
authorId: 'self',
authorName: rpg.state.playerProfile.displayName || 'You',
body,
ts: Date.now(),
ts,
color: '#a7f3d0',
})
rpg.markObjective('training-q3', 'send-local-chat')
// Speech bubble over our own head too, so we see what we said in-world.
try {
window.dispatchEvent(
new CustomEvent('hermes-playground-self-chat-bubble', { detail: body }),
)
} catch {}
try { (window as any).__hermesPlaygroundSendChat?.(body) } catch {}
}
function handleIncomingChat(msg: { id: string; name: string; color: string; text: string; ts: number }) {
// Defensive: never accept a chat that we sent ourselves — the server tries
// to filter, but old chat ring entries from previous selfIds can leak.
if (msg.name === (rpg.state.playerProfile.displayName || 'You')) return
addChatMessage({
id: `${msg.ts}-${msg.id}`,
authorId: msg.id,