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.
78
docs/mobile-perf-after-bundle.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
docs/mobile-perf-baseline-bundle.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
docs/mobile-perf-report.md
Normal 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 |
|
||||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 96 KiB |
BIN
public/assets/hermesworld/art/hermesworld-logo-stacked@2x.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
public/assets/hermesworld/art/hermesworld-logo-stacked@3x.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 176 KiB |
@@ -158,7 +158,8 @@ subprocess.run([
|
|||||||
'pnpm', 'exec', 'esbuild',
|
'pnpm', 'exec', 'esbuild',
|
||||||
'src/screens/playground/play-standalone.tsx',
|
'src/screens/playground/play-standalone.tsx',
|
||||||
'--bundle', '--format=esm', '--platform=browser', '--target=es2020',
|
'--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"}',
|
f'--alias:@={root / "src"}',
|
||||||
'--log-level=warning',
|
'--log-level=warning',
|
||||||
], cwd=root, check=True)
|
], cwd=root, check=True)
|
||||||
|
|||||||
@@ -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 { useWorkspaceStore } from '@/stores/workspace-store'
|
||||||
import type { PlaygroundWorldId } from '../lib/playground-rpg'
|
import type { PlaygroundWorldId } from '../lib/playground-rpg'
|
||||||
import { botsFor } from '../lib/playground-bots'
|
import { botsFor } from '../lib/playground-bots'
|
||||||
@@ -20,7 +20,7 @@ type Props = {
|
|||||||
onToggle?: () => void
|
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 [draft, setDraft] = useState('')
|
||||||
const [softExpanded, setSoftExpanded] = useState(false)
|
const [softExpanded, setSoftExpanded] = useState(false)
|
||||||
const sidebarCollapsed = useWorkspaceStore((s) => s.sidebarCollapsed)
|
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 [filter, setFilter] = useState<'all' | 'humans' | 'npcs'>('all')
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
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])
|
}, [messages.length, filter])
|
||||||
// Live online count from the multiplayer hub (dispatched by playground-world-3d).
|
// 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.
|
// 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 liveConnected = transport === 'ws' || transport === 'both'
|
||||||
const npcCount = botsFor(worldId).length
|
const npcCount = botsFor(worldId).length
|
||||||
const humanMessages = messages.filter((m) => !(typeof m.authorId === 'string' && m.authorId.startsWith('bot:')))
|
const { humanMessages, npcMessages } = useMemo(() => {
|
||||||
const npcMessages = messages.filter((m) => typeof m.authorId === 'string' && m.authorId.startsWith('bot:'))
|
const humans: ChatMessage[] = []
|
||||||
const visibleMessages = filter === 'humans' ? humanMessages : filter === 'npcs' ? npcMessages : messages
|
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 onlineCount = serverOnline != null && liveConnected ? serverOnline : 1 + npcCount
|
||||||
const onlineLabel = serverOnline != null && liveConnected
|
const onlineLabel = serverOnline != null && liveConnected
|
||||||
? `${onlineCount} player${onlineCount === 1 ? '' : 's'}`
|
? `${onlineCount} player${onlineCount === 1 ? '' : 's'}`
|
||||||
@@ -180,3 +190,5 @@ function FilterButton({ active, label, count, onClick }: { active: boolean; labe
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PlaygroundChat = memo(PlaygroundChatInner)
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export function PlaygroundDialog({
|
|||||||
<img
|
<img
|
||||||
src={`/avatars/${npc.id}.png`}
|
src={`/avatars/${npc.id}.png`}
|
||||||
alt={npc.name}
|
alt={npc.name}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Reusable scenery primitives for Hermes Playground worlds.
|
* Reusable scenery primitives for Hermes Playground worlds.
|
||||||
* All Three.js primitives — no external assets. Looks intentional + low-poly.
|
* 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 { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useHermesWorldSettings } from './hermesworld-settings'
|
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) ── */
|
/* ── Scattered scenery cluster (auto-fills a world) ── */
|
||||||
export function ScatteredScenery({
|
export function ScatteredScenery({
|
||||||
worldId,
|
worldId,
|
||||||
@@ -764,8 +779,12 @@ export function ScatteredScenery({
|
|||||||
return out
|
return out
|
||||||
}, [worldId, seed])
|
}, [worldId, seed])
|
||||||
|
|
||||||
|
const rockItems = useMemo(() => items.filter((it) => it.type === 'rock'), [items])
|
||||||
|
const grassItems = useMemo(() => items.filter((it) => it.type === 'grass'), [items])
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{rockItems.length ? <InstancedRocks items={rockItems} /> : null}
|
||||||
|
{grassItems.length ? <InstancedGrassTufts items={grassItems} /> : null}
|
||||||
{items.map((it: any, i) => {
|
{items.map((it: any, i) => {
|
||||||
switch (it.type) {
|
switch (it.type) {
|
||||||
case 'pine':
|
case 'pine':
|
||||||
@@ -773,9 +792,8 @@ export function ScatteredScenery({
|
|||||||
case 'broadleaf':
|
case 'broadleaf':
|
||||||
return <BroadleafTree key={i} position={it.pos} scale={it.scale} color={it.color} />
|
return <BroadleafTree key={i} position={it.pos} scale={it.scale} color={it.color} />
|
||||||
case 'rock':
|
case 'rock':
|
||||||
return <Rock key={i} position={it.pos} scale={it.scale} color={it.color} />
|
|
||||||
case 'grass':
|
case 'grass':
|
||||||
return <GrassTuft key={i} position={it.pos} color={it.color} />
|
return null
|
||||||
case 'stall':
|
case 'stall':
|
||||||
return <MarketStall key={i} position={it.pos} awningColor={it.awningColor} />
|
return <MarketStall key={i} position={it.pos} awningColor={it.awningColor} />
|
||||||
case 'townsfolk':
|
case 'townsfolk':
|
||||||
|
|||||||
@@ -68,11 +68,12 @@ function GlbInner({ url, scale, yOffset }: { url: string; scale: number; yOffset
|
|||||||
const s = (scene as THREE.Object3D).clone(true)
|
const s = (scene as THREE.Object3D).clone(true)
|
||||||
s.traverse((obj: any) => {
|
s.traverse((obj: any) => {
|
||||||
if (obj.isMesh) {
|
if (obj.isMesh) {
|
||||||
obj.castShadow = true
|
obj.frustumCulled = true
|
||||||
|
obj.castShadow = false
|
||||||
obj.receiveShadow = false
|
obj.receiveShadow = false
|
||||||
obj.raycast = () => {}
|
obj.raycast = () => {}
|
||||||
if (obj.material && obj.material.map) {
|
if (obj.material && obj.material.map) {
|
||||||
obj.material.map.anisotropy = 4
|
obj.material.map.anisotropy = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -130,21 +130,40 @@ export function PlaygroundHud({
|
|||||||
boxShadow: panelShadow,
|
boxShadow: panelShadow,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div className="relative">
|
||||||
className="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border"
|
<div
|
||||||
style={{
|
className="h-14 w-14 overflow-hidden rounded-full border-2"
|
||||||
borderColor: `${hudAccent}bb`,
|
style={{
|
||||||
background: `radial-gradient(circle at 35% 30%, ${HUD.parchment}55, transparent 28%), linear-gradient(180deg, ${HUD.bronze}, ${HUD.obsidian})`,
|
borderColor: worldAccent,
|
||||||
boxShadow: `0 0 18px ${hudAccent}44, inset 0 0 0 2px ${HUD.obsidian}`,
|
background: `linear-gradient(180deg, ${playerProfile.avatarConfig.outfitAccent || worldAccent}33, ${playerProfile.avatarConfig.outfit || '#0f172a'})`,
|
||||||
}}
|
boxShadow: `0 0 12px ${worldAccent}66`,
|
||||||
>
|
}}
|
||||||
<img src={HUD_SIGIL_SRC} alt="" className="h-9 w-9 object-contain opacity-90" />
|
>
|
||||||
|
<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>
|
||||||
<div className="min-w-0 leading-tight">
|
<div className="min-w-0 leading-tight">
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-[11px] font-black uppercase tracking-[0.14em]" style={{ color: HUD.parchment }}>
|
||||||
<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>
|
{playerProfile.displayName || 'Builder'}
|
||||||
<span className="text-[11px] font-black uppercase tracking-[0.14em]" style={{ color: HUD.parchment }}>{playerProfile.displayName || 'Builder'}</span>
|
</div>
|
||||||
</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-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">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { botsFor } from '../lib/playground-bots'
|
import { botsFor } from '../lib/playground-bots'
|
||||||
import type { PlaygroundWorldId } from '../lib/playground-rpg'
|
import type { PlaygroundWorldId } from '../lib/playground-rpg'
|
||||||
|
|
||||||
@@ -51,11 +52,31 @@ type Props = {
|
|||||||
|
|
||||||
export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
|
export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
|
||||||
const npcs = NPC_POSITIONS[worldId]
|
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
|
const frameAccent = worldId === 'agora' ? '#F1C56D' : worldAccent
|
||||||
// Map world coords (-30..30) to minimap pixels (0..150)
|
// Map world coords (-30..30) to minimap pixels (0..150)
|
||||||
const map = (v: number) => 75 + (v / 30) * 70
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto fixed right-[18px] top-[18px] z-[70] rounded-[22px] border p-2 text-white shadow-2xl backdrop-blur-xl"
|
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"
|
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' }}
|
style={{ left: 75, top: 75, borderColor: frameAccent + 'aa' }}
|
||||||
/>
|
/>
|
||||||
{/* Player at 0,0 (always center for now since we map world coords) */}
|
{/* 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: 75, top: 75, width: 8, height: 8, background: '#F4E9D3', boxShadow: '0 0 10px #F1C56D' }} />
|
<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 */}
|
||||||
{npcs.map((n, i) => (
|
{npcs.map((n, i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,35 +27,37 @@ import { useHermesWorldSettings } from './hermesworld-settings'
|
|||||||
* the result resolves.
|
* the result resolves.
|
||||||
*/
|
*/
|
||||||
const _glbPresence = new Map<string, 'unknown' | 'present' | 'missing'>()
|
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 [_, force] = useState(0)
|
||||||
const cached = _glbPresence.get(id)
|
const url = npcGlbUrl(id)
|
||||||
|
const cached = _glbPresence.get(url)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cached === 'present' || cached === 'missing') return
|
if (!enabled || cached === 'present' || cached === 'missing') return
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
_glbPresence.set(id, 'unknown')
|
_glbPresence.set(url, 'unknown')
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
// TanStack Start's catch-all SSRs index.html for missing static files,
|
fetch(url, { method: 'HEAD' })
|
||||||
// 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' })
|
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const ct = r.headers.get('content-type') || ''
|
const ct = r.headers.get('content-type') || ''
|
||||||
const isReal = r.ok
|
const isReal = r.ok
|
||||||
&& !ct.includes('text/html')
|
&& !ct.includes('text/html')
|
||||||
&& (ct.includes('octet-stream') || ct.includes('gltf') || ct.includes('binary') || ct === '' || ct.includes('application/'))
|
&& (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)
|
force((n) => n + 1)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
_glbPresence.set(id, 'missing')
|
_glbPresence.set(url, 'missing')
|
||||||
force((n) => n + 1)
|
force((n) => n + 1)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [id, cached])
|
}, [cached, enabled, url])
|
||||||
return cached === 'present'
|
return enabled && cached === 'present'
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAvatarConfig() {
|
function useAvatarConfig() {
|
||||||
@@ -1245,7 +1247,8 @@ function NPC({
|
|||||||
const ref = useRef<THREE.Group>(null)
|
const ref = useRef<THREE.Group>(null)
|
||||||
const base = useMemo(() => new THREE.Vector3(...position), [position])
|
const base = useMemo(() => new THREE.Vector3(...position), [position])
|
||||||
const phase = useMemo(() => Math.random() * Math.PI * 2, [])
|
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.
|
// Ambient speech bubble — cycles every ~12-22s with NPC lore lines.
|
||||||
const [ambient, setAmbient] = useState<string | null>(null)
|
const [ambient, setAmbient] = useState<string | null>(null)
|
||||||
@@ -1307,7 +1310,7 @@ function NPC({
|
|||||||
</mesh>
|
</mesh>
|
||||||
{hasGlb ? (
|
{hasGlb ? (
|
||||||
// GLB body replaces voxel meshes when /avatars-3d/<id>.glb is present.
|
// GLB body replaces voxel meshes when /avatars-3d/<id>.glb is present.
|
||||||
<PlaygroundNpcGlb npcId={npcId || avatar} />
|
<PlaygroundNpcGlb avatar={glbId} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* legs */}
|
{/* legs */}
|
||||||
@@ -1380,7 +1383,7 @@ function NPC({
|
|||||||
{/* nameplate w/ portrait chip — replaces floating PNG */}
|
{/* nameplate w/ portrait chip — replaces floating PNG */}
|
||||||
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
|
<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`}}>
|
<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>
|
<span>{name}</span>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
@@ -2141,7 +2144,7 @@ function PlayerAndCamera({
|
|||||||
{/* nameplate w/ portrait chip — "You" */}
|
{/* nameplate w/ portrait chip — "You" */}
|
||||||
<Html position={[0, 2.2, 0]} center distanceFactor={8}>
|
<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'}}>
|
<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>
|
<span>{displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
@@ -2366,7 +2369,7 @@ function BotPlayer({
|
|||||||
{/* nameplate w/ portrait chip */}
|
{/* nameplate w/ portrait chip */}
|
||||||
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
|
<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`}}>
|
<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>
|
<span>{bot.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
@@ -2744,7 +2747,7 @@ function RemotePlayer({ remote }: { remote: MpRemotePlayer }) {
|
|||||||
<Html position={[0, 1.95, 0]} center distanceFactor={8}>
|
<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'}}>
|
<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 && (
|
{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>
|
<span>{remote.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2810,10 +2813,11 @@ function Scene({
|
|||||||
objectiveTargetId: string | null
|
objectiveTargetId: string | null
|
||||||
objectivePulseKey: number
|
objectivePulseKey: number
|
||||||
}) {
|
}) {
|
||||||
const bots = botsFor(worldId)
|
const bots = useMemo(() => botsFor(worldId), [worldId])
|
||||||
const world = WORLDS_3D[worldId]
|
const world = WORLDS_3D[worldId]
|
||||||
const [settings] = useHermesWorldSettings()
|
const [settings] = useHermesWorldSettings()
|
||||||
const photosensitiveMode = settings.accessibility.photosensitiveMode
|
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 moveTarget = useRef<THREE.Vector3 | null>(null)
|
||||||
const [pingPos, setPingPos] = useState<[number, number, number] | null>(null)
|
const [pingPos, setPingPos] = useState<[number, number, number] | null>(null)
|
||||||
const [interior, setInterior] = useState<InteriorId | null>(null)
|
const [interior, setInterior] = useState<InteriorId | null>(null)
|
||||||
@@ -3038,9 +3042,7 @@ function Scene({
|
|||||||
<SummonedFamiliar playerRef={playerPos} />
|
<SummonedFamiliar playerRef={playerPos} />
|
||||||
|
|
||||||
{/* Real remote players */}
|
{/* Real remote players */}
|
||||||
{Object.values(remotePlayers)
|
{visibleRemotePlayers.map((remote) => (
|
||||||
.filter((r) => r.world === worldId && (r.interior ?? null) === null)
|
|
||||||
.map((remote) => (
|
|
||||||
<Suspense key={remote.id} fallback={null}>
|
<Suspense key={remote.id} fallback={null}>
|
||||||
<RemotePlayer remote={remote} />
|
<RemotePlayer remote={remote} />
|
||||||
</Suspense>
|
</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 ── */
|
/* ── Public component ── */
|
||||||
export function PlaygroundWorld3D({
|
export function PlaygroundWorld3D({
|
||||||
worldId,
|
worldId,
|
||||||
@@ -3117,11 +3194,12 @@ export function PlaygroundWorld3D({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Sample player position at presence cadence (~5Hz). The hook
|
// Sample player position at presence cadence (~5Hz). The hook
|
||||||
// skip-sends when delta < epsilon, so this is cheap.
|
// 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 id = window.setInterval(() => {
|
||||||
const p = { x: playerPos.current.x, y: playerPos.current.y, z: playerPos.current.z }
|
const p = { x: playerPos.current.x, y: playerPos.current.y, z: playerPos.current.z }
|
||||||
positionForMp.current = p
|
positionForMp.current = p
|
||||||
;(window as any).__hermesPlaygroundPlayerPos = p
|
;(window as any).__hermesPlaygroundPlayerPos = p
|
||||||
}, 200)
|
}, isMobile ? Math.ceil(1000 / 30) : Math.ceil(1000 / 60))
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
const { remotePlayers, online, transport, serverCount, sendChat, myName, myColor, selfId } = usePlaygroundMultiplayer({
|
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])
|
}, [sendChat, online, transport, myName, myColor, selfId, remotePlayers, serverCount])
|
||||||
|
|
||||||
|
const remotePublishRef = useRef<{ sig: string; ts: number; timer: number | null }>({ sig: '', ts: 0, timer: null })
|
||||||
useEffect(() => {
|
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])
|
}, [onRemotePlayersChange, remotePlayers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3196,7 +3294,7 @@ export function PlaygroundWorld3D({
|
|||||||
camera={{ position: [10, 12, 10], fov: 45 }}
|
camera={{ position: [10, 12, 10], fov: 45 }}
|
||||||
// Adaptive DPR: matches device pixel ratio up to 1.5 for crispness on retina,
|
// 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.
|
// 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.
|
// Render on demand when no animation needed reduces CPU when idle on title screen.
|
||||||
// Game loop still runs because useFrame components subscribe.
|
// Game loop still runs because useFrame components subscribe.
|
||||||
frameloop="always"
|
frameloop="always"
|
||||||
@@ -3212,6 +3310,8 @@ export function PlaygroundWorld3D({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
<DevFpsSampler />
|
||||||
|
<PerfDebugOverlay />
|
||||||
<EffectComposer enableNormalPass={false}>
|
<EffectComposer enableNormalPass={false}>
|
||||||
<Bloom mipmapBlur intensity={photosensitiveMode ? 0.18 : 0.78} luminanceThreshold={0.72} luminanceSmoothing={0.35} radius={0.85} />
|
<Bloom mipmapBlur intensity={photosensitiveMode ? 0.18 : 0.78} luminanceThreshold={0.72} luminanceSmoothing={0.35} radius={0.85} />
|
||||||
<ToneMapping mode={ToneMappingMode.ACES_FILMIC} />
|
<ToneMapping mode={ToneMappingMode.ACES_FILMIC} />
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function QuestDialogPanel({ npcName = 'Athena', npcTitle = 'Oracle of the
|
|||||||
<img
|
<img
|
||||||
alt={`${npcName} portrait`}
|
alt={`${npcName} portrait`}
|
||||||
src="/avatars/athena.png"
|
src="/avatars/athena.png"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.style.display = 'none'
|
event.currentTarget.style.display = 'none'
|
||||||
|
|||||||
@@ -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="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="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)]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ function Footer() {
|
|||||||
return (
|
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">
|
<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">
|
<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>
|
<span className="font-serif text-base text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4 uppercase tracking-[0.16em]">
|
<div className="flex flex-wrap gap-4 uppercase tracking-[0.16em]">
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export function usePlaygroundMultiplayer({
|
|||||||
const sameAvatar = avatarSig(cur.avatar) === avatarSig(msg.avatar)
|
const sameAvatar = avatarSig(cur.avatar) === avatarSig(msg.avatar)
|
||||||
const noChat = (cur.lastChatAt || 0) === (msg.lastChatAt || 0)
|
const noChat = (cur.lastChatAt || 0) === (msg.lastChatAt || 0)
|
||||||
if (sameWorld && sameAvatar && noChat && dx < RENDER_POS_EPSILON && dz < RENDER_POS_EPSILON && dyaw < YAW_EPSILON) {
|
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 } }
|
return { ...prev, [msg.id]: { ...cur, ts: msg.ts } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,7 @@ export function usePlaygroundMultiplayer({
|
|||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let stop = false
|
let stop = false
|
||||||
let retry = 0
|
let retry = 0
|
||||||
|
let retryTimer: number | null = null
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (stop) return
|
if (stop) return
|
||||||
try {
|
try {
|
||||||
@@ -245,7 +247,8 @@ export function usePlaygroundMultiplayer({
|
|||||||
console.log('[Hermes MP] WS close', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean })
|
console.log('[Hermes MP] WS close', { code: ev.code, reason: ev.reason, wasClean: ev.wasClean })
|
||||||
if (!stop) {
|
if (!stop) {
|
||||||
retry = Math.min(8, retry + 1)
|
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) => {
|
ws.addEventListener('error', (e) => {
|
||||||
@@ -257,7 +260,9 @@ export function usePlaygroundMultiplayer({
|
|||||||
open()
|
open()
|
||||||
return () => {
|
return () => {
|
||||||
stop = true
|
stop = true
|
||||||
|
if (retryTimer != null) window.clearTimeout(retryTimer)
|
||||||
try { ws?.close() } catch {}
|
try { ws?.close() } catch {}
|
||||||
|
wsOpenRef.current = false
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
}
|
}
|
||||||
}, [selfId, mergePresence])
|
}, [selfId, mergePresence])
|
||||||
|
|||||||
@@ -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 { PlaygroundActionBar } from './components/playground-actionbar'
|
||||||
import { PlaygroundAdminPanel } from './components/playground-admin-panel'
|
import { PlaygroundChat, type ChatMessage } from './components/playground-chat'
|
||||||
import { PlaygroundChat } from './components/playground-chat'
|
|
||||||
import { PlaygroundCustomizer } from './components/playground-customizer'
|
|
||||||
import { PlaygroundDialog } from './components/playground-dialog'
|
|
||||||
import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
|
import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
|
||||||
import { PlaygroundHud } from './components/playground-hud'
|
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 { PlaygroundMinimap } from './components/playground-minimap'
|
||||||
import { PlaygroundSidePanel } from './components/playground-sidepanel'
|
|
||||||
import { PlaygroundWorld3D } from './components/playground-world-3d'
|
import { PlaygroundWorld3D } from './components/playground-world-3d'
|
||||||
import { Toast } from './components/toast'
|
import { Toast } from './components/toast'
|
||||||
import { FpsCounter } from './components/fps-counter'
|
import { FpsCounter } from './components/fps-counter'
|
||||||
import { KeyboardShortcutsOverlay } from './components/keyboard-shortcuts-overlay'
|
import { KeyboardShortcutsOverlay } from './components/keyboard-shortcuts-overlay'
|
||||||
import { PhotosensitiveWarningSplash } from './components/photosensitive-warning-splash'
|
import { PhotosensitiveWarningSplash } from './components/photosensitive-warning-splash'
|
||||||
import { SettingsPanel } from './components/settings-panel'
|
|
||||||
import { useHermesWorldSettings } from './components/hermesworld-settings'
|
import { useHermesWorldSettings } from './components/hermesworld-settings'
|
||||||
import { usePlaygroundRpg } from './hooks/use-playground-rpg'
|
import { usePlaygroundRpg } from './hooks/use-playground-rpg'
|
||||||
import { playgroundAudio, usePlaygroundAudioMuted } from './lib/playground-audio'
|
import { playgroundAudio, usePlaygroundAudioMuted } from './lib/playground-audio'
|
||||||
import { autoNarrateWorld, cancelNarration, isNarrationMuted, narrateWorldNow, setNarrationMuted } from './lib/playground-narration'
|
import { autoNarrateWorld, cancelNarration, isNarrationMuted, narrateWorldNow, setNarrationMuted } from './lib/playground-narration'
|
||||||
import { botsFor } from './lib/playground-bots'
|
import { botsFor } from './lib/playground-bots'
|
||||||
import { PLAYGROUND_WORLDS, itemById } from './lib/playground-rpg'
|
import { PLAYGROUND_WORLDS, itemById, type PlaygroundItemId, type PlaygroundWorldId } 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 type { RemotePlayer } from './hooks/use-playground-multiplayer'
|
import type { RemotePlayer } from './hooks/use-playground-multiplayer'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace-store'
|
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 }> = {
|
const WORLD_META: Record<PlaygroundWorldId, { name: string; accent: string }> = {
|
||||||
training: { name: 'Training Grounds', accent: '#5eead4' },
|
training: { name: 'Training Grounds', accent: '#5eead4' },
|
||||||
agora: { name: 'Agora Commons', accent: '#d9b35f' },
|
agora: { name: 'Agora Commons', accent: '#d9b35f' },
|
||||||
@@ -542,12 +545,16 @@ export function PlaygroundScreen() {
|
|||||||
onCustomize={() => setCustomizerOpen(true)}
|
onCustomize={() => setCustomizerOpen(true)}
|
||||||
onEnter={() => setLaunched(true)}
|
onEnter={() => setLaunched(true)}
|
||||||
/>
|
/>
|
||||||
<PlaygroundCustomizer
|
{customizerOpen ? (
|
||||||
open={customizerOpen}
|
<LazyPanelBoundary>
|
||||||
onClose={() => setCustomizerOpen(false)}
|
<PlaygroundCustomizer
|
||||||
value={rpg.state.playerProfile.avatarConfig}
|
open={customizerOpen}
|
||||||
onChange={rpg.setAvatarConfig}
|
onClose={() => setCustomizerOpen(false)}
|
||||||
/>
|
value={rpg.state.playerProfile.avatarConfig}
|
||||||
|
onChange={rpg.setAvatarConfig}
|
||||||
|
/>
|
||||||
|
</LazyPanelBoundary>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -583,37 +590,46 @@ export function PlaygroundScreen() {
|
|||||||
objectivePulseKey={objectivePulseKey}
|
objectivePulseKey={objectivePulseKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaygroundDialog
|
{dialogNpc ? (
|
||||||
npcId={dialogNpc}
|
<LazyPanelBoundary>
|
||||||
activeQuest={activeQuest ?? null}
|
<PlaygroundDialog
|
||||||
onClose={() => setDialogNpc(null)}
|
npcId={dialogNpc}
|
||||||
onCompleteQuest={(questId) => rpg.completeQuestById(questId)}
|
activeQuest={activeQuest ?? null}
|
||||||
onGrantItems={(items) => rpg.grantItems(items)}
|
onClose={() => setDialogNpc(null)}
|
||||||
onGrantSkillXp={(skills) => rpg.grantSkillXp(skills)}
|
onCompleteQuest={(questId) => rpg.completeQuestById(questId)}
|
||||||
onChoice={onDialogChoice}
|
onGrantItems={(items) => rpg.grantItems(items)}
|
||||||
/>
|
onGrantSkillXp={(skills) => rpg.grantSkillXp(skills)}
|
||||||
<PlaygroundJournal open={journalOpen} onClose={() => setJournalOpen(false)} state={rpg.state} />
|
onChoice={onDialogChoice}
|
||||||
<PlaygroundCustomizer
|
/>
|
||||||
open={customizerOpen}
|
</LazyPanelBoundary>
|
||||||
onClose={() => setCustomizerOpen(false)}
|
) : null}
|
||||||
value={rpg.state.playerProfile.avatarConfig}
|
{journalOpen ? (
|
||||||
onChange={rpg.setAvatarConfig}
|
<LazyPanelBoundary><PlaygroundJournal open={journalOpen} onClose={() => setJournalOpen(false)} state={rpg.state} /></LazyPanelBoundary>
|
||||||
/>
|
) : null}
|
||||||
<PlaygroundMap
|
{customizerOpen ? (
|
||||||
open={mapOpen}
|
<LazyPanelBoundary>
|
||||||
onClose={() => setMapOpen(false)}
|
<PlaygroundCustomizer open={customizerOpen} onClose={() => setCustomizerOpen(false)} value={rpg.state.playerProfile.avatarConfig} onChange={rpg.setAvatarConfig} />
|
||||||
currentWorld={world}
|
</LazyPanelBoundary>
|
||||||
unlocked={rpg.state.unlockedWorlds}
|
) : null}
|
||||||
onTravel={(id) => {
|
{mapOpen ? (
|
||||||
if (!rpg.state.unlockedWorlds.includes(id)) return
|
<LazyPanelBoundary>
|
||||||
setTransitioning(true)
|
<PlaygroundMap
|
||||||
window.setTimeout(() => {
|
open={mapOpen}
|
||||||
setWorld(id)
|
onClose={() => setMapOpen(false)}
|
||||||
setMapOpen(false)
|
currentWorld={world}
|
||||||
window.setTimeout(() => setTransitioning(false), 350)
|
unlocked={rpg.state.unlockedWorlds}
|
||||||
}, 280)
|
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
|
<PlaygroundChat
|
||||||
worldId={world}
|
worldId={world}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@@ -660,9 +676,10 @@ export function PlaygroundScreen() {
|
|||||||
/>
|
/>
|
||||||
{/* Online chip removed — the chat header now shows live player count + NPC count. */}
|
{/* Online chip removed — the chat header now shows live player count + NPC count. */}
|
||||||
{!focusMode && <NearbyBuildersChip players={remotePlayersInZone} />}
|
{!focusMode && <NearbyBuildersChip players={remotePlayersInZone} />}
|
||||||
{!focusMode && (
|
{!focusMode && (!isNarrow || mobileMenuOpen) ? (
|
||||||
<PlaygroundSidePanel
|
<LazyPanelBoundary>
|
||||||
state={rpg.state}
|
<PlaygroundSidePanel
|
||||||
|
state={rpg.state}
|
||||||
currentWorld={world}
|
currentWorld={world}
|
||||||
worlds={PLAYGROUND_WORLDS}
|
worlds={PLAYGROUND_WORLDS}
|
||||||
onSelectWorld={(next) => {
|
onSelectWorld={(next) => {
|
||||||
@@ -686,8 +703,51 @@ export function PlaygroundScreen() {
|
|||||||
worldAccent={WORLD_META[world].accent}
|
worldAccent={WORLD_META[world].accent}
|
||||||
open={!isNarrow || mobileMenuOpen}
|
open={!isNarrow || mobileMenuOpen}
|
||||||
onOpenChange={setMobileMenuOpen}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileMenuOpen(true)}
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
@@ -699,9 +759,17 @@ export function PlaygroundScreen() {
|
|||||||
<MobileAbilityControls />
|
<MobileAbilityControls />
|
||||||
<OnboardingHintCard open={onboardingHintOpen} />
|
<OnboardingHintCard open={onboardingHintOpen} />
|
||||||
<PhotosensitiveWarningSplash onOpenSettings={() => setSettingsOpen(true)} />
|
<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} />
|
<PlaygroundHelpHud worldName={WORLD_META[world].name} />
|
||||||
{adminMode ? <PlaygroundAdminPanel /> : null}
|
{adminMode ? (
|
||||||
|
<LazyPanelBoundary>
|
||||||
|
<PlaygroundAdminPanel />
|
||||||
|
</LazyPanelBoundary>
|
||||||
|
) : null}
|
||||||
<PlaygroundUtilityDock
|
<PlaygroundUtilityDock
|
||||||
audioMuted={audioMuted}
|
audioMuted={audioMuted}
|
||||||
narrationMuted={narrationMuted}
|
narrationMuted={narrationMuted}
|
||||||
@@ -916,6 +984,8 @@ function TitleScreen({
|
|||||||
alt="HermesWorld"
|
alt="HermesWorld"
|
||||||
width={760}
|
width={760}
|
||||||
height={228}
|
height={228}
|
||||||
|
fetchPriority="high"
|
||||||
|
decoding="async"
|
||||||
className="mt-2 w-[min(760px,82vw)] max-w-full"
|
className="mt-2 w-[min(760px,82vw)] max-w-full"
|
||||||
style={{
|
style={{
|
||||||
filter:
|
filter:
|
||||||
|
|||||||