Plateau jouable en PWA : interface, Web Worker, stockage, service worker

L'application est à la racine du site : plateau en SVG, saisie au toucher
(pion puis case d'arrivée, proposées à partir du moteur), boutons du jet,
du videau et de nouvelle partie, coup de l'ordinateur affiché avec son
temps, fin de partie, reprise depuis localStorage, aide lue dans les
copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts
est le seul assembleur (nouvelles règles dependency-cruiser). Le service
worker met tout le site en cache, avec une empreinte du contenu comme
version. Les pages de test du moteur passent sous /moteur/. Politique de
sécurité du contenu dans le Caddyfile ; make test-browser joue une partie
dans Chromium et Firefox sans écran (Playwright, hors de make test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 21:47:12 +01:00
co-authored by Claude Opus 5.5
parent 613ff38254
commit a8636b3f83
37 changed files with 2548 additions and 106 deletions
+373
View File
@@ -0,0 +1,373 @@
// L'écran de jeu : le plateau, les boutons et les messages, branchés sur une
// partie du domaine.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout passe par la partie (Game) : l'interface ne connaît ni le moteur ni le
// stockage, qu'elle reçoit déjà branchés (main.ts). Après chaque action du
// joueur, la partie est enregistrée, puis l'ordinateur joue tant que c'est
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
import type { GameJournal } from "../domain/game/events.js";
import type { Game, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
import { renderBoard, type ShownDie } from "./board-view.js";
import { showHelp } from "./help.js";
import { TEXTS } from "./texts.js";
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;
/** La partie enregistrée, ou null s'il n'y en a pas. */
readonly savedGame: () => Game | null;
readonly saveGame: (journal: GameJournal) => void;
readonly confirm: (question: string) => boolean;
}
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element === null) {
throw new Error(`élément #${id} absent de la page`);
}
return element as T;
}
/** Démarre l'écran de jeu dans la page (index.html). */
export function startApp(options: AppOptions): void {
const ui = {
board: byId("board") as unknown as SVGSVGElement,
info: byId("info"),
prompt: byId("prompt"),
pips: byId("pips"),
roll: byId<HTMLButtonElement>("roll"),
double: byId<HTMLButtonElement>("double"),
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
cancel: byId<HTMLButtonElement>("cancel"),
again: byId<HTMLButtonElement>("again"),
newGame: byId<HTMLButtonElement>("new-game"),
helpButton: byId<HTMLButtonElement>("help-button"),
helpBack: byId<HTMLButtonElement>("help-back"),
game: byId("game"),
help: byId("help"),
helpContent: byId("help-content"),
};
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
ui.roll.textContent = TEXTS.roll;
ui.double.textContent = TEXTS.double;
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.cancel.textContent = TEXTS.cancel;
ui.again.textContent = TEXTS.newGame;
ui.newGame.textContent = TEXTS.newGame;
ui.helpButton.textContent = TEXTS.help;
ui.helpBack.textContent = TEXTS.back;
// État de l'écran, en plus de la partie elle-même.
let game: Game | null = null;
let sides: SideBoards | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let canDouble = false;
let busy = true;
let info = "";
// ------------------------------------------------------------ Dessin
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
if (game === null) {
return;
}
sides = await game.displayedBoard();
targets = moveTargets(await game.nextHalfMoves());
canDouble = await game.canDouble();
if (selected !== null && !targets.has(selected)) {
selected = null;
}
render();
}
function render(): void {
if (game === null || sides === null) {
return;
}
const state = game.state;
const model = boardModel(sides);
renderBoard(ui.board, model, {
dice: shownDice(game),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
lastMove: lastComputerMove(game),
});
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
ui.info.textContent = info;
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
const player = state.turn === "player";
show(ui.roll, player && state.phase === "rolling");
show(ui.double, player && state.phase === "rolling");
ui.double.disabled = busy || !canDouble;
show(ui.cancel, player && state.phase === "moving");
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
const doubled = state.turn === "computer" && state.phase === "doubled";
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, state.phase === "over");
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
button.disabled = busy;
}
// Pour le style et pour les tests dans un navigateur : l'écran
// attend le moteur.
document.body.classList.toggle("busy", busy);
}
function show(button: HTMLButtonElement, visible: boolean): void {
button.hidden = !visible;
}
// ----------------------------------------------------------- Actions
function save(): void {
if (game !== null) {
options.saveGame(game.journal);
}
}
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
async function computerTurns(): Promise<void> {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
const start = performance.now();
await game.playComputerTurn();
info = describeComputerTurn(game, performance.now() - start);
save();
}
}
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
* ignorées pendant ce temps. */
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
if (busy || game === null) {
return;
}
busy = true;
selected = null;
targets = new Map();
render();
try {
await action(game);
save();
await computerTurns();
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
await refresh();
}
}
function startNewGame(): void {
game = options.newGame();
const opening = game.events.findLast((e) => e.type === "DiceRolled");
info =
opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : "";
}
ui.roll.onclick = () =>
act(async (g) => {
info = "";
await g.roll();
const last = g.events.at(-1);
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
info = TEXTS.youCannotMove(rollBefore(g));
}
});
ui.double.onclick = () =>
act(async (g) => {
await g.double();
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
});
ui.take.onclick = () =>
act((g) => {
g.take();
info = TEXTS.youTook(g.state.cube.value);
});
ui.drop.onclick = () =>
act((g) => {
g.drop();
info = "";
});
ui.cancel.onclick = () => {
if (!busy && game !== null) {
game.cancelHalfMoves();
selected = null;
void refresh();
}
};
const newGame = () => {
if (busy) {
return;
}
const inProgress = game !== null && game.state.phase !== "over";
if (inProgress && !options.confirm(TEXTS.confirmNewGame)) {
return;
}
void act(() => startNewGame());
};
ui.newGame.onclick = newGame;
ui.again.onclick = newGame;
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
ui.board.addEventListener("click", (event) => {
const target = event.target as Element | null;
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
void act(async (g) => {
info = "";
await g.playHalfMove(halfMove);
});
return;
}
selected = targets.has(place) && place !== selected ? place : null;
render();
});
ui.helpButton.onclick = () => {
ui.game.hidden = true;
ui.help.hidden = false;
window.scrollTo(0, 0);
void showHelp(ui.helpContent);
};
ui.helpBack.onclick = () => {
ui.help.hidden = true;
ui.game.hidden = false;
};
// ---------------------------------------------------------- Démarrage
ui.prompt.textContent = TEXTS.loading;
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
button.hidden = true;
}
ui.newGame.disabled = true;
options.engineReady.then(
async () => {
game = options.savedGame();
if (game === null) {
startNewGame();
save();
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour).
try {
await computerTurns();
} finally {
busy = false;
await refresh();
}
},
(error: unknown) => {
ui.prompt.textContent = TEXTS.engineFailed(
error instanceof Error ? error.message : String(error),
);
},
);
}
// ------------------------------------------------- Lecture de la partie
/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */
function isPlaying(game: Game): boolean {
const phase = game.state.phase;
return phase === "rolling" || phase === "moving";
}
/** Le jet du dernier DiceRolled. */
function rollBefore(game: Game): readonly number[] {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
return rolled?.type === "DiceRolled" ? rolled.roll : [];
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
* (au jet d'ouverture, un dé chacun). */
function shownDice(game: Game): ShownDie[] {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
if (rolled?.type !== "DiceRolled") {
return [];
}
const sides: Side[] =
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" }));
}
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
* l'ordinateur. */
function lastComputerMove(game: Game): Set<Place> {
const played = game.events.findLast((e) => e.type === "MovePlayed");
const places = new Set<Place>();
if (played?.type === "MovePlayed" && played.side === "computer") {
for (const h of played.halfMoves) {
places.add(placeOf("computer", h.from));
places.add(placeOf("computer", h.to));
}
}
return places;
}
/** Ce qu'a fait l'ordinateur pendant son tour. */
function describeComputerTurn(game: Game, ms: number): string {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
}
const played = game.events.findLast((e) => e.type === "MovePlayed");
if (played?.type !== "MovePlayed") {
return "";
}
const dice = rollBefore(game);
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves), ms);
}
/** Ce que le joueur doit faire maintenant, ou le résultat. */
function prompt(game: Game, selecting: boolean, canDouble: boolean): string {
const state = game.state;
if (state.phase === "over" && state.result !== null) {
const { winner, kind, points } = state.result;
const dropped = game.events.find((e) => e.type === "CubeDropped");
if (dropped !== undefined) {
return winner === "player" ? TEXTS.computerDropped(points) : TEXTS.youDropped(points);
}
return winner === "player" ? TEXTS.youWin(kind, points) : TEXTS.youLose(kind, points);
}
if (state.turn === "computer") {
return state.phase === "doubled" ? TEXTS.computerDoubles : TEXTS.thinking;
}
if (state.phase === "rolling") {
return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble;
}
if (state.phase === "moving" && state.roll !== null) {
return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll);
}
return "";
}
+92
View File
@@ -0,0 +1,92 @@
// Ce que dessine le plateau, calculé sans le navigateur (testé sous Node).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le plateau est toujours dessiné du point de vue du joueur : ses cases 1 à 6
// en bas à droite, 7 à 12 en bas à gauche, 13 à 18 en haut à gauche, 19 à 24
// en haut à droite ; ses pions vont de 24 vers 1, puis sortent à droite, en
// bas. La case 1 de l'ordinateur est la case 24 du joueur.
import type { SideBoards } from "../domain/game/game.js";
import { BAR, type HalfMove, OFF } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
/**
* Un endroit du plateau où l'on peut toucher : une case (« p1 » à « p24 »,
* numérotée du point de vue du joueur), la barre ou la sortie d'un camp.
*/
export type Place = `p${number}` | `bar-${Side}` | `off-${Side}`;
/** L'endroit désigné par une case d'un demi-coup de side, dans sa propre
* numérotation (BAR, OFF ou 1 à 24). */
export function placeOf(side: Side, point: number): Place {
if (point === BAR) {
return `bar-${side}`;
}
if (point === OFF) {
return `off-${side}`;
}
return side === "player" ? `p${point}` : `p${25 - point}`;
}
/** Une case du plateau, et les pions de chaque camp qui s'y trouvent. */
export interface PointModel {
readonly place: Place;
/** Numéro de la case pour le joueur, 1 à 24. */
readonly number: number;
/** Le camp qui l'occupe (null : vide) et son nombre de pions. */
readonly owner: Side | null;
readonly count: number;
}
export interface BoardModel {
/** Les cases 1 à 24 du joueur, dans l'ordre. */
readonly points: readonly PointModel[];
readonly bar: Readonly<Record<Side, number>>;
readonly off: Readonly<Record<Side, number>>;
readonly pipCount: Readonly<Record<Side, number>>;
}
/** Le plateau à dessiner, à partir du plateau vu de chaque camp. */
export function boardModel(sides: SideBoards): BoardModel {
const points: PointModel[] = [];
for (let number = 1; number <= 24; number++) {
const player = sides.player.points[number - 1] ?? 0;
const computer = sides.computer.points[24 - number] ?? 0;
// Une case n'a jamais de pions des deux camps à la fois.
const owner = player > 0 ? "player" : computer > 0 ? "computer" : null;
points.push({ place: `p${number}`, number, owner, count: player || computer });
}
return {
points,
bar: { player: sides.player.bar, computer: sides.computer.bar },
off: { player: sides.player.off, computer: sides.computer.off },
pipCount: { player: sides.player.pipCount, computer: sides.computer.pipCount },
};
}
/** Les cases de départ des demi-coups possibles, et pour chacune ses cases
* d'arrivée : ce que l'interface met en évidence. */
export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Place, HalfMove>> {
const targets = new Map<Place, Map<Place, HalfMove>>();
for (const halfMove of halfMoves) {
const from = placeOf("player", halfMove.from);
let tos = targets.get(from);
if (tos === undefined) {
tos = new Map();
targets.set(from, tos);
}
tos.set(placeOf("player", halfMove.to), halfMove);
}
return targets;
}
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
export function pointSlot(number: number): { top: boolean; column: number } {
if (number <= 12) {
return { top: false, column: 12 - number };
}
return { top: true, column: number - 13 };
}
+358
View File
@@ -0,0 +1,358 @@
// Le dessin du plateau, en SVG.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le plateau est redessiné en entier à chaque changement : c'est rapide, et
// il n'y a aucun état caché dans le dessin. Les couleurs viennent des
// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque
// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus
// le reste, qui porte son nom dans data-place.
import type { Cube } from "../domain/game/cube.js";
import type { Side } from "../domain/game/side.js";
import { type BoardModel, type Place, pointSlot } from "./board-model.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
export interface ShownDie {
readonly value: number;
readonly side: Side;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
export interface BoardDecor {
readonly dice: readonly ShownDie[];
readonly cube: Cube;
/** Les pions que le joueur peut déplacer. */
readonly movable: ReadonlySet<Place>;
/** Le pion touché, dont on montre les cases d'arrivée. */
readonly selected: Place | null;
readonly targets: ReadonlySet<Place>;
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
readonly lastMove: ReadonlySet<Place>;
}
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
const WIDTH = 640;
const HEIGHT = 520;
const FRAME = 8;
const POINT_WIDTH = 44;
const HALF_WIDTH = 6 * POINT_WIDTH;
const BAR_LEFT = FRAME + HALF_WIDTH;
const BAR_WIDTH = 40;
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
const TRAY_WIDTH = 48;
const POINT_HEIGHT = 190;
const MIDDLE = HEIGHT / 2;
const RADIUS = 20;
const STEP = 42; // d'un pion au suivant dans une pile
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
const OFF_SLAB = 12; // épaisseur d'un pion sorti
const DIE = 36;
const CUBE = 34;
const SVG_NS = "http://www.w3.org/2000/svg";
type Attributes = Record<string, string | number>;
function add(parent: Element, name: string, attributes: Attributes, text?: string): SVGElement {
const element = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attributes)) {
element.setAttribute(key, String(value));
}
if (text !== undefined) {
element.textContent = text;
}
parent.append(element);
return element;
}
/** Abscisse du bord gauche d'une colonne (0 à 11). */
function columnLeft(column: number): number {
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
}
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
function stackY(top: boolean, index: number): number {
const offset = RADIUS + 1 + index * STEP;
return top ? FRAME + offset : HEIGHT - FRAME - offset;
}
/** Redessine tout le plateau dans svg. */
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
drawFrame(svg);
const hits = add(svg, "g", {});
for (const point of board.points) {
drawPoint(svg, hits, board, point.number, decor);
}
drawBar(svg, hits, board, decor);
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
drawCube(svg, decor.cube);
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
function drawFrame(svg: SVGSVGElement): void {
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
for (const left of [FRAME, RIGHT_HALF]) {
add(svg, "rect", {
class: "board-bed",
x: left,
y: FRAME,
width: HALF_WIDTH,
height: HEIGHT - 2 * FRAME,
});
}
add(svg, "rect", {
class: "board-tray",
x: TRAY_LEFT,
y: FRAME,
width: TRAY_WIDTH,
height: HEIGHT - 2 * FRAME,
});
}
function drawPoint(
svg: SVGSVGElement,
hits: Element,
board: BoardModel,
number: number,
decor: BoardDecor,
): void {
const point = board.points[number - 1];
if (point === undefined) {
return;
}
const { top, column } = pointSlot(number);
const left = columnLeft(column);
const center = left + POINT_WIDTH / 2;
const base = top ? FRAME : HEIGHT - FRAME;
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
add(svg, "polygon", {
class: column % 2 === 0 ? "point-a" : "point-b",
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
});
add(
svg,
"text",
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
String(number),
);
if (decor.targets.has(point.place)) {
add(svg, "rect", {
class: "target-zone",
x: left + 2,
y: top ? FRAME : MIDDLE,
width: POINT_WIDTH - 4,
height: HEIGHT / 2 - FRAME,
rx: 4,
});
}
if (point.owner !== null) {
const y = (i: number) => stackY(top, i);
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
}
if (decor.lastMove.has(point.place)) {
add(svg, "circle", {
class: "last-move",
cx: center,
cy: top ? MIDDLE - 12 : MIDDLE + 12,
r: 5,
});
}
addHit(hits, point.place, decor, {
x: left,
y: top ? 0 : MIDDLE,
width: POINT_WIDTH,
height: MIDDLE,
});
}
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
* nombre total s'il y en a trop pour les montrer tous. */
function drawStack(
svg: SVGSVGElement,
side: Side,
count: number,
cx: number,
y: (index: number) => number,
place: Place,
decor: BoardDecor,
): void {
const shown = Math.min(count, MAX_STACK);
for (let i = 0; i < shown; i++) {
const last = i === shown - 1;
const classes = [`checker checker-${side}`];
if (last && decor.selected === place) {
classes.push("selected");
} else if (last && decor.movable.has(place)) {
classes.push("movable");
}
const cy = y(i);
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
if (last && count > MAX_STACK) {
add(
svg,
"text",
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
String(count),
);
}
}
}
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
add(svg, "rect", {
class: "board-bar",
x: BAR_LEFT,
y: 0,
width: BAR_WIDTH,
height: HEIGHT,
});
const cx = BAR_LEFT + BAR_WIDTH / 2;
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
const up = side === "player";
const y = (i: number) =>
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
}
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
}
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
for (const side of ["player", "computer"] as const) {
for (let i = 0; i < board.off[side]; i++) {
const y =
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
add(svg, "rect", {
class: `checker checker-${side}`,
x: TRAY_LEFT + 4,
y: y + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
}
}
if (decor.targets.has("off-player")) {
add(svg, "rect", {
class: "target-zone",
x: TRAY_LEFT + 2,
y: MIDDLE,
width: TRAY_WIDTH - 4,
height: MIDDLE - FRAME,
rx: 4,
});
}
if (decor.lastMove.has("off-computer")) {
add(svg, "circle", {
class: "last-move",
cx: TRAY_LEFT + TRAY_WIDTH / 2,
cy: MIDDLE - 70,
r: 5,
});
}
addHit(hits, "off-player", decor, {
x: TRAY_LEFT,
y: MIDDLE,
width: TRAY_WIDTH,
height: MIDDLE,
});
}
// Position des points d'un dé, en fractions de son côté.
const PIPS: Record<number, [number, number][]> = {
1: [[0.5, 0.5]],
2: [
[0.25, 0.25],
[0.75, 0.75],
],
3: [
[0.25, 0.25],
[0.5, 0.5],
[0.75, 0.75],
],
4: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.75],
[0.75, 0.75],
],
5: [
[0.25, 0.25],
[0.75, 0.25],
[0.5, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
6: [
[0.25, 0.25],
[0.75, 0.25],
[0.25, 0.5],
[0.75, 0.5],
[0.25, 0.75],
[0.75, 0.75],
],
};
/** Les dés, au milieu de la moitié droite. */
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
const center = RIGHT_HALF + HALF_WIDTH / 2;
const gap = 8;
const total = dice.length * DIE + (dice.length - 1) * gap;
dice.forEach((die, i) => {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const group = add(svg, "g", { class: "die", "data-value": die.value });
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
for (const [px, py] of PIPS[die.value] ?? []) {
add(group, "circle", {
class: `pip-${die.side}`,
cx: x + px * DIE,
cy: y + py * DIE,
r: 3.5,
});
}
});
}
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
* sinon du côté de son propriétaire. Au centre, il montre 64. */
function drawCube(svg: SVGSVGElement, cube: Cube): void {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
const y = MIDDLE + shift - CUBE / 2;
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
add(
group,
"text",
{ class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 },
String(cube.owner === null ? 64 : cube.value),
);
}
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
* tests dans un navigateur. */
function addHit(
hits: Element,
place: Place,
decor: BoardDecor,
box: { x: number; y: number; width: number; height: number },
): void {
const classes = ["hit"];
if (decor.movable.has(place)) {
classes.push("can-move");
}
if (decor.targets.has(place)) {
classes.push("can-land");
}
add(hits, "rect", { class: classes.join(" "), "data-place": place, ...box });
}
+91
View File
@@ -0,0 +1,91 @@
// Lecture des textes de l'aide : les scénarios (docs/scenarios/) et les
// fiches du glossaire (docs/glossaire/), copiés tels quels dans le site par
// make site (ADR 0008 : une seule source). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un scénario, mis en forme comme une page d'aide. */
export interface HelpFeature {
readonly title: string;
/** Paragraphes d'introduction. */
readonly description: readonly string[];
readonly scenarios: readonly HelpScenario[];
}
export interface HelpScenario {
readonly title: string;
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
readonly steps: readonly string[];
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
const SCENARIO = /^(Scénario|Exemple|Contexte)\s*:\s*(.*)$/;
const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
let paragraph: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
const endParagraph = () => {
if (paragraph.length > 0) {
(scenarios.at(-1)?.description ?? description).push(paragraph.join(" "));
paragraph = [];
}
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const feature = FEATURE.exec(line);
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
} else {
paragraph.push(line);
}
}
endParagraph();
return { title, description, scenarios };
}
/** Les fiches citées par l'index du glossaire (README.md), dans l'ordre :
* les liens « [Terme](fichier.md) » de la section « Termes ». */
export function parseGlossaryIndex(readme: string): string[] {
const section = readme.split(/^## Termes$/m)[1] ?? "";
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
export interface GlossaryEntry {
readonly term: string;
readonly brief: string;
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
}
+102
View File
@@ -0,0 +1,102 @@
// 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.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type GlossaryEntry,
type HelpFeature,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.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"];
const GLOSSARY = "docs/glossaire/";
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} : ${response.status}`);
}
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
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);
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
text?: string,
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (text !== undefined) {
child.textContent = text;
}
parent.append(child);
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);
}
const steps = element(parent, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
}
}
}
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);
}
renderGlossary(container, glossary);
const about = element(container, "p", `${TEXTS.about} `);
about.className = "about";
const link = element(about, "a", TEXTS.engineTestLink);
link.href = "moteur/";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
}
+91
View File
@@ -0,0 +1,91 @@
// Les textes de l'interface (ADR 0007, point 5), en français, avec les mots
// du glossaire (docs/glossaire/). Le code n'écrit aucun texte lui-même : il
// prend tout ici.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
/** Un jet écrit « 6-4 ». */
function roll(dice: readonly number[]): string {
return dice.join("-");
}
const RESULT_KIND: Record<ResultKind, string> = {
single: "la partie simple",
gammon: "un gammon",
backgammon: "un backgammon",
};
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
function seconds(ms: number): string {
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
}
export const TEXTS = {
title: "Ustagammon",
boardLabel: "Plateau de backgammon",
// Boutons
roll: "Lancer les dés",
double: "Doubler",
take: "Prendre",
drop: "Passer",
cancel: "Annuler",
newGame: "Nouvelle partie",
help: "Aide",
back: "Retour à la partie",
// Chargement
loading: "Chargement du moteur…",
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
actionFailed: (message: string) => `Erreur : ${message}`,
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
// Déroulement
opening: (player: number, computer: number) =>
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
player > computer ? "Vous commencez." : "L'ordinateur commence."
}`,
yourRoll: "À vous : doublez ou lancez les dés.",
yourRollNoDouble: "À vous : lancez les dés.",
yourMove: (dice: readonly number[]) =>
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
youCannotMove: (dice: readonly number[]) =>
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
thinking: "L'ordinateur réfléchit…",
computerMoved: (dice: readonly number[], move: string, ms: number) =>
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
computerCannotMove: (dice: readonly number[]) =>
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
computerTakes: (value: number) =>
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
youTook: (value: number) =>
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
// Fin de partie
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
youLose: (kind: ResultKind, n: number) =>
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
// Sous le plateau
pipCounts: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
engineTestLink: "Test du moteur",
} as const;