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:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user