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;