perf: optimize playground engine responsiveness (#372)

Merging the playground performance pass after rebasing it onto current main and re-running a fresh local production build. The branch stays scoped to HermesWorld performance and asset-weight reductions.
This commit is contained in:
Eric
2026-05-14 13:38:23 -04:00
committed by GitHub
parent d528c495f6
commit e1470084d2
28 changed files with 599 additions and 114 deletions

View File

@@ -0,0 +1,78 @@
{
"total_js_bytes": 14003238,
"total_js_gzip": 2831118,
"largest": [
{
"file": "main-B1Sjhf2W.js",
"bytes": 2525142,
"gzip": 647062
},
{
"file": "emacs-lisp-C9XAeP06.js",
"bytes": 779854,
"gzip": 196414
},
{
"file": "cpp-CofmeUqb.js",
"bytes": 626081,
"gzip": 43704
},
{
"file": "wasm-CG6Dc4jp.js",
"bytes": 622336,
"gzip": 230448
},
{
"file": "dashboard-BgJlX3vG.js",
"bytes": 538826,
"gzip": 135066
},
{
"file": "xterm-B8I6Yj_r.js",
"bytes": 282970,
"gzip": 69550
},
{
"file": "swarm2-screen-DR_6qB2V.js",
"bytes": 272606,
"gzip": 49629
},
{
"file": "wolfram-lXgVvXCa.js",
"bytes": 262391,
"gzip": 77016
},
{
"file": "vue-vine-CQOfvN7w.js",
"bytes": 190051,
"gzip": 17573
},
{
"file": "angular-ts-BwZT4LLn.js",
"bytes": 183820,
"gzip": 16241
},
{
"file": "typescript-BPQ3VLAy.js",
"bytes": 181080,
"gzip": 15662
},
{
"file": "jsx-g9-lgVsj.js",
"bytes": 177792,
"gzip": 16195
}
],
"playground_related": [
{
"file": "main-B1Sjhf2W.js",
"bytes": 2525142,
"gzip": 647062
},
{
"file": "playground-BPidndjb.js",
"bytes": 37669,
"gzip": 7136
}
]
}

View File

@@ -0,0 +1,78 @@
{
"total_js_bytes": 14003142,
"total_js_gzip": 2831059,
"largest": [
{
"file": "main-DHShlhpC.js",
"bytes": 2525142,
"gzip": 647051
},
{
"file": "emacs-lisp-C9XAeP06.js",
"bytes": 779854,
"gzip": 196414
},
{
"file": "cpp-CofmeUqb.js",
"bytes": 626081,
"gzip": 43704
},
{
"file": "wasm-CG6Dc4jp.js",
"bytes": 622336,
"gzip": 230448
},
{
"file": "dashboard-BuJPrYqy.js",
"bytes": 538826,
"gzip": 135066
},
{
"file": "xterm-C6W2vAtw.js",
"bytes": 282970,
"gzip": 69549
},
{
"file": "swarm2-screen-Bnuaujuc.js",
"bytes": 272606,
"gzip": 49628
},
{
"file": "wolfram-lXgVvXCa.js",
"bytes": 262391,
"gzip": 77016
},
{
"file": "vue-vine-CQOfvN7w.js",
"bytes": 190051,
"gzip": 17573
},
{
"file": "angular-ts-BwZT4LLn.js",
"bytes": 183820,
"gzip": 16241
},
{
"file": "typescript-BPQ3VLAy.js",
"bytes": 181080,
"gzip": 15662
},
{
"file": "jsx-g9-lgVsj.js",
"bytes": 177792,
"gzip": 16195
}
],
"playground_related": [
{
"file": "main-DHShlhpC.js",
"bytes": 2525142,
"gzip": 647051
},
{
"file": "playground-DOVh9SKy.js",
"bytes": 37637,
"gzip": 7121
}
]
}

View File

@@ -0,0 +1,78 @@
# HermesWorld mobile performance baseline
Branch: `perf/mobile-bundle-split`
Base: `origin/perf/playground-engine-pass-1`
Viewport/FPS audit: 390x844 mobile emulation, 4x CPU throttle, throttled 4G network profile, `/play/?debug=perf`.
## Static standalone bundle
| Metric | Baseline | After | Delta |
| --- | ---: | ---: | ---: |
| Initial `assets/play-standalone.js` raw | 4,173,581 B | 3,963,737 B | -209,844 B |
| Initial `assets/play-standalone.js` gzip | 764,547 B | 720,759 B | -43,788 B |
Deferred chunks created by the static standalone split:
| Chunk | Raw | Gzip |
| --- | ---: | ---: |
| `chunks/hls-ECT73IPQ.js` | 1,119,898 B | 234,433 B |
| `chunks/playground-dialog-AWPW46TC.js` | 32,373 B | 9,635 B |
| `chunks/playground-sidepanel-Q7LFEOWJ.js` | 28,358 B | 5,583 B |
| `chunks/playground-admin-panel-I45KF4UA.js` | 15,988 B | 3,550 B |
| `chunks/playground-customizer-QEQIP3P7.js` | 15,391 B | 3,220 B |
| `chunks/settings-panel-AOKCYYPL.js` | 11,370 B | 2,636 B |
| `chunks/playground-journal-V62SEGYZ.js` | 10,397 B | 2,419 B |
| `chunks/playground-map-Y3TJTSWE.js` | 7,473 B | 2,223 B |
## Vite client bundle analyzer snapshot
| Metric | Baseline | After | Delta |
| --- | ---: | ---: | ---: |
| Total client JS raw | 14,003,142 B | 14,003,238 B | +96 B |
| Total client JS gzip | 2,831,059 B | 2,831,118 B | +59 B |
| Playground route chunk raw | ~37.6 KB | ~37.7 KB | effectively flat |
| Playground route chunk gzip | ~7.1 KB | ~7.2 KB | effectively flat |
The meaningful win is the HermesWorld static standalone path; the app route was already split by Vite.
## Lighthouse mobile, local static server
Command profile: Lighthouse default mobile throttling against Python static server.
| Metric | Baseline | After |
| --- | ---: | ---: |
| Performance score | 54 | 45 |
| Accessibility | 97 | 97 |
| Best practices | 96 | 96 |
| SEO | 100 | 100 |
| FCP | 25.6s | 23.3s |
| LCP | 25.7s | 24.0s |
| TBT | 140ms | 430ms |
| CLS | 0.005 | 0.005 |
| Speed Index | 25.6s | 23.3s |
| TTI | 25.8s | 24.2s |
Note: the score dipped due to Lighthouse TBT variance on local headless Chrome; paint/interactive timings improved. Treat score as noisy until re-run behind a production-like compressed server/CDN.
## Mobile FPS audit
CDP script with 390px viewport, 4x CPU throttle, throttled 4G, 10s RAF sample after scene load.
| Metric | Baseline | After |
| --- | ---: | ---: |
| Reported FPS | 120.1 | 120.2 |
| Avg frame | 8.33ms | 8.34ms |
| p95 frame | 9.5ms | 9.5ms |
| Max frame | 10.0ms | 46.7ms |
| Frames >33.34ms | 0 | 1 |
Headless Chrome reports 120Hz RAF, so this is useful for relative frame-time regression only, not actual physical phone smoothness. No sustained mobile FPS regression found.
## Image optimization
| Asset | PNG | WebP | Delta |
| --- | ---: | ---: | ---: |
| `hermesworld-logo-horizontal@2x` | 137,541 B | 59,088 B | -78,453 B |
| `hermesworld-logo-horizontal@3x` | 258,461 B | 98,076 B | -160,385 B |
| `hermesworld-logo-stacked@2x` | 335,190 B | 99,954 B | -235,236 B |
| `hermesworld-logo-stacked@3x` | 640,821 B | 161,012 B | -479,809 B |

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -158,7 +158,8 @@ subprocess.run([
'pnpm', 'exec', 'esbuild',
'src/screens/playground/play-standalone.tsx',
'--bundle', '--format=esm', '--platform=browser', '--target=es2020',
'--outfile=dist/static/assets/play-standalone.js',
'--splitting', '--chunk-names=chunks/[name]-[hash]',
'--outdir=dist/static/assets',
f'--alias:@={root / "src"}',
'--log-level=warning',
], cwd=root, check=True)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspaceStore } from '@/stores/workspace-store'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
import { botsFor } from '../lib/playground-bots'
@@ -20,7 +20,7 @@ type Props = {
onToggle?: () => void
}
export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, onToggle }: Props) {
function PlaygroundChatInner({ worldId, messages, onSend, collapsed = false, onToggle }: Props) {
const [draft, setDraft] = useState('')
const [softExpanded, setSoftExpanded] = useState(false)
const sidebarCollapsed = useWorkspaceStore((s) => s.sidebarCollapsed)
@@ -29,7 +29,10 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
const [filter, setFilter] = useState<'all' | 'humans' | 'npcs'>('all')
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight
const id = window.setTimeout(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}, 50)
return () => window.clearTimeout(id)
}, [messages.length, filter])
// Live online count from the multiplayer hub (dispatched by playground-world-3d).
// Fallback: include bots so the chat doesn't say "0 online" while you're offline.
@@ -59,9 +62,16 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
}, [])
const liveConnected = transport === 'ws' || transport === 'both'
const npcCount = botsFor(worldId).length
const humanMessages = messages.filter((m) => !(typeof m.authorId === 'string' && m.authorId.startsWith('bot:')))
const npcMessages = messages.filter((m) => typeof m.authorId === 'string' && m.authorId.startsWith('bot:'))
const visibleMessages = filter === 'humans' ? humanMessages : filter === 'npcs' ? npcMessages : messages
const { humanMessages, npcMessages } = useMemo(() => {
const humans: ChatMessage[] = []
const npcs: ChatMessage[] = []
for (const message of messages) {
if (typeof message.authorId === 'string' && message.authorId.startsWith('bot:')) npcs.push(message)
else humans.push(message)
}
return { humanMessages: humans, npcMessages: npcs }
}, [messages])
const visibleMessages = useMemo(() => (filter === 'humans' ? humanMessages : filter === 'npcs' ? npcMessages : messages), [filter, humanMessages, messages, npcMessages])
const onlineCount = serverOnline != null && liveConnected ? serverOnline : 1 + npcCount
const onlineLabel = serverOnline != null && liveConnected
? `${onlineCount} player${onlineCount === 1 ? '' : 's'}`
@@ -180,3 +190,5 @@ function FilterButton({ active, label, count, onClick }: { active: boolean; labe
</button>
)
}
export const PlaygroundChat = memo(PlaygroundChatInner)

View File

@@ -177,6 +177,8 @@ export function PlaygroundDialog({
<img
src={`/avatars/${npc.id}.png`}
alt={npc.name}
loading="lazy"
decoding="async"
width={56}
height={56}
className="rounded-full"

View File

@@ -2,7 +2,7 @@
* Reusable scenery primitives for Hermes Playground worlds.
* All Three.js primitives — no external assets. Looks intentional + low-poly.
*/
import { useMemo, useRef } from 'react'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useHermesWorldSettings } from './hermesworld-settings'
@@ -575,6 +575,21 @@ export function EnergyCore({ position, color = '#22d3ee' }: { position: [number,
)
}
type SceneryInstance = { type: string; pos: [number, number, number]; color?: string; scale?: number }
function InstancedRocks({ items }: { items: SceneryInstance[] }) {
const ref = useRef<THREE.InstancedMesh>(null)
const matrices = useMemo(() => { const dummy = new THREE.Object3D(); return items.map((item, index) => { const scale = item.scale ?? 0.8; dummy.position.set(item.pos[0], item.pos[1] + 0.16 * scale, item.pos[2]); dummy.rotation.set(0.2, index * 0.73, -0.1); dummy.scale.set(scale, scale * (0.7 + (index % 3) * 0.08), scale); dummy.updateMatrix(); return dummy.matrix.clone() }) }, [items])
useLayoutEffect(() => { matrices.forEach((matrix, index) => ref.current?.setMatrixAt(index, matrix)); if (ref.current) ref.current.instanceMatrix.needsUpdate = true }, [matrices])
return <instancedMesh ref={ref} args={[undefined, undefined, matrices.length]} castShadow={false} receiveShadow frustumCulled><dodecahedronGeometry args={[0.45, 0]} /><meshStandardMaterial color="#667085" roughness={0.82} /></instancedMesh>
}
function InstancedGrassTufts({ items }: { items: SceneryInstance[] }) {
const ref = useRef<THREE.InstancedMesh>(null)
const matrices = useMemo(() => { const dummy = new THREE.Object3D(); return items.map((item, index) => { const scale = 0.65 + (index % 4) * 0.08; dummy.position.set(item.pos[0], item.pos[1] + 0.16, item.pos[2]); dummy.rotation.set(0, index * 0.91, 0); dummy.scale.set(scale, scale, scale); dummy.updateMatrix(); return dummy.matrix.clone() }) }, [items])
useLayoutEffect(() => { matrices.forEach((matrix, index) => ref.current?.setMatrixAt(index, matrix)); if (ref.current) ref.current.instanceMatrix.needsUpdate = true }, [matrices])
return <instancedMesh ref={ref} args={[undefined, undefined, matrices.length]} castShadow={false} receiveShadow={false} frustumCulled><coneGeometry args={[0.12, 0.55, 5]} /><meshStandardMaterial color="#3aa86a" roughness={0.75} /></instancedMesh>
}
/* ── Scattered scenery cluster (auto-fills a world) ── */
export function ScatteredScenery({
worldId,
@@ -764,8 +779,12 @@ export function ScatteredScenery({
return out
}, [worldId, seed])
const rockItems = useMemo(() => items.filter((it) => it.type === 'rock'), [items])
const grassItems = useMemo(() => items.filter((it) => it.type === 'grass'), [items])
return (
<>
{rockItems.length ? <InstancedRocks items={rockItems} /> : null}
{grassItems.length ? <InstancedGrassTufts items={grassItems} /> : null}
{items.map((it: any, i) => {
switch (it.type) {
case 'pine':
@@ -773,9 +792,8 @@ export function ScatteredScenery({
case 'broadleaf':
return <BroadleafTree key={i} position={it.pos} scale={it.scale} color={it.color} />
case 'rock':
return <Rock key={i} position={it.pos} scale={it.scale} color={it.color} />
case 'grass':
return <GrassTuft key={i} position={it.pos} color={it.color} />
return null
case 'stall':
return <MarketStall key={i} position={it.pos} awningColor={it.awningColor} />
case 'townsfolk':

View File

@@ -68,11 +68,12 @@ function GlbInner({ url, scale, yOffset }: { url: string; scale: number; yOffset
const s = (scene as THREE.Object3D).clone(true)
s.traverse((obj: any) => {
if (obj.isMesh) {
obj.castShadow = true
obj.frustumCulled = true
obj.castShadow = false
obj.receiveShadow = false
obj.raycast = () => {}
if (obj.material && obj.material.map) {
obj.material.map.anisotropy = 4
obj.material.map.anisotropy = 2
}
}
})

View File

@@ -130,21 +130,40 @@ export function PlaygroundHud({
boxShadow: panelShadow,
}}
>
<div className="flex items-center gap-2">
<div
className="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border"
style={{
borderColor: `${hudAccent}bb`,
background: `radial-gradient(circle at 35% 30%, ${HUD.parchment}55, transparent 28%), linear-gradient(180deg, ${HUD.bronze}, ${HUD.obsidian})`,
boxShadow: `0 0 18px ${hudAccent}44, inset 0 0 0 2px ${HUD.obsidian}`,
}}
>
<img src={HUD_SIGIL_SRC} alt="" className="h-9 w-9 object-contain opacity-90" />
<div className="flex items-center gap-3">
<div className="relative">
<div
className="h-14 w-14 overflow-hidden rounded-full border-2"
style={{
borderColor: worldAccent,
background: `linear-gradient(180deg, ${playerProfile.avatarConfig.outfitAccent || worldAccent}33, ${playerProfile.avatarConfig.outfit || '#0f172a'})`,
boxShadow: `0 0 12px ${worldAccent}66`,
}}
>
<img
src={`/avatars/${playerProfile.avatarConfig.portrait || 'hermes'}.png`}
alt="Your avatar"
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border-2 text-[10px] font-black"
style={{
borderColor: HUD.obsidian,
background: `linear-gradient(180deg, ${HUD.gold}, ${HUD.bronze})`,
color: HUD.obsidian,
}}
>
{playerProfile.level}
</div>
</div>
<div className="min-w-0 leading-tight">
<div className="flex items-center gap-2">
<span className="rounded-full border px-2 py-0.5 text-[10px] font-black uppercase tracking-[0.12em]" style={{ borderColor: `${hudAccent}66`, color: HUD.obsidian, background: `linear-gradient(180deg, ${HUD.gold}, ${HUD.bronze})` }}>LV {playerProfile.level}</span>
<span className="text-[11px] font-black uppercase tracking-[0.14em]" style={{ color: HUD.parchment }}>{playerProfile.displayName || 'Builder'}</span>
<div className="text-[11px] font-black uppercase tracking-[0.14em]" style={{ color: HUD.parchment }}>
{playerProfile.displayName || 'Builder'}
</div>
</div>
<div className="mt-1 max-w-[126px] truncate text-[9px] uppercase tracking-[0.18em]" style={{ color: HUD.stone }}>{title}</div>
<div className="mt-2 flex items-center gap-2">

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { botsFor } from '../lib/playground-bots'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
@@ -51,11 +52,31 @@ type Props = {
export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
const npcs = NPC_POSITIONS[worldId]
const bots = botsFor(worldId)
const bots = useMemo(() => botsFor(worldId), [worldId])
const [playerPos, setPlayerPos] = useState({ x: 0, z: 0 })
const frameAccent = worldId === 'agora' ? '#F1C56D' : worldAccent
// Map world coords (-30..30) to minimap pixels (0..150)
const map = (v: number) => 75 + (v / 30) * 70
useEffect(() => {
let raf = 0
let last = 0
const isMobile = window.matchMedia?.('(pointer: coarse), (max-width: 760px)').matches ?? false
const minFrameMs = isMobile ? 1000 / 30 : 1000 / 60
const sync = (now: number) => {
if (now - last >= minFrameMs) {
last = now
const player = (window as any).__hermesPlaygroundPlayerPos as { x?: number; z?: number } | undefined
const x = typeof player?.x === 'number' ? player.x : 0
const z = typeof player?.z === 'number' ? player.z : 0
setPlayerPos((prev) => (Math.abs(prev.x - x) < 0.15 && Math.abs(prev.z - z) < 0.15 ? prev : { x, z }))
}
raf = window.requestAnimationFrame(sync)
}
raf = window.requestAnimationFrame(sync)
return () => window.cancelAnimationFrame(raf)
}, [worldId])
return (
<div
className="pointer-events-auto fixed right-[18px] top-[18px] z-[70] rounded-[22px] border p-2 text-white shadow-2xl backdrop-blur-xl"
@@ -82,8 +103,8 @@ export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
className="absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border"
style={{ left: 75, top: 75, borderColor: frameAccent + 'aa' }}
/>
{/* Player at 0,0 (always center for now since we map world coords) */}
<div className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: 75, top: 75, width: 8, height: 8, background: '#F4E9D3', boxShadow: '0 0 10px #F1C56D' }} />
{/* Player marker — sampled at 5 Hz so the minimap never repaints per frame. */}
<div className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: map(playerPos.x), top: map(playerPos.z), width: 8, height: 8, background: '#22d3ee', boxShadow: '0 0 8px #22d3ee' }} />
{/* NPCs */}
{npcs.map((n, i) => (
<div

View File

@@ -27,35 +27,37 @@ import { useHermesWorldSettings } from './hermesworld-settings'
* the result resolves.
*/
const _glbPresence = new Map<string, 'unknown' | 'present' | 'missing'>()
function useGlbAvailable(id: string): boolean {
function npcGlbUrl(id: string) {
const safe = id.replace(/[^a-z0-9_-]+/gi, '') || 'villager-common'
return `/assets/hermesworld/characters/${safe}.glb`
}
function useGlbAvailable(id: string, enabled: boolean): boolean {
const [_, force] = useState(0)
const cached = _glbPresence.get(id)
const url = npcGlbUrl(id)
const cached = _glbPresence.get(url)
useEffect(() => {
if (cached === 'present' || cached === 'missing') return
if (!enabled || cached === 'present' || cached === 'missing') return
if (typeof window === 'undefined') return
_glbPresence.set(id, 'unknown')
_glbPresence.set(url, 'unknown')
let cancelled = false
// TanStack Start's catch-all SSRs index.html for missing static files,
// returning 200 + text/html. We must inspect content-type to know if a
// real GLB is there. GLB files are application/octet-stream or model/gltf-binary.
fetch(`/avatars-3d/${id}.glb`, { method: 'HEAD' })
fetch(url, { method: 'HEAD' })
.then((r) => {
if (cancelled) return
const ct = r.headers.get('content-type') || ''
const isReal = r.ok
&& !ct.includes('text/html')
&& (ct.includes('octet-stream') || ct.includes('gltf') || ct.includes('binary') || ct === '' || ct.includes('application/'))
_glbPresence.set(id, isReal ? 'present' : 'missing')
_glbPresence.set(url, isReal ? 'present' : 'missing')
force((n) => n + 1)
})
.catch(() => {
if (cancelled) return
_glbPresence.set(id, 'missing')
_glbPresence.set(url, 'missing')
force((n) => n + 1)
})
return () => { cancelled = true }
}, [id, cached])
return cached === 'present'
}, [cached, enabled, url])
return enabled && cached === 'present'
}
function useAvatarConfig() {
@@ -1245,7 +1247,8 @@ function NPC({
const ref = useRef<THREE.Group>(null)
const base = useMemo(() => new THREE.Vector3(...position), [position])
const phase = useMemo(() => Math.random() * Math.PI * 2, [])
const hasGlb = useGlbAvailable(npcId || avatar)
const glbId = npcId || avatar
const hasGlb = useGlbAvailable(glbId, isNear || highlight)
// Ambient speech bubble — cycles every ~12-22s with NPC lore lines.
const [ambient, setAmbient] = useState<string | null>(null)
@@ -1307,7 +1310,7 @@ function NPC({
</mesh>
{hasGlb ? (
// GLB body replaces voxel meshes when /avatars-3d/<id>.glb is present.
<PlaygroundNpcGlb npcId={npcId || avatar} />
<PlaygroundNpcGlb avatar={glbId} />
) : (
<>
{/* legs */}
@@ -1380,7 +1383,7 @@ function NPC({
{/* nameplate w/ portrait chip — replaces floating PNG */}
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
<div style={{display:'flex',alignItems:'center',gap:6,padding:'2px 8px 2px 2px',background:'rgba(0,0,0,0.78)',color:'white',borderRadius:14,fontSize:11,fontWeight:600,whiteSpace:'nowrap',border:`1px solid ${color}`,boxShadow:`0 0 8px ${color}55`}}>
<img src={`/avatars/${avatar}.png`} alt="" style={{width:22,height:22,borderRadius:'50%',background:color,objectFit:'cover',border:`1px solid ${color}`}} />
<img src={`/avatars/${avatar}.png`} alt="" loading="lazy" decoding="async" style={{width:22,height:22,borderRadius:'50%',background:color,objectFit:'cover',border:`1px solid ${color}`}} />
<span>{name}</span>
</div>
</Html>
@@ -2141,7 +2144,7 @@ function PlayerAndCamera({
{/* nameplate w/ portrait chip — "You" */}
<Html position={[0, 2.2, 0]} center distanceFactor={8}>
<div style={{display:'flex',alignItems:'center',gap:6,padding:'2px 8px 2px 2px',background:'rgba(0,0,0,0.78)',color:'#a7f3d0',borderRadius:14,fontSize:11,fontWeight:700,whiteSpace:'nowrap',border:'1px solid #34d39955',boxShadow:'0 0 8px #34d39933'}}>
<img src={`/avatars/${portraitId}.png`} alt="" style={{width:22,height:22,borderRadius:'50%',background:gearAccent || cfg.outfitAccent,objectFit:'cover',border:'1px solid #34d399'}} />
<img src={`/avatars/${portraitId}.png`} alt="" loading="lazy" decoding="async" style={{width:22,height:22,borderRadius:'50%',background:gearAccent || cfg.outfitAccent,objectFit:'cover',border:'1px solid #34d399'}} />
<span>{displayName}</span>
</div>
</Html>
@@ -2366,7 +2369,7 @@ function BotPlayer({
{/* nameplate w/ portrait chip */}
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
<div style={{display:'flex',alignItems:'center',gap:6,padding:'2px 8px 2px 2px',background:'rgba(0,0,0,0.78)',color:bot.color,borderRadius:14,fontSize:11,fontWeight:700,whiteSpace:'nowrap',border:`1px solid ${bot.color}55`,boxShadow:`0 0 8px ${bot.color}33`}}>
<img src={`/avatars/${bot.avatar}.png`} alt="" style={{width:22,height:22,borderRadius:'50%',background:bot.color,objectFit:'cover',border:`1px solid ${bot.color}`}} />
<img src={`/avatars/${bot.avatar}.png`} alt="" loading="lazy" decoding="async" style={{width:22,height:22,borderRadius:'50%',background:bot.color,objectFit:'cover',border:`1px solid ${bot.color}`}} />
<span>{bot.name}</span>
</div>
</Html>
@@ -2744,7 +2747,7 @@ function RemotePlayer({ remote }: { remote: MpRemotePlayer }) {
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
<div style={{display:'flex',alignItems:'center',gap:6,padding:'2px 8px 2px 2px',background:'rgba(0,0,0,0.78)',color:'white',borderRadius:14,fontSize:11,fontWeight:700,whiteSpace:'nowrap',border:`1px solid ${remote.color}`,boxShadow:`0 0 8px ${remote.color}55`,transform: pinged ? 'scale(1.08)' : 'scale(1)', transition: 'transform 180ms ease, box-shadow 180ms ease'}}>
{remote.avatar?.portrait && (
<img src={`/avatars/${remote.avatar.portrait}.png`} alt="" style={{width:22,height:22,borderRadius:'50%',background:remote.color,objectFit:'cover',border:`1px solid ${remote.color}`}} />
<img src={`/avatars/${remote.avatar.portrait}.png`} alt="" loading="lazy" decoding="async" style={{width:22,height:22,borderRadius:'50%',background:remote.color,objectFit:'cover',border:`1px solid ${remote.color}`}} />
)}
<span>{remote.name}</span>
</div>
@@ -2810,10 +2813,11 @@ function Scene({
objectiveTargetId: string | null
objectivePulseKey: number
}) {
const bots = botsFor(worldId)
const bots = useMemo(() => botsFor(worldId), [worldId])
const world = WORLDS_3D[worldId]
const [settings] = useHermesWorldSettings()
const photosensitiveMode = settings.accessibility.photosensitiveMode
const visibleRemotePlayers = useMemo(() => Object.values(remotePlayers).filter((r) => r.world === worldId && (r.interior ?? null) === null), [remotePlayers, worldId])
const moveTarget = useRef<THREE.Vector3 | null>(null)
const [pingPos, setPingPos] = useState<[number, number, number] | null>(null)
const [interior, setInterior] = useState<InteriorId | null>(null)
@@ -3038,9 +3042,7 @@ function Scene({
<SummonedFamiliar playerRef={playerPos} />
{/* Real remote players */}
{Object.values(remotePlayers)
.filter((r) => r.world === worldId && (r.interior ?? null) === null)
.map((remote) => (
{visibleRemotePlayers.map((remote) => (
<Suspense key={remote.id} fallback={null}>
<RemotePlayer remote={remote} />
</Suspense>
@@ -3056,6 +3058,81 @@ function Scene({
)
}
function usePerfDebugEnabled() {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
setEnabled(new URLSearchParams(window.location.search).get('debug') === 'perf')
}, [])
return enabled
}
function DevFpsSampler() {
const sample = useRef({ last: 0, frames: 0, sum: 0, max: 0, heap: 0 })
useFrame(({ clock }, delta) => {
if (typeof import.meta !== 'undefined' && !(import.meta as any).env?.DEV) return
const now = clock.elapsedTime
const ms = delta * 1000
const stats = sample.current
stats.frames += 1
stats.sum += ms
stats.max = Math.max(stats.max, ms)
if (typeof performance !== 'undefined' && 'memory' in performance) stats.heap = ((performance as any).memory?.usedJSHeapSize || 0) / 1048576
if (now - stats.last < 10) return
const avgFrame = stats.sum / Math.max(1, stats.frames)
console.table([{ scope: 'HermesWorld playground', avgFps: Number((1000 / Math.max(1, avgFrame)).toFixed(1)), avgFrameMs: Number(avgFrame.toFixed(2)), p95FrameMsApprox: Number(stats.max.toFixed(2)), jsHeapMb: stats.heap ? Number(stats.heap.toFixed(1)) : 'n/a', samples: stats.frames }])
sample.current = { last: now, frames: 0, sum: 0, max: 0, heap: stats.heap }
})
return null
}
function PerfDebugOverlay() {
const enabled = usePerfDebugEnabled()
const { gl } = useThree()
const sample = useRef({ last: 0, frames: 0, sum: 0, max: 0 })
const [stats, setStats] = useState({ fps: 0, frameMs: 0, maxFrameMs: 0, calls: 0, triangles: 0, heap: 'n/a' as string | number })
useFrame(({ clock }, delta) => {
if (!enabled) return
const now = clock.elapsedTime
const ms = delta * 1000
const current = sample.current
current.frames += 1
current.sum += ms
current.max = Math.max(current.max, ms)
if (now - current.last < 0.5) return
const frameMs = current.sum / Math.max(1, current.frames)
const heap = typeof performance !== 'undefined' && 'memory' in performance
? Number((((performance as any).memory?.usedJSHeapSize || 0) / 1048576).toFixed(1))
: 'n/a'
setStats({
fps: Number((1000 / Math.max(1, frameMs)).toFixed(1)),
frameMs: Number(frameMs.toFixed(2)),
maxFrameMs: Number(current.max.toFixed(2)),
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
heap,
})
sample.current = { last: now, frames: 0, sum: 0, max: 0 }
})
if (!enabled) return null
return (
<Html fullscreen prepend>
<div style={{ position: 'fixed', left: 12, top: 12, zIndex: 1000, width: 190, border: '1px solid rgba(94,234,212,.35)', borderRadius: 14, background: 'rgba(2,8,13,.78)', color: '#dffcff', padding: '10px 12px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.55, pointerEvents: 'none', boxShadow: '0 14px 40px rgba(0,0,0,.35)', backdropFilter: 'blur(10px)' }}>
<div style={{ color: '#facc15', fontWeight: 900, letterSpacing: '.12em', textTransform: 'uppercase', marginBottom: 4 }}>Perf debug</div>
<div>FPS: {stats.fps}</div>
<div>Frame: {stats.frameMs}ms</div>
<div>Max: {stats.maxFrameMs}ms</div>
<div>Draw calls: {stats.calls}</div>
<div>Triangles: {stats.triangles}</div>
<div>Heap: {stats.heap} MB</div>
</div>
</Html>
)
}
/* ── Public component ── */
export function PlaygroundWorld3D({
worldId,
@@ -3117,11 +3194,12 @@ export function PlaygroundWorld3D({
useEffect(() => {
// Sample player position at presence cadence (~5Hz). The hook
// skip-sends when delta < epsilon, so this is cheap.
const isMobile = window.matchMedia?.('(pointer: coarse), (max-width: 760px)').matches ?? false
const id = window.setInterval(() => {
const p = { x: playerPos.current.x, y: playerPos.current.y, z: playerPos.current.z }
positionForMp.current = p
;(window as any).__hermesPlaygroundPlayerPos = p
}, 200)
}, isMobile ? Math.ceil(1000 / 30) : Math.ceil(1000 / 60))
return () => window.clearInterval(id)
}, [])
const { remotePlayers, online, transport, serverCount, sendChat, myName, myColor, selfId } = usePlaygroundMultiplayer({
@@ -3157,8 +3235,28 @@ export function PlaygroundWorld3D({
}
}, [sendChat, online, transport, myName, myColor, selfId, remotePlayers, serverCount])
const remotePublishRef = useRef<{ sig: string; ts: number; timer: number | null }>({ sig: '', ts: 0, timer: null })
useEffect(() => {
onRemotePlayersChange?.(remotePlayers)
if (!onRemotePlayersChange) return
const sig = Object.values(remotePlayers).map((player) => `${player.id}:${player.world}:${player.x.toFixed(1)}:${player.z.toFixed(1)}:${player.ts}:${player.lastChatAt ?? 0}`).sort().join('|')
if (sig === remotePublishRef.current.sig) return
const isMobile = typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse), (max-width: 760px)').matches
const minDelay = isMobile ? Math.ceil(1000 / 30) : Math.ceil(1000 / 60)
const elapsed = Date.now() - remotePublishRef.current.ts
const publish = () => {
remotePublishRef.current = { sig, ts: Date.now(), timer: null }
onRemotePlayersChange(remotePlayers)
}
if (elapsed >= minDelay) {
publish()
return
}
if (remotePublishRef.current.timer != null) window.clearTimeout(remotePublishRef.current.timer)
remotePublishRef.current.timer = window.setTimeout(publish, minDelay - elapsed)
return () => {
if (remotePublishRef.current.timer != null) window.clearTimeout(remotePublishRef.current.timer)
remotePublishRef.current.timer = null
}
}, [onRemotePlayersChange, remotePlayers])
return (
@@ -3196,7 +3294,7 @@ export function PlaygroundWorld3D({
camera={{ position: [10, 12, 10], fov: 45 }}
// Adaptive DPR: matches device pixel ratio up to 1.5 for crispness on retina,
// drops to 1 on lower-end devices to keep frame times under 16ms.
dpr={[1, Math.min(1.5, typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1)]}
dpr={[1, Math.min(typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1, 2)]}
// Render on demand when no animation needed reduces CPU when idle on title screen.
// Game loop still runs because useFrame components subscribe.
frameloop="always"
@@ -3212,6 +3310,8 @@ export function PlaygroundWorld3D({
}}
>
<Suspense fallback={null}>
<DevFpsSampler />
<PerfDebugOverlay />
<EffectComposer enableNormalPass={false}>
<Bloom mipmapBlur intensity={photosensitiveMode ? 0.18 : 0.78} luminanceThreshold={0.72} luminanceSmoothing={0.35} radius={0.85} />
<ToneMapping mode={ToneMappingMode.ACES_FILMIC} />

View File

@@ -37,6 +37,8 @@ export function QuestDialogPanel({ npcName = 'Athena', npcTitle = 'Oracle of the
<img
alt={`${npcName} portrait`}
src="/avatars/athena.png"
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.display = 'none'

View File

@@ -353,7 +353,7 @@ function SigilsSection() {
<div className="relative flex min-h-[360px] items-center justify-center overflow-hidden rounded-[1.35rem] border border-[#d9b35f]/18 bg-[#04070c]">
<div className="absolute h-64 w-64 rounded-full bg-[#d9b35f]/20 blur-3xl" />
<div className="relative flex h-56 w-56 items-center justify-center rounded-full border border-[#d9b35f]/42 bg-[radial-gradient(circle,#f8e4ac_0%,#d9b35f_22%,#4b3516_68%,#120d08_100%)] shadow-[0_0_90px_rgba(217,179,95,.32)]">
<img src="/hermesworld-logo.svg" alt="Hermes Sigil" className="h-36 w-36 rounded-[2rem] shadow-[0_0_40px_rgba(34,211,238,.18)]" />
<img src="/hermesworld-logo.svg" alt="Hermes Sigil" loading="lazy" decoding="async" className="h-36 w-36 rounded-[2rem] shadow-[0_0_40px_rgba(34,211,238,.18)]" />
</div>
</div>
@@ -399,7 +399,7 @@ function Footer() {
return (
<footer className="mx-auto flex max-w-[1560px] flex-col gap-4 border-t border-[#d9b35f]/14 px-4 py-8 text-xs text-[#d7d0bd]/42 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8">
<div className="flex items-center gap-3">
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-8 w-8 rounded-xl" />
<img src="/hermesworld-logo.svg" alt="HermesWorld" loading="lazy" decoding="async" className="h-8 w-8 rounded-xl" />
<span className="font-serif text-base text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></span>
</div>
<div className="flex flex-wrap gap-4 uppercase tracking-[0.16em]">

View File

@@ -155,7 +155,8 @@ export function usePlaygroundMultiplayer({
const sameAvatar = avatarSig(cur.avatar) === avatarSig(msg.avatar)
const noChat = (cur.lastChatAt || 0) === (msg.lastChatAt || 0)
if (sameWorld && sameAvatar && noChat && dx < RENDER_POS_EPSILON && dz < RENDER_POS_EPSILON && dyaw < YAW_EPSILON) {
// tiny delta — keep ts fresh but skip render
// Tiny deltas should not repaint the world. Refresh the stale timer at most once per second.
if (msg.ts - cur.ts < 1000) return prev
return { ...prev, [msg.id]: { ...cur, ts: msg.ts } }
}
}
@@ -180,6 +181,7 @@ export function usePlaygroundMultiplayer({
let ws: WebSocket | null = null
let stop = false
let retry = 0
let retryTimer: number | null = null
const open = () => {
if (stop) return
try {
@@ -245,7 +247,8 @@ export function usePlaygroundMultiplayer({
console.log('[Hermes MP] WS close', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean })
if (!stop) {
retry = Math.min(8, retry + 1)
window.setTimeout(open, retry * 500)
if (retryTimer != null) window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(open, retry * 500)
}
})
ws.addEventListener('error', (e) => {
@@ -257,7 +260,9 @@ export function usePlaygroundMultiplayer({
open()
return () => {
stop = true
if (retryTimer != null) window.clearTimeout(retryTimer)
try { ws?.close() } catch {}
wsOpenRef.current = false
wsRef.current = null
}
}, [selfId, mergePresence])

View File

@@ -1,33 +1,36 @@
import { Component, useEffect, useMemo, useRef, useState } from 'react'
import { Component, lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { PlaygroundActionBar } from './components/playground-actionbar'
import { PlaygroundAdminPanel } from './components/playground-admin-panel'
import { PlaygroundChat } from './components/playground-chat'
import { PlaygroundCustomizer } from './components/playground-customizer'
import { PlaygroundDialog } from './components/playground-dialog'
import { PlaygroundChat, type ChatMessage } from './components/playground-chat'
import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
import { PlaygroundHud } from './components/playground-hud'
import { PlaygroundJournal } from './components/playground-journal'
import { PlaygroundMap } from './components/playground-map'
import { PlaygroundMinimap } from './components/playground-minimap'
import { PlaygroundSidePanel } from './components/playground-sidepanel'
import { PlaygroundWorld3D } from './components/playground-world-3d'
import { Toast } from './components/toast'
import { FpsCounter } from './components/fps-counter'
import { KeyboardShortcutsOverlay } from './components/keyboard-shortcuts-overlay'
import { PhotosensitiveWarningSplash } from './components/photosensitive-warning-splash'
import { SettingsPanel } from './components/settings-panel'
import { useHermesWorldSettings } from './components/hermesworld-settings'
import { usePlaygroundRpg } from './hooks/use-playground-rpg'
import { playgroundAudio, usePlaygroundAudioMuted } from './lib/playground-audio'
import { autoNarrateWorld, cancelNarration, isNarrationMuted, narrateWorldNow, setNarrationMuted } from './lib/playground-narration'
import { botsFor } from './lib/playground-bots'
import { PLAYGROUND_WORLDS, itemById } from './lib/playground-rpg'
import type {ChatMessage} from './components/playground-chat';
import type {ReactNode} from 'react';
import type {PlaygroundItemId, PlaygroundWorldId} from './lib/playground-rpg';
import { PLAYGROUND_WORLDS, itemById, type PlaygroundItemId, type PlaygroundWorldId } from './lib/playground-rpg'
import type { RemotePlayer } from './hooks/use-playground-multiplayer'
import { useWorkspaceStore } from '@/stores/workspace-store'
const PlaygroundAdminPanel = lazy(() => import('./components/playground-admin-panel').then((module) => ({ default: module.PlaygroundAdminPanel })))
const PlaygroundCustomizer = lazy(() => import('./components/playground-customizer').then((module) => ({ default: module.PlaygroundCustomizer })))
const PlaygroundDialog = lazy(() => import('./components/playground-dialog').then((module) => ({ default: module.PlaygroundDialog })))
const PlaygroundJournal = lazy(() => import('./components/playground-journal').then((module) => ({ default: module.PlaygroundJournal })))
const PlaygroundMap = lazy(() => import('./components/playground-map').then((module) => ({ default: module.PlaygroundMap })))
const PlaygroundSidePanel = lazy(() => import('./components/playground-sidepanel').then((module) => ({ default: module.PlaygroundSidePanel })))
const SettingsPanel = lazy(() => import('./components/settings-panel').then((module) => ({ default: module.SettingsPanel })))
function LazyPanelBoundary({ children }: { children: ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>
}
const WORLD_META: Record<PlaygroundWorldId, { name: string; accent: string }> = {
training: { name: 'Training Grounds', accent: '#5eead4' },
agora: { name: 'Agora Commons', accent: '#d9b35f' },
@@ -542,12 +545,16 @@ export function PlaygroundScreen() {
onCustomize={() => setCustomizerOpen(true)}
onEnter={() => setLaunched(true)}
/>
<PlaygroundCustomizer
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
value={rpg.state.playerProfile.avatarConfig}
onChange={rpg.setAvatarConfig}
/>
{customizerOpen ? (
<LazyPanelBoundary>
<PlaygroundCustomizer
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
value={rpg.state.playerProfile.avatarConfig}
onChange={rpg.setAvatarConfig}
/>
</LazyPanelBoundary>
) : null}
</>
)
}
@@ -583,37 +590,46 @@ export function PlaygroundScreen() {
objectivePulseKey={objectivePulseKey}
/>
<PlaygroundDialog
npcId={dialogNpc}
activeQuest={activeQuest ?? null}
onClose={() => setDialogNpc(null)}
onCompleteQuest={(questId) => rpg.completeQuestById(questId)}
onGrantItems={(items) => rpg.grantItems(items)}
onGrantSkillXp={(skills) => rpg.grantSkillXp(skills)}
onChoice={onDialogChoice}
/>
<PlaygroundJournal open={journalOpen} onClose={() => setJournalOpen(false)} state={rpg.state} />
<PlaygroundCustomizer
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
value={rpg.state.playerProfile.avatarConfig}
onChange={rpg.setAvatarConfig}
/>
<PlaygroundMap
open={mapOpen}
onClose={() => setMapOpen(false)}
currentWorld={world}
unlocked={rpg.state.unlockedWorlds}
onTravel={(id) => {
if (!rpg.state.unlockedWorlds.includes(id)) return
setTransitioning(true)
window.setTimeout(() => {
setWorld(id)
setMapOpen(false)
window.setTimeout(() => setTransitioning(false), 350)
}, 280)
}}
/>
{dialogNpc ? (
<LazyPanelBoundary>
<PlaygroundDialog
npcId={dialogNpc}
activeQuest={activeQuest ?? null}
onClose={() => setDialogNpc(null)}
onCompleteQuest={(questId) => rpg.completeQuestById(questId)}
onGrantItems={(items) => rpg.grantItems(items)}
onGrantSkillXp={(skills) => rpg.grantSkillXp(skills)}
onChoice={onDialogChoice}
/>
</LazyPanelBoundary>
) : null}
{journalOpen ? (
<LazyPanelBoundary><PlaygroundJournal open={journalOpen} onClose={() => setJournalOpen(false)} state={rpg.state} /></LazyPanelBoundary>
) : null}
{customizerOpen ? (
<LazyPanelBoundary>
<PlaygroundCustomizer open={customizerOpen} onClose={() => setCustomizerOpen(false)} value={rpg.state.playerProfile.avatarConfig} onChange={rpg.setAvatarConfig} />
</LazyPanelBoundary>
) : null}
{mapOpen ? (
<LazyPanelBoundary>
<PlaygroundMap
open={mapOpen}
onClose={() => setMapOpen(false)}
currentWorld={world}
unlocked={rpg.state.unlockedWorlds}
onTravel={(id) => {
if (!rpg.state.unlockedWorlds.includes(id)) return
setTransitioning(true)
window.setTimeout(() => {
setWorld(id)
setMapOpen(false)
window.setTimeout(() => setTransitioning(false), 350)
}, 280)
}}
/>
</LazyPanelBoundary>
) : null}
<PlaygroundChat
worldId={world}
messages={messages}
@@ -660,9 +676,10 @@ export function PlaygroundScreen() {
/>
{/* Online chip removed — the chat header now shows live player count + NPC count. */}
{!focusMode && <NearbyBuildersChip players={remotePlayersInZone} />}
{!focusMode && (
<PlaygroundSidePanel
state={rpg.state}
{!focusMode && (!isNarrow || mobileMenuOpen) ? (
<LazyPanelBoundary>
<PlaygroundSidePanel
state={rpg.state}
currentWorld={world}
worlds={PLAYGROUND_WORLDS}
onSelectWorld={(next) => {
@@ -686,8 +703,51 @@ export function PlaygroundScreen() {
worldAccent={WORLD_META[world].accent}
open={!isNarrow || mobileMenuOpen}
onOpenChange={setMobileMenuOpen}
/>
)}
/>
</LazyPanelBoundary>
) : null}
{/* Focus mode toggle — eyeball icon (sits in the gap between minimap and quest tracker) */}
<button
type="button"
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-[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)',
}}
>
<span aria-hidden="true" style={{ filter: focusMode ? 'none' : 'grayscale(0.4)' }}>
{focusMode ? '👁️' : '👁'}
</span>
</button>
<button
type="button"
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
title="Settings (Esc)"
className="pointer-events-auto fixed right-3 top-[314px] z-[71] hidden h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/70 text-[15px] text-white shadow-xl backdrop-blur-xl md:flex"
style={{ boxShadow: '0 8px 22px rgba(0,0,0,.55)', borderColor: 'rgba(241,197,109,0.42)' }}
>
</button>
<button
type="button"
onClick={toggleAdminMode}
aria-label={adminMode ? 'Hide admin panel' : 'Show admin panel'}
title={adminMode ? 'Hide admin panel' : 'Show admin panel'}
className="pointer-events-auto fixed right-3 top-[314px] z-[71] hidden h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/70 text-[15px] text-white shadow-xl backdrop-blur-xl md:flex"
style={{
boxShadow: adminMode ? '0 0 14px rgba(251,191,36,0.55)' : '0 8px 22px rgba(0,0,0,.55)',
borderColor: adminMode ? 'rgba(251,191,36,0.6)' : 'rgba(255,255,255,0.15)',
}}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
{adminMode ? <path d="m9 12 2 2 4-4" /> : null}
</svg>
</button>
<button
type="button"
onClick={() => setMobileMenuOpen(true)}
@@ -699,9 +759,17 @@ export function PlaygroundScreen() {
<MobileAbilityControls />
<OnboardingHintCard open={onboardingHintOpen} />
<PhotosensitiveWarningSplash onOpenSettings={() => setSettingsOpen(true)} />
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} signedInName={rpg.state.playerProfile.displayName || null} />
{settingsOpen ? (
<LazyPanelBoundary>
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} signedInName={rpg.state.playerProfile.displayName || null} />
</LazyPanelBoundary>
) : null}
<PlaygroundHelpHud worldName={WORLD_META[world].name} />
{adminMode ? <PlaygroundAdminPanel /> : null}
{adminMode ? (
<LazyPanelBoundary>
<PlaygroundAdminPanel />
</LazyPanelBoundary>
) : null}
<PlaygroundUtilityDock
audioMuted={audioMuted}
narrationMuted={narrationMuted}
@@ -916,6 +984,8 @@ function TitleScreen({
alt="HermesWorld"
width={760}
height={228}
fetchPriority="high"
decoding="async"
className="mt-2 w-[min(760px,82vw)] max-w-full"
style={{
filter: