mancala/apps/frontend/src/routes/GamePage.tsx

595 lines
30 KiB
TypeScript
Raw Normal View History

import { MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
2022-07-31 00:59:18 +03:00
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { v4 } from 'uuid';
import { PitAnimator } from '@mancala/core';
2022-07-31 00:59:18 +03:00
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, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver } from '@mancala/core';
import { Context } from '../context/context';
2024-06-17 15:37:17 +03:00
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
import { BoardViewModel } from "@mancala/core";
2022-09-04 00:04:32 +03:00
import notyf from '../util/Notyf';
2022-09-04 00:16:14 +03:00
import swal from 'sweetalert';
2022-09-04 01:06:26 +03:00
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 RulesPanel from '../components/rules/RulesPanel';
import '../styles/game.css';
2022-07-31 00:59:18 +03:00
// Where a rematch of the ended game stands (docs/specs/rematch): none before the end, available to ask, waiting for
// the opponent, requested by the opponent, or over (a player left, said no or looked for another game).
type RematchStatus = "none" | "available" | "waiting" | "requested" | "over";
interface RematchState {
status: RematchStatus;
// Translation key of what happened to the last request; worded with the opponent's name.
note?: string;
}
// What the player is told when a request ends; nothing when they ended it themselves.
function getRematchNoteKey(status: RematchStatus, reason: RematchEndReason): string | undefined {
switch (reason) {
case "declined": return status === "waiting" ? "RematchDeclined" : undefined;
case "withdrawn": return status === "requested" ? "RematchWithdrawn" : undefined;
case "expired": return status === "waiting" ? "RematchNoAnswer" : status === "requested" ? "RematchRequestExpired" : undefined;
case "offline":
case "left": return status === "over" ? undefined : "RematchOpponentGone";
case "closed": return status === "over" ? undefined : "RematchClosed";
}
}
2022-07-31 00:59:18 +03:00
const GamePage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme,
2022-09-04 01:06:26 +03:00
}> = ({ context, userKey, theme }) => {
2022-07-31 00:59:18 +03:00
let params = useParams<{ gameId: string }>();
const [game, setGame] = useState<Game | undefined>(undefined);
2022-07-31 00:59:18 +03:00
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>();
2022-07-31 00:59:18 +03:00
// 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);
// The rules over the game; the game goes on behind them.
const [rulesOpen, setRulesOpen] = useState(false);
// Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device.
const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage));
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
2022-07-31 00:59:18 +03:00
const [rematch, setRematch] = useState<RematchState>({ status: "none" });
2022-07-31 00:59:18 +03:00
const navigate = useNavigate();
const { t } = useTranslation();
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(LoadingState.Unset());
2022-07-31 00:59:18 +03:00
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);
2022-07-31 00:59:18 +03:00
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);
2022-07-31 00:59:18 +03:00
}
const onGameCrashed = (message: any) => {
const newCrashMessage = message as string;
notyf.error(t("InternalErrorOccurred"));
2022-07-31 00:59:18 +03:00
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);
setRematch({ status: message.rematch === "available" ? "available" : "over" });
};
2022-07-31 00:59:18 +03:00
const onUserConnectionChange = (message: any) => {
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
setGameUsersConnectionInfo(gameUsersConnectionInfo);
2022-07-31 00:59:18 +03:00
};
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
// The opponent asked for a rematch; the card comes back if it was closed.
const onRematchRequest = (message: RematchRequest) => {
if (message.gameId !== game.id) return;
setRematch({ status: "requested" });
setGameOverClosed(false);
};
const onRematchEnded = (message: RematchEnded) => {
if (message.gameId !== game.id) return;
setRematch((rematch) => ({
status: isSeriesOver(message.reason) ? "over" : "available",
note: getRematchNoteKey(rematch.status, message.reason),
}));
};
// The server sends a player's new game only to them: here, the rematch both have asked for.
const onGameStart = (message: Game) => {
if (message.id !== game.id) navigate(`/game/${message.id}`);
};
2022-09-02 00:04:21 +03:00
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);
context.rtmt.addMessageListener(channel_on_rematch_request, onRematchRequest);
context.rtmt.addMessageListener(channel_on_rematch_ended, onRematchEnded);
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
2022-07-31 00:59:18 +03:00
return () => {
context.rtmt.removeMessageListener(channel_on_rematch_request, onRematchRequest);
context.rtmt.removeMessageListener(channel_on_rematch_ended, onRematchEnded);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd);
2022-09-02 00:04:21 +03:00
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);
2022-07-31 00:59:18 +03:00
}
};
// 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]);
2022-07-31 00:59:18 +03:00
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;
2022-07-31 00:59:18 +03:00
return index;
};
const getOpponentId = () => mancalaGame?.player1Id === userKey ? mancalaGame?.player2Id : mancalaGame?.player1Id;
2022-07-31 00:59:18 +03:00
const checkHasAnOngoingAction = () => hasOngoingAction;
const onLeaveGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
2022-09-04 00:16:14 +03:00
swal({
title: t("AreYouSureToLeaveGame"),
2022-09-04 00:16:14 +03:00
icon: "warning",
buttons: [t("Cancel"), t("Yes")],
2022-09-04 00:16:14 +03:00
dangerMode: true,
})
.then((confirm) => {
if (confirm) {
2022-09-04 00:16:14 +03:00
context.rtmt.sendMessage(channel_leave_game, {});
navigate("/");
2022-09-04 00:16:14 +03:00
}
});
2022-07-31 00:59:18 +03:00
};
const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
2022-07-31 00:59:18 +03:00
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());
// Once a rematch of a bot game is no longer possible, "Play again" starts a new game at the same level.
const onPlayAgainClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate(`/loby?bot=${opponentBotLevel}`)
};
// Asks for a rematch, or accepts the opponent's request; the bot accepts at once.
const onRematchClick = () => {
if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return;
context.rtmt.sendMessage(channel_rematch, { gameId: game.id });
setRematch({ status: "waiting" });
};
// Withdraws the player's request, or says no to the opponent's, which ends the series.
const onDeclineRematchClick = () => {
if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return;
context.rtmt.sendMessage(channel_decline_rematch, { gameId: game.id });
setRematch({ status: rematch.status === "waiting" ? "available" : "over" });
};
2022-07-31 00:59:18 +03:00
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;
}
2022-09-07 22:28:03 +03:00
if(game.mancalaGame.state === "ended") {
notyf.error(t("GameEnded"));
2022-09-07 22:28:03 +03:00
return;
}
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
2022-09-04 00:16:14 +03:00
if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) {
notyf.error(t("UCanOnlyPlayYourOwnPits"));
2022-09-04 00:04:32 +03:00
return;
}
const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2);
2022-09-04 00:04:32 +03:00
if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) {
notyf.error(t("OpponentTurn"));
return;
}
2022-07-31 00:59:18 +03:00
if (checkHasAnOngoingAction()) {
notyf.error(t("UMustWaitUntilCurrentMoveComplete"));
2022-07-31 00:59:18 +03:00
return;
}
if (!boardViewModel) return;
//TODO: this check should be in mancala.js
2022-07-31 00:59:18 +03:00
if (pit.stoneCount === 0) {
notyf.error(t("UCanNotPlayEmptyPit"));
2022-07-31 00:59:18 +03:00
return;
}
2022-09-04 00:04:32 +03:00
setHasOngoingAction(true);
const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
selectedPit.highlight = "selected";
2022-07-31 00:59:18 +03:00
updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: pitIndexForUser };
2022-07-31 00:59:18 +03:00
context.rtmt.sendMessage(channel_game_move, gameMove);
};
React.useEffect(() => {
let pitAnimator: PitAnimator | undefined;
let unlistenMessages: () => void;
2022-07-31 00:59:18 +03:00
setLoadingStateGame(LoadingState.Loading())
context.gameStore.get(params.gameId!!).then((loaded) => {
if (loaded?.kind === "live") {
const game = loaded.game;
pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel);
2022-07-31 00:59:18 +03:00
setPitAnimator(pitAnimator);
onGameUpdate(pitAnimator, game);
unlistenMessages = listenMessages(game, pitAnimator);
2022-07-31 00:59:18 +03:00
setLoadingStateGame(LoadingState.Loaded({ value: game }))
} else if (loaded?.kind === "finished") {
setReplayRecord(loaded.record);
2022-07-31 00:59:18 +03:00
} else {
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
2022-07-31 00:59:18 +03:00
}
}).catch((error) => {
console.error(error);
setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") }))
2022-07-31 00:59:18 +03:00
})
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;
// Hints are only for games against the bot, never between people (R3.5).
const hintsOn = !!opponentBotLevel && hintsEnabled;
const toggleHints = () => {
setHintsEnabled(context.storage, !hintsEnabled);
setHintsEnabledState(!hintsEnabled);
};
const getHints = (): Record<number, string> | undefined => {
if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined;
if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined;
const stones = mancalaGame.board.getStoneArray();
const first = isPlayer2 ? 7 : 0;
const hints: Record<number, string> = {};
for (let index = first; index < first + 6; index++) {
if (!stones[index]) continue;
const outcome = getMoveOutcome(stones, index);
if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured });
else if (outcome.extraTurn) hints[index] = t("HintExtraTurn");
}
return hints;
};
// What the last move did and why, under the board (R3.3).
const getLastMoveExplanation = (): string | undefined => {
if (!hintsOn || moves.length === 0 || isReviewing) return undefined;
const last = moves[moves.length - 1];
const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex);
return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t);
};
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} />;
};
2022-09-02 00:04:21 +03:00
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,
2022-09-02 00:04:21 +03:00
isOnline: isUserOnline(topLocatedUserId),
};
const bottomLocatedUser = {
id: bottomLocatedUserId,
name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"),
isGuest: playerInfo(bottomLocatedUserId).isGuest,
2022-09-04 01:06:26 +03:00
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
2022-09-02 00:04:21 +03:00
};
if (replayRecord) {
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined;
const getRematchStatusText = (): string | undefined => {
if (isSpectator) return undefined;
const name = topLocatedUser.name;
if (rematch.status === "waiting" && !opponentBotLevel) return t("RematchWaiting", { name });
if (rematch.status === "requested") return t("RematchRequested", { name });
return rematch.note && t(rematch.note, { name });
};
const renderGameOverButtons = () => {
const newGame = (primary: boolean) => (
<button key="new" className={"btn " + (primary ? "pri" : "ghost")} onClick={onNewGameClick}>{t("NewGame")}</button>
);
const home = <button key="home" className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>;
if (isSpectator) return [newGame(true), home];
if (opponentBotLevel) {
const canRematch = rematch.status === "available" || rematch.status === "waiting";
return [
<button key="again" className="btn pri" disabled={rematch.status === "waiting"}
onClick={canRematch ? onRematchClick : onPlayAgainClick}>{t("PlayAgain")}</button>,
home,
];
}
switch (rematch.status) {
case "available":
return [<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>, newGame(false), home];
case "waiting":
return [<button key="cancel" className="btn ghost" onClick={onDeclineRematchClick}>{t("Cancel")}</button>, home];
case "requested":
return [
<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>,
<button key="no" className="btn ghost" onClick={onDeclineRematchClick}>{t("No")}</button>,
];
default:
return [newGame(true), home];
}
};
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 lastMoveExplanation = getLastMoveExplanation();
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>
{opponentBotLevel && (
<button className={"btn hints-toggle " + (hintsOn ? "pri" : "ghost")} aria-pressed={hintsOn} onClick={toggleHints}>
{t("Hints")}
</button>
)}
</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}
hints={getHints()} />
{lastMoveExplanation && <p className="hint-line" aria-live="polite">{lastMoveExplanation}</p>}
{isGameOver && !gameOverClosed && (
<GameOverCard
title={resultTitle ?? t("GameEnded")}
reason={resultReason}
scores={[
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
]}
status={getRematchStatusText()}
waiting={rematch.status === "waiting"}
onClose={() => setGameOverClosed(true)}>
{renderGameOverButtons()}
</GameOverCard>
)}
</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>
2022-09-02 00:04:21 +03:00
{renderHeaderBar()}
<main className="wrap">
{showBoardView && renderGame()}
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</main>
{rulesOpen && <RulesPanel onClose={() => setRulesOpen(false)} />}
2022-07-31 00:59:18 +03:00
</PageContainer>
);
2022-09-02 00:04:21 +03:00
function renderHeaderBar() {
return <AppHeader context={context} onHelp={() => setRulesOpen(true)}>
{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>;
2022-09-02 00:04:21 +03:00
}
}
// A page of its own for every game, so following a rematch to the new game's id starts the page afresh.
const GameRoute: FunctionComponent<React.ComponentProps<typeof GamePage>> = (props) => {
const { gameId } = useParams<{ gameId: string }>();
return <GamePage key={gameId} {...props} />;
};
export default GameRoute;