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

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

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

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

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

Difficulty runs from 1 to 5.

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

49 lines
2.1 KiB
TypeScript

import { formatClock, type RoomState } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
/** Elapsed time plus penalties, or a countdown when the game has a time limit. Runs locally between server updates. */
export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000;
if (timer.limitSeconds !== null) {
const used = elapsed + timer.penaltySeconds;
const left = Math.max(0, timer.limitSeconds - used);
const label = timer.limitLabel ?? 'Time left';
return (
<div className="flex shrink-0 flex-col items-end" role="timer" aria-label={`${label}: ${formatClock(left)} left`}>
<div className="flex items-baseline gap-1.5">
<span className="max-w-[7.5rem] truncate text-[10px] font-semibold uppercase tracking-wider text-stone-400">{label}</span>
<span className={`font-mono text-base font-bold tabular-nums ${left <= 300 ? 'text-red-300' : ''}`}>
{formatClock(left)}
</span>
</div>
{/* Fills up as the time (and every hint penalty) runs out. */}
<div className="mt-0.5 h-1 w-28 overflow-hidden rounded-full bg-white/10" aria-hidden>
<div
className={`h-full ${left <= 300 ? 'bg-red-400' : 'bg-accent'}`}
style={{ width: `${Math.min(100, (used / timer.limitSeconds) * 100)}%` }}
/>
</div>
</div>
);
}
return (
<div
className="flex items-baseline gap-1.5 font-mono tabular-nums"
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>
);
}