mancala/apps/frontend/src/components/history/ReplayView.tsx

328 lines
14 KiB
TypeScript
Raw Normal View History

import * as React from "react";
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
import {
BoardViewModel,
createGameAtMove,
createGameFromBoard,
DEFAULT_ANIMATION_INTERVAL,
GameRecord,
getBoardAfterMove,
getClockAtMove,
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 GameClockView from "../GameClockView";
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;
// 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} />
);
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}`}{renderClock(topPlayer)}</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}`}{renderClock(bottomPlayer)}</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;
display: flex;
align-items: center;
gap: 8px;
}
.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;