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:
co-authored by
Claude Opus 5.5
parent
43031d1ec6
commit
f4914b30ab
+406
-64
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user