From 2a10b4a519655b9d7ef6d2c1a39726ef6400a2c7 Mon Sep 17 00:00:00 2001 From: Nathan Mande Date: Mon, 9 Dec 2024 20:21:18 +0200 Subject: [PATCH] feat(web): implement simplified free mode logic --- .../main-menu/new-section.component.tsx | 49 ++++--- apps/web/src/routes/_hooks/use-game.hook.ts | 129 ----------------- apps/web/src/routes/_stores/game.store.ts | 6 +- .../play/_components/free-mode.component.tsx | 131 +++++++++++++++++- apps/web/src/routes/play/index.tsx | 72 +++++++--- 5 files changed, 216 insertions(+), 171 deletions(-) delete mode 100644 apps/web/src/routes/_hooks/use-game.hook.ts diff --git a/apps/web/src/routes/_components/main-menu/new-section.component.tsx b/apps/web/src/routes/_components/main-menu/new-section.component.tsx index b04d16c..9e8733e 100644 --- a/apps/web/src/routes/_components/main-menu/new-section.component.tsx +++ b/apps/web/src/routes/_components/main-menu/new-section.component.tsx @@ -1,17 +1,20 @@ import { FC } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { GameMode, MainMenuSection } from "../../../shared/enum"; -import { useMainMenuStore } from "../../_stores"; +import { useGameStore, useMainMenuStore } from "../../_stores"; export const NewGameSection: FC = () => { + const navigate = useNavigate(); + + const { reset: resetGame } = useGameStore(); const { setSection } = useMainMenuStore(); const GameModeOptions: { label: string; title: string; mode: keyof typeof GameMode; - action?: () => void; + action: () => void; }[] = [ { label: "AI", @@ -25,7 +28,15 @@ export const NewGameSection: FC = () => { title: "Play against another human player", action: () => setSection(MainMenuSection.newGameHuman) }, - { label: "Free Mode", mode: "free", title: "Play against yourself" }, + { + label: "Free Mode", + mode: "free", + title: "Play against yourself", + action: () => { + navigate("/play?mode=free"); + resetGame(); + } + }, { label: "Simulation", mode: "simulation", @@ -40,24 +51,18 @@ export const NewGameSection: FC = () => {

Choose your game mode:

- {GameModeOptions.map((option) => { - const Component = option.action ? "button" : Link; - - return ( - - {option.label} - - ); - })} + {GameModeOptions.map((option) => ( + + ))}
diff --git a/apps/web/src/routes/_hooks/use-game.hook.ts b/apps/web/src/routes/_hooks/use-game.hook.ts deleted file mode 100644 index ae78d55..0000000 --- a/apps/web/src/routes/_hooks/use-game.hook.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { Move } from "chess.js"; -import { register, RegisterModule } from "@quick-threejs/reactive"; - -import { - EngineGameUpdatedMessageEventPayload, - MessageEventPayload -} from "../../shared/types"; -import { GAME_UPDATED_TOKEN, PIECE_WILL_MOVE_TOKEN } from "../../shared/tokens"; -import { PlayerModel } from "../../shared/models"; - -/** @description Game login worker location. */ -const workerLocation = new URL( - "../../core/game/game.worker.ts", - import.meta.url -) as unknown as string; - -/** @description Provide resources about the chess game and the application logic. */ -export const useGame = () => { - const [app, setApp] = useState(); - - const state = useRef<{ - app?: RegisterModule; - isPending: boolean; - isReady: boolean; - }>({ isPending: false, isReady: false }); - - const [players, setPlayers] = useState([]); - - const handleGmeUpdate = useRef< - | ((payload?: EngineGameUpdatedMessageEventPayload["value"]) => unknown) - | null - >(); - - const onGameUpdate = useCallback( - ( - callback: ( - payload: EngineGameUpdatedMessageEventPayload["value"] - ) => unknown - ) => { - handleGmeUpdate.current = callback; - }, - [] - ); - - const performPieceMove = useCallback((move: Move) => { - state.current.app?.worker()?.postMessage?.({ - token: PIECE_WILL_MOVE_TOKEN, - value: move - } satisfies MessageEventPayload); - }, []); - - const handleMessages = useCallback( - (payload: MessageEvent) => { - if (!payload.data?.token) return; - - if (payload.data.token === GAME_UPDATED_TOKEN && payload.data?.value?.fen) - handleGmeUpdate.current?.(payload.data.value); - }, - [] - ); - - const init = useCallback(() => { - if (state.current.isPending || state.current.isReady) return; - state.current.isPending = true; - - register({ - location: workerLocation, - enableDebug: !!import.meta.env?.DEV, - axesSizes: 5, - gridSizes: 10, - withMiniCamera: true, - onReady: (_app) => { - _app.worker()?.addEventListener("message", handleMessages); - - state.current.app = _app; - state.current.isPending = false; - state.current.isReady = true; - - setApp(_app); - } - }); - }, [state, handleMessages]); - - const createPlayer = useCallback(() => { - const player = new PlayerModel(); - setPlayers((prev) => [...prev, player]); - - return player; - }, []); - - const removePlayer = useCallback( - (player: PlayerModel) => - setPlayers((prev) => - prev.filter((_player) => { - if (_player !== player) return true; - - _player.unsubscribe(); - _player.complete(); - - return false; - }) - ), - [] - ); - - const dispose = useCallback(() => { - app?.worker()?.removeEventListener("message", handleMessages); - app?.dispose(); - - state.current.app = undefined; - state.current.isReady = false; - state.current.isPending = false; - - setApp(undefined); - }, [app, handleMessages]); - - return { - app, - state: state.current, - init, - players, - createPlayer, - removePlayer, - performPieceMove, - onGameUpdate, - dispose - }; -}; diff --git a/apps/web/src/routes/_stores/game.store.ts b/apps/web/src/routes/_stores/game.store.ts index 83e154d..2061854 100644 --- a/apps/web/src/routes/_stores/game.store.ts +++ b/apps/web/src/routes/_stores/game.store.ts @@ -4,12 +4,14 @@ import { Properties } from "@quick-threejs/utils"; export interface GameStore { app?: RegisterModule; - setApp: (app?: RegisterModule) => void; + reset: () => void; + setApp: (app: RegisterModule | undefined) => void; } -export const gameInitialState: Properties = {}; +export const gameInitialState: Properties = { app: undefined }; export const useGameStore = create((set) => ({ ...gameInitialState, + reset: () => set(() => ({ ...gameInitialState })), setApp: (app?: RegisterModule) => set(() => ({ app })) })); diff --git a/apps/web/src/routes/play/_components/free-mode.component.tsx b/apps/web/src/routes/play/_components/free-mode.component.tsx index 21609c9..df2f9fd 100644 --- a/apps/web/src/routes/play/_components/free-mode.component.tsx +++ b/apps/web/src/routes/play/_components/free-mode.component.tsx @@ -1,5 +1,134 @@ -import { FC, Fragment } from "react"; +import { Move } from "chess.js"; +import { FC, Fragment, useCallback, useEffect } from "react"; +import { merge } from "rxjs"; + +import { useGameStore } from "../../_stores"; +import { PlayerModel } from "../../../shared/models"; +import { + GAME_UPDATED_TOKEN, + PIECE_WILL_MOVE_TOKEN +} from "../../../shared/tokens"; +import { + EngineGameUpdatedMessageEventPayload, + MessageEventPayload +} from "../../../shared/types"; +import { ColorSide, PlayerEntity } from "@chess-d/shared"; export const FreeModeComponent: FC = () => { + const { app } = useGameStore(); + + const performPieceMove = useCallback( + (move: Move) => { + app?.worker()?.postMessage?.({ + token: PIECE_WILL_MOVE_TOKEN, + value: move + } satisfies MessageEventPayload); + }, + [app] + ); + + const createPlayer = useCallback((identity: PlayerEntity) => { + const player = new PlayerModel(); + player.color = identity.color as ColorSide; + + return player; + }, []); + + useEffect(() => { + const appGui = app?.gui(); + const guiFreeMode = appGui.addFolder("Free Mode"); + + const _players = [ + createPlayer({ color: ColorSide.white }), + createPlayer({ color: ColorSide.black }) + ]; + + if (import.meta.env?.DEV) { + const guiPlayer1 = guiFreeMode.addFolder("Player 1"); + const player1Positions = { piece: "p", from: "a2", to: "a3" }; + guiPlayer1.add(player1Positions, "piece"); + guiPlayer1.add(player1Positions, "from"); + guiPlayer1.add(player1Positions, "to"); + guiPlayer1.add( + { + "Perform Move": () => { + const move = { + color: _players[0]?.color, + from: player1Positions.from, + to: player1Positions.to, + piece: player1Positions.piece + } as Move; + + _players[0]?.next({ + token: "PLACED_PIECE", + value: { move } + }); + } + }, + "Perform Move" + ); + + const guiPlayer2 = guiFreeMode.addFolder("Player 2"); + const player2Positions = { piece: "p", from: "a7", to: "a6" }; + guiPlayer2.add(player2Positions, "piece"); + guiPlayer2.add(player2Positions, "from"); + guiPlayer2.add(player2Positions, "to"); + guiPlayer2.add( + { + "Perform Move": () => { + const move = { + color: _players[1]?.color, + from: player2Positions.from, + to: player2Positions.to, + piece: player2Positions.piece + } as Move; + + _players[1]?.next({ + token: "PLACED_PIECE", + value: { move } + }); + } + }, + "Perform Move" + ); + } + + const handleMessages = ( + payload: MessageEvent + ) => { + if (!payload.data?.token) return; + + if ( + payload.data.token === GAME_UPDATED_TOKEN && + payload.data?.value?.fen + ) { + _players.forEach((player) => { + player.next({ + token: "NOTIFIED", + value: payload.data.value + }); + }); + } + }; + + const playersSubscription = merge(..._players).subscribe((payload) => { + if (payload.token === "PLACED_PIECE" && payload.value?.move) + return performPieceMove(payload.value?.move); + }); + + app?.worker()?.addEventListener("message", handleMessages); + + return () => { + playersSubscription.unsubscribe(); + _players.forEach((player) => { + player.complete(); + player.unsubscribe(); + _players.shift(); + }); + app?.worker()?.removeEventListener("message", handleMessages); + guiFreeMode.destroy(); + }; + }, [app, createPlayer, performPieceMove]); + return ; }; diff --git a/apps/web/src/routes/play/index.tsx b/apps/web/src/routes/play/index.tsx index 1a537ac..37c8a9a 100644 --- a/apps/web/src/routes/play/index.tsx +++ b/apps/web/src/routes/play/index.tsx @@ -1,28 +1,66 @@ -import { FC, useEffect } from "react"; +import { register, RegisterModule } from "@quick-threejs/reactive"; +import { FC, useCallback, useEffect, useRef } from "react"; -import { useGame } from "../_hooks"; import { useGameStore } from "../_stores"; +import { FreeModeComponent } from "./_components/free-mode.component"; + +/** @internal */ +const workerLocation = new URL( + "../../core/game/game.worker.ts", + import.meta.url +) as unknown as string; export const PlayRoute: FC = () => { - const { setApp } = useGameStore(); - const { - state: gameState, - init: initGame, - createPlayer: createFreePlayer, - dispose: disposeGame - } = useGame(); + const { app, setApp } = useGameStore(); - useEffect(() => { - if (gameState.app || gameState.isPending || gameState.isReady) - return setApp(gameState.app); + const state = useRef<{ + isPending: boolean; + isReady: boolean; + }>({ isPending: false, isReady: false }); + + const init = useCallback( + () => + new Promise((resolve) => { + if (state.current.isPending || state.current.isReady) return; + state.current.isPending = true; + + register({ + location: workerLocation, + enableDebug: !!import.meta.env?.DEV, + axesSizes: 5, + gridSizes: 10, + withMiniCamera: true, + onReady: (_app) => { + state.current.isPending = false; + state.current.isReady = true; + + resolve(_app); + } + }); + }), + [] + ); + + const dispose = useCallback(() => { + if (state.current.isPending || !state.current.isReady) return; - initGame(); + state.current.isPending = false; + state.current.isReady = false; + + app?.dispose(); + + setApp(undefined); + }, [app, setApp]); + + useEffect(() => { + if (!app) init().then(setApp); return () => { - if (gameState.app && !gameState.isPending && gameState.isReady) - disposeGame(); + if (app) dispose(); }; - }, [createFreePlayer, disposeGame, gameState, initGame, setApp]); + }, [app, init, dispose, setApp]); + + if (!app) return null; - return null; + return ; };