V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#120c0e" />
|
||||
<meta name="description" content="Co-op mystery games for couples. Two players, different clues, every lock needs both of you." />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Two Keys · Co-op mysteries for couples</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@two-keys/client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@two-keys/shared": "*",
|
||||
"framer-motion": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#160c10"/><text x="32" y="44" font-size="34" text-anchor="middle">🔑</text></svg>
|
||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1,24 @@
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Toasts } from './components/ui';
|
||||
import { Home } from './pages/Home';
|
||||
import { HowToPlay } from './pages/HowToPlay';
|
||||
import { Library } from './pages/Library';
|
||||
import { JoinRedirect, Room } from './pages/Room';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
// Animations switch off for anyone who asks their device for reduced motion.
|
||||
<MotionConfig reducedMotion="user">
|
||||
<Toasts />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/games" element={<Library />} />
|
||||
<Route path="/how-to-play" element={<HowToPlay />} />
|
||||
<Route path="/join/:code" element={<JoinRedirect />} />
|
||||
<Route path="/room/:code" element={<Room />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Routes>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ClueType, ClueView } from '@two-keys/shared';
|
||||
import { Markdown } from './ui';
|
||||
|
||||
const TYPE_LABEL: Record<ClueType, string> = {
|
||||
note: 'Note',
|
||||
letter: 'Letter',
|
||||
document: 'Document',
|
||||
report: 'Report',
|
||||
log: 'Log',
|
||||
table: 'Table',
|
||||
photo: 'Photo',
|
||||
map: 'Map',
|
||||
transcript: 'Transcript',
|
||||
signal: 'Signal',
|
||||
diagram: 'Diagram',
|
||||
};
|
||||
|
||||
/** Who can see this clue: tinted in the viewer's role colour when it's theirs alone. */
|
||||
function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
|
||||
const isPrivate = clue.audience === 'private';
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 font-sans text-[11px] font-semibold not-italic ${
|
||||
isPrivate
|
||||
? light
|
||||
? 'bg-stone-900 text-[color:var(--role)]'
|
||||
: 'bg-black/40 text-[color:var(--role)] ring-1 ring-[color:var(--role)]'
|
||||
: light
|
||||
? 'bg-stone-900/10 text-stone-700'
|
||||
: 'bg-white/10 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{isPrivate ? '👁 Only you can see this' : 'You both have this'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className={`text-[10px] font-bold uppercase tracking-[0.18em] opacity-60 ${mono ? 'font-mono' : 'font-sans'}`}>
|
||||
{TYPE_LABEL[clue.type]}
|
||||
</div>
|
||||
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
|
||||
</div>
|
||||
<Audience clue={clue} light={light} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `.` = 250ms on, `-` = 750ms on, 250ms between symbols, `/` = 1000ms between letters,
|
||||
// then a 3000ms pause and it loops.
|
||||
function signalTimeline(pattern: string): { on: boolean; ms: number }[] {
|
||||
const steps: { on: boolean; ms: number }[] = [];
|
||||
const symbols = [...pattern];
|
||||
symbols.forEach((symbol, i) => {
|
||||
if (symbol === '/') return;
|
||||
steps.push({ on: true, ms: symbol === '-' ? 750 : 250 });
|
||||
const next = symbols[i + 1];
|
||||
if (next === undefined) steps.push({ on: false, ms: 3000 });
|
||||
else steps.push({ on: false, ms: next === '/' ? 1000 : 250 });
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** A blinking light. The pattern is only ever shown as light, never as text. */
|
||||
export function SignalLamp({ pattern }: { pattern: string }) {
|
||||
const [on, setOn] = useState(false);
|
||||
const [run, setRun] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const steps = signalTimeline(pattern);
|
||||
if (steps.length === 0) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let i = 0;
|
||||
setOn(false);
|
||||
const tick = () => {
|
||||
const step = steps[i % steps.length];
|
||||
setOn(step.on);
|
||||
i++;
|
||||
timer = setTimeout(tick, step.ms);
|
||||
};
|
||||
timer = setTimeout(tick, 600); // a beat of darkness so the start is unmistakable
|
||||
return () => clearTimeout(timer);
|
||||
}, [pattern, run]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-3">
|
||||
<div className="rounded-full border-4 border-stone-700 bg-black p-3">
|
||||
<div role="img" aria-label="A blinking signal lamp" className={`lamp h-20 w-20 rounded-full ${on ? 'on' : ''}`} />
|
||||
</div>
|
||||
<button className="btn text-sm" onClick={() => setRun((n) => n + 1)}>
|
||||
↻ Replay from the start
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClueCard({ clue }: { clue: ClueView }) {
|
||||
switch (clue.type) {
|
||||
case 'note':
|
||||
return (
|
||||
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'letter':
|
||||
return (
|
||||
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
|
||||
<Header clue={clue} light />
|
||||
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'report':
|
||||
case 'document':
|
||||
case 'log':
|
||||
return (
|
||||
<article className="overflow-hidden rounded-md bg-[#ecebe4] text-stone-900 shadow-lg shadow-black/40">
|
||||
<div className="bg-stone-800 px-4 py-1.5 font-mono text-[10px] font-bold uppercase tracking-[0.25em] text-stone-200">
|
||||
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Header clue={clue} light mono />
|
||||
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
case 'photo':
|
||||
return (
|
||||
<article className="rotate-[0.5deg] rounded-sm bg-stone-100 p-3 pb-4 text-stone-900 shadow-lg shadow-black/50">
|
||||
<div className="flex min-h-[7rem] items-center rounded-sm bg-gradient-to-br from-stone-700 to-stone-900 p-4 text-stone-100">
|
||||
<Markdown className="italic leading-6">{clue.body}</Markdown>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Header clue={clue} light />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
case 'map':
|
||||
case 'diagram':
|
||||
return (
|
||||
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'transcript':
|
||||
return (
|
||||
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
|
||||
<Header clue={clue} light={false} />
|
||||
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'signal':
|
||||
return (
|
||||
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
{clue.signal && <SignalLamp pattern={clue.signal} />}
|
||||
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
case 'table':
|
||||
return (
|
||||
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
|
||||
<Header clue={clue} light={false} mono />
|
||||
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
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 { pushToast } from '../lib/toasts';
|
||||
import { RoleBadge, Sheet } from './ui';
|
||||
|
||||
const ENGINE_BLURB: Record<GameSummary['engine'], string> = {
|
||||
story: 'Split clues, shared locks',
|
||||
defuse: 'One sees it, one has the manual',
|
||||
sync: 'How well do you know each other?',
|
||||
labyrinth: 'One map, one explorer',
|
||||
};
|
||||
|
||||
/** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */
|
||||
export function GameCard({ game, className = '' }: { game: GameSummary; className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const best = getBest(game.id);
|
||||
|
||||
const start = async (mode: PlayMode) => {
|
||||
setCreating(true);
|
||||
const res = await request('room:create', { gameId: game.id, mode });
|
||||
setCreating(false);
|
||||
if (!res.ok) return pushToast(res.message, 'error');
|
||||
saveSession(res.code, res.playerToken);
|
||||
navigate(`/room/${res.code}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={themeVars(game.theme)} className={className}>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex h-full w-full flex-col rounded-2xl border border-white/10 bg-bg bg-gradient-to-br from-primary/60 via-primary/15 to-transparent p-5 text-left shadow-lg shadow-black/30 transition hover:-translate-y-0.5 hover:border-accent/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<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`}>
|
||||
{difficultyKeys(game.difficulty)}
|
||||
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.tagline}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-stone-400">
|
||||
<span>{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]}</span>
|
||||
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onClose={() => setOpen(false)} title={game.title}>
|
||||
<p className="text-stone-300">{game.tagline}</p>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{(['A', 'B'] as const).map((role) => (
|
||||
<div key={role} className="card !p-3">
|
||||
<RoleBadge role={role} name={game.roles[role].name} />
|
||||
<p className="mt-2 text-sm text-stone-300">{game.roles[role].blurb}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">How are you playing?</h4>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<button className="btn-primary justify-start py-3 text-left" disabled={creating} onClick={() => start('duo')}>
|
||||
<span className="text-2xl" aria-hidden>📱📱</span>
|
||||
<span>
|
||||
Two devices
|
||||
<span className="block text-xs font-normal opacity-80">One phone each. You'll get a code to share.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn justify-start py-3 text-left" disabled={creating} onClick={() => start('couch')}>
|
||||
<span className="text-2xl" aria-hidden>🛋️</span>
|
||||
<span>
|
||||
Couch mode
|
||||
<span className="block text-xs font-normal opacity-80">One device, passed back and forth. No peeking.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import 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. */
|
||||
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 reveal = () => {
|
||||
setConfirming(false);
|
||||
send('hint:request', { lockId: lock.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn shrink-0 px-3 ${lock.nudge ? 'border-accent text-accent motion-safe:animate-pulse' : ''}`}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Hints, ${used} of ${lock.hintsTotal} revealed`}
|
||||
>
|
||||
💡<span className="text-xs tabular-nums">{used}/{lock.hintsTotal}</span>
|
||||
</button>
|
||||
|
||||
<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
|
||||
your time, and you both see it.
|
||||
</p>
|
||||
<ol className="space-y-2">
|
||||
{lock.hints.map((hint, i) => (
|
||||
<li key={i} className="card">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-accent">Hint {i + 1}</div>
|
||||
<p className="mt-1">{hint}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{left === 0 ? (
|
||||
<p className="mt-4 text-sm text-stone-400">That's every hint for this lock.</p>
|
||||
) : confirming ? (
|
||||
<div className="mt-4 rounded-2xl border border-accent/50 bg-accent/10 p-4">
|
||||
<p className="font-semibold">This basically solves it. Sure?</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="btn-primary" onClick={reveal}>Yes, reveal it</button>
|
||||
<button className="btn" onClick={() => setConfirming(false)}>Keep thinking</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
|
||||
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
|
||||
</button>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Sheet } from './ui';
|
||||
|
||||
/** Keys earned so far. A new key drops onto the ring with a little flourish. */
|
||||
export function KeyRing({ keys, total }: { keys: string[]; total: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex min-h-[36px] items-center gap-1 rounded-full border border-accent/40 bg-black/30 px-2.5 py-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Key ring: ${keys.length} of ${total} keys`}
|
||||
>
|
||||
{/* A long ring collapses to one key and the count on narrow phones, leaving room for the partner's name. */}
|
||||
{total > 3 && <span className="text-sm min-[440px]:hidden" aria-hidden>🔑</span>}
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={`relative h-5 w-4 text-sm leading-5 ${total > 3 ? 'hidden min-[440px]:inline-block' : 'inline-block'}`}>
|
||||
<span className="opacity-20 grayscale" aria-hidden>🔑</span>
|
||||
<AnimatePresence>
|
||||
{i < keys.length && (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
initial={{ y: -40, scale: 2.4, rotate: -90, opacity: 0 }}
|
||||
animate={{ y: 0, scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 16 }}
|
||||
>
|
||||
🔑
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-1 text-xs font-semibold tabular-nums text-accent">
|
||||
{keys.length}/{total}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onClose={() => setOpen(false)} title="Key ring">
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-stone-400">No keys yet. Every lock you open together earns one.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{keys.map((name, i) => (
|
||||
<li key={i} className="card flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden>🔑</span>
|
||||
<span className="font-semibold">{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-stone-400">
|
||||
{keys.length} of {total} keys collected.
|
||||
</p>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import type { LockView } from '@two-keys/shared';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
import { HintButton } from './HintButton';
|
||||
|
||||
/** The shared lock: both players see it, either can submit. Only the server knows the answer. */
|
||||
export function LockPanel({ lock, send }: { lock: LockView; send: Send }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [wrong, setWrong] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onResult = ({ lockId, correct }: { lockId: string; correct: boolean }) => {
|
||||
if (lockId !== lock.id) return;
|
||||
setBusy(false);
|
||||
setWrong(!correct);
|
||||
if (!correct) setShake(true);
|
||||
};
|
||||
const onError = () => setBusy(false);
|
||||
socket.on('lock:result', onResult);
|
||||
socket.on('error', onError);
|
||||
return () => {
|
||||
socket.off('lock:result', onResult);
|
||||
socket.off('error', onError);
|
||||
};
|
||||
}, [lock.id]);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setWrong(false);
|
||||
send('lock:submit', { lockId: lock.id, value });
|
||||
};
|
||||
|
||||
const numeric = lock.inputType === 'code' && /digit|number/i.test(lock.format ?? '');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Lock"
|
||||
className={`${lock.inputType === 'choice' ? '' : 'sticky bottom-0 z-10'} -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur`}
|
||||
>
|
||||
<form onSubmit={submit} onAnimationEnd={() => setShake(false)} className={`mx-auto max-w-2xl ${shake ? 'shake' : ''}`}>
|
||||
<label htmlFor={`lock-${lock.id}`} className="flex items-start gap-2 font-semibold leading-snug">
|
||||
<span aria-hidden>🔒</span>
|
||||
<span>{lock.prompt}</span>
|
||||
</label>
|
||||
|
||||
{lock.inputType === 'choice' && lock.options ? (
|
||||
<div role="radiogroup" aria-label={lock.prompt} className="mt-2 flex flex-wrap gap-2">
|
||||
{lock.options.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`cursor-pointer rounded-xl border px-3 py-2 text-sm font-medium transition focus-within:ring-2 focus-within:ring-accent ${
|
||||
value === o.id ? 'border-accent bg-accent/20' : 'border-white/20 bg-black/30 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`lock-${lock.id}`}
|
||||
className="sr-only"
|
||||
checked={value === o.id}
|
||||
onChange={() => setValue(o.id)}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
{lock.inputType !== 'choice' && (
|
||||
<input
|
||||
id={`lock-${lock.id}`}
|
||||
className={`field ${lock.inputType === 'code' ? 'font-mono tracking-[0.2em]' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
inputMode={numeric ? 'numeric' : 'text'}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
maxLength={80}
|
||||
placeholder={lock.format ?? 'Your answer'}
|
||||
aria-describedby={`lock-${lock.id}-help`}
|
||||
/>
|
||||
)}
|
||||
<button type="submit" className="btn-primary shrink-0" disabled={!value.trim() || busy}>
|
||||
{busy ? '…' : 'Unlock'}
|
||||
</button>
|
||||
<HintButton lock={lock} send={send} />
|
||||
</div>
|
||||
|
||||
<p id={`lock-${lock.id}-help`} className="mt-1.5 min-h-[1.25rem] text-xs text-stone-400" aria-live="polite">
|
||||
{wrong ? (
|
||||
<span className="font-semibold text-red-300">
|
||||
Not quite. {lock.nudge ? 'Need a hint? 💡' : 'Compare notes and try again.'}
|
||||
</span>
|
||||
) : lock.nudge ? (
|
||||
<span className="font-semibold text-accent">Stuck? Need a hint? 💡</span>
|
||||
) : lock.format && lock.inputType !== 'choice' ? (
|
||||
<>Format: {lock.format}</>
|
||||
) : (
|
||||
<>Either of you can answer. You both need to agree first!</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { socket } from '../lib/socket';
|
||||
import { Sheet } from './ui';
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
/** A notepad both players share, synced live. Last write wins. */
|
||||
export function Notepad({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const dirty = useRef(false);
|
||||
|
||||
// Take the partner's edits unless we have unsent typing of our own.
|
||||
useEffect(() => {
|
||||
if (!dirty.current) setDraft(text);
|
||||
}, [text]);
|
||||
|
||||
const flush = (value: string) => {
|
||||
clearTimeout(timer.current);
|
||||
if (!dirty.current) return;
|
||||
dirty.current = false;
|
||||
socket.emit('notepad:update', { text: value });
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setDraft(value);
|
||||
dirty.current = true;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => flush(value), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex min-h-[36px] items-center gap-1 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open the shared notepad"
|
||||
>
|
||||
📝<span className="hidden sm:inline">Notes</span>
|
||||
{text.trim() !== '' && <span className="h-1.5 w-1.5 rounded-full bg-accent" aria-hidden />}
|
||||
</button>
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={() => {
|
||||
flush(draft);
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Shared notepad"
|
||||
>
|
||||
<p className="mb-2 text-sm text-stone-400">You both see this, live. Jot down times, names, half-baked theories.</p>
|
||||
<textarea
|
||||
className="field min-h-[40dvh] font-hand text-xl leading-7"
|
||||
value={draft}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={() => flush(draft)}
|
||||
maxLength={5000}
|
||||
placeholder="Our notes…"
|
||||
aria-label="Shared notes"
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { PING_EMOJIS, type Role, type RoomState } from '@two-keys/shared';
|
||||
import { socket } from '../lib/socket';
|
||||
import { ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
/** Partner's online dot, what they're looking at, and the ping buttons. */
|
||||
export function PartnerStatus({ state }: { state: RoomState }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const partner = state.partner;
|
||||
const name = partner?.seat ? state.game.roles[partner.seat].name : 'Partner';
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<button
|
||||
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-label={`${name} is ${partner?.online ? 'online' : 'offline'}. Send a ping.`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${partner?.online ? 'bg-emerald-400 shadow-[0_0_6px_#34d399]' : 'bg-stone-500'}`}
|
||||
/>
|
||||
<span className={`truncate font-medium ${partner?.seat ? ROLE_TEXT[partner.seat] : ''}`}>{name}</span>
|
||||
<span aria-hidden>👋</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
// Anchored to the header bar (not the button) so it always fits a narrow screen.
|
||||
<div className="absolute inset-x-4 top-full z-30 mt-2 max-w-xs rounded-2xl border border-white/15 bg-bg p-3 shadow-2xl">
|
||||
<p className="text-xs text-stone-400">
|
||||
{partner?.online ? 'Online' : 'Offline. They can rejoin with the same link.'}
|
||||
{partner?.online && partner.viewing ? ` · looking at ${partner.viewing}` : ''}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{PING_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="btn flex-1 px-0 text-xl"
|
||||
disabled={!partner?.online}
|
||||
aria-label={`Send ${emoji}`}
|
||||
onClick={() => {
|
||||
socket.emit('ping', { emoji });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Ping {
|
||||
id: number;
|
||||
emoji: string;
|
||||
from: Role;
|
||||
x: number;
|
||||
}
|
||||
|
||||
/** Reactions from the partner float up the screen. */
|
||||
export function PingOverlay({ roles }: { roles: RoomState['game']['roles'] }) {
|
||||
const [pings, setPings] = useState<Ping[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextId = 1;
|
||||
const onPing = ({ from, emoji }: { from: Role; emoji: string }) => {
|
||||
const id = nextId++;
|
||||
setPings((p) => [...p, { id, emoji, from, x: 15 + Math.random() * 70 }]);
|
||||
setTimeout(() => setPings((p) => p.filter((x) => x.id !== id)), 2600);
|
||||
};
|
||||
socket.on('ping', onPing);
|
||||
return () => {
|
||||
socket.off('ping', onPing);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{pings.map((p) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className="absolute bottom-24 flex flex-col items-center"
|
||||
style={{ left: `${p.x}%` }}
|
||||
initial={{ y: 0, opacity: 0, scale: 0.5 }}
|
||||
animate={{ y: -260, opacity: 1, scale: 1.4 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut' }}
|
||||
>
|
||||
<span className="text-5xl">{p.emoji}</span>
|
||||
<span className={`mt-1 rounded-full bg-black/70 px-2 text-[10px] font-semibold ${ROLE_TEXT[p.from]}`}>
|
||||
{roles[p.from].name}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import { ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
/** Couch mode: covers the screen while the device changes hands. */
|
||||
export function PassDeviceScreen({
|
||||
role,
|
||||
roleName,
|
||||
onReveal,
|
||||
}: {
|
||||
role: Role;
|
||||
roleName: string;
|
||||
onReveal: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-6 bg-bg px-6 text-center">
|
||||
<div className="text-6xl" aria-hidden>
|
||||
🙈
|
||||
</div>
|
||||
<h2 className="font-display text-3xl font-bold leading-tight">
|
||||
Pass to <span className={ROLE_TEXT[role]}>{roleName}</span> 👀
|
||||
</h2>
|
||||
<p className="text-stone-400">(no peeking)</p>
|
||||
<button className="btn-primary px-8 py-3 text-lg" onClick={onReveal} autoFocus>
|
||||
I'm {roleName}. Tap to reveal
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex min-h-[44px] items-center rounded-lg px-3 text-sm font-medium transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
isActive ? 'text-accent' : 'text-stone-300'
|
||||
}`;
|
||||
|
||||
export function SiteShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-5xl flex-col px-4">
|
||||
<header className="flex items-center justify-between gap-2 py-2">
|
||||
<Link to="/" className="flex min-h-[44px] items-center gap-1.5 font-display text-xl font-bold tracking-tight focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
Two Keys <span aria-hidden>🔑🔑</span>
|
||||
</Link>
|
||||
<nav className="flex items-center" aria-label="Main">
|
||||
<NavLink to="/games" className={navClass}>Games</NavLink>
|
||||
<NavLink to="/how-to-play" className={navClass}>How to play</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 pb-16">{children}</main>
|
||||
<footer className="border-t border-white/10 py-6 text-center text-xs text-stone-500">
|
||||
Two players. Different clues. Every lock needs both of you.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatClock, type RoomState } from '@two-keys/shared';
|
||||
import { useNow } from '../lib/hooks';
|
||||
|
||||
/** Elapsed time plus penalties. 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;
|
||||
return (
|
||||
<div
|
||||
className="flex items-baseline gap-1.5 font-mono tabular-nums"
|
||||
role="timer"
|
||||
aria-label={`Time ${formatClock(elapsed + timer.penaltySeconds)}`}
|
||||
>
|
||||
<span className="text-base font-bold">{formatClock(elapsed + timer.penaltySeconds)}</span>
|
||||
{timer.penaltySeconds > 0 && (
|
||||
<span className="text-[11px] text-red-300" title="Penalty time from hints and traps">
|
||||
+{formatClock(timer.penaltySeconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Role } from '@two-keys/shared';
|
||||
import { useToasts } from '../lib/toasts';
|
||||
import { ROLE_BG, ROLE_BORDER, ROLE_TEXT } from '../lib/theme';
|
||||
|
||||
export function Markdown({ children, className = '' }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={`md ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Wide tables scroll inside their card instead of stretching the page.
|
||||
table: ({ node: _node, ...props }) => (
|
||||
<div className="scroll-x">
|
||||
<table {...props} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleBadge({ role, name, className = '' }: { role: Role; name: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ROLE_BORDER[role]} ${ROLE_BG[role]} ${ROLE_TEXT[role]} ${className}`}
|
||||
>
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useEscape(onClose: () => void, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [active, onClose]);
|
||||
}
|
||||
|
||||
/** A panel that slides up from the bottom (a centred dialog on wide screens). */
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useEscape(onClose, open);
|
||||
useEffect(() => {
|
||||
if (open) panel.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Rendered on <body>: the sticky bars use backdrop-filter, which would otherwise trap a fixed overlay inside them.
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 flex items-end justify-center sm:items-center">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="relative flex max-h-[85dvh] w-full max-w-2xl flex-col rounded-t-3xl border border-white/15 bg-bg shadow-2xl outline-none sm:rounded-3xl"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.25 }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
|
||||
<h2 className="font-display text-xl font-bold">{title}</h2>
|
||||
<button className="btn !min-h-[36px] px-3 py-1 text-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const toasts = useToasts();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed inset-x-0 top-3 z-50 flex flex-col items-center gap-2 px-4"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className={`max-w-sm rounded-xl border px-4 py-2 text-sm font-medium shadow-xl backdrop-blur ${
|
||||
t.kind === 'error'
|
||||
? 'border-red-400/40 bg-red-950/90 text-red-100'
|
||||
: 'border-accent/40 bg-black/85 text-stone-100'
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 text-center text-stone-300">
|
||||
<div className="text-4xl motion-safe:animate-pulse" aria-hidden>
|
||||
🔑🔑
|
||||
</div>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ButtonModuleView,
|
||||
DefuseAction,
|
||||
DefuseView,
|
||||
KeypadModuleView,
|
||||
RoomState,
|
||||
TechnicianView,
|
||||
WiresModuleView,
|
||||
} from '@two-keys/shared';
|
||||
import { Markdown } from '../components/ui';
|
||||
import { useNow } from '../lib/hooks';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: DefuseView;
|
||||
state: RoomState;
|
||||
receivedAt: number;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
// Colours always come with their name as text, so nothing depends on telling hues apart.
|
||||
const SWATCH: Record<string, string> = {
|
||||
red: '#e5484d',
|
||||
blue: '#3b82f6',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
white: '#f5f5f4',
|
||||
};
|
||||
const darkText = (color: string) => color === 'yellow' || color === 'white';
|
||||
|
||||
function timerText(remaining: number): string {
|
||||
const t = Math.max(0, Math.ceil(remaining));
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useRemaining(view: DefuseView, receivedAt: number): number {
|
||||
const now = useNow(200);
|
||||
if (!view.clock) return view.level.timeSeconds;
|
||||
if (!view.clock.running) return view.clock.remaining;
|
||||
return Math.max(0, view.clock.remaining - ((now - receivedAt) / 1000) * view.clock.speed);
|
||||
}
|
||||
|
||||
function StatusBar({ view, remaining }: { view: DefuseView; remaining: number }) {
|
||||
const urgent = view.status === 'active' && remaining <= 30;
|
||||
return (
|
||||
<div className="sticky top-0 z-10 -mx-4 flex items-center justify-between gap-3 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-accent">
|
||||
Level {view.level.index + 1} of {view.level.total}
|
||||
</div>
|
||||
<div className="truncate font-display font-bold">{view.level.title}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg border-2 bg-black px-3 py-1 font-mono text-3xl font-bold tabular-nums ${
|
||||
urgent ? 'border-red-500 text-red-400' : 'border-stone-700 text-red-500'
|
||||
}`}
|
||||
role="timer"
|
||||
aria-label={`Bomb timer ${timerText(remaining)}`}
|
||||
>
|
||||
{timerText(remaining)}
|
||||
</div>
|
||||
<div className="text-right" aria-label={`${view.strikes} of ${view.maxStrikes} strikes`}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Strikes</div>
|
||||
<div className="font-mono text-lg tracking-widest" aria-hidden>
|
||||
{Array.from({ length: view.maxStrikes }, (_, i) => (
|
||||
<span key={i} className={i < view.strikes ? 'text-red-500' : 'text-stone-600'}>
|
||||
✖
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{view.clock && view.clock.speed > 1 && (
|
||||
<div className="text-[10px] text-red-300">clock ×{view.clock.speed.toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleFrame({ title, solved, children }: { title: string; solved: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className={`rounded-2xl border-2 p-4 ${solved ? 'border-emerald-500/60 bg-emerald-500/10' : 'border-stone-600 bg-stone-900/70'}`}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-mono text-sm font-bold uppercase tracking-widest">{title}</h3>
|
||||
<span className={`flex items-center gap-1.5 text-xs font-bold ${solved ? 'text-emerald-400' : 'text-stone-500'}`}>
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${solved ? 'bg-emerald-400' : 'bg-stone-600'}`} aria-hidden />
|
||||
{solved ? 'DISARMED' : 'ARMED'}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) {
|
||||
return (
|
||||
<ModuleFrame title="Wires" solved={module.solved}>
|
||||
<ol className="space-y-2">
|
||||
{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="w-16 font-mono text-sm font-bold uppercase">{color}</span>
|
||||
<button
|
||||
className="btn !min-h-[40px] px-3 text-sm"
|
||||
disabled={module.solved}
|
||||
onClick={() => act({ type: 'cut', index: i + 1 })}
|
||||
aria-label={`Cut wire ${i + 1}, ${color}`}
|
||||
>
|
||||
✂ Cut
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function BigButton({ module, act }: { module: ButtonModuleView; act: (a: DefuseAction) => void }) {
|
||||
const bg = SWATCH[module.color] ?? module.color;
|
||||
return (
|
||||
<ModuleFrame title="The Button" solved={module.solved}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-28 w-28 shrink-0 flex-col items-center justify-center rounded-full border-4 border-black/40 font-mono shadow-xl ${
|
||||
module.holding ? 'scale-95 shadow-inner' : ''
|
||||
} ${darkText(module.color) ? 'text-stone-900' : 'text-white'}`}
|
||||
style={{ backgroundColor: bg }}
|
||||
aria-label={`A ${module.color} button labelled ${module.label}`}
|
||||
role="img"
|
||||
>
|
||||
<span className="text-xl font-bold">{module.label}</span>
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">{module.color} button</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{module.holding ? (
|
||||
<>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-center font-mono text-sm font-bold uppercase ${darkText(module.strip ?? '') ? 'text-stone-900' : 'text-white'}`}
|
||||
style={{ backgroundColor: SWATCH[module.strip ?? ''] ?? '#444' }}
|
||||
aria-live="assertive"
|
||||
>
|
||||
Strip: {module.strip}
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => act({ type: 'release' })}>
|
||||
Release now
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'tap' })}>
|
||||
Tap it
|
||||
</button>
|
||||
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'hold' })}>
|
||||
Press & hold
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModuleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseAction) => void }) {
|
||||
return (
|
||||
<ModuleFrame title="Hearts & Symbols" solved={module.solved}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{module.symbols.map((s) => {
|
||||
const [glyph, ...name] = s.label.split(' ');
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`flex flex-col items-center rounded-xl border-2 px-2 py-3 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
s.pressed ? 'border-emerald-500 bg-emerald-500/20' : 'border-stone-500 bg-stone-800 hover:bg-stone-700'
|
||||
}`}
|
||||
disabled={s.pressed || module.solved}
|
||||
onClick={() => act({ type: 'press', symbol: s.id })}
|
||||
aria-label={`${name.join(' ')}${s.pressed ? ', pressed' : ''}`}
|
||||
>
|
||||
<span className="text-3xl" aria-hidden>{glyph}</span>
|
||||
<span className="mt-1 text-xs font-semibold">{name.join(' ')}{s.pressed ? ' ✓' : ''}</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;
|
||||
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">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Serial number</div>
|
||||
<div className="text-2xl font-bold tracking-[0.15em]">{serial}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Batteries</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
|
||||
</div>
|
||||
</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)} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DefuseGame({ view, state, receivedAt, send }: Props) {
|
||||
const remaining = useRemaining(view, receivedAt);
|
||||
const technician = view.seat === 'technician';
|
||||
const partner = state.game.roles[technician ? 'B' : 'A'].name;
|
||||
|
||||
let banner = null;
|
||||
if (view.status === 'ready') {
|
||||
banner = (
|
||||
<div className="card text-center">
|
||||
<div className="text-5xl" aria-hidden>💣</div>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold">
|
||||
Level {view.level.index + 1}: {view.level.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-300">
|
||||
{timerText(view.level.timeSeconds)} on the clock. {view.maxStrikes} strikes and it's over.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-stone-400">
|
||||
{technician
|
||||
? `You'll see the device. Describe everything to the ${partner}: serial number, batteries, every colour and label.`
|
||||
: `You have the manual below. Ask the ${partner} for the serial number and battery count first.`}
|
||||
</p>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
Start the clock ⏱
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (view.status === 'exploded') {
|
||||
banner = (
|
||||
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/60 p-5 text-center" aria-live="assertive">
|
||||
<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.
|
||||
</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')}>
|
||||
Try again with a new device
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (view.status === 'cleared') {
|
||||
banner = (
|
||||
<div className="rounded-2xl border-2 border-emerald-500/70 bg-emerald-950/50 p-5 text-center" aria-live="polite">
|
||||
<div className="text-5xl" aria-hidden>✅</div>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold">Disarmed with {timerText(remaining)} to spare!</h2>
|
||||
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
|
||||
Next level →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StatusBar view={view} remaining={remaining} />
|
||||
{banner}
|
||||
{view.seat === 'technician' && view.status !== 'ready' && (
|
||||
<div className={view.status === 'active' ? '' : 'pointer-events-none opacity-50'}>
|
||||
<Device view={view} send={send} />
|
||||
</div>
|
||||
)}
|
||||
{view.seat === 'technician' && view.status === 'active' && (
|
||||
<p className="text-center text-sm text-stone-400">You can't see the manual. Describe what you see out loud.</p>
|
||||
)}
|
||||
{view.seat === 'expert' && (
|
||||
<article className="rounded-md bg-[#ecebe4] p-5 text-stone-900 shadow-lg">
|
||||
<Markdown className="paper leading-7">{view.manual}</Markdown>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dir, ExplorerView, LabyrinthView, 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 ----------
|
||||
|
||||
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
|
||||
const width = view.grid[0].length;
|
||||
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">
|
||||
<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];
|
||||
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>
|
||||
))}
|
||||
<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 ----------
|
||||
|
||||
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
|
||||
{ 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'] },
|
||||
];
|
||||
|
||||
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
|
||||
// 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));
|
||||
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]);
|
||||
|
||||
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
|
||||
const open = DIRS.filter((d) => view.open[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>
|
||||
</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>
|
||||
</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!
|
||||
</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} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { ClueCard } from '../components/ClueCard';
|
||||
import { LockPanel } from '../components/LockPanel';
|
||||
import { Markdown, Sheet } from '../components/ui';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: StoryView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Clues({ clues }: { clues: ClueView[] }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{clues.map((clue) => (
|
||||
<ClueCard key={clue.id} clue={clue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lock springs open, the key drops onto the ring, either player continues. */
|
||||
function Unlocked({ lock, onContinue, last }: { lock: LockView; onContinue: () => void; last: boolean }) {
|
||||
return (
|
||||
<motion.section
|
||||
className="rounded-2xl border border-accent/50 bg-accent/10 p-5"
|
||||
initial={{ opacity: 0, scale: 0.94 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.span
|
||||
className="text-4xl"
|
||||
aria-hidden
|
||||
initial={{ rotate: -30, scale: 0.4 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 10 }}
|
||||
>
|
||||
🔓
|
||||
</motion.span>
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">Unlocked</div>
|
||||
{lock.keyName && <div className="font-display text-xl font-bold">🔑 {lock.keyName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{lock.successText && <Markdown className="mt-4 leading-7">{lock.successText}</Markdown>}
|
||||
<button className="btn-primary mt-5 w-full" onClick={onContinue}>
|
||||
{last ? 'On to the finale →' : 'Continue →'}
|
||||
</button>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both players must agree: one proposes, the other agrees or changes it. */
|
||||
function Accusation({ finale, role, state, send }: { finale: AccusationView; role: Role; state: RoomState; send: Send }) {
|
||||
const proposalKey = JSON.stringify(finale.proposal?.answers ?? null);
|
||||
const [draft, setDraft] = useState<Record<string, string>>(finale.proposal?.answers ?? {});
|
||||
|
||||
// A new proposal (from either side) replaces whatever was being picked.
|
||||
useEffect(() => {
|
||||
if (finale.proposal) setDraft(finale.proposal.answers);
|
||||
}, [proposalKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const complete = finale.questions.every((q) => draft[q.id]);
|
||||
const matchesProposal =
|
||||
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
|
||||
const partnerName = state.game.roles[otherRole(role)].name;
|
||||
const mine = finale.proposal?.by === role;
|
||||
|
||||
let action;
|
||||
if (matchesProposal && finale.agreed) {
|
||||
action = (
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
|
||||
You both agree. Make the accusation ⚖️
|
||||
</button>
|
||||
);
|
||||
} else if (matchesProposal && mine) {
|
||||
action = (
|
||||
<button className="btn w-full" disabled>
|
||||
Waiting for {partnerName} to agree…
|
||||
</button>
|
||||
);
|
||||
} else if (matchesProposal) {
|
||||
action = (
|
||||
<div className="space-y-2">
|
||||
<button className="btn-primary w-full" onClick={() => send('finale:agree')}>
|
||||
Agree with {partnerName} ✓
|
||||
</button>
|
||||
<p className="text-center text-xs text-stone-400">Disagree? Change any answer above to propose your own.</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
action = (
|
||||
<button className="btn-primary w-full" disabled={!complete} onClick={() => send('finale:propose', { answers: draft })}>
|
||||
{finale.proposal ? 'Propose this instead' : 'Propose this accusation'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
{finale.questions.map((q) => (
|
||||
<fieldset key={q.id} className="card">
|
||||
<legend className="float-left mb-3 w-full font-display text-xl font-bold">{q.label}</legend>
|
||||
<div className="clear-both space-y-2">
|
||||
{q.options.map((o) => {
|
||||
const picked = draft[q.id] === o.id;
|
||||
const proposed = finale.proposal?.answers[q.id] === o.id;
|
||||
return (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 transition focus-within:ring-2 focus-within:ring-accent ${
|
||||
picked ? 'border-accent bg-accent/20' : 'border-white/15 bg-black/20 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={q.id}
|
||||
className="h-4 w-4 accent-[rgb(var(--accent))]"
|
||||
checked={picked}
|
||||
onChange={() => setDraft((d) => ({ ...d, [q.id]: o.id }))}
|
||||
/>
|
||||
<span className="flex-1">{o.label}</span>
|
||||
{proposed && !picked && <span className="chip">proposed</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
|
||||
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
|
||||
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
|
||||
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{view.suspects.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
|
||||
<ul className="space-y-2">
|
||||
{view.suspects.map((s) => (
|
||||
<li key={s.id} className="card !p-3">
|
||||
<div className="font-semibold">
|
||||
{s.name} <span className="font-normal text-stone-400">· {s.role}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-stone-300">{s.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{chapters.length === 0 && view.suspects.length === 0 && (
|
||||
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
|
||||
)}
|
||||
{chapters.map((ch, i) => (
|
||||
<details key={ch.id} className="group" open={i === chapters.length - 1}>
|
||||
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
|
||||
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
|
||||
Chapter {i + 1}: {ch.title}
|
||||
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
|
||||
<Clues clues={ch.clues} />
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoryGame({ view, state, role, send }: Props) {
|
||||
const [lockerOpen, setLockerOpen] = useState(false);
|
||||
const atFinale = view.finale !== null;
|
||||
const chapter = atFinale ? null : view.chapters[view.currentChapter];
|
||||
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
|
||||
// Everything already solved: all chapters once the finale opens, earlier ones before that.
|
||||
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
|
||||
|
||||
// Tell the partner what we're looking at.
|
||||
useEffect(() => {
|
||||
if (state.mode === 'duo') socket.emit('presence:view', { label: lockerOpen ? 'the evidence locker' : stageLabel });
|
||||
}, [lockerOpen, stageLabel, state.mode]);
|
||||
|
||||
// Braces matter: newer browsers return a promise from scrollTo, and an effect must not return one.
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, [view.currentChapter, atFinale]);
|
||||
|
||||
const heading = (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
|
||||
</div>
|
||||
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
|
||||
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<h2 className="font-display text-3xl font-bold leading-tight">
|
||||
{chapter ? chapter.title : view.finale?.type === 'accusation' ? 'The Accusation' : 'The Way Out'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{heading}
|
||||
|
||||
{chapter && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
|
||||
<Clues clues={chapter.clues} />
|
||||
{chapter.lock.solved && view.awaitingContinue ? (
|
||||
<Unlocked
|
||||
lock={chapter.lock}
|
||||
last={view.currentChapter === view.totalChapters - 1}
|
||||
onContinue={() => send('chapter:continue')}
|
||||
/>
|
||||
) : (
|
||||
<LockPanel key={chapter.lock.id} lock={chapter.lock} send={send} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.finale?.type === 'accusation' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Accusation finale={view.finale} role={role} state={state} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{view.finale?.type === 'escape' && (
|
||||
<>
|
||||
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
|
||||
<Clues clues={view.finale.clues} />
|
||||
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
|
||||
<EvidenceLocker view={view} chapters={past} />
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Role, RoomState, SyncView } from '@two-keys/shared';
|
||||
import { otherRole } from '@two-keys/shared';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import type { Send } from '../lib/socket';
|
||||
|
||||
interface Props {
|
||||
view: SyncView;
|
||||
state: RoomState;
|
||||
role: Role;
|
||||
send: Send;
|
||||
}
|
||||
|
||||
function Marks({ view }: { view: SyncView }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2" aria-label={`${view.points} matches so far, ${view.pointsForKey} needed for the key`}>
|
||||
{Array.from({ length: view.questionsPerRound }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full border text-sm ${
|
||||
view.marks[i] === true
|
||||
? 'border-accent bg-accent text-stone-950'
|
||||
: view.marks[i] === false
|
||||
? 'border-white/20 bg-white/5 text-stone-500'
|
||||
: 'border-white/15 text-transparent'
|
||||
}`}
|
||||
>
|
||||
{view.marks[i] === true ? '♥' : view.marks[i] === false ? '✕' : '·'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
return (
|
||||
<div className="flex-1 [perspective:800px]">
|
||||
<motion.div
|
||||
className="card flex min-h-[7rem] flex-col items-center justify-center gap-2 text-center"
|
||||
initial={{ rotateY: 180, opacity: 0.4 }}
|
||||
animate={{ rotateY: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay }}
|
||||
>
|
||||
<RoleBadge role={role} name={name} />
|
||||
<div className="font-display text-lg font-bold leading-snug">{answer}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncGame({ view, state, role, send }: Props) {
|
||||
const roles = state.game.roles;
|
||||
const partnerName = roles[otherRole(role)].name;
|
||||
const couch = state.mode === 'couch';
|
||||
const header = (
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">
|
||||
Round {view.round.index + 1} of {view.round.total}
|
||||
{view.isReplay && ' · Replay'}
|
||||
</div>
|
||||
<h2 className="font-display text-3xl font-bold">{view.round.title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (view.status === 'intro') {
|
||||
return (
|
||||
<div className="space-y-5 text-center">
|
||||
{header}
|
||||
<p className="text-lg text-stone-200">{view.round.blurb}</p>
|
||||
<p className="text-sm text-stone-400">
|
||||
{view.questionsPerRound} questions. Match on {view.pointsForKey} to earn the key. Answer privately, no peeking.
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
We're ready 💞
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.status === 'summary' && view.summary) {
|
||||
const { earned, canReplay, keyName } = view.summary;
|
||||
const last = view.round.index === view.round.total - 1;
|
||||
return (
|
||||
<div className="space-y-5 text-center" aria-live="polite">
|
||||
{header}
|
||||
<Marks view={view} />
|
||||
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-6xl" aria-hidden>
|
||||
{earned ? '🔑' : '🙃'}
|
||||
</motion.div>
|
||||
<p className="font-display text-2xl font-bold">
|
||||
{view.points} out of {view.questionsPerRound}
|
||||
</p>
|
||||
<p className="text-stone-300">
|
||||
{earned
|
||||
? `In sync! You earned the ${keyName}.`
|
||||
: canReplay
|
||||
? `Not quite ${view.pointsForKey}. You get one replay with fresh questions.`
|
||||
: 'No key for this round. Something to talk about over dessert.'}
|
||||
</p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{!earned && canReplay ? 'Replay this round ↻' : last ? 'See how we did →' : 'Next round →'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const q = view.question;
|
||||
if (!q) return null;
|
||||
const predict = view.round.type === 'predict';
|
||||
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{header}
|
||||
<Marks view={view} />
|
||||
<div className="card text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
<p className="text-center text-sm text-stone-400" aria-live="polite">
|
||||
{view.myAnswer !== null
|
||||
? couch
|
||||
? `Locked in. Pass the device to ${partnerName}.`
|
||||
: `Locked in. Waiting for ${partnerName}…`
|
||||
: view.partnerAnswered
|
||||
? `${partnerName} has answered. Your turn!`
|
||||
: 'Your answer stays hidden until you have both answered.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
</div>
|
||||
<motion.p
|
||||
className="text-center font-display text-2xl font-bold"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
{view.reveal.match ? '💞 In sync!' : '🙈 Not this time'}
|
||||
</motion.p>
|
||||
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
|
||||
{view.questionNumber < view.questionsPerRound ? 'Next question →' : 'See the round result →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Defaults; a room overrides these from its game's YAML theme. */
|
||||
--primary: 123 30 58;
|
||||
--accent: 232 176 74;
|
||||
--bg: 18 12 14;
|
||||
--heading-font: 'Playfair Display';
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg font-sans text-stone-100 antialiased;
|
||||
overflow-x: hidden;
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgb(var(--primary) / 0.35), transparent);
|
||||
background-attachment: fixed;
|
||||
transition: background-color 0.4s;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2 font-semibold text-stone-100 transition hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl bg-accent px-4 py-2 font-semibold text-stone-950 shadow-lg shadow-black/30 transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-2xl border border-white/10 bg-white/[0.04] p-4;
|
||||
}
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/30 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide;
|
||||
}
|
||||
.field {
|
||||
@apply min-h-[44px] w-full rounded-xl border border-white/20 bg-black/40 px-3 py-2 text-base text-stone-100 placeholder:text-stone-500 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Markdown ---------- */
|
||||
.md {
|
||||
--line: rgb(255 255 255 / 0.18);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.md.paper {
|
||||
--line: rgb(0 0 0 / 0.22);
|
||||
}
|
||||
.md > * + * {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3 {
|
||||
font-family: var(--heading-font), 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.md h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.md h2 {
|
||||
font-size: 1.3em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.md ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.md ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.md li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.md blockquote {
|
||||
border-left: 3px solid rgb(var(--accent) / 0.7);
|
||||
padding-left: 0.9em;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.md hr {
|
||||
border-color: var(--line);
|
||||
}
|
||||
.md table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md th,
|
||||
.md td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.3em 0.6em;
|
||||
text-align: left;
|
||||
}
|
||||
.md th:empty {
|
||||
display: none;
|
||||
}
|
||||
.md pre {
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
.md pre code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.md code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md .scroll-x {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Signal lamp ---------- */
|
||||
.lamp {
|
||||
transition: background-color 40ms, box-shadow 40ms;
|
||||
background-color: #2a0e0e;
|
||||
box-shadow: inset 0 0 12px rgb(0 0 0 / 0.8);
|
||||
}
|
||||
.lamp.on {
|
||||
background-color: #ff4d3d;
|
||||
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.35s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { GameSummary, RoomState } from '@two-keys/shared';
|
||||
import { getToken, saveSession } from './session';
|
||||
import { request, socket } from './socket';
|
||||
|
||||
/** Re-renders every `ms` and returns the current time. */
|
||||
export function useNow(ms = 1000): number {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), ms);
|
||||
return () => clearInterval(id);
|
||||
}, [ms]);
|
||||
return now;
|
||||
}
|
||||
|
||||
let gamesCache: Promise<GameSummary[]> | null = null;
|
||||
|
||||
export function useGames(): GameSummary[] | null {
|
||||
const [games, setGames] = useState<GameSummary[] | null>(null);
|
||||
useEffect(() => {
|
||||
gamesCache ??= fetch('/api/games').then((r) => {
|
||||
if (!r.ok) throw new Error('failed to load games');
|
||||
return r.json();
|
||||
});
|
||||
let alive = true;
|
||||
gamesCache.then((g) => alive && setGames(g)).catch(() => {
|
||||
gamesCache = null;
|
||||
if (alive) setGames([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return games;
|
||||
}
|
||||
|
||||
export interface RoomSnapshot {
|
||||
state: RoomState;
|
||||
/** Local time the state arrived, for running clocks between updates. */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
// One join per room at a time, shared between effect runs (StrictMode mounts twice).
|
||||
const joins = new Map<string, ReturnType<typeof request>>();
|
||||
|
||||
function join(code: string) {
|
||||
let pending = joins.get(code);
|
||||
if (!pending) {
|
||||
pending = request('room:join', { code, playerToken: getToken(code) }).then((res) => {
|
||||
joins.delete(code);
|
||||
if (res.ok) saveSession(res.code, res.playerToken);
|
||||
return res;
|
||||
});
|
||||
joins.set(code, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Joins (or rejoins) a room and keeps its sanitized state up to date. */
|
||||
export function useRoom(code: string) {
|
||||
const [snapshot, setSnapshot] = useState<RoomSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(socket.connected);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const onConnect = () => {
|
||||
setConnected(true);
|
||||
join(code).then((res) => alive && setError(res.ok ? null : res.message));
|
||||
};
|
||||
const onDisconnect = () => setConnected(false);
|
||||
const onState = (state: RoomState) => {
|
||||
if (state.code === code) setSnapshot({ state, receivedAt: Date.now() });
|
||||
};
|
||||
|
||||
socket.on('connect', onConnect);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
socket.on('room:state', onState);
|
||||
if (socket.connected) onConnect();
|
||||
return () => {
|
||||
alive = false;
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('disconnect', onDisconnect);
|
||||
socket.off('room:state', onState);
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
return { snapshot, error, connected };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// localStorage can throw (private mode, blocked storage), so every access is wrapped.
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* play on without persistence */
|
||||
}
|
||||
}
|
||||
|
||||
const tokenKey = (code: string) => `twokeys:room:${code}`;
|
||||
const LAST_ROOM = 'twokeys:last-room';
|
||||
|
||||
/** The seat token for a room, so a refresh rejoins the same seat. */
|
||||
export const getToken = (code: string) => read(tokenKey(code));
|
||||
|
||||
export function saveSession(code: string, playerToken: string) {
|
||||
write(tokenKey(code), playerToken);
|
||||
write(LAST_ROOM, code);
|
||||
}
|
||||
|
||||
export const getLastRoom = () => read(LAST_ROOM);
|
||||
export const forgetLastRoom = () => write(LAST_ROOM, null);
|
||||
|
||||
export interface BestResult {
|
||||
finalSeconds: number;
|
||||
rank: string;
|
||||
hintsUsed: number;
|
||||
keysEarned: number;
|
||||
}
|
||||
|
||||
export function getBest(gameId: string): BestResult | null {
|
||||
try {
|
||||
return JSON.parse(read(`twokeys:best:${gameId}`) ?? 'null');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the best result per game: more keys first, then the faster time. */
|
||||
export function saveBest(gameId: string, result: BestResult): boolean {
|
||||
const best = getBest(gameId);
|
||||
const better =
|
||||
!best ||
|
||||
result.keysEarned > best.keysEarned ||
|
||||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
|
||||
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
|
||||
return better;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import type { JoinAck } from '@two-keys/shared';
|
||||
import { pushToast } from './toasts';
|
||||
|
||||
export const socket = io({ transports: ['websocket', 'polling'] });
|
||||
|
||||
socket.on('toast', ({ message }: { message: string }) => pushToast(message));
|
||||
socket.on('error', ({ message }: { message: string }) => pushToast(message, 'error'));
|
||||
|
||||
/** Emit an event that the server acknowledges (room:create, room:join). */
|
||||
export function request(event: string, payload: object): Promise<JoinAck> {
|
||||
return new Promise((resolve) => {
|
||||
socket.timeout(8000).emit(event, payload, (err: Error | null, res: JoinAck) => {
|
||||
resolve(err ? { ok: false, code: 'timeout', message: "Can't reach the server. Check your connection." } : res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type Send = (event: string, payload?: Record<string, unknown>) => void;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Category, Role, Theme } from '@two-keys/shared';
|
||||
|
||||
/** "#7b1e3a" → "123 30 58", the form Tailwind's rgb(var(--x) / alpha) wants. */
|
||||
export function hexToRgb(hex: string): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
|
||||
}
|
||||
|
||||
export function themeVars(theme: Theme): CSSProperties {
|
||||
return {
|
||||
'--primary': hexToRgb(theme.primary),
|
||||
'--accent': hexToRgb(theme.accent),
|
||||
'--bg': hexToRgb(theme.background),
|
||||
'--heading-font': `'${theme.headingFont}'`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
/** Applies a game's theme to the whole page while a room is open. */
|
||||
export function useTheme(theme: Theme | undefined) {
|
||||
const key = theme ? JSON.stringify(theme) : '';
|
||||
useEffect(() => {
|
||||
if (!key) return;
|
||||
const root = document.documentElement;
|
||||
const vars = themeVars(JSON.parse(key)) as Record<string, string>;
|
||||
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
|
||||
return () => {
|
||||
for (const name of Object.keys(vars)) root.style.removeProperty(name);
|
||||
};
|
||||
}, [key]);
|
||||
}
|
||||
|
||||
export const CATEGORY_LABEL: Record<Category, string> = {
|
||||
'murder-mystery': 'Murder Mystery',
|
||||
'escape-room': 'Escape Room',
|
||||
'co-op': 'Co-op',
|
||||
};
|
||||
|
||||
/** A is warm (amber), B is cool (teal). */
|
||||
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);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: 'info' | 'error';
|
||||
}
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function set(next: Toast[]) {
|
||||
toasts = next;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function pushToast(message: string, kind: Toast['kind'] = 'info') {
|
||||
const id = nextId++;
|
||||
set([...toasts.slice(-2), { id, message, kind }]);
|
||||
setTimeout(() => set(toasts.filter((t) => t.id !== id)), 4500);
|
||||
}
|
||||
|
||||
export function useToasts(): Toast[] {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => toasts,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { getLastRoom } from '../lib/session';
|
||||
|
||||
function JoinForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const clean = code.trim().toUpperCase();
|
||||
if (clean) navigate(`/join/${clean}`);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-xs gap-2">
|
||||
<input
|
||||
className="field text-center font-mono uppercase tracking-[0.3em]"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 5))}
|
||||
placeholder="CODE"
|
||||
aria-label="Room code from your partner"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="btn shrink-0" disabled={code.length < 5}>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const games = useGames();
|
||||
const lastRoom = getLastRoom();
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<section className="flex flex-col items-center py-10 text-center sm:py-16">
|
||||
<p className="chip text-accent">Date night, solved</p>
|
||||
<h1 className="mt-5 max-w-3xl font-display text-4xl font-bold leading-[1.1] sm:text-6xl">
|
||||
Two players. One mystery. <span className="text-accent">Every key needs both of you.</span>
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-lg leading-8 text-stone-300">
|
||||
Co-op mysteries for couples. You each see different clues, so neither of you can crack it alone. Talk it
|
||||
out, pool what you know, and open the locks together.
|
||||
</p>
|
||||
<div className="mt-8 flex w-full flex-col items-center gap-3">
|
||||
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
|
||||
Pick a mystery
|
||||
</Link>
|
||||
<span className="text-sm text-stone-500">or join your partner's game</span>
|
||||
<JoinForm />
|
||||
{lastRoom && (
|
||||
<Link to={`/room/${lastRoom}`} className="py-3 text-sm text-accent underline underline-offset-4">
|
||||
Back to your last game ({lastRoom})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="library">
|
||||
<div className="mb-4 flex items-end justify-between">
|
||||
<h2 id="library" className="font-display text-2xl font-bold">
|
||||
The library
|
||||
</h2>
|
||||
<Link to="/how-to-play" className="-my-3 py-3 text-sm text-accent underline underline-offset-4">
|
||||
How does it work?
|
||||
</Link>
|
||||
</div>
|
||||
{games === null ? (
|
||||
<p className="text-stone-400">Loading games…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="text-red-300">Couldn't load the games. Is the server running?</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{games.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: '🎟️',
|
||||
title: 'Pick a game, share the code',
|
||||
body: 'One of you starts a game and gets a five-letter room code and a link. The other joins on their own phone. Only have one device? Choose couch mode and pass it back and forth.',
|
||||
},
|
||||
{
|
||||
icon: '🎭',
|
||||
title: 'Choose your roles',
|
||||
body: "Every game has two roles, like Inspector and Estate Insider. Your role decides which clues you get. Your partner gets a completely different set.",
|
||||
},
|
||||
{
|
||||
icon: '🗣️',
|
||||
title: 'Talk it out',
|
||||
body: "Describe what you see. Don't just show your screen, that's cheating (and less fun). Every lock needs something from each of you.",
|
||||
},
|
||||
{
|
||||
icon: '🔑',
|
||||
title: 'Open the locks together',
|
||||
body: 'Either of you can type the answer. Each lock you open earns a key. Collect them all to reach the finale.',
|
||||
},
|
||||
];
|
||||
|
||||
const TIPS = [
|
||||
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
|
||||
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
|
||||
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
|
||||
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
|
||||
['🏆 Ranks', 'Finish under the estimated time with no hints to become Soulmate Sleuths.'],
|
||||
] as const;
|
||||
|
||||
export function HowToPlay() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<h1 className="mt-4 font-display text-4xl font-bold">How to play</h1>
|
||||
<p className="mt-2 max-w-2xl text-lg text-stone-300">
|
||||
Best played on a call or side by side. There's no in-game chat on purpose: the talking is the game.
|
||||
</p>
|
||||
|
||||
<ol className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title} className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl" aria-hidden>{step.icon}</span>
|
||||
<h2 className="font-display text-xl font-bold">
|
||||
{i + 1}. {step.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-2 leading-7 text-stone-300">{step.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="mt-10 font-display text-2xl font-bold">Good to know</h2>
|
||||
<dl className="mt-4 space-y-3">
|
||||
{TIPS.map(([term, text]) => (
|
||||
<div key={term} className="card !py-3">
|
||||
<dt className="font-semibold">{term}</dt>
|
||||
<dd className="mt-0.5 text-stone-300">{text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
|
||||
Pick a mystery
|
||||
</Link>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import type { Category } from '@two-keys/shared';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { SiteShell } from '../components/SiteShell';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { CATEGORY_LABEL } from '../lib/theme';
|
||||
|
||||
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
|
||||
|
||||
export function Library() {
|
||||
const games = useGames();
|
||||
const [filter, setFilter] = useState<Category | 'all'>('all');
|
||||
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<h1 className="mt-4 font-display text-4xl font-bold">Game library</h1>
|
||||
<p className="mt-2 text-stone-300">
|
||||
New here? Start with <strong>The Clockmaker's Attic</strong>. It's the gentlest first game.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
aria-pressed={filter === f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
|
||||
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{games === null ? (
|
||||
<p className="mt-6 text-stone-400">Loading games…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="mt-6 text-red-300">Couldn't load the games. Is the server running?</p>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{shown.map((game) => (
|
||||
<GameCard key={game.id} game={game} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
|
||||
function ShareBox({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const link = `${window.location.origin}/join/${code}`;
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard blocked: the link is on screen to copy by hand */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="card text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Room code</div>
|
||||
<div className="mt-1 font-mono text-5xl font-bold tracking-[0.25em] text-accent" aria-label={`Room code ${code.split('').join(' ')}`}>
|
||||
{code}
|
||||
</div>
|
||||
<p className="mt-3 break-all font-mono text-xs text-stone-400">{link}</p>
|
||||
<button className="btn mt-3 w-full" onClick={copy}>
|
||||
{copied ? 'Copied ✓' : 'Copy invite link'}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Lobby({ state }: { state: RoomState }) {
|
||||
const { game, you, partner } = state;
|
||||
const couch = state.mode === 'couch';
|
||||
const mySeat = you.seat === 'A' || you.seat === 'B' ? you.seat : null;
|
||||
|
||||
const holder = (role: Role) => (couch ? null : mySeat === role ? 'You' : partner?.seat === role ? 'Your partner' : null);
|
||||
|
||||
let status: string;
|
||||
if (couch) status = 'One device, two detectives. Decide who is who, then start.';
|
||||
else if (!partner) status = 'Waiting for your partner to join…';
|
||||
else if (!mySeat) status = 'Pick a role. Your partner gets the other one.';
|
||||
else if (you.ready && !partner.ready) status = "You're ready. Waiting for your partner…";
|
||||
else if (!you.ready && partner.ready) status = 'Your partner is ready. Tap Ready when you are!';
|
||||
else status = "Both here. Tap Ready when you're set.";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-6">
|
||||
<header className="text-center">
|
||||
<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>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{!couch && <ShareBox code={state.code} />}
|
||||
|
||||
<section aria-labelledby="roles">
|
||||
<h2 id="roles" className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">
|
||||
{couch ? 'The two roles' : 'Choose your role'}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['A', 'B'] as const).map((role) => {
|
||||
const who = holder(role);
|
||||
const mine = mySeat === role;
|
||||
return (
|
||||
<div key={role} className={`card flex flex-col ${mine ? `${ROLE_BORDER[role]} bg-white/[0.08]` : ''}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<RoleBadge role={role} name={game.roles[role].name} />
|
||||
{who && <span className="text-xs font-semibold text-stone-300">{who}</span>}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.roles[role].blurb}</p>
|
||||
{!couch && (
|
||||
<button
|
||||
className="btn mt-3"
|
||||
disabled={mine}
|
||||
onClick={() => socket.emit('room:pickRole', { role })}
|
||||
>
|
||||
{mine ? 'This is you' : who ? 'Swap roles' : `Play as ${game.roles[role].name}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<Markdown className="leading-7 text-stone-200">{game.intro}</Markdown>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-0 -mx-4 border-t border-white/10 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
|
||||
<p className="mb-2 text-center text-sm text-stone-300" aria-live="polite">
|
||||
{!couch && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${partner?.online ? 'bg-emerald-400' : 'bg-stone-500'}`}
|
||||
/>
|
||||
)}
|
||||
{status}
|
||||
</p>
|
||||
<button
|
||||
className={you.ready ? 'btn w-full' : 'btn-primary w-full'}
|
||||
disabled={!couch && !mySeat}
|
||||
onClick={() => socket.emit('room:ready')}
|
||||
>
|
||||
{couch ? 'Start the game' : you.ready ? 'Ready ✓ (tap to undo)' : "I'm ready"}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-xs text-stone-500">Best played on a call or side by side.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useState, type CSSProperties } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { otherRole, type Role, type RoomState } from '@two-keys/shared';
|
||||
import { KeyRing } from '../components/KeyRing';
|
||||
import { Notepad } from '../components/Notepad';
|
||||
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
|
||||
import { PassDeviceScreen } from '../components/PassDeviceScreen';
|
||||
import { Timer } from '../components/Timer';
|
||||
import { RoleBadge } from '../components/ui';
|
||||
import { DefuseGame } from '../engines/DefuseGame';
|
||||
import { LabyrinthGame } from '../engines/LabyrinthGame';
|
||||
import { StoryGame } from '../engines/StoryGame';
|
||||
import { SyncGame } from '../engines/SyncGame';
|
||||
import { socket, type Send } from '../lib/socket';
|
||||
|
||||
const ROLE_COLOR: Record<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
|
||||
|
||||
export function Play({ state, receivedAt }: { state: RoomState; receivedAt: number }) {
|
||||
const couch = state.you.seat === 'both';
|
||||
const [activeRole, setActiveRole] = useState<Role>(couch ? 'A' : (state.you.seat as Role));
|
||||
// Couch mode: the role waiting behind the "pass the device" cover, if it's up.
|
||||
const [cover, setCover] = useState<Role | null>(couch ? 'A' : null);
|
||||
|
||||
const send = useCallback<Send>(
|
||||
(event, payload = {}) => socket.emit(event, couch ? { ...payload, asRole: activeRole } : payload),
|
||||
[couch, activeRole],
|
||||
);
|
||||
|
||||
const { game } = state;
|
||||
const view = state.views[activeRole];
|
||||
|
||||
if (cover) {
|
||||
return (
|
||||
<PassDeviceScreen
|
||||
role={cover}
|
||||
roleName={game.roles[cover].name}
|
||||
onReveal={() => {
|
||||
setActiveRole(cover);
|
||||
setCover(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
|
||||
<header
|
||||
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
|
||||
🔑🔑
|
||||
</Link>
|
||||
<h1 className="min-w-0 flex-1 truncate font-display text-base font-bold">{game.title}</h1>
|
||||
<Timer timer={state.timer} receivedAt={receivedAt} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<KeyRing keys={state.keys} total={game.totalKeys} />
|
||||
{couch ? (
|
||||
<button
|
||||
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onClick={() => setCover(otherRole(activeRole))}
|
||||
>
|
||||
<span className="truncate">Pass to {game.roles[otherRole(activeRole)].name}</span>
|
||||
<span aria-hidden>👀</span>
|
||||
</button>
|
||||
) : (
|
||||
<PartnerStatus state={state} />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Notepad text={state.notepad} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="pb-5 pt-3">
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-stone-400">
|
||||
<span>You are</span>
|
||||
<RoleBadge role={activeRole} name={game.roles[activeRole].name} />
|
||||
</div>
|
||||
{view?.engine === 'story' && <StoryGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'defuse' && <DefuseGame view={view} state={state} receivedAt={receivedAt} send={send} />}
|
||||
{view?.engine === 'sync' && <SyncGame view={view} state={state} role={activeRole} send={send} />}
|
||||
{view?.engine === 'labyrinth' && <LabyrinthGame view={view} state={state} send={send} />}
|
||||
</main>
|
||||
|
||||
{!couch && <PingOverlay roles={game.roles} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatClock, type GameResult, type RoomState } from '@two-keys/shared';
|
||||
import { GameCard } from '../components/GameCard';
|
||||
import { Markdown } from '../components/ui';
|
||||
import { useGames } from '../lib/hooks';
|
||||
import { forgetLastRoom, saveBest } from '../lib/session';
|
||||
|
||||
const RANK_ICON: Record<string, string> = {
|
||||
'Soulmate Sleuths': '💞',
|
||||
'Dream Team': '🌟',
|
||||
'Getting There': '🧩',
|
||||
};
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<div className="card !p-3 text-center">
|
||||
<div className="font-mono text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xs uppercase tracking-wider text-stone-400">{label}</div>
|
||||
{note && <div className="mt-0.5 text-[11px] text-red-300">{note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Results({ state, result }: { state: RoomState; result: GameResult }) {
|
||||
const { game } = state;
|
||||
const games = useGames();
|
||||
const [isBest, setIsBest] = useState(false);
|
||||
|
||||
// Remember the best result for this game on this device.
|
||||
useEffect(() => {
|
||||
forgetLastRoom();
|
||||
if (!result.success) return;
|
||||
setIsBest(
|
||||
saveBest(game.id, {
|
||||
finalSeconds: result.finalSeconds,
|
||||
rank: result.rank,
|
||||
hintsUsed: result.hintsUsed,
|
||||
keysEarned: result.keysEarned,
|
||||
}),
|
||||
);
|
||||
}, [game.id, result]);
|
||||
|
||||
const others = (games ?? []).filter((g) => g.id !== game.id);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 px-4 py-8">
|
||||
<header className="text-center">
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-accent">{game.title}</div>
|
||||
<motion.div
|
||||
className="mt-3 text-7xl"
|
||||
aria-hidden
|
||||
initial={{ scale: 0.3, rotate: -20, opacity: 0 }}
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
|
||||
>
|
||||
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
|
||||
</motion.div>
|
||||
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
|
||||
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
|
||||
</header>
|
||||
|
||||
{result.ending && (
|
||||
<section className="card">
|
||||
<Markdown className="text-lg leading-8">{result.ending}</Markdown>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{result.reveal && (
|
||||
<section className="rounded-2xl border border-accent/40 bg-accent/10 p-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-accent">What really happened</h2>
|
||||
<Markdown className="mt-2 leading-7">{result.reveal}</Markdown>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-2 gap-3" aria-label="Your results">
|
||||
<Stat
|
||||
label="Final time"
|
||||
value={formatClock(result.finalSeconds)}
|
||||
note={result.penaltySeconds > 0 ? `includes +${formatClock(result.penaltySeconds)} penalty` : undefined}
|
||||
/>
|
||||
<Stat label="Keys earned" value={`${result.keysEarned}/${result.totalKeys}`} />
|
||||
<Stat label="Hints used" value={String(result.hintsUsed)} />
|
||||
<Stat label="Wrong guesses" value={String(result.wrongGuesses)} />
|
||||
</section>
|
||||
<p className="text-center text-xs text-stone-500">
|
||||
Estimated time for this game: {game.estimatedMinutes} minutes.
|
||||
</p>
|
||||
|
||||
{others.length > 0 && (
|
||||
<section aria-labelledby="another">
|
||||
<h2 id="another" className="mb-3 font-display text-2xl font-bold">
|
||||
Play another
|
||||
</h2>
|
||||
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-3">
|
||||
{others.map((g) => (
|
||||
<GameCard key={g.id} game={g} className="w-[78%] max-w-xs shrink-0 snap-center" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Link to="/" className="btn">
|
||||
Back to the library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { Spinner } from '../components/ui';
|
||||
import { useRoom } from '../lib/hooks';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { Lobby } from './Lobby';
|
||||
import { Play } from './Play';
|
||||
import { Results } from './Results';
|
||||
|
||||
/** /join/K7MRX is the share link; the room page does the actual joining. */
|
||||
export function JoinRedirect() {
|
||||
const { code = '' } = useParams();
|
||||
return <Navigate to={`/room/${code.toUpperCase()}`} replace />;
|
||||
}
|
||||
|
||||
export function Room() {
|
||||
const code = (useParams().code ?? '').toUpperCase();
|
||||
const { snapshot, error, connected } = useRoom(code);
|
||||
useTheme(snapshot?.state.game.theme);
|
||||
|
||||
if (error && !snapshot) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="text-5xl" aria-hidden>🗝️</div>
|
||||
<h1 className="font-display text-3xl font-bold">This door won't open</h1>
|
||||
<p className="max-w-sm text-stone-300">{error}</p>
|
||||
<Link to="/" className="btn-primary">
|
||||
Back to the library
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!snapshot) return <Spinner label={`Opening room ${code}…`} />;
|
||||
|
||||
const { state, receivedAt } = snapshot;
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
{!connected && (
|
||||
<div role="status" className="sticky top-0 z-30 bg-red-900 px-4 py-1.5 text-center text-sm font-medium">
|
||||
Connection lost. Reconnecting…
|
||||
</div>
|
||||
)}
|
||||
{state.phase === 'lobby' && <Lobby state={state} />}
|
||||
{/* Keyed by seat so a couch/duo rejoin starts from the right role. */}
|
||||
{state.phase === 'playing' && <Play key={state.you.seat} state={state} receivedAt={receivedAt} />}
|
||||
{state.phase === 'finished' && state.result && <Results state={state} result={state.result} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Per-game theme, set as CSS variables from the YAML `theme` block.
|
||||
primary: 'rgb(var(--primary) / <alpha-value>)',
|
||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||
bg: 'rgb(var(--bg) / <alpha-value>)',
|
||||
// Role colours: A is warm, B is cool.
|
||||
roleA: '#f5b942',
|
||||
roleB: '#2dd4bf',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
hand: ['Caveat', 'cursive'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const server = 'http://localhost:3001';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': server,
|
||||
'/socket.io': { target: server, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user