Ceviz, Gece and Yeşim themes as CSS variables on <html>, Bricolage Grotesque, shared styles, and one header for every page with theme swatches, the account menu and the page's main action. The lobby, privacy page and status panels use the new look.
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
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 PageContainer from "../PageContainer";
|
|
import AppHeader from "../headerbar/AppHeader";
|
|
import { PlusIcon } from "../Icons";
|
|
import GameClockView from "../GameClockView";
|
|
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>
|
|
<AppHeader context={context}>
|
|
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={() => navigate("/loby")}>
|
|
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
|
</button>
|
|
</AppHeader>
|
|
|
|
<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;
|