Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
19 changed files with 485 additions and 555 deletions
Showing only changes of commit efe97da0b7 - Show all commits

View File

@ -1,21 +0,0 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Center: FunctionComponent = ({children}) => {
return (
<div className='center'>
{children}
<style jsx>{`
.center {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
}
`}</style>
</div>
);
}
export default Center;

View File

@ -1,24 +0,0 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const CircularPanel: FunctionComponent<{
color: string;
style?: React.CSSProperties
}> = (props) => {
return (
<div style={Object.assign({ background: props.color }, props.style)}>
<style jsx>{`
div {
padding: 10px 20px;
border-radius: 30px;
transition: background-color 0.5s;
white-space: nowrap;
}
`}
</style>
{props.children}
</div>
);
}
export default CircularPanel;

View File

@ -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 (
<span className="clock" style={{ background, color }} role="timer">
<span className={"clock" + (lowTime ? " low" : running ? " run" : "")} role="timer">
{formatClock(timeMs)}
<style jsx>{`
.clock {
padding: 2px 10px;
border-radius: 30px;
border: 2px solid ${theme.boardColor};
font-weight: bold;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: background-color 0.5s, color 0.5s;
}
`}</style>
</span>
);
}

View File

@ -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 (
<CircularPanel style={style} color={containerColor}>
<h4 style={{ margin: "0", color: textColor, transition: 'color 0.5s' }}>
{text}
</h4>
</CircularPanel>
);
} else {
return (<div></div>)
}
};
export default InfoPanel;

View File

@ -1,10 +0,0 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => {
return (
<div style={{ width, height}}/>
);
}
export default Space;

View File

@ -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 (
<div style={style} className={layoutMode === "right" ? "flex-rtl" : "flex-ltr"}>
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span>
<Space width='5px' />
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
{accessory && <><Space width='8px' />{accessory}</>}
<style jsx>{`
.online {
background-color: ${context.themeManager.theme.boardColor};
}
.offline {
background-color: transparent;
}
.circle {
width: 15px;
height: 15px;
min-width: 15px;
min-height: 15px;
border-radius: 15px;
border: 2px solid ${context.themeManager.theme.boardColor};
transition: background-color 0.5s;
transition: color 0.5s;
}
.flex-rtl {
display: flex;
flex-direction: row-reverse;
align-items: center;
}
.flex-ltr {
display: flex;
flex-direction: row;
align-items: center;
}
.text {
font-weight: bold;
text-overflow: ellipsis;
overflow: hidden;
}
.icon {
color : "grey";
width: 32px;
height: 32px;
font-size: 32px;
}
`}</style>
</div>
);
}
export default UserStatus;

View File

@ -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 (
<div style={style} className='toolbar'>
{children}
<style jsx>{`
.toolbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
align-self: stretch;
}
`}</style>
</div>
);
}
export default BoardToolbar;

View File

@ -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 (
<div className="over">
<div className="ocard" role="dialog" aria-label={title}>
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
<div className="otitle">{title}</div>
{reason && <div className="oreason">{reason}</div>}
<div className="oscore">{score}</div>
<div className="row">
<button className="btn pri" onClick={onNewGame}>{t("NewGame")}</button>
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
</div>
</div>
</div>
);
};
export default GameOverCard;

View File

@ -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 (
<div className={"pl" + (active ? " on" : "") + (alignRight ? " r" : "")}>
<div className="av" aria-hidden="true">
{getInitials(name, isGuest)}
{online !== undefined && <span className={"presence" + (online ? " online" : "")} />}
</div>
<div className="pinfo">
<div className="pname">{name}</div>
<div className="psub">
{details}
{online !== undefined && <span className="visually-hidden">{` · ${online ? t("Online") : t("Offline")}`}</span>}
</div>
</div>
{clock}
</div>
);
};
export default PlayerCard;

View File

@ -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; }
}

View File

@ -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<boolean>(!!collapsedByDefault);
const [collapsed, setCollapsed] = useState<boolean>(false);
const selectedRef = useRef<HTMLLIElement>(null);
const listRef = useRef<HTMLOListElement>(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) => (
<li
key={moveNumber}
ref={moveNumber === selectedMove ? selectedRef : undefined}
className={moveNumber === selectedMove ? "selected" : ""}
style={moveNumber === selectedMove ? { background: theme.boardColor } : undefined}
onClick={() => onSelect(moveNumber)}>
{label}
</li>
);
const entry = (moveNumber: number) => {
const selected = moveNumber === selectedMove;
const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined;
return (
<li key={moveNumber} ref={selected ? selectedRef : undefined}>
<button className={"mrow" + (selected ? " on" : "")} aria-current={selected} onClick={() => onSelect(moveNumber)}>
<span className="mn">{move ? `${moveNumber}.` : ""}</span>
{move && <span className="mdot" style={{ background: move.playerId === accentPlayerId ? "var(--accent)" : "var(--muted)" }} />}
{move && <span className="mname">{playerName(move.playerId)}</span>}
<span className="mpit">{move ? t("PitNumber", { pit: describeMove(moves, moveNumber).pit }) : t("Start")}</span>
{move && <MoveTags moves={moves} moveNumber={moveNumber} />}
</button>
</li>
);
};
const moveNumbers = Array.from({ length: moves.length + 1 }, (_, i) => i);
if (newestFirst) moveNumbers.reverse();
return (
<div className="move-list" style={{ background: theme.appBarBgColor, color: textColor }}>
<button className="header" style={{ color: textColor }} onClick={() => setCollapsed(!collapsed)}>
{`${t("Moves")} (${moves.length}) ${collapsed ? "▸" : "▾"}`}
<section className="moves" aria-label={t("Moves")}>
<button className="mhead" aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}>
<span>
{`${t("Moves")} `}
<span className="muted">{`(${moves.length})${newestFirst ? ` · ${t("NewestFirst")}` : ""}`}</span>
</span>
<ChevronIcon className={"chev" + (collapsed ? " closed" : "")} />
</button>
{!collapsed && (
<ol ref={listRef}>
{entry(0, <span className="start">{t("Start")}</span>)}
{moves.map((move, i) => entry(i + 1, (
<>
<span className="number">{i + 1}.</span>
<span className="player">{playerName(move.playerId)}</span>
<span className="details">{getMoveText(moves, i + 1, t)}</span>
</>
)))}
</ol>
)}
<style jsx>{`
.move-list {
/* The page is a flex column; without this the list is squeezed to almost nothing. */
flex-shrink: 0;
width: calc(100% - 32px);
max-width: 640px;
margin: 16px;
border-radius: 16px;
overflow: hidden;
transition: background-color 0.5s;
}
.header {
width: 100%;
padding: 10px 16px;
background: none;
border: none;
text-align: left;
font-weight: bold;
cursor: pointer;
}
ol {
position: relative;
list-style: none;
margin: 0;
padding: 0 8px 8px;
max-height: 240px;
overflow-y: auto;
}
/* Rows are built in helper functions, which styled-jsx does not scope. */
.move-list :global(li) {
display: flex;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
}
.move-list :global(li.selected) {
font-weight: bold;
}
.move-list :global(.number) {
min-width: 2.5em;
opacity: 0.7;
}
.move-list :global(.player) {
min-width: 8em;
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.move-list :global(.details) {
opacity: 0.9;
}
`}</style>
</div>
{!collapsed && <ol className="mlist" ref={listRef}>{moveNumbers.map(entry)}</ol>}
</section>
);
};

View File

@ -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 (
<span className="tags">
{move.extraTurn && <span className="tag x">{t("ExtraTurn")}</span>}
{move.capture && <span className="tag c">{t("Capture")}</span>}
{move.storeGain > 0 && <span className="tag">{t("StoreGain", { count: move.storeGain })}</span>}
{move.gameOver && <span className="tag">{t("GameOver")}</span>}
</span>
);
};
export default MoveTags;

View File

@ -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 && (
<GameClockView context={context} running={false}
<GameClockView running={false}
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
);
@ -219,7 +219,6 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
</div>
<MoveList
context={context}
moves={moves}
selectedMove={moveNumber}
onSelect={jumpTo}

View File

@ -1,43 +1,21 @@
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 }) => {
}> = ({ 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 (
<div className="review-banner" style={{ background, color: textColor }}>
<div className="review" role="status">
<span>
{`⟲ ${t("ViewingMove", { number: moveNumber })}`}
{t("ViewingMove", { number: moveNumber })}
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
</span>
<Button context={context} color={theme.pitColor} text={`${t("BackToLive")} ▶|`} onClick={onBackToLive} />
<style jsx>{`
.review-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: calc(100% - 32px);
max-width: 640px;
margin: 0 16px 8px;
padding: 4px 8px 4px 16px;
border-radius: 30px;
box-sizing: border-box;
transition: background-color 0.5s;
}
`}</style>
<button className="btn pri" onClick={onBackToLive}>{t("BackToLive")}</button>
</div>
);
};

View File

@ -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;
}

View File

@ -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<GameEndMessage | undefined>(undefined);
const { height, width } = useWindowDimensions();
// Set when the player closes the game over card to look at the board.
const [gameOverClosed, setGameOverClosed] = useState<boolean>(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 <GameClockView context={context} timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
return <GameClockView timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
};
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 <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
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) => (
<PlayerCard
name={user.name}
isGuest={user.isGuest}
details={[role, t("StoreCount", { count: shownStones(storeIndex) })].filter(Boolean).join(" · ")}
active={!isGameOver && mancalaGame!.turnPlayerId === user.id}
online={user.isOnline}
clock={renderClock(user.id)}
alignRight={alignRight} />
);
return (
<div className="gl">
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
<div className="a-turn">
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
{!isGameOver && <span className="dot" />}
{getTurnText()}
</div>
</div>
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
<div className="a-board">
{isReviewing && (
<ReviewBanner
moveNumber={viewedMove!}
isYourTurn={isYourTurn}
onBackToLive={() => setViewedMove(undefined)} />
)}
<BoardView
game={game!}
boardViewModel={shownBoardViewModel!}
context={context}
onPitSelect={isSpectator ? undefined : onPitSelect}
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} />
{isGameOver && !gameOverClosed && (
<GameOverCard
title={resultTitle ?? t("GameEnded")}
reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
onNewGame={onNewGameClick}
onClose={() => setGameOverClosed(true)} />
)}
</div>
<div className="a-moves">
<MoveList
moves={moves}
selectedMove={isReviewing ? viewedMove! : moves.length}
onSelect={selectMove}
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
accentPlayerId={bottomLocatedUserId}
newestFirst />
</div>
</div>
);
};
return (
<PageContainer>
{renderHeaderBar()}
{renderMobileBoardToolbar()}
{buildBoardTopToolbar()}
{showBoardView && isReviewing && (
<ReviewBanner
context={context}
moveNumber={viewedMove!}
isYourTurn={isYourTurn}
onBackToLive={() => setViewedMove(undefined)} />
)}
{showBoardView && (
<BoardView
game={game}
boardViewModel={shownBoardViewModel!}
context={context}
onPitSelect={isSpectator ? undefined : onPitSelect}
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} />
)}
{showBoardView && (
<MoveList
context={context}
moves={moves}
selectedMove={isReviewing ? viewedMove! : moves.length}
onSelect={selectMove}
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
collapsedByDefault={isMobile} />
)}
<Center>
<main className="wrap">
{showBoardView && renderGame()}
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</Center>
</main>
</PageContainer>
);
@ -386,46 +424,6 @@ const GamePage: FunctionComponent<{
</button>}
</AppHeader>;
}
function renderMobileBoardToolbar() {
return <BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
{buildInfoPanel({ visible: isMobile })}
</BoardToolbar>;
}
function buildBoardTopToolbar() {
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
<UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
accessory={renderClock(topLocatedUserId)} />
{buildInfoPanel({ visible: !isMobile })}
<UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
accessory={renderClock(bottomLocatedUserId)} />
</BoardToolbar>;
}
function buildInfoPanel(params: { visible: boolean }) {
return (
<InfoPanel
// Long texts such as "Game Ended · You Won · Opponent ran out of time" wrap on phones.
style={{ marginTop: "0.5rem", marginBottom: "0.5rem", ...(isMobile && { whiteSpace: "normal", textAlign: "center" }) }}
context={context}
game={game}
currentUser={currentUser}
whitePlayer={topLocatedUser}
blackPlayer={bottomLocatedUser}
leftPlayer={leftPlayer}
visible={params.visible}
isSpectator={isSpectator}
overrideText={endText} />
);
}
}
export default GamePage;

View File

@ -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; }
}

View File

@ -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();
}

View File

@ -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;