feat(frontend): show the moves of a live game and let players look back
- Move list under the board with player, pit and what happened (extra turn, capture, stones to the store, game over); collapsed on phones - Selecting a move or using the arrow keys shows that position with a banner and Back to live; pits are locked meanwhile and the banner says when it is your turn - Countdown while the opponent is offline on their turn - on_game_end is handled, so games that end by timeout or inactivity end on the page with the reason
This commit is contained in:
parent
a65662f695
commit
aa265f7e6c
@ -68,6 +68,8 @@ const InfoPanel: FunctionComponent<{
|
||||
style?: React.CSSProperties;
|
||||
visible?: boolean;
|
||||
isSpectator?: boolean;
|
||||
// Replaces the text derived from the game, e.g. for games that ended by a timeout.
|
||||
overrideText?: string;
|
||||
}> = ({
|
||||
context,
|
||||
game,
|
||||
@ -77,7 +79,8 @@ const InfoPanel: FunctionComponent<{
|
||||
leftPlayer,
|
||||
style,
|
||||
visible,
|
||||
isSpectator
|
||||
isSpectator,
|
||||
overrideText
|
||||
}) => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
@ -92,7 +95,7 @@ const InfoPanel: FunctionComponent<{
|
||||
context.themeManager.theme.textColor,
|
||||
context.themeManager.theme.textLightColor
|
||||
);
|
||||
const text = getInfoPanelTextByGameState({
|
||||
const text = overrideText ?? getInfoPanelTextByGameState({
|
||||
context,
|
||||
t,
|
||||
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 {
|
||||
pointer-events: none;
|
||||
}
|
||||
.line {
|
||||
/* Built in a map callback, which styled-jsx does not scope. */
|
||||
.card :global(.line) {
|
||||
font-size: 0.9em;
|
||||
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 Row from '../components/Row';
|
||||
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 useWindowDimensions from '../hooks/useWindowDimensions';
|
||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||
@ -29,6 +29,12 @@ 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 CircularPanel from '../components/CircularPanel';
|
||||
|
||||
const GamePage: FunctionComponent<{
|
||||
context: Context,
|
||||
@ -53,6 +59,15 @@ const GamePage: FunctionComponent<{
|
||||
|
||||
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 navigate = useNavigate();
|
||||
@ -105,6 +120,10 @@ const GamePage: FunctionComponent<{
|
||||
setUserKeyWhoLeave(userKeyWhoLeave);
|
||||
setHasOngoingAction(false);
|
||||
};
|
||||
const onGameEnd = (message: GameEndMessage) => {
|
||||
setGameEnd(message);
|
||||
setHasOngoingAction(false);
|
||||
};
|
||||
const onUserConnectionChange = (message: any) => {
|
||||
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
||||
setGameUsersConnectionInfo(gameUsersConnectionInfo);
|
||||
@ -116,7 +135,9 @@ const GamePage: FunctionComponent<{
|
||||
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);
|
||||
@ -178,10 +199,14 @@ const GamePage: FunctionComponent<{
|
||||
if (!game || isSpectator || !userKey) {
|
||||
return;
|
||||
}
|
||||
if(userKeyWhoLeave) {
|
||||
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;
|
||||
@ -246,7 +271,51 @@ const GamePage: FunctionComponent<{
|
||||
);
|
||||
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 topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||
@ -276,15 +345,36 @@ const GamePage: FunctionComponent<{
|
||||
{renderHeaderBar()}
|
||||
{renderMobileBoardToolbar()}
|
||||
{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 && (
|
||||
<BoardView
|
||||
game={game}
|
||||
boardId={boardId}
|
||||
boardViewModel={boardViewModel}
|
||||
boardId={isReviewing ? `review-${viewedMove}` : boardId}
|
||||
boardViewModel={shownBoardViewModel!}
|
||||
context={context}
|
||||
onPitSelect={onPitSelect}
|
||||
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 context={context} loadingState={gameLoadingState}></LoadingComponent>
|
||||
</Center>
|
||||
@ -343,7 +433,8 @@ const GamePage: FunctionComponent<{
|
||||
blackPlayer={bottomLocatedUser}
|
||||
leftPlayer={leftPlayer}
|
||||
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] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
||||
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.
|
||||
- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user