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>
49 lines
2.1 KiB
TypeScript
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>
|
|
);
|
|
}
|