Compare commits
3 commits
6975384751
...
974646ebfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
974646ebfb | ||
|
|
f2eb6ce17f | ||
|
|
d064338145 |
6 changed files with 503 additions and 20 deletions
114
apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
Normal file
114
apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -17,18 +17,22 @@ const RootLayout = () => {
|
||||||
<Link to="/" className="[&.active]:font-bold">
|
<Link to="/" className="[&.active]:font-bold">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/about" className="[&.active]:font-bold">
|
<Link to="/play" className="[&.active]:font-bold">
|
||||||
About
|
Play
|
||||||
|
</Link>
|
||||||
|
<Link to="/multiplayer" className="[&.active]:font-bold">
|
||||||
|
Multiplayer
|
||||||
</Link>
|
</Link>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{session ? (
|
{session ? (
|
||||||
<button
|
<button
|
||||||
className="text-sm text-gray-600 hover:text-gray-900"
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void (async () => {
|
void signOut()
|
||||||
await signOut();
|
.then(() => {
|
||||||
void navigate({ to: "/" });
|
void navigate({ to: "/" });
|
||||||
})().catch((err) => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
console.error("Sign out error:", err);
|
console.error("Sign out error:", err);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const Route = createFileRoute("/multiplayer")({
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
return { session };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,205 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js";
|
||||||
|
import { QuestionCard } from "../../components/game/QuestionCard.js";
|
||||||
|
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
|
||||||
|
import { GameRouteSearchSchema } from "@lila/shared";
|
||||||
|
|
||||||
export const Route = createFileRoute('/multiplayer/game/$code')({
|
import type {
|
||||||
component: RouteComponent,
|
WsGameQuestion,
|
||||||
})
|
WsGameAnswerResult,
|
||||||
|
WsGameFinished,
|
||||||
|
WsError,
|
||||||
|
} from "@lila/shared";
|
||||||
|
|
||||||
function RouteComponent() {
|
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||||
return <div>Hello "/multiplayer/game/$code"!</div>
|
|
||||||
|
export const Route = createFileRoute("/multiplayer/game/$code")({
|
||||||
|
component: GamePage,
|
||||||
|
validateSearch: GameRouteSearchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function GamePage() {
|
||||||
|
const { code } = Route.useParams();
|
||||||
|
const { lobbyId } = Route.useSearch();
|
||||||
|
const { session } = Route.useRouteContext();
|
||||||
|
const currentUserId = session.user.id;
|
||||||
|
const client = useWsClient();
|
||||||
|
const connect = useWsConnect();
|
||||||
|
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [answerResult, setAnswerResult] = useState<WsGameAnswerResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasAnswered, setHasAnswered] = useState(false);
|
||||||
|
const isConnectedRef = useRef(false);
|
||||||
|
|
||||||
|
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
||||||
|
setCurrentQuestion(msg);
|
||||||
|
setAnswerResult(null);
|
||||||
|
setHasAnswered(false);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => {
|
||||||
|
setAnswerResult(msg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameFinished = useCallback((msg: WsGameFinished) => {
|
||||||
|
setGameFinished(msg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWsError = useCallback((msg: WsError) => {
|
||||||
|
setError(msg.message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.on("game:question", handleGameQuestion);
|
||||||
|
client.on("game:answer_result", handleAnswerResult);
|
||||||
|
client.on("game:finished", handleGameFinished);
|
||||||
|
client.on("error", handleWsError);
|
||||||
|
|
||||||
|
if (!client.isConnected()) {
|
||||||
|
void connect(API_URL)
|
||||||
|
.then(() => {
|
||||||
|
client.send({ type: "game:ready", lobbyId });
|
||||||
|
isConnectedRef.current = true;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to connect to WebSocket:", err);
|
||||||
|
setError("Could not connect to server. Please try again.");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
client.send({ type: "game:ready", lobbyId });
|
||||||
|
isConnectedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.off("game:question", handleGameQuestion);
|
||||||
|
client.off("game:answer_result", handleAnswerResult);
|
||||||
|
client.off("game:finished", handleGameFinished);
|
||||||
|
client.off("error", handleWsError);
|
||||||
|
};
|
||||||
|
// stable deps: client, connect, lobbyId is a search param stable for
|
||||||
|
// this route. handlers wrapped in useCallback with stable deps.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnswer = useCallback(
|
||||||
|
(optionId: number) => {
|
||||||
|
if (hasAnswered || !currentQuestion) return;
|
||||||
|
setHasAnswered(true);
|
||||||
|
client.send({
|
||||||
|
type: "game:answer",
|
||||||
|
lobbyId,
|
||||||
|
questionId: currentQuestion.question.questionId,
|
||||||
|
selectedOptionId: optionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[hasAnswered, currentQuestion, client, lobbyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase: finished
|
||||||
|
if (gameFinished) {
|
||||||
|
return (
|
||||||
|
<MultiplayerScoreScreen
|
||||||
|
players={gameFinished.players}
|
||||||
|
winnerIds={gameFinished.winnerIds}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
lobbyCode={code}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: loading
|
||||||
|
if (!currentQuestion) {
|
||||||
|
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 ?? "Loading game..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: playing
|
||||||
|
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">
|
||||||
|
{/* Progress */}
|
||||||
|
<p className="text-sm text-gray-500 text-center">
|
||||||
|
Question {currentQuestion.questionNumber} of{" "}
|
||||||
|
{currentQuestion.totalQuestions}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<QuestionCard
|
||||||
|
question={currentQuestion.question}
|
||||||
|
questionNumber={currentQuestion.questionNumber}
|
||||||
|
totalQuestions={currentQuestion.totalQuestions}
|
||||||
|
currentResult={
|
||||||
|
answerResult
|
||||||
|
? {
|
||||||
|
questionId: currentQuestion.question.questionId,
|
||||||
|
isCorrect:
|
||||||
|
answerResult.results.find((r) => r.userId === currentUserId)
|
||||||
|
?.isCorrect ?? false,
|
||||||
|
correctOptionId: answerResult.correctOptionId,
|
||||||
|
selectedOptionId:
|
||||||
|
answerResult.results.find((r) => r.userId === currentUserId)
|
||||||
|
?.selectedOptionId ?? 0,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onNext={() => {
|
||||||
|
setAnswerResult(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
|
||||||
|
{/* Round results */}
|
||||||
|
{answerResult && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
Round results
|
||||||
|
</h3>
|
||||||
|
{answerResult.players.map((player) => {
|
||||||
|
const result = answerResult.results.find(
|
||||||
|
(r) => r.userId === player.userId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={player.userId}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700">{player.user.name}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
result?.isCorrect
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: "text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result?.selectedOptionId === null
|
||||||
|
? "Timed out"
|
||||||
|
: result?.isCorrect
|
||||||
|
? "✓ Correct"
|
||||||
|
: "✗ Wrong"}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">{player.score} pts</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,174 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
useWsClient,
|
||||||
|
useWsConnect,
|
||||||
|
useWsDisconnect,
|
||||||
|
} from "../../lib/ws-hooks.js";
|
||||||
|
import type {
|
||||||
|
Lobby,
|
||||||
|
WsLobbyState,
|
||||||
|
WsError,
|
||||||
|
WsGameQuestion,
|
||||||
|
} 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 lobbyIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleLobbyState = useCallback((msg: WsLobbyState) => {
|
||||||
|
setLobby(msg.lobby);
|
||||||
|
lobbyIdRef.current = msg.lobby.id;
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameQuestion = useCallback(
|
||||||
|
(_msg: WsGameQuestion) => {
|
||||||
|
void navigate({
|
||||||
|
to: "/multiplayer/game/$code",
|
||||||
|
params: { code },
|
||||||
|
search: { lobbyId: lobbyIdRef.current ?? "" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ export const JoinLobbyResponseSchema = LobbySchema;
|
||||||
|
|
||||||
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
|
export type JoinLobbyResponse = z.infer<typeof JoinLobbyResponseSchema>;
|
||||||
|
|
||||||
|
export const GameRouteSearchSchema = z.object({ lobbyId: z.uuid() });
|
||||||
|
export type GameRouteSearch = z.infer<typeof GameRouteSearchSchema>;
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// WebSocket: Client → Server
|
// WebSocket: Client → Server
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue