diff --git a/client/src/components/game/GamePage.tsx b/client/src/components/game/GamePage.tsx index 8dff44f..d87ed26 100644 --- a/client/src/components/game/GamePage.tsx +++ b/client/src/components/game/GamePage.tsx @@ -1,79 +1,120 @@ "use client"; -// TODO: restructure? +// TODO: restructure import { Chessboard } from "react-chessboard"; import { IconCopy } from "@tabler/icons-react"; -import { useState, useEffect, useContext } from "react"; -import Image from "next/image"; -import { Game } from "@chessu/types"; +import { useState, useEffect, useContext, useReducer, useRef } from "react"; +import type { KeyboardEvent, FormEvent } from "react"; +//import Image from "next/image"; +import type { Game } from "@chessu/types"; +import type { Message } from "@/types"; import { io } from "socket.io-client"; import { API_URL } from "@/config"; import { SessionContext } from "@/context/session"; import { Chess } from "chess.js"; +import type { Square, Move } from "chess.js"; +import { initSocket, lobbyReducer, squareReducer } from "./handlers"; -const socket = io(API_URL, { withCredentials: true, autoConnect: true }); +const socket = io(API_URL, { withCredentials: true, autoConnect: false }); export default function GamePage({ initialLobby }: { initialLobby: Game }) { const session = useContext(SessionContext); - // TODO: useReducer - const [lobbyData, setLobbyData] = useState(initialLobby); - const [game, setGame] = useState(new Chess()); - const [side, setSide] = useState<"b" | "w" | "s">("s"); + const [lobby, updateLobby] = useReducer(lobbyReducer, { + ...initialLobby, + actualGame: new Chess(), + side: "s" + }); + const [customSquares, updateCustomSquares] = useReducer(squareReducer, { + options: {}, + lastMove: {}, + rightClicked: {}, + check: {} + }); + + const [moveFrom, setMoveFrom] = useState(""); + + const [chatMessages, setChatMessages] = useState([]); const [boardWidth, setBoardWidth] = useState(480); + const [playBtnLoading, setPlayBtnLoading] = useState(false); + + const chatlistRef = useRef(null); + useEffect(() => { if (!session?.user || !session.user?.id) return; + socket.connect(); window.addEventListener("resize", handleResize); handleResize(); - if (lobbyData.pgn && game.pgn() !== lobbyData.pgn) { - const gameCopy = { ...game } as Chess; - gameCopy.loadPgn(lobbyData.pgn as string); - setGame(gameCopy); + if (lobby.pgn && lobby.actualGame.pgn() !== lobby.pgn) { + const gameCopy = new Chess(); + gameCopy.loadPgn(lobby.pgn as string); + updateLobby({ type: "setGame", payload: gameCopy }); + + const lastMove = gameCopy.history({ verbose: true }).pop(); + + let lastMoveSquares = undefined; + let kingSquare = undefined; + if (lastMove) { + lastMoveSquares = { + [lastMove.from]: { background: "rgba(255, 255, 0, 0.4)" }, + [lastMove.to]: { background: "rgba(255, 255, 0, 0.4)" } + }; + } + if (gameCopy.inCheck()) { + const kingPos = gameCopy.board().reduce((acc, row, index) => { + const squareIndex = row.findIndex( + (square) => square && square.type === "k" && square.color === gameCopy.turn() + ); + return squareIndex >= 0 ? `${String.fromCharCode(squareIndex + 97)}${8 - index}` : acc; + }, ""); + kingSquare = { + [kingPos]: { + background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)", + borderRadius: "50%" + } + }; + } + updateCustomSquares({ + lastMove: lastMoveSquares, + check: kingSquare + }); } - if (lobbyData.black?.id === session?.user?.id) { - if (side !== "b") setSide("b"); - } else if (lobbyData.white?.id === session?.user?.id) { - if (side !== "w") setSide("w"); - } else if (side !== "s") { - setSide("s"); + if (lobby.black?.id === session?.user?.id) { + if (lobby.side !== "b") updateLobby({ type: "setSide", payload: "b" }); + } else if (lobby.white?.id === session?.user?.id) { + if (lobby.side !== "w") updateLobby({ type: "setSide", payload: "w" }); + } else if (lobby.side !== "s") { + updateLobby({ type: "setSide", payload: "s" }); } - socket.on("connect", () => { - socket.emit("joinLobby", initialLobby.code); - }); - // TODO: handle disconnect - - socket.on("receivedLatestLobby", (latestLobby: Game) => { - setLobbyData(latestLobby); - - if (latestLobby.pgn && latestLobby.pgn !== game.pgn()) { - const gameCopy = { ...game } as Chess; - gameCopy.loadPgn(lobbyData.pgn as string); - setGame(gameCopy); - } - - if (latestLobby.black?.id === session?.user?.id) { - if (side !== "b") setSide("b"); - } else if (latestLobby.white?.id === session?.user?.id) { - if (side !== "w") setSide("w"); - } else if (side !== "s") { - setSide("s"); - } + initSocket(session.user, socket, lobby, { + updateLobby, + addMessage, + updateCustomSquares, + makeMove }); return () => { - socket.off("connect"); - socket.off("receivedLatestLobby"); + window.removeEventListener("resize", handleResize); + socket.removeAllListeners(); + socket.disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // auto scroll down when new message is added + useEffect(() => { + const chatlist = chatlistRef.current; + if (!chatlist) return; + chatlist.scrollTop = chatlist.scrollHeight; + }, [chatMessages]); + function handleResize() { if (window.innerWidth >= 1920) { setBoardWidth(580); @@ -86,22 +127,225 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) { } } + function addMessage(message: Message) { + setChatMessages((prev) => [...prev, message]); + } + + function sendChat(message: string) { + if (!session?.user) return; + + socket.emit("chat", message); + addMessage({ author: session.user, message }); + } + + function chatKeyUp(e: KeyboardEvent) { + e.preventDefault(); + if (e.key === "Enter") { + const input = e.target as HTMLInputElement; + if (!input.value || input.value.length == 0) return; + sendChat(input.value); + input.value = ""; + } + } + + function chatClickSend(e: FormEvent) { + e.preventDefault(); + + const target = e.target as HTMLFormElement; + const input = target.elements.namedItem("chatInput") as HTMLInputElement; + if (!input.value || input.value.length == 0) return; + sendChat(input.value); + input.value = ""; + } + + function makeMove(m: { from: string; to: string; promotion?: string }) { + try { + const result = lobby.actualGame.move(m); + if (result) { + let kingSquare = undefined; + if (lobby.actualGame.inCheck()) { + const kingPos = lobby.actualGame.board().reduce((acc, row, index) => { + const squareIndex = row.findIndex( + (square) => square && square.type === "k" && square.color === lobby.actualGame.turn() + ); + return squareIndex >= 0 ? `${String.fromCharCode(squareIndex + 97)}${8 - index}` : acc; + }, ""); + kingSquare = { + [kingPos]: { + background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)", + borderRadius: "50%" + } + }; + } + updateCustomSquares({ + lastMove: { + [result.from]: { background: "rgba(255, 255, 0, 0.4)" }, + [result.to]: { background: "rgba(255, 255, 0, 0.4)" } + }, + options: {}, + check: kingSquare + }); + return true; + } else { + throw new Error("Invalid move"); + } + } catch (err) { + updateCustomSquares({ + options: {} + }); + console.log(m); + console.log(err); + return false; + } + } + + function isDraggablePiece({ piece }: { piece: string }) { + if (lobby.side === "s") return true; + return piece.startsWith(lobby.side); + } + + function onDrop(sourceSquare: Square, targetSquare: Square) { + if (lobby.side !== lobby.actualGame.turn()) return false; + + const moveDetails = { + from: sourceSquare, + to: targetSquare, + promotion: "q" + }; + + const move = makeMove(moveDetails); + if (!move) return false; // illegal move + socket.emit("sendMove", moveDetails); + return true; + } + + function getMoveOptions(square: Square) { + const moves = lobby.actualGame.moves({ + square, + verbose: true + }) as Move[]; + if (moves.length === 0) { + return; + } + + const newSquares: { + [square: string]: { background: string; borderRadius?: string }; + } = {}; + moves.map((move) => { + newSquares[move.to] = { + background: + lobby.actualGame.get(move.to as Square) && + lobby.actualGame.get(move.to as Square)?.color !== lobby.actualGame.get(square)?.color + ? "radial-gradient(circle, rgba(0,0,0,.1) 85%, transparent 85%)" + : "radial-gradient(circle, rgba(0,0,0,.1) 25%, transparent 25%)", + borderRadius: "50%" + }; + return move; + }); + newSquares[square] = { + background: "rgba(255, 255, 0, 0.4)" + }; + updateCustomSquares({ options: newSquares }); + } + + function onPieceDragBegin(_piece: string, sourceSquare: Square) { + if (lobby.side !== lobby.actualGame.turn()) return; + + getMoveOptions(sourceSquare); + } + + function onPieceDragEnd() { + updateCustomSquares({ options: {} }); + } + + function onSquareClick(square: Square) { + updateCustomSquares({ rightClicked: {} }); + if (lobby.side !== lobby.actualGame.turn()) return; + + function resetFirstMove(square: Square) { + setMoveFrom(square); + getMoveOptions(square); + } + + // from square + if (!moveFrom) { + resetFirstMove(square); + return; + } + + const moveDetails = { + from: moveFrom, + to: square, + promotion: "q" + }; + + const move = makeMove(moveDetails); + if (!move) { + resetFirstMove(square); + } else { + setMoveFrom(""); + socket.emit("sendMove", moveDetails); + } + } + + function onSquareRightClick(square: Square) { + const colour = "rgba(0, 0, 255, 0.4)"; + updateCustomSquares({ + rightClicked: { + ...customSquares.rightClicked, + [square]: + customSquares.rightClicked[square] && + customSquares.rightClicked[square]?.backgroundColor === colour + ? undefined + : { backgroundColor: colour } + } + }); + } + + function clickPlay(e: FormEvent) { + setPlayBtnLoading(true); + e.preventDefault(); + socket.emit("joinAsPlayer"); + } + return (
{/* overlay */} -
-
- Waiting for opponent. - + {(!lobby.white?.id || !lobby.black?.id) && ( +
+
+ Waiting for opponent. + {session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && ( + + )} +
-
+ )}
@@ -109,21 +353,15 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
-
-
- avatar -
-
- nize + {session?.user?.id === lobby.black?.id + ? lobby.white?.name || "(no one)" + : lobby.black?.name || "(no one)"}
vs
-
-
- avatar -
-
- notnize + {session?.user?.id === lobby.black?.id + ? lobby.black?.name || "(no one)" + : lobby.white?.name || "(no one)"}
@@ -132,13 +370,13 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) { Invite friends:
- ches.su/game/{initialLobby.code} + ches.su/game/{initialLobby.code} {/* TODO */}
- {(lobbyData.pgn || "") + {(lobby.actualGame.pgn() || "") .split(/\d+\./) .filter((move) => move.trim() !== "") .map((moveSet, i) => { @@ -159,18 +397,37 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
- chatbox -
+
    + {chatMessages.map((m, i) => ( +
  • + {m.author.name}: {m.message} +
  • + ))} +
+
- -
+ +
-
Spectators: nize, nize, nize
+ {lobby.observers && lobby.observers.length > 0 && ( +
+ Spectators: {lobby.observers?.map((o) => o.name).join(", ")} +
+ )} ); diff --git a/client/src/components/game/handlers.ts b/client/src/components/game/handlers.ts new file mode 100644 index 0000000..27ac08d --- /dev/null +++ b/client/src/components/game/handlers.ts @@ -0,0 +1,146 @@ +import type { Dispatch } from "react"; +import type { Action, Lobby, Message, CustomSquares } from "@/types"; +import { Chess } from "chess.js"; +import type { Game, User } from "@chessu/types"; +import type { Socket } from "socket.io-client"; + +export function lobbyReducer(lobby: Lobby, action: Action): Lobby { + switch (action.type) { + case "updateLobby": + return { ...lobby, ...action.payload }; + + case "setSide": + return { ...lobby, side: action.payload }; + + case "setGame": + return { ...lobby, actualGame: action.payload }; + + default: + throw new Error("Invalid action type"); + } +} + +export function squareReducer(squares: CustomSquares, action: Partial) { + return { ...squares, ...action }; +} + +export function initSocket( + user: User, + socket: Socket, + lobby: Lobby, + actions: { + updateLobby: Dispatch; + addMessage: Function; + updateCustomSquares: Dispatch>; + makeMove: Function; + } +) { + socket.on("connect", () => { + console.log("connected!"); + socket.emit("joinLobby", lobby.code); + }); + socket.on("disconnect", () => { + console.log("disconnected!"); + }); + // TODO: handle disconnect + + socket.on("chat", (message: Message) => { + actions.addMessage(message); + }); + + socket.on("receivedLatestGame", (latestGame: Game) => { + actions.updateLobby({ type: "updateLobby", payload: latestGame }); + + if (latestGame.pgn && latestGame.pgn !== lobby.actualGame.pgn()) { + const gameCopy = new Chess(); + gameCopy.loadPgn(latestGame.pgn as string); + actions.updateLobby({ type: "setGame", payload: gameCopy }); + + const lastMove = gameCopy.history({ verbose: true }).pop(); + + let lastMoveSquares = undefined; + let kingSquare = undefined; + if (lastMove) { + lastMoveSquares = { + [lastMove.from]: { background: "rgba(255, 255, 0, 0.4)" }, + [lastMove.to]: { background: "rgba(255, 255, 0, 0.4)" } + }; + } + if (gameCopy.inCheck()) { + const kingPos = gameCopy.board().reduce((acc, row, index) => { + const squareIndex = row.findIndex( + (square) => + square && square.type === "k" && square.color === gameCopy.turn() + ); + return squareIndex >= 0 + ? `${String.fromCharCode(squareIndex + 97)}${8 - index}` + : acc; + }, ""); + kingSquare = { + [kingPos]: { + background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)", + borderRadius: "50%" + } + }; + } + actions.updateCustomSquares({ + lastMove: lastMoveSquares, + check: kingSquare + }); + } + + if (latestGame.black?.id === user?.id) { + if (lobby.side !== "b") actions.updateLobby({ type: "setSide", payload: "b" }); + } else if (latestGame.white?.id === user?.id) { + if (lobby.side !== "w") actions.updateLobby({ type: "setSide", payload: "w" }); + } else if (lobby.side !== "s") { + actions.updateLobby({ type: "setSide", payload: "s" }); + } + }); + + socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => { + const success = actions.makeMove(m); + if (!success) { + socket.emit("getLatestGame"); + } + }); + + socket.on("userJoinedAsPlayer", ({ name, side }: { name: string; side: "white" | "black" }) => { + actions.addMessage({ + author: { name: "server" }, + message: `${name} is now playing as ${side}.` + }); + }); + + socket.on( + "gameOver", + ({ + reason, + winnerName, + winnerSide + }: { + reason: string; + winnerName?: string; + winnerSide?: string; + }) => { + const m = { + author: { name: "game" } + } as Message; + + if (reason === "checkmate") { + m.message = `${winnerName}(${winnerSide}) has won by checkmate.`; + } else { + let message = "The game has ended in a draw"; + if (reason === "repetition") { + message = message.concat(" due to threefold repetition"); + } else if (reason === "insufficient") { + message = message.concat(" due to insufficient material"); + } else if (reason === "stalemate") { + message = "The game has been drawn due to stalemate"; + } + m.message = message.concat("."); + } + actions.addMessage(m); + } + ); +} diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..aafc632 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,33 @@ +import type { Game, User } from "@chessu/types"; +import type { Chess } from "chess.js"; + +export interface Lobby extends Game { + actualGame: Chess; + side: "b" | "w" | "s"; +} + +export interface CustomSquares { + options: { [square: string]: { background: string; borderRadius?: string } }; + lastMove: { [square: string]: { background: string } }; + rightClicked: { [square: string]: { backgroundColor: string } | undefined }; + check: { [square: string]: { background: string; borderRadius?: string } }; +} + +export type Action = + | { + type: "updateLobby"; + payload: Partial; + } + | { + type: "setSide"; + payload: Lobby["side"]; + } + | { + type: "setGame"; + payload: Chess; + }; + +export interface Message { + author: User; + message: string; +}