feat(frontend): lay out the game page in the new design
Players beside the board with avatar, online status, store count and clock, the turn between them, a game-over card over the board, and a move list with the newest move first and tags for extra turns, captures and store gains. On phones the bottom player is shown below the board.
This commit is contained in:
parent
e3e7916265
commit
efe97da0b7
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -1,38 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core";
|
import { CLOCK_LOW_TIME_MS, formatClock } from "@mancala/core";
|
||||||
import { Context } from '../context/context';
|
|
||||||
|
|
||||||
// Readable on every theme; the themes have no warning color of their own.
|
// One player's clock: highlighted while it runs, in a warning colour when the running clock is low.
|
||||||
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.
|
|
||||||
const GameClockView: FunctionComponent<{
|
const GameClockView: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
running: boolean,
|
running: boolean,
|
||||||
}> = ({ context, timeMs, running }) => {
|
}> = ({ timeMs, running }) => {
|
||||||
const theme = context.themeManager.theme;
|
|
||||||
const lowTime = running && timeMs < CLOCK_LOW_TIME_MS;
|
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 (
|
return (
|
||||||
<span className="clock" style={{ background, color }} role="timer">
|
<span className={"clock" + (lowTime ? " low" : running ? " run" : "")} role="timer">
|
||||||
{formatClock(timeMs)}
|
{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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
33
apps/frontend/src/components/game/GameOverCard.tsx
Normal file
33
apps/frontend/src/components/game/GameOverCard.tsx
Normal 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;
|
||||||
36
apps/frontend/src/components/game/PlayerCard.tsx
Normal file
36
apps/frontend/src/components/game/PlayerCard.tsx
Normal 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;
|
||||||
61
apps/frontend/src/components/history/MoveList.css
Normal file
61
apps/frontend/src/components/history/MoveList.css
Normal 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; }
|
||||||
|
}
|
||||||
@ -1,26 +1,27 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
import { getColorByBrightness, PlayedMove } from "@mancala/core";
|
import { describeMove, PlayedMove } from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { ChevronIcon } from "../Icons";
|
||||||
import { getMoveText } from "../../util/MoveText";
|
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.
|
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
|
||||||
const MoveList: FunctionComponent<{
|
const MoveList: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
moves: PlayedMove[],
|
moves: PlayedMove[],
|
||||||
// Move whose position is shown.
|
// Move whose position is shown.
|
||||||
selectedMove: number,
|
selectedMove: number,
|
||||||
onSelect: (moveNumber: number) => void,
|
onSelect: (moveNumber: number) => void,
|
||||||
playerName: (playerId: string) => string,
|
playerName: (playerId: string) => string,
|
||||||
collapsedByDefault?: boolean,
|
// Moves of this player get an accent dot, the others a muted one.
|
||||||
}> = ({ context, moves, selectedMove, onSelect, playerName, collapsedByDefault }) => {
|
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 { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(!!collapsedByDefault);
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
const selectedRef = useRef<HTMLLIElement>(null);
|
const selectedRef = useRef<HTMLLIElement>(null);
|
||||||
const listRef = useRef<HTMLOListElement>(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.
|
// Keep the shown move visible by scrolling the list only, never the page.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -32,89 +33,36 @@ const MoveList: FunctionComponent<{
|
|||||||
}
|
}
|
||||||
}, [selectedMove, moves.length, collapsed]);
|
}, [selectedMove, moves.length, collapsed]);
|
||||||
|
|
||||||
const entry = (moveNumber: number, label: React.ReactNode) => (
|
const entry = (moveNumber: number) => {
|
||||||
<li
|
const selected = moveNumber === selectedMove;
|
||||||
key={moveNumber}
|
const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined;
|
||||||
ref={moveNumber === selectedMove ? selectedRef : undefined}
|
return (
|
||||||
className={moveNumber === selectedMove ? "selected" : ""}
|
<li key={moveNumber} ref={selected ? selectedRef : undefined}>
|
||||||
style={moveNumber === selectedMove ? { background: theme.boardColor } : undefined}
|
<button className={"mrow" + (selected ? " on" : "")} aria-current={selected} onClick={() => onSelect(moveNumber)}>
|
||||||
onClick={() => onSelect(moveNumber)}>
|
<span className="mn">{move ? `${moveNumber}.` : ""}</span>
|
||||||
{label}
|
{move && <span className="mdot" style={{ background: move.playerId === accentPlayerId ? "var(--accent)" : "var(--muted)" }} />}
|
||||||
</li>
|
{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 (
|
return (
|
||||||
<div className="move-list" style={{ background: theme.appBarBgColor, color: textColor }}>
|
<section className="moves" aria-label={t("Moves")}>
|
||||||
<button className="header" style={{ color: textColor }} onClick={() => setCollapsed(!collapsed)}>
|
<button className="mhead" aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}>
|
||||||
{`${t("Moves")} (${moves.length}) ${collapsed ? "▸" : "▾"}`}
|
<span>
|
||||||
|
{`${t("Moves")} `}
|
||||||
|
<span className="muted">{`(${moves.length})${newestFirst ? ` · ${t("NewestFirst")}` : ""}`}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronIcon className={"chev" + (collapsed ? " closed" : "")} />
|
||||||
</button>
|
</button>
|
||||||
{!collapsed && (
|
{!collapsed && <ol className="mlist" ref={listRef}>{moveNumbers.map(entry)}</ol>}
|
||||||
<ol ref={listRef}>
|
</section>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
20
apps/frontend/src/components/history/MoveTags.tsx
Normal file
20
apps/frontend/src/components/history/MoveTags.tsx
Normal 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;
|
||||||
@ -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.
|
// Each player's clock after the shown move; games saved before the game clock have none.
|
||||||
const clockTimes = getClockAtMove(record, moveNumber);
|
const clockTimes = getClockAtMove(record, moveNumber);
|
||||||
const renderClock = (player: PlayerSummary) => clockTimes && (
|
const renderClock = (player: PlayerSummary) => clockTimes && (
|
||||||
<GameClockView context={context} running={false}
|
<GameClockView running={false}
|
||||||
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -219,7 +219,6 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MoveList
|
<MoveList
|
||||||
context={context}
|
|
||||||
moves={moves}
|
moves={moves}
|
||||||
selectedMove={moveNumber}
|
selectedMove={moveNumber}
|
||||||
onSelect={jumpTo}
|
onSelect={jumpTo}
|
||||||
|
|||||||
@ -1,43 +1,21 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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.
|
// Shown during a live game while an earlier position is on the board.
|
||||||
const ReviewBanner: FunctionComponent<{
|
const ReviewBanner: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
moveNumber: number,
|
moveNumber: number,
|
||||||
isYourTurn: boolean,
|
isYourTurn: boolean,
|
||||||
onBackToLive: () => void,
|
onBackToLive: () => void,
|
||||||
}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => {
|
}> = ({ moveNumber, isYourTurn, onBackToLive }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = context.themeManager.theme;
|
|
||||||
const background = isYourTurn ? theme.playerTurnColor : theme.boardColor;
|
|
||||||
const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor);
|
|
||||||
return (
|
return (
|
||||||
<div className="review-banner" style={{ background, color: textColor }}>
|
<div className="review" role="status">
|
||||||
<span>
|
<span>
|
||||||
{`⟲ ${t("ViewingMove", { number: moveNumber })}`}
|
{t("ViewingMove", { number: moveNumber })}
|
||||||
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
|
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
|
||||||
</span>
|
</span>
|
||||||
<Button context={context} color={theme.pitColor} text={`${t("BackToLive")} ▶|`} onClick={onBackToLive} />
|
<button className="btn pri" onClick={onBackToLive}>{t("BackToLive")}</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -4,22 +4,16 @@ import { FunctionComponent, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { PitAnimator } from '@mancala/core';
|
import { PitAnimator } from '@mancala/core';
|
||||||
import BoardToolbar from '../components/board/BoardToolbar';
|
|
||||||
import BoardView from '../components/board/BoardView';
|
import BoardView from '../components/board/BoardView';
|
||||||
import InfoPanel from '../components/InfoPanel';
|
|
||||||
import LoadingComponent from '../components/LoadingComponent';
|
import LoadingComponent from '../components/LoadingComponent';
|
||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
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 { 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 { Context } from '../context/context';
|
||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme } from '@mancala/core';
|
import { Theme, getPlayerDisplayName } from '@mancala/core';
|
||||||
import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core";
|
|
||||||
import { BoardViewModel } from "@mancala/core";
|
import { BoardViewModel } from "@mancala/core";
|
||||||
import Center from '../components/Center';
|
|
||||||
import notyf from '../util/Notyf';
|
import notyf from '../util/Notyf';
|
||||||
import swal from 'sweetalert';
|
import swal from 'sweetalert';
|
||||||
import Util from '../util/Util';
|
import Util from '../util/Util';
|
||||||
@ -32,6 +26,9 @@ import { getResultText } from '../util/GameResultText';
|
|||||||
import ReplayView from '../components/history/ReplayView';
|
import ReplayView from '../components/history/ReplayView';
|
||||||
import GameClockView from '../components/GameClockView';
|
import GameClockView from '../components/GameClockView';
|
||||||
import useGameClock from '../hooks/useGameClock';
|
import useGameClock from '../hooks/useGameClock';
|
||||||
|
import PlayerCard from '../components/game/PlayerCard';
|
||||||
|
import GameOverCard from '../components/game/GameOverCard';
|
||||||
|
import '../styles/game.css';
|
||||||
|
|
||||||
const GamePage: FunctionComponent<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -65,7 +62,8 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set when the server says the game ended, for whatever reason.
|
// Set when the server says the game ended, for whatever reason.
|
||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
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();
|
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 renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended"));
|
||||||
|
|
||||||
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
||||||
@ -292,86 +283,133 @@ const GamePage: FunctionComponent<{
|
|||||||
.find((connectionInfo) => connectionInfo?.userId === userId);
|
.find((connectionInfo) => connectionInfo?.userId === userId);
|
||||||
return { userId, name: info?.name, isGuest: info?.isGuest ?? true };
|
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
|
// How the game ended, as a saved game would say it; from the server's message when it sent one.
|
||||||
? `${t("GameEnded")} · ${getResultText({
|
const getEndSummary = (): FinishedGameSummary | undefined => {
|
||||||
result: gameEnd.result, endReason: gameEnd.endReason,
|
if (!mancalaGame || !isGameOver) return undefined;
|
||||||
player1: playerInfo(mancalaGame.player1Id),
|
const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id);
|
||||||
player2: playerInfo(mancalaGame.player2Id),
|
const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2";
|
||||||
} as FinishedGameSummary, isSpectator ? undefined : userKey, t)}`
|
if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary;
|
||||||
: undefined;
|
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 clockTimes = useGameClock(game, gameEnd);
|
||||||
const renderClock = (playerId: string) => {
|
const renderClock = (playerId: string) => {
|
||||||
const seat = mancalaGame && getSeat(mancalaGame, playerId);
|
const seat = mancalaGame && getSeat(mancalaGame, playerId);
|
||||||
if (!clockTimes || !seat) return undefined;
|
if (!clockTimes || !seat) return undefined;
|
||||||
const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
|
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 showBoardView = game && boardViewModel && userKey && true;
|
||||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||||
const topLocatedUserName = getPlayerName(topLocatedUserId);
|
|
||||||
const bottomLocatedUserName = getPlayerName(bottomLocatedUserId);
|
|
||||||
const topLocatedUser = {
|
const topLocatedUser = {
|
||||||
id: topLocatedUserId,
|
id: topLocatedUserId,
|
||||||
name: topLocatedUserName ?? "Anonymous",
|
name: getPlayerName(topLocatedUserId) ?? t("Anonymous"),
|
||||||
|
isGuest: playerInfo(topLocatedUserId).isGuest,
|
||||||
isOnline: isUserOnline(topLocatedUserId),
|
isOnline: isUserOnline(topLocatedUserId),
|
||||||
isAnonymous: !topLocatedUserName
|
|
||||||
};
|
};
|
||||||
const bottomLocatedUser = {
|
const bottomLocatedUser = {
|
||||||
id: bottomLocatedUserId,
|
id: bottomLocatedUserId,
|
||||||
name: bottomLocatedUserName ?? "Anonymous",
|
name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"),
|
||||||
|
isGuest: playerInfo(bottomLocatedUserId).isGuest,
|
||||||
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
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) {
|
if (replayRecord) {
|
||||||
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{renderHeaderBar()}
|
{renderHeaderBar()}
|
||||||
{renderMobileBoardToolbar()}
|
<main className="wrap">
|
||||||
{buildBoardTopToolbar()}
|
{showBoardView && renderGame()}
|
||||||
{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>
|
|
||||||
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
||||||
</Center>
|
</main>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -386,46 +424,6 @@ const GamePage: FunctionComponent<{
|
|||||||
</button>}
|
</button>}
|
||||||
</AppHeader>;
|
</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;
|
export default GamePage;
|
||||||
|
|
||||||
|
|||||||
174
apps/frontend/src/styles/game.css
Normal file
174
apps/frontend/src/styles/game.css
Normal 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; }
|
||||||
|
}
|
||||||
7
apps/frontend/src/util/PlayerText.ts
Normal file
7
apps/frontend/src/util/PlayerText.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -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,
|
- [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.
|
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.
|
- [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.
|
- [ ] 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.
|
- [ ] 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;
|
- [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user