diff --git a/apps/frontend/src/components/InfoPanel.tsx b/apps/frontend/src/components/InfoPanel.tsx index d15c1d5..ce68598 100644 --- a/apps/frontend/src/components/InfoPanel.tsx +++ b/apps/frontend/src/components/InfoPanel.tsx @@ -68,6 +68,8 @@ const InfoPanel: FunctionComponent<{ style?: React.CSSProperties; visible?: boolean; isSpectator?: boolean; + // Replaces the text derived from the game, e.g. for games that ended by a timeout. + overrideText?: string; }> = ({ context, game, @@ -77,7 +79,8 @@ const InfoPanel: FunctionComponent<{ leftPlayer, style, visible, - isSpectator + isSpectator, + overrideText }) => { const { t } = useTranslation(); @@ -92,7 +95,7 @@ const InfoPanel: FunctionComponent<{ context.themeManager.theme.textColor, context.themeManager.theme.textLightColor ); - const text = getInfoPanelTextByGameState({ + const text = overrideText ?? getInfoPanelTextByGameState({ context, t, game, diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx new file mode 100644 index 0000000..3daf240 --- /dev/null +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -0,0 +1,131 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useRef, useState } from "react"; +import { describeMove, getColorByBrightness, PlayedMove } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; + +// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start. +const MoveList: FunctionComponent<{ + context: Context, + moves: PlayedMove[], + // Move whose position is shown. + selectedMove: number, + onSelect: (moveNumber: number) => void, + playerName: (playerId: string) => string, + collapsedByDefault?: boolean, +}> = ({ context, moves, selectedMove, onSelect, playerName, collapsedByDefault }) => { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(!!collapsedByDefault); + const selectedRef = useRef(null); + const listRef = useRef(null); + const theme = context.themeManager.theme; + const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); + + // Keep the shown move visible by scrolling the list only, never the page. + useEffect(() => { + const list = listRef.current, item = selectedRef.current; + if (!list || !item) return; + if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop; + else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) { + list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight; + } + }, [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) => ( +
  • onSelect(moveNumber)}> + {label} +
  • + ); + + return ( +
    + + {!collapsed && ( +
      + {entry(0, {t("Start")})} + {moves.map((move, i) => entry(i + 1, ( + <> + {i + 1}. + {playerName(move.playerId)} + {describe(i + 1)} + + )))} +
    + )} + +
    + ); +}; + +export default MoveList; diff --git a/apps/frontend/src/components/history/ReviewBanner.tsx b/apps/frontend/src/components/history/ReviewBanner.tsx new file mode 100644 index 0000000..553166f --- /dev/null +++ b/apps/frontend/src/components/history/ReviewBanner.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getColorByBrightness } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import Button from "../Button"; + +// Shown during a live game while an earlier position is on the board. +const ReviewBanner: FunctionComponent<{ + context: Context, + moveNumber: number, + isYourTurn: boolean, + onBackToLive: () => void, +}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const background = isYourTurn ? theme.playerTurnColor : theme.boardColor; + const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor); + return ( +
    + + {`⟲ ${t("ViewingMove", { number: moveNumber })}`} + {isYourTurn && {` · ${t("YourTurn")}`}} + +
    + ); +}; + +export default ReviewBanner; diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx index f0f10ed..fec02b6 100644 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ b/apps/frontend/src/components/home/GamePreviewCard.tsx @@ -92,7 +92,8 @@ const GamePreviewCard: FunctionComponent<{ .board { pointer-events: none; } - .line { + /* Built in a map callback, which styled-jsx does not scope. */ + .card :global(.line) { font-size: 0.9em; opacity: 0.85; } diff --git a/apps/frontend/src/hooks/useMoveKeys.ts b/apps/frontend/src/hooks/useMoveKeys.ts new file mode 100644 index 0000000..c4e7e9d --- /dev/null +++ b/apps/frontend/src/hooks/useMoveKeys.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +// Arrow keys step through moves and space toggles play; ignored while typing in a form field. +export default function useMoveKeys(handlers: { previous: () => void, next: () => void, togglePlay?: () => void }) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) && target.getAttribute("type") !== "range") return; + if (event.key === "ArrowLeft") handlers.previous(); + else if (event.key === "ArrowRight") handlers.next(); + else if (event.key === " " && handlers.togglePlay) handlers.togglePlay(); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); +} diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 7889633..96e8bba 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import Row from '../components/Row'; import UserStatus from '../components/UserStatus'; -import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events } from '@mancala/core'; +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 { Context } from '../context/context'; import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; @@ -29,6 +29,12 @@ import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; import { useTranslation } from 'react-i18next'; +import MoveList from '../components/history/MoveList'; +import ReviewBanner from '../components/history/ReviewBanner'; +import useMoveKeys from '../hooks/useMoveKeys'; +import { createBoardViewModel } from '../util/BoardViewModels'; +import { getResultText } from '../util/GameResultText'; +import CircularPanel from '../components/CircularPanel'; const GamePage: FunctionComponent<{ context: Context, @@ -53,6 +59,15 @@ const GamePage: FunctionComponent<{ const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); + // Set while an earlier position is shown instead of the live board. + const [viewedMove, setViewedMove] = useState(undefined); + + // Set when the server says the game ended, for whatever reason. + const [gameEnd, setGameEnd] = useState(undefined); + + // Ticks every second while a countdown is shown. + const [now, setNow] = useState(Date.now()); + const { height, width } = useWindowDimensions(); const navigate = useNavigate(); @@ -105,6 +120,10 @@ const GamePage: FunctionComponent<{ setUserKeyWhoLeave(userKeyWhoLeave); setHasOngoingAction(false); }; + const onGameEnd = (message: GameEndMessage) => { + setGameEnd(message); + setHasOngoingAction(false); + }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); @@ -116,7 +135,9 @@ const GamePage: FunctionComponent<{ context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); + context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); return () => { + context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); @@ -178,10 +199,14 @@ const GamePage: FunctionComponent<{ if (!game || isSpectator || !userKey) { return; } - if(userKeyWhoLeave) { + if(userKeyWhoLeave || gameEnd) { notyf.error(t("GameEnded")); return; } + if (viewedMove !== undefined) { + notyf.error(t("ViewingMove", { number: viewedMove })); + return; + } if(game.mancalaGame.state === "ended") { notyf.error(t("GameEnded")); return; @@ -246,7 +271,51 @@ const GamePage: FunctionComponent<{ ); const isMobile = width < 600; - const renderNewGameBtn = isSpectator || (userKeyWhoLeave || !game || (game && game.mancalaGame.state == "ended")); + const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); + + const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; + const isReviewing = viewedMove !== undefined && viewedMove < moves.length; + const shownBoardViewModel = isReviewing && mancalaGame + ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) + : boardViewModel; + const isGameOver = !!(userKeyWhoLeave || gameEnd || mancalaGame?.state === "ended"); + const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; + const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); + useMoveKeys({ + previous: () => selectMove((isReviewing ? viewedMove! : moves.length) - 1), + next: () => isReviewing && selectMove(viewedMove! + 1), + }); + + const playerInfo = (userId: string) => { + const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] + .find((connectionInfo) => connectionInfo?.userId === userId); + return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; + }; + // A game that ended by a timeout or inactivity is not over by the rules, so say why it ended. + const endText = gameEnd && (gameEnd.endReason === "timeout" || gameEnd.endReason === "inactive") && mancalaGame + ? `${t("GameEnded")} · ${getResultText({ + result: gameEnd.result, endReason: gameEnd.endReason, + player1: playerInfo(mancalaGame.player1Id), + player2: playerInfo(mancalaGame.player2Id), + } as FinishedGameSummary, isSpectator ? undefined : userKey, t)}` + : undefined; + + // Countdown for the waiting player while the opponent is offline on their turn. + const opponentInfo = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] + .find((info) => info && info.userId !== userKey); + const countdownMs = !isSpectator && !isGameOver && opponentInfo && !opponentInfo.isOnline + && opponentInfo.offlineSince !== undefined && mancalaGame?.turnPlayerId === opponentInfo.userId + ? Math.max(0, opponentInfo.offlineSince + TURN_TIMEOUT_MS - now) + : undefined; + React.useEffect(() => { + if (countdownMs === undefined) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [countdownMs === undefined]); + const formatCountdown = (ms: number) => { + const seconds = Math.ceil(ms / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; @@ -276,15 +345,36 @@ const GamePage: FunctionComponent<{ {renderHeaderBar()} {renderMobileBoardToolbar()} {buildBoardTopToolbar()} + {showBoardView && countdownMs !== undefined && ( + + {t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })} + + )} + {showBoardView && isReviewing && ( + setViewedMove(undefined)} /> + )} {showBoardView && ( )} + {showBoardView && ( + getPlayerName(playerId) ?? t("Anonymous")} + collapsedByDefault={isMobile} /> + )}
    @@ -343,7 +433,8 @@ const GamePage: FunctionComponent<{ blackPlayer={bottomLocatedUser} leftPlayer={leftPlayer} visible={params.visible} - isSpectator={isSpectator} /> + isSpectator={isSpectator} + overrideText={endText} /> ); } } diff --git a/apps/frontend/src/util/BoardViewModels.ts b/apps/frontend/src/util/BoardViewModels.ts new file mode 100644 index 0000000..419fa84 --- /dev/null +++ b/apps/frontend/src/util/BoardViewModels.ts @@ -0,0 +1,9 @@ +import { BoardViewModel, BoardViewModelFactory, PitViewModelFactory, Theme } from "@mancala/core"; +import { v4 } from "uuid"; + +// A plain board (no highlights) showing the given stone counts. +export function createBoardViewModel(stones: number[], theme: Theme): BoardViewModel { + return BoardViewModelFactory.create(v4(), stones.map((stoneCount, index) => PitViewModelFactory.create({ + id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor, + }))); +} diff --git a/docs/specs/game-history/tasks.md b/docs/specs/game-history/tasks.md index 6dc1edb..f33773c 100644 --- a/docs/specs/game-history/tasks.md +++ b/docs/specs/game-history/tasks.md @@ -20,6 +20,6 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. - [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby rejection message. -- [ ] 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. - [ ] 12. **End to end and docs**: Chrome tests from `design.md`, README and `TODO.md` updates.