Accueil, tutoriel, aide en questions, installation et mise à jour de l'application

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-09 15:17:49 +01:00
co-authored by Claude Opus 5.5
parent 3f5b00133d
commit 441bca1bd7
37 changed files with 3518 additions and 130 deletions
+5 -7
View File
@@ -51,9 +51,10 @@ export async function tutorialBoard(
board.result === null
? null
: {
winner: (board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
winner:
(board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
@@ -126,10 +127,7 @@ export async function illustratedMove(
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(
played: readonly HalfMove[],
expected: readonly Move[],
): boolean {
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
+26 -12
View File
@@ -8,12 +8,19 @@
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
import { startUpdates } from "./ui/updates.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
@@ -96,20 +103,27 @@ startApp({
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// Hors ligne : le service worker met tout le site en cache. Le navigateur
// le refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// network error »), et l'application n'est pas disponible hors ligne.
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
// fait échouer l'installation (« Cache.add() encountered a network error »),
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
// télécharger deux fois ces données à la première visite.
if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
+309 -12
View File
@@ -52,11 +52,19 @@ import { createGamesView, type GamesView } from "./games-view.js";
import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js";
import { createHaptics, type Vibrate } from "./haptics.js";
import { createHelpView, loadGlossaryEntry } from "./help.js";
import { type HelpMoment, helpHash } from "./help-context.js";
import { dismissInstall, homeModel, installMenuShown, launchHash, welcomed } from "./home-model.js";
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 { replacingLink, startNavigation } from "./navigation.js";
import { isHelpScreen, type Route, routeHash } from "./router.js";
import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
import { type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
* (interaction.md : on suit le coup sans l'attendre). */
@@ -95,6 +103,11 @@ 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. */
readonly tutorial: TutorialAccess;
/** Les mises à jour de l'application (service worker) : onAvailable est
* appelé quand une nouvelle version attend. */
readonly startUpdates: (onAvailable: () => void) => Updates;
}
/** Ce que « Mes parties » demande au stockage. */
@@ -222,6 +235,21 @@ export function startApp(options: AppOptions): void {
banner: byId("banner"),
bannerText: byId("banner-text"),
resumeOld: byId<HTMLButtonElement>("resume-old"),
home: byId("home"),
tutorial: byId("tutorial"),
menuHome: byId<HTMLAnchorElement>("menu-home"),
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
menuInstall: byId<HTMLButtonElement>("menu-install"),
menuUpdate: byId<HTMLButtonElement>("menu-update"),
installVeil: byId("install-veil"),
installTitle: byId("install-title"),
installClose: byId<HTMLButtonElement>("install-close"),
installLead: byId("install-lead"),
installSteps: byId("install-steps"),
installDone: byId<HTMLButtonElement>("install-done"),
updateBanner: byId("update-banner"),
updateText: byId("update-text"),
updateApply: byId<HTMLButtonElement>("update-apply"),
};
// ------------------------------------------------------ Réglages
@@ -304,6 +332,28 @@ export function startApp(options: AppOptions): void {
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
ui.bannerText.textContent = TEXTS.newGameStarted;
ui.resumeOld.textContent = TEXTS.resumeOld;
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
ui.installTitle.textContent = HOME_TEXTS.installTitle;
ui.installClose.append(icon("close"));
ui.installClose.setAttribute("aria-label", TEXTS.close);
ui.installLead.textContent = HOME_TEXTS.installLead;
ui.installDone.textContent = HOME_TEXTS.installUnderstood;
ui.updateApply.textContent = HOME_TEXTS.updateApply;
for (const [i, text] of HOME_TEXTS.installSteps[
installPlatform(navigator.userAgent)
].entries()) {
const item = document.createElement("li");
const number = document.createElement("span");
number.className = "step-number";
number.textContent = String(i + 1);
const words = document.createElement("span");
words.textContent = text;
item.append(number, words);
ui.installSteps.append(item);
}
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
glossaryLink.textContent = TEXTS.seeGlossary;
glossaryLink.href = routeHash({
@@ -352,6 +402,23 @@ export function startApp(options: AppOptions): void {
// La position partagée affichée (#/position/...), une fois lue.
let shownPosition: SharedPosition | null = null;
let positionRequest = 0;
// L'installation (invite du navigateur, application installée) et les
// mises à jour (service worker).
const installer = startInstaller((installedNow) => {
if (installedNow) {
saveSettings({ installed: true });
} else {
render();
}
});
let updates: Updates | null = null;
// Un message éphémère dans le bandeau de mise à jour (« Vous avez la
// dernière version. »), sans bouton.
let updateMessage: string | null = null;
let updateMessageTimer: ReturnType<typeof setTimeout> | null = null;
// Les parties jouées et gagnées, lues dans « Mes parties » pour
// l'accueil.
let homeProgress: { played: number; won: number } | null = null;
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
const portraitQuery = window.matchMedia("(orientation: portrait)");
@@ -515,6 +582,7 @@ export function startApp(options: AppOptions): void {
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
renderPanel(game, landscape, doubled);
renderMenu();
renderChrome();
ui.resumeOld.disabled = busy;
// Pour le style et pour les tests dans un navigateur : l'écran
// attend le moteur.
@@ -563,6 +631,7 @@ export function startApp(options: AppOptions): void {
ui.info.textContent = notice;
}
renderMenu();
renderChrome();
}
/** Les commandes de la colonne de gauche, en paysage. */
@@ -641,6 +710,53 @@ export function startApp(options: AppOptions): void {
element.hidden = !visible;
}
/** Ce qui entoure les écrans : l'aide du moment derrière le bouton
* « Aide », la ligne « Installer » du menu, le bandeau de mise à jour,
* et l'accueil s'il est affiché. */
function renderChrome(): void {
const helpLink = helpHash(helpMoment());
ui.helpButton.href = helpLink;
ui.menuHelp.href = helpLink;
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
const moveInProgress =
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
if (updateMessage !== null) {
ui.updateBanner.hidden = false;
ui.updateText.textContent = updateMessage;
ui.updateApply.hidden = true;
} else {
const shown = updateBannerShown(
updates?.available ?? false,
moveInProgress,
!ui.banner.hidden,
);
ui.updateBanner.hidden = !shown;
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
ui.updateApply.hidden = false;
}
if (navigation.route.screen === "home") {
renderHome();
}
}
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
function helpMoment(): HelpMoment | null {
if (game === null) {
return null;
}
const state = game.state;
if (state.phase === "over") {
return "over";
}
if (state.turn === "computer" && state.phase === "doubled") {
return "doubled";
}
if (state.turn === "player" && state.phase === "moving") {
return stuck ? "stuck" : "moving";
}
return null;
}
// --------------------------------------------- Vibrations, écran allumé
/** Une seule vibration par fin de partie. */
@@ -801,10 +917,18 @@ export function startApp(options: AppOptions): void {
notice = null;
ui.bubble.hidden = true;
render();
const before = game;
const wasOver = game.state.phase === "over";
try {
await action(game);
save();
await computerTurns();
// Une partie vient de se terminer (pas une partie finie
// reprise) : elle compte pour l'invitation à installer.
if (game === before && !wasOver && game.state.phase === "over") {
saveSettings({ gamesFinished: settings.gamesFinished + 1 });
homeProgress = null;
}
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
@@ -888,7 +1012,8 @@ export function startApp(options: AppOptions): void {
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
const navigation = startNavigation((route) => applyRoute(route));
const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games";
const isSheet = (route: Route) =>
route.screen === "menu" || route.screen === "games" || route.screen === "install";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1018,7 +1143,7 @@ export function startApp(options: AppOptions): void {
},
help: () => {
closeTransient();
navigation.go("#/aide");
navigation.go(helpHash(helpMoment()));
},
fullscreen: () => {
closeOverlays();
@@ -1037,6 +1162,8 @@ export function startApp(options: AppOptions): void {
navigation.leave(isHelpScreen);
} else if (route.screen === "position") {
navigation.leave((r) => r.screen === "position");
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
}
},
};
@@ -1075,6 +1202,7 @@ export function startApp(options: AppOptions): void {
function leaveSheet(): void {
const menuWasOpen = !ui.menuVeil.hidden;
ui.menuVeil.hidden = true;
ui.installVeil.hidden = true;
gamesView?.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
@@ -1125,6 +1253,8 @@ export function startApp(options: AppOptions): void {
// téléphone ne rouvre pas le menu.
replacingLink(ui.menuGames, navigation);
replacingLink(ui.menuHelp, navigation);
replacingLink(ui.menuHome, navigation);
replacingLink(ui.menuTutorial, navigation);
ui.menuClose.onclick = () => closeOverlays();
ui.menuNewGame.onclick = () => newGame();
ui.menuCopyLink.onclick = () => {
@@ -1164,14 +1294,41 @@ export function startApp(options: AppOptions): void {
function applyRoute(route: Route): void {
const help = isHelpScreen(route);
const position = route.screen === "position";
const home = route.screen === "home";
const tutorial = route.screen === "tutorial" || route.screen === "tutorialEnd";
const page = help || position || home || tutorial;
const wasPage = ui.game.hidden;
ui.game.hidden = help || position;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
ui.help.hidden = !help;
ui.position.hidden = !position;
ui.root.classList.toggle("page-open", help || position);
if (wasPage && !ui.game.hidden) {
ui.home.hidden = !home;
ui.tutorial.hidden = !tutorial;
ui.root.classList.toggle("page-open", page);
// Le tutoriel et le premier lancement ont leur propre en-tête.
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
ui.root.dataset.screen = route.screen;
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== route.screen)) {
window.scrollTo(0, 0);
}
if (tutorial) {
void tutorialView.show(route);
} else {
tutorialView.hide();
}
if (home) {
homeProgress = null;
}
// La marche à suivre pour installer : une feuille, comme le menu.
if (route.screen === "install" && ui.installVeil.hidden) {
closeTransient();
ui.menuVeil.hidden = true;
ui.installVeil.hidden = false;
ui.installDone.focus();
} else if (route.screen !== "install") {
ui.installVeil.hidden = true;
}
// Feuilles : le menu et « Mes parties ».
const menuOpen = route.screen === "menu";
@@ -1214,16 +1371,147 @@ export function startApp(options: AppOptions): void {
shownPosition = null;
}
// Écrans à venir (0e.5) et adresses inconnues : la partie, avec un
// message, à l'adresse de la partie.
if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") {
notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet;
// Une adresse inconnue : la partie, avec un message, à l'adresse de
// la partie.
if (route.screen === "unknown") {
notice = TEXTS.unknownAddress;
navigation.replace("#/");
return;
}
render();
}
// ----------------------------------------------- Accueil et tutoriel
const homeView = createHomeView({
section: ui.home,
newGame: () => {
if (busy || game === null) {
return;
}
navigation.go("#/");
newGame();
},
install: () => void install(false),
dismissInstall: () => saveSettings(dismissInstall(settings)),
welcomed: () => saveSettings(welcomed()),
});
let progressLoading = false;
function renderHome(): void {
const playing = game === null ? null : game.state.phase !== "over";
homeView.render({
model: homeModel(settings, playing, installer.installed),
sides: sides ?? options.cachedBoard()?.sides ?? null,
tutorialSeen: settings.tutorialSeen,
progress: homeProgress,
busy: busy || game === null,
});
if (homeProgress === null && !progressLoading) {
progressLoading = true;
// La liste vient du stockage, ouvert avec le moteur.
options.engineReady
.then(() => options.games.list())
.then((list) => {
const over = list.filter((summary) => summary.status === "over");
homeProgress = {
played: over.length,
won: over.filter((summary) => summary.result?.winner === "player").length,
};
if (navigation.route.screen === "home") {
renderHome();
}
})
.catch(() => undefined)
.finally(() => {
progressLoading = false;
});
}
}
const tutorialView = createTutorialView({
section: ui.tutorial,
access: options.tutorial,
engineReady: options.engineReady,
seen: () => settings.tutorialSeen,
visit: (place) => {
const seen = seenAfter(settings.tutorialSeen, place);
if (seen !== settings.tutorialSeen || !settings.welcomed) {
saveSettings({ tutorialSeen: seen, ...welcomed() });
}
},
placed: () => haptic("checkerPlaced"),
reducedMotion,
replace: (hash) => navigation.replace(hash),
});
// ------------------------------------------------------- Installation
/** « Installer » : l'invite du navigateur s'il en a donné une, sinon la
* marche à suivre (#/installer). Depuis le menu, l'adresse du menu est
* remplacée : le retour du téléphone ne le rouvre pas. */
async function install(fromMenu: boolean): Promise<void> {
if (installer.canPrompt) {
if (fromMenu) {
closeOverlays();
}
await installer.offer();
render();
return;
}
if (fromMenu) {
ui.menuVeil.hidden = true;
navigation.replace("#/installer");
} else {
navigation.go("#/installer");
}
}
ui.menuInstall.onclick = () => void install(true);
const leaveInstall = () => {
ui.installVeil.hidden = true;
navigation.leave((route) => route.screen === "install");
};
ui.installClose.onclick = leaveInstall;
ui.installDone.onclick = leaveInstall;
ui.installVeil.addEventListener("click", (event) => {
if (event.target === ui.installVeil) {
leaveInstall();
}
});
// ------------------------------------------------------- Mise à jour
updates = options.startUpdates(() => render());
ui.updateApply.onclick = () => {
ui.updateApply.disabled = true;
updates?.apply();
};
function showUpdateMessage(message: string | null, lasting: boolean): void {
if (updateMessageTimer !== null) {
clearTimeout(updateMessageTimer);
updateMessageTimer = null;
}
updateMessage = message;
if (message !== null && !lasting) {
updateMessageTimer = setTimeout(() => showUpdateMessage(null, true), 4000);
}
render();
}
ui.menuUpdate.onclick = () => {
closeOverlays();
showUpdateMessage(HOME_TEXTS.updateChecking, true);
void (updates?.check() ?? Promise.resolve("unavailable" as const)).then((found) => {
showUpdateMessage(
found === "found"
? null
: found === "latest"
? HOME_TEXTS.updateLatest
: HOME_TEXTS.updateUnavailable,
found === "found",
);
});
};
// -------------------------------------------- Vue d'une position
/** #/position/... : lit la position par le moteur, puis la montre. */
@@ -1408,6 +1696,9 @@ export function startApp(options: AppOptions): void {
if (shownPosition !== null) {
renderPosition({ kind: "shown", shared: shownPosition });
}
if (!ui.tutorial.hidden) {
tutorialView.relayout();
}
});
};
window.addEventListener("resize", relayout);
@@ -1550,8 +1841,14 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
// ou l'écran que désigne l'adresse.
applyRoute(navigation.route);
// ou l'écran que désigne l'adresse. Lancée sans adresse d'écran (icône,
// adresse du site), l'application s'ouvre sur l'accueil.
const launch = launchHash(location.hash);
if (launch !== null) {
navigation.replace(launch);
} else {
applyRoute(navigation.route);
}
options.engineReady.then(
async () => {
game = options.savedGame();
+32
View File
@@ -0,0 +1,32 @@
// La question de l'aide qui correspond au moment de la partie : le bouton
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
// titre dans un .feature demande de le changer ici.
import { routeHash, slug } from "./router.js";
/** Le moment de la partie, vu de l'aide. */
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
moving: "Comment jouer un coup ?",
stuck: "Je ne peux rien jouer avec mes dés",
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
over: "Comment se termine une partie ?",
};
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
* question particulière : null). */
export function helpHash(moment: HelpMoment | null): string {
return routeHash({
screen: "help",
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
});
}
+54 -6
View File
@@ -14,10 +14,16 @@ export interface HelpFeature {
}
export interface HelpScenario {
/** Le titre, formulé comme la question du joueur. */
readonly title: string;
/** La réponse : le paragraphe sous le titre. */
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
* s'affichent pas dans l'aide : elles prouvent la réponse. */
readonly steps: readonly string[];
/** La position de l'exemple, donnée par l'étiquette
* @position:<Position ID> du scénario (null sans étiquette). */
readonly position: string | null;
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
* ignorés ; une étiquette @position:<Position ID> donne la position de
* l'exemple du scénario qui la suit.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
const scenarios: {
title: string;
description: string[];
steps: string[];
position: string | null;
}[] = [];
let paragraph: string[] = [];
let tags: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (line.startsWith("@")) {
endParagraph();
tags.push(...line.split(/\s+/));
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
tags = [];
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
const position = tags.find((tag) => tag.startsWith("@position:"));
scenarios.push({
title: scenario[2] ?? "",
description: [],
steps: [],
position: position === undefined ? null : position.slice("@position:".length),
});
tags = [];
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
* phrase « En bref », et ses renvois « Voir aussi ». */
export interface GlossaryEntry {
readonly term: string;
readonly english: string;
readonly brief: string;
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
term: m[1] ?? "",
name: m[2] ?? "",
}));
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
}
/** Un texte sans accents ni majuscules, pour la recherche dans le
* glossaire. */
export function searchable(text: string): string {
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
}
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
* phrase « En bref » contiennent chacun des mots cherchés) ? */
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
const words = searchable(query).split(/\s+/).filter(Boolean);
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
return words.every((word) => haystack.includes(word));
}
+112 -21
View File
@@ -8,25 +8,33 @@
//
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
// est ouvert (show).
// est ouvert (show). Le titre d'un scénario est la question, sa
// description la réponse ; ses étapes, la preuve que l'application fait ce
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { HOME_TEXTS } from "./home-texts.js";
import { type HelpRoute, routeHash, slug } from "./router.js";
import { TEXTS } from "./texts.js";
/** Les scénarios montrés dans l'aide, dans l'ordre. */
const FEATURES = [
export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
"docs/scenarios/installer-l-application.feature",
"docs/scenarios/decouvrir-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -59,6 +67,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
}
/** Le commit publié (version.json, écrit par make site), en abrégé. */
async function publishedVersion(): Promise<string | null> {
try {
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
return typeof version.commit === "string" && version.commit !== ""
? version.commit.slice(0, 12)
: null;
} catch {
return null;
}
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
@@ -101,7 +121,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, HTMLElement>();
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -126,10 +146,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
for (const paragraph of scenario.description) {
element(answer, "p", paragraph);
}
const steps = element(answer, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
if (scenario.position !== null) {
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
board.className = "see-board";
board.href = routeHash({
screen: "position",
position: scenario.position,
match: null,
});
}
questions.push({ id, section, link, answer });
}
@@ -137,7 +161,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph);
element(play, "p", paragraph).className = "feature-intro";
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
@@ -146,38 +170,103 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const label = element(glossary, "label");
label.className = "glossary-search";
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
const search = element(label, "input");
search.id = "glossary-search";
search.type = "search";
search.autocomplete = "off";
search.spellcheck = false;
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
none.className = "glossary-none muted";
none.hidden = true;
const list = element(glossary, "dl");
list.className = "glossary";
for (const entry of entries) {
const term = element(list, "dt");
term.id = `glossaire-${entry.name}`;
const link = element(term, "a", entry.term);
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
for (const entry of sorted) {
const card = element(list, "div");
card.className = "term-card";
const dt = element(card, "dt");
dt.id = `glossaire-${entry.name}`;
const link = element(dt, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
if (entry.english !== "") {
const english = element(dt, "span", entry.english);
english.className = "english";
english.lang = "en";
}
const dd = element(card, "dd");
element(dd, "p", entry.brief);
if (entry.seeAlso.length > 0) {
const see = element(dd, "p");
see.className = "see-also";
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
entry.seeAlso.forEach((other, i) => {
if (i > 0) {
see.append(", ");
}
const a = element(see, "a", other.term);
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
}
search.addEventListener("input", () => {
let shown = 0;
for (const { card, entry } of terms.values()) {
const match = matchesSearch(entry, search.value);
card.hidden = !match;
shown += match ? 1 : 0;
}
none.hidden = shown > 0;
});
}
function renderAbout(): void {
function renderAbout(version: string | null): void {
element(about, "h2", TEXTS.aboutTitle);
const text = element(about, "p", TEXTS.about);
text.className = "about";
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
element(about, "p", paragraph).className = "about";
}
const line = element(
about,
"p",
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
);
line.id = "about-version";
line.className = "about";
}
function renderTutorialCard(): void {
const card = element(container, "a");
card.id = "help-tutorial";
card.className = "card help-tutorial";
card.href = "#/tutoriel/1";
const text = element(card, "span");
text.className = "help-tutorial-text";
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries] = await Promise.all([
const [features, entries, version] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
publishedVersion(),
]);
const intro = element(play, "p", HOME_TEXTS.helpIntro);
intro.className = "help-intro muted";
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout();
renderAbout(version);
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
@@ -215,10 +304,12 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, dt] of terms) {
for (const [name, { dt, card }] of terms) {
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
card.hidden = false;
} else {
dt.removeAttribute("aria-current");
}
@@ -231,7 +322,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
const target = opened?.section ?? (term === null ? undefined : terms.get(term));
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
+64
View File
@@ -0,0 +1,64 @@
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
/** Les deux choix du premier lancement : « Je découvre » mène au
* tutoriel, « Je connais les règles » à la partie. */
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
/** Les réglages après l'un des deux choix du premier lancement. */
export function welcomed(): Partial<Settings> {
return { welcomed: true };
}
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
* gardée (null). */
export function launchHash(hash: string): string | null {
return hash === "" || hash === "#" ? "#/accueil" : null;
}
/** L'accueil, au premier lancement ou ensuite. */
export interface HomeModel {
/** Premier lancement : le choix « Je découvre » / « Je connais les
* règles », et rien d'autre. */
readonly welcome: boolean;
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
* encore connu, le moteur se charge). */
export function homeModel(
settings: Settings,
playing: boolean | null,
installed: boolean,
): HomeModel {
const welcome = isFirstLaunch(settings);
return {
welcome,
resume: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
/** La ligne « Installer l'application » du menu : tant que l'application
* n'est pas installée. */
export function installMenuShown(settings: Settings, installed: boolean): boolean {
return !installed && !settings.installed;
}
/** Les réglages après « Plus tard » sur l'invitation à installer. */
export function dismissInstall(settings: Settings): Partial<Settings> {
return {
installDismissals: settings.installDismissals + 1,
installDismissedAt: settings.gamesFinished,
};
}
+125
View File
@@ -0,0 +1,125 @@
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { InstallPlatform } from "./install.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const HOME_TEXTS = {
// Premier lancement
welcomeTitle: "Bienvenue à la table",
welcomeText:
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
discover: "Je découvre",
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
knowRules: "Je connais les règles",
knowRulesDetail: "Une partie tout de suite",
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
// Accueil
homeLink: "Accueil",
homeTitle: "À vous de jouer",
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
gameOverline: "Partie en cours",
noGameOverline: "Pas de partie en cours",
againstComputer: "Contre l'ordinateur",
homePips: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
homeLoading: "Chargement…",
resumeGame: "Reprendre la partie",
rulesTitle: "Les règles du jeu",
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
rulesStart: "Commencer",
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
rulesAgain: "Revoir",
progress: (played: number, won: number) =>
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
// Installation
installTitle: "Installer Ustagammon",
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
install: "Installer",
installLater: "Plus tard",
installMenu: "Installer l'application",
installLead:
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
installSteps: {
firefox: [
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
],
ios: [
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
"Choisissez « Sur l'écran d'accueil ».",
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
],
} satisfies Record<InstallPlatform, readonly string[]>,
installUnderstood: "J'ai compris",
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version disponible.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
updateLatest: "Vous avez la dernière version.",
updateUnavailable:
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
// Tutoriel
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Passer",
tutorialSkipLabel: "Passer le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
tutorialEndTitle: "Vous connaissez les règles",
back: "Retour",
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Je prends",
drop: "Je passe",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
helpTutorialTitle: "Nouveau au backgammon ?",
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
helpTutorialLink: "Apprendre les règles",
helpIntro:
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
seeOnBoard: "Voir sur le plateau",
glossarySearch: "Chercher un mot",
glossaryNone: "Aucun mot ne correspond.",
glossarySeeAlso: "Voir aussi :",
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
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.",
"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.",
],
} as const;
+242
View File
@@ -0,0 +1,242 @@
// 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
// (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.
import type { SideBoards } from "../domain/game/game.js";
import { boardModel, OPENING_BOARDS } from "./board-model.js";
import { NO_MARKS, renderBoard } from "./board-view.js";
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 { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
export interface HomeViewOptions {
readonly section: HTMLElement;
/** « Nouvelle partie » (bouton : une action, pas un écran). */
readonly newGame: () => void;
readonly install: () => void;
readonly dismissInstall: () => void;
/** Un des deux choix du premier lancement a été suivi. */
readonly welcomed: () => void;
}
export interface HomeState {
readonly model: HomeModel;
/** Le plateau de la partie en cours ou de la dernière partie (null :
* jamais joué). */
readonly sides: SideBoards | null;
/** La dernière étape du tutoriel vue. */
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
export interface HomeView {
render(state: HomeState): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
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;
}
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("aria-hidden", "true");
parent.append(svg);
return svg;
}
function logo(parent: HTMLElement): void {
for (const [variant, file] of [
["logo-light", "logo-horizontal-couleur.svg"],
["logo-dark", "logo-horizontal-sombre.svg"],
] as const) {
const img = element(parent, "img", { className: variant });
img.src = `icons/${file}`;
img.alt = "Ustagammon";
}
}
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
...NO_MARKS,
dice: [],
cube: { value: 1, owner: null },
band: null,
controls: null,
});
}
/** Une grande carte-lien du premier lancement : le choix et son détail. */
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
const link = element(parent, "a", { id, className: "choice" });
link.href = href;
element(link, "strong", { text: label });
element(link, "span", { text: detail });
return link;
}
export function createHomeView(options: HomeViewOptions): HomeView {
const { section } = options;
section.replaceChildren();
// Premier lancement.
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
logo(welcomeLogo);
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
const welcomeText = element(welcome, "div", { className: "welcome-text" });
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
const choices = element(welcome, "div", { className: "welcome-choices" });
const discover = choice(
choices,
"welcome-discover",
WELCOME_LINKS.discover,
HOME_TEXTS.discover,
HOME_TEXTS.discoverDetail,
);
discover.classList.add("primary");
const play = choice(
choices,
"welcome-play",
WELCOME_LINKS.play,
HOME_TEXTS.knowRules,
HOME_TEXTS.knowRulesDetail,
);
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
for (const link of [discover, play]) {
link.addEventListener("click", () => options.welcomed());
}
// Ensuite.
const main = element(section, "div", { id: "home-main", className: "home-main" });
const intro = element(main, "div", { className: "home-intro" });
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
const card = element(main, "article", { id: "home-game", className: "card home-game" });
const cardTop = element(card, "div", { className: "home-game-top" });
const miniBox = element(cardTop, "div", { className: "home-mini" });
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
id: "home-resume",
className: "button",
text: HOME_TEXTS.resumeGame,
});
resume.href = "#/";
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
fresh.type = "button";
fresh.addEventListener("click", () => options.newGame());
const install = element(main, "aside", { id: "install-card", className: "install-card" });
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
const symbol = element(install, "img", { className: "install-symbol" });
symbol.src = "icons/symbole-couleur.svg";
symbol.alt = "";
const installText = element(install, "span", { className: "install-text" });
element(installText, "strong", { text: HOME_TEXTS.installTitle });
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
const installButton = element(install, "button", {
id: "install-card-button",
className: "secondary",
text: HOME_TEXTS.install,
});
installButton.type = "button";
installButton.addEventListener("click", () => options.install());
const later = element(install, "button", { id: "install-later", className: "icon-button" });
later.type = "button";
later.append(icon("close"));
later.setAttribute("aria-label", HOME_TEXTS.installLater);
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
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 });
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
const rulesAction = element(rules, "span", { className: "pill" });
const progress = element(main, "p", {
id: "home-progress",
className: "caption home-progress",
});
const footer = element(main, "footer", { className: "home-footer" });
const footerSymbol = element(footer, "img");
footerSymbol.src = "icons/symbole-couleur.svg";
footerSymbol.alt = "";
element(footer, "span", { text: HOME_TEXTS.footer });
return {
render(state) {
const { model } = state;
welcome.hidden = !model.welcome;
main.hidden = model.welcome;
if (model.welcome) {
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
return;
}
const sides = state.sides ?? OPENING_BOARDS;
drawBoard(mini, sides, miniBox.clientWidth || 132);
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
// Une seule action principale : « Reprendre la partie », ou
// « Nouvelle partie » quand il n'y a rien à reprendre.
resume.hidden = !model.resume;
fresh.textContent = TEXTS.newGame;
fresh.classList.toggle("secondary", model.resume);
fresh.disabled = state.busy;
install.hidden = !model.installCard;
const seen = state.tutorialSeen;
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
rulesAction.textContent =
seen === 0
? HOME_TEXTS.rulesStart
: 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);
},
};
}
+3
View File
@@ -22,6 +22,9 @@ export const ICON_PATHS = {
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
screenOn: "M7 3h10v18H7zM11 18h2",
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",
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",
+82
View File
@@ -0,0 +1,82 @@
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
// aide.md, « Installation ») : l'invite du système quand le navigateur en
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
// l'application installée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
export type InstallPlatform = "ios" | "firefox";
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
export function installPlatform(userAgent: string): InstallPlatform {
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
}
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
interface InstallPromptEvent extends Event {
readonly prompt: () => Promise<void>;
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface Installer {
/** L'application tourne installée, ou vient de l'être. */
readonly installed: boolean;
/** Le navigateur a donné son invite d'installation. */
readonly canPrompt: boolean;
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
}
/**
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
* empêche le navigateur de l'afficher de lui-même), et note l'installation
* (display-mode: standalone, événement appinstalled). onChange est appelé à
* chaque changement.
*/
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
const standalone = window.matchMedia("(display-mode: standalone)");
let installedNow = false;
let deferred: InstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferred = event as InstallPromptEvent;
onChange(false);
});
window.addEventListener("appinstalled", () => {
installedNow = true;
deferred = null;
onChange(true);
});
standalone.addEventListener("change", () => onChange(false));
return {
get installed() {
return installedNow || standalone.matches;
},
get canPrompt() {
return deferred !== null;
},
async offer() {
const event = deferred;
if (event === null) {
return "unavailable";
}
// Une invite ne sert qu'une fois.
deferred = null;
try {
await event.prompt();
const choice = await event.userChoice;
return choice.outcome;
} catch {
return "unavailable";
} finally {
onChange(false);
}
},
};
}
+87 -4
View File
@@ -3,6 +3,10 @@
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
// toujours une valeur par défaut, et un réglage enregistré par une version
// précédente se relit sans perte.
export type Theme = "auto" | "light" | "dark";
@@ -15,6 +19,21 @@ export interface Settings {
/** Parties commencées : la consigne est longue pendant les trois
* premières, courte ensuite. */
readonly gamesStarted: number;
/** Parties terminées (l'invitation à installer vient après la
* première). */
readonly gamesFinished: number;
/** Le choix du premier lancement (« Je découvre », « Je connais les
* règles ») a été fait : il ne revient plus. */
readonly welcomed: boolean;
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
* la fin). La barre de progression ne mène qu'aux étapes vues. */
readonly tutorialSeen: number;
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
* le nombre de parties terminées à la dernière fois. */
readonly installDismissals: number;
readonly installDismissedAt: number;
/** L'application a été installée (événement appinstalled). */
readonly installed: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -22,11 +41,24 @@ export const DEFAULT_SETTINGS: Settings = {
keepScreenOn: true,
theme: "auto",
gamesStarted: 0,
gamesFinished: 0,
welcomed: false,
tutorialSeen: 0,
installDismissals: 0,
installDismissedAt: 0,
installed: false,
};
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
export const LONG_PROMPT_GAMES = 3;
/** tutorialSeen quand la fin du tutoriel a été vue. */
export const TUTORIAL_DONE = 99;
/** Parties terminées de plus avant que l'invitation à installer, masquée
* par « Plus tard », revienne (une seule fois). */
export const INSTALL_AGAIN_AFTER = 3;
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
* valeur illisible garde sa valeur par défaut. */
export function parseSettings(text: string | null): Settings {
@@ -39,16 +71,33 @@ export function parseSettings(text: string | null): Settings {
} catch {
// Réglages illisibles : valeurs par défaut.
}
const bool = (key: "vibrations" | "keepScreenOn") =>
const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") =>
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
const count = (
key:
| "gamesStarted"
| "gamesFinished"
| "tutorialSeen"
| "installDismissals"
| "installDismissedAt",
) => {
const value = raw[key];
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: DEFAULT_SETTINGS[key];
};
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
const games = raw.gamesStarted;
return {
vibrations: bool("vibrations"),
keepScreenOn: bool("keepScreenOn"),
theme,
gamesStarted:
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
gamesStarted: count("gamesStarted"),
gamesFinished: count("gamesFinished"),
welcomed: bool("welcomed"),
tutorialSeen: count("tutorialSeen"),
installDismissals: count("installDismissals"),
installDismissedAt: count("installDismissedAt"),
installed: bool("installed"),
};
}
@@ -61,3 +110,37 @@ export function nextTheme(theme: Theme): Theme {
export function longPrompts(settings: Settings): boolean {
return settings.gamesStarted <= LONG_PROMPT_GAMES;
}
/**
* Premier lancement : rien n'a encore été fait dans l'application (le choix
* d'accueil, une partie terminée, une étape du tutoriel). La partie
* commencée d'office au chargement compte pour une : jusqu'à une partie
* commencée, c'est encore le premier lancement.
*/
export function isFirstLaunch(settings: Settings): boolean {
return (
!settings.welcomed &&
settings.gamesStarted <= 1 &&
settings.gamesFinished === 0 &&
settings.tutorialSeen === 0
);
}
/**
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
* l'application installée ; à partir de la première partie terminée ;
* « Plus tard » la masque, et elle ne revient qu'une fois, après
* INSTALL_AGAIN_AFTER parties terminées de plus.
*/
export function installCardShown(settings: Settings, installed: boolean): boolean {
if (installed || settings.installed || settings.gamesFinished < 1) {
return false;
}
if (settings.installDismissals === 0) {
return true;
}
return (
settings.installDismissals === 1 &&
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
);
}
-1
View File
@@ -164,7 +164,6 @@ export const TEXTS = {
// Adresses (ADR 0013)
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
screenNotYet: "Cet écran n'existe pas encore : voici votre partie.",
// Lien vers une position (ADR 0013)
copyPositionLink: "Copier le lien de la position",
+96
View File
@@ -0,0 +1,96 @@
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
// de progression (charte : « les segments sont des liens vers les étapes
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Route } from "./router.js";
import { TUTORIAL_DONE } from "./settings.js";
import type { Tutorial } from "./tutorial-text.js";
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
* étape hors du tutoriel mène à la première, un « à vous » absent à
* l'explication de son étape. */
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];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: route.practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
export function placeRoute(place: TutorialPlace): Route {
return place === "end"
? { screen: "tutorialEnd" }
: { screen: "tutorial", 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") {
return "end";
}
const step = tutorial.steps[place.step - 1];
if (!place.practice && step?.practice != null) {
return { step: place.step, practice: true };
}
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
}
/** L'écran précédent (null avant la première étape). */
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
if (place === "end") {
const last = tutorial.steps.at(-1);
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
}
if (place.practice) {
return { step: place.step, practice: false };
}
const before = tutorial.steps[place.step - 2];
return before === undefined
? null
: { step: before.number, practice: before.practice !== null };
}
/** Ce que retient la progression quand on arrive sur un écran : la
* dernière étape vue (TUTORIAL_DONE à la fin). */
export function seenAfter(seen: number, place: TutorialPlace): number {
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
}
/** Un segment de la barre de progression. */
export interface ProgressSegment {
readonly step: number;
/** Étape faite ou en cours : le segment est en brique. */
readonly done: boolean;
/** Étape déjà vue : le segment est un lien vers elle. */
readonly link: boolean;
readonly current: boolean;
}
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
* seen ont déjà été vues. */
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
const current = place === "end" ? total + 1 : place.step;
const reached = Math.max(seen, current);
return Array.from({ length: total }, (_, i) => {
const step = i + 1;
return {
step,
done: step <= current,
link: step <= reached && step !== current,
current: step === current,
};
});
}
+6 -2
View File
@@ -121,8 +121,12 @@ function screenOf(raw: RawScreen, where: string): TutorialScreen {
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: { number: number; title: string; explain: RawScreen; practice: RawScreen | null }[] =
[];
const steps: {
number: number;
title: string;
explain: RawScreen;
practice: RawScreen | null;
}[] = [];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
+702
View File
@@ -0,0 +1,702 @@
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
// #/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 »).
//
// 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,
// 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.
import type { MoveSteps } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
type Place,
placeOf as placeOfChecker,
} from "./board-model.js";
import {
type BoardDecor,
checkerSpot,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
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";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
const SLIDE_MS = 160;
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";
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
board(): Promise<{
player: TutorialBoard["sides"]["player"];
computer: TutorialBoard["sides"]["computer"];
}>;
nextHalfMoves(): Promise<HalfMove[]>;
play(halfMove: HalfMove): Promise<PracticeVerdict>;
reset(): void;
readonly diceToPlay: readonly { value: number; used: boolean }[];
}
/** Ce que le tutoriel demande au domaine et au moteur. */
export interface TutorialAccess {
board(position: string, onRoll: Side): Promise<TutorialBoard>;
illustrate(
position: string,
roll: Roll,
move: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
shouldTake(position: string): Promise<boolean>;
}
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;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
export interface TutorialView {
show(route: Route): Promise<void>;
/** La taille de l'écran a changé : redessiner le plateau. */
relayout(): void;
/** L'écran est quitté : arrêter le coup illustré. */
hide(): void;
}
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;
}
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
target.replaceChildren();
for (const part of paragraph) {
if (part.term !== undefined) {
const link = document.createElement("a");
link.href = routeHash({ screen: "glossary", term: part.term });
link.textContent = part.text;
target.append(link);
} else if (part.strong === true) {
const strong = document.createElement("strong");
strong.textContent = part.text;
target.append(strong);
} else {
target.append(part.text);
}
}
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */
function drawPath(svg: SVGSVGElement, g: Geometry): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const width = g.pointWidth * 0.22;
for (const [className, stroke] of [
["path-edge", width * 2.4],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = width * 2.2;
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(width * 1.2));
group.append(head);
// Sous les zones à toucher, toujours dessinées en dernier.
svg.insertBefore(group, svg.lastChild);
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access } = options;
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" });
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" });
const boardBox = element(section, "div", { className: "tutorial-board-box" });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = "tutorial-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" });
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(section, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
const drop = element(cube, "button", {
id: "tutorial-drop",
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: "tutorial-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",
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: "tutorial-next",
className: "button",
text: HOME_TEXTS.next,
});
let tutorial: Promise<Tutorial> | 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;
let autoplay: ReturnType<typeof setTimeout> | null = null;
let busy = false;
// L'écran affiché.
let screen: TutorialScreen | null = null;
let place: TutorialPlace | null = null;
let boards: TutorialBoard | null = null;
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
let illustrated = false;
let practice: PracticeSession | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
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;
};
function geometry(): Geometry {
const width = svg.clientWidth || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
function shownDice(): ShownDie[] {
if (practice !== null) {
return practice.diceToPlay.map((die) => ({
value: die.value,
side: "player",
blocked: false,
used: die.used,
}));
}
const roll = screen?.move != null ? screen.roll : null;
if (roll === null || roll === undefined) {
return [];
}
const [a, b] = roll;
return [a, b].map((value) => ({
value,
side: "player" as const,
blocked: false,
used: false,
}));
}
/** Redessine le plateau dans son état courant. */
function draw(sides: TutorialBoard["sides"] | null = null): void {
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
if (current === undefined || current === null) {
return;
}
const g = geometry();
const decor: BoardDecor = {
...NO_MARKS,
dice: shownDice(),
cube: { value: 1, owner: null },
band: null,
controls: null,
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
illustrated && illustration !== null
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
: null,
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g);
}
}
function clearTimer(): void {
if (autoplay !== null) {
clearTimeout(autoplay);
autoplay = null;
}
}
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
* puis ses traces ; sans mouvement si le système le demande. */
async function playIllustration(id: number): Promise<void> {
const shownMove = illustration;
if (shownMove === null || id !== shown) {
return;
}
busy = true;
replay.disabled = true;
illustrated = false;
try {
if (!options.reducedMotion.matches) {
const { steps } = shownMove;
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== shown) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOfChecker("player", halfMove.from);
const to = placeOfChecker("player", halfMove.to);
draw(steps.boards[i] ?? null);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(steps.boards[i + 1] ?? null);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
} finally {
if (id === shown) {
busy = false;
illustrated = true;
replay.disabled = false;
draw();
const result = shownMove.after.result;
feedback.textContent =
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
feedback.dataset.verdict = result === null ? "" : "info";
}
}
}
async function refreshTargets(): Promise<void> {
targets =
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
feedback.dataset.verdict = verdict;
fillParagraph(feedback, words);
if (verdict === "right") {
feedback.prepend(icon("verdictGood"));
}
}
function markSolved(): void {
solved = true;
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
options.placed();
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
const session = practice;
const current = screen;
if (session === null || current === null || busy || solved) {
return;
}
const id = shown;
busy = true;
selected = null;
try {
const verdict = await session.play(halfMove);
if (id !== shown) {
return;
}
draw(await session.board());
if (verdict === "right") {
markSolved();
setVerdict("right", current.right);
} else if (verdict === "wrong") {
// Le pion revient, et une phrase dit pourquoi.
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
if (id !== shown) {
return;
}
session.reset();
setVerdict("wrong", current.wrong);
} else {
feedback.textContent = "";
feedback.dataset.verdict = "";
}
await refreshTargets();
} finally {
if (id === shown) {
busy = false;
draw(practice === null ? null : await practice.board());
}
}
}
svg.addEventListener("click", (event) => {
if (practice === null || busy || solved) {
return;
}
const target = event.target as Element | null;
const placeName = target
?.closest("[data-place]")
?.getAttribute("data-place") as Place | null;
if (placeName == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
if (halfMove !== undefined) {
void playHalfMove(halfMove);
return;
}
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
if (only !== null) {
void playHalfMove(only);
return;
}
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
void practice.board().then((sides) => draw(sides));
});
replay.addEventListener("click", () => {
clearTimer();
void playIllustration(shown);
});
async function answer(takes: boolean): Promise<void> {
const current = screen;
if (current === null || solved || busy) {
return;
}
const id = shown;
busy = true;
try {
const right = (await access.shouldTake(current.position)) === takes;
if (id !== shown) {
return;
}
if (right) {
markSolved();
}
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
} finally {
busy = false;
}
}
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
bar.replaceChildren();
for (const segment of progress(total, at, options.seen())) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = HOME_TEXTS.tutorialStepLink(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.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
}
if (segment.current) {
item.setAttribute("aria-current", "step");
}
bar.append(item);
}
bar.setAttribute(
"aria-description",
at === "end"
? HOME_TEXTS.tutorialEndOverline
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
);
}
return {
async show(route) {
const id = ++shown;
clearTimer();
busy = false;
practice = null;
illustration = null;
illustrated = false;
screen = null;
boards = null;
selected = null;
targets = new Map();
solved = false;
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
cube.hidden = true;
replay.hidden = true;
take.disabled = false;
drop.disabled = false;
let loaded: Tutorial;
try {
if (tutorial === null) {
title.textContent = HOME_TEXTS.tutorialLoading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await load();
} catch {
if (id === shown) {
title.textContent = HOME_TEXTS.tutorialFailed;
}
return;
}
if (id !== shown) {
return;
}
const at = placeOf(loaded, route);
const wanted = routeHash(placeRoute(at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
options.visit(at);
const total = loaded.steps.length;
renderProgress(total, at, loaded.steps);
const before = previousPlace(loaded, at);
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
if (at === "end") {
overline.textContent = HOME_TEXTS.tutorialEndOverline;
title.textContent = HOME_TEXTS.tutorialEndTitle;
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = "#/";
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
const step = loaded.steps[at.step - 1];
const current = at.practice ? step?.practice : step?.explain;
if (step === undefined || current == null) {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
for (const paragraph of paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
if (at.practice) {
task.hidden = false;
task.replaceChildren();
for (const [i, paragraph] of current.paragraphs.entries()) {
if (i > 0) {
task.append(" ");
}
const span = document.createElement("span");
fillParagraph(span, paragraph);
task.append(span);
}
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(placeRoute(after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
if (currentScreen === null) {
return;
}
// Le plateau : la position, puis le coup illustré ou le geste à
// faire, tous lus par le moteur.
try {
await options.engineReady;
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
if (id !== shown) {
return;
}
boards = shownBoard;
if (
at !== "end" &&
at.practice &&
currentScreen.roll !== null &&
currentScreen.expected.length > 0
) {
practice = await access.practice(
currentScreen.position,
currentScreen.roll,
currentScreen.expected,
);
if (id !== shown) {
return;
}
await refreshTargets();
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
cube.hidden = false;
feedback.textContent = HOME_TEXTS.computerDoubles;
feedback.dataset.verdict = "info";
} else if (
at !== "end" &&
currentScreen.move !== null &&
currentScreen.roll !== null
) {
illustration = await access.illustrate(
currentScreen.position,
currentScreen.roll,
currentScreen.move,
);
if (id !== shown) {
return;
}
replay.hidden = false;
replay.disabled = true;
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
}
if (id !== shown) {
return;
}
draw(practice === null ? null : await practice.board());
section.dataset.ready = String(id);
} catch (error) {
if (id === shown) {
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
}
}
},
relayout() {
if (place === null || busy) {
return;
}
if (practice !== null) {
void practice.board().then((sides) => draw(sides));
} else {
draw();
}
},
hide() {
shown++;
clearTimer();
busy = false;
},
};
}
+139
View File
@@ -0,0 +1,139 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle
// version disponible » ; « Mettre à jour » active la nouvelle version et
// recharge la page, qui reprend la partie enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export interface Updates {
/** Une nouvelle version est installée et attend l'accord du joueur. */
readonly available: boolean;
/** « Rechercher une mise à jour » : demande au navigateur de relire le
* service worker publié. found : une nouvelle version attend (elle a pu
* demander de télécharger tout le site) ; latest : rien de nouveau ;
* unavailable : pas de service worker (http hors localhost, navigateur
* qui le refuse) ou réseau absent. */
check(): Promise<"found" | "latest" | "unavailable">;
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
* la main. */
apply(): void;
}
/** Message au service worker en attente : prendre la main (sw.js). */
export const SKIP_WAITING = "skip-waiting";
/**
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
* l'ancienne ». Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
moveInProgress: boolean,
otherBanner: boolean,
): boolean {
return available && !moveInProgress && !otherBanner;
}
/**
* Enregistre le service worker une fois ready résolue (le moteur chargé :
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
* nouvelle version attend.
*/
export function startUpdates(
script: URL,
ready: Promise<unknown>,
onAvailable: () => void,
): Updates {
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
let registration: ServiceWorkerRegistration | null = null;
let waiting: ServiceWorker | null = null;
/** Une version installée attend, et une version plus ancienne a la
* main : c'est une mise à jour (à la toute première installation,
* aucune page n'est encore contrôlée, et rien n'attend). */
const notice = (worker: ServiceWorker | null) => {
if (worker !== null && worker.state === "installed" && container?.controller) {
waiting = worker;
onAvailable();
}
};
/** Suit une version en cours d'installation ; résolue quand elle est
* installée (vrai) ou abandonnée (faux). */
const follow = (worker: ServiceWorker): Promise<boolean> =>
new Promise((resolve) => {
const settle = () => {
if (worker.state === "installed" || worker.state === "activated") {
notice(worker);
resolve(true);
} else if (worker.state === "redundant") {
resolve(false);
}
};
worker.addEventListener("statechange", settle);
settle();
});
if (container !== null) {
ready
.then(() => container.register(script))
.then((reg) => {
registration = reg;
notice(reg.waiting);
if (reg.installing !== null) {
void follow(reg.installing);
}
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) {
void follow(reg.installing);
}
});
})
.catch(() => {
// Refusé (http hors localhost) : l'application marche sans.
});
}
return {
get available() {
return waiting !== null;
},
async check() {
const reg = registration;
if (reg === null) {
return "unavailable";
}
try {
await reg.update();
} catch {
return "unavailable";
}
if (reg.waiting !== null) {
notice(reg.waiting);
return "found";
}
if (reg.installing !== null) {
return (await follow(reg.installing)) ? "found" : "latest";
}
return "latest";
},
apply() {
const worker = waiting ?? registration?.waiting ?? null;
if (container === null || worker === null) {
return;
}
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!reloading) {
reloading = true;
location.reload();
}
});
worker.postMessage({ type: SKIP_WAITING });
},
};
}