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>
This commit is contained in:
2026-10-13 23:34:11 +02:00
co-authored by Claude Opus 5.5
parent 62245ca67a
commit bbe44e84c4
38 changed files with 4201 additions and 197 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/client",
"version": "1.0.1",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+3 -3
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
import { getBest, saveSession } from '../lib/session';
import { request } from '../lib/socket';
import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme';
import { CATEGORY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme';
import { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
@@ -39,9 +39,9 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam
<div className="flex flex-wrap items-center gap-2">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of 3`}>
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}`}>
{difficultyKeys(game.difficulty)}
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
</span>
</div>
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
+5 -4
View File
@@ -1,15 +1,16 @@
import { useState } from 'react';
import type { LockView } from '@two-keys/shared';
import { formatClock, type LockView } from '@two-keys/shared';
import type { Send } from '../lib/socket';
import { Sheet } from './ui';
/** Hints unlock in order, cost +2:00 each, and both players see them. */
/** Hints unlock in order, cost penalty time each, and both players see them. */
export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const used = lock.hints.length;
const left = lock.hintsTotal - used;
const nextIsLast = used === lock.hintsTotal - 1;
const cost = `+${formatClock(lock.hintPenaltySeconds)}`;
const reveal = () => {
setConfirming(false);
@@ -29,7 +30,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
<p className="mb-4 text-sm text-stone-400">
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">+2:00</strong> to
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">{cost}</strong> to
your time, and you both see it.
</p>
<ol className="space-y-2">
@@ -53,7 +54,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
</div>
) : (
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
Reveal hint {used + 1} of {lock.hintsTotal} ({cost})
</button>
)}
</Sheet>
+25 -1
View File
@@ -1,12 +1,36 @@
import { formatClock, type RoomState } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
/** Elapsed time plus penalties. Runs locally between server updates. */
/** Elapsed time plus penalties, or a countdown when the game has a time limit. Runs locally between server updates. */
export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000;
if (timer.limitSeconds !== null) {
const used = elapsed + timer.penaltySeconds;
const left = Math.max(0, timer.limitSeconds - used);
const label = timer.limitLabel ?? 'Time left';
return (
<div className="flex shrink-0 flex-col items-end" role="timer" aria-label={`${label}: ${formatClock(left)} left`}>
<div className="flex items-baseline gap-1.5">
<span className="max-w-[7.5rem] truncate text-[10px] font-semibold uppercase tracking-wider text-stone-400">{label}</span>
<span className={`font-mono text-base font-bold tabular-nums ${left <= 300 ? 'text-red-300' : ''}`}>
{formatClock(left)}
</span>
</div>
{/* Fills up as the time (and every hint penalty) runs out. */}
<div className="mt-0.5 h-1 w-28 overflow-hidden rounded-full bg-white/10" aria-hidden>
<div
className={`h-full ${left <= 300 ? 'bg-red-400' : 'bg-accent'}`}
style={{ width: `${Math.min(100, (used / timer.limitSeconds) * 100)}%` }}
/>
</div>
</div>
);
}
return (
<div
className="flex items-baseline gap-1.5 font-mono tabular-nums"
+170 -12
View File
@@ -1,12 +1,17 @@
import { useEffect, useState } from 'react';
import type {
ButtonModuleView,
DefuseAction,
DefuseView,
KeypadModuleView,
MorseModuleView,
PasswordsModuleView,
RoomState,
SimonModuleView,
TechnicianView,
WiresModuleView,
} from '@two-keys/shared';
import { SignalLamp } from '../components/ClueCard';
import { Markdown } from '../components/ui';
import { useNow } from '../lib/hooks';
import type { Send } from '../lib/socket';
@@ -25,6 +30,7 @@ const SWATCH: Record<string, string> = {
yellow: '#facc15',
green: '#22c55e',
white: '#f5f5f4',
black: '#0c0a09',
};
const darkText = (color: string) => color === 'yellow' || color === 'white';
@@ -98,7 +104,7 @@ function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction
{module.wires.map((color, i) => (
<li key={i} className="flex items-center gap-2">
<span className="w-5 font-mono text-sm text-stone-400">{i + 1}</span>
<span className="h-3 flex-1 rounded-full shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
<span className="h-3 flex-1 rounded-full border border-white/25 shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
<span className="w-16 font-mono text-sm font-bold uppercase">{color}</span>
<button
className="btn !min-h-[40px] px-3 text-sm"
@@ -187,10 +193,137 @@ function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseActi
);
}
/** Five letter columns to cycle. The word list is in the manual, not here. */
function Passwords({ module, act }: { module: PasswordsModuleView; act: (a: DefuseAction) => void }) {
const [rows, setRows] = useState<number[]>(() => module.columns.map(() => 0));
const word = module.columns.map((letters, i) => letters[rows[i]]).join('');
const turn = (column: number, by: number) =>
setRows((r) => r.map((row, i) => (i === column ? (row + by + module.columns[i].length) % module.columns[i].length : row)));
return (
<ModuleFrame title="Passwords" solved={module.solved}>
<div className="flex justify-center gap-1.5">
{module.columns.map((letters, i) => (
<div key={i} className="flex flex-col items-center gap-1">
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, -1)} aria-label={`Column ${i + 1}, previous letter`}>
▲
</button>
<div
className="flex h-12 w-11 items-center justify-center rounded-lg border-2 border-stone-500 bg-black font-mono text-2xl font-bold"
aria-label={`Column ${i + 1}: ${letters[rows[i]]}, letter ${rows[i] + 1} of ${letters.length}`}
>
{letters[rows[i]]}
</div>
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, 1)} aria-label={`Column ${i + 1}, next letter`}>
▼
</button>
<span className="font-mono text-[10px] text-stone-500" aria-hidden>
{rows[i] + 1}/{letters.length}
</span>
</div>
))}
</div>
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'submit', word })}>
Submit {word}
</button>
</ModuleFrame>
);
}
/** A blinking light and a dial. The pattern is only ever shown as light. */
function Morse({ module, act }: { module: MorseModuleView; act: (a: DefuseAction) => void }) {
const [khz, setKhz] = useState(module.minKhz);
const tune = (by: number) => setKhz((f) => Math.min(module.maxKhz, Math.max(module.minKhz, f + by)));
const mhz = (khz / 1000).toFixed(3);
const steps: [string, number][] = [['−10', -10], ['−1', -1], ['+1', 1], ['+10', 10]];
return (
<ModuleFrame title="Morse Light" solved={module.solved}>
{!module.solved && <SignalLamp pattern={module.signal} />}
<div className="rounded-lg border-2 border-stone-600 bg-black px-3 py-2 text-center font-mono text-2xl font-bold tabular-nums" aria-live="polite">
{mhz} <span className="text-sm text-stone-400">MHz</span>
</div>
<input
type="range"
className="mt-3 w-full accent-[rgb(var(--accent))]"
min={module.minKhz}
max={module.maxKhz}
step={1}
value={khz}
disabled={module.solved}
onChange={(e) => setKhz(Number(e.target.value))}
aria-label="Frequency dial"
aria-valuetext={`${mhz} megahertz`}
/>
<div className="mt-2 grid grid-cols-4 gap-2">
{steps.map(([label, by]) => (
<button key={label} className="btn !min-h-[40px] px-0 font-mono text-sm" disabled={module.solved} onClick={() => tune(by)} aria-label={`${by > 0 ? 'Up' : 'Down'} ${Math.abs(by)} kilohertz`}>
{label}
</button>
))}
</div>
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'transmit', frequency: mhz })}>
TX on {mhz}
</button>
</ModuleFrame>
);
}
/** Pads that flash a sequence, then take the answer. Every pad carries its colour name. */
function Simon({ module, act }: { module: SimonModuleView; act: (a: DefuseAction) => void }) {
const [lit, setLit] = useState<string | null>(null);
const flashes = module.flashes.join(',');
useEffect(() => {
if (module.solved) return setLit(null);
const sequence = flashes.split(',');
let timer: ReturnType<typeof setTimeout>;
let i = 0;
// Each flash 600ms on, 300ms off, then a long pause before the sequence repeats.
const show = () => {
setLit(sequence[i]);
timer = setTimeout(hide, 600);
};
const hide = () => {
setLit(null);
i = (i + 1) % sequence.length;
timer = setTimeout(show, i === 0 ? 1800 : 300);
};
timer = setTimeout(show, 800);
return () => clearTimeout(timer);
}, [flashes, module.solved]);
return (
<ModuleFrame title="Say It Back" solved={module.solved}>
<div className="mb-3 flex items-center justify-between font-mono text-xs text-stone-400" aria-live="polite">
<span>Stage {module.stage} of {module.stages}</span>
<span>{module.entered} of {module.flashes.length} pressed</span>
</div>
<div className="grid grid-cols-2 gap-2">
{module.colors.map((color) => (
<button
key={color}
className={`flex h-20 flex-col items-center justify-center rounded-xl border-4 font-mono text-sm font-bold uppercase transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
lit === color ? 'scale-105 border-white opacity-100 shadow-[0_0_24px_4px_rgba(255,255,255,0.45)]' : 'border-black/50 opacity-45'
} ${darkText(color) ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: SWATCH[color] ?? color }}
disabled={module.solved}
onClick={() => act({ type: 'press', color })}
aria-label={`Press ${color}${lit === color ? ' (flashing now)' : ''}`}
>
{color}
<span className="text-[10px]" aria-hidden>{lit === color ? '● FLASH' : '\u00a0'}</span>
</button>
))}
</div>
</ModuleFrame>
);
}
function Device({ view, send }: { view: TechnicianView; send: Send }) {
if (!view.device) return null;
const act = (moduleId: string) => (action: DefuseAction) => send('defuse:action', { moduleId, action });
const { serial, batteries, modules } = view.device;
const { serial, batteries, indicators, modules } = view.device;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 rounded-2xl border-2 border-stone-600 bg-stone-900/70 p-4 font-mono">
@@ -204,16 +337,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
</div>
</div>
{indicators.length > 0 && (
<div className="col-span-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Indicators</div>
<ul className="mt-1 flex flex-wrap gap-2">
{indicators.map((i) => (
<li
key={i.label}
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-bold ${
i.lit ? 'border-amber-300 bg-amber-300/15 text-amber-200' : 'border-stone-600 text-stone-400'
}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${i.lit ? 'bg-amber-300 shadow-[0_0_8px_2px_rgba(252,211,77,0.7)]' : 'bg-stone-700'}`} aria-hidden />
{i.label} <span className="text-[10px] font-normal uppercase">{i.lit ? 'lit' : 'unlit'}</span>
</li>
))}
</ul>
</div>
)}
</div>
{modules.map((m) =>
m.id === 'wires' ? (
<Wires key={m.id} module={m} act={act(m.id)} />
) : m.id === 'button' ? (
<BigButton key={m.id} module={m} act={act(m.id)} />
) : (
<Keypad key={m.id} module={m} act={act(m.id)} />
),
)}
{modules.map((m) => {
switch (m.id) {
case 'wires':
return <Wires key={m.id} module={m} act={act(m.id)} />;
case 'button':
return <BigButton key={m.id} module={m} act={act(m.id)} />;
case 'keypad':
return <Keypad key={m.id} module={m} act={act(m.id)} />;
case 'passwords':
return <Passwords key={m.columns.join('')} module={m} act={act(m.id)} />;
case 'morse':
return <Morse key={m.signal} module={m} act={act(m.id)} />;
case 'simon':
return <Simon key={m.id} module={m} act={act(m.id)} />;
}
})}
</div>
);
}
@@ -250,7 +408,7 @@ export function DefuseGame({ view, state, receivedAt, send }: Props) {
<div className="text-5xl" aria-hidden>💥</div>
<h2 className="mt-2 font-display text-2xl font-bold">Boom.</h2>
<p className="mt-1 text-stone-200">
{view.failReason === 'time' ? 'The clock ran out.' : 'Three strikes.'} Good thing it was only confetti.
{view.failReason === 'time' ? 'The clock ran out.' : `${view.maxStrikes} strikes.`} Good thing it was only confetti.
</p>
<p className="mt-1 text-sm text-stone-400">You keep your keys. A fresh device is waiting.</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
+56 -17
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
import type { Dir, ExplorerView, LabyrinthView, MoveDir, NavigatorView, RoomState } from '@two-keys/shared';
import type { Send } from '../lib/socket';
interface Props {
@@ -28,13 +28,21 @@ function Lanterns({ carried, total }: { carried: number; total: number }) {
// ---------- 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">
@@ -61,6 +69,11 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
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}
@@ -93,6 +106,9 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
{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>
@@ -104,30 +120,42 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
// ---------- Explorer: one cell, four directions ----------
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
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 = DIRS.find((d) => d.keys.includes(e.key));
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, send]);
}, [view.open, controls, send]);
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
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">
@@ -153,25 +181,36 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
<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">
{DIRS.map((d) => (
<button
key={d.dir}
className={`btn aspect-square 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] ? '' : ' (wall)'}`}
>
{d.arrow}
</button>
))}
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>🧭</div>
{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>
);
+130 -33
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import type { Role, RoomState, SyncView } from '@two-keys/shared';
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { RoleBadge } from '../components/ui';
import type { Send } from '../lib/socket';
@@ -34,7 +35,7 @@ function Marks({ view }: { view: SyncView }) {
}
/** One answer card that flips over when both answers are in. */
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) {
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: ReactNode; delay: number }) {
return (
<div className="flex-1 [perspective:800px]">
<motion.div
@@ -50,6 +51,77 @@ function FlipCard({ role, name, answer, delay }: { role: Role; name: string; ans
);
}
/** Telepathy: one typed word, no options to lean on. */
function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
const [text, setText] = useState('');
const submit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) onSubmit(text.trim());
};
return (
<form onSubmit={submit} className="flex gap-2">
<input
className="field text-lg"
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={40}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
placeholder="One word"
aria-label="Your word"
/>
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
Lock in
</button>
</form>
);
}
/** Ranking: tap the items in order, most to least. Tap a placed one to take it back out. */
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
const [order, setOrder] = useState<number[]>([]);
const toggle = (i: number) => setOrder((o) => (o.includes(i) ? o.filter((x) => x !== i) : [...o, i]));
return (
<div className="space-y-2">
<div className="grid gap-2" role="group" aria-label="Tap in order, most to least">
{items.map((item, i) => {
const place = order.indexOf(i);
return (
<button
key={i}
onClick={() => toggle(i)}
aria-pressed={place >= 0}
aria-label={place >= 0 ? `${item}, ranked number ${place + 1}. Tap to remove.` : `${item}, not ranked yet`}
className={`flex min-h-[52px] items-center gap-3 rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
place >= 0 ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
place >= 0 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-500'
}`}
aria-hidden
>
{place >= 0 ? place + 1 : '·'}
</span>
{item}
</button>
);
})}
</div>
<div className="flex gap-2">
<button className="btn" disabled={order.length === 0} onClick={() => setOrder([])}>
Start over
</button>
<button className="btn-primary flex-1" disabled={order.length !== items.length} onClick={() => onSubmit(order)}>
{order.length === items.length ? 'Lock in this order' : `Tap ${items.length - order.length} more`}
</button>
</div>
</div>
);
}
export function SyncGame({ view, state, role, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
@@ -108,8 +180,33 @@ export function SyncGame({ view, state, role, send }: Props) {
const q = view.question;
if (!q) return null;
const predict = view.round.type === 'predict';
const { type } = view.round;
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
const answered = view.myAnswer !== null;
let instruction: string;
if (type === 'match') instruction = 'Pick what you think your partner will pick.';
else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
else if (type === 'rank') {
instruction = view.iAmSubject
? "This one's about you. Tap them in order, most to least. Be honest!"
: `Tap them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
/** An answer as the reveal cards show it. */
const show = (answer: SyncAnswer): ReactNode => {
if (typeof answer === 'string') return answer;
if (typeof answer === 'number') return q.options[answer];
return (
<ol className="space-y-0.5 text-left text-base">
{answer.map((item, place) => (
<li key={item} className={place === 0 || place === answer.length - 1 ? '' : 'font-normal text-stone-400'}>
{place + 1}. {q.options[item]}
</li>
))}
</ol>
);
};
return (
<div className="space-y-5">
@@ -120,39 +217,39 @@ export function SyncGame({ view, state, role, send }: Props) {
Question {view.questionNumber} of {view.questionsPerRound}
</div>
<p className="mt-2 font-display text-2xl font-bold leading-snug">{q.text}</p>
{view.status === 'question' && (
<p className="mt-2 text-sm text-accent">
{!predict
? 'Pick what you think your partner will pick.'
: view.iAmSubject
? "This one's about you. Answer honestly!"
: `Guess what ${subjectName} will say.`}
</p>
)}
{view.status === 'question' && <p className="mt-2 text-sm text-accent">{instruction}</p>}
</div>
{view.status === 'question' && (
<>
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
{q.options.map((option, i) => (
<button
key={i}
role="radio"
aria-checked={view.myAnswer === i}
disabled={view.myAnswer !== null}
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
view.myAnswer === i
? 'border-accent bg-accent/25'
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
}`}
>
{option}
</button>
))}
</div>
{answered && type !== 'match' && type !== 'predict' ? (
<div className="card text-center font-display text-lg font-bold">{show(view.myAnswer!)}</div>
) : type === 'telepathy' ? (
<WordInput key={q.id} onSubmit={(text) => send('sync:answer', { questionId: q.id, text })} />
) : type === 'rank' ? (
<RankPicker key={q.id} items={q.options} onSubmit={(order) => send('sync:answer', { questionId: q.id, order })} />
) : (
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
{q.options.map((option, i) => (
<button
key={i}
role="radio"
aria-checked={view.myAnswer === i}
disabled={answered}
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
view.myAnswer === i
? 'border-accent bg-accent/25'
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
}`}
>
{option}
</button>
))}
</div>
)}
<p className="text-center text-sm text-stone-400" aria-live="polite">
{view.myAnswer !== null
{answered
? couch
? `Locked in. Pass the device to ${partnerName}.`
: `Locked in. Waiting for ${partnerName}…`
@@ -166,8 +263,8 @@ export function SyncGame({ view, state, role, send }: Props) {
{view.status === 'reveal' && view.reveal && (
<div className="space-y-4" aria-live="polite">
<div className="flex gap-3">
<FlipCard role="A" name={roles.A.name} answer={q.options[view.reveal.A]} delay={0} />
<FlipCard role="B" name={roles.B.name} answer={q.options[view.reveal.B]} delay={0.15} />
<FlipCard role="A" name={roles.A.name} answer={show(view.reveal.A)} delay={0} />
<FlipCard role="B" name={roles.B.name} answer={show(view.reveal.B)} delay={0.15} />
</div>
<motion.p
className="text-center font-display text-2xl font-bold"
+2 -1
View File
@@ -42,4 +42,5 @@ export const ROLE_TEXT: Record<Role, string> = { A: 'text-roleA', B: 'text-roleB
export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'border-roleB/60' };
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
export const difficultyKeys = (n: number) => '🔑'.repeat(n);
export const MAX_DIFFICULTY = 5;
export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n));
+3 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import type { Role, RoomState } from '@two-keys/shared';
import { Markdown, RoleBadge } from '../components/ui';
import { socket } from '../lib/socket';
import { CATEGORY_LABEL, ROLE_BORDER, difficultyKeys } from '../lib/theme';
import { CATEGORY_LABEL, MAX_DIFFICULTY, ROLE_BORDER, difficultyKeys } from '../lib/theme';
function ShareBox({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
@@ -51,7 +51,8 @@ export function Lobby({ state }: { state: RoomState }) {
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-stone-300">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span>⏱ {game.estimatedMinutes} min</span>
<span aria-label={`Difficulty ${game.difficulty} of 3`}>{difficultyKeys(game.difficulty)}</span>
<span aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}`}>{difficultyKeys(game.difficulty)}</span>
{game.timeLimitMinutes !== null && <span className="text-red-300">⏳ {game.timeLimitMinutes} min limit</span>}
</div>
<h1 className="mt-3 font-display text-4xl font-bold leading-tight">{game.title}</h1>
<p className="mt-2 text-stone-300">{game.tagline}</p>