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.
This commit is contained in:
parent
3a46833e86
commit
8511e0b3f2
@ -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 = () => {
|
||||
</Route>
|
||||
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
|
||||
</Route>
|
||||
<Route path="rules" element={<RulesPage context={context} theme={theme} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>}
|
||||
|
||||
@ -71,3 +71,7 @@ export const PauseIcon = ({ size = 22 }: IconProps) => (
|
||||
export const BotIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
|
||||
);
|
||||
|
||||
export const HelpIcon = ({ size = 20 }: IconProps) => (
|
||||
<svg {...stroke(size, 2)}><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7v.5" /><path d="M12 17.5h.01" /></svg>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
<header className={"hdr" + (authUser ? " has-account" : "")}>
|
||||
@ -19,6 +20,9 @@ const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> =
|
||||
<span>{t("Mancala")}</span>
|
||||
</Link>
|
||||
<ThemeSwatches context={context} />
|
||||
{onHelp
|
||||
? <button className="btn icon ghost" aria-label={t("HowToPlay")} title={t("HowToPlay")} onClick={onHelp}><HelpIcon /></button>
|
||||
: <Link className="btn icon ghost" to="/rules" aria-label={t("HowToPlay")} title={t("HowToPlay")}><HelpIcon /></Link>}
|
||||
{authUser && <AccountMenu context={context} authUser={authUser} />}
|
||||
{children}
|
||||
</header>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
19
apps/frontend/src/components/home/MiniBoard.css
Normal file
19
apps/frontend/src/components/home/MiniBoard.css
Normal file
@ -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; }
|
||||
}
|
||||
@ -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) => <div key={index} className="mp">{board[index] || ""}</div>;
|
||||
const marks = (index: number) => (index === played ? " played" : "") + (index === landed ? " land" : "");
|
||||
const pit = (index: number) => <div key={index} className={"mp" + marks(index)}>{board[index] || ""}</div>;
|
||||
return (
|
||||
<div className={"mini" + (large ? " lg" : "")} aria-hidden="true">
|
||||
<div className="ms l">{board[topStore]}</div>
|
||||
<div className={"ms l" + marks(topStore)}>{board[topStore]}</div>
|
||||
{[...topPits].reverse().map(pit)}
|
||||
<div className="ms r">{board[bottomStore]}</div>
|
||||
<div className={"ms r" + marks(bottomStore)}>{board[bottomStore]}</div>
|
||||
{bottomPits.map(pit)}
|
||||
</div>
|
||||
);
|
||||
|
||||
44
apps/frontend/src/components/rules/RuleBoard.tsx
Normal file
44
apps/frontend/src/components/rules/RuleBoard.tsx
Normal file
@ -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 <div className="rboard single"><MiniBoard board={example.board} /></div>;
|
||||
}
|
||||
const outcome = getMoveOutcome(example.board, example.pitIndex);
|
||||
return (
|
||||
<div className="rboard">
|
||||
<MiniBoard board={example.board} played={example.pitIndex} />
|
||||
<span className="arrow"><NextIcon /></span>
|
||||
<MiniBoard board={outcome.board} landed={outcome.path[outcome.path.length - 1]} />
|
||||
<style jsx>{`
|
||||
.rboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.rboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.arrow {
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleBoard;
|
||||
52
apps/frontend/src/components/rules/RulesPanel.tsx
Normal file
52
apps/frontend/src/components/rules/RulesPanel.tsx
Normal file
@ -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 (
|
||||
<div className="rpanel-back" onClick={onClose}>
|
||||
<aside className="rpanel" role="dialog" aria-label={t("HowToPlay")} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="rhead">
|
||||
<h1>{t("HowToPlay")}</h1>
|
||||
<button className="btn icon ghost" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
||||
</div>
|
||||
<RulesView />
|
||||
</aside>
|
||||
<style jsx>{`
|
||||
.rpanel-back {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, .45);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.rpanel {
|
||||
width: min(480px, 100%);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
box-shadow: -12px 0 30px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.rhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesPanel;
|
||||
58
apps/frontend/src/components/rules/RulesView.tsx
Normal file
58
apps/frontend/src/components/rules/RulesView.tsx
Normal file
@ -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 (
|
||||
<ol className="rules">
|
||||
{RULES.map((rule) => (
|
||||
<li key={rule.id} id={rule.id} className="rule panel">
|
||||
<h2>{t(rule.titleKey)}</h2>
|
||||
{rule.textKeys.map((key) => <p key={key}>{t(key)}</p>)}
|
||||
{rule.example && <RuleBoard example={rule.example} />}
|
||||
{showLessons && rule.lesson && (
|
||||
<Link className="btn ghost try" to={`/learn/${rule.id}`}>{t("TryIt")}</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<style jsx>{`
|
||||
.rules {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.rule {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rules :global(.try) {
|
||||
align-self: flex-start;
|
||||
text-decoration: none;
|
||||
}
|
||||
`}</style>
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesView;
|
||||
@ -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<GameEndMessage | undefined>(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<boolean>(false);
|
||||
@ -419,11 +422,12 @@ const GamePage: FunctionComponent<{
|
||||
{showBoardView && renderGame()}
|
||||
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
||||
</main>
|
||||
{rulesOpen && <RulesPanel onClose={() => setRulesOpen(false)} />}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
function renderHeaderBar() {
|
||||
return <AppHeader context={context}>
|
||||
return <AppHeader context={context} onHelp={() => setRulesOpen(true)}>
|
||||
{renderNewGameBtn
|
||||
? <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
|
||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
||||
|
||||
37
apps/frontend/src/routes/RulesPage.tsx
Normal file
37
apps/frontend/src/routes/RulesPage.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Theme } from "@mancala/core";
|
||||
import AppHeader from "../components/headerbar/AppHeader";
|
||||
import PageContainer from "../components/PageContainer";
|
||||
import RulesView from "../components/rules/RulesView";
|
||||
import { Context } from "../context/context";
|
||||
|
||||
// How to play: every rule with an example (docs/specs/tutorial, R1).
|
||||
const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PageContainer>
|
||||
<AppHeader context={context} />
|
||||
<main className="wrap rules-page">
|
||||
<h1>{t("HowToPlay")}</h1>
|
||||
<RulesView />
|
||||
</main>
|
||||
<style jsx>{`
|
||||
.rules-page {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-self: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
`}</style>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default RulesPage;
|
||||
@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
||||
- [x] 1. **Spec**: requirements, design and tasks.
|
||||
- [x] 2. **Core**: `RULES` with examples and lessons, `getMoveOutcome`, `fulfilsTask`, `explainMove`, `captured` and
|
||||
the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests.
|
||||
- [ ] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
|
||||
- [x] 3. **Rules on the web**: `RuleBoard`, `RulesView`, `/rules`, the help button in `AppHeader`, `RulesPanel` on the
|
||||
game page.
|
||||
- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
||||
reset, scripted opponent replies.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user