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
This commit is contained in:
parent
aa265f7e6c
commit
3bb151dbed
@ -1,8 +1,9 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useRef, useState } 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 { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
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.
|
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
|
||||||
const MoveList: FunctionComponent<{
|
const MoveList: FunctionComponent<{
|
||||||
@ -31,17 +32,6 @@ const MoveList: FunctionComponent<{
|
|||||||
}
|
}
|
||||||
}, [selectedMove, moves.length, collapsed]);
|
}, [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) => (
|
const entry = (moveNumber: number, label: React.ReactNode) => (
|
||||||
<li
|
<li
|
||||||
key={moveNumber}
|
key={moveNumber}
|
||||||
@ -65,7 +55,7 @@ const MoveList: FunctionComponent<{
|
|||||||
<>
|
<>
|
||||||
<span className="number">{i + 1}.</span>
|
<span className="number">{i + 1}.</span>
|
||||||
<span className="player">{playerName(move.playerId)}</span>
|
<span className="player">{playerName(move.playerId)}</span>
|
||||||
<span className="details">{describe(i + 1)}</span>
|
<span className="details">{getMoveText(moves, i + 1, t)}</span>
|
||||||
</>
|
</>
|
||||||
)))}
|
)))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
316
apps/frontend/src/components/history/ReplayView.tsx
Normal file
316
apps/frontend/src/components/history/ReplayView.tsx
Normal file
@ -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<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;
|
||||||
|
|
||||||
|
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}`}</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}`}</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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent';
|
|||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import Row from '../components/Row';
|
import Row from '../components/Row';
|
||||||
import UserStatus from '../components/UserStatus';
|
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 { Context } from '../context/context';
|
||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
import useWindowDimensions from '../hooks/useWindowDimensions';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
@ -35,6 +35,7 @@ import useMoveKeys from '../hooks/useMoveKeys';
|
|||||||
import { createBoardViewModel } from '../util/BoardViewModels';
|
import { createBoardViewModel } from '../util/BoardViewModels';
|
||||||
import { getResultText } from '../util/GameResultText';
|
import { getResultText } from '../util/GameResultText';
|
||||||
import CircularPanel from '../components/CircularPanel';
|
import CircularPanel from '../components/CircularPanel';
|
||||||
|
import ReplayView from '../components/history/ReplayView';
|
||||||
|
|
||||||
const GamePage: FunctionComponent<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -62,6 +63,9 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set while an earlier position is shown instead of the live board.
|
// Set while an earlier position is shown instead of the live board.
|
||||||
const [viewedMove, setViewedMove] = useState<number | undefined>(undefined);
|
const [viewedMove, setViewedMove] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Set when the game has ended and was loaded from history; the page then shows the replay.
|
||||||
|
const [replayRecord, setReplayRecord] = useState<GameRecord | undefined>(undefined);
|
||||||
|
|
||||||
// Set when the server says the game ended, for whatever reason.
|
// Set when the server says the game ended, for whatever reason.
|
||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
@ -251,6 +255,8 @@ const GamePage: FunctionComponent<{
|
|||||||
onGameUpdate(pitAnimator, game);
|
onGameUpdate(pitAnimator, game);
|
||||||
unlistenMessages = listenMessages(game, pitAnimator);
|
unlistenMessages = listenMessages(game, pitAnimator);
|
||||||
setLoadingStateGame(LoadingState.Loaded({ value: game }))
|
setLoadingStateGame(LoadingState.Loaded({ value: game }))
|
||||||
|
} else if (loaded?.kind === "finished") {
|
||||||
|
setReplayRecord(loaded.record);
|
||||||
} else {
|
} else {
|
||||||
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
|
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
|
||||||
}
|
}
|
||||||
@ -282,8 +288,8 @@ const GamePage: FunctionComponent<{
|
|||||||
const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey;
|
const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey;
|
||||||
const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber));
|
const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber));
|
||||||
useMoveKeys({
|
useMoveKeys({
|
||||||
previous: () => selectMove((isReviewing ? viewedMove! : moves.length) - 1),
|
previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1),
|
||||||
next: () => isReviewing && selectMove(viewedMove! + 1),
|
next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playerInfo = (userId: string) => {
|
const playerInfo = (userId: string) => {
|
||||||
@ -340,6 +346,10 @@ const GamePage: FunctionComponent<{
|
|||||||
isAnonymous: true
|
isAnonymous: true
|
||||||
} : bottomLocatedUser;
|
} : bottomLocatedUser;
|
||||||
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
|
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
|
||||||
|
if (replayRecord) {
|
||||||
|
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer theme={theme!}>
|
<PageContainer theme={theme!}>
|
||||||
{renderHeaderBar()}
|
{renderHeaderBar()}
|
||||||
|
|||||||
@ -36,3 +36,11 @@ export function getViewerSeat(game: FinishedGameSummary, viewerId: string | unde
|
|||||||
export function formatDate(epochMillis: number, language: string): string {
|
export function formatDate(epochMillis: number, language: string): string {
|
||||||
return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" });
|
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)}`;
|
||||||
|
}
|
||||||
|
|||||||
15
apps/frontend/src/util/MoveText.ts
Normal file
15
apps/frontend/src/util/MoveText.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { describeMove, PlayedMove } from "@mancala/core";
|
||||||
|
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => 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(" · ");
|
||||||
|
}
|
||||||
@ -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
|
- [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
||||||
rejection message.
|
rejection message.
|
||||||
- [x] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
|
- [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.
|
- [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.
|
||||||
|
|||||||
@ -15,7 +15,8 @@ import { Game } from "../models";
|
|||||||
import { getColorByBrightness } from "../util";
|
import { getColorByBrightness } from "../util";
|
||||||
import { Theme, ThemeManager } from "../theme";
|
import { Theme, ThemeManager } from "../theme";
|
||||||
|
|
||||||
const animationUpdateInterval = 300;
|
export const DEFAULT_ANIMATION_INTERVAL = 300;
|
||||||
|
const animationUpdateInterval = DEFAULT_ANIMATION_INTERVAL;
|
||||||
|
|
||||||
export class PitAnimator {
|
export class PitAnimator {
|
||||||
themeManager: ThemeManager;
|
themeManager: ThemeManager;
|
||||||
@ -28,12 +29,17 @@ export class PitAnimator {
|
|||||||
animationIndex: number = 0;
|
animationIndex: number = 0;
|
||||||
currentHistoryItem: HistoryItem | undefined;
|
currentHistoryItem: HistoryItem | undefined;
|
||||||
|
|
||||||
|
// Time between animation steps, in milliseconds.
|
||||||
|
animationInterval: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
themeManager: ThemeManager,
|
themeManager: ThemeManager,
|
||||||
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void
|
onBoardViewModelUpdate: (boardViewModel: BoardViewModel) => void,
|
||||||
|
animationInterval: number = animationUpdateInterval
|
||||||
) {
|
) {
|
||||||
this.themeManager = themeManager;
|
this.themeManager = themeManager;
|
||||||
this.onBoardViewModelUpdate = onBoardViewModelUpdate;
|
this.onBoardViewModelUpdate = onBoardViewModelUpdate;
|
||||||
|
this.animationInterval = animationInterval;
|
||||||
this.themeManager.on("themechange", this.onThemeChange.bind(this));
|
this.themeManager.on("themechange", this.onThemeChange.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,7 +157,7 @@ export class PitAnimator {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
this.currentIntervalID = setInterval(
|
this.currentIntervalID = setInterval(
|
||||||
() => this.onAnimate(),
|
() => this.onAnimate(),
|
||||||
animationUpdateInterval
|
this.animationInterval
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user