import { useEffect } from 'react'; import type { Dir, ExplorerView, LabyrinthView, MoveDir, NavigatorView, RoomState } from '@two-keys/shared'; import type { Send } from '../lib/socket'; interface Props { view: LabyrinthView; state: RoomState; send: Send; } /** "☀ A carved sun (the entrance)" → ["☀", "A carved sun (the entrance)"] */ const splitLabel = (label: string): [string, string] => { const i = label.indexOf(' '); return i === -1 ? [label, ''] : [label.slice(0, i), label.slice(i + 1)]; }; function Lanterns({ carried, total }: { carried: number; total: number }) { return (
{Array.from({ length: total }, (_, i) => ( 🏮 ))}
); } // ---------- Navigator: the whole map, but not where the Explorer is ---------- const GATE_ARROW: Record = { n: '↑', e: '→', s: '↓', w: '←' }; function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) { const width = view.grid[0].length; const gateAt = new Map(view.oneWay.map((g) => [`${g.x},${g.y}`, g])); const glyphAt = new Map(view.glyphs.map((g) => [`${g.x},${g.y}`, g])); const lanternAt = new Map(view.lanterns.map((l) => [`${l.x},${l.y}`, l])); return (
{view.banner && (

🪞 {view.banner}

)}
{view.moves !== null &&
{view.moves} moves
} {view.falls > 0 &&
{view.falls} trap{view.falls === 1 ? '' : 's'} sprung
}
{view.grid.flatMap((row, y) => [...row].map((cell, x) => { const key = `${x},${y}`; const glyph = glyphAt.get(key); const lantern = lanternAt.get(key); let content = ''; let tone = 'bg-stone-800/40'; if (cell === '#') tone = 'bg-stone-700'; else if (cell === 'T') { content = '☠'; tone = 'bg-red-950 text-red-400'; } else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; } else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; } else if (glyph) content = splitLabel(glyph.label)[0]; const gate = gateAt.get(key); if (gate && cell !== '#') { content += GATE_ARROW[gate.dir]; if (tone === 'bg-stone-800/40') tone = 'bg-sky-950 font-bold text-accent'; } return (
{content}
); }), )}

You can't see {explorerName}. Ask what's on the floor and which ways are open, then work out where they are.

Map legend

    {view.glyphs.map((g) => { const [symbol, text] = splitLabel(g.label); return (
  • {symbol} {text}
  • ); })} {view.lanterns.map((l) => (
  • 🏮 {l.name} {l.collected && ✓}
  • ))} {view.oneWay.length > 0 && (
  • →One-way gate. Only opens the way the arrow points.
  • )}
  • ☠Trap. They can't see it!
  • 🚪Exit (needs all {view.totalKeys} lanterns)
  • ▪Most floor is bare stone
); } // ---------- Explorer: one cell, four directions ---------- type Control = { dir: MoveDir; label: string; arrow: string; area: string; keys: string[] }; const DIRS: Control[] = [ { dir: 'n', label: 'North', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] }, { dir: 'w', label: 'West', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] }, { dir: 'e', label: 'East', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] }, { dir: 's', label: 'South', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] }, ]; // No compass: the same pad, but relative to the way the Explorer is facing. const RELATIVE_DIRS: Control[] = [ { dir: 'f', label: 'Forward', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] }, { dir: 'l', label: 'Left', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] }, { dir: 'r', label: 'Right', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] }, { dir: 'b', label: 'Back', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] }, ]; function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) { const relative = view.compass === 'relative'; const controls = relative ? RELATIVE_DIRS : DIRS; // Arrow keys / WASD move too (ignored while typing in the notepad). useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return; const match = controls.find((d) => d.keys.includes(e.key)); if (!match || !view.open[match.dir]) return; e.preventDefault(); send('maze:move', { dir: match.dir }); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [view.open, controls, send]); const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', '']; const open = controls.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase()); const gated = controls.filter((d) => view.gates.includes(d.dir)).map((d) => d.label.toLowerCase()); return (
{view.moves} moves
Under your feet
{view.glyphLabel ? ( <>
{symbol}

{text}

) : (
🕯️
)} {view.message &&

{view.message}

}

Open passages: {open.join(', ') || 'none'}

{gated.length > 0 && (

Gate (won't open from this side): {gated.join(', ')}

)}
{controls.map((d) => { const gate = view.gates.includes(d.dir); const blocked = gate ? " (gate, won't open from this side)" : ' (wall)'; return ( ); })}
{relative ? '👣' : '🧭'}

Tell {navigatorName} what's on the floor and which ways are open. There are traps you can't see. Wait for directions! {relative && ' You have no compass: every step turns you to face the way you just went.'}

); } export function LabyrinthGame({ view, state, send }: Props) { return view.seat === 'navigator' ? ( ) : ( ); }