fix(playground): MP keepalive on visibility change + UI nudges

Multiplayer:
- KEEPALIVE_MS 1500 -> 1000 so we send at least once per second.
- STALE_AFTER_MS 5000 -> 6500 locally (server is still 5000) so we hold remotes a bit longer than the server prunes.
- Send presence packet immediately on document.visibilitychange + window.focus so backgrounded tabs don't stay pruned for the few seconds after refocusing. This is the main reason 'one of my characters disappeared' happens — Chromium throttles bg setInterval and the server prunes us after 5s.

UI:
- Player card + chat moved a bit further LEFT (left: min(120px, 9vw)) so they sit under the small left rail not on top of it.
- Focus eyeball moved down 20px so it doesn't crowd the minimap.
- Minimap 'M for full' and quest tracker 'J for journal' both replaced with compact [M] / [J] keycap chips.
This commit is contained in:
Aurora release bot
2026-05-03 22:09:55 -04:00
parent 788925fbce
commit 9f332a9641
6 changed files with 49 additions and 7 deletions

View File

@@ -60,7 +60,7 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
return (
<div
className="pointer-events-auto fixed bottom-3 z-[60] flex max-w-[92vw] flex-col rounded-2xl border border-white/10 bg-black/65 text-white shadow-2xl backdrop-blur-xl"
style={{ width: 360, height: collapsed ? 42 : 240, maxWidth: 'calc(100vw - 320px)', left: 'min(180px, 14vw)' }}
style={{ width: 360, height: collapsed ? 42 : 240, maxWidth: 'calc(100vw - 320px)', left: 'min(120px, 9vw)' }}
>
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-white/65">

View File

@@ -84,7 +84,7 @@ export function PlaygroundHud({
<>
{/* Combined player card: avatar portrait + name + level + title + HP/MP/SP/XP */}
{/* Sits to the right of the side rail (left:140 instead of left:3) so it doesn't crowd the chat. */}
<div className="pointer-events-auto fixed top-3 z-[70] flex max-w-[360px] flex-col items-start gap-2" style={{ left: 'min(180px, 14vw)' }}>
<div className="pointer-events-auto fixed top-3 z-[70] flex max-w-[360px] flex-col items-start gap-2" style={{ left: 'min(120px, 9vw)' }}>
<div
className="rounded-2xl border-2 border-white/15 bg-gradient-to-b from-[#0b1320]/92 to-black/86 px-3 py-2.5 text-white shadow-2xl backdrop-blur-xl"
style={{ boxShadow: `0 0 18px ${worldAccent}33, 0 12px 36px rgba(0,0,0,.55)` }}

View File

@@ -61,7 +61,7 @@ export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-[10px] font-bold uppercase tracking-[0.16em]" style={{ color: worldAccent }}>{worldName}</span>
<span className="text-[9px] uppercase tracking-[0.12em] text-white/45">M for full</span>
<span className="rounded border border-white/20 bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] text-white/65">M</span>
</div>
<div
className="relative h-[150px] w-[150px] overflow-hidden rounded-lg border border-white/15"

View File

@@ -84,7 +84,7 @@ export function PlaygroundSidePanel({
>
<div className="mb-1 flex items-center justify-between">
<span className="text-[9px] font-bold uppercase tracking-[0.18em] text-white/55">Quest Tracker</span>
<span className="text-[9px] uppercase tracking-[0.12em] text-white/40">J for journal</span>
<span className="rounded border border-white/20 bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] text-white/55">J</span>
</div>
<div className="flex items-start gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-lg">

View File

@@ -43,8 +43,8 @@ type Wire = PresenceWire | ChatWire | LeaveWire | CountWire
const CHANNEL_NAME = 'hermes.playground.v0'
const PRESENCE_INTERVAL_MS = 200 // 5 Hz, was 100
const KEEPALIVE_MS = 1500 // force a packet at least this often even if static
const STALE_AFTER_MS = 5000 // matches server prune
const KEEPALIVE_MS = 1000 // force a packet at least this often even if static
const STALE_AFTER_MS = 6500 // matches server prune (server is 5000ms; we're slightly more lenient locally)
const POS_EPSILON = 0.04 // skip-send if both deltas under this
const YAW_EPSILON = 0.025 // ~1.4°
const RENDER_POS_EPSILON = 0.03 // suppress re-render for ultra-small jitters
@@ -332,6 +332,48 @@ export function usePlaygroundMultiplayer({
return () => window.clearInterval(tick)
}, [selfId, myName, myColor, world, interior, positionRef, yawRef])
// Immediately re-send presence when the tab becomes visible (after being
// backgrounded). Background tabs are throttled by the browser and can stop
// ticking long enough for the server to prune them — this prevents the
// "player disappears for a moment after switching tabs" flicker.
useEffect(() => {
if (typeof document === 'undefined') return
const onVisible = () => {
if (document.hidden) return
const pos = positionRef.current
if (!pos) return
// Force a fresh presence on next tick by clearing the dedupe baseline.
lastSentRef.current = { x: NaN, y: NaN, z: NaN, yaw: NaN, ts: 0, world: null }
// Also send immediately if WS open.
if (wsOpenRef.current && wsRef.current) {
try {
const wire: PresenceWire = {
kind: 'presence',
id: selfId,
name: myName,
color: myColor,
world,
interior,
x: pos.x,
y: pos.y,
z: pos.z,
yaw: yawRef.current ?? 0,
ts: Date.now(),
avatar: avatarRef.current || undefined,
}
wsRef.current.send(JSON.stringify(wire))
lastSentRef.current = { x: pos.x, y: pos.y, z: pos.z, yaw: yawRef.current ?? 0, ts: Date.now(), world }
} catch {}
}
}
document.addEventListener('visibilitychange', onVisible)
window.addEventListener('focus', onVisible)
return () => {
document.removeEventListener('visibilitychange', onVisible)
window.removeEventListener('focus', onVisible)
}
}, [selfId, myName, myColor, world, interior, positionRef, yawRef])
const sendChat = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed) return

View File

@@ -573,7 +573,7 @@ export function PlaygroundScreen() {
onClick={() => setFocusMode((v) => !v)}
aria-label={focusMode ? 'Exit focus mode (F or Esc)' : 'Focus mode — hide side rail (F)'}
title={focusMode ? 'Exit focus mode (F or Esc)' : 'Focus mode — hide side rail (F)'}
className="pointer-events-auto fixed right-3 top-[210px] z-[71] hidden h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/70 text-[16px] text-white shadow-xl backdrop-blur-xl md:flex"
className="pointer-events-auto fixed right-3 top-[230px] z-[71] hidden h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/70 text-[16px] text-white shadow-xl backdrop-blur-xl md:flex"
style={{
boxShadow: focusMode ? `0 0 14px ${WORLD_META[world].accent}88` : '0 8px 22px rgba(0,0,0,.55)',
borderColor: focusMode ? WORLD_META[world].accent : 'rgba(255,255,255,0.15)',