2022-08-01 22:27:55 +03:00
|
|
|
|
import { MancalaGame, Pit } from 'mancala.js';
|
2022-07-30 12:01:50 +03:00
|
|
|
|
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';
|
2024-06-18 00:24:45 +03:00
|
|
|
|
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';
|
2026-10-16 09:35:39 +03:00
|
|
|
|
import AppHeader from '../components/headerbar/AppHeader';
|
|
|
|
|
|
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
2026-10-16 01:39:36 +03:00
|
|
|
|
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';
|
2022-07-30 12:01:50 +03:00
|
|
|
|
import { Context } from '../context/context';
|
2024-06-17 15:37:17 +03:00
|
|
|
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
2026-10-16 14:22:04 +03:00
|
|
|
|
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
2024-06-18 00:24:45 +03:00
|
|
|
|
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';
|
2024-06-17 23:50:13 +03:00
|
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-10-16 00:31:43 +03:00
|
|
|
|
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';
|
2026-10-16 00:34:49 +03:00
|
|
|
|
import ReplayView from '../components/history/ReplayView';
|
2026-10-16 01:39:36 +03:00
|
|
|
|
import GameClockView from '../components/GameClockView';
|
|
|
|
|
|
import useGameClock from '../hooks/useGameClock';
|
2026-10-16 09:41:47 +03:00
|
|
|
|
import PlayerCard from '../components/game/PlayerCard';
|
|
|
|
|
|
import GameOverCard from '../components/game/GameOverCard';
|
|
|
|
|
|
import '../styles/game.css';
|
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 }>();
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
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);
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
|
2022-07-31 00:59:18 +03:00
|
|
|
|
|
2026-10-16 00:31:43 +03:00
|
|
|
|
// Set while an earlier position is shown instead of the live board.
|
|
|
|
|
|
const [viewedMove, setViewedMove] = useState<number | undefined>(undefined);
|
|
|
|
|
|
|
2026-10-16 00:34:49 +03:00
|
|
|
|
// Set when the game has ended and was loaded from history; the page then shows the replay.
|
|
|
|
|
|
const [replayRecord, setReplayRecord] = useState<GameRecord | undefined>(undefined);
|
|
|
|
|
|
|
2026-10-16 00:31:43 +03:00
|
|
|
|
// Set when the server says the game ended, for whatever reason.
|
|
|
|
|
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
|
|
|
|
|
|
2026-10-16 09:41:47 +03:00
|
|
|
|
// 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 navigate = useNavigate();
|
|
|
|
|
|
|
2024-06-17 23:50:13 +03:00
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const [gameLoadingState, setLoadingStateGame] = useState<LoadingState<Game>>(LoadingState.Unset());
|
2022-07-31 00:59:18 +03:00
|
|
|
|
|
2022-09-02 00:11:48 +03:00
|
|
|
|
|
|
|
|
|
|
const checkIsSpectator = (game: Game) => userKey !== game.mancalaGame.player1Id && userKey !== game.mancalaGame.player2Id;
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const mancalaGame: MancalaGame | undefined = game?.mancalaGame;
|
2022-09-02 00:11:48 +03:00
|
|
|
|
const isSpectator = game ? checkIsSpectator(game) : undefined;
|
|
|
|
|
|
const isPlayer2 = !isSpectator && userKey === mancalaGame?.player2Id;
|
2022-08-01 22:27:55 +03:00
|
|
|
|
|
|
|
|
|
|
const onGameUpdate = (pitAnimator: PitAnimator, newGame: Game) => {
|
|
|
|
|
|
setGame(newGame);
|
|
|
|
|
|
pitAnimator.setUpdatedGame(newGame);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
setHasOngoingAction(false);
|
2022-08-01 22:27:55 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 02:22:13 +03:00
|
|
|
|
const getPlayerName = (userId: string) => {
|
|
|
|
|
|
const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo];
|
|
|
|
|
|
return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
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;
|
2024-06-17 23:50:13 +03:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-10-16 00:31:43 +03:00
|
|
|
|
const onGameEnd = (message: GameEndMessage) => {
|
|
|
|
|
|
setGameEnd(message);
|
|
|
|
|
|
setHasOngoingAction(false);
|
|
|
|
|
|
};
|
2022-07-31 00:59:18 +03:00
|
|
|
|
const onUserConnectionChange = (message: any) => {
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
|
|
|
|
|
setGameUsersConnectionInfo(gameUsersConnectionInfo);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2022-09-02 00:11:48 +03:00
|
|
|
|
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
|
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);
|
2026-10-16 00:31:43 +03:00
|
|
|
|
context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
return () => {
|
2026-10-16 00:31:43 +03:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-16 00:27:06 +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) => {
|
2022-08-01 22:27:55 +03:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2022-08-01 22:27:55 +03:00
|
|
|
|
const getOpponentId = () => mancalaGame?.player1Id === userKey ? mancalaGame?.player2Id : mancalaGame?.player1Id;
|
2022-07-31 00:59:18 +03:00
|
|
|
|
|
|
|
|
|
|
const checkHasAnOngoingAction = () => hasOngoingAction;
|
|
|
|
|
|
|
|
|
|
|
|
const onLeaveGameClick = () => {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
2022-09-04 00:16:14 +03:00
|
|
|
|
swal({
|
2024-06-17 23:50:13 +03:00
|
|
|
|
title: t("AreYouSureToLeaveGame"),
|
2022-09-04 00:16:14 +03:00
|
|
|
|
icon: "warning",
|
2026-10-16 09:26:02 +03:00
|
|
|
|
buttons: [t("Cancel"), t("Yes")],
|
2022-09-04 00:16:14 +03:00
|
|
|
|
dangerMode: true,
|
|
|
|
|
|
})
|
2026-10-16 09:26:02 +03:00
|
|
|
|
.then((confirm) => {
|
|
|
|
|
|
if (confirm) {
|
2022-09-04 00:16:14 +03:00
|
|
|
|
context.rtmt.sendMessage(channel_leave_game, {});
|
2026-10-16 09:26:02 +03:00
|
|
|
|
navigate("/");
|
2022-09-04 00:16:14 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2022-07-31 00:59:18 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onNewGameClick = () => {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
2022-07-31 00:59:18 +03:00
|
|
|
|
navigate("/loby")
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-16 14:22:04 +03:00
|
|
|
|
// 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}`)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2022-07-31 00:59:18 +03:00
|
|
|
|
const onPitSelect = (index: number, pit: Pit) => {
|
2022-09-02 00:11:48 +03:00
|
|
|
|
if (!game || isSpectator || !userKey) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-16 00:31:43 +03:00
|
|
|
|
if(userKeyWhoLeave || gameEnd) {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("GameEnded"));
|
2022-10-08 16:08:30 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-16 00:31:43 +03:00
|
|
|
|
if (viewedMove !== undefined) {
|
|
|
|
|
|
notyf.error(t("ViewingMove", { number: viewedMove }));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-09-07 22:28:03 +03:00
|
|
|
|
if(game.mancalaGame.state === "ended") {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("GameEnded"));
|
2022-09-07 22:28:03 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-06-17 23:50:13 +03:00
|
|
|
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
2022-09-04 00:16:14 +03:00
|
|
|
|
if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("UCanOnlyPlayYourOwnPits"));
|
2022-09-04 00:04:32 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-09-02 00:11:48 +03:00
|
|
|
|
const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2);
|
2022-09-04 00:04:32 +03:00
|
|
|
|
if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("OpponentTurn"));
|
2022-09-02 00:11:48 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-07-31 00:59:18 +03:00
|
|
|
|
if (checkHasAnOngoingAction()) {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("UMustWaitUntilCurrentMoveComplete"));
|
2022-07-31 00:59:18 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!boardViewModel) return;
|
2022-09-02 00:11:48 +03:00
|
|
|
|
//TODO: this check should be in mancala.js
|
2022-07-31 00:59:18 +03:00
|
|
|
|
if (pit.stoneCount === 0) {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
notyf.error(t("UCanNotPlayEmptyPit"));
|
2022-07-31 00:59:18 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-09-04 00:04:32 +03:00
|
|
|
|
setHasOngoingAction(true);
|
2026-10-16 09:37:50 +03:00
|
|
|
|
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);
|
2022-09-02 00:11:48 +03:00
|
|
|
|
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;
|
2022-08-01 22:27:55 +03:00
|
|
|
|
let unlistenMessages: () => void;
|
2022-07-31 00:59:18 +03:00
|
|
|
|
setLoadingStateGame(LoadingState.Loading())
|
2026-10-16 00:27:06 +03:00
|
|
|
|
context.gameStore.get(params.gameId!!).then((loaded) => {
|
|
|
|
|
|
if (loaded?.kind === "live") {
|
|
|
|
|
|
const game = loaded.game;
|
2024-06-18 00:24:45 +03:00
|
|
|
|
pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
setPitAnimator(pitAnimator);
|
2022-08-01 22:27:55 +03:00
|
|
|
|
onGameUpdate(pitAnimator, game);
|
2022-09-02 00:11:48 +03:00
|
|
|
|
unlistenMessages = listenMessages(game, pitAnimator);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
setLoadingStateGame(LoadingState.Loaded({ value: game }))
|
2026-10-16 00:34:49 +03:00
|
|
|
|
} else if (loaded?.kind === "finished") {
|
|
|
|
|
|
setReplayRecord(loaded.record);
|
2022-07-31 00:59:18 +03:00
|
|
|
|
} else {
|
2024-06-17 23:50:13 +03:00
|
|
|
|
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
|
2022-07-31 00:59:18 +03:00
|
|
|
|
}
|
2026-10-16 00:27:06 +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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-10-16 00:31:43 +03:00
|
|
|
|
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({
|
2026-10-16 00:34:49 +03:00
|
|
|
|
previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1),
|
|
|
|
|
|
next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1),
|
2026-10-16 00:31:43 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const playerInfo = (userId: string) => {
|
|
|
|
|
|
const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]
|
|
|
|
|
|
.find((connectionInfo) => connectionInfo?.userId === userId);
|
|
|
|
|
|
return { userId, name: info?.name, isGuest: info?.isGuest ?? true };
|
|
|
|
|
|
};
|
2026-10-16 09:41:47 +03:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-10-16 00:31:43 +03:00
|
|
|
|
|
2026-10-16 01:39:36 +03:00
|
|
|
|
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;
|
2026-10-16 09:41:47 +03:00
|
|
|
|
return <GameClockView timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
|
2026-10-16 00:31:43 +03:00
|
|
|
|
};
|
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,
|
2026-10-16 09:41:47 +03:00
|
|
|
|
name: getPlayerName(topLocatedUserId) ?? t("Anonymous"),
|
|
|
|
|
|
isGuest: playerInfo(topLocatedUserId).isGuest,
|
2022-09-02 00:04:21 +03:00
|
|
|
|
isOnline: isUserOnline(topLocatedUserId),
|
|
|
|
|
|
};
|
|
|
|
|
|
const bottomLocatedUser = {
|
|
|
|
|
|
id: bottomLocatedUserId,
|
2026-10-16 09:41:47 +03:00
|
|
|
|
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
|
|
|
|
};
|
2026-10-16 00:34:49 +03:00
|
|
|
|
if (replayRecord) {
|
|
|
|
|
|
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 09:41:47 +03:00
|
|
|
|
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}`}
|
2026-10-16 14:22:04 +03:00
|
|
|
|
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
|
|
|
|
|
|
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
|
2026-10-16 09:41:47 +03:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2022-07-30 12:01:50 +03:00
|
|
|
|
return (
|
2026-10-16 09:35:39 +03:00
|
|
|
|
<PageContainer>
|
2022-09-02 00:04:21 +03:00
|
|
|
|
{renderHeaderBar()}
|
2026-10-16 09:41:47 +03:00
|
|
|
|
<main className="wrap">
|
|
|
|
|
|
{showBoardView && renderGame()}
|
2026-10-16 09:35:39 +03:00
|
|
|
|
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
2026-10-16 09:41:47 +03:00
|
|
|
|
</main>
|
2022-07-31 00:59:18 +03:00
|
|
|
|
</PageContainer>
|
2022-07-30 12:01:50 +03:00
|
|
|
|
);
|
2022-09-02 00:04:21 +03:00
|
|
|
|
|
|
|
|
|
|
function renderHeaderBar() {
|
2026-10-16 09:35:39 +03:00
|
|
|
|
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>;
|
2022-09-02 00:04:21 +03:00
|
|
|
|
}
|
2022-07-30 12:01:50 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2022-09-02 00:04:21 +03:00
|
|
|
|
export default GamePage;
|