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:
Halit Aksoy 2026-10-16 00:31:43 +03:00
parent a65662f695
commit aa265f7e6c
8 changed files with 308 additions and 10 deletions

View File

@ -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,

View 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;

View 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;

View File

@ -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;
} }

View 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);
});
}

View File

@ -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} />
); );
} }
} }

View 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,
})));
}

View File

@ -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.