Compare commits
9
Commits
71bff4def1
...
86c0ebfc36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c0ebfc36 | ||
|
|
7c10c688c3 | ||
|
|
d1e234bb11 | ||
|
|
65a4638475 | ||
|
|
2b8fa1a0b0 | ||
|
|
8e5be8e339 | ||
|
|
e724368f09 | ||
|
|
2352a5317f | ||
|
|
0a88e835d2 |
+20
-10
@@ -64,7 +64,7 @@ import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } f
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import { type Updates, updateBannerShown } from "./updates.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
* (interaction.md : on suit le coup sans l'attendre). */
|
||||
@@ -718,18 +718,17 @@ export function startApp(options: AppOptions): void {
|
||||
ui.helpButton.href = helpLink;
|
||||
ui.menuHelp.href = helpLink;
|
||||
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
|
||||
const moveInProgress =
|
||||
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
|
||||
// Le bandeau de mise à jour attend la fin du tour, même hors de
|
||||
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
|
||||
const quiet =
|
||||
!busy &&
|
||||
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
|
||||
if (updateMessage !== null) {
|
||||
ui.updateBanner.hidden = false;
|
||||
ui.updateText.textContent = updateMessage;
|
||||
ui.updateApply.hidden = true;
|
||||
} else {
|
||||
const shown = updateBannerShown(
|
||||
updates?.available ?? false,
|
||||
moveInProgress,
|
||||
!ui.banner.hidden,
|
||||
);
|
||||
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
|
||||
ui.updateBanner.hidden = !shown;
|
||||
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
|
||||
ui.updateApply.hidden = false;
|
||||
@@ -1455,6 +1454,7 @@ export function startApp(options: AppOptions): void {
|
||||
},
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
@@ -1565,6 +1565,7 @@ export function startApp(options: AppOptions): void {
|
||||
? TEXTS.unreadablePosition
|
||||
: "";
|
||||
ui.positionBoard.toggleAttribute("hidden", !shown);
|
||||
ui.position.classList.toggle("with-board", shown);
|
||||
ui.positionDetail.hidden = !shown;
|
||||
ui.positionCopy.hidden = !shown;
|
||||
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
|
||||
@@ -1572,11 +1573,20 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
const shared = state.shared;
|
||||
const width = ui.positionBoard.clientWidth || Math.min(window.innerWidth, 640);
|
||||
// En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
// colonne du titre et des actions (app.css), avec la géométrie du
|
||||
// jeu : la colonne gauche du cadre garde le videau, sans menu ni
|
||||
// bouton annuler (il ne se joue pas).
|
||||
const landscape = layout() === "landscape";
|
||||
const box = ui.positionBoard.getBoundingClientRect();
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
const height = box.height || window.innerHeight;
|
||||
const model = boardModel(shared.sides);
|
||||
renderBoard(
|
||||
ui.positionBoard,
|
||||
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
|
||||
landscape
|
||||
? boardGeometry("landscape", width / height, height / 520)
|
||||
: boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
|
||||
model,
|
||||
{
|
||||
dice: [],
|
||||
|
||||
@@ -166,6 +166,7 @@ export function renderBoard(
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
pieces.setAttribute("class", "pieces");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
|
||||
@@ -322,6 +322,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
const opened = questions.find((q) => q.id === question) ?? null;
|
||||
const closed = route.screen === "help" && opened === null ? lastOpen : null;
|
||||
lastOpen = opened;
|
||||
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
|
||||
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
|
||||
@@ -68,7 +68,7 @@ export const HOME_TEXTS = {
|
||||
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
|
||||
|
||||
// Mise à jour
|
||||
updateAvailable: "Nouvelle version disponible.",
|
||||
updateAvailable: "Nouvelle version prête.",
|
||||
updateApply: "Mettre à jour",
|
||||
updateCheck: "Rechercher une mise à jour",
|
||||
updateChecking: "Recherche d'une mise à jour…",
|
||||
@@ -80,8 +80,8 @@ export const HOME_TEXTS = {
|
||||
tutorialMenu: "Les règles (tutoriel)",
|
||||
tutorialProgress: "Étapes du tutoriel",
|
||||
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
tutorialSkip: "Passer",
|
||||
tutorialSkipLabel: "Passer le tutoriel",
|
||||
tutorialSkip: "Quitter",
|
||||
tutorialSkipLabel: "Quitter le tutoriel",
|
||||
tutorialOverline: (n: number, total: number, practice: boolean) =>
|
||||
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
tutorialEndOverline: "Les règles · fin",
|
||||
@@ -90,8 +90,8 @@ export const HOME_TEXTS = {
|
||||
next: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayLabel: "Revoir le coup",
|
||||
take: "Je prends",
|
||||
drop: "Je passe",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
|
||||
+38
-14
@@ -95,6 +95,8 @@ export interface TutorialViewOptions {
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
|
||||
readonly landscape: () => boolean;
|
||||
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
|
||||
readonly replace: (hash: string) => void;
|
||||
}
|
||||
@@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
@@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const width = g.pointWidth * 0.22;
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", width * 2.4],
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
@@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = width * 2.2;
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(width * 1.2));
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les zones à toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(group, svg.lastChild);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
}
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
@@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
replay.title = HOME_TEXTS.replayLabel;
|
||||
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(section, "p", {
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
@@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
const width = svg.clientWidth || Math.min(window.innerWidth, 640);
|
||||
const box = svg.getBoundingClientRect();
|
||||
if (options.landscape()) {
|
||||
const width = box.width || window.innerWidth;
|
||||
const height = box.height || window.innerHeight;
|
||||
return boardGeometry("landscape", width / height, height / 520);
|
||||
}
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
|
||||
}
|
||||
|
||||
@@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g);
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
|
||||
function markSolved(): void {
|
||||
solved = true;
|
||||
section.toggleAttribute("data-solved", true);
|
||||
next.classList.remove("secondary");
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
@@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
solved = false;
|
||||
section.toggleAttribute("data-solved", false);
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
task.hidden = true;
|
||||
|
||||
+20
-9
@@ -1,8 +1,9 @@
|
||||
// La mise à jour de l'application : le service worker (web/sw.js) installe
|
||||
// chaque nouvelle version publiée dans son propre cache, puis attend
|
||||
// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle
|
||||
// version disponible » ; « Mettre à jour » active la nouvelle version et
|
||||
// recharge la page, qui reprend la partie enregistrée.
|
||||
// l'accord du joueur. L'application affiche alors, entre deux tours, le
|
||||
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
|
||||
// active la nouvelle version et recharge la page, qui reprend la partie
|
||||
// enregistrée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -25,17 +26,27 @@ export interface Updates {
|
||||
export const SKIP_WAITING = "skip-waiting";
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
|
||||
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
|
||||
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
|
||||
* l'ancienne ». Fonction pure, testée sous Node.
|
||||
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
|
||||
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
|
||||
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
|
||||
* pure, testée sous Node.
|
||||
*/
|
||||
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
|
||||
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
|
||||
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
|
||||
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
|
||||
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
moveInProgress: boolean,
|
||||
betweenTwoTurns: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && !moveInProgress && !otherBanner;
|
||||
return available && betweenTwoTurns && !otherBanner;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,11 +113,11 @@ Then("le tutoriel salue votre coup", () => {
|
||||
assert.ok(screen.right.length > 0);
|
||||
});
|
||||
|
||||
When("vous répondez « Je passe »", async () => {
|
||||
When("vous répondez « Passer »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Je prends »", async () => {
|
||||
When("vous répondez « Prendre »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === true;
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
@@ -168,23 +168,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
// ------------------------------------------------------------ Mise à jour
|
||||
|
||||
// Le bandeau dépend de deux choses : une version attend (le service worker
|
||||
// l'a installée), et aucun coup n'est en cours de saisie. La partie est
|
||||
// celle du domaine, sur le vrai moteur.
|
||||
// l'a installée), et la partie est entre deux tours. La partie est celle du
|
||||
// domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
Given("vous avez déjà déplacé un pion de votre coup", async () => {
|
||||
const shownNow = (otherBanner = false) =>
|
||||
updateBannerShown(
|
||||
updateAvailable,
|
||||
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
|
||||
otherBanner,
|
||||
);
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
When("vous déplacez un pion de votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
assert.ok(!shownNow());
|
||||
});
|
||||
|
||||
When("vous avez joué votre coup", async () => {
|
||||
@@ -193,7 +204,7 @@ When("vous avez joué votre coup", async () => {
|
||||
});
|
||||
|
||||
Then("l'application vous propose de mettre à jour", () => {
|
||||
assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
assert.ok(shownNow());
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!updateBannerShown(updateAvailable, false, true));
|
||||
assert.ok(!shownNow(true));
|
||||
});
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
|
||||
@@ -67,7 +67,8 @@ interrompre.
|
||||
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
|
||||
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
|
||||
- En haut : une barre de progression en huit segments (`accent` pour les
|
||||
étapes faites) et « Passer » à droite. En bas : « Retour » et
|
||||
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
|
||||
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
|
||||
« Continuer ».
|
||||
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
|
||||
plateau est le composant de l'app, non jouable sauf sur les écrans
|
||||
@@ -113,9 +114,10 @@ interrompre.
|
||||
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
|
||||
droite. » *À vous* : sortir deux pions avec 6-3.
|
||||
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
|
||||
l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon :
|
||||
2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
|
||||
c'est un backgammon : 3 points. »
|
||||
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
|
||||
reste en plus un pion sur la barre ou dans votre camp, c'est un
|
||||
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
|
||||
que si le videau a été doublé : c'est la règle de Jacoby. »
|
||||
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
|
||||
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
|
||||
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
|
||||
|
||||
@@ -29,7 +29,7 @@ du moteur.
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
|
||||
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
|
||||
@@ -10,7 +10,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel
|
||||
Chaque étape tient en un écran court : un titre, le plateau, deux ou
|
||||
trois phrases. Le coup montré se joue tout seul après une seconde ;
|
||||
« Revoir » le rejoue. La barre du haut montre où vous en êtes et vous
|
||||
ramène aux étapes déjà vues. « Passer » vous emmène jouer.
|
||||
ramène aux étapes déjà vues. « Quitter » vous emmène jouer.
|
||||
|
||||
Étant donné que vous commencez le tutoriel
|
||||
Alors il compte huit étapes, dont quatre où c'est à vous de jouer
|
||||
@@ -36,7 +36,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel
|
||||
passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi.
|
||||
|
||||
Étant donné que vous êtes à l'écran « à vous » de l'étape 8
|
||||
Quand vous répondez « Je passe »
|
||||
Quand vous répondez « Passer »
|
||||
Alors le maître dit qu'il aurait pris
|
||||
Quand vous répondez « Je prends »
|
||||
Quand vous répondez « Prendre »
|
||||
Alors le tutoriel salue votre réponse
|
||||
|
||||
@@ -52,13 +52,16 @@ Fonctionnalité: Régler l'application
|
||||
|
||||
Scénario: Comment mettre l'application à jour ?
|
||||
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
|
||||
télécharge d'elle-même, puis affiche « Nouvelle version disponible »,
|
||||
jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et
|
||||
vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à
|
||||
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
|
||||
version prête. », seulement entre deux tours, jamais pendant que vous
|
||||
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
|
||||
étiez. Dans le menu, « Rechercher une mise à
|
||||
jour » vérifie tout de suite s'il y en a une.
|
||||
|
||||
Étant donné qu'une nouvelle version d'Ustagammon est disponible
|
||||
Et que vous avez déjà déplacé un pion de votre coup
|
||||
Et que vous avez des dés à jouer
|
||||
Alors l'application ne vous propose pas encore de mettre à jour
|
||||
Quand vous déplacez un pion de votre coup
|
||||
Alors l'application ne vous propose pas encore de mettre à jour
|
||||
Quand vous avez joué votre coup
|
||||
Alors l'application vous propose de mettre à jour
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Les règles du backgammon
|
||||
|
||||
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
|
||||
première version à valider par Pierre. Format : voir README.md.
|
||||
validé par Pierre le 9 novembre 2026. Format : voir README.md.
|
||||
|
||||
## 1. Le but du jeu
|
||||
|
||||
@@ -101,9 +101,7 @@ Vous avez 6 et 3 : sortez deux pions.
|
||||
- jet: 2-1
|
||||
- coup: 2/off 1/off
|
||||
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion quand vous finissez, c'est un [gammon](gammon.md) : 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points.
|
||||
|
||||
Dans une partie libre contre l'ordinateur, le gammon et le backgammon ne comptent double ou triple qu'une fois le videau doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
|
||||
## 8. Le videau
|
||||
|
||||
|
||||
+133
-15
@@ -11,7 +11,8 @@
|
||||
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
|
||||
// dossier que ce script modifie : il y publie une deuxième version pour
|
||||
// essayer la mise à jour. Captures de la recette design (Chromium) dans
|
||||
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412.
|
||||
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
|
||||
// en 844×320 pour le tutoriel.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
@@ -29,6 +30,8 @@ const SIZES = [
|
||||
[412, 915],
|
||||
[915, 412],
|
||||
];
|
||||
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
|
||||
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const problems = [];
|
||||
@@ -102,12 +105,12 @@ async function until(page, what, test, arg, ms = 30_000) {
|
||||
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
|
||||
}
|
||||
|
||||
/** Range une capture de l'écran courant dans les deux tailles (Chromium). */
|
||||
async function capture(page, name, settle = async () => {}) {
|
||||
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
|
||||
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const [width, height] of SIZES) {
|
||||
for (const [width, height] of sizes) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
@@ -139,6 +142,60 @@ async function tutorialReady(page, hash) {
|
||||
}, hash);
|
||||
}
|
||||
|
||||
/** Recette du tutoriel dans la taille courante : numéros des flèches de
|
||||
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
|
||||
async function checkTutorialView(page, name) {
|
||||
const view = await page.evaluate(() => {
|
||||
const svg = document.getElementById("tutorial-board");
|
||||
const box = svg.getBoundingClientRect();
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const scale = Math.min(box.width / vb.width, box.height / vb.height);
|
||||
const numbers = [...svg.querySelectorAll(".point-number")]
|
||||
.map((t) => Number(t.getAttribute("font-size")) * scale);
|
||||
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((el) => el !== null && el.closest("[hidden]") === null)
|
||||
.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { id: el.id, top: r.top, bottom: r.bottom };
|
||||
});
|
||||
return {
|
||||
landscape: document.documentElement.classList.contains("landscape"),
|
||||
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
|
||||
scroll: document.scrollingElement.scrollHeight - innerHeight,
|
||||
body: (() => {
|
||||
const b = document.querySelector("#tutorial .tutorial-body");
|
||||
return b.scrollHeight - b.clientHeight;
|
||||
})(),
|
||||
height: innerHeight,
|
||||
buttons,
|
||||
};
|
||||
});
|
||||
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
|
||||
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
|
||||
if (view.landscape) {
|
||||
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
|
||||
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
|
||||
if (view.body > tutorialBodyScroll.px) {
|
||||
tutorialBodyScroll = { px: view.body, where: size };
|
||||
}
|
||||
for (const b of view.buttons) {
|
||||
check(b.top >= 0 && b.bottom <= view.height + 0.5,
|
||||
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
|
||||
}
|
||||
}
|
||||
tutorialViews++;
|
||||
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
|
||||
}
|
||||
let tutorialViews = 0;
|
||||
let tutorialBodyScroll = { px: 0, where: "" };
|
||||
let tutorialMinNumber = Infinity;
|
||||
|
||||
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
|
||||
async function tutorialCapture(page, name) {
|
||||
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
|
||||
}
|
||||
|
||||
/** Touche une case du plateau du tutoriel. */
|
||||
async function touch(page, place) {
|
||||
await page.click(`#tutorial-board [data-place="${place}"]`);
|
||||
@@ -210,8 +267,17 @@ async function playTouches(page, touches) {
|
||||
if (step === 2) {
|
||||
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
|
||||
"étape 2 : pas de chemin en pointillés");
|
||||
// Sous les pions et sous les numéros des flèches.
|
||||
const order = await page.evaluate(() => {
|
||||
const children = [...document.getElementById("tutorial-board").children];
|
||||
const at = (selector) => children.findIndex((c) => c.matches(selector));
|
||||
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
|
||||
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
|
||||
});
|
||||
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
|
||||
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
|
||||
}
|
||||
await capture(page, `tutoriel-${step}`);
|
||||
await tutorialCapture(page, `tutoriel-${step}`);
|
||||
screens++;
|
||||
await page.click("#tutorial-next");
|
||||
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
|
||||
@@ -222,11 +288,11 @@ async function playTouches(page, touches) {
|
||||
await tutorialReady(page, avous);
|
||||
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
|
||||
`étape ${step}, à vous : « Continuer » principal avant le geste`);
|
||||
await capture(page, `tutoriel-${step}-a-vous`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
|
||||
if (step === 8) {
|
||||
await page.click("#tutorial-drop");
|
||||
check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté");
|
||||
await capture(page, "tutoriel-8-a-vous-refuse");
|
||||
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
|
||||
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
|
||||
await page.click("#tutorial-take");
|
||||
await until(page, "étape 8 juste", () =>
|
||||
document.getElementById("tutorial-feedback").dataset.verdict === "right");
|
||||
@@ -238,7 +304,7 @@ async function playTouches(page, touches) {
|
||||
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
|
||||
const words = await page.textContent("#tutorial-feedback");
|
||||
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
|
||||
await capture(page, `tutoriel-${step}-a-vous-refuse`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
|
||||
await playTouches(page, moves.right);
|
||||
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
|
||||
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
|
||||
@@ -248,13 +314,13 @@ async function playTouches(page, touches) {
|
||||
`étape ${step} : « Continuer » pas principal après le bon geste`);
|
||||
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
|
||||
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
|
||||
await capture(page, `tutoriel-${step}-a-vous-reussi`);
|
||||
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
|
||||
screens++;
|
||||
await page.click("#tutorial-next");
|
||||
}
|
||||
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
|
||||
document.getElementById("tutorial").dataset.ready !== undefined);
|
||||
await capture(page, "tutoriel-fin");
|
||||
await tutorialCapture(page, "tutoriel-fin");
|
||||
// Le retour du téléphone : l'écran d'avant.
|
||||
await page.goBack();
|
||||
await tutorialReady(page, "#/tutoriel/8/a-vous");
|
||||
@@ -262,7 +328,9 @@ async function playTouches(page, touches) {
|
||||
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
|
||||
await page.click("#tutorial-next");
|
||||
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
|
||||
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`);
|
||||
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
|
||||
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
|
||||
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
|
||||
|
||||
// Le choix ne revient plus : l'accueil, ensuite.
|
||||
await page.goto(base);
|
||||
@@ -391,8 +459,27 @@ if (browserName === "chromium") {
|
||||
};
|
||||
open.onerror = () => resolve(null);
|
||||
}));
|
||||
const gameBefore = await journal();
|
||||
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
|
||||
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
|
||||
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
|
||||
const toPlay = () => page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return "busy";
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return b !== null && !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (document.querySelector("#board .hit.can-land")) {
|
||||
return "land";
|
||||
}
|
||||
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
|
||||
});
|
||||
// Une deuxième version publiée : autre version.json, autre sw.js.
|
||||
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
|
||||
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
|
||||
@@ -400,9 +487,40 @@ if (browserName === "chromium") {
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-update");
|
||||
await until(page, "nouvelle version trouvée", () =>
|
||||
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
|
||||
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
|
||||
let waited = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const todo = await toPlay();
|
||||
if (todo === "roll" || todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "busy") {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
if (todo === "move" || todo === "land" || todo === "confirm") {
|
||||
await sleep(400);
|
||||
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
|
||||
waited = true;
|
||||
}
|
||||
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
|
||||
await sleep(200);
|
||||
}
|
||||
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
|
||||
check((await page.textContent("#update-text")) === "Nouvelle version disponible.",
|
||||
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
|
||||
`bandeau : « ${await page.textContent("#update-text")} »`);
|
||||
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
|
||||
const place = await page.evaluate(() => {
|
||||
const banner = document.getElementById("update-banner").getBoundingClientRect();
|
||||
const actions = document.getElementById("actions").getBoundingClientRect();
|
||||
const top = document.querySelector(".topbar").getBoundingClientRect();
|
||||
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
|
||||
});
|
||||
check(place.bottom <= place.actions && place.top > place.bar,
|
||||
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
|
||||
const gameBefore = await journal();
|
||||
await capture(page, "09-mise-a-jour");
|
||||
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
|
||||
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
|
||||
@@ -410,7 +528,7 @@ if (browserName === "chromium") {
|
||||
await page.goto(`${updateBase}#/a-propos`);
|
||||
await until(page, "nouvelle version", () =>
|
||||
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
|
||||
log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version");
|
||||
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
|
||||
// Rien de neuf : « Vous avez la dernière version ».
|
||||
await page.goto(`${updateBase}#/`);
|
||||
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
|
||||
|
||||
+106
-2
@@ -300,10 +300,49 @@ async function checkStable(when) {
|
||||
stableChecks++;
|
||||
}
|
||||
|
||||
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
|
||||
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
|
||||
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
|
||||
async function wrappedButtons(target) {
|
||||
return target.evaluate(() => {
|
||||
const wrapped = [];
|
||||
for (const el of document.querySelectorAll("button, a.button")) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (el.closest("[hidden]") || r.width === 0 || r.height === 0) {
|
||||
continue;
|
||||
}
|
||||
const name = el.id ? `#${el.id}` : el.textContent.trim();
|
||||
const tops = new Set();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
||||
if (node.textContent.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
for (const line of range.getClientRects()) {
|
||||
if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) {
|
||||
tops.add(line.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.height > 48.5 && tops.size > 1) {
|
||||
wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`);
|
||||
} else if (el.scrollWidth > el.clientWidth + 1) {
|
||||
wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`);
|
||||
}
|
||||
}
|
||||
return wrapped;
|
||||
});
|
||||
}
|
||||
|
||||
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
|
||||
* mesures pour le compte rendu. */
|
||||
async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const wrapped = await wrappedButtons(page);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -767,6 +806,65 @@ async function checkAddresses() {
|
||||
await expectScreen(tab, `capture ${name}`, expected);
|
||||
await sleep(300);
|
||||
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
if (name === "question" || name === "terme") {
|
||||
// La barre haute de l'aide reste en vue, et ce qu'on a
|
||||
// ouvert s'arrête juste sous elle.
|
||||
const seen = await tab.evaluate(() => {
|
||||
const bar = document.getElementById("help-bar").getBoundingClientRect();
|
||||
const target = document.querySelector(
|
||||
"#help-play .question.open, #help-glossary .term-card.current",
|
||||
).getBoundingClientRect();
|
||||
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
|
||||
});
|
||||
check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1,
|
||||
`${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`);
|
||||
check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2,
|
||||
`${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`);
|
||||
}
|
||||
if (name === "position") {
|
||||
// Les trois actions, dans l'ordre, visibles sans défiler.
|
||||
const seen = await tab.evaluate(() => ({
|
||||
scrolled: window.scrollY,
|
||||
order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id),
|
||||
boxes: ["position-play", "position-copy", "position-back"].map((id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return [r.left, r.top, r.right, r.bottom];
|
||||
}),
|
||||
board: (() => {
|
||||
const r = document.getElementById("position-board").getBoundingClientRect();
|
||||
return [r.left, r.top, r.width, r.height];
|
||||
})(),
|
||||
cube: (() => {
|
||||
const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect();
|
||||
return r === undefined ? null : [r.left, r.top, r.right, r.bottom];
|
||||
})(),
|
||||
controls: document.querySelectorAll("#position-board [data-action]").length,
|
||||
}));
|
||||
check(seen.order.join() === "position-play,position-copy,position-back",
|
||||
`${where} : actions dans l'ordre ${seen.order}`);
|
||||
const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height);
|
||||
if (width > height) {
|
||||
check(seen.scrolled === 0 && inView,
|
||||
`${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`);
|
||||
check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1,
|
||||
`${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`);
|
||||
// Le videau, dans la colonne gauche du cadre, entier à
|
||||
// l'écran ; ni menu ni annuler : la position ne se joue pas.
|
||||
const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1];
|
||||
check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height,
|
||||
`${where} : videau coupé : ${JSON.stringify(seen.cube)}`);
|
||||
check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`);
|
||||
} else {
|
||||
// Empilées sur toute la largeur.
|
||||
const [first] = seen.boxes;
|
||||
check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 &&
|
||||
Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])),
|
||||
`${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`);
|
||||
}
|
||||
}
|
||||
await tab.close();
|
||||
}
|
||||
}
|
||||
@@ -784,9 +882,15 @@ async function checkAddresses() {
|
||||
}
|
||||
// Le bouton « Aide » ouvre la question du moment de la partie
|
||||
// (help-context.ts) ; ici, l'onglet « Jouer » sans question.
|
||||
const helpHref = await tab.getAttribute("#help-button", "href");
|
||||
// L'adresse est lue au moment du toucher : la partie qui vient de
|
||||
// s'ouvrir peut encore changer de moment (jet d'ouverture).
|
||||
const helpHref = await tab.evaluate(() => {
|
||||
const button = document.getElementById("help-button");
|
||||
const href = button.getAttribute("href");
|
||||
button.click();
|
||||
return href;
|
||||
});
|
||||
check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`);
|
||||
await tab.click("#help-button");
|
||||
await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play);
|
||||
if (helpHref !== "#/aide") {
|
||||
await tab.goBack();
|
||||
|
||||
+212
-30
@@ -363,6 +363,8 @@ button {
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
/* Un bouton tient sur une ligne (charte). */
|
||||
white-space: nowrap;
|
||||
/* Pas de délai ni de zoom sur un double toucher. */
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -1291,7 +1293,7 @@ html.rotated .banner {
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.glossary dt {
|
||||
.glossary .term-card {
|
||||
padding-top: var(--space-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1317,16 +1319,36 @@ html.rotated .banner {
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
#help-back {
|
||||
margin-top: var(--space-4);
|
||||
/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la
|
||||
* page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une
|
||||
* question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */
|
||||
.help {
|
||||
--help-bar-height: 113px;
|
||||
}
|
||||
|
||||
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique. */
|
||||
.help-bar {
|
||||
position: sticky;
|
||||
top: env(safe-area-inset-top);
|
||||
z-index: 3;
|
||||
height: var(--help-bar-height);
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.help .question,
|
||||
.glossary .term-card {
|
||||
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ;
|
||||
* sur une ligne, qui défile si l'écran est trop étroit. */
|
||||
.help-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-4);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
margin-top: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -1402,6 +1424,12 @@ html.rotated .banner {
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--accent-soft);
|
||||
/* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */
|
||||
animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards;
|
||||
}
|
||||
|
||||
@keyframes term-fade {
|
||||
to { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Liens et boutons */
|
||||
@@ -1420,6 +1448,7 @@ a.button {
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
white-space: nowrap;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -1483,14 +1512,6 @@ a.link-button {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
/* En paysage, le plateau tient dans la hauteur de l'écran. */
|
||||
html.landscape .position-board {
|
||||
width: min(100%, calc(80vh * 640 / 520));
|
||||
height: auto;
|
||||
margin-inline: auto;
|
||||
aspect-ratio: 640 / 520;
|
||||
}
|
||||
|
||||
.position-detail {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
font-size: 15px;
|
||||
@@ -1498,26 +1519,25 @@ html.landscape .position-board {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Les actions empilées sur toute la largeur, la principale en premier. */
|
||||
.position-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.6fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.position-actions > * {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-copy {
|
||||
#position-copy {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.position-copy .icon {
|
||||
#position-copy .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -1855,13 +1875,6 @@ html.bare .topbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Bandeau de mise à jour : en haut, discret, jamais sur la barre
|
||||
* d'actions ni sur « Reprendre l'ancienne ». */
|
||||
.update-banner {
|
||||
top: var(--space-2);
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Tutoriel. */
|
||||
.tutorial-head {
|
||||
display: flex;
|
||||
@@ -2019,8 +2032,132 @@ html.bare .topbar {
|
||||
stroke: var(--brand-noyer);
|
||||
}
|
||||
|
||||
/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */
|
||||
html.landscape .tutorial-board,
|
||||
/* En paysage, comme la position partagée : le plateau à gauche sur toute
|
||||
* la hauteur (avec la colonne gauche du cadre), et à droite une colonne
|
||||
* avec la barre de progression, le titre, le texte, la consigne « à vous »
|
||||
* et les boutons, le tout sans défilement de la page. Si le texte est trop
|
||||
* long pour l'écran, lui seul défile : les boutons restent en vue. */
|
||||
html.landscape .tutorial {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px);
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
html.landscape .tutorial > * {
|
||||
grid-column: 2;
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.landscape .tutorial .overline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-head {
|
||||
grid-row: 1;
|
||||
min-height: 44px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title {
|
||||
grid-row: 2;
|
||||
margin: 0 0 var(--space-1);
|
||||
padding-right: calc(44px + var(--space-4));
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
|
||||
* eux-mêmes dans la grille. */
|
||||
html.landscape .tutorial-board-box {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-board {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
aspect-ratio: auto;
|
||||
background: var(--frame);
|
||||
}
|
||||
|
||||
html.landscape #tutorial-replay {
|
||||
position: static;
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-body {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-text {
|
||||
margin: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-text p {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .tutorial-task {
|
||||
margin: 0;
|
||||
padding: var(--space-2) 12px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-feedback {
|
||||
min-height: 0;
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-cube {
|
||||
grid-row: 4;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* La réponse au double donnée et saluée, les deux boutons n'ont plus
|
||||
* d'usage : en paysage, leur place revient au texte. */
|
||||
html.landscape .tutorial[data-solved] .tutorial-cube {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Écran très bas (844 × 320) : texte un cran plus petit. */
|
||||
@media (max-height: 360px) {
|
||||
html.landscape .tutorial-body {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
html.landscape .tutorial-title {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
html.landscape .tutorial-nav {
|
||||
grid-row: 5;
|
||||
margin: 0;
|
||||
padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */
|
||||
html.landscape .welcome-board {
|
||||
width: min(100%, calc(70vh * 640 / 520));
|
||||
height: auto;
|
||||
@@ -2122,3 +2259,48 @@ html.landscape .welcome {
|
||||
padding-inline: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
html.landscape .position-view.with-board {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 288px;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board > * {
|
||||
grid-column: 2;
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-title {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-board {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
aspect-ratio: auto;
|
||||
background: var(--frame);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-actions {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-actions > * {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
+15
-10
@@ -91,27 +91,31 @@
|
||||
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<a id="help-back" href="#/" class="button secondary"></a>
|
||||
<nav id="help-tabs" class="help-tabs">
|
||||
<a href="#/aide" data-screen="help"></a>
|
||||
<a href="#/glossaire" data-screen="glossary"></a>
|
||||
<a href="#/a-propos" data-screen="about"></a>
|
||||
</nav>
|
||||
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
|
||||
<div id="help-bar" class="help-bar">
|
||||
<a id="help-back" href="#/" class="button secondary"></a>
|
||||
<nav id="help-tabs" class="help-tabs">
|
||||
<a href="#/aide" data-screen="help"></a>
|
||||
<a href="#/glossaire" data-screen="glossary"></a>
|
||||
<a href="#/a-propos" data-screen="about"></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div id="help-content"></div>
|
||||
</section>
|
||||
|
||||
<!-- Une position partagée (#/position/<Position ID>/<Match ID>), non
|
||||
jouable. -->
|
||||
jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
|
||||
sur toute la hauteur, le reste dans une colonne à droite. -->
|
||||
<section id="position-view" class="position-view page" aria-labelledby="position-title" hidden>
|
||||
<h2 id="position-title" class="position-title"></h2>
|
||||
<p id="position-message" class="position-message" role="status"></p>
|
||||
<svg id="position-board" class="board position-board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<p id="position-detail" class="position-detail"></p>
|
||||
<div class="position-actions">
|
||||
<a id="position-back" href="#/" class="button secondary"></a>
|
||||
<button id="position-play" type="button"></button>
|
||||
<button id="position-copy" type="button" class="secondary"></button>
|
||||
<a id="position-back" href="#/" class="button secondary"></a>
|
||||
</div>
|
||||
<button id="position-copy" type="button" class="secondary position-copy"></button>
|
||||
</section>
|
||||
|
||||
<!-- Menu : une feuille qui monte du bas. -->
|
||||
@@ -184,7 +188,8 @@
|
||||
<span class="banner-timer" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<!-- « Nouvelle version disponible » : jamais pendant un coup en cours. -->
|
||||
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
|
||||
seulement entre deux tours. -->
|
||||
<div id="update-banner" class="banner update-banner" role="status" hidden>
|
||||
<span id="update-text"></span>
|
||||
<button id="update-apply" type="button" class="secondary"></button>
|
||||
|
||||
Reference in New Issue
Block a user