feat(web): add multiplayer lobby, game, and score screen routes

- lobby.$code.tsx: waiting room with live player list via lobby:state,
  copyable lobby code, host Start Game button (disabled until 2+ players),
  sends lobby:join on connect, lobby:leave on unmount
- game.$code.tsx: in-game view, sends game:ready on mount to get current
  question, handles game:question/answer_result/finished messages,
  reuses QuestionCard component, shows round results after each answer
- MultiplayerScoreScreen: final score screen sorted by score, highlights
  winner(s) with crown, handles ties via winnerIds array, Play Again
  navigates back to lobby, Leave goes to multiplayer landing
- GameRouteSearchSchema added to shared for typed lobbyId search param
  without requiring Zod in apps/web
This commit is contained in:
lila 2026-04-18 10:33:48 +02:00
parent d064338145
commit f2eb6ce17f
5 changed files with 340 additions and 11 deletions

View file

@ -0,0 +1,114 @@
import { useNavigate } from "@tanstack/react-router";
import type { LobbyPlayer } from "@lila/shared";
type MultiplayerScoreScreenProps = {
players: LobbyPlayer[];
winnerIds: string[];
currentUserId: string;
lobbyCode: string;
};
export const MultiplayerScoreScreen = ({
players,
winnerIds,
currentUserId,
lobbyCode,
}: MultiplayerScoreScreenProps) => {
const navigate = useNavigate();
const sortedPlayers = [...players].sort((a, b) => b.score - a.score);
const isWinner = winnerIds.includes(currentUserId);
const isTie = winnerIds.length > 1;
const winnerNames = winnerIds
.map((id) => players.find((p) => p.userId === id)?.user.name ?? id)
.join(" and ");
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
{/* Result header */}
<div className="text-center flex flex-col gap-1">
<h1 className="text-2xl font-bold text-purple-800">
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
</h1>
<p className="text-sm text-gray-500">
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
</p>
</div>
<div className="border-t border-gray-200" />
{/* Score list */}
<div className="flex flex-col gap-2">
{sortedPlayers.map((player, index) => {
const isCurrentUser = player.userId === currentUserId;
const isPlayerWinner = winnerIds.includes(player.userId);
return (
<div
key={player.userId}
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
isCurrentUser
? "bg-purple-50 border border-purple-200"
: "bg-gray-50"
}`}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-400 w-4">
{index + 1}.
</span>
<span
className={`text-sm font-medium ${
isCurrentUser ? "text-purple-800" : "text-gray-700"
}`}
>
{player.user.name}
{isCurrentUser && (
<span className="text-xs text-purple-400 ml-1">
(you)
</span>
)}
</span>
{isPlayerWinner && (
<span className="text-xs text-yellow-500 font-medium">
👑
</span>
)}
</div>
<span className="text-sm font-bold text-gray-700">
{player.score} pts
</span>
</div>
);
})}
</div>
<div className="border-t border-gray-200" />
{/* Actions */}
<div className="flex flex-col gap-3">
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
onClick={() => {
void navigate({
to: "/multiplayer/lobby/$code",
params: { code: lobbyCode },
});
}}
>
Play Again
</button>
<button
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
onClick={() => {
void navigate({ to: "/multiplayer" });
}}
>
Leave
</button>
</div>
</div>
</div>
);
};