feat(web): add multiplayer lobby waiting room

- connects WebSocket on mount, sends lobby:join after connection open
- registers handlers for lobby:state, game:question, error messages
- lobby:state updates player list in real time
- game:question navigates to game route (server re-sends via game:ready)
- displays lobby code as copyable button
- host sees Start Game button, disabled until 2+ players connected
- non-host sees waiting message
- cleanup sends lobby:leave and disconnects on unmount
- lobbyIdRef tracks lobby id for reliable cleanup before lobby state arrives
This commit is contained in:
lila 2026-04-18 10:10:25 +02:00
parent 6975384751
commit d064338145

View file

@ -1,9 +1,159 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState, useCallback } from "react";
import {
useWsClient,
useWsConnect,
useWsDisconnect,
} from "../../lib/ws-hooks.js";
import type { Lobby, WsLobbyState, WsError } from "@lila/shared";
export const Route = createFileRoute('/multiplayer/lobby/$code')({
component: RouteComponent,
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
export const Route = createFileRoute("/multiplayer/lobby/$code")({
component: LobbyPage,
});
function LobbyPage() {
const { code } = Route.useParams();
const { session } = Route.useRouteContext();
const currentUserId = session.user.id;
const navigate = useNavigate();
const client = useWsClient();
const connect = useWsConnect();
const disconnect = useWsDisconnect();
const [lobby, setLobby] = useState<Lobby | null>(null);
const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const handleLobbyState = useCallback((msg: WsLobbyState) => {
setLobby(msg.lobby);
setError(null);
}, []);
const handleGameQuestion = useCallback(() => {
void navigate({ to: "/multiplayer/game/$code", params: { code } });
}, [navigate, code]);
const handleWsError = useCallback((msg: WsError) => {
setError(msg.message);
setIsStarting(false);
}, []);
useEffect(() => {
client.on("lobby:state", handleLobbyState);
client.on("game:question", handleGameQuestion);
client.on("error", handleWsError);
void connect(API_URL)
.then(() => {
client.send({ type: "lobby:join", code });
})
.catch((err) => {
console.error("Failed to connect to WebSocket:", err);
setError("Could not connect to server. Please try again.");
});
function RouteComponent() {
return <div>Hello "/multiplayer/lobby/$code"!</div>
return () => {
client.off("lobby:state", handleLobbyState);
client.off("game:question", handleGameQuestion);
client.off("error", handleWsError);
client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" });
disconnect();
};
// Effect runs once on mount. All referenced values are stable:
// client/connect/disconnect from context (useCallback), handlers
// wrapped in useCallback, code is a URL param. lobbyIdRef is a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleStart = useCallback(() => {
if (!lobby) return;
setIsStarting(true);
client.send({ type: "lobby:start", lobbyId: lobby.id });
}, [lobby, client]);
if (!lobby) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">
{error ?? "Connecting..."}
</p>
</div>
);
}
const isHost = lobby.hostUserId === currentUserId;
const canStart = isHost && lobby.players.length >= 2 && !isStarting;
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">
{/* Lobby code */}
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-gray-500">Lobby code</p>
<button
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
onClick={() => {
void navigator.clipboard.writeText(code);
}}
title="Click to copy"
>
{code}
</button>
<p className="text-xs text-gray-400">Click to copy</p>
</div>
<div className="border-t border-gray-200" />
{/* Player list */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-gray-700">
Players ({lobby.players.length})
</h2>
<ul className="flex flex-col gap-1">
{lobby.players.map((player) => (
<li
key={player.userId}
className="flex items-center gap-2 text-sm text-gray-700"
>
<span className="w-2 h-2 rounded-full bg-green-400" />
{player.user.name}
{player.userId === lobby.hostUserId && (
<span className="text-xs text-purple-500 font-medium">
host
</span>
)}
</li>
))}
</ul>
</div>
{/* Error */}
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
{/* Start button — host only */}
{isHost && (
<button
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
onClick={handleStart}
disabled={!canStart}
>
{isStarting
? "Starting..."
: lobby.players.length < 2
? "Waiting for players..."
: "Start Game"}
</button>
)}
{/* Non-host waiting message */}
{!isHost && (
<p className="text-sm text-gray-500 text-center">
Waiting for host to start the game...
</p>
)}
</div>
</div>
);
}