refactor(games): overhaul WS game system with improved UX and solo test support
Some checks failed
Deploy to Production / test (push) Failing after 35s
Some checks failed
Deploy to Production / test (push) Failing after 35s
Backend: - Fix session never being attached to ws.data at upgrade time - Add GameServer class: connection registry, per-connection room tracking, automatic room cleanup on disconnect via ws.data.rooms - Replace ws-handler.ts with typed event-driven architecture using mitt - Remove redundant subscription tracking from RoomManager - Add JOIN_RESULT with player/spectator lists replacing error-as-control-flow - Add SESSION_REPLACED for multi-tab same-account detection - Add FILL_ROOM command for admin solo testing (fills empty slots with host) - Fix dual-schema routing; remove game types from WsMessageSchema - Per-player personalized views sent directly after each action Chess plugin: - Allow same-player (solo) mode: skip color/turn ownership checks - Fix forfeit and disconnect handling in solo mode (winner: null) Frontend: - Click-to-move with legal move dots and last-move highlight - Auto-scroll move history, forfeit confirmation, turn-reactive board border - JOIN_RESULT initialises player/spectator lists immediately on join - Contextual connecting state, player slot cards in waiting room - Copy-invite button with Copied! flash, Back to Lobby CTA on finish - Session-replaced warning banner with Rejoin here action - Lobby passes preferAs intent through route state - Admin waiting room shows Start Solo Test button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -117,7 +117,9 @@ export function GameLobby() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
|
||||
onClick={() => navigate(`/${room.gameSlug}/${room.id}`, {
|
||||
state: { preferAs: room.status === "waiting" ? "player" : "spectator" }
|
||||
})}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
|
||||
room.status === "waiting"
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useGameRoom } from "../lib/useGameRoom";
|
||||
import { gameUIRegistry } from "./registry";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import "./chess";
|
||||
|
||||
function CopyInviteLink({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
||||
<div className="flex items-center gap-2 w-full max-w-sm">
|
||||
<span className="flex-1 font-mono bg-surface border border-border px-2 py-1.5 rounded text-[11px] text-text-tertiary truncate">
|
||||
{url}
|
||||
</span>
|
||||
<button
|
||||
onClick={copy}
|
||||
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
copied
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
||||
|
||||
const {
|
||||
gameState, players, spectators, roomStatus,
|
||||
isSpectator, gameOver, error, sendAction, leaveRoom,
|
||||
} = useGameRoom(roomId!, userId, role);
|
||||
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom,
|
||||
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||
|
||||
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||
|
||||
@@ -40,8 +74,11 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
|
||||
if (roomStatus === "connecting") {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +113,20 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessionReplaced && (
|
||||
<div className="mb-4 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-warning">
|
||||
You opened this game in another tab. Actions from this tab are disabled.
|
||||
</p>
|
||||
<button
|
||||
onClick={rejoin}
|
||||
className="shrink-0 text-xs font-medium text-warning underline hover:no-underline"
|
||||
>
|
||||
Rejoin here
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
@@ -93,19 +144,53 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roomStatus === "waiting" && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 md:p-8 text-center">
|
||||
<div className="text-sm text-text-tertiary mb-2">
|
||||
Waiting for players ({players.length}/2)
|
||||
</div>
|
||||
<div className="text-xs text-text-disabled">
|
||||
Share this URL to invite:
|
||||
<span className="block mt-1 font-mono bg-surface px-2 py-1 rounded select-all text-[11px] break-all">{window.location.href}</span>
|
||||
</div>
|
||||
{roomStatus === "finished" && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||
className="rounded-md bg-primary text-primary-foreground px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Back to Lobby
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(roomStatus === "playing" || roomStatus === "finished") && gameState && (
|
||||
{roomStatus === "waiting" && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 md:p-8">
|
||||
<div className="text-sm font-semibold mb-4 text-center">
|
||||
Waiting for players ({players.length}/{plugin.maxPlayers})
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center mb-6">
|
||||
{Array.from({ length: plugin.maxPlayers }).map((_, i) => {
|
||||
const player = players[i];
|
||||
return (
|
||||
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-lg border ${player ? "border-primary/40 bg-primary/5" : "border-border bg-surface"}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
||||
{player ? player.username[0]?.toUpperCase() : "?"}
|
||||
</div>
|
||||
<div className="text-xs font-medium">
|
||||
{player ? player.username : <span className="text-text-disabled">Waiting...</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-disabled">
|
||||
Player {i + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<CopyInviteLink url={window.location.href} />
|
||||
{role === "admin" && players.length < plugin.maxPlayers && (
|
||||
<button
|
||||
onClick={fillRoom}
|
||||
className="mt-4 w-full max-w-sm rounded-md px-4 py-2 text-sm font-medium bg-warning/10 text-warning border border-warning/30 hover:bg-warning/20 transition-colors"
|
||||
>
|
||||
Start Solo Test
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(roomStatus === "playing" || roomStatus === "finished") && gameState != null && (
|
||||
<GameComponent
|
||||
state={gameState}
|
||||
myPlayerId={userId}
|
||||
|
||||
@@ -15,9 +15,13 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
const chess = state as ChessState;
|
||||
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
|
||||
const [promotionTo, setPromotionTo] = useState<string | null>(null);
|
||||
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
|
||||
const [confirmForfeit, setConfirmForfeit] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const moveHistoryRef = useRef<HTMLDivElement>(null);
|
||||
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
|
||||
const [boardWidth, setBoardWidth] = useState(400);
|
||||
|
||||
|
||||
// Track latest state in ref to avoid stale closures
|
||||
const chessRef = useRef(chess);
|
||||
useEffect(() => {
|
||||
@@ -38,6 +42,14 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Auto-scroll move history to bottom when new moves arrive
|
||||
useEffect(() => {
|
||||
const el = moveHistoryRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [chess?.moveHistory?.length]);
|
||||
|
||||
const game = useMemo(() => {
|
||||
if (!chess?.fen) return null;
|
||||
return new Chess(chess.fen);
|
||||
@@ -54,13 +66,23 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
const turn = game.turn() === "w" ? "white" : "black";
|
||||
const isMyTurn = (isBothSides || myColor === turn) && !isSpectator;
|
||||
const boardOrientation = myColor ?? "white";
|
||||
const isGameOver = chess.status !== "playing";
|
||||
|
||||
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
||||
const opponent = players.find(p => p.discordId === opponentId);
|
||||
const me = players.find(p => p.discordId === myPlayerId);
|
||||
|
||||
// Determine if it's the opponent's turn
|
||||
const isOpponentTurn = !isMyTurn && !isSpectator && !isGameOver;
|
||||
|
||||
function dispatchMove(from: string, to: string, promotion?: string) {
|
||||
lastMoveRef.current = { from, to };
|
||||
setSelectedSquare(null);
|
||||
onAction({ type: "move", from, to, ...(promotion ? { promotion } : {}) });
|
||||
}
|
||||
|
||||
function onDrop(sourceSquare: string, targetSquare: string): boolean {
|
||||
if (isSpectator || !isMyTurn) return false;
|
||||
if (isSpectator || !isMyTurn || isGameOver) return false;
|
||||
|
||||
const testGame = new Chess(chessRef.current.fen);
|
||||
|
||||
@@ -71,6 +93,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||
setPromotionFrom(sourceSquare);
|
||||
setPromotionTo(targetSquare);
|
||||
lastMoveRef.current = { from: sourceSquare, to: targetSquare };
|
||||
return true; // allow the visual drop, handle promotion via dialog
|
||||
}
|
||||
}
|
||||
@@ -83,7 +106,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
}
|
||||
if (!move) return false;
|
||||
|
||||
onAction({ type: "move", from: sourceSquare, to: targetSquare });
|
||||
dispatchMove(sourceSquare, targetSquare);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -91,15 +114,79 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
if (promotionFrom && promotionTo) {
|
||||
// react-chessboard gives us e.g. "wQ" — extract just the piece letter lowercase
|
||||
const promotionPiece = piece[1]?.toLowerCase() ?? "q";
|
||||
onAction({ type: "move", from: promotionFrom, to: promotionTo, promotion: promotionPiece });
|
||||
dispatchMove(promotionFrom, promotionTo, promotionPiece);
|
||||
}
|
||||
setPromotionFrom(null);
|
||||
setPromotionTo(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSquareClick(square: string) {
|
||||
if (isSpectator || isGameOver) return;
|
||||
if (!isMyTurn) return;
|
||||
|
||||
// If promotion dialog is open, ignore clicks
|
||||
if (promotionFrom !== null) return;
|
||||
|
||||
const testGame = new Chess(chessRef.current.fen);
|
||||
|
||||
// If a square is already selected
|
||||
if (selectedSquare !== null) {
|
||||
// Clicking the same square again → deselect
|
||||
if (square === selectedSquare) {
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a valid destination from selected square
|
||||
const legalMoves = testGame.moves({ square: selectedSquare as any, verbose: true });
|
||||
const validDest = legalMoves.find(m => m.to === square);
|
||||
|
||||
if (validDest) {
|
||||
// Check for promotion
|
||||
const piece = testGame.get(selectedSquare as any);
|
||||
if (piece?.type === "p") {
|
||||
const targetRank = square[1];
|
||||
if ((piece.color === "w" && targetRank === "8") || (piece.color === "b" && targetRank === "1")) {
|
||||
setPromotionFrom(selectedSquare);
|
||||
setPromotionTo(square);
|
||||
lastMoveRef.current = { from: selectedSquare, to: square };
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchMove(selectedSquare, square);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a valid dest — check if clicked square has a moveable piece to switch selection
|
||||
const clickedPiece = testGame.get(square as any);
|
||||
if (clickedPiece) {
|
||||
const clickedColor = clickedPiece.color === "w" ? "white" : "black";
|
||||
const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor;
|
||||
if (canMovePiece) {
|
||||
setSelectedSquare(square);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise deselect
|
||||
setSelectedSquare(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// No square selected yet — select if we can move this piece
|
||||
const clickedPiece = testGame.get(square as any);
|
||||
if (!clickedPiece) return;
|
||||
const clickedColor = clickedPiece.color === "w" ? "white" : "black";
|
||||
const canMovePiece = isBothSides ? clickedColor === turn : clickedColor === myColor;
|
||||
if (canMovePiece) {
|
||||
setSelectedSquare(square);
|
||||
}
|
||||
}
|
||||
|
||||
function isDraggablePiece({ piece }: { piece: string }): boolean {
|
||||
if (isSpectator || !isMyTurn) return false;
|
||||
if (isSpectator || !isMyTurn || isGameOver) return false;
|
||||
if (isBothSides) {
|
||||
const pieceColor = piece[0] === "w" ? "white" : "black";
|
||||
return pieceColor === turn;
|
||||
@@ -108,8 +195,43 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
return pieceColor === myColor;
|
||||
}
|
||||
|
||||
// Highlight king in check
|
||||
// Build custom square styles: last move, selected, legal move dots, check
|
||||
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
||||
|
||||
// Last-move highlight
|
||||
const lastMove = lastMoveRef.current;
|
||||
if (lastMove) {
|
||||
const highlight: React.CSSProperties = { backgroundColor: "rgba(139, 92, 246, 0.2)" };
|
||||
customSquareStyles[lastMove.from] = { ...customSquareStyles[lastMove.from], ...highlight };
|
||||
customSquareStyles[lastMove.to] = { ...customSquareStyles[lastMove.to], ...highlight };
|
||||
}
|
||||
|
||||
// Selected square highlight
|
||||
if (selectedSquare !== null) {
|
||||
customSquareStyles[selectedSquare] = {
|
||||
...customSquareStyles[selectedSquare],
|
||||
boxShadow: "inset 0 0 0 3px rgba(139,92,246,0.7)",
|
||||
};
|
||||
|
||||
// Legal move dots
|
||||
const legalMoves = game.moves({ square: selectedSquare as any, verbose: true });
|
||||
for (const m of legalMoves) {
|
||||
const dest = m.to;
|
||||
const hasPiece = game.get(dest as any) !== null;
|
||||
const dotStyle: React.CSSProperties = hasPiece
|
||||
? {
|
||||
background: "radial-gradient(circle, rgba(139,92,246,0.35) 85%, transparent 87%)",
|
||||
borderRadius: "50%",
|
||||
}
|
||||
: {
|
||||
background: "radial-gradient(circle, rgba(139,92,246,0.5) 25%, transparent 27%)",
|
||||
borderRadius: "50%",
|
||||
};
|
||||
customSquareStyles[dest] = { ...customSquareStyles[dest], ...dotStyle };
|
||||
}
|
||||
}
|
||||
|
||||
// Check highlight (applied last so it's not overwritten by other styles)
|
||||
if (game.inCheck()) {
|
||||
const board = game.board();
|
||||
const kingColor = game.turn();
|
||||
@@ -119,7 +241,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
if (sq?.type === "k" && sq.color === kingColor) {
|
||||
const file = String.fromCharCode(97 + c);
|
||||
const rank = 8 - r;
|
||||
customSquareStyles[`${file}${rank}`] = {
|
||||
const key = `${file}${rank}`;
|
||||
customSquareStyles[key] = {
|
||||
...customSquareStyles[key],
|
||||
backgroundColor: "rgba(220, 38, 38, 0.45)",
|
||||
borderRadius: "50%",
|
||||
};
|
||||
@@ -128,6 +252,10 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
}
|
||||
}
|
||||
|
||||
const boardBorderClass = isMyTurn && !isGameOver
|
||||
? "border-primary"
|
||||
: "border-border";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Board column */}
|
||||
@@ -139,13 +267,19 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
</div>
|
||||
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
|
||||
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
||||
{isOpponentTurn && (
|
||||
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded animate-pulse">
|
||||
Their turn
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded overflow-hidden border-2 border-border">
|
||||
<div className={`rounded overflow-hidden border-2 transition-colors duration-300 ${boardBorderClass}`}>
|
||||
<Chessboard
|
||||
position={chess.fen}
|
||||
onPieceDrop={onDrop}
|
||||
onPromotionPieceSelect={handlePromotion}
|
||||
onSquareClick={onSquareClick}
|
||||
boardOrientation={boardOrientation}
|
||||
isDraggablePiece={isDraggablePiece}
|
||||
boardWidth={boardWidth}
|
||||
@@ -169,7 +303,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
</div>
|
||||
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
|
||||
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
|
||||
{isMyTurn && (
|
||||
{isMyTurn && !isGameOver && (
|
||||
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -179,7 +313,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
|
||||
<div className="bg-card rounded-lg border border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
||||
<div className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
|
||||
<div ref={moveHistoryRef} className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
|
||||
{chess.moveHistory.length === 0 ? (
|
||||
<div className="text-xs text-text-disabled">No moves yet</div>
|
||||
) : (
|
||||
@@ -196,12 +330,32 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
</div>
|
||||
|
||||
{!isSpectator && chess.status === "playing" && (
|
||||
<button
|
||||
onClick={() => onAction({ type: "forfeit" })}
|
||||
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||
>
|
||||
Forfeit
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{confirmForfeit ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAction({ type: "forfeit" })}
|
||||
className="flex-1 rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive border border-destructive hover:bg-destructive/25 transition-colors"
|
||||
>
|
||||
Confirm forfeit?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(false)}
|
||||
className="rounded-md px-2 py-2 text-sm font-medium bg-card border border-border hover:bg-surface transition-colors text-text-tertiary"
|
||||
aria-label="Cancel forfeit"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmForfeit(true)}
|
||||
className="w-full rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||
>
|
||||
Forfeit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,5 +5,6 @@ gameUIRegistry.register({
|
||||
slug: "chess",
|
||||
name: "Chess",
|
||||
icon: "♟",
|
||||
maxPlayers: 2,
|
||||
component: ChessBoard,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface GameUIPlugin {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
maxPlayers: number;
|
||||
component: ComponentType<GameUIProps>;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,9 @@ const plugins = new Map<string, GameUIPlugin>();
|
||||
|
||||
export const gameUIRegistry = {
|
||||
register(plugin: GameUIPlugin) {
|
||||
if (plugins.has(plugin.slug)) {
|
||||
throw new Error(`Game UI "${plugin.slug}" is already registered`);
|
||||
}
|
||||
plugins.set(plugin.slug, plugin);
|
||||
},
|
||||
get(slug: string): GameUIPlugin | undefined {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface PlayerInfo {
|
||||
discordId: string;
|
||||
@@ -14,10 +15,17 @@ interface GameRoomState {
|
||||
isSpectator: boolean;
|
||||
gameOver: { winner: string | null; reason: string } | null;
|
||||
error: string | null;
|
||||
sessionReplaced: boolean;
|
||||
}
|
||||
|
||||
export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
|
||||
const { send, subscribe, connected } = useWebSocket();
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef(navigate);
|
||||
useEffect(() => {
|
||||
navigateRef.current = navigate;
|
||||
}, [navigate]);
|
||||
|
||||
const [state, setState] = useState<GameRoomState>({
|
||||
gameState: null,
|
||||
players: [],
|
||||
@@ -26,17 +34,29 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
isSpectator: false,
|
||||
gameOver: null,
|
||||
error: null,
|
||||
sessionReplaced: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
send({ type: "JOIN_ROOM", roomId, as: "player", role: role ?? "player" });
|
||||
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
||||
|
||||
const unsubscribe = subscribe((msg: any) => {
|
||||
if (msg.roomId && msg.roomId !== roomId) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "JOIN_RESULT":
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSpectator: msg.joinedAs === "spectator",
|
||||
roomStatus: msg.roomStatus,
|
||||
players: msg.players ?? prev.players,
|
||||
spectators: msg.spectators ?? prev.spectators,
|
||||
gameState: msg.state !== undefined ? msg.state : prev.gameState,
|
||||
}));
|
||||
break;
|
||||
|
||||
case "GAME_STATE":
|
||||
setState(prev => ({ ...prev, gameState: msg.state, roomStatus: "playing" }));
|
||||
break;
|
||||
@@ -51,7 +71,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
|
||||
case "PLAYER_JOINED":
|
||||
setState(prev => {
|
||||
if (msg.as === "spectator") {
|
||||
if (msg.joinedAs === "spectator") {
|
||||
const isMe = msg.player.discordId === userId;
|
||||
return {
|
||||
...prev,
|
||||
@@ -60,7 +80,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
roomStatus: prev.roomStatus === "connecting" ? "waiting" : prev.roomStatus,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const isMe = msg.player.discordId === userId;
|
||||
return {
|
||||
...prev,
|
||||
@@ -87,11 +107,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
}));
|
||||
break;
|
||||
|
||||
case "SESSION_REPLACED":
|
||||
setState(prev => ({ ...prev, sessionReplaced: true }));
|
||||
break;
|
||||
|
||||
case "ERROR":
|
||||
if (msg.message === "Game already started" || msg.message === "Room is full") {
|
||||
send({ type: "JOIN_ROOM", roomId, as: "spectator" });
|
||||
} else if (msg.message === "Room not found") {
|
||||
if (msg.message === "Room not found") {
|
||||
setState(prev => ({ ...prev, roomStatus: "not_found" }));
|
||||
setTimeout(() => navigateRef.current("/games"), 2000);
|
||||
} else {
|
||||
setState(prev => ({ ...prev, error: msg.message }));
|
||||
}
|
||||
@@ -114,5 +137,14 @@ export function useGameRoom(roomId: string, userId: string, role?: string) {
|
||||
send({ type: "LEAVE_ROOM", roomId });
|
||||
}, [roomId, send]);
|
||||
|
||||
return { ...state, sendAction, leaveRoom };
|
||||
const rejoin = useCallback(() => {
|
||||
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
||||
setState(prev => ({ ...prev, sessionReplaced: false }));
|
||||
}, [roomId, preferAs, role, send]);
|
||||
|
||||
const fillRoom = useCallback(() => {
|
||||
send({ type: "FILL_ROOM", roomId });
|
||||
}, [roomId, send]);
|
||||
|
||||
return { ...state, sendAction, leaveRoom, rejoin, fillRoom };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user