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')({
|
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||||
component: RouteComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function RouteComponent() {
|
export const Route = createFileRoute("/multiplayer/lobby/$code")({
|
||||||
return <div>Hello "/multiplayer/lobby/$code"!</div>
|
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.");
|
||||||
|
});
|
||||||
|
|
||||||
|
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