diff --git a/apps/frontend/src/components/Center.tsx b/apps/frontend/src/components/Center.tsx deleted file mode 100644 index 19aeaf8..0000000 --- a/apps/frontend/src/components/Center.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Center: FunctionComponent = ({children}) => { - return ( -
- {children} - -
- ); -} - -export default Center; \ No newline at end of file diff --git a/apps/frontend/src/components/CircularPanel.tsx b/apps/frontend/src/components/CircularPanel.tsx deleted file mode 100644 index 2de9283..0000000 --- a/apps/frontend/src/components/CircularPanel.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const CircularPanel: FunctionComponent<{ - color: string; - style?: React.CSSProperties -}> = (props) => { - return ( -
- - {props.children} -
- ); -} - -export default CircularPanel; diff --git a/apps/frontend/src/components/GameClockView.tsx b/apps/frontend/src/components/GameClockView.tsx index 72660ff..e52bb2b 100644 --- a/apps/frontend/src/components/GameClockView.tsx +++ b/apps/frontend/src/components/GameClockView.tsx @@ -1,38 +1,16 @@ import * as React from 'react'; import { FunctionComponent } from 'react'; -import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core"; -import { Context } from '../context/context'; +import { CLOCK_LOW_TIME_MS, formatClock } from "@mancala/core"; -// Readable on every theme; the themes have no warning color of their own. -const LOW_TIME_COLOR = "#b3261e"; -const LOW_TIME_TEXT_COLOR = "#ffffff"; - -// One player's clock: highlighted while it runs, in a warning color when the running clock is low. +// One player's clock: highlighted while it runs, in a warning colour when the running clock is low. const GameClockView: FunctionComponent<{ - context: Context, timeMs: number, running: boolean, -}> = ({ context, timeMs, running }) => { - const theme = context.themeManager.theme; +}> = ({ timeMs, running }) => { const lowTime = running && timeMs < CLOCK_LOW_TIME_MS; - const background = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent"; - const color = lowTime ? LOW_TIME_TEXT_COLOR : running - ? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor) - : getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); return ( - + {formatClock(timeMs)} - ); } diff --git a/apps/frontend/src/components/InfoPanel.tsx b/apps/frontend/src/components/InfoPanel.tsx deleted file mode 100644 index ce68598..0000000 --- a/apps/frontend/src/components/InfoPanel.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { Game, User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import CircularPanel from "./CircularPanel"; -import { useTranslation } from "react-i18next"; - -function getInfoPanelTextByGameState(params: { - context: Context; - t: (text: string) => string; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - isSpectator?: boolean; -}): string | undefined { - const { - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - } = params; - - if (leftPlayer) { - return isSpectator ? `${leftPlayer.name} ${t("UserLeftTheGame")}` : - leftPlayer.id == currentUser.id ? t("YouLeftTheGame") : t("OpponentLeftTheGame"); - } - - const isGameEnded = game?.mancalaGame.state == "ended"; - if (isGameEnded) { - const wonPlayerID = game.mancalaGame.getWonPlayerId(); - let whoWon; - if (wonPlayerID) { - const wonPlayer = wonPlayerID == whitePlayer.id ? whitePlayer : blackPlayer; - whoWon = isSpectator ? `${wonPlayer.name} ${t("Won")}` : - game.mancalaGame.getWonPlayerId() === currentUser.id - ? t("YouWon") - : t("YouLost"); - } else { - whoWon = t("GameDraw"); - } - return t("GameEnded") + " " + whoWon; - } - - if (game) { - const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer; - return isSpectator ? `${playingPlayer.name} ${t("Playing")}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id) - ? t("YourTurn") - : t("OpponentTurn"); - } - - return undefined; -} - -const InfoPanel: FunctionComponent<{ - context: Context; - game?: Game; - currentUser: User; - whitePlayer: User; - blackPlayer: User; - leftPlayer?: User; - 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, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - style, - visible, - isSpectator, - overrideText -}) => { - - const { t } = useTranslation(); - - if (visible === false) return <>; - const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false; - const containerColor = isUserTurn - ? context.themeManager.theme.playerTurnColor - : context.themeManager.theme.boardColor; - const textColor = getColorByBrightness( - containerColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const text = overrideText ?? getInfoPanelTextByGameState({ - context, - t, - game, - currentUser, - whitePlayer, - blackPlayer, - leftPlayer, - isSpectator - }); - if (text) { - return ( - -

- {text} -

-
- ); - } else { - return (
) - } - }; - -export default InfoPanel; diff --git a/apps/frontend/src/components/Space.tsx b/apps/frontend/src/components/Space.tsx deleted file mode 100644 index c1dfc5f..0000000 --- a/apps/frontend/src/components/Space.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => { - return ( -
- ); -} - -export default Space; \ No newline at end of file diff --git a/apps/frontend/src/components/UserStatus.tsx b/apps/frontend/src/components/UserStatus.tsx deleted file mode 100644 index a9ddc98..0000000 --- a/apps/frontend/src/components/UserStatus.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; -import { Context } from '../context/context'; -import { User } from "@mancala/core"; -import { getColorByBrightness } from "@mancala/core"; -import Space from './Space'; -import { useTranslation } from 'react-i18next'; - -export type LayoutMode = "right" | "left"; - -const UserStatus: FunctionComponent<{ - context: Context, - user: User, - layoutMode: LayoutMode, - visible?: boolean, - style?: React.CSSProperties, - // Shown after the online status, e.g. the player's clock. - accessory?: React.ReactNode, -}> = ({ context, user, layoutMode, visible, style, accessory }) => { - if (visible === false) return <>; - - const { t } = useTranslation(); - - const textColorOnBoard = getColorByBrightness( - context.themeManager.theme.background, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
- {user.isAnonymous ? t("Anonymous") : user.name} - -
- {accessory && <>{accessory}} - -
- ); -} - -export default UserStatus; \ No newline at end of file diff --git a/apps/frontend/src/components/board/BoardToolbar.tsx b/apps/frontend/src/components/board/BoardToolbar.tsx deleted file mode 100644 index f51a029..0000000 --- a/apps/frontend/src/components/board/BoardToolbar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import { FunctionComponent } from 'react'; - -const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: React.CSSProperties }> = ({ children, visible, style }) => { - if(visible === false) return <>; - return ( -
- {children} - -
- ); -} - -export default BoardToolbar; \ No newline at end of file diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx new file mode 100644 index 0000000..cd01de7 --- /dev/null +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { CloseIcon } from "../Icons"; + +// Over the board when a game ends: the result, why it ended, the score, and where to go next. +const GameOverCard: FunctionComponent<{ + title: string, + reason?: string, + score: string, + onNewGame: () => void, + onClose: () => void, +}> = ({ title, reason, score, onNewGame, onClose }) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + return ( +
+
+ +
{title}
+ {reason &&
{reason}
} +
{score}
+
+ + +
+
+
+ ); +}; + +export default GameOverCard; diff --git a/apps/frontend/src/components/game/PlayerCard.tsx b/apps/frontend/src/components/game/PlayerCard.tsx new file mode 100644 index 0000000..b0fcc95 --- /dev/null +++ b/apps/frontend/src/components/game/PlayerCard.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { getInitials } from "../../util/PlayerText"; + +// A player beside the board: avatar with initials and online status, name, a line of details and their clock. +// The player to move is ringed. Aligned to the right, everything is mirrored. +const PlayerCard: FunctionComponent<{ + name: string, + isGuest: boolean, + details: string, + active: boolean, + online?: boolean, + clock?: React.ReactNode, + alignRight?: boolean, +}> = ({ name, isGuest, details, active, online, clock, alignRight }) => { + const { t } = useTranslation(); + return ( +
+ +
+
{name}
+
+ {details} + {online !== undefined && {` · ${online ? t("Online") : t("Offline")}`}} +
+
+ {clock} +
+ ); +}; + +export default PlayerCard; diff --git a/apps/frontend/src/components/history/MoveList.css b/apps/frontend/src/components/history/MoveList.css new file mode 100644 index 0000000..448ec05 --- /dev/null +++ b/apps/frontend/src/components/history/MoveList.css @@ -0,0 +1,61 @@ +.moves { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; flex-shrink: 0; } + +.mhead { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 54px; + padding: 0 20px; + border: none; + background: transparent; + color: var(--ink); + font: inherit; + font-weight: 800; + font-size: 16px; + cursor: pointer; +} + +.mhead .muted { color: var(--muted); font-weight: 600; } +.mhead:focus-visible, .mrow:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; } + +.chev { flex: none; transition: transform .2s; } +.chev.closed { transform: rotate(-90deg); } + +.mlist { position: relative; max-height: 330px; overflow: auto; margin: 0; padding: 0 8px 8px; list-style: none; } + +.mrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 12px; + min-height: 44px; + padding: 8px 12px; + border-radius: 14px; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +@media (hover: hover) { .mrow:hover { background: var(--surface2); } } + +.mrow.on { background: var(--surface2); box-shadow: inset 3px 0 0 var(--accent); } +.mrow.on .tag { background: var(--surface); } +.mrow.on .tag.x { background: var(--accent); } +.mrow.on .tag.c { background: var(--ink); } + +.mn { width: 26px; color: var(--muted); font-variant-numeric: tabular-nums; } +.mdot { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.mname { font-weight: 700; min-width: 120px; max-width: 14em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mpit { color: var(--muted); } +.mrow .tags { margin-left: auto; } + +@media (max-width: 640px) { + .mname { min-width: 0; } +} diff --git a/apps/frontend/src/components/history/MoveList.tsx b/apps/frontend/src/components/history/MoveList.tsx index ca0016a..5f2d3ed 100644 --- a/apps/frontend/src/components/history/MoveList.tsx +++ b/apps/frontend/src/components/history/MoveList.tsx @@ -1,26 +1,27 @@ import * as React from "react"; import { FunctionComponent, useEffect, useRef, useState } from "react"; -import { getColorByBrightness, PlayedMove } from "@mancala/core"; +import { describeMove, PlayedMove } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import { getMoveText } from "../../util/MoveText"; +import { ChevronIcon } from "../Icons"; +import MoveTags from "./MoveTags"; +import "./MoveList.css"; // 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 }) => { + // Moves of this player get an accent dot, the others a muted one. + accentPlayerId?: string, + // Live games list the newest move first, replays the start first. + newestFirst?: boolean, +}> = ({ moves, selectedMove, onSelect, playerName, accentPlayerId, newestFirst }) => { const { t } = useTranslation(); - const [collapsed, setCollapsed] = useState(!!collapsedByDefault); + const [collapsed, setCollapsed] = useState(false); 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(() => { @@ -32,89 +33,36 @@ const MoveList: FunctionComponent<{ } }, [selectedMove, moves.length, collapsed]); - const entry = (moveNumber: number, label: React.ReactNode) => ( -
  • onSelect(moveNumber)}> - {label} -
  • - ); + const entry = (moveNumber: number) => { + const selected = moveNumber === selectedMove; + const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + return ( +
  • + +
  • + ); + }; + + const moveNumbers = Array.from({ length: moves.length + 1 }, (_, i) => i); + if (newestFirst) moveNumbers.reverse(); return ( -
    - - {!collapsed && ( -
      - {entry(0, {t("Start")})} - {moves.map((move, i) => entry(i + 1, ( - <> - {i + 1}. - {playerName(move.playerId)} - {getMoveText(moves, i + 1, t)} - - )))} -
    - )} - -
    + {!collapsed &&
      {moveNumbers.map(entry)}
    } + ); }; diff --git a/apps/frontend/src/components/history/MoveTags.tsx b/apps/frontend/src/components/history/MoveTags.tsx new file mode 100644 index 0000000..fab3934 --- /dev/null +++ b/apps/frontend/src/components/history/MoveTags.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { describeMove, PlayedMove } from "@mancala/core"; +import { useTranslation } from "react-i18next"; + +// What happened in a move, as tags: extra turn, capture, stones added to the store, game over. +const MoveTags: FunctionComponent<{ moves: PlayedMove[], moveNumber: number }> = ({ moves, moveNumber }) => { + const { t } = useTranslation(); + const move = describeMove(moves, moveNumber); + return ( + + {move.extraTurn && {t("ExtraTurn")}} + {move.capture && {t("Capture")}} + {move.storeGain > 0 && {t("StoreGain", { count: move.storeGain })}} + {move.gameOver && {t("GameOver")}} + + ); +}; + +export default MoveTags; diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 530fc1d..0885f72 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -151,7 +151,7 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga // 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 && ( - ); @@ -219,7 +219,6 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
    void, -}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => { +}> = ({ 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 })}`} + {t("ViewingMove", { number: moveNumber })} {isYourTurn && {` · ${t("YourTurn")}`}} -
    ); }; diff --git a/apps/frontend/src/hooks/useWindowDimensions.ts b/apps/frontend/src/hooks/useWindowDimensions.ts deleted file mode 100644 index 21daa13..0000000 --- a/apps/frontend/src/hooks/useWindowDimensions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useState, useEffect } from 'react'; - -//https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs - -function getWindowDimensions() { - const { innerWidth: width, innerHeight: height } = window; - return { - width, - height - }; -} - -export default function useWindowDimensions() { - const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); - - useEffect(() => { - function handleResize() { - setWindowDimensions(getWindowDimensions()); - } - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return windowDimensions; -} diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index efebe38..7f33d2e 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -4,22 +4,16 @@ import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; -import BoardToolbar from '../components/board/BoardToolbar'; import BoardView from '../components/board/BoardView'; -import InfoPanel from '../components/InfoPanel'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import AppHeader from '../components/headerbar/AppHeader'; import { LeaveIcon, PlusIcon } from '../components/Icons'; -import UserStatus from '../components/UserStatus'; 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, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; -import useWindowDimensions from '../hooks/useWindowDimensions'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme } from '@mancala/core'; -import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core"; +import { Theme, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; -import Center from '../components/Center'; import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; @@ -32,6 +26,9 @@ import { getResultText } from '../util/GameResultText'; import ReplayView from '../components/history/ReplayView'; import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; +import PlayerCard from '../components/game/PlayerCard'; +import GameOverCard from '../components/game/GameOverCard'; +import '../styles/game.css'; const GamePage: FunctionComponent<{ context: Context, @@ -65,7 +62,8 @@ const GamePage: FunctionComponent<{ // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); - const { height, width } = useWindowDimensions(); + // Set when the player closes the game over card to look at the board. + const [gameOverClosed, setGameOverClosed] = useState(false); const navigate = useNavigate(); @@ -265,13 +263,6 @@ const GamePage: FunctionComponent<{ }; }, []); - const textColorOnAppBar = getColorByBrightness( - context.themeManager.theme.appBarBgColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - const isMobile = width < 600; - const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; @@ -292,86 +283,133 @@ const GamePage: FunctionComponent<{ .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; + + // How the game ended, as a saved game would say it; from the server's message when it sent one. + const getEndSummary = (): FinishedGameSummary | undefined => { + if (!mancalaGame || !isGameOver) return undefined; + const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id); + const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2"; + if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary; + if (userKeyWhoLeave) { + const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1"; + return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary; + } + const winnerId = mancalaGame.getWonPlayerId(); + return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary; + }; + const endSummary = getEndSummary(); + // "You won · Opponent left" becomes the title "You won" and the reason "Opponent left". + const [resultTitle, ...resultReasons] = endSummary + ? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ") + : []; + const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId; + const resultReason = resultReasons.join(" · ") + || (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined); const clockTimes = useGameClock(game, gameEnd); const renderClock = (playerId: string) => { const seat = mancalaGame && getSeat(mancalaGame, playerId); if (!clockTimes || !seat) return undefined; const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms; - return ; + return ; }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; - const topLocatedUserName = getPlayerName(topLocatedUserId); - const bottomLocatedUserName = getPlayerName(bottomLocatedUserId); const topLocatedUser = { id: topLocatedUserId, - name: topLocatedUserName ?? "Anonymous", + name: getPlayerName(topLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(topLocatedUserId).isGuest, isOnline: isUserOnline(topLocatedUserId), - isAnonymous: !topLocatedUserName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: bottomLocatedUserName ?? "Anonymous", + name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"), + isGuest: playerInfo(bottomLocatedUserId).isGuest, isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: !bottomLocatedUserName }; - const currentUser = isSpectator ? { - id: "2", - name: "Anonymous", - isOnline: context.rtmt.connectionState === "connected", - isAnonymous: true - } : bottomLocatedUser; - const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined; if (replayRecord) { return ; } + const getTurnText = () => { + if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded"); + if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`; + return isYourTurn ? t("YourTurn") : t("OpponentTurn"); + }; + + const renderGame = () => { + const board = mancalaGame!.board; + const [topStore, bottomStore] = isPlayer2 + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; + const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount; + const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => ( + + ); + return ( +
    +
    {playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}
    +
    +
    + {!isGameOver && } + {getTurnText()} +
    +
    +
    {playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
    +
    + {isReviewing && ( + setViewedMove(undefined)} /> + )} + + {isGameOver && !gameOverClosed && ( + setGameOverClosed(true)} /> + )} +
    +
    + getPlayerName(playerId) ?? t("Anonymous")} + accentPlayerId={bottomLocatedUserId} + newestFirst /> +
    +
    + ); + }; + return ( {renderHeaderBar()} - {renderMobileBoardToolbar()} - {buildBoardTopToolbar()} - {showBoardView && isReviewing && ( - setViewedMove(undefined)} /> - )} - {showBoardView && ( - - )} - {showBoardView && ( - getPlayerName(playerId) ?? t("Anonymous")} - collapsedByDefault={isMobile} /> - )} -
    +
    + {showBoardView && renderGame()} -
    +
    ); @@ -386,46 +424,6 @@ const GamePage: FunctionComponent<{ } ; } - - function renderMobileBoardToolbar() { - return - {buildInfoPanel({ visible: isMobile })} - ; - } - - function buildBoardTopToolbar() { - return - - {buildInfoPanel({ visible: !isMobile })} - - ; - } - - function buildInfoPanel(params: { visible: boolean }) { - return ( - - ); - } } export default GamePage; - diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css new file mode 100644 index 0000000..208b706 --- /dev/null +++ b/apps/frontend/src/styles/game.css @@ -0,0 +1,174 @@ +/* Layout of the game and replay pages: the top player on the left, the turn in the middle, the bottom player on the + right, then the board and what is below it. On phones the bottom player moves below the board. */ + +.gl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-areas: "opp turn me" "board board board" "moves moves moves"; + gap: 26px 16px; + align-items: center; +} + +.a-opp { grid-area: opp; min-width: 0; } +.a-turn { grid-area: turn; min-width: 0; } +.a-me { grid-area: me; justify-self: end; min-width: 0; } +.a-board { grid-area: board; position: relative; min-width: 0; } +.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; } + +/* Players */ + +.pl { display: flex; align-items: center; gap: 12px; min-width: 0; } +.pl.r { flex-direction: row-reverse; text-align: right; } + +.av { + position: relative; + width: 44px; + height: 44px; + flex: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 15px; + background: var(--chip); + color: var(--chipInk); + box-shadow: 0 0 0 2px var(--rim); + transition: box-shadow .25s; +} + +.pl.on .av { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); } + +.presence { + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + box-sizing: border-box; + background: var(--bg); + border: 2px solid var(--muted); +} + +.presence.online { background: var(--accent); border-color: var(--bg); } + +.pinfo { min-width: 0; } +.pname { font-weight: 700; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.psub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.clock { + flex: none; + min-width: 56px; + height: 32px; + padding: 0 10px; + box-sizing: border-box; + border-radius: 16px; + font-weight: 800; + font-size: 15px; + line-height: 32px; + text-align: center; + font-variant-numeric: tabular-nums; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + transition: background .25s, color .25s; +} + +.clock.run { background: var(--accent); color: var(--onAccent); box-shadow: none; } +.clock.low { background: var(--live); color: #1a0d08; box-shadow: none; } + +/* Turn */ + +.turn { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 18px; + box-sizing: border-box; + border-radius: 20px; + font-weight: 800; + font-size: 15px; + background: var(--surface); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--line); + white-space: nowrap; + font-variant-numeric: tabular-nums; + transition: background .25s, color .25s; +} + +.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; } + +/* Review banner, above the board */ + +.review { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 760px; + margin: 0 auto 22px; + padding: 6px 6px 6px 18px; + border-radius: 28px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--accentSoft); + font-size: 14px; + font-weight: 600; +} + +.review strong { color: var(--accent); } + +/* Game over card, over the board */ + +.over { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + border-radius: 32px; + background: rgba(0, 0, 0, .38); + backdrop-filter: blur(3px); +} + +.ocard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: calc(100% - 32px); + padding: 28px 32px; + box-sizing: border-box; + border-radius: 28px; + background: var(--surface); + box-shadow: 0 20px 50px rgba(0, 0, 0, .35); + text-align: center; +} + +.ocard .close { position: absolute; top: 8px; right: 8px; } +.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; } +.oreason { font-size: 15px; font-weight: 700; color: var(--muted); } +.oscore { font-size: 18px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 8px; } +.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } + +/* Tags of a move: extra turn, capture, stones to the store */ + +.tags { display: flex; gap: 6px; flex-wrap: wrap; } +.tag { height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; font-weight: 700; line-height: 24px; background: var(--surface2); color: var(--ink); white-space: nowrap; } +.tag.x { background: var(--accent); color: var(--onAccent); } +.tag.c { background: var(--ink); color: var(--bg); } + +@media (max-width: 640px) { + .gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; } + .a-me { justify-self: start; } + .pl.r { flex-direction: row; text-align: left; } + .pl { gap: 8px; } + .clock { min-width: 50px; padding: 0 8px; font-size: 14px; } + .turn { min-height: 36px; padding: 6px 14px; font-size: 14px; white-space: normal; text-align: center; } + .review { margin-bottom: 16px; } + .ocard { padding: 26px 20px 20px; } + .otitle { font-size: 26px; } +} diff --git a/apps/frontend/src/util/PlayerText.ts b/apps/frontend/src/util/PlayerText.ts new file mode 100644 index 0000000..d696a30 --- /dev/null +++ b/apps/frontend/src/util/PlayerText.ts @@ -0,0 +1,7 @@ +// Up to two characters for an avatar: a guest's code ("Guest 4F2A" → "4F"), otherwise the first letter. +export function getInitials(name: string | undefined, isGuest: boolean): string { + const trimmed = name?.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/); + return isGuest && words.length > 1 ? words[words.length - 1].slice(0, 2).toUpperCase() : trimmed[0].toUpperCase(); +} diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index e83df0f..32734f4 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, action) on every page; restyle the lobby, privacy page, loading and connection panels. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. -- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. +- [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [ ] 6. **Web app replay page**: summary card, controls card, move list. - [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes;