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