2026-10-16 00:34:49 +03:00
|
|
|
import * as React from "react";
|
|
|
|
|
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
BoardViewModel,
|
|
|
|
|
createGameAtMove,
|
|
|
|
|
createGameFromBoard,
|
|
|
|
|
DEFAULT_ANIMATION_INTERVAL,
|
|
|
|
|
GameRecord,
|
|
|
|
|
getBoardAfterMove,
|
2026-10-16 01:40:05 +03:00
|
|
|
getClockAtMove,
|
2026-10-16 00:34:49 +03:00
|
|
|
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";
|
2026-10-16 01:40:05 +03:00
|
|
|
import GameClockView from "../GameClockView";
|
2026-10-16 00:34:49 +03:00
|
|
|
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;
|
2026-10-16 01:40:05 +03:00
|
|
|
// Each player's clock after the shown move; games saved before the game clock have none.
|
|
|
|
|
const clockTimes = getClockAtMove(record, moveNumber);
|
|
|
|
|
const renderClock = (player: PlayerSummary) => clockTimes && (
|
|
|
|
|
<GameClockView context={context} running={false}
|
|
|
|
|
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
|
|
|
|
);
|
2026-10-16 00:34:49 +03:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
2026-10-16 01:40:05 +03:00
|
|
|
<div className="player" style={{ color: textColor }}>{`${name(topPlayer)} · ${topStore}`}{renderClock(topPlayer)}</div>
|
2026-10-16 00:34:49 +03:00
|
|
|
<BoardView
|
|
|
|
|
game={createGameAtMove(baseGame, moves, moveNumber)}
|
|
|
|
|
context={context}
|
|
|
|
|
boardId={boardViewModel.id}
|
|
|
|
|
boardViewModel={boardViewModel}
|
|
|
|
|
revert={revert}
|
|
|
|
|
onPitSelect={() => undefined} />
|
2026-10-16 01:40:05 +03:00
|
|
|
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
|
2026-10-16 00:34:49 +03:00
|
|
|
|
|
|
|
|
<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;
|
2026-10-16 01:40:05 +03:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
2026-10-16 00:34:49 +03:00
|
|
|
}
|
|
|
|
|
.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;
|