feat(frontend): replay finished games move by move

- Opening a finished game shows its final position, result, score, date
  and duration, with Watch from start
- First / previous / play-pause / next / last, speed 1x, 2x, 4x, a
  timeline, the move list and arrow keys and space
- Stepping forward animates the move like a live game; jumps show the
  position directly
- ?move=n links to a move and follows the shown move
- Players see the board from their own side
- PitAnimator takes its step interval as a parameter
This commit is contained in:
Halit Aksoy 2026-10-16 00:34:49 +03:00
parent aa265f7e6c
commit 3bb151dbed
7 changed files with 365 additions and 20 deletions

View File

@ -1,8 +1,9 @@
import * as React from "react";
import { FunctionComponent, useEffect, useRef, useState } from "react";
import { describeMove, getColorByBrightness, PlayedMove } from "@mancala/core";
import { getColorByBrightness, PlayedMove } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { getMoveText } from "../../util/MoveText";
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
const MoveList: FunctionComponent<{
@ -31,17 +32,6 @@ const MoveList: FunctionComponent<{
}
}, [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}
@ -65,7 +55,7 @@ const MoveList: FunctionComponent<{
<>
<span className="number">{i + 1}.</span>
<span className="player">{playerName(move.playerId)}</span>
<span className="details">{describe(i + 1)}</span>
<span className="details">{getMoveText(moves, i + 1, t)}</span>
</>
)))}
</ol>

View File

@ -0,0 +1,316 @@
import * as React from "react";
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
import {
BoardViewModel,
createGameAtMove,
createGameFromBoard,
DEFAULT_ANIMATION_INTERVAL,
GameRecord,
getBoardAfterMove,
getColorByBrightness,
getPlayerDisplayName,
PitAnimator,
PlayerSummary,
Theme,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { v4 } from "uuid";
import { Context } from "../../context/context";
import useMoveKeys from "../../hooks/useMoveKeys";
import { createBoardViewModel } from "../../util/BoardViewModels";
import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
import { getMoveText } from "../../util/MoveText";
import BoardView from "../board/BoardView";
import Button from "../Button";
import HeaderBar from "../headerbar/HeaderBar";
import HeaderbarIcon from "../headerbar/HeaderbarIcon";
import HeaderbarTitle from "../headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../headerbar/ThemeSwitchMenu";
import PageContainer from "../PageContainer";
import Row from "../Row";
import MoveList from "./MoveList";
const SPEEDS = [1, 2, 4];
// Extra pause after a move's animation while playing, at 1x.
const PAUSE_BETWEEN_MOVES_MS = 700;
const PLAYER1_STORE = 6;
const PLAYER2_STORE = 13;
function readMoveFromUrl(total: number): number {
const value = new URLSearchParams(window.location.search).get("move");
const move = value === null ? NaN : Number(value);
return Number.isInteger(move) && move >= 0 && move <= total ? move : total;
}
// Replay of a finished game: starts at the final position; moves can be stepped through, played at 1x/2x/4x or
// jumped to from the timeline and the move list. Players see the board from their own side.
const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: GameRecord, userKey?: string }> = (
{ context, theme, record, userKey }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const moves = record.moves;
const total = moves.length;
const viewerId = getViewerSeat(record, userKey) ? userKey : undefined;
const revert = getViewerSeat(record, userKey) === "player2";
const baseGame = useMemo(() => {
const game = createGameFromBoard({
id: record.id, player1Id: record.player1.userId, player2Id: record.player2.userId,
turnPlayerId: moves[total - 1]?.playerId ?? record.player1.userId, board: record.board,
});
game.mancalaGame.state = "ended";
return game;
}, [record.id]);
const [moveNumber, setMoveNumber] = useState<number>(() => readMoveFromUrl(total));
const [playing, setPlaying] = useState<boolean>(false);
const [speed, setSpeed] = useState<number>(1);
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel>(
() => createBoardViewModel(getBoardAfterMove(moves, moveNumber), theme));
const animatorRef = useRef<PitAnimator>();
useEffect(() => {
// The animator changes its view model in place, so pass React a new object for every frame.
const animator = new PitAnimator(context.themeManager,
(viewModel) => setBoardViewModel({ id: v4(), pits: [...viewModel.pits] } as BoardViewModel));
animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber));
animatorRef.current = animator;
return () => animator.dispose();
}, [baseGame]);
useEffect(() => {
window.history.replaceState(null, "", `${window.location.pathname}?move=${moveNumber}`);
}, [moveNumber]);
// Shows a position without animation.
const showMove = (n: number) => {
const target = Math.min(Math.max(n, 0), total);
const animator = animatorRef.current;
if (animator) {
animator.clearCurrentInterval();
animator.setNewGame(createGameAtMove(baseGame, moves, target));
}
setMoveNumber(target);
};
// Plays the next move with the same animation as a live game.
const animateNextMove = () => {
const animator = animatorRef.current;
if (!animator || moveNumber >= total) return;
animator.clearCurrentInterval();
animator.animationInterval = DEFAULT_ANIMATION_INTERVAL / speed;
animator.setNewGame(createGameAtMove(baseGame, moves, moveNumber));
animator.setUpdatedGame(createGameAtMove(baseGame, moves, moveNumber + 1));
setMoveNumber(moveNumber + 1);
};
const jumpTo = (n: number) => {
setPlaying(false);
showMove(n);
};
const togglePlay = () => {
if (playing) {
setPlaying(false);
} else {
if (moveNumber >= total) showMove(0);
setPlaying(true);
}
};
useEffect(() => {
if (!playing) return;
if (moveNumber >= total) {
setPlaying(false);
return;
}
// Wait for the previous move's animation (one step per sown stone and rule event) plus a pause.
const previousSteps = moveNumber > 0 ? moves[moveNumber - 1].gameSteps.length + 1 : 0;
const delay = (previousSteps * DEFAULT_ANIMATION_INTERVAL + PAUSE_BETWEEN_MOVES_MS) / speed;
const timer = setTimeout(animateNextMove, delay);
return () => clearTimeout(timer);
}, [playing, moveNumber, speed]);
useMoveKeys({
previous: () => jumpTo(moveNumber - 1),
next: () => { setPlaying(false); animateNextMove(); },
togglePlay,
});
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
const playerName = (playerId: string) =>
name(playerId === record.player1.userId ? record.player1 : record.player2);
const stones = getBoardAfterMove(moves, moveNumber);
const [topPlayer, topStore, bottomPlayer, bottomStore] = revert
? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]]
: [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]];
const atEnd = moveNumber >= total;
const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => (
<button className="control" title={title} aria-label={title} disabled={disabled} onClick={onClick}
style={{ background: theme.pitColor, color: getColorByBrightness(theme.pitColor, theme.textColor, theme.textLightColor) }}>
{label}
</button>
);
return (
<PageContainer theme={theme}>
<HeaderBar color={theme.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}><HeaderbarIcon /></Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button context={context} color={theme.pitColor} text={t("NewGame")} onClick={() => navigate("/loby")} />
</Row>
</HeaderBar>
<div className="result" style={{ background: theme.boardColor, color: panelTextColor }}>
<div>
<div className="result-title">{`${getResultText(record, viewerId, t)} · ${getScoreText(record, viewerId)}`}</div>
<div>{`${formatDate(record.endedAt, i18n.language)} · ${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`}</div>
</div>
{atEnd && !playing && total > 0 && (
<Button context={context} color={theme.pitColor} text={`▶ ${t("WatchFromStart")}`}
onClick={() => { showMove(0); setPlaying(true); }} />
)}
</div>
<div className="player" style={{ color: textColor }}>{`${name(topPlayer)} · ${topStore}`}</div>
<BoardView
game={createGameAtMove(baseGame, moves, moveNumber)}
context={context}
boardId={boardViewModel.id}
boardViewModel={boardViewModel}
revert={revert}
onPitSelect={() => undefined} />
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}</div>
<div className="controls" style={{ color: textColor }}>
<div className="buttons">
{controlButton("⏮", t("Start"), () => jumpTo(0), moveNumber === 0)}
{controlButton("◀", "←", () => jumpTo(moveNumber - 1), moveNumber === 0)}
{controlButton(playing ? "⏸" : "▶", playing ? "Pause" : "Play", togglePlay, total === 0)}
{controlButton("▶|", "→", () => { setPlaying(false); animateNextMove(); }, atEnd)}
{controlButton("⏭", t("GameOver"), () => jumpTo(total), atEnd)}
<span className="speed">
{`${t("Speed")}:`}
{SPEEDS.map((s) => (
<button key={s} className={"speed-option" + (s === speed ? " active" : "")}
style={{ color: textColor }} onClick={() => setSpeed(s)}>{`${s}x`}</button>
))}
</span>
</div>
<div className="position">
<span>{t("MoveOf", { number: moveNumber, total })}</span>
<span className="move-text">
{moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`}
</span>
</div>
<input className="timeline" type="range" min={0} max={total} value={moveNumber}
style={{ accentColor: theme.boardColor }}
aria-label={t("Moves")} onChange={(event) => jumpTo(Number(event.target.value))} />
</div>
<MoveList
context={context}
moves={moves}
selectedMove={moveNumber}
onSelect={jumpTo}
playerName={playerName} />
<style jsx>{`
.result {
flex-shrink: 0;
width: calc(100% - 32px);
max-width: 868px;
margin: 16px 16px 8px;
padding: 12px 16px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-sizing: border-box;
transition: background-color 0.5s;
}
.result-title {
font-weight: bold;
margin-bottom: 4px;
}
.player {
margin: 6px 0;
font-weight: bold;
}
.controls {
flex-shrink: 0;
width: calc(100% - 32px);
max-width: 640px;
margin: 8px 16px 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
}
.position {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.move-text {
opacity: 0.85;
}
.timeline {
width: 100%;
}
/* Built in helper functions and callbacks, which styled-jsx does not scope. */
.controls :global(.control) {
min-width: 44px;
padding: 8px 12px;
border: none;
border-radius: 30px;
cursor: pointer;
font-size: 1em;
}
.controls :global(.control:disabled) {
opacity: 0.4;
cursor: default;
}
.speed {
margin-left: 12px;
display: flex;
align-items: center;
gap: 2px;
}
.controls :global(.speed-option) {
background: none;
border: none;
padding: 4px 6px;
cursor: pointer;
opacity: 0.6;
}
.controls :global(.speed-option.active) {
opacity: 1;
font-weight: bold;
text-decoration: underline;
}
`}</style>
</PageContainer>
);
};
export default ReplayView;

View File

@ -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_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 { 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, 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";
@ -35,6 +35,7 @@ import useMoveKeys from '../hooks/useMoveKeys';
import { createBoardViewModel } from '../util/BoardViewModels';
import { getResultText } from '../util/GameResultText';
import CircularPanel from '../components/CircularPanel';
import ReplayView from '../components/history/ReplayView';
const GamePage: FunctionComponent<{
context: Context,
@ -62,6 +63,9 @@ const GamePage: FunctionComponent<{
// 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);
@ -251,6 +255,8 @@ const GamePage: FunctionComponent<{
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") }))
}
@ -282,8 +288,8 @@ const GamePage: FunctionComponent<{
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),
previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1),
next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1),
});
const playerInfo = (userId: string) => {
@ -340,6 +346,10 @@ const GamePage: FunctionComponent<{
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 theme={theme!}>
{renderHeaderBar()}

View File

@ -36,3 +36,11 @@ export function getViewerSeat(game: FinishedGameSummary, viewerId: string | unde
export function formatDate(epochMillis: number, language: string): string {
return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" });
}
// "12:05" or "1:02:05"
export function formatDuration(milliseconds: number): string {
const totalSeconds = Math.max(0, Math.round(milliseconds / 1000));
const hours = Math.floor(totalSeconds / 3600), minutes = Math.floor(totalSeconds / 60) % 60, seconds = totalSeconds % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}

View File

@ -0,0 +1,15 @@
import { describeMove, PlayedMove } from "@mancala/core";
type Translate = (key: string, options?: Record<string, unknown>) => string;
// "Pit 3 · ★ Extra turn · +1 to store"
export function getMoveText(moves: PlayedMove[], moveNumber: number, t: Translate): 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(" · ");
}

View File

@ -21,5 +21,5 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
rejection message.
- [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.
- [x] 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.

View File

@ -15,7 +15,8 @@ import { Game } from "../models";
import { getColorByBrightness } from "../util";
import { Theme, ThemeManager } from "../theme";
const animationUpdateInterval = 300;
export const DEFAULT_ANIMATION_INTERVAL = 300;
const animationUpdateInterval = DEFAULT_ANIMATION_INTERVAL;
export class PitAnimator {
themeManager: ThemeManager;
@ -28,12 +29,17 @@ export class PitAnimator {
animationIndex: number = 0;
currentHistoryItem: HistoryItem | undefined;
// Time between animation steps, in milliseconds.
animationInterval: number;
constructor(
themeManager: ThemeManager,
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void,
animationInterval: number = animationUpdateInterval
) {
this.themeManager = themeManager;
this.onBoardViewModelUpdate = onBoardViewModelUpdate;
this.animationInterval = animationInterval;
this.themeManager.on("themechange", this.onThemeChange.bind(this));
}
@ -151,7 +157,7 @@ export class PitAnimator {
//@ts-ignore
this.currentIntervalID = setInterval(
() => this.onAnimate(),
animationUpdateInterval
this.animationInterval
);
}