From 8511e0b3f2ef36ca81b9eb0a6a8e156844255af7 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:53:18 +0300 Subject: [PATCH] feat(frontend): show the rules A How to play page with every rule and a before/after board computed by the engine, a help button in the header, and the rules in a side panel over the game page so a game can go on while a rule is looked up. --- apps/frontend/src/MancalaApp.tsx | 2 + apps/frontend/src/components/Icons.tsx | 4 ++ .../src/components/headerbar/AppHeader.tsx | 12 ++-- apps/frontend/src/components/home/Home.css | 14 ----- .../src/components/home/MiniBoard.css | 19 ++++++ .../src/components/home/MiniBoard.tsx | 15 +++-- .../src/components/rules/RuleBoard.tsx | 44 ++++++++++++++ .../src/components/rules/RulesPanel.tsx | 52 +++++++++++++++++ .../src/components/rules/RulesView.tsx | 58 +++++++++++++++++++ apps/frontend/src/routes/GamePage.tsx | 6 +- apps/frontend/src/routes/RulesPage.tsx | 37 ++++++++++++ docs/specs/tutorial/tasks.md | 2 +- 12 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/src/components/home/MiniBoard.css create mode 100644 apps/frontend/src/components/rules/RuleBoard.tsx create mode 100644 apps/frontend/src/components/rules/RulesPanel.tsx create mode 100644 apps/frontend/src/components/rules/RulesView.tsx create mode 100644 apps/frontend/src/routes/RulesPage.tsx diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index bf2a1f8..a2faa37 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -14,6 +14,7 @@ import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core'; import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; import PrivacyPage from './routes/PrivacyPage'; +import RulesPage from './routes/RulesPage'; import swal from 'sweetalert'; import Util from './util/Util'; import { server } from './const/config'; @@ -130,6 +131,7 @@ const MancalaApp: FunctionComponent = () => { }> + } /> } diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 3f7e5c0..37f93d1 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => ( export const BotIcon = ({ size = 20 }: IconProps) => ( ); + +export const HelpIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/headerbar/AppHeader.tsx b/apps/frontend/src/components/headerbar/AppHeader.tsx index 236e159..bb22127 100644 --- a/apps/frontend/src/components/headerbar/AppHeader.tsx +++ b/apps/frontend/src/components/headerbar/AppHeader.tsx @@ -4,13 +4,14 @@ import { Link } from "react-router-dom"; import { AuthUser } from "@mancala/core"; import { useTranslation } from "react-i18next"; import { Context } from "../../context/context"; -import { LogoIcon } from "../Icons"; +import { HelpIcon, LogoIcon } from "../Icons"; import AccountMenu from "./AccountMenu"; import ThemeSwatches from "./ThemeSwatches"; -// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's -// main action (children). -const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => { +// The header of every page: logo and name, theme swatches, how to play, the account menu when a user is given, and +// the page's main action (children). How to play opens the rules page, or calls `onHelp` (the game page shows the +// rules over the game instead). +const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser, onHelp?: () => void }> = ({ context, authUser, onHelp, children }) => { const { t } = useTranslation(); return (
@@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = {t("Mancala")} + {onHelp + ? + : } {authUser && } {children}
diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css index 74fc073..3cdda22 100644 --- a/apps/frontend/src/components/home/Home.css +++ b/apps/frontend/src/components/home/Home.css @@ -47,24 +47,10 @@ .meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .meta > span:last-child { text-align: right; } -/* Mini board: the top player's store on the left, the bottom player's on the right */ - -.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } -.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } -.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } -.mini .ms.l { grid-column: 1; } -.mini .ms.r { grid-column: 8; color: var(--accent); } -.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } -.mini.lg .mp { font-size: 16px; } -.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } - @media (max-width: 640px) { .hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; } .hero h1 { font-size: 25px; } .hero .btn { flex: 1; } - .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } - .mini.lg .mp { font-size: 13px; } - .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } .grid { grid-template-columns: 1fr; gap: 14px; } .sec { margin: 12px 0 -4px; } } diff --git a/apps/frontend/src/components/home/MiniBoard.css b/apps/frontend/src/components/home/MiniBoard.css new file mode 100644 index 0000000..17d5c87 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.css @@ -0,0 +1,19 @@ +/* Mini board: the top player's store on the left, the bottom player's on the right */ + +.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } +.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms.l { grid-column: 1; } +.mini .ms.r { grid-column: 8; color: var(--accent); } +.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } +.mini.lg .mp { font-size: 16px; } +.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } +/* A move shown on the board: the pit played and where its last stone landed */ +.mini .played { box-shadow: inset 0 0 0 2px var(--accent); } +.mini .land, .mini .ms.r.land { background: var(--accent); color: var(--onAccent); } + +@media (max-width: 640px) { + .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } + .mini.lg .mp { font-size: 13px; } + .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } +} diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx index b6e1332..8923dd3 100644 --- a/apps/frontend/src/components/home/MiniBoard.tsx +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -1,22 +1,27 @@ import * as React from "react"; import { FunctionComponent } from "react"; +import "./MiniBoard.css"; const PLAYER1_PITS = [0, 1, 2, 3, 4, 5]; const PLAYER2_PITS = [7, 8, 9, 10, 11, 12]; const PLAYER1_STORE = 6; const PLAYER2_STORE = 13; -// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. -const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => { +// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. `played` and `landed` mark +// a move: the pit played and where its last stone landed. +const MiniBoard: FunctionComponent<{ + board: number[], revert?: boolean, large?: boolean, played?: number, landed?: number, +}> = ({ board, revert, large, played, landed }) => { const [topPits, topStore, bottomPits, bottomStore] = revert ? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE] : [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE]; - const pit = (index: number) =>
{board[index] || ""}
; + const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : ""); + const pit = (index: number) =>
{board[index] || ""}
; return ( ); diff --git a/apps/frontend/src/components/rules/RuleBoard.tsx b/apps/frontend/src/components/rules/RuleBoard.tsx new file mode 100644 index 0000000..5d925ef --- /dev/null +++ b/apps/frontend/src/components/rules/RuleBoard.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getMoveOutcome, RuleExample } from "@mancala/core"; +import MiniBoard from "../home/MiniBoard"; +import { NextIcon } from "../Icons"; + +// A rule's example: the board before the move with the pit played marked, and after it with the landing pit marked. +// The board after the move comes from the engine. +const RuleBoard: FunctionComponent<{ example: RuleExample }> = ({ example }) => { + if (example.pitIndex === undefined) { + return
; + } + const outcome = getMoveOutcome(example.board, example.pitIndex); + return ( +
+ + + + +
+ ); +}; + +export default RuleBoard; diff --git a/apps/frontend/src/components/rules/RulesPanel.tsx b/apps/frontend/src/components/rules/RulesPanel.tsx new file mode 100644 index 0000000..566b0be --- /dev/null +++ b/apps/frontend/src/components/rules/RulesPanel.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseIcon } from "../Icons"; +import RulesView from "./RulesView"; + +// The rules over the game page, so a player can look a rule up without leaving the game (R1.4). +const RulesPanel: FunctionComponent<{ onClose: () => void }> = ({ onClose }) => { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +}; + +export default RulesPanel; diff --git a/apps/frontend/src/components/rules/RulesView.tsx b/apps/frontend/src/components/rules/RulesView.tsx new file mode 100644 index 0000000..2ca7727 --- /dev/null +++ b/apps/frontend/src/components/rules/RulesView.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { RULES } from "@mancala/core"; +import RuleBoard from "./RuleBoard"; + +// Every rule in order with its text and example (docs/specs/tutorial, R1). `showLessons` adds a link to each lesson; +// left out where following it would leave a game. +const RulesView: FunctionComponent<{ showLessons?: boolean }> = ({ showLessons }) => { + const { t } = useTranslation(); + return ( +
    + {RULES.map((rule) => ( +
  1. +

    {t(rule.titleKey)}

    + {rule.textKeys.map((key) =>

    {t(key)}

    )} + {rule.example && } + {showLessons && rule.lesson && ( + {t("TryIt")} + )} +
  2. + ))} + +
+ ); +}; + +export default RulesView; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index bd3f812..cfd8a47 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -28,6 +28,7 @@ import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; import PlayerCard from '../components/game/PlayerCard'; import GameOverCard from '../components/game/GameOverCard'; +import RulesPanel from '../components/rules/RulesPanel'; import '../styles/game.css'; const GamePage: FunctionComponent<{ @@ -61,6 +62,8 @@ const GamePage: FunctionComponent<{ // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); + // The rules over the game; the game goes on behind them. + const [rulesOpen, setRulesOpen] = useState(false); // Set when the player closes the game over card to look at the board. const [gameOverClosed, setGameOverClosed] = useState(false); @@ -419,11 +422,12 @@ const GamePage: FunctionComponent<{ {showBoardView && renderGame()} + {rulesOpen && setRulesOpen(false)} />} ); function renderHeaderBar() { - return + return setRulesOpen(true)}> {renderNewGameBtn ?