import { MancalaGame, Pit } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; import BoardToolbar from '../components/board/BoardToolbar'; import BoardView from '../components/board/BoardView'; import Button from '../components/Button'; import HeaderBar from '../components/headerbar/HeaderBar'; import HeaderbarIcon from '../components/headerbar/HeaderbarIcon'; import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; import InfoPanel from '../components/InfoPanel'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import Row from '../components/Row'; import UserStatus from '../components/UserStatus'; import { channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, PlayedMove, TURN_TIMEOUT_MS } from '@mancala/core'; import { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { Theme } from '@mancala/core'; import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core"; import { BoardViewModel } from "@mancala/core"; import Center from '../components/Center'; import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; import { useTranslation } from 'react-i18next'; import MoveList from '../components/history/MoveList'; import ReviewBanner from '../components/history/ReviewBanner'; import useMoveKeys from '../hooks/useMoveKeys'; import { createBoardViewModel } from '../util/BoardViewModels'; import { getResultText } from '../util/GameResultText'; import CircularPanel from '../components/CircularPanel'; const GamePage: FunctionComponent<{ context: Context, userKey?: string, theme: Theme, }> = ({ context, userKey, theme }) => { let params = useParams<{ gameId: string }>(); const [game, setGame] = useState(undefined); const [userKeyWhoLeave, setUserKeyWhoLeave] = useState(undefined); const [boardViewModel, setBoardViewModel] = useState(undefined); const [boardId, setBoardId] = useState("-1"); const [pitAnimator, setPitAnimator] = useState(undefined); // It is a flag for ongoing action such as send game move. // We have to block future actions if there is an ongoing action. const [hasOngoingAction, setHasOngoingAction] = useState(false); const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); // Set while an earlier position is shown instead of the live board. const [viewedMove, setViewedMove] = useState(undefined); // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); // Ticks every second while a countdown is shown. const [now, setNow] = useState(Date.now()); const { height, width } = useWindowDimensions(); const navigate = useNavigate(); const { t } = useTranslation(); const [gameLoadingState, setLoadingStateGame] = useState>(LoadingState.Unset()); const checkIsSpectator = (game: Game) => userKey !== game.mancalaGame.player1Id && userKey !== game.mancalaGame.player2Id; const mancalaGame: MancalaGame | undefined = game?.mancalaGame; const isSpectator = game ? checkIsSpectator(game) : undefined; const isPlayer2 = !isSpectator && userKey === mancalaGame?.player2Id; const onGameUpdate = (pitAnimator: PitAnimator, newGame: Game) => { setGame(newGame); pitAnimator.setUpdatedGame(newGame); setHasOngoingAction(false); setGameUsersConnectionInfo(newGame.gameUsersConnectionInfo); } const isUserOnline = (userId: string) => { if (!gameUsersConnectionInfo) return false; const user1ConnectionInfo = gameUsersConnectionInfo.user1ConnectionInfo; const user2ConnectionInfo = gameUsersConnectionInfo.user2ConnectionInfo; if (user1ConnectionInfo.userId === userId) return user1ConnectionInfo.isOnline; if (user2ConnectionInfo.userId === userId) return user2ConnectionInfo.isOnline; return false; } const getPlayerName = (userId: string) => { const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]; return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t); } const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => { const newGame: Game = message as Game; newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame); onGameUpdate(pitAnimator, newGame); } const onGameCrashed = (message: any) => { const newCrashMessage = message as string; notyf.error(t("InternalErrorOccurred")); console.error("on_game_crash"); console.error(newCrashMessage); } const onGameUserLeave = (message: any) => { const userKeyWhoLeave = message; setUserKeyWhoLeave(userKeyWhoLeave); setHasOngoingAction(false); }; const onGameEnd = (message: GameEndMessage) => { setGameEnd(message); setHasOngoingAction(false); }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); }; const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => { const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message); context.rtmt.addMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); return () => { context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange); } }; // Spectators tell the server they are watching once their identity is known and the connection is open, // and again after every reconnect, since the server forgets spectators who go offline. React.useEffect(() => { if (!game || !userKey || !checkIsSpectator(game)) return; const listen = () => context.rtmt.sendMessage(channel_listen_game_events, game.id); if (context.rtmt.connectionState === "connected") listen(); context.rtmt.on("open", listen); return () => { context.rtmt.off("open", listen); if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unlisten_game_events, game.id); }; }, [game?.id, userKey]); const updateBoardViewModel = (boardViewModel: BoardViewModel) => { boardViewModel.id = v4(); setBoardId(boardViewModel.id); setBoardViewModel(boardViewModel); }; const getBoardIndex = (index: number) => { if (!game || !mancalaGame) return -1; const pitsLenght = mancalaGame.board.pits.length; if (userKey === mancalaGame.player2Id) return index + pitsLenght / 2; return index; }; const getOpponentId = () => mancalaGame?.player1Id === userKey ? mancalaGame?.player2Id : mancalaGame?.player1Id; const checkHasAnOngoingAction = () => hasOngoingAction; const onLeaveGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t)) return; swal({ title: t("AreYouSureToLeaveGame"), icon: "warning", buttons: [t("Yes"), t("Cancel")], dangerMode: true, }) .then((cancel) => { if (!cancel) { context.rtmt.sendMessage(channel_leave_game, {}); } }); }; const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t)) return; navigate("/loby") }; const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; } if(userKeyWhoLeave || gameEnd) { notyf.error(t("GameEnded")); return; } if (viewedMove !== undefined) { notyf.error(t("ViewingMove", { number: viewedMove })); return; } if(game.mancalaGame.state === "ended") { notyf.error(t("GameEnded")); return; } if (Util.checkConnectionAndMaybeAlert(context, t)) return; if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) { notyf.error(t("UCanOnlyPlayYourOwnPits")); return; } const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2); if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) { notyf.error(t("OpponentTurn")); return; } if (checkHasAnOngoingAction()) { notyf.error(t("UMustWaitUntilCurrentMoveComplete")); return; } if (!boardViewModel) return; //TODO: this check should be in mancala.js if (pit.stoneCount === 0) { notyf.error(t("UCanNotPlayEmptyPit")); return; } setHasOngoingAction(true); boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor = context.themeManager.theme.pitSelectedColor; updateBoardViewModel(boardViewModel); const gameMove: GameMove = { index: pitIndexForUser }; context.rtmt.sendMessage(channel_game_move, gameMove); }; React.useEffect(() => { let pitAnimator: PitAnimator | undefined; let unlistenMessages: () => void; setLoadingStateGame(LoadingState.Loading()) context.gameStore.get(params.gameId!!).then((loaded) => { if (loaded?.kind === "live") { const game = loaded.game; pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel); setPitAnimator(pitAnimator); onGameUpdate(pitAnimator, game); unlistenMessages = listenMessages(game, pitAnimator); setLoadingStateGame(LoadingState.Loaded({ value: game })) } else { setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) } }).catch((error) => { console.error(error); setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") })) }) return () => { unlistenMessages?.(); pitAnimator?.dispose(); }; }, []); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, context.themeManager.theme.textColor, context.themeManager.theme.textLightColor ); const isMobile = width < 600; const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; const isReviewing = viewedMove !== undefined && viewedMove < moves.length; const shownBoardViewModel = isReviewing && mancalaGame ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) : boardViewModel; const isGameOver = !!(userKeyWhoLeave || gameEnd || mancalaGame?.state === "ended"); const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); useMoveKeys({ previous: () => selectMove((isReviewing ? viewedMove! : moves.length) - 1), next: () => isReviewing && selectMove(viewedMove! + 1), }); const playerInfo = (userId: string) => { const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] .find((connectionInfo) => connectionInfo?.userId === userId); return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; }; // A game that ended by a timeout or inactivity is not over by the rules, so say why it ended. const endText = gameEnd && (gameEnd.endReason === "timeout" || gameEnd.endReason === "inactive") && mancalaGame ? `${t("GameEnded")} ยท ${getResultText({ result: gameEnd.result, endReason: gameEnd.endReason, player1: playerInfo(mancalaGame.player1Id), player2: playerInfo(mancalaGame.player2Id), } as FinishedGameSummary, isSpectator ? undefined : userKey, t)}` : undefined; // Countdown for the waiting player while the opponent is offline on their turn. const opponentInfo = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] .find((info) => info && info.userId !== userKey); const countdownMs = !isSpectator && !isGameOver && opponentInfo && !opponentInfo.isOnline && opponentInfo.offlineSince !== undefined && mancalaGame?.turnPlayerId === opponentInfo.userId ? Math.max(0, opponentInfo.offlineSince + TURN_TIMEOUT_MS - now) : undefined; React.useEffect(() => { if (countdownMs === undefined) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [countdownMs === undefined]); const formatCountdown = (ms: number) => { const seconds = Math.ceil(ms / 1000); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; const topLocatedUserName = getPlayerName(topLocatedUserId); const bottomLocatedUserName = getPlayerName(bottomLocatedUserId); const topLocatedUser = { id: topLocatedUserId, name: topLocatedUserName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), isAnonymous: !topLocatedUserName }; const bottomLocatedUser = { id: bottomLocatedUserId, name: bottomLocatedUserName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", isAnonymous: !bottomLocatedUserName }; const currentUser = isSpectator ? { id: "2", name: "Anonymous", isOnline: context.rtmt.connectionState === "connected", isAnonymous: true } : bottomLocatedUser; const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; return ( {renderHeaderBar()} {renderMobileBoardToolbar()} {buildBoardTopToolbar()} {showBoardView && countdownMs !== undefined && ( {t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })} )} {showBoardView && isReviewing && ( setViewedMove(undefined)} /> )} {showBoardView && ( )} {showBoardView && ( getPlayerName(playerId) ?? t("Anonymous")} collapsedByDefault={isMobile} /> )}
); function renderHeaderBar() { return