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:
parent
6975384751
commit
d064338145
1 changed files with 156 additions and 6 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue