Files
two-keys/client/src/engines/LabyrinthGame.tsx
T
joshuaandClaude Opus 5.5 bbe44e84c4 V1.1.0
Add ten new games (18 in total) and the engine features they need.

Story: Dead Air, The Masquerade at Montague House, The Kalahari Expedition,
The Alchemist's Vault, Submerged: Deep Lab Nereid, The Kestrel Bank Job.
Games can now set their own hint penalty and a hard time limit, shown as a
countdown; running out ends the game with the fail ending.

Defuse: Tick Tock: Overtime. Manuals are now rulesets, so each game has its
own solver. Adds indicator lights, per-level strike limits, and the
Passwords, Morse Light and Say It Back modules.

Sync: In Sync: Deep Cuts. Adds telepathy (typed word) and ranking rounds,
and a per-round bar for the key.

Labyrinth: The Drowned Catacombs and The Mirror Maze. Adds one-way gates, a
relative (no compass) Explorer, a mirrored Navigator map, and mazes with a
lantern count other than three. Validation now rejects a maze where a gate
can leave the Explorer stuck.

Difficulty runs from 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:34:11 +02:00

226 lines
10 KiB
TypeScript

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 (
<div className="flex items-center gap-1" aria-label={`${carried} of ${total} lanterns carried`}>
{Array.from({ length: total }, (_, i) => (
<span key={i} aria-hidden className={`text-2xl ${i < carried ? '' : 'opacity-25 grayscale'}`}>
🏮
</span>
))}
</div>
);
}
// ---------- Navigator: the whole map, but not where the Explorer is ----------
const GATE_ARROW: Record<Dir, string> = { 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 (
<div className="space-y-4">
{view.banner && (
<p className="rounded-xl border border-accent/50 bg-accent/10 px-4 py-3 text-center text-sm font-semibold text-accent">
🪞 {view.banner}
</p>
)}
<div className="flex items-center justify-between gap-3">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-right text-sm text-stone-300">
{view.moves !== null && <div>{view.moves} moves</div>}
{view.falls > 0 && <div className="text-red-300">{view.falls} trap{view.falls === 1 ? '' : 's'} sprung</div>}
</div>
</div>
<div
className="mx-auto grid w-full max-w-md gap-px overflow-hidden rounded-lg border-2 border-accent/40 bg-black/60 p-1"
style={{ gridTemplateColumns: `repeat(${width}, minmax(0, 1fr))` }}
role="img"
aria-label="Map of the labyrinth. The legend below lists every glyph, lantern, trap and the exit."
>
{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 (
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
{content}
</div>
);
}),
)}
</div>
<p className="text-center text-sm text-stone-400">
You can't see {explorerName}. Ask what's on the floor and which ways are open, then work out where they are.
</p>
<div className="card">
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Map legend</h3>
<ul className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{view.glyphs.map((g) => {
const [symbol, text] = splitLabel(g.label);
return (
<li key={`${g.x},${g.y}`} className="flex gap-2">
<span className="w-6 text-center" aria-hidden>{symbol}</span>
{text}
</li>
);
})}
{view.lanterns.map((l) => (
<li key={l.name} className={`flex gap-2 ${l.collected ? 'text-stone-500 line-through' : ''}`}>
<span className="w-6 text-center" aria-hidden>🏮</span>
{l.name}
{l.collected && <span className="no-underline"> ✓</span>}
</li>
))}
{view.oneWay.length > 0 && (
<li className="flex gap-2 text-accent"><span className="w-6 text-center" aria-hidden>→</span>One-way gate. Only opens the way the arrow points.</li>
)}
<li className="flex gap-2 text-red-300"><span className="w-6 text-center" aria-hidden>☠</span>Trap. They can't see it!</li>
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>🚪</span>Exit (needs all {view.totalKeys} lanterns)</li>
<li className="flex gap-2 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
</ul>
</div>
</div>
);
}
// ---------- 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 (
<div className="space-y-5">
<div className="flex items-center justify-between">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-sm text-stone-300">{view.moves} moves</div>
</div>
<div
className="rounded-2xl border border-accent/30 bg-[radial-gradient(circle_at_center,rgb(var(--accent)/0.22),transparent_70%)] p-6 text-center"
aria-live="polite"
>
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Under your feet</div>
{view.glyphLabel ? (
<>
<div className="mt-2 text-7xl" aria-hidden>{symbol}</div>
<p className="mt-2 font-display text-2xl font-bold">{text}</p>
</>
) : (
<div className="mt-2 text-6xl opacity-60" aria-hidden>🕯️</div>
)}
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
<p className="mt-3 text-sm text-stone-400">
Open passages: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
</p>
{gated.length > 0 && (
<p className="mt-1 text-sm text-stone-400">
Gate (won't open from this side): <strong className="text-stone-200">{gated.join(', ')}</strong>
</p>
)}
</div>
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
{controls.map((d) => {
const gate = view.gates.includes(d.dir);
const blocked = gate ? " (gate, won't open from this side)" : ' (wall)';
return (
<button
key={d.dir}
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
disabled={!view.open[d.dir]}
onClick={() => send('maze:move', { dir: d.dir })}
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : blocked}`}
>
{gate ? '⛩' : d.arrow}
{relative && <span className="text-[10px] font-semibold uppercase tracking-wide">{d.label}</span>}
</button>
);
})}
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>{relative ? '👣' : '🧭'}</div>
</div>
<p className="text-center text-sm text-stone-400">
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.'}
</p>
</div>
);
}
export function LabyrinthGame({ view, state, send }: Props) {
return view.seat === 'navigator' ? (
<NavigatorMap view={view} explorerName={state.game.roles.B.name} />
) : (
<ExplorerCell view={view} send={send} navigatorName={state.game.roles.A.name} />
);
}