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>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user