Files
hermes-workspace/src/screens/playground/components/playground-minimap.tsx
Eric e1470084d2 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.
2026-05-14 13:38:23 -04:00

159 lines
6.0 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { botsFor } from '../lib/playground-bots'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
const NPC_POSITIONS: Record<PlaygroundWorldId, Array<{ x: number; z: number; color: string }>> = {
training: [
{ x: -9, z: 7, color: '#a78bfa' },
{ x: -3, z: 0, color: '#22d3ee' },
{ x: 8, z: -4, color: '#f59e0b' },
],
agora: [
{ x: -5, z: 2, color: '#F1C56D' },
{ x: 5, z: 3, color: '#B8862B' },
{ x: -3, z: -5, color: '#2E6A63' },
{ x: 6, z: -4, color: '#F4E9D3' },
],
forge: [
{ x: -4, z: 0, color: '#34d399' },
{ x: 4, z: 0, color: '#facc15' },
],
grove: [
{ x: -4, z: 1, color: '#34d399' },
{ x: 4, z: 0, color: '#f59e0b' },
{ x: 0, z: -5, color: '#9ca3af' },
],
oracle: [
{ x: -3, z: -2, color: '#a78bfa' },
{ x: 3, z: -2, color: '#facc15' },
{ x: 0, z: 4, color: '#f472b6' },
],
arena: [
{ x: -3, z: 4, color: '#fb7185' },
{ x: 3, z: 4, color: '#2dd4bf' },
{ x: 0, z: -5, color: '#facc15' },
],
}
const PORTAL_POSITION: Record<PlaygroundWorldId, { x: number; z: number }> = {
training: { x: 14, z: -10 },
agora: { x: 10, z: -2 },
forge: { x: 10, z: -2 },
grove: { x: 10, z: -2 },
oracle: { x: 10, z: -2 },
arena: { x: 10, z: -2 },
}
type Props = {
worldId: PlaygroundWorldId
worldName: string
worldAccent: string
}
export function PlaygroundMinimap({ worldId, worldName, worldAccent }: Props) {
const npcs = NPC_POSITIONS[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"
style={{
borderColor: `${frameAccent}72`,
background: 'linear-gradient(180deg, rgba(15,22,34,.9), rgba(10,13,18,.84)), radial-gradient(circle at 50% 0%, rgba(241,197,109,.16), transparent 62%)',
boxShadow: `0 18px 42px rgba(0,0,0,.62), 0 0 24px ${frameAccent}2f, inset 0 1px 0 rgba(244,233,211,.12)`,
}}
>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-[10px] font-bold uppercase tracking-[0.16em]" style={{ color: frameAccent }}>{worldName}</span>
<span style={{ borderColor: `${frameAccent}55`, background: "rgba(244,233,211,.06)" }} className="rounded border px-1.5 py-0.5 text-[9px] font-black uppercase tracking-[0.12em] text-[#F4E9D3]/70">M</span>
</div>
<div
className="relative h-[148px] w-[148px] overflow-hidden rounded-[14px] border"
style={{
borderColor: `${frameAccent}40`,
background:
'radial-gradient(circle at 50% 50%, rgba(241,197,109,.18), rgba(46,106,99,.13) 42%, rgba(10,13,18,.86) 72%), repeating-linear-gradient(0deg, rgba(244,233,211,.045) 0 1px, transparent 1px 18px), repeating-linear-gradient(90deg, rgba(244,233,211,.045) 0 1px, transparent 1px 18px)',
}}
>
{/* Center medallion */}
<div
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 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
key={i}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
left: map(n.x),
top: map(n.z),
width: 6,
height: 6,
background: n.color,
boxShadow: `0 0 4px ${n.color}`,
}}
/>
))}
{/* Bots */}
{bots.map((b, i) => (
<div
key={`b-${i}`}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-sm"
style={{
left: map(b.spawn[0]),
top: map(b.spawn[2]),
width: 5,
height: 5,
background: b.color,
boxShadow: `0 0 4px ${b.color}`,
}}
/>
))}
{/* Portal */}
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full border"
style={{
left: map(PORTAL_POSITION[worldId].x),
top: map(PORTAL_POSITION[worldId].z),
width: 10,
height: 10,
borderColor: '#2E6A63',
background: '#2E6A6355',
boxShadow: '0 0 8px #2E6A63',
}}
/>
</div>
<div className="mt-1 flex justify-between px-1 text-[8px] uppercase tracking-[0.12em] text-[#F4E9D3]/45">
<span> You</span>
<span style={{ color: frameAccent }}> Portal</span>
</div>
</div>
)
}