From 9beb8ba7d45e9173981eab6bcb92976fbc913e1e Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:54:55 +0300 Subject: [PATCH] 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. --- apps/frontend/src/MancalaApp.tsx | 4 + apps/frontend/src/routes/LearnPage.tsx | 94 +++++++++++ apps/frontend/src/routes/LessonPage.tsx | 201 ++++++++++++++++++++++++ apps/frontend/src/routes/RulesPage.tsx | 17 +- docs/specs/tutorial/tasks.md | 2 +- 5 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/routes/LearnPage.tsx create mode 100644 apps/frontend/src/routes/LessonPage.tsx diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index a2faa37..670b298 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -15,6 +15,8 @@ import { Theme } from '@mancala/core'; import LobyPage from './routes/LobyPage'; import PrivacyPage from './routes/PrivacyPage'; import RulesPage from './routes/RulesPage'; +import LearnPage from './routes/LearnPage'; +import LessonPage from './routes/LessonPage'; import swal from 'sweetalert'; import Util from './util/Util'; import { server } from './const/config'; @@ -132,6 +134,8 @@ const MancalaApp: FunctionComponent = () => { }> } /> + } /> + } /> } diff --git a/apps/frontend/src/routes/LearnPage.tsx b/apps/frontend/src/routes/LearnPage.tsx new file mode 100644 index 0000000..ed64ab2 --- /dev/null +++ b/apps/frontend/src/routes/LearnPage.tsx @@ -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 ( + + +
+

{t("LearnMangala")}

+
    + {LESSON_RULES.map((rule, index) => { + const done = completed.includes(rule.id); + return ( +
  1. + + {done ? "✓" : index + 1} + {t(rule.titleKey)} + {done && {t("LessonDone")}} + +
  2. + ); + })} +
+ {t("HowToPlay")} +
+ +
+ ); +}; + +export default LearnPage; diff --git a/apps/frontend/src/routes/LessonPage.tsx b/apps/frontend/src/routes/LessonPage.tsx new file mode 100644 index 0000000..b7ae967 --- /dev/null +++ b/apps/frontend/src/routes/LessonPage.tsx @@ -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(undefined); + const [boardViewModel, setBoardViewModel] = useState(undefined); + const [phase, setPhase] = useState("playing"); + const [outcome, setOutcome] = useState(undefined); + const [playedIndex, setPlayedIndex] = useState(undefined); + const animator = useRef(undefined); + const timer = useRef | 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 ( + + +
{t("AllLessons")}
+
+ ); + } + + 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 ( + + +
+
+ {t("LessonNumber", { number, count: LESSON_RULES.length })} +

{t(rule.titleKey)}

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

{t(key)}

)} +
+
+ {phase !== "done" && {t(lesson.taskKey)}} + {phase === "done" && outcome && ( + <> + {fulfilled && {t("WellDone")}} + {explainMove(outcome, "you", t)} +
+ {fulfilled && next && ( + + )} + {fulfilled && !next && ( + + )} + +
+ + )} +
+ {game && boardViewModel && ( +
+ onPitSelect(index)} + canPlay={phase === "playing"} + lastMoveIndex={phase === "playing" ? undefined : outcome?.pitIndex ?? playedIndex} /> +
+ )} +
+ +
+ ); +}; + +export default LessonPage; diff --git a/apps/frontend/src/routes/RulesPage.tsx b/apps/frontend/src/routes/RulesPage.tsx index 0256cbc..752dbde 100644 --- a/apps/frontend/src/routes/RulesPage.tsx +++ b/apps/frontend/src/routes/RulesPage.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { FunctionComponent } from "react"; +import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Theme } from "@mancala/core"; import AppHeader from "../components/headerbar/AppHeader"; @@ -14,8 +15,11 @@ const RulesPage: FunctionComponent<{ context: Context, theme: Theme }> = ({ cont
-

{t("HowToPlay")}

- +
+

{t("HowToPlay")}

+ {t("Lessons")} +
+