From 3bb151dbeda0496050953d0ea12bd05e86bc31cf Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 00:34:49 +0300 Subject: [PATCH] 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 --- .../src/components/history/MoveList.tsx | 16 +- .../src/components/history/ReplayView.tsx | 316 ++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 16 +- apps/frontend/src/util/GameResultText.ts | 8 + apps/frontend/src/util/MoveText.ts | 15 + docs/specs/game-history/tasks.md | 2 +- packages/core/src/animation/PitAnimator.ts | 12 +- 7 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 apps/frontend/src/components/history/ReplayView.tsx create mode 100644 apps/frontend/src/util/MoveText.ts diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx index 3daf240..ca0016a 100644 --- a/apps/frontend/src/components/history/MoveList.tsx +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -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) => (
  • {i + 1}. {playerName(move.playerId)} - {describe(i + 1)} + {getMoveText(moves, i + 1, t)} )))} diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx new file mode 100644 index 0000000..bcf3dfa --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -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(() => readMoveFromUrl(total)); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [boardViewModel, setBoardViewModel] = useState( + () => createBoardViewModel(getBoardAfterMove(moves, moveNumber), theme)); + const animatorRef = useRef(); + + 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) => ( + + ); + + return ( + + + + + + + + + + + + ))} + + +
    + {t("MoveOf", { number: moveNumber, total })} + + {moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`} + +
    + jumpTo(Number(event.target.value))} /> + + + + + +
    + ); +}; + +export default ReplayView; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 96e8bba..cc73552 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -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(undefined); + // Set when the game has ended and was loaded from history; the page then shows the replay. + const [replayRecord, setReplayRecord] = useState(undefined); + // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(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 ; + } + return ( {renderHeaderBar()} diff --git a/apps/frontend/src/util/GameResultText.ts b/apps/frontend/src/util/GameResultText.ts index eac4764..a1d1607 100644 --- a/apps/frontend/src/util/GameResultText.ts +++ b/apps/frontend/src/util/GameResultText.ts @@ -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)}`; +} diff --git a/apps/frontend/src/util/MoveText.ts b/apps/frontend/src/util/MoveText.ts new file mode 100644 index 0000000..7c97ec8 --- /dev/null +++ b/apps/frontend/src/util/MoveText.ts @@ -0,0 +1,15 @@ +import { describeMove, PlayedMove } from "@mancala/core"; + +type Translate = (key: string, options?: Record) => 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(" · "); +} diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index f33773c..feaf618 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -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. diff --git a/packages/core/src/animation/PitAnimator.ts b/packages/core/src/animation/PitAnimator.ts index c211881..9adea4a 100644 --- a/packages/core/src/animation/PitAnimator.ts +++ b/packages/core/src/animation/PitAnimator.ts @@ -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 ); }