feat(frontend): add the lessons
Six lessons, one per rule, on the real board with the TypeScript engine: a task, the move animated, praise or an explanation and a retry. Completed lessons are remembered on the device.
This commit is contained in:
parent
8511e0b3f2
commit
9beb8ba7d4
@ -15,6 +15,8 @@ import { Theme } from '@mancala/core';
|
|||||||
import LobyPage from './routes/LobyPage';
|
import LobyPage from './routes/LobyPage';
|
||||||
import PrivacyPage from './routes/PrivacyPage';
|
import PrivacyPage from './routes/PrivacyPage';
|
||||||
import RulesPage from './routes/RulesPage';
|
import RulesPage from './routes/RulesPage';
|
||||||
|
import LearnPage from './routes/LearnPage';
|
||||||
|
import LessonPage from './routes/LessonPage';
|
||||||
import swal from 'sweetalert';
|
import swal from 'sweetalert';
|
||||||
import Util from './util/Util';
|
import Util from './util/Util';
|
||||||
import { server } from './const/config';
|
import { server } from './const/config';
|
||||||
@ -132,6 +134,8 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
|
<Route path="privacy" element={<PrivacyPage context={context} theme={theme} userKey={userKey} />}>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="rules" element={<RulesPage context={context} theme={theme} />} />
|
<Route path="rules" element={<RulesPage context={context} theme={theme} />} />
|
||||||
|
<Route path="learn" element={<LearnPage context={context} theme={theme} />} />
|
||||||
|
<Route path="learn/:ruleId" element={<LessonPage context={context} theme={theme} />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>}
|
</BrowserRouter>}
|
||||||
|
|||||||
94
apps/frontend/src/routes/LearnPage.tsx
Normal file
94
apps/frontend/src/routes/LearnPage.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getCompletedLessons, hideTutorialBanner, LESSON_RULES, Theme } from "@mancala/core";
|
||||||
|
import AppHeader from "../components/headerbar/AppHeader";
|
||||||
|
import PageContainer from "../components/PageContainer";
|
||||||
|
import { Context } from "../context/context";
|
||||||
|
|
||||||
|
// The lessons in order, the ones done marked (docs/specs/tutorial, R2.6).
|
||||||
|
const LearnPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => hideTutorialBanner(context.storage), []);
|
||||||
|
const completed = getCompletedLessons(context.storage);
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap learn">
|
||||||
|
<h1>{t("LearnMangala")}</h1>
|
||||||
|
<ol className="lessons">
|
||||||
|
{LESSON_RULES.map((rule, index) => {
|
||||||
|
const done = completed.includes(rule.id);
|
||||||
|
return (
|
||||||
|
<li key={rule.id}>
|
||||||
|
<Link className={"lesson-link panel" + (done ? " done" : "")} to={`/learn/${rule.id}`}>
|
||||||
|
<span className="num">{done ? "✓" : index + 1}</span>
|
||||||
|
<span className="name">{t(rule.titleKey)}</span>
|
||||||
|
{done && <span className="pill">{t("LessonDone")}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<Link className="rules-link" to="/rules">{t("HowToPlay")}</Link>
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.learn {
|
||||||
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.lessons {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.learn :global(.lesson-link) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.learn :global(.lesson-link:hover) {
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.learn :global(.num) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.learn :global(.done .num) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--onAccent);
|
||||||
|
}
|
||||||
|
.learn :global(.name) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.learn :global(.rules-link) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearnPage;
|
||||||
201
apps/frontend/src/routes/LessonPage.tsx
Normal file
201
apps/frontend/src/routes/LessonPage.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { CommonMancalaGame } from "mancala.js";
|
||||||
|
import {
|
||||||
|
BoardViewModel, completeLesson, explainMove, fulfilsTask, Game, getMoveOutcome, getNextLessonRule, getRule,
|
||||||
|
hideTutorialBanner, LESSON_RULES, MoveOutcome, PitAnimator, Theme,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import AppHeader from "../components/headerbar/AppHeader";
|
||||||
|
import PageContainer from "../components/PageContainer";
|
||||||
|
import BoardView from "../components/board/BoardView";
|
||||||
|
import { Context } from "../context/context";
|
||||||
|
|
||||||
|
const YOU = "you";
|
||||||
|
const OPPONENT = "opponent";
|
||||||
|
|
||||||
|
// A game showing the lesson's board, with the real rules, the player at the bottom to move.
|
||||||
|
function createLessonGame(board: number[]): Game {
|
||||||
|
const mancalaGame = new CommonMancalaGame("lesson", YOU, OPPONENT);
|
||||||
|
board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
|
||||||
|
mancalaGame.turnPlayerId = YOU;
|
||||||
|
mancalaGame.state = "playing";
|
||||||
|
return {
|
||||||
|
id: "lesson",
|
||||||
|
mancalaGame,
|
||||||
|
gameUsersConnectionInfo: {
|
||||||
|
user1ConnectionInfo: { userId: YOU, isOnline: true },
|
||||||
|
user2ConnectionInfo: { userId: OPPONENT, isOnline: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = "playing" | "animating" | "done";
|
||||||
|
|
||||||
|
// One rule taught on a prepared board (docs/specs/tutorial, R2). Everything runs in the browser on the TypeScript
|
||||||
|
// engine; nothing is sent to the server.
|
||||||
|
const LessonPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ context }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { ruleId } = useParams();
|
||||||
|
const rule = getRule(ruleId);
|
||||||
|
const lesson = rule?.lesson;
|
||||||
|
const [game, setGame] = useState<Game | undefined>(undefined);
|
||||||
|
const [boardViewModel, setBoardViewModel] = useState<BoardViewModel | undefined>(undefined);
|
||||||
|
const [phase, setPhase] = useState<Phase>("playing");
|
||||||
|
const [outcome, setOutcome] = useState<MoveOutcome | undefined>(undefined);
|
||||||
|
const [playedIndex, setPlayedIndex] = useState<number | undefined>(undefined);
|
||||||
|
const animator = useRef<PitAnimator | undefined>(undefined);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!lesson || !animator.current) return;
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
const lessonGame = createLessonGame(lesson.board);
|
||||||
|
setGame(lessonGame);
|
||||||
|
setOutcome(undefined);
|
||||||
|
setPhase("playing");
|
||||||
|
animator.current.setNewGame(lessonGame);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hideTutorialBanner(context.storage);
|
||||||
|
animator.current = new PitAnimator(context.themeManager, (model) => {
|
||||||
|
model.id = v4();
|
||||||
|
setBoardViewModel(model);
|
||||||
|
});
|
||||||
|
start();
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
animator.current?.dispose();
|
||||||
|
};
|
||||||
|
}, [ruleId]);
|
||||||
|
|
||||||
|
if (!rule || !lesson) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap"><Link to="/learn">{t("AllLessons")}</Link></main>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPitSelect = (index: number) => {
|
||||||
|
if (phase !== "playing" || !game || !(lesson.board[index] > 0) || index > 5) return;
|
||||||
|
const moveOutcome = getMoveOutcome(lesson.board, index);
|
||||||
|
const played = createLessonGame(lesson.board);
|
||||||
|
played.mancalaGame.moveByPlayerPit(YOU, index);
|
||||||
|
setGame(played);
|
||||||
|
setPlayedIndex(index);
|
||||||
|
setPhase("animating");
|
||||||
|
animator.current?.setUpdatedGame(played);
|
||||||
|
const steps = played.mancalaGame.history[0].gameSteps.length;
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
setOutcome(moveOutcome);
|
||||||
|
setPhase("done");
|
||||||
|
if (fulfilsTask(lesson.task, moveOutcome)) completeLesson(context.storage, rule.id);
|
||||||
|
}, (steps + 1) * animator.current!.animationInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fulfilled = outcome !== undefined && fulfilsTask(lesson.task, outcome);
|
||||||
|
const next = getNextLessonRule(rule.id);
|
||||||
|
const number = LESSON_RULES.findIndex((r) => r.id === rule.id) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<AppHeader context={context} />
|
||||||
|
<main className="wrap lesson">
|
||||||
|
<div className="lhead">
|
||||||
|
<Link to="/learn" className="crumb">{t("LessonNumber", { number, count: LESSON_RULES.length })}</Link>
|
||||||
|
<h1>{t(rule.titleKey)}</h1>
|
||||||
|
{rule.textKeys.map((key) => <p key={key}>{t(key)}</p>)}
|
||||||
|
</div>
|
||||||
|
<div className={"task panel" + (fulfilled ? " ok" : "")} aria-live="polite">
|
||||||
|
{phase !== "done" && <strong>{t(lesson.taskKey)}</strong>}
|
||||||
|
{phase === "done" && outcome && (
|
||||||
|
<>
|
||||||
|
{fulfilled && <strong>{t("WellDone")}</strong>}
|
||||||
|
<span>{explainMove(outcome, "you", t)}</span>
|
||||||
|
<div className="actions">
|
||||||
|
{fulfilled && next && (
|
||||||
|
<button className="btn pri" onClick={() => navigate(`/learn/${next.id}`)}>{t("NextLesson")}</button>
|
||||||
|
)}
|
||||||
|
{fulfilled && !next && (
|
||||||
|
<button className="btn pri" onClick={() => navigate("/learn")}>{t("AllLessons")}</button>
|
||||||
|
)}
|
||||||
|
<button className={"btn " + (fulfilled ? "ghost" : "pri")} onClick={start}>{t("TryAgain")}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{game && boardViewModel && (
|
||||||
|
<div className="lboard">
|
||||||
|
<BoardView
|
||||||
|
game={game}
|
||||||
|
boardViewModel={boardViewModel}
|
||||||
|
context={context}
|
||||||
|
revert={false}
|
||||||
|
topName={t("Opponent")}
|
||||||
|
bottomName={t("You")}
|
||||||
|
onPitSelect={(index) => onPitSelect(index)}
|
||||||
|
canPlay={phase === "playing"}
|
||||||
|
lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<style jsx>{`
|
||||||
|
.lesson {
|
||||||
|
max-width: 980px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.lhead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.lesson :global(.crumb) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task.ok {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lboard {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Theme } from "@mancala/core";
|
import { Theme } from "@mancala/core";
|
||||||
import AppHeader from "../components/headerbar/AppHeader";
|
import AppHeader from "../components/headerbar/AppHeader";
|
||||||
@ -14,8 +15,11 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<AppHeader context={context} />
|
<AppHeader context={context} />
|
||||||
<main className="wrap rules-page">
|
<main className="wrap rules-page">
|
||||||
|
<div className="rtitle">
|
||||||
<h1>{t("HowToPlay")}</h1>
|
<h1>{t("HowToPlay")}</h1>
|
||||||
<RulesView />
|
<Link className="btn pri" to="/learn">{t("Lessons")}</Link>
|
||||||
|
</div>
|
||||||
|
<RulesView showLessons />
|
||||||
</main>
|
</main>
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.rules-page {
|
.rules-page {
|
||||||
@ -24,6 +28,15 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
.rtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rules-page :global(.rtitle .btn) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests.
|
the capture kind in `describeMove`, the storage helpers, texts in English and Turkish; unit tests.
|
||||||
- [x] 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.
|
game page.
|
||||||
- [ ] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
- [x] 4. **Lessons**: `/learn` with progress, `LessonPage` with the local game, animation, praise, explanation and
|
||||||
reset, scripted opponent replies.
|
reset, scripted opponent replies.
|
||||||
- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
|
- [ ] 5. **Newcomers and practice game**: `TutorialBanner`, `markGamePlayed`, the practice game button.
|
||||||
- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
|
- [ ] 6. **Hints**: hint chips on `BoardView`, the explanation line and the "Hints" switch in bot games on `GamePage`.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user