Une adresse pour chaque écran, de vrais liens pour y aller

Routeur pur (fragment vers écran et inversement) et navigation sur
hashchange : aide et ses questions, glossaire et ses termes, à propos,
menu, mes parties et chaque partie, vue d'une position avec « Jouer à
partir d'ici » et « Retour à ma partie », « Copier le lien de la
position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 21:21:45 +01:00
co-authored by Claude Opus 5.5
parent 43031d1ec6
commit f4914b30ab
11 changed files with 1441 additions and 162 deletions
+10 -2
View File
@@ -6,6 +6,7 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { exportSgf } from "./domain/game/sgf.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
@@ -35,7 +36,10 @@ const opening = (async () => {
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
const session = createGameSession(
library,
{ start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) },
{
start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
resume: (journal) => Game.resume(engine, journal),
},
"2ply",
);
const saved = await session.restore();
@@ -57,7 +61,11 @@ startApp({
stored = opened;
}),
]),
newGame: () => storage().session.newGame(),
newGame: (setup) => storage().session.newGame(setup),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
},
savedGame: () => storage().saved,
resumePrevious: () => storage().session.resumePrevious(),
saveGame: (journal) => storage().session.save(journal),
+406 -64
View File
@@ -14,11 +14,18 @@
// d'information, consigne, barre d'actions) ; en paysage, le plateau est
// l'écran et porte ses commandes (ADR 0012). Toutes les actions passent par
// une même table (actions), qu'on les déclenche au toucher ou au clavier.
//
// Navigation (ADR 0013, amendement du 2026-11-03) : chaque écran a son
// adresse, et l'adresse est la source de vérité. On change d'écran par de
// vrais liens (aide, glossaire, menu, mes parties, position) ; applyRoute
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
// changement. Les actions de jeu restent des boutons.
import type { Cube } from "../domain/game/cube.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
import {
@@ -44,8 +51,10 @@ import { GAMES_TEXTS } from "./games-texts.js";
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 { loadGlossaryEntry, showHelp } from "./help.js";
import { createHelpView, loadGlossaryEntry } from "./help.js";
import { icon } from "./icons.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 { type PromptText, TEXTS } from "./texts.js";
@@ -67,8 +76,11 @@ export interface CachedBoard {
export interface AppOptions {
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
readonly engineReady: Promise<unknown>;
/** Une nouvelle partie (le jet d'ouverture est déjà tiré). */
readonly newGame: () => Game;
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
* une position donnée (setup). */
readonly newGame: (setup?: GameSetup) => Game;
/** Les liens vers une position (ADR 0013). */
readonly positions: PositionsAccess;
/** La partie enregistrée, ou null s'il n'y en a pas. */
readonly savedGame: () => Game | null;
/** « Reprendre l'ancienne » : la partie quittée par la dernière
@@ -99,6 +111,14 @@ export interface SavedGamesAccess {
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
}
/** Ce que la vue d'une position demande au domaine et au moteur. */
export interface PositionsAccess {
/** La position d'un lien, lue par le moteur (erreur si illisible). */
readonly read: (link: PositionLink) => Promise<SharedPosition>;
/** Le lien de la position d'une partie. */
readonly linkOf: (game: Game) => Promise<PositionLink>;
}
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element === null) {
@@ -162,21 +182,32 @@ export function startApp(options: AppOptions): void {
again: byId<HTMLButtonElement>("again"),
moveHint: byId("move-hint"),
replay: byId<HTMLButtonElement>("replay"),
helpButton: byId<HTMLButtonElement>("help-button"),
copyLink: byId<HTMLButtonElement>("copy-link"),
helpButton: byId<HTMLAnchorElement>("help-button"),
fullscreenButton: byId<HTMLButtonElement>("fullscreen-button"),
menuButton: byId<HTMLButtonElement>("menu-button"),
helpBack: byId<HTMLButtonElement>("help-back"),
menuButton: byId<HTMLAnchorElement>("menu-button"),
helpBack: byId<HTMLAnchorElement>("help-back"),
helpTabs: byId("help-tabs"),
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
position: byId("position-view"),
positionTitle: byId("position-title"),
positionMessage: byId("position-message"),
positionBoard: byId("position-board") as unknown as SVGSVGElement,
positionDetail: byId("position-detail"),
positionBack: byId<HTMLAnchorElement>("position-back"),
positionPlay: byId<HTMLButtonElement>("position-play"),
positionCopy: byId<HTMLButtonElement>("position-copy"),
menuVeil: byId("menu-veil"),
menu: byId("menu"),
menuTitle: byId("menu-title"),
menuClose: byId<HTMLButtonElement>("menu-close"),
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
menuGames: byId<HTMLButtonElement>("menu-games"),
menuGames: byId<HTMLAnchorElement>("menu-games"),
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
menuHelp: byId<HTMLButtonElement>("menu-help"),
menuHelp: byId<HTMLAnchorElement>("menu-help"),
menuTheme: byId<HTMLButtonElement>("menu-theme"),
menuVibrations: byId<HTMLButtonElement>("menu-vibrations"),
menuScreenOn: byId<HTMLButtonElement>("menu-screen-on"),
@@ -218,7 +249,8 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------- Textes fixes
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
const label = (button: HTMLButtonElement, text: string, name?: Parameters<typeof icon>[0]) => {
ui.positionBoard.setAttribute("aria-label", TEXTS.boardLabel);
const label = (button: HTMLElement, text: string, name?: Parameters<typeof icon>[0]) => {
button.replaceChildren();
if (name !== undefined) {
button.append(icon(name));
@@ -234,6 +266,9 @@ export function startApp(options: AppOptions): void {
ui.replay.append(icon("replay"));
ui.replay.setAttribute("aria-label", TEXTS.replayTitle);
ui.replay.title = TEXTS.replayTitle;
ui.copyLink.append(icon("copy"));
ui.copyLink.setAttribute("aria-label", TEXTS.copyPositionLink);
ui.copyLink.title = TEXTS.copyPositionLink;
ui.roll.textContent = TEXTS.roll;
ui.double.textContent = TEXTS.double;
ui.take.textContent = TEXTS.take;
@@ -246,11 +281,20 @@ export function startApp(options: AppOptions): void {
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
ui.helpBack.textContent = TEXTS.back;
ui.helpTabs.setAttribute("aria-label", TEXTS.helpTabs);
for (const tab of ui.helpTabs.querySelectorAll<HTMLElement>("a[data-screen]")) {
const screen = tab.dataset.screen as "help" | "glossary" | "about";
tab.textContent = TEXTS.helpTabNames[screen];
}
ui.positionBack.textContent = TEXTS.backToGame;
ui.positionPlay.textContent = TEXTS.playFromHere;
label(ui.positionCopy, TEXTS.copyPositionLink, "copy");
ui.menuTitle.textContent = TEXTS.menu;
ui.menuClose.append(icon("close"));
ui.menuClose.setAttribute("aria-label", TEXTS.close);
label(ui.menuNewGame, TEXTS.newGame, "newGame");
label(ui.menuGames, GAMES_TEXTS.button, "games");
label(ui.menuCopyLink, TEXTS.copyPositionLink, "copy");
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
ui.menuTheme.prepend(icon("theme"));
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
@@ -260,7 +304,12 @@ export function startApp(options: AppOptions): void {
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
ui.bannerText.textContent = TEXTS.newGameStarted;
ui.resumeOld.textContent = TEXTS.resumeOld;
child(ui.bubble, ".link-button").textContent = TEXTS.seeGlossary;
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
glossaryLink.textContent = TEXTS.seeGlossary;
glossaryLink.href = routeHash({
screen: "glossary",
term: TEXTS.pipCountFile.replace(/\.md$/, ""),
});
// ------------------------------------------------ État de l'écran
@@ -294,6 +343,15 @@ export function startApp(options: AppOptions): void {
let immersive = false;
let rotated = false;
let bubbleSide: Side | null = null;
// Le lien de la position de la partie, recalculé à chaque mise à jour :
// « Copier le lien » écrit alors tout de suite dans le presse-papiers.
let currentLink: PositionLink | null = null;
// Un message lié à l'adresse (adresse inconnue, lien copié…), montré
// dans la ligne d'information jusqu'à la prochaine action.
let notice: string | null = null;
// La position partagée affichée (#/position/...), une fois lue.
let shownPosition: SharedPosition | null = null;
let positionRequest = 0;
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
const portraitQuery = window.matchMedia("(orientation: portrait)");
@@ -349,6 +407,7 @@ export function startApp(options: AppOptions): void {
if (game.pendingHalfMoves.length === 0) {
options.saveBoard({ sides, cube: game.state.cube });
}
currentLink = await options.positions.linkOf(game).catch(() => null);
}
/** Recalcule ce qui demande le moteur, puis redessine. */
@@ -403,7 +462,9 @@ export function startApp(options: AppOptions): void {
// Ligne d'information : un jet sans coup possible reste annoncé
// jusqu'à l'action suivante, même après un rechargement de la page.
if (stuck && state.roll !== null) {
if (notice !== null) {
ui.info.textContent = notice;
} else if (stuck && state.roll !== null) {
ui.info.textContent = TEXTS.youCannotMove(state.roll);
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
@@ -434,6 +495,8 @@ export function startApp(options: AppOptions): void {
show(ui.drop, doubled);
show(ui.again, over && panelDismissed);
show(ui.replay, shownMove !== null);
// Le coup de l'ordinateur montré : sa position se copie en lien.
show(ui.copyLink, shownMove !== null && currentLink !== null);
for (const button of [
ui.roll,
ui.double,
@@ -444,10 +507,11 @@ export function startApp(options: AppOptions): void {
ui.replay,
ui.again,
ui.menuNewGame,
ui.positionPlay,
]) {
button.disabled = busy;
}
ui.menuGames.disabled = false;
ui.menuCopyLink.disabled = currentLink === null;
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
renderPanel(game, landscape, doubled);
renderMenu();
@@ -487,12 +551,17 @@ export function startApp(options: AppOptions): void {
ui.skip,
ui.again,
ui.replay,
ui.copyLink,
]) {
button.hidden = true;
}
ui.moveHint.hidden = true;
ui.menuNewGame.disabled = true;
ui.menuGames.disabled = true;
ui.menuCopyLink.disabled = true;
ui.positionPlay.disabled = true;
if (notice !== null) {
ui.info.textContent = notice;
}
renderMenu();
}
@@ -727,6 +796,7 @@ export function startApp(options: AppOptions): void {
targets = new Map();
shownMove = null;
bubbleSide = null;
notice = null;
ui.bubble.hidden = true;
render();
try {
@@ -740,18 +810,24 @@ export function startApp(options: AppOptions): void {
}
}
function startNewGame(): void {
game = options.newGame();
function startNewGame(setup?: GameSetup): void {
game = options.newGame(setup);
panelDismissed = false;
announcedEnd = null;
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
const opening = game.events.findLast((e) => e.type === "DiceRolled");
info =
opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : "";
setup !== undefined
? TEXTS.startedFromPosition
: opening?.type === "DiceRolled"
? TEXTS.opening(opening.roll[0], opening.roll[1])
: "";
}
/** « Nouvelle partie » : tout de suite, sans confirmation ; une partie en
* cours reste reprenable pendant 10 s (interaction.md). */
function newGame(): void {
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
* position) : tout de suite, sans confirmation ; une partie en cours
* reste reprenable pendant 10 s (interaction.md). */
function newGame(setup?: GameSetup): void {
closeOverlays();
if (busy || game === null) {
return;
@@ -760,7 +836,7 @@ export function startApp(options: AppOptions): void {
if (old !== null) {
showBanner(old);
}
void act(() => startNewGame());
void act(() => startNewGame(setup));
}
function showBanner(journal: GameJournal): void {
@@ -808,6 +884,10 @@ export function startApp(options: AppOptions): void {
info = "";
}
// 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";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
gamesView = createGamesView({
@@ -820,12 +900,16 @@ export function startApp(options: AppOptions): void {
}
const other = await options.games.open(id, branchHead);
hideBanner();
leaveSheet();
await act(() => showGame(other));
},
remove: options.games.remove,
exportSgf: options.games.exportSgf,
onClose: () => leaveSheet(),
});
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
function playHalfMove(halfMove: HalfMove): void {
@@ -920,32 +1004,37 @@ export function startApp(options: AppOptions): void {
void settle(true);
},
again: () => newGame(),
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
// lien) ; le refermer, revenir à l'écran d'avant.
menu: () => {
if (ui.menuVeil.hidden) {
if (navigation.route.screen === "menu") {
closeOverlays();
renderMenu();
openOverlay(ui.menuVeil, ui.menuNewGame);
} else {
closeOverlays();
closeTransient();
navigation.go("#/menu");
}
},
help: () => {
closeOverlays();
ui.game.hidden = true;
ui.help.hidden = false;
ui.root.classList.add("help-open");
window.scrollTo(0, 0);
void showHelp(ui.helpContent);
closeTransient();
navigation.go("#/aide");
},
fullscreen: () => {
closeOverlays();
void (immersive ? exitImmersive() : enterImmersive());
},
// Échap : la confirmation ou la bulle ouverte, sinon l'écran courant
// (feuille, aide, position), comme le retour du téléphone.
close: () => {
if (!ui.help.hidden) {
closeHelp();
} else {
closeOverlays();
const route = navigation.route;
if (!ui.doubleVeil.hidden || !ui.bubble.hidden) {
closeTransient();
render();
} else if (isSheet(route)) {
leaveSheet();
} else if (isHelpScreen(route)) {
navigation.leave(isHelpScreen);
} else if (route.screen === "position") {
navigation.leave((r) => r.screen === "position");
}
},
};
@@ -959,12 +1048,10 @@ export function startApp(options: AppOptions): void {
focus.focus();
}
/** Ferme le menu, la confirmation du double et la bulle ; ne touche
* jamais à la partie. */
function closeOverlays(): void {
const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden;
gamesView?.close();
ui.menuVeil.hidden = true;
/** Ferme la confirmation du double et la bulle, qui n'ont pas
* d'adresse ; ne touche jamais à la partie. */
function closeTransient(): void {
const open = !ui.doubleVeil.hidden;
ui.doubleVeil.hidden = true;
bubbleSide = null;
ui.bubble.hidden = true;
@@ -973,24 +1060,40 @@ export function startApp(options: AppOptions): void {
}
}
function closeHelp(): void {
ui.help.hidden = true;
ui.game.hidden = false;
ui.root.classList.remove("help-open");
render();
/** Ferme aussi le menu ou « Mes parties » : on quitte leur adresse. */
function closeOverlays(): void {
closeTransient();
if (isSheet(navigation.route)) {
leaveSheet();
}
}
for (const veil of [ui.menuVeil, ui.doubleVeil]) {
veil.addEventListener("click", (event) => {
if (event.target === veil) {
closeOverlays();
}
});
/** Quitte le menu ou « Mes parties » : la feuille se ferme tout de
* suite, l'adresse suit (le retour dans l'historique est asynchrone). */
function leaveSheet(): void {
const menuWasOpen = !ui.menuVeil.hidden;
ui.menuVeil.hidden = true;
gamesView?.hide();
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
}
navigation.leave(isSheet);
}
ui.doubleCancel.onclick = () => closeOverlays();
ui.menuVeil.addEventListener("click", (event) => {
if (event.target === ui.menuVeil) {
closeOverlays();
}
});
ui.doubleVeil.addEventListener("click", (event) => {
if (event.target === ui.doubleVeil) {
closeTransient();
}
});
ui.doubleCancel.onclick = () => closeTransient();
ui.doubleConfirm.onclick = () => {
closeOverlays();
closeTransient();
void act(async (g) => {
await g.double();
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
@@ -1015,21 +1118,221 @@ export function startApp(options: AppOptions): void {
// --------------------------------------------------------- Menu
ui.menuButton.onclick = actions.menu;
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
// brancher. Les liens du menu remplacent son adresse : le retour du
// téléphone ne rouvre pas le menu.
replacingLink(ui.menuGames, navigation);
replacingLink(ui.menuHelp, navigation);
ui.menuClose.onclick = () => closeOverlays();
ui.menuNewGame.onclick = () => newGame();
ui.menuGames.onclick = () => {
ui.menuCopyLink.onclick = () => {
const link = currentLink;
closeOverlays();
void gamesView?.open();
if (link !== null) {
void copyLink(link);
}
};
ui.menuFullscreen.onclick = actions.fullscreen;
ui.menuHelp.onclick = actions.help;
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
ui.helpButton.onclick = actions.help;
ui.helpBack.onclick = closeHelp;
ui.fullscreenButton.onclick = actions.fullscreen;
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
// ouverte par son adresse).
leavingLink(ui.helpBack, isHelpScreen);
leavingLink(ui.positionBack, (route) => route.screen === "position");
/** Un lien « Retour » : il revient dans l'historique plutôt que d'y
* empiler la partie (sauf ouvert dans un nouvel onglet). */
function leavingLink(link: HTMLAnchorElement, stay: (route: Route) => boolean): void {
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
navigation.leave(stay);
});
}
// ----------------------------------------------------------- Adresses
let menuReturnFocus: HTMLElement | null = null;
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
function applyRoute(route: Route): void {
const help = isHelpScreen(route);
const position = route.screen === "position";
const wasPage = ui.game.hidden;
ui.game.hidden = help || position;
ui.help.hidden = !help;
ui.position.hidden = !position;
ui.root.classList.toggle("page-open", help || position);
if (wasPage && !ui.game.hidden) {
window.scrollTo(0, 0);
}
// Feuilles : le menu et « Mes parties ».
const menuOpen = route.screen === "menu";
if (menuOpen && ui.menuVeil.hidden) {
closeTransient();
menuReturnFocus =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
renderMenu();
ui.menuVeil.hidden = false;
ui.menuNewGame.focus();
} else if (!menuOpen && !ui.menuVeil.hidden) {
ui.menuVeil.hidden = true;
if (route.screen === "game") {
(menuReturnFocus ?? ui.menuButton).focus();
}
}
if (route.screen === "games") {
const expanded = route.game;
// La liste vient du stockage, ouvert avec le moteur.
void options.engineReady.then(
() => {
const now = navigation.route;
if (now.screen === "games" && now.game === expanded) {
void gamesView?.show(expanded);
}
},
() => undefined,
);
} else {
gamesView?.hide();
}
if (help) {
void helpView.show(route);
}
if (position) {
void showPosition(route);
} else {
positionRequest++;
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;
navigation.replace("#/");
return;
}
render();
}
// -------------------------------------------- Vue d'une position
/** #/position/... : lit la position par le moteur, puis la montre. */
async function showPosition(route: Extract<Route, { screen: "position" }>): Promise<void> {
const request = ++positionRequest;
shownPosition = null;
renderPosition({ kind: "loading" });
try {
await options.engineReady;
const shared = await options.positions.read({
position: route.position,
matchId: route.match,
});
if (request === positionRequest) {
shownPosition = shared;
renderPosition({ kind: "shown", shared });
}
} catch {
if (request === positionRequest) {
renderPosition({ kind: "unreadable" });
}
}
}
type PositionState =
| { kind: "loading" }
| { kind: "unreadable" }
| { kind: "shown"; shared: SharedPosition };
function renderPosition(state: PositionState): void {
const shown = state.kind === "shown";
ui.positionTitle.textContent =
state.kind === "unreadable" ? TEXTS.unreadablePositionTitle : TEXTS.positionTitle;
ui.positionMessage.textContent =
state.kind === "loading"
? TEXTS.prompts.loading.short
: state.kind === "unreadable"
? TEXTS.unreadablePosition
: "";
ui.positionBoard.toggleAttribute("hidden", !shown);
ui.positionDetail.hidden = !shown;
ui.positionCopy.hidden = !shown;
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
if (state.kind !== "shown") {
return;
}
const shared = state.shared;
const width = ui.positionBoard.clientWidth || Math.min(window.innerWidth, 640);
const model = boardModel(shared.sides);
renderBoard(
ui.positionBoard,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
model,
{
dice: [],
cube: shared.cube,
...NO_MARKS,
band: null,
controls: null,
},
);
ui.positionDetail.textContent = TEXTS.positionDetail(
model.pipCount,
shared.cube,
shared.match,
shared.result === null
? null
: shared.result.winner === "onRoll"
? "player"
: "computer",
);
}
ui.positionPlay.onclick = () => {
const shared = shownPosition;
if (shared === null || busy || game === null) {
return;
}
// La nouvelle partie est l'écran de jeu, à la place de la position
// (le retour du téléphone ne rouvre pas la position).
navigation.replace("#/");
newGame(setupOf(shared));
};
ui.positionCopy.onclick = () => {
if (shownPosition !== null) {
void copyLink(shownPosition.link, ui.positionMessage);
}
};
ui.copyLink.onclick = () => {
if (currentLink !== null) {
void copyLink(currentLink);
}
};
/** Copie l'adresse complète d'une position ; le résultat s'affiche dans
* target (par défaut, la ligne d'information de la partie). */
async function copyLink(link: PositionLink, target?: HTMLElement): Promise<void> {
const url = new URL(location.href);
url.hash = routeHash({
screen: "position",
position: link.position,
match: link.matchId,
});
const text = (await copyText(url.href)) ? TEXTS.linkCopied : TEXTS.linkNotCopied(url.href);
if (target === undefined) {
notice = text;
render();
} else {
target.textContent = text;
}
}
// ------------------------------------------------- Plein écran (ADR 0012)
@@ -1100,6 +1403,9 @@ export function startApp(options: AppOptions): void {
if (!animating) {
render();
}
if (shownPosition !== null) {
renderPosition({ kind: "shown", shared: shownPosition });
}
});
};
window.addEventListener("resize", relayout);
@@ -1130,7 +1436,6 @@ export function startApp(options: AppOptions): void {
for (const side of ["computer", "player"] as const) {
ui.strips[side].count.onclick = () => void toggleBubble(side);
}
child<HTMLButtonElement>(ui.bubble, ".link-button").onclick = actions.help;
// ------------------------------------------------------ Touchers
@@ -1153,6 +1458,10 @@ export function startApp(options: AppOptions): void {
closeOverlays();
render();
}
// Un lien du plateau (le menu, en paysage) : le navigateur le suit.
if (target?.closest("a[href]") != null) {
return;
}
const action = target?.closest("[data-action]")?.getAttribute("data-action");
if (action != null && action in actions) {
actions[action as ActionName]();
@@ -1184,7 +1493,8 @@ export function startApp(options: AppOptions): void {
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
return;
}
const onButton = event.target instanceof HTMLButtonElement;
const onButton =
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
let name: ActionName | null = null;
switch (event.key) {
case " ":
@@ -1224,6 +1534,11 @@ export function startApp(options: AppOptions): void {
name = "close";
break;
}
// Hors de l'écran de jeu (aide, position), seules les touches de
// navigation servent.
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
name = null;
}
if (name !== null) {
event.preventDefault();
actions[name]();
@@ -1232,8 +1547,9 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive.
render();
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
// ou l'écran que désigne l'adresse.
applyRoute(navigation.route);
options.engineReady.then(
async () => {
game = options.savedGame();
@@ -1267,6 +1583,32 @@ export function startApp(options: AppOptions): void {
// ------------------------------------------------- Lecture de la partie
/** Écrit un texte dans le presse-papiers ; faux si le navigateur refuse.
* Hors contexte sûr (http sur le réseau local), navigator.clipboard
* n'existe pas : repli sur une zone de texte sélectionnée, hors de l'écran. */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Repli ci-dessous.
}
const area = document.createElement("textarea");
area.value = text;
area.readOnly = true;
area.className = "offscreen";
document.body.append(area);
area.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
area.remove();
return copied;
}
/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */
function isPlaying(game: Game): boolean {
const phase = game.state.phase;
+20 -1
View File
@@ -532,7 +532,26 @@ function drawControls(
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
});
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
if (on) {
if (!on) {
return;
}
if (action === "menu") {
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
// la barre haute du portrait.
const link = add(hits, "a", {
href: "#/menu",
"data-action": action,
role: "link",
"aria-label": label,
});
add(link, "rect", {
class: "hit action",
x: Math.max(0, x - g.target / 2),
y: Math.max(0, y - g.target / 2),
width: g.target,
height: g.target,
});
} else {
addAction(g, hits, action, label, { x, y }, g.target);
}
};
+63 -34
View File
@@ -8,12 +8,17 @@
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
//
// La feuille a son adresse (#/parties), et chaque partie la sienne
// (#/parties/<id>, ses branches dépliées) : l'écran de jeu l'ouvre et la
// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien.
import { branches } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { GAMES_TEXTS } from "./games-texts.js";
import { icon } from "./icons.js";
import { routeHash } from "./router.js";
export interface GamesViewOptions {
/** Les parties, la dernière jouée d'abord. */
@@ -23,17 +28,21 @@ export interface GamesViewOptions {
/** L'identifiant de la partie affichée à l'écran. */
readonly shownId: () => string | null;
/** Reprend une partie, au bout de la branche branchHead si elle est
* donnée (sinon là où elle en était). */
* donnée (sinon là où elle en était), et quitte la feuille. */
readonly resume: (id: string, branchHead?: string) => Promise<void>;
readonly remove: (id: string) => Promise<void>;
/** Le fichier SGF d'une partie : son nom et son contenu. */
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
/** La croix ou le voile touché : quitter la feuille (l'écran de jeu
* change d'adresse, puis appelle hide). */
readonly onClose: () => void;
}
export interface GamesView {
/** Ouvre la liste, à jour. */
open(): Promise<void>;
close(): void;
/** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les
* branches de la partie expanded dépliées. */
show(expanded: string | null): Promise<void>;
hide(): void;
readonly isOpen: boolean;
}
@@ -51,7 +60,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
const title = element("h2", "sheet-title");
title.id = "games-title";
title.textContent = GAMES_TEXTS.title;
const close = button("", () => hide(), "icon-button");
const close = button("", () => options.onClose(), "icon-button");
close.id = "games-close";
close.setAttribute("aria-label", GAMES_TEXTS.close);
close.append(icon("close"));
@@ -64,13 +73,12 @@ export function createGamesView(options: GamesViewOptions): GamesView {
document.body.append(veil);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
hide();
options.onClose();
}
});
function hide(): void {
veil.hidden = true;
}
// La partie dont les branches sont dépliées (adresse #/parties/<id>).
let expanded: string | null = null;
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => async () => {
@@ -110,24 +118,11 @@ export function createGamesView(options: GamesViewOptions): GamesView {
actions.append(
button(
GAMES_TEXTS.resume,
run(async () => {
await options.resume(game.id);
hide();
}),
run(() => options.resume(game.id)),
"",
"resume",
),
button(
GAMES_TEXTS.showBranches,
run(async () => {
if (branchList.hidden) {
await renderBranches(game.id, branchList);
}
branchList.hidden = !branchList.hidden;
}),
"secondary",
"branches",
),
branchesLink(game.id),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
@@ -149,6 +144,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
}
await options.remove(game.id);
await render();
await expand();
}),
"secondary",
"remove",
@@ -164,6 +160,17 @@ export function createGamesView(options: GamesViewOptions): GamesView {
return item;
}
/** « Branches » : le lien qui déplie les branches d'une partie (ou les
* replie, quand elles le sont déjà). */
function branchesLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.showBranches;
link.dataset.action = "branches";
link.href = routeHash({ screen: "games", game: id });
link.setAttribute("aria-expanded", "false");
return link;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
@@ -182,10 +189,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
text,
button(
GAMES_TEXTS.resumeBranch,
run(async () => {
await options.resume(id, branch.head);
hide();
}),
run(() => options.resume(id, branch.head)),
"secondary",
"resume-branch",
),
@@ -195,14 +199,39 @@ export function createGamesView(options: GamesViewOptions): GamesView {
);
}
/** Déplie les branches de la partie expanded, replie les autres. */
async function expand(): Promise<void> {
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
const id = item.dataset.id ?? "";
const open = id === expanded;
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
link?.setAttribute("aria-expanded", String(open));
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
const branchList = item.querySelector<HTMLElement>(".games-branches");
if (branchList === null) {
continue;
}
if (open && branchList.hidden) {
await run(() => renderBranches(id, branchList))();
}
branchList.hidden = !open;
}
}
return {
async open() {
message.textContent = "";
await render();
veil.hidden = false;
close.focus();
async show(next) {
expanded = next;
if (veil.hidden) {
message.textContent = "";
veil.hidden = false;
await run(render)();
close.focus();
}
await expand();
},
hide() {
veil.hidden = true;
},
close: hide,
get isOpen() {
return !veil.hidden;
},
+182 -48
View File
@@ -1,23 +1,31 @@
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
// (ADR 0008). Rien n'est recopié dans le code.
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
// dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// 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).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.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 = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -30,10 +38,19 @@ async function fetchText(url: string): Promise<string> {
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
/** Une fiche du glossaire et son nom de fichier, sans .md. */
interface GlossaryCard extends GlossaryEntry {
readonly name: string;
}
async function loadGlossary(): Promise<GlossaryCard[]> {
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
return texts.flatMap((text, i) => {
const entry = parseGlossaryEntry(text);
const name = (files[i] ?? "").replace(/\.md$/, "");
return entry === null ? [] : [{ ...entry, name }];
});
}
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
@@ -55,56 +72,173 @@ function element<K extends keyof HTMLElementTagNameMap>(
return child;
}
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
element(parent, "h2", feature.title);
for (const paragraph of feature.description) {
element(parent, "p", paragraph);
}
for (const scenario of feature.scenarios) {
element(parent, "h3", scenario.title);
for (const paragraph of scenario.description) {
element(parent, "p", paragraph);
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
interface Question {
readonly id: string;
readonly section: HTMLElement;
readonly link: HTMLAnchorElement;
readonly answer: HTMLElement;
}
export interface HelpView {
/** Montre l'onglet, la question ou le terme que désigne l'adresse
* (l'aide est lue au premier appel). */
show(route: HelpRoute): Promise<void>;
}
/**
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
*/
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
const play = document.createElement("div");
play.id = "help-play";
const glossary = document.createElement("div");
glossary.id = "help-glossary";
const about = document.createElement("div");
about.id = "help-about";
const notice = document.createElement("p");
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, HTMLElement>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
let id = slug(scenario.title);
for (let n = 2; questions.some((q) => q.id === id); n++) {
id = `${slug(scenario.title)}-${n}`;
}
const steps = element(parent, "ul");
const section = element(parent, "section");
section.className = "question";
section.id = `aide-${id}`;
const heading = element(section, "h3");
const link = element(heading, "a", scenario.title);
link.href = routeHash({ screen: "help", question: id });
link.setAttribute("aria-controls", `aide-${id}-reponse`);
link.setAttribute("aria-expanded", "false");
const answer = element(section, "div");
answer.id = `aide-${id}-reponse`;
answer.className = "answer";
answer.hidden = true;
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);
}
questions.push({ id, section, link, answer });
}
}
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
element(parent, "h2", TEXTS.glossaryTitle);
const list = element(parent, "dl");
list.className = "glossary";
for (const entry of entries) {
element(list, "dt", entry.term);
element(list, "dd", entry.brief);
}
}
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
* font rien). */
export async function showHelp(container: HTMLElement): Promise<void> {
if (container.dataset.loaded === "true") {
return;
}
container.replaceChildren();
const status = element(container, "p", TEXTS.helpLoading);
try {
const [features, glossary] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
status.remove();
for (const feature of features) {
renderFeature(container, feature);
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph);
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
}
renderGlossary(container, glossary);
const about = element(container, "p", TEXTS.about);
about.className = "about";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
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);
link.href = routeHash({ screen: "glossary", term: entry.name });
element(list, "dd", entry.brief);
terms.set(entry.name, term);
}
}
function renderAbout(): void {
element(about, "h2", TEXTS.aboutTitle);
const text = element(about, "p", TEXTS.about);
text.className = "about";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout();
notice.textContent = "";
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
} catch {
notice.textContent = TEXTS.helpFailed;
loading = null; // on réessaiera à la prochaine ouverture
return false;
}
}
return {
async show(route) {
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
if (tab.dataset.screen === route.screen) {
tab.setAttribute("aria-current", "page");
} else {
tab.removeAttribute("aria-current");
}
}
loading ??= load();
if (!(await loading)) {
return;
}
play.hidden = route.screen !== "help";
glossary.hidden = route.screen !== "glossary";
about.hidden = route.screen !== "about";
const question = route.screen === "help" ? route.question : null;
for (const q of questions) {
const open = q.id === question;
q.answer.hidden = !open;
q.link.setAttribute("aria-expanded", String(open));
// Le lien d'une question ouverte la referme.
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, dt] of terms) {
dt.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
} else {
dt.removeAttribute("aria-current");
}
}
const missing =
(question !== null && !questions.some((q) => q.id === question)) ||
(term !== null && !terms.has(term));
notice.textContent = missing ? TEXTS.helpNotFound : "";
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));
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
closed.section.scrollIntoView({ block: "nearest" });
} else {
window.scrollTo(0, 0);
}
},
};
}
+140
View File
@@ -0,0 +1,140 @@
// La navigation entre les écrans : l'adresse de la page est la source de
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
// dessine l'écran que désigne l'adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
// l'onglet garde, dans history.state, la suite des adresses visitées
// jusqu'à elle. Elle survit au rechargement de la page.
import { parseRoute, type Route, stepsBack } from "./router.js";
/** Au plus autant d'adresses gardées par entrée de l'historique. */
const TRAIL_MAX = 50;
export interface Navigation {
/** L'écran que désigne l'adresse actuelle. */
readonly route: Route;
/** Va à une adresse, comme un lien (empile une entrée). */
go(hash: string): void;
/** Remplace l'adresse actuelle, sans empiler. */
replace(hash: string): void;
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
* nouvel onglet), remplace l'adresse par celle de la partie. */
leave(stay: (route: Route) => boolean): void;
}
interface TrailState {
readonly ustagammonTrail: readonly string[];
}
function trailOf(state: unknown): readonly string[] | null {
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
return Array.isArray(trail) ? trail : null;
}
/** Le fragment actuel, « #/ » s'il est vide. */
function currentHash(): string {
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
}
/**
* Branche la navigation sur la page : onChange est appelé à chaque
* changement d'adresse (pas au démarrage : l'appelant lit route).
*/
export function startNavigation(onChange: (route: Route) => void): Navigation {
let trail: readonly string[] = [];
const remember = (next: readonly string[]) => {
trail = next.slice(-TRAIL_MAX);
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
} catch {
// Historique indisponible (page dans un cadre) : la navigation
// marche quand même, « Retour » ramène alors à la partie.
}
};
// L'entrée actuelle : déjà connue (rechargement), ou la première.
const known = trailOf(history.state);
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
window.addEventListener("hashchange", () => {
const hash = currentHash();
const saved = trailOf(history.state);
if (saved !== null && saved.at(-1) === hash) {
// Retour ou avance dans l'historique : une entrée déjà connue.
trail = saved;
} else {
// Un lien suivi : une nouvelle entrée.
remember([...trail, hash]);
}
onChange(parseRoute(hash));
});
function replace(hash: string): void {
if (hash === currentHash()) {
return;
}
const url = new URL(location.href);
url.hash = hash;
const next = [...trail.slice(0, -1), hash];
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
} catch {
location.replace(url);
return;
}
trail = next;
// replaceState ne déclenche pas hashchange.
onChange(parseRoute(hash));
}
return {
get route() {
return parseRoute(currentHash());
},
go(hash) {
if (hash !== currentHash()) {
location.hash = hash;
}
},
replace,
leave(stay) {
const steps = stepsBack(trail, stay);
if (steps === null) {
replace("#/");
} else {
history.go(steps);
}
},
};
}
/**
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
* du milieu ou une touche, il reste un lien ordinaire.
*/
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
link.addEventListener("click", (event) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
navigation.replace(new URL(link.href).hash);
});
}
+191
View File
@@ -0,0 +1,191 @@
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
// testées sous Node (app/tests/router.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un écran de l'application, tel que le désigne son adresse. */
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil (écran à venir, étape 0e.5). */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, et une étape : #/tutoriel/<n> (écran à venir). */
| { readonly screen: "tutorial"; readonly step: number | null }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
| { readonly screen: "menu" }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
| { readonly screen: "unknown"; readonly hash: string };
export type Screen = Route["screen"];
export const GAME_ROUTE: Route = { screen: "game" };
/** Longueur d'un Position ID de gnubg. */
const POSITION_ID_LENGTH = 14;
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
* chiffres et tirets. */
const NAME = /^[a-z0-9][a-z0-9-]*$/;
/** L'écran que désigne un fragment (avec ou sans # au début). */
export function parseRoute(hash: string): Route {
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
if (fragment === "" || fragment === "/") {
return GAME_ROUTE;
}
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
if (!fragment.startsWith("/")) {
return unknown;
}
const [head = "", ...rest] = fragment.slice(1).split("/");
// Une barre finale (#/aide/) ne change rien.
if (rest.at(-1) === "") {
rest.pop();
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
const name = rest[0] ?? null;
if (name !== null && !NAME.test(name)) {
return unknown;
}
switch (head) {
case "":
return name === null ? GAME_ROUTE : unknown;
case "accueil":
return name === null ? { screen: "home" } : unknown;
case "aide":
return { screen: "help", question: name };
case "glossaire":
return { screen: "glossary", term: name };
case "a-propos":
return name === null ? { screen: "about" } : unknown;
case "menu":
return name === null ? { screen: "menu" } : unknown;
case "parties":
return { screen: "games", game: name };
case "tutoriel": {
if (name === null) {
return { screen: "tutorial", step: null };
}
const step = Number(name);
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
}
default:
return unknown;
}
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
* comprise aussi : le texte est décodé en entier, puis découpé à la
* longueur du Position ID. Leur validité est l'affaire du moteur.
*/
function positionRoute(text: string): Route | null {
let decoded: string;
try {
decoded = decodeURIComponent(text);
} catch {
return null;
}
if (decoded === "") {
return null;
}
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
const match = decoded.slice(POSITION_ID_LENGTH + 1);
return {
screen: "position",
position: decoded.slice(0, POSITION_ID_LENGTH),
match: match === "" ? null : match,
};
}
return { screen: "position", position: decoded, match: null };
}
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
case "home":
return "#/accueil";
case "help":
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
case "glossary":
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null ? "#/tutoriel" : `#/tutoriel/${route.step}`;
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "menu":
return "#/menu";
case "position": {
const position = `#/position/${encodeURIComponent(route.position)}`;
return route.match === null
? position
: `${position}/${encodeURIComponent(route.match)}`;
}
case "unknown":
return route.hash;
}
}
/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */
export type HelpRoute = Extract<Route, { screen: "help" | "glossary" | "about" }>;
/** L'adresse est-elle celle d'un écran de l'aide ? */
export function isHelpScreen(route: Route): route is HelpRoute {
return route.screen === "help" || route.screen === "glossary" || route.screen === "about";
}
/** L'identifiant d'une question de l'aide : son titre, en minuscules sans
* accents, les mots séparés par des tirets. */
export function slug(title: string): string {
return title
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Où revenir en quittant un écran (« Retour », Échap, la croix d'une
* feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas
* de cet écran. trail est la suite des adresses de l'onglet jusqu'à
* l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou
* null si aucune adresse ne convient : il faut alors remplacer l'adresse
* par celle de la partie, sans rien empiler.
*/
export function stepsBack(
trail: readonly string[],
stay: (route: Route) => boolean,
): number | null {
for (let i = trail.length - 2; i >= 0; i--) {
const hash = trail[i];
if (hash !== undefined && !stay(parseRoute(hash))) {
return i - (trail.length - 1);
}
}
return null;
}
+50 -1
View File
@@ -153,8 +153,57 @@ export const TEXTS = {
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpTabs: "Rubriques de l'aide",
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
glossaryTitle: "Les mots du jeu",
aboutTitle: "À propos",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
// 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",
linkCopied: "Lien de la position copié.",
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
positionTitle: "Position partagée",
unreadablePositionTitle: "Lien illisible",
unreadablePosition:
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
backToGame: "Retour à ma partie",
playFromHere: "Jouer à partir d'ici",
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
/** Ce que montre la vue d'une position, sous le plateau. */
positionDetail: (
pips: Readonly<Record<"player" | "computer", number>>,
cube: { value: number; owner: "player" | "computer" | null },
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
winner: "player" | "computer" | null,
) =>
[
winner === null
? "Vous avez la main : vos pions ivoire, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
cube.value > 1 || cube.owner !== null
? `Videau à ${cube.value}, ${
cube.owner === null
? "au centre"
: cube.owner === "player"
? "chez vous"
: "chez l'ordinateur"
}.`
: "",
match === null
? ""
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
]
.filter((part) => part !== "")
.join(" "),
} as const;