mancala/apps/frontend/src/routes/GamePage.tsx
Halit Aksoy e66c4a3a41 feat(frontend): offer games against the bot
The lobby offers the bot levels after 10 seconds without an opponent, the
home page has a Play with bot menu, and a game against the bot ends with
Play again at the same level.
2026-10-16 14:22:04 +03:00

439 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { v4 } from 'uuid';
import { PitAnimator } from '@mancala/core';
import BoardView from '../components/board/BoardView';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import AppHeader from '../components/headerbar/AppHeader';
import { LeaveIcon, PlusIcon } from '../components/Icons';
import { GameRecord, 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, getSeat, PlayedMove } from '@mancala/core';
import { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
import { BoardViewModel } from "@mancala/core";
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 ReplayView from '../components/history/ReplayView';
import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock';
import PlayerCard from '../components/game/PlayerCard';
import GameOverCard from '../components/game/GameOverCard';
import '../styles/game.css';
const GamePage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
}> = ({ context, userKey, theme }) => {
let params = useParams<{ gameId: string }>();
const [game, setGame] = useState<Game | undefined>(undefined);
const [userKeyWhoLeave, setUserKeyWhoLeave] = useState<string | undefined>(undefined);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
const [boardId, setBoardId] = useState<string>("-1");
const [pitAnimator, setPitAnimator] = useState<PitAnimator | undefined>(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<boolean>(false);
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
// Set while an earlier position is shown instead of the live board.
const [viewedMove, setViewedMove] = useState<number | undefined>(undefined);
// Set when the game has ended and was loaded from history; the page then shows the replay.
const [replayRecord, setReplayRecord] = useState<GameRecord | undefined>(undefined);
// Set when the server says the game ended, for whatever reason.
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
const navigate = useNavigate();
const { t } = useTranslation();
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(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("Cancel"), t("Yes")],
dangerMode: true,
})
.then((confirm) => {
if (confirm) {
context.rtmt.sendMessage(channel_leave_game, {});
navigate("/");
}
});
};
const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate("/loby")
};
// In a game against the bot, the game over card starts another one at the same level.
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
const onPlayAgainClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate(`/loby?bot=${opponentBotLevel}`)
};
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);
const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
selectedPit.highlight = "selected";
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 if (loaded?.kind === "finished") {
setReplayRecord(loaded.record);
} else {
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
}
}).catch((error) => {
console.error(error);
setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") }))
})
return () => {
unlistenMessages?.();
pitAnimator?.dispose();
};
}, []);
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: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1),
next: () => !replayRecord && 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 };
};
// How the game ended, as a saved game would say it; from the server's message when it sent one.
const getEndSummary = (): FinishedGameSummary | undefined => {
if (!mancalaGame || !isGameOver) return undefined;
const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id);
const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2";
if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary;
if (userKeyWhoLeave) {
const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1";
return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary;
}
const winnerId = mancalaGame.getWonPlayerId();
return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary;
};
const endSummary = getEndSummary();
// "You won · Opponent left" becomes the title "You won" and the reason "Opponent left".
const [resultTitle, ...resultReasons] = endSummary
? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ")
: [];
const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId;
const resultReason = resultReasons.join(" · ")
|| (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined);
const clockTimes = useGameClock(game, gameEnd);
const renderClock = (playerId: string) => {
const seat = mancalaGame && getSeat(mancalaGame, playerId);
if (!clockTimes || !seat) return undefined;
const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
return <GameClockView timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
};
const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
const topLocatedUser = {
id: topLocatedUserId,
name: getPlayerName(topLocatedUserId) ?? t("Anonymous"),
isGuest: playerInfo(topLocatedUserId).isGuest,
isOnline: isUserOnline(topLocatedUserId),
};
const bottomLocatedUser = {
id: bottomLocatedUserId,
name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"),
isGuest: playerInfo(bottomLocatedUserId).isGuest,
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
};
if (replayRecord) {
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
const getTurnText = () => {
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
return isYourTurn ? t("YourTurn") : t("OpponentTurn");
};
const renderGame = () => {
const board = mancalaGame!.board;
const [topStore, bottomStore] = isPlayer2
? [board.player1BankIndex(), board.player2BankIndex()]
: [board.player2BankIndex(), board.player1BankIndex()];
const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount;
const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => (
<PlayerCard
name={user.name}
isGuest={user.isGuest}
details={[role, t("StoreCount", { count: shownStones(storeIndex) })].filter(Boolean).join(" · ")}
active={!isGameOver && mancalaGame!.turnPlayerId === user.id}
online={user.isOnline}
clock={renderClock(user.id)}
alignRight={alignRight} />
);
return (
<div className="gl">
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
<div className="a-turn">
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
{!isGameOver && <span className="dot" />}
{getTurnText()}
</div>
</div>
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
<div className="a-board">
{isReviewing && (
<ReviewBanner
moveNumber={viewedMove!}
isYourTurn={isYourTurn}
onBackToLive={() => setViewedMove(undefined)} />
)}
<BoardView
game={game!}
boardViewModel={shownBoardViewModel!}
context={context}
onPitSelect={isSpectator ? undefined : onPitSelect}
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} />
{isGameOver && !gameOverClosed && (
<GameOverCard
title={resultTitle ?? t("GameEnded")}
reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
onClose={() => setGameOverClosed(true)} />
)}
</div>
<div className="a-moves">
<MoveList
moves={moves}
selectedMove={isReviewing ? viewedMove! : moves.length}
onSelect={selectMove}
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
accentPlayerId={bottomLocatedUserId}
newestFirst />
</div>
</div>
);
};
return (
<PageContainer>
{renderHeaderBar()}
<main className="wrap">
{showBoardView && renderGame()}
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</main>
</PageContainer>
);
function renderHeaderBar() {
return <AppHeader context={context}>
{renderNewGameBtn
? <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button>
: <button className="btn ghost ic-m" aria-label={t("Leave")} onClick={onLeaveGameClick}>
<LeaveIcon /><span className="hide-m">{t("Leave")}</span>
</button>}
</AppHeader>;
}
}
export default GamePage;