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 (
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 (
send('maze:move', { dir: d.dir })}
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : blocked}`}
>
{gate ? '⛩' : d.arrow}
{relative && {d.label} }
);
})}
{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' ? (
) : (
);
}