Écrans des leçons, carte de l'accueil et adresses #/lecons
Liste des leçons, écrans d'une leçon sur le moteur d'écrans du tutoriel, carte « prochaine leçon » de l'accueil, ligne du menu. Tests du contenu sur le vrai moteur, scénario Gherkin, test navigateur. Amendement de l'ADR 0013 ; 1g faite, plan clos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e9ef965438
commit
0493858c1a
+239
-12
@@ -44,6 +44,15 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import {
|
||||
finishedLessons,
|
||||
LESSON_END,
|
||||
type LessonProgress,
|
||||
lessonRecord,
|
||||
NO_PROGRESS,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../domain/learning/lessons.js";
|
||||
import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
@@ -82,6 +91,10 @@ import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { LESSON_COUNT, type Lesson, lessonSource, outlineOf, parseLesson } from "./lesson-text.js";
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import { lessonCards, nextLessonCard } from "./lessons-model.js";
|
||||
import { createLessonsView } from "./lessons-view.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
@@ -106,8 +119,21 @@ import {
|
||||
} from "./settings.js";
|
||||
import { createSummaryView } from "./summary-view.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import {
|
||||
lessonPlaceOf,
|
||||
lessonPlaceRoute,
|
||||
seenAfter,
|
||||
placeOf as tutorialPlaceOf,
|
||||
placeRoute as tutorialPlaceRoute,
|
||||
} from "./tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
|
||||
import {
|
||||
type Course,
|
||||
cachedText,
|
||||
createTutorialView,
|
||||
TUTORIAL_SOURCE,
|
||||
type TutorialAccess,
|
||||
} from "./tutorial-view.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
@@ -147,8 +173,15 @@ export interface AppOptions {
|
||||
/** Les réglages, en JSON (null : jamais enregistrés). */
|
||||
readonly loadSettings: () => string | null;
|
||||
readonly saveSettings: (json: string) => void;
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur ;
|
||||
* les leçons s'en servent aussi. */
|
||||
readonly tutorial: TutorialAccess;
|
||||
/** La progression dans les leçons (étape 1g), dans le stockage des
|
||||
* parties. */
|
||||
readonly lessons: {
|
||||
readonly load: () => Promise<LessonProgress>;
|
||||
readonly save: (progress: LessonProgress) => void;
|
||||
};
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
@@ -308,8 +341,11 @@ export function startApp(options: AppOptions): void {
|
||||
resumeOld: byId<HTMLButtonElement>("resume-old"),
|
||||
home: byId("home"),
|
||||
tutorial: byId("tutorial"),
|
||||
lessons: byId("lessons"),
|
||||
lesson: byId("lesson"),
|
||||
menuHome: byId<HTMLAnchorElement>("menu-home"),
|
||||
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
|
||||
menuLessons: byId<HTMLAnchorElement>("menu-lessons"),
|
||||
menuInstall: byId<HTMLButtonElement>("menu-install"),
|
||||
menuUpdate: byId<HTMLButtonElement>("menu-update"),
|
||||
installVeil: byId("install-veil"),
|
||||
@@ -407,6 +443,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.resumeOld.textContent = TEXTS.resumeOld;
|
||||
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
|
||||
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
|
||||
label(ui.menuLessons, LESSON_TEXTS.menu, "lessons");
|
||||
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
|
||||
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
|
||||
ui.installTitle.textContent = HOME_TEXTS.installTitle;
|
||||
@@ -1722,6 +1759,10 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave((r) => r.screen === "analysis");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
} else if (route.screen === "lesson" || route.screen === "lessonEnd") {
|
||||
navigation.leave((r) => r.screen === "lesson" || r.screen === "lessonEnd");
|
||||
} else if (route.screen === "lessons") {
|
||||
navigation.leave((r) => r.screen === "lessons");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1827,6 +1868,7 @@ export function startApp(options: AppOptions): void {
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
replacingLink(ui.menuLessons, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
@@ -1878,9 +1920,12 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const lessonList = below.screen === "lessons";
|
||||
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
|
||||
const choosing = below.screen === "newGame";
|
||||
const analysing = below.screen === "analysis";
|
||||
const page = help || position || home || tutorial || choosing || analysing;
|
||||
const page =
|
||||
help || position || home || tutorial || lessonList || lesson || choosing || analysing;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1888,13 +1933,18 @@ export function startApp(options: AppOptions): void {
|
||||
ui.position.hidden = !position;
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
ui.lessons.hidden = !lessonList;
|
||||
ui.lesson.hidden = !lesson;
|
||||
ui.analysis.hidden = !analysing;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, les leçons, le premier lancement et l'écran « Nouvelle
|
||||
// partie » ont leur propre en-tête.
|
||||
ui.root.classList.toggle(
|
||||
"bare",
|
||||
tutorial || lesson || choosing || (home && isFirstLaunch(settings)),
|
||||
);
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
@@ -1910,6 +1960,21 @@ export function startApp(options: AppOptions): void {
|
||||
} else {
|
||||
tutorialView.hide();
|
||||
}
|
||||
if (lesson) {
|
||||
// Une leçon qui n'existe pas : la liste des leçons.
|
||||
if (below.lesson > LESSON_COUNT) {
|
||||
navigation.replace(routeHash({ screen: "lessons" }));
|
||||
return;
|
||||
}
|
||||
if (route.screen !== "install") {
|
||||
void lessonView.show(below);
|
||||
}
|
||||
} else {
|
||||
lessonView.hide();
|
||||
}
|
||||
if (lessonList) {
|
||||
renderLessons();
|
||||
}
|
||||
if (home) {
|
||||
homeProgress = null;
|
||||
}
|
||||
@@ -2064,8 +2129,26 @@ export function startApp(options: AppOptions): void {
|
||||
sides: sides ?? options.cachedBoard()?.sides ?? null,
|
||||
tutorialSeen: settings.tutorialSeen,
|
||||
progress: homeProgress,
|
||||
lessons:
|
||||
lessonTexts === null || lessonProgress === null
|
||||
? null
|
||||
: {
|
||||
next: nextLessonCard(lessonTexts, lessonProgress),
|
||||
finished: finishedLessons(lessonProgress, lessonTexts.map(outlineOf)),
|
||||
total: lessonTexts.length,
|
||||
},
|
||||
busy: busy || game === null,
|
||||
});
|
||||
if (lessonTexts === null || lessonProgress === null) {
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
if (homeProgress === null && !progressLoading) {
|
||||
progressLoading = true;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -2088,17 +2171,158 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial);
|
||||
const tutorialCourse: Course = {
|
||||
id: "tutorial",
|
||||
load: () => loadTutorial(),
|
||||
placeOf: tutorialPlaceOf,
|
||||
routeOf: (_, place) => tutorialPlaceRoute(place),
|
||||
seen: () => settings.tutorialSeen,
|
||||
visit: (place) => {
|
||||
visit: (_, place) => {
|
||||
const seen = seenAfter(settings.tutorialSeen, place);
|
||||
if (seen !== settings.tutorialSeen || !settings.welcomed) {
|
||||
saveSettings({ tutorialSeen: seen, ...welcomed() });
|
||||
}
|
||||
},
|
||||
solved: () => undefined,
|
||||
quitHref: () => "#/",
|
||||
firstBackHref: () => "#/accueil",
|
||||
endHref: () => "#/",
|
||||
texts: {
|
||||
progress: HOME_TEXTS.tutorialProgress,
|
||||
stepLink: HOME_TEXTS.tutorialStepLink,
|
||||
quit: HOME_TEXTS.tutorialSkip,
|
||||
quitLabel: HOME_TEXTS.tutorialSkipLabel,
|
||||
overline: (_, n, total, practice) => HOME_TEXTS.tutorialOverline(n, total, practice),
|
||||
endOverline: () => HOME_TEXTS.tutorialEndOverline,
|
||||
endTitle: () => HOME_TEXTS.tutorialEndTitle,
|
||||
loading: HOME_TEXTS.tutorialLoading,
|
||||
failed: HOME_TEXTS.tutorialFailed,
|
||||
},
|
||||
};
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: tutorialCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Leçons
|
||||
|
||||
// Les textes des leçons (docs/lecons/, copiés dans le site) et la
|
||||
// progression enregistrée, lus une fois.
|
||||
const lessonLoaders = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
cachedText(lessonSource(i + 1), (text) => parseLesson(i + 1, text)),
|
||||
);
|
||||
let lessonTexts: Lesson[] | null = null;
|
||||
let lessonProgress: LessonProgress | null = null;
|
||||
const lessonProgressLoading = options.lessons
|
||||
.load()
|
||||
.catch(() => NO_PROGRESS)
|
||||
.then((progress) => {
|
||||
lessonProgress = progress;
|
||||
return progress;
|
||||
});
|
||||
/** Les leçons et la progression, lues (rejetée si un texte manque). */
|
||||
async function lessonsReady(): Promise<{ texts: Lesson[]; progress: LessonProgress }> {
|
||||
const [texts, progress] = await Promise.all([
|
||||
Promise.all(lessonLoaders.map((load) => load())),
|
||||
lessonProgressLoading,
|
||||
]);
|
||||
lessonTexts = texts;
|
||||
return { texts, progress };
|
||||
}
|
||||
const progressNow = () => lessonProgress ?? NO_PROGRESS;
|
||||
function updateLessons(progress: LessonProgress): void {
|
||||
lessonProgress = progress;
|
||||
options.lessons.save(progress);
|
||||
}
|
||||
const lessonOf = (route: Route) =>
|
||||
route.screen === "lesson" || route.screen === "lessonEnd" ? route.lesson : 1;
|
||||
// Le texte que l'écran d'une leçon reçoit est une leçon (Lesson).
|
||||
const lessonNumberOf = (text: Tutorial) => (text as Partial<Lesson>).number ?? 1;
|
||||
const lessonTitleOf = (text: Tutorial) => (text as Partial<Lesson>).title ?? "";
|
||||
|
||||
const lessonsView = createLessonsView(ui.lessons);
|
||||
function renderLessons(): void {
|
||||
if (lessonTexts !== null && lessonProgress !== null) {
|
||||
lessonsView.render(lessonCards(lessonTexts, lessonProgress));
|
||||
return;
|
||||
}
|
||||
lessonsView.render(null);
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
renderLessons();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
lessonsView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const lessonCourse: Course = {
|
||||
id: "lesson",
|
||||
load: async (route) => {
|
||||
await lessonProgressLoading;
|
||||
const load = lessonLoaders[lessonOf(route) - 1];
|
||||
if (load === undefined) {
|
||||
throw new Error(`leçon ${lessonOf(route)} absente`);
|
||||
}
|
||||
return load();
|
||||
},
|
||||
placeOf: lessonPlaceOf,
|
||||
routeOf: (route, place) => lessonPlaceRoute(lessonOf(route), place),
|
||||
seen: (route) => lessonRecord(progressNow(), lessonOf(route)).reached,
|
||||
visit: (route, place) => {
|
||||
const number = lessonOf(route);
|
||||
const at = place === "end" ? LESSON_END : place.step;
|
||||
const record = lessonRecord(progressNow(), number);
|
||||
if (record.last !== at || record.reached < at) {
|
||||
updateLessons(withVisit(progressNow(), number, at));
|
||||
}
|
||||
if (!settings.welcomed) {
|
||||
saveSettings(welcomed());
|
||||
}
|
||||
},
|
||||
solved: (route, place) => {
|
||||
if (place !== "end" && place.practice) {
|
||||
updateLessons(withSolved(progressNow(), lessonOf(route), place.step));
|
||||
}
|
||||
},
|
||||
quitHref: () => routeHash({ screen: "lessons" }),
|
||||
firstBackHref: () => routeHash({ screen: "lessons" }),
|
||||
endHref: (route) => {
|
||||
const number = lessonOf(route);
|
||||
return number < LESSON_COUNT
|
||||
? routeHash({ screen: "lesson", lesson: number + 1, step: 1, practice: false })
|
||||
: routeHash({ screen: "lessons" });
|
||||
},
|
||||
texts: {
|
||||
progress: LESSON_TEXTS.progress,
|
||||
stepLink: LESSON_TEXTS.stepLink,
|
||||
quit: LESSON_TEXTS.quit,
|
||||
quitLabel: LESSON_TEXTS.quitLabel,
|
||||
overline: (text, n, total, practice) =>
|
||||
LESSON_TEXTS.overline(lessonNumberOf(text), n, total, practice),
|
||||
endOverline: (text) => LESSON_TEXTS.endOverline(lessonNumberOf(text)),
|
||||
endTitle: (text) => LESSON_TEXTS.endTitle(lessonTitleOf(text)),
|
||||
loading: LESSON_TEXTS.loading,
|
||||
failed: LESSON_TEXTS.failed,
|
||||
},
|
||||
};
|
||||
const lessonView = createTutorialView({
|
||||
section: ui.lesson,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: lessonCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
@@ -2369,6 +2593,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (!ui.tutorial.hidden) {
|
||||
tutorialView.relayout();
|
||||
}
|
||||
if (!ui.lesson.hidden) {
|
||||
lessonView.relayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
|
||||
@@ -35,6 +35,7 @@ export const FEATURES = [
|
||||
"docs/scenarios/revoir-ma-partie.feature",
|
||||
"docs/scenarios/demander-a-une-ia.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/suivre-une-lecon.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
|
||||
@@ -119,7 +119,7 @@ export const HOME_TEXTS = {
|
||||
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
|
||||
aboutParagraphs: [
|
||||
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"Les textes du tutoriel, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
|
||||
+64
-7
@@ -1,13 +1,13 @@
|
||||
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
|
||||
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
|
||||
// installer, les règles et la progression résumée
|
||||
// installer, la prochaine leçon (1g), les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
// La progression détaillée (1h) n'existe pas encore : l'accueil n'affiche
|
||||
// que ce qui existe.
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
@@ -16,6 +16,9 @@ import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import type { LessonCardModel } from "./lessons-model.js";
|
||||
import { fillLessonCard, lessonAction } from "./lessons-view.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
@@ -38,6 +41,13 @@ export interface HomeState {
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Les leçons : la prochaine (null : toutes terminées), et combien sont
|
||||
* terminées sur combien (null : pas encore lues). */
|
||||
readonly lessons: {
|
||||
readonly next: LessonCardModel | null;
|
||||
readonly finished: number;
|
||||
readonly total: number;
|
||||
} | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
@@ -186,6 +196,23 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
later.title = HOME_TEXTS.installLater;
|
||||
later.addEventListener("click", () => options.dismissInstall());
|
||||
|
||||
// La prochaine leçon : une seule carte, « Continuer » en secondaire, et
|
||||
// le lien vers toutes les leçons.
|
||||
const lesson = element(main, "article", { id: "home-lesson", className: "card home-lesson" });
|
||||
element(lesson, "span", { className: "overline", text: LESSON_TEXTS.homeOverline });
|
||||
const lessonInfo = element(lesson, "div", { className: "home-lesson-info" });
|
||||
const lessonActions = element(lesson, "div", { className: "home-lesson-actions" });
|
||||
const lessonGo = element(lessonActions, "a", {
|
||||
id: "home-lesson-go",
|
||||
className: "button secondary",
|
||||
});
|
||||
const lessonAll = element(lessonActions, "a", {
|
||||
id: "home-lessons",
|
||||
className: "home-lessons-link",
|
||||
text: LESSON_TEXTS.allLessons,
|
||||
});
|
||||
lessonAll.href = "#/lecons";
|
||||
|
||||
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
|
||||
const rulesText = element(rules, "span", { className: "home-rules-text" });
|
||||
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
|
||||
@@ -233,10 +260,40 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
// La carte de la leçon, une fois les leçons lues.
|
||||
lesson.hidden = state.lessons === null;
|
||||
if (state.lessons !== null) {
|
||||
const card = state.lessons.next;
|
||||
if (card === null) {
|
||||
lessonInfo.replaceChildren();
|
||||
element(lessonInfo, "strong", {
|
||||
className: "lesson-card-title",
|
||||
text: LESSON_TEXTS.homeAllDone,
|
||||
});
|
||||
element(lessonInfo, "span", {
|
||||
className: "muted",
|
||||
text: LESSON_TEXTS.homeAllDoneDetail,
|
||||
});
|
||||
lessonGo.hidden = true;
|
||||
} else {
|
||||
fillLessonCard(lessonInfo, card);
|
||||
lessonGo.hidden = false;
|
||||
lessonGo.textContent = lessonAction(card);
|
||||
lessonGo.href = card.href;
|
||||
lessonGo.setAttribute(
|
||||
"aria-label",
|
||||
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (state.lessons !== null) {
|
||||
parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total));
|
||||
}
|
||||
if (state.progress !== null && state.progress.played > 0) {
|
||||
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
|
||||
}
|
||||
progress.textContent = parts.join(" · ");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ICON_PATHS = {
|
||||
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
|
||||
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
|
||||
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
||||
lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Les textes de la liste des leçons, de leurs écrans et de la carte
|
||||
// « prochaine leçon » de l'accueil (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). Le texte des
|
||||
// leçons elles-mêmes n'est pas ici : il vit dans docs/lecons/.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { LessonState } from "../domain/learning/lessons.js";
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n > 1 ? many : one}`;
|
||||
}
|
||||
|
||||
export const LESSON_TEXTS = {
|
||||
// Liste
|
||||
menu: "Les leçons",
|
||||
listTitle: "Les leçons",
|
||||
listLead:
|
||||
"Cinq leçons courtes, plateau à l'appui, avec des exercices où vous jouez le coup du maître. L'ordre est conseillé, jamais imposé.",
|
||||
rulesReminder: "Les règles du jeu",
|
||||
rulesReview: "revoir",
|
||||
rulesReviewLabel: "Revoir les règles du jeu (le tutoriel)",
|
||||
number: (n: number) => `Leçon ${n}`,
|
||||
listLoading: "Chargement des leçons…",
|
||||
listFailed: "Les leçons n'ont pas pu se charger.",
|
||||
state: (state: LessonState) =>
|
||||
state.kind === "done"
|
||||
? "Terminée"
|
||||
: state.kind === "toStart"
|
||||
? `À commencer · ${plural(state.total, "exercice", "exercices")}`
|
||||
: `${plural(state.solved, "exercice", "exercices")} sur ${state.total}`,
|
||||
continue: "Continuer",
|
||||
start: "Commencer",
|
||||
review: "Revoir",
|
||||
openLabel: (n: number, title: string) => `Leçon ${n} : ${title}`,
|
||||
|
||||
// Écrans d'une leçon
|
||||
progress: "Étapes de la leçon",
|
||||
stepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
quit: "Quitter",
|
||||
quitLabel: "Quitter la leçon",
|
||||
overline: (lesson: number, n: number, total: number, practice: boolean) =>
|
||||
`Leçon ${lesson} · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
endOverline: (lesson: number) => `Leçon ${lesson} · fin`,
|
||||
endTitle: (title: string) => title,
|
||||
loading: "Chargement de la leçon…",
|
||||
failed: "La leçon n'a pas pu se charger.",
|
||||
|
||||
// Accueil
|
||||
homeOverline: "Prochaine leçon",
|
||||
homeAllDone: "Toutes les leçons sont terminées",
|
||||
homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.",
|
||||
allLessons: "Toutes les leçons",
|
||||
homeSummary: (finished: number, total: number) =>
|
||||
`${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`,
|
||||
} as const;
|
||||
@@ -0,0 +1,66 @@
|
||||
// Ce que montrent la liste des leçons et la carte « prochaine leçon » de
|
||||
// l'accueil : fonctions pures, testées sous Node
|
||||
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
type LessonProgress,
|
||||
type LessonState,
|
||||
lessonRecord,
|
||||
lessonState,
|
||||
nextLesson,
|
||||
resumeStep,
|
||||
} from "../domain/learning/lessons.js";
|
||||
import { type Lesson, outlineOf } from "./lesson-text.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Une leçon dans la liste, ou sur l'accueil. */
|
||||
export interface LessonCardModel {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly state: LessonState;
|
||||
/** Les exercices, dans l'ordre : réussi ou non (barre de progression). */
|
||||
readonly exercises: readonly boolean[];
|
||||
/** Où mène la carte : là où reprendre la leçon. */
|
||||
readonly href: string;
|
||||
/** La leçon proposée ensuite : carte bordée de brique, seule action
|
||||
* principale de l'écran. */
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** L'adresse où reprendre la leçon. */
|
||||
export function resumeHref(progress: LessonProgress, lesson: Lesson): string {
|
||||
const step = resumeStep(progress, outlineOf(lesson));
|
||||
return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false });
|
||||
}
|
||||
|
||||
/** Les cartes de la liste des leçons, dans l'ordre. */
|
||||
export function lessonCards(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel[] {
|
||||
const outlines = lessons.map(outlineOf);
|
||||
const next = nextLesson(progress, outlines)?.number ?? null;
|
||||
return lessons.map((lesson, i) => {
|
||||
const outline = outlines[i] ?? outlineOf(lesson);
|
||||
const solved = lessonRecord(progress, lesson.number).solved;
|
||||
return {
|
||||
number: lesson.number,
|
||||
title: lesson.title,
|
||||
state: lessonState(progress, outline),
|
||||
exercises: outline.exercises.map((step) => solved.includes(step)),
|
||||
href: resumeHref(progress, lesson),
|
||||
current: lesson.number === next,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */
|
||||
export function nextLessonCard(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel | null {
|
||||
return lessonCards(lessons, progress).find((card) => card.current) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// La liste des leçons (#/lecons) et la carte d'une leçon, reprise par la
|
||||
// carte « prochaine leçon » de l'accueil
|
||||
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import type { LessonCardModel } from "./lessons-model.js";
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** La pastille ronde au numéro de la leçon : verte une fois terminée,
|
||||
* brique pour la leçon proposée, au contour sinon. */
|
||||
function badge(parent: HTMLElement, card: LessonCardModel): void {
|
||||
const kind = card.state.kind === "done" ? "done" : card.current ? "current" : "todo";
|
||||
const span = element(parent, "span", {
|
||||
className: `lesson-badge ${kind}`,
|
||||
text: String(card.number),
|
||||
});
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
/** La barre de progression : un segment par exercice, en brique une fois
|
||||
* réussi. */
|
||||
function progressBar(parent: HTMLElement, card: LessonCardModel): void {
|
||||
const bar = element(parent, "span", { className: "lesson-progress" });
|
||||
bar.setAttribute("aria-hidden", "true");
|
||||
for (const done of card.exercises) {
|
||||
element(bar, "span", { className: done ? "segment done" : "segment" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Le contenu d'une carte de leçon : pastille, numéro, titre, état et
|
||||
* progression. */
|
||||
export function fillLessonCard(target: HTMLElement, card: LessonCardModel): void {
|
||||
target.replaceChildren();
|
||||
const top = element(target, "span", { className: "lesson-card-top" });
|
||||
badge(top, card);
|
||||
const body = element(top, "span", { className: "lesson-card-body" });
|
||||
element(body, "span", { className: "overline", text: LESSON_TEXTS.number(card.number) });
|
||||
element(body, "strong", { className: "lesson-card-title", text: card.title });
|
||||
element(body, "span", {
|
||||
className: `lesson-card-state ${card.state.kind}`,
|
||||
text: LESSON_TEXTS.state(card.state),
|
||||
});
|
||||
if (card.state.kind !== "toStart" && card.state.total > 0) {
|
||||
progressBar(body, card);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le libellé de l'action d'une leçon. */
|
||||
export function lessonAction(card: LessonCardModel): string {
|
||||
return card.state.kind === "toStart"
|
||||
? LESSON_TEXTS.start
|
||||
: card.state.kind === "done"
|
||||
? LESSON_TEXTS.review
|
||||
: LESSON_TEXTS.continue;
|
||||
}
|
||||
|
||||
export interface LessonsView {
|
||||
/** Les cartes (null : les leçons se chargent ; erreur : échec). */
|
||||
render(cards: readonly LessonCardModel[] | null, failed?: boolean): void;
|
||||
}
|
||||
|
||||
/** Crée la liste des leçons dans section (vide dans index.html). */
|
||||
export function createLessonsView(section: HTMLElement): LessonsView {
|
||||
section.replaceChildren();
|
||||
const head = element(section, "div", { className: "lessons-intro" });
|
||||
element(head, "h2", { text: LESSON_TEXTS.listTitle });
|
||||
element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead });
|
||||
const rules = element(section, "p", { className: "lessons-rules caption" });
|
||||
rules.append(`${LESSON_TEXTS.rulesReminder} · `);
|
||||
const review = element(rules, "a", { id: "lessons-rules", text: LESSON_TEXTS.rulesReview });
|
||||
review.href = "#/tutoriel/1";
|
||||
review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel);
|
||||
const status = element(section, "p", { id: "lessons-status", className: "muted" });
|
||||
status.setAttribute("role", "status");
|
||||
const list = element(section, "ol", { id: "lesson-list", className: "lesson-list" });
|
||||
|
||||
return {
|
||||
render(cards, failed = false) {
|
||||
status.textContent =
|
||||
cards !== null ? "" : failed ? LESSON_TEXTS.listFailed : LESSON_TEXTS.listLoading;
|
||||
list.replaceChildren();
|
||||
for (const card of cards ?? []) {
|
||||
const item = element(list, "li");
|
||||
// La leçon proposée porte la seule action principale ; les
|
||||
// autres cartes sont des liens, toutes restent ouvertes.
|
||||
if (card.current) {
|
||||
const article = element(item, "article", {
|
||||
id: `lesson-card-${card.number}`,
|
||||
className: "card lesson-card current",
|
||||
});
|
||||
const info = element(article, "div");
|
||||
fillLessonCard(info, card);
|
||||
const action = element(article, "a", {
|
||||
className: "button lesson-card-action",
|
||||
text: lessonAction(card),
|
||||
});
|
||||
action.href = card.href;
|
||||
action.setAttribute(
|
||||
"aria-label",
|
||||
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
|
||||
);
|
||||
} else {
|
||||
const link = element(item, "a", {
|
||||
id: `lesson-card-${card.number}`,
|
||||
className: "card lesson-card",
|
||||
});
|
||||
link.href = card.href;
|
||||
fillLessonCard(link, card);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,19 @@ export type Route =
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/lecons : la liste des leçons (étape 1g). */
|
||||
| { readonly screen: "lessons" }
|
||||
/** #/lecons/<n> (son premier écran), une étape de la leçon n :
|
||||
* #/lecons/<n>/<étape>, et son exercice « à vous » :
|
||||
* #/lecons/<n>/<étape>/a-vous. */
|
||||
| {
|
||||
readonly screen: "lesson";
|
||||
readonly lesson: number;
|
||||
readonly step: number | null;
|
||||
readonly practice: boolean;
|
||||
}
|
||||
/** #/lecons/<n>/fin : la fin de la leçon n. */
|
||||
| { readonly screen: "lessonEnd"; readonly lesson: number }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
@@ -91,6 +104,9 @@ export function parseRoute(hash: string): Route {
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "lecons") {
|
||||
return lessonRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "parties" && rest.length > 1) {
|
||||
return gameReviewRoute(rest) ?? unknown;
|
||||
}
|
||||
@@ -194,6 +210,29 @@ function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/** #/lecons, #/lecons/<n>, #/lecons/<n>/<étape>, #/lecons/<n>/<étape>/a-vous,
|
||||
* #/lecons/<n>/fin. */
|
||||
function lessonRoute(rest: readonly string[]): Route | null {
|
||||
const [number, name, part, ...more] = rest;
|
||||
if (number === undefined) {
|
||||
return { screen: "lessons" };
|
||||
}
|
||||
if (!/^[1-9]$/.test(number) || more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const lesson = Number(number);
|
||||
if (name === undefined) {
|
||||
return { screen: "lesson", lesson, step: null, practice: false };
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "lessonEnd", lesson } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
|
||||
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
|
||||
@@ -246,6 +285,14 @@ function screenHash(route: Route): string {
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/fin";
|
||||
case "lessons":
|
||||
return "#/lecons";
|
||||
case "lesson":
|
||||
return route.step === null
|
||||
? `#/lecons/${route.lesson}`
|
||||
: `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "lessonEnd":
|
||||
return `#/lecons/${route.lesson}/fin`;
|
||||
case "install":
|
||||
return "#/installer";
|
||||
case "games":
|
||||
|
||||
@@ -19,14 +19,19 @@ export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
if (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
return route.screen === "tutorial"
|
||||
? existingPlace(tutorial, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première
|
||||
* étape (étape absente), ou l'explication (« à vous » absent). */
|
||||
function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace {
|
||||
const step = at === null ? undefined : tutorial.steps[at - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
return { step: step.number, practice: practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
@@ -36,6 +41,24 @@ export function placeRoute(place: TutorialPlace): Route {
|
||||
: { screen: "tutorial", step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran d'une leçon que désigne une adresse (#/lecons/<n>/...), ramené à
|
||||
* un écran qui existe, comme placeOf pour le tutoriel. */
|
||||
export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "lessonEnd") {
|
||||
return "end";
|
||||
}
|
||||
return route.screen === "lesson"
|
||||
? existingPlace(lesson, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran de la leçon number. */
|
||||
export function lessonPlaceRoute(number: number, place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "lessonEnd", lesson: number }
|
||||
: { screen: "lesson", lesson: number, step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran suivant (la fin après la dernière étape). */
|
||||
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
|
||||
if (place === "end") {
|
||||
|
||||
+135
-75
@@ -2,11 +2,14 @@
|
||||
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
|
||||
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
|
||||
// Les leçons (#/lecons/<n>/..., étape 1g) reprennent le même écran, avec
|
||||
// leur texte et leurs adresses (Course).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies du
|
||||
// site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
@@ -39,20 +42,8 @@ import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geo
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
|
||||
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
@@ -61,7 +52,32 @@ const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
/** Le texte du tutoriel, copié dans le site (make site). */
|
||||
export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/**
|
||||
* Un texte du site, lu une fois puis gardé : la fonction renvoyée le
|
||||
* télécharge à son premier appel et le relit avec parse. Un échec n'est
|
||||
* pas gardé : l'appel suivant réessaie.
|
||||
*/
|
||||
export function cachedText<T>(source: string, parse: (text: string) => T): () => Promise<T> {
|
||||
let loaded: Promise<T> | null = null;
|
||||
return () => {
|
||||
loaded ??= fetch(source)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${source} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parse)
|
||||
.catch((error: unknown) => {
|
||||
loaded = null;
|
||||
throw error;
|
||||
});
|
||||
return loaded;
|
||||
};
|
||||
}
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
@@ -87,14 +103,53 @@ export interface TutorialAccess {
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape
|
||||
* 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les
|
||||
* distingue : leur texte, leurs adresses, leurs libellés, et ce que retient
|
||||
* la progression.
|
||||
*/
|
||||
export interface Course {
|
||||
/** Préfixe des identifiants des éléments de l'écran (« tutorial »,
|
||||
* « lesson ») : deux parcours peuvent coexister dans la page. */
|
||||
readonly id: string;
|
||||
/** Le texte du parcours que désigne l'adresse. */
|
||||
load(route: Route): Promise<Tutorial>;
|
||||
/** L'écran que désigne l'adresse, ramené à un écran qui existe. */
|
||||
placeOf(text: Tutorial, route: Route): TutorialPlace;
|
||||
/** L'adresse d'un écran du même parcours que route. */
|
||||
routeOf(route: Route, place: TutorialPlace): Route;
|
||||
/** La dernière étape déjà vue : la barre de progression n'en mène pas
|
||||
* plus loin. */
|
||||
seen(route: Route): number;
|
||||
/** L'arrivée sur un écran. */
|
||||
visit(route: Route, place: TutorialPlace): void;
|
||||
/** Un écran « à vous » réussi. */
|
||||
solved(route: Route, place: TutorialPlace): void;
|
||||
/** « Quitter » : où il mène. */
|
||||
quitHref(route: Route): string;
|
||||
/** « Retour » sur le premier écran. */
|
||||
firstBackHref(route: Route): string;
|
||||
/** L'action de l'écran de fin. */
|
||||
endHref(route: Route): string;
|
||||
readonly texts: {
|
||||
readonly progress: string;
|
||||
readonly stepLink: (n: number, title: string) => string;
|
||||
readonly quit: string;
|
||||
readonly quitLabel: string;
|
||||
readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string;
|
||||
readonly endOverline: (text: Tutorial) => string;
|
||||
readonly endTitle: (text: Tutorial) => string;
|
||||
readonly loading: string;
|
||||
readonly failed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TutorialViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly access: TutorialAccess;
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
readonly course: Course;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
@@ -221,24 +276,28 @@ function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): voi
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
const { section, access, course } = options;
|
||||
const idOf = (name: string) => `${course.id}-${name}`;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", course.texts.progress);
|
||||
const skip = element(head, "a", { id: idOf("skip"), className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
skip.textContent = course.texts.quit;
|
||||
skip.setAttribute("aria-label", course.texts.quitLabel);
|
||||
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
|
||||
const title = element(section, "h2", { id: idOf("title"), className: "tutorial-title" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-board";
|
||||
svg.id = idOf("board");
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
const replay = element(boardBox, "button", {
|
||||
id: idOf("replay"),
|
||||
className: "icon-button tutorial-replay",
|
||||
});
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
@@ -246,39 +305,43 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
id: idOf("feedback"),
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
id: idOf("drop"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-take",
|
||||
id: idOf("take"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: "tutorial-back",
|
||||
id: idOf("back"),
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
id: idOf("next"),
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se
|
||||
// charge.
|
||||
let lastText: Tutorial | null = null;
|
||||
// L'adresse affichée.
|
||||
let shownRoute: Route | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
@@ -302,22 +365,6 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
let selected: Place | null = null;
|
||||
let solved = false;
|
||||
|
||||
const load = () => {
|
||||
tutorial ??= fetch(SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${SOURCE} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parseTutorial)
|
||||
.catch((error: unknown) => {
|
||||
tutorial = null;
|
||||
throw error;
|
||||
});
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
@@ -456,6 +503,9 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
if (shownRoute !== null && place !== null) {
|
||||
course.solved(shownRoute, place);
|
||||
}
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
@@ -550,17 +600,21 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
|
||||
const steps = loaded.steps;
|
||||
const total = steps.length;
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
for (const segment of progress(total, at, course.seen(route))) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const label = course.texts.stepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.href = routeHash(
|
||||
course.routeOf(route, { step: segment.step, practice: false }),
|
||||
);
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
@@ -573,8 +627,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
? course.texts.endOverline(loaded)
|
||||
: course.texts.overline(loaded, at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -603,42 +657,48 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
if (lastText === null) {
|
||||
title.textContent = course.texts.loading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
loaded = await course.load(route);
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
title.textContent = course.texts.failed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
lastText = loaded;
|
||||
const at = course.placeOf(loaded, route);
|
||||
const wanted = routeHash(course.routeOf(route, at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
shownRoute = route;
|
||||
course.visit(route, at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
renderProgress(route, at, loaded);
|
||||
skip.href = course.quitHref(route);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
back.href =
|
||||
before === null
|
||||
? course.firstBackHref(route)
|
||||
: routeHash(course.routeOf(route, before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
overline.textContent = course.texts.endOverline(loaded);
|
||||
title.textContent = course.texts.endTitle(loaded);
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.href = course.endHref(route);
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
@@ -648,7 +708,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
@@ -669,7 +729,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.href = routeHash(course.routeOf(route, after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
|
||||
Reference in New Issue
Block a user