mancala/apps/frontend/src/routes/GamePage.tsx
Halit Aksoy e3e7916265 feat(frontend): draw the new board
Pits and stores are wells with scattered stones and count badges, the
viewer's store on the right. Below 600 px the board stands upright.
Playable pits preview their move on hover, animated pits are ringed and
the pit of the last move has a dashed ring.
2026-10-16 09:37:50 +03:00

432 lines
20 KiB
TypeScript

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 BoardToolbar from '../components/board/BoardToolbar';
import BoardView from '../components/board/BoardView';
import InfoPanel from '../components/InfoPanel';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import AppHeader from '../components/headerbar/AppHeader';
import { LeaveIcon, PlusIcon } from '../components/Icons';
import UserStatus from '../components/UserStatus';
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 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 ReplayView from '../components/history/ReplayView';
import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock';
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);
const { height, width } = useWindowDimensions();
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")
};
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 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: () => !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 };
};
// 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;
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 context={context} 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 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;
if (replayRecord) {
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
return (
<PageContainer>
{renderHeaderBar()}
{renderMobileBoardToolbar()}
{buildBoardTopToolbar()}
{showBoardView && isReviewing && (
<ReviewBanner
context={context}
moveNumber={viewedMove!}
isYourTurn={isYourTurn}
onBackToLive={() => setViewedMove(undefined)} />
)}
{showBoardView && (
<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} />
)}
{showBoardView && (
<MoveList
context={context}
moves={moves}
selectedMove={isReviewing ? viewedMove! : moves.length}
onSelect={selectMove}
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
collapsedByDefault={isMobile} />
)}
<Center>
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</Center>
</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>;
}
function renderMobileBoardToolbar() {
return <BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
{buildInfoPanel({ visible: isMobile })}
</BoardToolbar>;
}
function buildBoardTopToolbar() {
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
<UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
accessory={renderClock(topLocatedUserId)} />
{buildInfoPanel({ visible: !isMobile })}
<UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
accessory={renderClock(bottomLocatedUserId)} />
</BoardToolbar>;
}
function buildInfoPanel(params: { visible: boolean }) {
return (
<InfoPanel
// Long texts such as "Game Ended · You Won · Opponent ran out of time" wrap on phones.
style={{ marginTop: "0.5rem", marginBottom: "0.5rem", ...(isMobile && { whiteSpace: "normal", textAlign: "center" }) }}
context={context}
game={game}
currentUser={currentUser}
whitePlayer={topLocatedUser}
blackPlayer={bottomLocatedUser}
leftPlayer={leftPlayer}
visible={params.visible}
isSpectator={isSpectator}
overrideText={endText} />
);
}
}
export default GamePage;