Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
78
apps/frontend/src/components/game/SeriesStrip.tsx
Normal file
78
apps/frontend/src/components/game/SeriesStrip.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getSeriesScore, Seat, SeriesGame } from "@mancala/core";
|
||||||
|
|
||||||
|
// The most boxes the strip shows; earlier games collapse into a "+N" box.
|
||||||
|
const MAX_BOXES = 8;
|
||||||
|
|
||||||
|
// Above the board from the second game of a series (docs/specs/rematch, R3): the series score and one box per game,
|
||||||
|
// each with its winner's colour, the stores from the bottom player's side, a dot for who moved first and how it
|
||||||
|
// ended. Finished games open their replay in a new tab; the game being played is marked.
|
||||||
|
const SeriesStrip: FunctionComponent<{
|
||||||
|
// Finished games, oldest first.
|
||||||
|
games: SeriesGame[],
|
||||||
|
// A game of the series is being played after them.
|
||||||
|
hasCurrentGame: boolean,
|
||||||
|
bottomSeat: Seat,
|
||||||
|
bottomName: string,
|
||||||
|
topName: string,
|
||||||
|
}> = ({ games, hasCurrentGame, bottomSeat, bottomName, topName }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const score = getSeriesScore(games, bottomSeat);
|
||||||
|
const finishedBoxes = MAX_BOXES - (hasCurrentGame ? 1 : 0);
|
||||||
|
const hiddenCount = games.length > finishedBoxes ? games.length - finishedBoxes + 1 : 0;
|
||||||
|
const shown = games.slice(hiddenCount);
|
||||||
|
|
||||||
|
const renderGame = (game: SeriesGame, index: number) => {
|
||||||
|
const number = hiddenCount + index + 1;
|
||||||
|
const bottomStore = bottomSeat === "player1" ? game.player1Store : game.player2Store;
|
||||||
|
const topStore = bottomSeat === "player1" ? game.player2Store : game.player1Store;
|
||||||
|
const winner = game.result === "player1" || game.result === "player2"
|
||||||
|
? (game.result === bottomSeat ? "mine" : "theirs") : "draw";
|
||||||
|
const ending = game.endReason === "timeout" ? t("EndedOnTime") : game.endReason === "left" ? t("EndedByLeaving") : undefined;
|
||||||
|
const bottomFirst = game.firstSeat === bottomSeat;
|
||||||
|
const firstMark = <span className="sfirst" title={t("MovedFirst")} />;
|
||||||
|
const label = [
|
||||||
|
t("SeriesGameNumber", { number }),
|
||||||
|
`${bottomName} ${bottomStore} – ${topStore} ${topName}`,
|
||||||
|
`${t("MovedFirst")}: ${bottomFirst ? bottomName : topName}`,
|
||||||
|
ending,
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
return (
|
||||||
|
<li key={game.gameId}>
|
||||||
|
<a className={"sg " + winner} href={`/game/${game.gameId}`} target="_blank" rel="noopener" aria-label={label}>
|
||||||
|
<span className="sn">{ending ? `${number} · ${ending}` : number}</span>
|
||||||
|
<span className="ss">{bottomFirst && firstMark}{bottomStore}–{topStore}{!bottomFirst && firstMark}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="series" aria-label={t("Series")}>
|
||||||
|
<div className="sscore">
|
||||||
|
<span className="slabel">{t("Series")}</span>
|
||||||
|
<span className="sname">{bottomName}</span>
|
||||||
|
<b>{score.wins} – {score.losses}</b>
|
||||||
|
<span className="sname">{topName}</span>
|
||||||
|
</div>
|
||||||
|
<ol className="sgames">
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<li><span className="sg more" title={t("SeriesEarlierGames", { count: hiddenCount })}>+{hiddenCount}</span></li>
|
||||||
|
)}
|
||||||
|
{shown.map(renderGame)}
|
||||||
|
{hasCurrentGame && (
|
||||||
|
<li aria-current="true">
|
||||||
|
<span className="sg now">
|
||||||
|
<span className="sn">{games.length + 1}</span>
|
||||||
|
<span className="ss">{t("SeriesCurrentGame")}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeriesStrip;
|
||||||
@ -10,7 +10,7 @@ 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 { 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, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } 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, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core';
|
||||||
import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver } from '@mancala/core';
|
import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver, getSeriesScore } from '@mancala/core';
|
||||||
import { Context } from '../context/context';
|
import { Context } from '../context/context';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
||||||
@ -29,6 +29,7 @@ import GameClockView from '../components/GameClockView';
|
|||||||
import useGameClock from '../hooks/useGameClock';
|
import useGameClock from '../hooks/useGameClock';
|
||||||
import PlayerCard from '../components/game/PlayerCard';
|
import PlayerCard from '../components/game/PlayerCard';
|
||||||
import GameOverCard from '../components/game/GameOverCard';
|
import GameOverCard from '../components/game/GameOverCard';
|
||||||
|
import SeriesStrip from '../components/game/SeriesStrip';
|
||||||
import RulesPanel from '../components/rules/RulesPanel';
|
import RulesPanel from '../components/rules/RulesPanel';
|
||||||
import '../styles/game.css';
|
import '../styles/game.css';
|
||||||
|
|
||||||
@ -439,6 +440,19 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined;
|
const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined;
|
||||||
|
|
||||||
|
// The series' finished games, with this one once it has ended (R3).
|
||||||
|
const seriesGames = gameEnd?.series?.games ?? game?.series?.games ?? [];
|
||||||
|
const hasCurrentSeriesGame = !isGameOver;
|
||||||
|
const showSeries = seriesGames.length + (hasCurrentSeriesGame ? 1 : 0) > 1;
|
||||||
|
const bottomSeat = (mancalaGame && getSeat(mancalaGame, bottomLocatedUserId)) ?? "player1";
|
||||||
|
// "You" for a player, the names for spectators (R3.7).
|
||||||
|
const seriesBottomName = isSpectator ? bottomLocatedUser.name : t("You");
|
||||||
|
const getSeriesScoreText = (): string | undefined => {
|
||||||
|
if (!showSeries) return undefined;
|
||||||
|
const score = getSeriesScore(seriesGames, bottomSeat);
|
||||||
|
return t("SeriesScoreText", { first: seriesBottomName, firstWins: score.wins, secondWins: score.losses, second: topLocatedUser.name });
|
||||||
|
};
|
||||||
|
|
||||||
const getRematchStatusText = (): string | undefined => {
|
const getRematchStatusText = (): string | undefined => {
|
||||||
if (isSpectator) return undefined;
|
if (isSpectator) return undefined;
|
||||||
const name = topLocatedUser.name;
|
const name = topLocatedUser.name;
|
||||||
@ -500,7 +514,17 @@ const GamePage: FunctionComponent<{
|
|||||||
alignRight={alignRight} />
|
alignRight={alignRight} />
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="gl">
|
<div className={"gl" + (showSeries ? " has-series" : "")}>
|
||||||
|
{showSeries && (
|
||||||
|
<div className="a-series">
|
||||||
|
<SeriesStrip
|
||||||
|
games={seriesGames}
|
||||||
|
hasCurrentGame={hasCurrentSeriesGame}
|
||||||
|
bottomSeat={bottomSeat}
|
||||||
|
bottomName={seriesBottomName}
|
||||||
|
topName={topLocatedUser.name} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
|
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
|
||||||
<div className="a-turn">
|
<div className="a-turn">
|
||||||
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
|
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
|
||||||
@ -541,6 +565,7 @@ const GamePage: FunctionComponent<{
|
|||||||
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
|
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
|
||||||
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
|
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
|
||||||
]}
|
]}
|
||||||
|
seriesScore={getSeriesScoreText()}
|
||||||
status={getRematchStatusText()}
|
status={getRematchStatusText()}
|
||||||
waiting={rematch.status === "waiting"}
|
waiting={rematch.status === "waiting"}
|
||||||
onClose={() => setGameOverClosed(true)}>
|
onClose={() => setGameOverClosed(true)}>
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
|
.a-turn { grid-area: turn; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
|
||||||
.a-me { grid-area: me; justify-self: end; 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-board { grid-area: board; position: relative; min-width: 0; }
|
||||||
|
.gl.has-series { grid-template-areas: "series series series" "opp turn me" "board board board" "moves moves moves"; }
|
||||||
|
.a-series { grid-area: series; min-width: 0; }
|
||||||
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
/* Players */
|
/* Players */
|
||||||
@ -170,6 +172,30 @@
|
|||||||
.ostatus.waiting { color: var(--muted); }
|
.ostatus.waiting { color: var(--muted); }
|
||||||
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; }
|
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* Series strip: the score, then a box per game in the winner's colour, the game being played outlined. */
|
||||||
|
|
||||||
|
.series { display: flex; align-items: center; justify-content: center; gap: 10px 16px; flex-wrap: wrap; min-width: 0; }
|
||||||
|
.sscore { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 15px; white-space: nowrap; }
|
||||||
|
.sscore b { font-size: 18px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.slabel { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.sname { max-width: 140px; overflow: hidden; text-overflow: ellipsis; font-weight: 700; }
|
||||||
|
.sgames { display: flex; gap: 8px; max-width: 100%; margin: 0; padding: 2px; list-style: none; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.sgames::-webkit-scrollbar { display: none; }
|
||||||
|
.sg {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
|
||||||
|
min-width: 62px; height: 52px; padding: 0 8px; box-sizing: border-box; border-radius: 14px;
|
||||||
|
background: var(--surface2); color: var(--ink); text-decoration: none; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
a.sg:hover { filter: brightness(1.08); }
|
||||||
|
a.sg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.sg.mine { background: var(--accent); color: var(--onAccent); }
|
||||||
|
.sg.theirs { background: var(--ink); color: var(--bg); }
|
||||||
|
.sg.now { background: transparent; box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
.sg.more { color: var(--muted); font-weight: 800; }
|
||||||
|
.sn { font-size: 11px; font-weight: 700; line-height: 1.2; opacity: .75; white-space: nowrap; }
|
||||||
|
.ss { display: flex; align-items: center; gap: 3px; font-size: 15px; font-weight: 800; white-space: nowrap; }
|
||||||
|
.sfirst { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
/* Tags of a move: extra turn, capture, stones to the store */
|
/* Tags of a move: extra turn, capture, stones to the store */
|
||||||
|
|
||||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
@ -179,6 +205,9 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@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; }
|
.gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; }
|
||||||
|
.gl.has-series { grid-template-areas: "series series" "opp turn" "board board" "me me" "moves moves"; }
|
||||||
|
.series { flex-direction: column; gap: 8px; }
|
||||||
|
.sgames { justify-content: flex-start; }
|
||||||
.a-me { justify-self: start; }
|
.a-me { justify-self: start; }
|
||||||
.pl.r { flex-direction: row; text-align: left; }
|
.pl.r { flex-direction: row; text-align: left; }
|
||||||
.pl { gap: 8px; }
|
.pl { gap: 8px; }
|
||||||
|
|||||||
@ -111,10 +111,11 @@ The next game reaches both players as `on_game_start`, like a game from the lobb
|
|||||||
- The final score as two columns, the bottom player's first: the name (small, muted) above the store count
|
- The final score as two columns, the bottom player's first: the name (small, muted) above the store count
|
||||||
(large), the winner's count in their colour (R3.8).
|
(large), the winner's count in their colour (R3.8).
|
||||||
- The series score line once the series has more than one game (R3.6).
|
- The series score line once the series has more than one game (R3.6).
|
||||||
- **Series strip** (`SeriesStrip`, R3): above the board in the game grid, shown from the second game of a series. The
|
- **Series strip** (`SeriesStrip`, R3): the first row of the game grid, above the players and the board, shown from
|
||||||
|
the second game of a series. The
|
||||||
score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the
|
score, then one box per game: the winner's colour (`--accent` for the bottom player, the opponent's colour for the
|
||||||
top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a
|
top one, neutral for a draw), "30–18" from the bottom player's side, a small dot for the player who moved first, a
|
||||||
small "time" or "left" tag. The current game's box is outlined and says "now". Finished boxes are links to
|
"time" or "left" after the game's number. The current game's box is outlined and says "now". Finished boxes are links to
|
||||||
`/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below
|
`/game/<id>` with `target="_blank"` (R3.4). At most 8 boxes; earlier games collapse into a "+N" box (R3.5). Below
|
||||||
600 px the strip scrolls sideways inside itself, never the page.
|
600 px the strip scrolls sideways inside itself, never the page.
|
||||||
- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.
|
- Spectators: the strip uses the players' names instead of "You" (R3.7); they get no rematch buttons.
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
- [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
|
- [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests.
|
||||||
- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
|
- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch
|
||||||
states, "Play again" as a rematch, the score in two columns.
|
states, "Play again" as a rematch, the score in two columns.
|
||||||
- [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.
|
- [x] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators.
|
||||||
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),
|
- [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),
|
||||||
`TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note
|
`TODO.md` (the out of scope items; remove "A rematch button" from Web redesign), the web redesign design's note
|
||||||
on the rematch button.
|
on the rematch button.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user