+
- {`⟲ ${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;