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>
226 lines
10 KiB
TypeScript
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} />
|
|
);
|
|
}
|