Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -68,6 +68,8 @@ const InfoPanel: FunctionComponent<{
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
isSpectator?: boolean;
|
isSpectator?: boolean;
|
||||||
|
// Replaces the text derived from the game, e.g. for games that ended by a timeout.
|
||||||
|
overrideText?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
context,
|
context,
|
||||||
game,
|
game,
|
||||||
@ -77,7 +79,8 @@ const InfoPanel: FunctionComponent<{
|
|||||||
leftPlayer,
|
leftPlayer,
|
||||||
style,
|
style,
|
||||||
visible,
|
visible,
|
||||||
isSpectator
|
isSpectator,
|
||||||
|
overrideText
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -92,7 +95,7 @@ const InfoPanel: FunctionComponent<{
|
|||||||
context.themeManager.theme.textColor,
|
context.themeManager.theme.textColor,
|
||||||
context.themeManager.theme.textLightColor
|
context.themeManager.theme.textLightColor
|
||||||
);
|
);
|
||||||
const text = getInfoPanelTextByGameState({
|
const text = overrideText ?? getInfoPanelTextByGameState({
|
||||||
context,
|
context,
|
||||||
t,
|
t,
|
||||||
game,
|
game,
|
||||||
|
|||||||
131
apps/frontend/src/components/history/MoveList.tsx
Normal file
131
apps/frontend/src/components/history/MoveList.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
|
import { describeMove, getColorByBrightness, PlayedMove } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
|
||||||
|
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
|
||||||
|
const MoveList: FunctionComponent<{
|
||||||
|
context: Context,
|
||||||
|
moves: PlayedMove[],
|
||||||
|
// Move whose position is shown.
|
||||||
|
selectedMove: number,
|
||||||
|
onSelect: (moveNumber: number) => void,
|
||||||
|
playerName: (playerId: string) => string,
|
||||||
|
collapsedByDefault?: boolean,
|
||||||
|
}> = ({ context, moves, selectedMove, onSelect, playerName, collapsedByDefault }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(!!collapsedByDefault);
|
||||||
|
const selectedRef = useRef<HTMLLIElement>(null);
|
||||||
|
const listRef = useRef<HTMLOListElement>(null);
|
||||||
|
const theme = context.themeManager.theme;
|
||||||
|
const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
|
||||||
|
|
||||||
|
// Keep the shown move visible by scrolling the list only, never the page.
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current, item = selectedRef.current;
|
||||||
|
if (!list || !item) return;
|
||||||
|
if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop;
|
||||||
|
else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) {
|
||||||
|
list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight;
|
||||||
|
}
|
||||||
|
}, [selectedMove, moves.length, collapsed]);
|
||||||
|
|
||||||
|
const describe = (moveNumber: number): string => {
|
||||||
|
const move = describeMove(moves, moveNumber);
|
||||||
|
const events = [
|
||||||
|
move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined,
|
||||||
|
move.capture ? `✂ ${t("Capture")}` : undefined,
|
||||||
|
move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined,
|
||||||
|
move.gameOver ? `⚑ ${t("GameOver")}` : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
return [t("PitNumber", { pit: move.pit }), ...events].join(" · ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const entry = (moveNumber: number, label: React.ReactNode) => (
|
||||||
|
<li
|
||||||
|
key={moveNumber}
|
||||||
|
ref={moveNumber === selectedMove ? selectedRef : undefined}
|
||||||
|
className={moveNumber === selectedMove ? "selected" : ""}
|
||||||
|
style={moveNumber === selectedMove ? { background: theme.boardColor } : undefined}
|
||||||
|
onClick={() => onSelect(moveNumber)}>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="move-list" style={{ background: theme.appBarBgColor, color: textColor }}>
|
||||||
|
<button className="header" style={{ color: textColor }} onClick={() => setCollapsed(!collapsed)}>
|
||||||
|
{`${t("Moves")} (${moves.length}) ${collapsed ? "▸" : "▾"}`}
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<ol ref={listRef}>
|
||||||
|
{entry(0, <span className="start">{t("Start")}</span>)}
|
||||||
|
{moves.map((move, i) => entry(i + 1, (
|
||||||
|
<>
|
||||||
|
<span className="number">{i + 1}.</span>
|
||||||
|
<span className="player">{playerName(move.playerId)}</span>
|
||||||
|
<span className="details">{describe(i + 1)}</span>
|
||||||
|
</>
|
||||||
|
)))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
<style jsx>{`
|
||||||
|
.move-list {
|
||||||
|
/* The page is a flex column; without this the list is squeezed to almost nothing. */
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background-color 0.5s;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
/* Rows are built in helper functions, which styled-jsx does not scope. */
|
||||||
|
.move-list :global(li) {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.move-list :global(li.selected) {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.move-list :global(.number) {
|
||||||
|
min-width: 2.5em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.move-list :global(.player) {
|
||||||
|
min-width: 8em;
|
||||||
|
max-width: 12em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.move-list :global(.details) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoveList;
|
||||||
45
apps/frontend/src/components/history/ReviewBanner.tsx
Normal file
45
apps/frontend/src/components/history/ReviewBanner.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
|
// Shown during a live game while an earlier position is on the board.
|
||||||
|
const ReviewBanner: FunctionComponent<{
|
||||||
|
context: Context,
|
||||||
|
moveNumber: number,
|
||||||
|
isYourTurn: boolean,
|
||||||
|
onBackToLive: () => void,
|
||||||
|
}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = context.themeManager.theme;
|
||||||
|
const background = isYourTurn ? theme.playerTurnColor : theme.boardColor;
|
||||||
|
const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor);
|
||||||
|
return (
|
||||||
|
<div className="review-banner" style={{ background, color: textColor }}>
|
||||||
|
<span>
|
||||||
|
{`⟲ ${t("ViewingMove", { number: moveNumber })}`}
|
||||||
|
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
|
||||||
|
</span>
|
||||||
|
<Button context={context} color={theme.pitColor} text={`${t("BackToLive")} ▶|`} onClick={onBackToLive} />
|
||||||
|
<style jsx>{`
|
||||||
|
.review-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 16px 8px;
|
||||||
|
padding: 4px 8px 4px 16px;
|
||||||
|
border-radius: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: background-color 0.5s;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReviewBanner;
|
||||||
@ -92,7 +92,8 @@ const GamePreviewCard: FunctionComponent<{
|
|||||||
.board {
|
.board {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.line {
|
/* Built in a map callback, which styled-jsx does not scope. */
|
||||||
|
.card :global(.line) {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/frontend/src/hooks/useMoveKeys.ts
Normal file
18
apps/frontend/src/hooks/useMoveKeys.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Arrow keys step through moves and space toggles play; ignored while typing in a form field.
|
||||||
|
export default function useMoveKeys(handlers: { previous: () => void, next: () => void, togglePlay?: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) && target.getAttribute("type") !== "range") return;
|
||||||
|
if (event.key === "ArrowLeft") handlers.previous();
|
||||||
|
else if (event.key === "ArrowRight") handlers.next();
|
||||||
|
else if (event.key === " " && handlers.togglePlay) handlers.togglePlay();
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent';
|
|||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import Row from '../components/Row';
|
import Row from '../components/Row';
|
||||||
import UserStatus from '../components/UserStatus';
|
import UserStatus from '../components/UserStatus';
|
||||||
import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events } from '@mancala/core';
|
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 { Context } from '../context/context';
|
||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
import useWindowDimensions from '../hooks/useWindowDimensions';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
@ -29,6 +29,12 @@ import notyf from '../util/Notyf';
|
|||||||
import swal from 'sweetalert';
|
import swal from 'sweetalert';
|
||||||
import Util from '../util/Util';
|
import Util from '../util/Util';
|
||||||
import { useTranslation } from 'react-i18next';
|
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<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -53,6 +59,15 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState<GameUsersConnectionInfo | undefined>();
|
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 server says the game ended, for whatever reason.
|
||||||
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
|
// Ticks every second while a countdown is shown.
|
||||||
|
const [now, setNow] = useState<number>(Date.now());
|
||||||
|
|
||||||
const { height, width } = useWindowDimensions();
|
const { height, width } = useWindowDimensions();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -105,6 +120,10 @@ const GamePage: FunctionComponent<{
|
|||||||
setUserKeyWhoLeave(userKeyWhoLeave);
|
setUserKeyWhoLeave(userKeyWhoLeave);
|
||||||
setHasOngoingAction(false);
|
setHasOngoingAction(false);
|
||||||
};
|
};
|
||||||
|
const onGameEnd = (message: GameEndMessage) => {
|
||||||
|
setGameEnd(message);
|
||||||
|
setHasOngoingAction(false);
|
||||||
|
};
|
||||||
const onUserConnectionChange = (message: any) => {
|
const onUserConnectionChange = (message: any) => {
|
||||||
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
||||||
setGameUsersConnectionInfo(gameUsersConnectionInfo);
|
setGameUsersConnectionInfo(gameUsersConnectionInfo);
|
||||||
@ -116,7 +135,9 @@ const GamePage: FunctionComponent<{
|
|||||||
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
||||||
|
context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
|
||||||
return () => {
|
return () => {
|
||||||
|
context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
@ -178,10 +199,14 @@ const GamePage: FunctionComponent<{
|
|||||||
if (!game || isSpectator || !userKey) {
|
if (!game || isSpectator || !userKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(userKeyWhoLeave) {
|
if(userKeyWhoLeave || gameEnd) {
|
||||||
notyf.error(t("GameEnded"));
|
notyf.error(t("GameEnded"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (viewedMove !== undefined) {
|
||||||
|
notyf.error(t("ViewingMove", { number: viewedMove }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(game.mancalaGame.state === "ended") {
|
if(game.mancalaGame.state === "ended") {
|
||||||
notyf.error(t("GameEnded"));
|
notyf.error(t("GameEnded"));
|
||||||
return;
|
return;
|
||||||
@ -246,7 +271,51 @@ const GamePage: FunctionComponent<{
|
|||||||
);
|
);
|
||||||
const isMobile = width < 600;
|
const isMobile = width < 600;
|
||||||
|
|
||||||
const renderNewGameBtn = isSpectator || (userKeyWhoLeave || !game || (game && game.mancalaGame.state == "ended"));
|
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 showBoardView = game && boardViewModel && userKey && true;
|
||||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||||
@ -276,15 +345,36 @@ const GamePage: FunctionComponent<{
|
|||||||
{renderHeaderBar()}
|
{renderHeaderBar()}
|
||||||
{renderMobileBoardToolbar()}
|
{renderMobileBoardToolbar()}
|
||||||
{buildBoardTopToolbar()}
|
{buildBoardTopToolbar()}
|
||||||
|
{showBoardView && countdownMs !== undefined && (
|
||||||
|
<CircularPanel color={context.themeManager.theme.playerTurnColor} style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })}
|
||||||
|
</CircularPanel>
|
||||||
|
)}
|
||||||
|
{showBoardView && isReviewing && (
|
||||||
|
<ReviewBanner
|
||||||
|
context={context}
|
||||||
|
moveNumber={viewedMove!}
|
||||||
|
isYourTurn={isYourTurn}
|
||||||
|
onBackToLive={() => setViewedMove(undefined)} />
|
||||||
|
)}
|
||||||
{showBoardView && (
|
{showBoardView && (
|
||||||
<BoardView
|
<BoardView
|
||||||
game={game}
|
game={game}
|
||||||
boardId={boardId}
|
boardId={isReviewing ? `review-${viewedMove}` : boardId}
|
||||||
boardViewModel={boardViewModel}
|
boardViewModel={shownBoardViewModel!}
|
||||||
context={context}
|
context={context}
|
||||||
onPitSelect={onPitSelect}
|
onPitSelect={onPitSelect}
|
||||||
revert={isPlayer2} />
|
revert={isPlayer2} />
|
||||||
)}
|
)}
|
||||||
|
{showBoardView && (
|
||||||
|
<MoveList
|
||||||
|
context={context}
|
||||||
|
moves={moves}
|
||||||
|
selectedMove={isReviewing ? viewedMove! : moves.length}
|
||||||
|
onSelect={selectMove}
|
||||||
|
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
|
||||||
|
collapsedByDefault={isMobile} />
|
||||||
|
)}
|
||||||
<Center>
|
<Center>
|
||||||
<LoadingComponent context={context} loadingState={gameLoadingState}></LoadingComponent>
|
<LoadingComponent context={context} loadingState={gameLoadingState}></LoadingComponent>
|
||||||
</Center>
|
</Center>
|
||||||
@ -343,7 +433,8 @@ const GamePage: FunctionComponent<{
|
|||||||
blackPlayer={bottomLocatedUser}
|
blackPlayer={bottomLocatedUser}
|
||||||
leftPlayer={leftPlayer}
|
leftPlayer={leftPlayer}
|
||||||
visible={params.visible}
|
visible={params.visible}
|
||||||
isSpectator={isSpectator} />
|
isSpectator={isSpectator}
|
||||||
|
overrideText={endText} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/frontend/src/util/BoardViewModels.ts
Normal file
9
apps/frontend/src/util/BoardViewModels.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { BoardViewModel, BoardViewModelFactory, PitViewModelFactory, Theme } from "@mancala/core";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
// A plain board (no highlights) showing the given stone counts.
|
||||||
|
export function createBoardViewModel(stones: number[], theme: Theme): BoardViewModel {
|
||||||
|
return BoardViewModelFactory.create(v4(), stones.map((stoneCount, index) => PitViewModelFactory.create({
|
||||||
|
id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor,
|
||||||
|
})));
|
||||||
|
}
|
||||||
@ -20,6 +20,6 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
||||||
- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
||||||
rejection message.
|
rejection message.
|
||||||
- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
|
- [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
|
||||||
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.
|
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.
|
||||||
- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.
|
- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user