diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index bc3277a..c67ae35 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } { return { top: true, column: number - 13 }; } +/** + * Le jan intérieur d'un camp : ses six dernières flèches avant la sortie, + * numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour + * l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur + * reste seul juge des règles qui en dépendent (sortie, rentrée). + * Glossaire : docs/glossaire/jan-interieur.md + */ +export interface HomeBoard { + readonly side: Side; + readonly points: readonly number[]; +} + +/** + * Le jan extérieur d'un camp : les six flèches qui précèdent son jan + * intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens + * de marche du camp. + * Glossaire : docs/glossaire/jan-exterieur.md + */ +export interface OuterBoard { + readonly side: Side; + readonly points: readonly number[]; +} + +/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre + * numérotation, renumérotées pour le joueur. */ +function sixPoints(side: Side, first: number): number[] { + return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i))); +} + +export function homeBoard(side: Side): HomeBoard { + return { side, points: sixPoints(side, 6) }; +} + +export function outerBoard(side: Side): OuterBoard { + return { side, points: sixPoints(side, 12) }; +} + /** Le plateau de la position de départ, pour le dessiner avant que le * moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case * 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée, diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 35bf99a..f2e9dd6 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -303,23 +303,17 @@ function drawPoint( "data-point": number, points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`, }); - // Numéros : sur le cadre en paysage, dans la bande centrale en - // portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle + // Numéros : sur le cadre, bandes haute et basse, en portrait comme en + // paysage. Leur taille (12 px au moins à l'écran) dépend de l'échelle // du dessin : c'est une mesure du dessin, comme x et y. const size = g.numberSize; - let y: number; - if (g.layout === "landscape") { - y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36; - } else { - y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72; - } add( svg, "text", { - class: g.layout === "landscape" ? "point-number on-frame" : "point-number", + class: "point-number", x: center, - y, + y: top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36, "font-size": size, }, String(number), diff --git a/app/src/ui/geometry.ts b/app/src/ui/geometry.ts index bcacc61..6620af0 100644 --- a/app/src/ui/geometry.ts +++ b/app/src/ui/geometry.ts @@ -25,7 +25,7 @@ export interface Geometry { readonly height: number; /** Épaisseur du cadre sur les côtés. */ readonly frame: number; - /** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */ + /** Hauteur du cadre en haut et en bas : la bande des numéros des flèches. */ readonly edge: number; /** Colonne de commandes à gauche (0 en portrait). */ readonly left: number; @@ -95,14 +95,19 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) arrowWidth: 2.5 / pxPerUnit, }; if (layout === "portrait") { - // Les mesures du plateau d'origine : flèches de 44, pions de 20. + // Les mesures du plateau d'origine : flèches de 44. Les bandes haute + // et basse du cadre portent les numéros des flèches, comme en + // paysage (12 px au moins à l'écran) : pions de 19 au plus. La bande ne + // dépasse pas 36 unités : sur une miniature (moins de 275 px de + // large), les numéros rapetissent plutôt que d'écraser les flèches. const frame = 8; + const edge = Math.min(36, Math.max(24, numberSize + 8)); const tray = 48; const bar = 40; const width = 640; const pointWidth = (width - 3 * frame - tray - bar) / 12; const band = 84; - const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10); + const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2) / 10); const x0 = frame; const barLeft = x0 + 6 * pointWidth; return { @@ -110,7 +115,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) width, height, frame, - edge: frame, + edge, left: 0, tray, bar, @@ -123,7 +128,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number) rightHalf: barLeft + bar, trayLeft: barLeft + bar + 6 * pointWidth + frame, target, - numberSize, + numberSize: Math.min(numberSize, edge - 8), bandFont: atLeast(13.5, pxPerUnit, 17), chipFont: atLeast(13.5, pxPerUnit, 17), ...sizes, diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index b662d27..2c31ac1 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -94,14 +94,15 @@ export const HOME_TEXTS = { 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", + pathLabel: + "Le sens du jeu : la flèche claire pour vos pions, la sombre pour ceux de l'ordinateur", /** Le résultat d'une partie finie par le coup illustré (étape 7). */ illustratedEnd: (kind: ResultKind) => kind === "single" ? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple." : kind === "gammon" ? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon." - : "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.", + : "Partie finie : il reste un pion de l'ordinateur dans votre jan intérieur, c'est un backgammon.", computerDoubles: "L'ordinateur double.", // Aide diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index aab99c1..b5ce55f 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -188,7 +188,7 @@ export const TEXTS = { ) => [ winner === null - ? "Vous avez la main : vos pions ivoire, en bas." + ? "Vous avez la main : vos pions blancs, en bas." : `La partie est finie dans cette position : ${ winner === "player" ? "vous gagnez" : "l'ordinateur gagne" }.`, diff --git a/app/src/ui/tutorial-text.ts b/app/src/ui/tutorial-text.ts index e8df0a0..04260a0 100644 --- a/app/src/ui/tutorial-text.ts +++ b/app/src/ui/tutorial-text.ts @@ -24,7 +24,7 @@ export interface TutorialScreen { readonly roll: readonly [number, number] | null; /** Explication : le coup illustré, en notation de gnubg. */ readonly move: string | null; - /** Explication : le sens du jeu dessiné en pointillés. */ + /** Explication : le sens du jeu, deux flèches circulaires. */ readonly path: boolean; /** « À vous » : les coups justes, en notation de gnubg. */ readonly expected: readonly string[]; diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 7c6b2c1..425ec3a 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -18,11 +18,14 @@ import type { Side } from "../domain/game/side.js"; import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js"; import { boardModel, + homeBoard, moveTargets, moveTrace, onlyHalfMove, + outerBoard, type Place, placeOf as placeOfChecker, + pointSlot, } from "./board-model.js"; import { type BoardDecor, @@ -149,57 +152,71 @@ 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. - * 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 { +/** Le sens du jeu : deux flèches circulaires, une claire pour les pions du + * joueur (du haut à droite vers la gauche, puis en bas jusqu'à son jan + * intérieur), une sombre pour ceux de l'ordinateur, en sens inverse et à + * l'intérieur de la première. Chacune a un liseré de la couleur opposée et + * finit par une pointe ; jamais en brique (la brique veut dire « à faire »). + * Dessinées sous les pions, qu'elles ne cachent jamais. pxPerUnit : pixels + * à l'écran par unité du dessin (trait de 5 px sur un liseré de 9 px, + * quelle que soit la taille du plateau). */ +function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void { + const px = (n: number) => n / pxPerUnit; const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2; - const top = g.height * 0.3; - const bottom = g.height * 0.7; - const right = center(11); - const left = center(0) - g.pointWidth * 0.25; - const turn = g.pointWidth * 0.6; - const end = g.trayLeft + g.tray * 0.25; - const d = - `M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` + - `L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`; const group = document.createElementNS(SVG_NS, "g"); group.setAttribute("class", "tutorial-path"); group.setAttribute("role", "img"); group.setAttribute("aria-label", HOME_TEXTS.pathLabel); - const px = (n: number) => n / pxPerUnit; - const width = px(4); - for (const [className, stroke] of [ - ["path-edge", px(8)], - ["path-dots", width], - ] as const) { - const path = document.createElementNS(SVG_NS, "path"); - path.setAttribute("class", className); - path.setAttribute("d", d); - path.setAttribute("stroke-width", String(stroke)); - if (className === "path-dots") { - path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`); + // Chaque flèche part du jan intérieur adverse (sa flèche 24), longe les + // deux jans extérieurs, tourne au bout du sien et finit dans son jan + // intérieur. Celle de l'ordinateur passe à l'intérieur de la claire. + const column = (point: number) => pointSlot(point).column; + const loops = [ + { + side: "player", + from: g.height * 0.29, + to: g.height * 0.71, + left: center(column(outerBoard("player").points[0] ?? 12)) - g.pointWidth * 0.1, + }, + { + side: "computer", + from: g.height * 0.62, + to: g.height * 0.38, + left: center(column(outerBoard("computer").points[0] ?? 13) + 1) + g.pointWidth * 0.5, + }, + ] as const; + const head = px(18); + const half = px(10); + for (const { side, from, to, left } of loops) { + const other = side === "player" ? "computer" : "player"; + const start = center(column(homeBoard(other).points[5] ?? 1)); + const end = center(column(homeBoard(side).points[4] ?? 2)) + g.pointWidth * 0.3; + const turn = Math.min(Math.abs(to - from) / 2, g.pointWidth * 0.9); + const down = to > from ? 1 : -1; + const d = + `M ${start} ${from} L ${left + turn} ${from} Q ${left} ${from} ${left} ${from + down * turn} ` + + `L ${left} ${to - down * turn} Q ${left} ${to} ${left + turn} ${to} L ${end - head / 2} ${to}`; + for (const [part, stroke] of [ + ["edge", px(9)], + ["line", px(5)], + ] as const) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("class", `direction-${part} direction-${side}`); + path.setAttribute("d", d); + path.setAttribute("stroke-width", String(stroke)); + group.append(path); } - group.append(path); - } - const head = document.createElementNS(SVG_NS, "path"); - head.setAttribute("class", "path-head"); - 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(px(3))); - group.append(head); - // 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); + const tip = document.createElementNS(SVG_NS, "path"); + tip.setAttribute("class", `direction-head direction-${side}`); + tip.setAttribute( + "d", + `M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`, + ); + tip.setAttribute("stroke-width", String(px(2))); + group.append(tip); } + // Sous les pions ; les zones à toucher restent devant tout. + svg.insertBefore(group, svg.querySelector(":scope > g.pieces")); } /** Crée l'écran du tutoriel dans section (vide dans index.html). */ @@ -359,7 +376,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { }; renderBoard(svg, g, boardModel(current), decor); if (screen?.path === true) { - drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width); + drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width); } } diff --git a/app/tests/layout.test.ts b/app/tests/layout.test.ts index a0eaa20..b2bfdb8 100644 --- a/app/tests/layout.test.ts +++ b/app/tests/layout.test.ts @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { test } from "node:test"; import { createSettingsStore } from "../src/storage/settings-store.js"; -import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js"; +import { homeBoard, OPENING_BOARDS, outerBoard, pipLead } from "../src/ui/board-model.js"; import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js"; import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js"; import { ICON_PATHS } from "../src/ui/icons.js"; @@ -37,15 +37,32 @@ const LANDSCAPES: [number, number][] = [ [800, 308], ]; -test("géométrie en portrait : les mesures du plateau d'origine", () => { +test("géométrie en portrait : les mesures du plateau d'origine, numéros sur le cadre", () => { const g = boardGeometry("portrait", 640 / 520, 396 / 640); assert.equal(g.width, 640); assert.equal(g.pointWidth, 44); - assert.equal(g.radius, 20); - assert.equal(g.step, 42); + // Pions d'un peu moins de 19 unités (20 avant les numéros sur le cadre). + assert.ok(g.radius >= 18.5 && g.radius <= 19, `pions de ${g.radius}`); + assert.equal(g.step, 2 * g.radius + 2); assert.equal(g.trayLeft + g.tray + g.frame, 640); - // Numéros d'au moins 12 px à l'écran, à 412 px de large. + // Numéros d'au moins 12 px à l'écran, à 412 px de large, logés dans les + // bandes haute et basse du cadre (retours des premiers joueurs). assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`); + assert.ok(g.numberSize + 8 <= g.edge, `bande de ${g.edge} pour des numéros de ${g.numberSize}`); +}); + +test("géométrie en portrait : numéros d'au moins 12 px sur le cadre dès 275 px de large", () => { + for (const width of [275, 320, 358, 380, 396, 600]) { + const px = width / 640; + const g = boardGeometry("portrait", 640 / 520, px); + assert.ok(g.numberSize * px >= 12, `${width} px : numéros de ${g.numberSize * px} px`); + assert.ok(g.numberSize < g.edge, `${width} px : numéros hors du cadre`); + assert.ok(g.edge <= 36, `${width} px : bande de ${g.edge}`); + } + // Une miniature : la bande reste de 36 unités, les numéros y tiennent. + const thumbnail = boardGeometry("portrait", 640 / 520, 200 / 640); + assert.equal(thumbnail.edge, 36); + assert.ok(thumbnail.numberSize < thumbnail.edge); }); test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => { @@ -93,6 +110,13 @@ test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comp } }); +test("les jans de chaque camp, numérotés pour le joueur, dans le sens de marche", () => { + assert.deepEqual(homeBoard("player").points, [6, 5, 4, 3, 2, 1]); + assert.deepEqual(outerBoard("player").points, [12, 11, 10, 9, 8, 7]); + assert.deepEqual(homeBoard("computer").points, [19, 20, 21, 22, 23, 24]); + assert.deepEqual(outerBoard("computer").points, [13, 14, 15, 16, 17, 18]); +}); + // -------------------------------------------------------------- Vibrations test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => { diff --git a/app/tests/tutorial.test.ts b/app/tests/tutorial.test.ts index d4cc81f..3abbc01 100644 --- a/app/tests/tutorial.test.ts +++ b/app/tests/tutorial.test.ts @@ -101,8 +101,8 @@ test("chaque position du tutoriel est valide pour le moteur", async () => { const s = board.sides[side]; return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off; }; - assert.equal(count("player"), 15, `${name} : pions ivoire`); - assert.equal(count("computer"), 15, `${name} : pions noyer`); + assert.equal(count("player"), 15, `${name} : pions blancs`); + assert.equal(count("computer"), 15, `${name} : pions noirs`); } }); diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index dc7a5e8..caae8e5 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -209,14 +209,14 @@ test("aide contextuelle : chaque question ouverte depuis la partie existe dans l }); test("glossaire : nom anglais, renvois, recherche sans accents", () => { - const entry = parseGlossaryEntry(read("docs/glossaire/camp.md")); + const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md")); assert.ok(entry !== null); assert.equal(entry.english, "Home board"); assert.deepEqual( entry.seeAlso.map((s) => s.name), - ["sortie", "barre", "point"], + ["jan-exterieur", "sortie", "barre", "point"], ); - assert.ok(matchesSearch(entry, "camp")); + assert.ok(matchesSearch(entry, "jan interieur")); assert.ok(matchesSearch(entry, "HOME")); assert.ok(matchesSearch(entry, "fleches sortir")); assert.ok(!matchesSearch(entry, "videau")); diff --git a/docs/adr/0010-identite-visuelle.md b/docs/adr/0010-identite-visuelle.md index 3914d28..e278c33 100644 --- a/docs/adr/0010-identite-visuelle.md +++ b/docs/adr/0010-identite-visuelle.md @@ -71,7 +71,8 @@ au noyer. Après comparaison sur le canevas (rangée « Retours des premiers joueurs »), Pierre retient le blanc et le noir, avec un contour. - Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé - (`#6E6E69`) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris + (`#55554F` en thème clair, pour 3:1 sur les flèches en chêne ; `#6E6E69` en + sombre) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions. - Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1 en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1). diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index b49b1c8..c2a3342 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -40,7 +40,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Barre](barre.md) | Bar | | [Blot](blot.md) | Blot | | [Branche](branche.md) | Branch | -| [Camp](camp.md) | Home board | | [Chance](chance.md) | Luck | | [Compte de points](compte-de-points.md) | Pip count | | [Conseil du maître](conseil-du-maitre.md) | Advice | @@ -54,6 +53,8 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Gammon](gammon.md) | Gammon | | [Identifiant de match](identifiant-de-match.md) | Match ID | | [Identifiant de position](identifiant-de-position.md) | Position ID | +| [Jan extérieur](jan-exterieur.md) | Outer board | +| [Jan intérieur](jan-interieur.md) | Home board | | [Jet](jet.md) | Roll | | [Lien de position](lien-de-position.md) | Position link | | [Match](match.md) | Match | diff --git a/docs/glossaire/camp.md b/docs/glossaire/camp.md deleted file mode 100644 index 3b9b8c3..0000000 --- a/docs/glossaire/camp.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -term: Camp -english: Home board ---- -# Camp - -**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer. - -Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, -en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés. - -Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md) diff --git a/docs/glossaire/jan-exterieur.md b/docs/glossaire/jan-exterieur.md new file mode 100644 index 0000000..29153eb --- /dev/null +++ b/docs/glossaire/jan-exterieur.md @@ -0,0 +1,15 @@ +--- +term: Jan extérieur +english: Outer board +code: OuterBoard +--- +# Jan extérieur + +**En bref** : les six flèches qui précèdent le jan intérieur d'un joueur : ses pions y passent avant d'y entrer. + +Votre jan extérieur est en bas à gauche (vos flèches 7 à 12) ; celui de +l'ordinateur, en haut à gauche (vos flèches 13 à 18). Vos pions partent du jan +intérieur de l'ordinateur, traversent les deux jans extérieurs, puis entrent +dans votre jan intérieur. + +Voir aussi : [Jan intérieur](jan-interieur.md), [Point](point.md) diff --git a/docs/glossaire/jan-interieur.md b/docs/glossaire/jan-interieur.md new file mode 100644 index 0000000..6284842 --- /dev/null +++ b/docs/glossaire/jan-interieur.md @@ -0,0 +1,16 @@ +--- +term: Jan intérieur +english: Home board +code: HomeBoard +--- +# Jan intérieur + +**En bref** : les six dernières flèches d'un joueur avant la sortie : il y rassemble ses quinze pions pour les sortir, et les pions adverses frappés y rentrent. + +Le plateau se partage en quatre jans de six flèches. Votre jan intérieur est +en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, en haut à droite +(vos flèches 19 à 24) : c'est là que rentrent vos pions frappés. Un pion +adverse resté dans votre jan intérieur à la fin de la partie en fait un +backgammon. + +Voir aussi : [Jan extérieur](jan-exterieur.md), [Sortie](sortie.md), [Barre](barre.md), [Point](point.md) diff --git a/docs/identite/charte/recette-ux.md b/docs/identite/charte/recette-ux.md index 791590f..910bd55 100644 --- a/docs/identite/charte/recette-ux.md +++ b/docs/identite/charte/recette-ux.md @@ -8,8 +8,9 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas. ## Toutes tailles - [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes. -- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. -- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. +- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. (Vérifié par `tests/browser/retours-joueurs.mjs`.) +- [ ] Pions du joueur lisibles partout : en thème clair, contour `checker-player-edge` à 3:1 au moins sur les flèches en chêne et sur le fond. Règle automatique : `checkerContrastProblems` de `tests/browser/recette.mjs`. +- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. Règle automatique : là où sa couleur ne se détache pas de son fond (moins de 3:1), son contour s'en détache à 3:1 au moins (`checkerContrasts` de `tests/browser/recette.mjs`). - [ ] Une seule action principale (brique) visible à la fois. - [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide). - [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau). diff --git a/docs/identite/charte/tokens.json b/docs/identite/charte/tokens.json index a356fec..beff30a 100644 --- a/docs/identite/charte/tokens.json +++ b/docs/identite/charte/tokens.json @@ -224,10 +224,10 @@ { "name": "checker-player-edge", "value": { - "light": "#6E6E69", + "light": "#55554F", "dark": "#6E6E69" }, - "usage": "Contour et filet des pions blancs : gris foncé, pour qu'ils se détachent du fond clair du plateau." + "usage": "Contour des pions blancs. Thème clair : #55554F, pour 3:1 au moins sur les flèches en chêne (3,5:1) et le fond (5,5:1). Thème sombre : le pion blanc se détache déjà par sa couleur (plus de 5:1)." }, { "name": "checker-computer", diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 5de42e5..5fd8be1 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -144,7 +144,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur @position:BQAACAIAAAAAAA Scénario: Sortir ses pions dans l'ordre de son choix - Une fois tous vos pions dans les six dernières flèches, vous les + Une fois tous vos pions dans votre jan intérieur (flèches 1 à 6), vous les sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6 et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce diff --git a/docs/tutoriel/README.md b/docs/tutoriel/README.md index 95bc379..ca9e684 100644 --- a/docs/tutoriel/README.md +++ b/docs/tutoriel/README.md @@ -51,7 +51,8 @@ Le texte de la fin du tutoriel. position (par défaut, vous) ; - `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se joue tout seul (notation de gnubg) ; - - `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ; + - `chemin: oui` : le sens du jeu, deux flèches circulaires sur le plateau + (claire pour vos pions, sombre pour ceux de l'ordinateur) ; - `attendu` (une ligne par coup accepté) : sur un écran « à vous », les coups justes, en notation de gnubg, tels que le moteur les écrit ; - `réponse` : `prendre` ou `passer`, pour un écran « à vous » où diff --git a/docs/tutoriel/regles.md b/docs/tutoriel/regles.md index df1dcd8..2470cf8 100644 --- a/docs/tutoriel/regles.md +++ b/docs/tutoriel/regles.md @@ -1,13 +1,15 @@ # Les règles du backgammon Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0, -validé par Pierre le 9 novembre 2026. Format : voir README.md. +validé par Pierre le 9 novembre 2026 ; pions blancs et noirs, flèches +circulaires et jans repris de la charte le 11 novembre 2026 +(docs/identite/charte/accueil-tutoriel-aide.md). Format : voir README.md. ## 1. Le but du jeu - position: 4HPwATDgc/ABMA -Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md). +Vous avez quinze [pions](pion.md) blancs, l'ordinateur quinze pions noirs. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md). Le premier qui a sorti tous les siens gagne la [partie](partie.md). @@ -16,9 +18,9 @@ Le premier qui a sorti tous les siens gagne la [partie](partie.md). - position: 4HPwATDgc/ABMA - chemin: oui -Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir. +Vos pions suivent la **flèche claire** : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à votre [jan intérieur](jan-interieur.md), les flèches 1 à 6. C'est là que vous les rassemblez avant de les sortir. -Les pions noyer de l'ordinateur font le même chemin, en sens inverse. +Les pions de l'ordinateur suivent la **flèche sombre**, en sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. ## 3. Les dés et les doubles @@ -60,10 +62,10 @@ Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez p - attendu: 24/23 13/7* - attendu: 24/18 8/7* - attendu: 8/7* 8/2 -- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin. +- juste: Touché : le pion noir part sur la barre, il devra refaire tout le chemin. - refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1. -Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le. +Vous avez 6 et 1. Le pion noir de la flèche 7 est seul : frappez-le. ## 5. Rentrer depuis la barre @@ -71,7 +73,7 @@ Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le. - jet: 4-3 - coup: bar/22 13/9 -Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite. +Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe. @@ -81,7 +83,7 @@ Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les d - jet: 5-2 - coup: 5/off 2/off -Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1. +Quand vos quinze pions sont dans votre jan intérieur (flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à droite. @@ -101,7 +103,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, 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). +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 jan intérieur, 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 diff --git a/tests/browser.sh b/tests/browser.sh index 8ca14e9..f232419 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -15,10 +15,13 @@ # captures dans build/browser-shots/prompts/. « Nouvelle partie » après une # partie finie et son bilan, par chaque entrée # (tests/browser/nouvelle-partie.mjs), captures dans -# build/browser-shots/nouvelle-partie/. +# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs +# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le +# cadre, flèches du tutoriel, en thème clair et sombre), captures dans +# build/browser-shots/retours-joueurs/. # # BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, -# prompts, nouvelle-partie) restreignent le test. +# prompts, nouvelle-partie, retours-joueurs) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -31,7 +34,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -70,7 +73,7 @@ rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \ - "$tools/" + tests/browser/retours-joueurs.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -125,5 +128,13 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" retours-joueurs "* ]]; then + # Retours des premiers joueurs : pions sur la barre, numéros sur le + # cadre, flèches du tutoriel ; captures dans + # build/browser-shots/retours-joueurs/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 1e305dd..361b881 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -274,17 +274,16 @@ async function playTouches(page, touches) { await tutorialReady(page, hash); } 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. + check(await page.locator("#tutorial-board .tutorial-path .direction-line").count() === 2, + "étape 2 : pas les deux flèches du sens du jeu"); + // Sous les pions (détail : retours-joueurs.mjs). 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) }; + return { path: at(".tutorial-path"), pieces: at("g.pieces") }; }); - 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)}`); + check(order.path >= 0 && order.path < order.pieces, + `étape 2 : les flèches passent au-dessus des pions ${JSON.stringify(order)}`); } await tutorialCapture(page, `tutoriel-${step}`); screens++; diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index ddc2658..0d60a23 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -167,3 +167,106 @@ export function tutorialWidthProblems(views) { } return problems; } + +/** + * Les contrastes des pions dessinés sur les plateaux visibles de la page + * (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche, + * le fond du plateau ou la sortie, trouvé au centre du pion (le dernier + * dessiné qui le contient). Pour chaque pion : son camp, ce fond, le + * contraste de son contour (stroke) et celui de sa couleur (fill) avec ce + * fond. Les pions entourés de brique (jouable, sélectionné) sont laissés : + * leur contour est celui du pion jouable, pas celui de son camp. + */ +export async function checkerContrasts(target) { + return target.evaluate(() => { + const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + const luminance = (color) => { + const [r, g, b] = rgb(color).map((v) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const ratio = (a, b) => { + const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y); + return (light + 0.05) / (dark + 0.05); + }; + const BACKGROUNDS = [ + ["board-bar", "barre"], + ["board-tray", "sortie"], + ["point-a", "flèche impaire"], + ["point-b", "flèche paire"], + ["board-bed", "fond"], + ]; + const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", "); + const found = []; + for (const svg of document.querySelectorAll("svg.board")) { + if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) { + continue; + } + const shapes = [...svg.querySelectorAll(selector)].reverse(); + for (const checker of svg.querySelectorAll("circle.checker")) { + if (checker.matches(".movable, .selected")) { + continue; + } + const point = svg.createSVGPoint(); + point.x = Number(checker.getAttribute("cx")); + point.y = Number(checker.getAttribute("cy")); + const under = shapes.find((s) => s.isPointInFill(point)); + if (under === undefined) { + continue; + } + const ground = getComputedStyle(under).fill; + const style = getComputedStyle(checker); + found.push({ + board: svg.id || "plateau", + side: checker.matches(".checker-computer") ? "computer" : "player", + on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1], + edge: ratio(style.stroke, ground), + fill: ratio(style.fill, ground), + }); + } + } + return found; + }); +} + +/** + * Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles + * partout ») : là où un pion de l'ordinateur ne se détache pas de son fond + * par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond + * sombre), son contour s'en détache à 3:1 au moins (guide de marque : + * contours porteurs de sens à 3:1). Et (« Pions du joueur lisibles + * partout ») : en thème clair, le contour d'un pion du joueur atteint 3:1 sur + * les flèches en chêne et sur le fond. contrasts : le résultat de + * checkerContrasts ; theme : « light » ou « dark ». Renvoie la liste des + * écarts, décrits (vide si tout va bien). + */ +export function checkerContrastProblems(contrasts, theme) { + const problems = new Set(); + for (const c of contrasts) { + if (c.side === "computer" && c.fill < 3 && c.edge < 3) { + problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`); + } + if ( + c.side === "player" && + theme === "light" && + (c.on === "flèche paire" || c.on === "fond") && + c.edge < 3 + ) { + problems.add(`pion du joueur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`); + } + } + return [...problems]; +} + +/** Le plus faible contraste du contour, par camp et par fond, pour le + * journal du test (« ordinateur sur barre 6.79:1, … »). */ +export function checkerContrastSummary(contrasts) { + const lowest = new Map(); + for (const c of contrasts) { + const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`; + lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge)); + } + return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", "); +} diff --git a/tests/browser/retours-joueurs.mjs b/tests/browser/retours-joueurs.mjs new file mode 100644 index 0000000..5595715 --- /dev/null +++ b/tests/browser/retours-joueurs.mjs @@ -0,0 +1,263 @@ +// retours-joueurs.mjs -- les règles tirées des retours des premiers joueurs +// (ADR 0010, amendement du 2026-11-11 ; recette-ux.md), dans un vrai +// navigateur sans écran (Playwright), sur le site servi par l'image de +// production, en thème clair et en thème sombre : +// +// - pions de l'ordinateur lisibles sur la barre : un pion de chaque camp sur +// la barre, contraste du contour de chaque pion sur son fond +// (recette.mjs, checkerContrasts) ; +// - numéros des flèches sur le cadre, en portrait comme en paysage, de +// 12 px au moins ; +// - étape 2 du tutoriel : deux flèches circulaires, claire et sombre, sous +// les pions, jamais en brique. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node retours-joueurs.mjs +// Lancé par tests/browser.sh. Captures (Chromium) dans +// /retours-joueurs/, en 412×915 et 915×412. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { + checkerContrastProblems, + checkerContrastSummary, + checkerContrasts, + hiddenUnderActionBar, +} from "./recette.mjs"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node retours-joueurs.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/retours-joueurs`; +mkdirSync(dir, { recursive: true }); +const SIZES = [ + { width: 412, height: 915 }, + { width: 915, height: 412 }, +]; +// Positions de gnubg, chacun vu de lui-même : la position de départ, et la +// même avec un pion de chaque camp de sa flèche 24 sur la barre. +const OPENING = "4HPwATDgc/ABMA"; +const BOTH_ON_BAR = "4HPwAVDgc/ABUA"; + +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const contrasts = { light: [], dark: [] }; +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const browser = await browserType.launch(); + +/** Un contexte neuf dans un thème, tutoriel déjà vu (l'accueil ne propose + * plus le premier lancement). */ +async function newPage(theme) { + const context = await browser.newContext({ + viewport: SIZES[0], + deviceScaleFactor: 1, + locale: "fr-FR", + reducedMotion: "reduce", + }); + await context.addInitScript((saved) => { + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); + if (localStorage.getItem("ustagammon.settings") === null) { + localStorage.setItem("ustagammon.settings", saved); + } + }, JSON.stringify({ theme, tutorialSeen: 1 })); + const page = await context.newPage(); + page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); + page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } + }); + return { context, page }; +} + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(page, what, fn, arg, ms = 60_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg).catch(() => false)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what}`); +} + +/** Une partie à partir d'une position, joueur au trait, dés à lancer. */ +async function startFrom(page, position) { + await page.goto(`${base}#/position/${encodeURIComponent(position)}`); + await until(page, "position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; + }); + await page.click("#position-play", { timeout: 15_000 }).catch(async (error) => { + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw error; + }); + await until( + page, + "partie commencée", + () => + location.hash === "#/" && + !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden, + ); +} + +/** Les numéros des flèches du plateau visible : tous sur le cadre (hors du + * fond du plateau) et de 12 px au moins à l'écran. Renvoie les écarts. */ +async function numberProblems(page, selector) { + return page.evaluate((selector) => { + const svg = document.querySelector(selector); + const box = svg.getBoundingClientRect(); + const vb = svg.viewBox.baseVal; + const scale = Math.min(box.width / vb.width, box.height / vb.height); + const bed = svg.querySelector(".board-bed").getBBox(); + const found = []; + const numbers = [...svg.querySelectorAll(".point-number")]; + if (numbers.length !== 24) { + found.push(`${numbers.length} numéros`); + } + for (const text of numbers) { + const b = text.getBBox(); + const px = Number(text.getAttribute("font-size")) * scale; + if (b.y + b.height > bed.y + 0.5 && b.y < bed.y + bed.height - 0.5) { + found.push(`flèche ${text.textContent} dans le fond du plateau`); + } + if (px < 12) { + found.push(`flèche ${text.textContent} en ${px.toFixed(1)} px`); + } + } + return found; + }, selector); +} + +/** Les règles sur l'écran courant, dans chaque taille, et sa capture + * (Chromium). */ +async function checkScreen(page, theme, name, selector, settle = async () => {}) { + // Le bandeau « Reprendre l'ancienne » (10 s) n'est pas le sujet. + await until(page, "bandeau parti", () => document.getElementById("banner").hidden, null, 15_000); + for (const size of SIZES) { + await page.setViewportSize(size); + await sleep(300); + await settle(); + const where = `${name} ${theme} ${size.width}x${size.height}`; + const numbers = await numberProblems(page, selector); + check(numbers.length === 0, `${where} : numéros : ${numbers.join(", ")}`); + const measured = await checkerContrasts(page); + check(measured.length > 0, `${where} : aucun pion mesuré`); + const low = checkerContrastProblems(measured, theme); + check(low.length === 0, `${where} : ${low.join(", ")}`); + contrasts[theme].push(...measured); + if (full) { + const hidden = await hiddenUnderActionBar(page); + check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`); + await page.screenshot({ path: `${dir}/${name}-${theme}-${size.width}x${size.height}.png` }); + captures++; + } + } + await page.setViewportSize(SIZES[0]); + await sleep(200); +} + +for (const theme of ["light", "dark"]) { + const { context, page } = await newPage(theme); + + // Un pion de chaque camp sur la barre. + await startFrom(page, BOTH_ON_BAR); + check( + (await page.evaluate(() => document.documentElement.dataset.theme)) === theme, + `thème ${theme} non appliqué`, + ); + const onBar = await page.evaluate(() => + ["player", "computer"].map( + (side) => document.querySelectorAll(`#board [data-checker="bar-${side}"]`).length, + ), + ); + check(onBar[0] === 1 && onBar[1] === 1, `pions sur la barre : ${onBar}`); + await checkScreen(page, theme, "barre", "#board"); + const bar = contrasts[theme].filter((c) => c.on === "barre" && c.side === "computer"); + check(bar.length > 0, `${theme} : pion de l'ordinateur sur la barre non mesuré`); + + // La position de départ : les numéros sur le cadre, en portrait aussi. + await startFrom(page, OPENING); + await checkScreen(page, theme, "numeros-sur-le-cadre", "#board"); + + // L'étape 2 du tutoriel : deux flèches circulaires sous les pions. + await page.goto(`${base}#/tutoriel/2`); + const ready = () => + until(page, "tutoriel, étape 2", () => { + const section = document.getElementById("tutorial"); + return ( + location.hash === "#/tutoriel/2" && + section.dataset.ready !== undefined && + document.querySelectorAll("#tutorial-board .tutorial-path").length === 1 + ); + }); + await ready(); + const arrows = async () => { + const drawn = await page.evaluate(() => { + const svg = document.getElementById("tutorial-board"); + const children = [...svg.children]; + const path = svg.querySelector(".tutorial-path"); + const colors = (selector) => + [...path.querySelectorAll(selector)].map((el) => getComputedStyle(el).stroke); + return { + path: children.indexOf(path), + pieces: children.findIndex((c) => c.matches("g.pieces")), + player: colors(".direction-line.direction-player"), + computer: colors(".direction-line.direction-computer"), + heads: path.querySelectorAll(".direction-head").length, + pieceColors: ["player", "computer"].map( + (side) => getComputedStyle(svg.querySelector(`.checker-${side}`)).fill, + ), + }; + }); + check(drawn.path >= 0 && drawn.path < drawn.pieces, `étape 2 : flèches au-dessus des pions ${JSON.stringify(drawn)}`); + check(drawn.player.length === 1 && drawn.computer.length === 1 && drawn.heads === 2, + `étape 2 : flèches ${JSON.stringify(drawn)}`); + // Aux couleurs des pions de chaque camp, donc jamais en brique. + check(drawn.player[0] === drawn.pieceColors[0] && drawn.computer[0] === drawn.pieceColors[1], + `étape 2 : flèches pas aux couleurs des pions ${JSON.stringify(drawn)}`); + }; + await checkScreen(page, theme, "tutoriel-2", "#tutorial-board", async () => { + await ready(); + await arrows(); + }); + + const csp = await page.evaluate(() => window.__csp); + check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`); + await context.close(); + log(`thème ${theme} : contours ${checkerContrastSummary(contrasts[theme])}`); +} + +await browser.close(); +if (full) { + // Les mesures, à côté des captures. + writeFileSync( + `${dir}/contrastes.txt`, + ["light", "dark"].map((theme) => `${theme}\t${checkerContrastSummary(contrasts[theme])}`).join("\n") + "\n", + ); +} +check(problems.length === 0, `problèmes relevés :\n ${problems.join("\n ")}`); +log( + "retours des joueurs : pions des deux camps sur la barre, numéros sur le cadre, " + + `flèches circulaires du tutoriel, thèmes clair et sombre${full ? ` ; ${captures} captures` : ""}`, +); diff --git a/web/app.css b/web/app.css index 5d658f0..7ccfe51 100644 --- a/web/app.css +++ b/web/app.css @@ -81,29 +81,23 @@ --point-a: #6b4430; --point-b: #c9ae88; --point-number: #f3ead8; - --checker-player: #f7f0e2; - --checker-player-edge: #8c7a62; - --checker-computer: #2e1c12; - --checker-computer-edge: #120a05; - --count-player: #2b1d15; - --count-computer: #f3ead8; - --die-player: #f7f0e2; - --pip-player: #2b1d15; - --die-computer: #2e1c12; - --pip-computer: #f3ead8; - --cube: #f7f0e2; - --cube-text: #2b1d15; + --checker-player: #fafaf7; + --checker-player-edge: #55554f; + --checker-computer: #1c1c1c; + --checker-computer-edge: #b5b5b0; + --count-player: #1c1c1c; + --count-computer: #fafaf7; + --die-player: #fafaf7; + --pip-player: #1c1c1c; + --die-computer: #1c1c1c; + --pip-computer: #fafaf7; + --cube: #fafaf7; + --cube-text: #1c1c1c; --highlight: #a8432b; --target: rgba(168, 67, 43, 0.32); /* Dernier coup joué (marques et traces) : le laiton, rien d'autre. */ --last-move: var(--brand-laiton); - /* Numéros des flèches. La charte les pose sur le cadre (point-number, - * ivoire) ; notre plateau les pose sur le fond, où l'ivoire ne se lirait - * pas : texte secondaire en thème clair (4,7:1 sur bed), point-number en - * thème sombre (6,3:1). */ - --point-label: var(--muted); - --shadow-raised: 0 1px 2px rgba(43, 29, 21, 0.1); --font-sans: "Alegreya Sans", "Gill Sans", "Segoe UI", system-ui, sans-serif; @@ -167,23 +161,21 @@ --point-a: #3a2519; --point-b: #7e6142; --point-number: #cdbfa8; - --checker-player: #ede3cf; - --checker-player-edge: #8e7c64; - --checker-computer: #140d09; - --checker-computer-edge: #a8957d; - --count-player: #2b1d15; - --count-computer: #f3ead8; - --die-player: #ede3cf; - --pip-player: #2b1d15; - --die-computer: #140d09; - --pip-computer: #ede3cf; - --cube: #ede3cf; - --cube-text: #2b1d15; + --checker-player: #f2f2ef; + --checker-player-edge: #6e6e69; + --checker-computer: #141414; + --checker-computer-edge: #b5b5b0; + --count-player: #1c1c1c; + --count-computer: #fafaf7; + --die-player: #f2f2ef; + --pip-player: #1c1c1c; + --die-computer: #141414; + --pip-computer: #f2f2ef; + --cube: #f2f2ef; + --cube-text: #1c1c1c; --highlight: #e07a5a; --target: rgba(224, 122, 90, 0.38); - --point-label: var(--point-number); - --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4); --scrim: rgba(0, 0, 0, 0.6); color-scheme: dark; @@ -215,23 +207,21 @@ --point-a: #3a2519; --point-b: #7e6142; --point-number: #cdbfa8; - --checker-player: #ede3cf; - --checker-player-edge: #8e7c64; - --checker-computer: #140d09; - --checker-computer-edge: #a8957d; - --count-player: #2b1d15; - --count-computer: #f3ead8; - --die-player: #ede3cf; - --pip-player: #2b1d15; - --die-computer: #140d09; - --pip-computer: #ede3cf; - --cube: #ede3cf; - --cube-text: #2b1d15; + --checker-player: #f2f2ef; + --checker-player-edge: #6e6e69; + --checker-computer: #141414; + --checker-computer-edge: #b5b5b0; + --count-player: #1c1c1c; + --count-computer: #fafaf7; + --die-player: #f2f2ef; + --pip-player: #1c1c1c; + --die-computer: #141414; + --pip-computer: #f2f2ef; + --cube: #f2f2ef; + --cube-text: #1c1c1c; --highlight: #e07a5a; --target: rgba(224, 122, 90, 0.38); - --point-label: var(--point-number); - --shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4); --scrim: rgba(0, 0, 0, 0.6); color-scheme: dark; @@ -984,9 +974,10 @@ a.notation { .point-b { fill: var(--point-b); } /* Numéros des flèches : leur taille est une mesure du dessin (attribut - * font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts). */ + * font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts). + * Posés sur le cadre, en portrait comme en paysage. */ .point-number { - fill: var(--point-label); + fill: var(--point-number); font-weight: 500; text-anchor: middle; } @@ -1118,10 +1109,8 @@ a.notation { cursor: pointer; } -/* Paysage : numéros sur le cadre (ivoire), comptes de points en pastilles - * aux bouts de la barre, consigne dans la bande, commandes à gauche. */ -.point-number.on-frame { fill: var(--point-number); } - +/* Paysage : comptes de points en pastilles aux bouts de la barre, consigne + * dans la bande, commandes à gauche. */ .pip-chip { stroke-width: 1.5; } .chip-player { fill: var(--checker-player); stroke: var(--checker-player-edge); } .chip-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); } @@ -2046,9 +2035,10 @@ html.bare .topbar { min-width: 0; } -/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique - * (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les - * deux thèmes : le liseré reste lisible sur le bois. */ +/* Le sens du jeu : deux flèches circulaires, claire pour le joueur, sombre + * pour l'ordinateur, aux couleurs de leurs pions, chacune avec un liseré de + * la couleur opposée pour rester lisible sur le bois ; jamais en brique (la + * brique veut dire « à faire »). */ .tutorial-path path { fill: none; stroke-linecap: round; @@ -2056,13 +2046,19 @@ html.bare .topbar { pointer-events: none; } -.tutorial-path .path-edge { - stroke: var(--brand-ivoire); +.tutorial-path .direction-player.direction-line { stroke: var(--checker-player); } +.tutorial-path .direction-player.direction-edge { stroke: var(--checker-computer); } +.tutorial-path .direction-computer.direction-line { stroke: var(--checker-computer); } +.tutorial-path .direction-computer.direction-edge { stroke: var(--checker-player); } + +.tutorial-path .direction-head.direction-player { + fill: var(--checker-player); + stroke: var(--checker-computer); } -.tutorial-path .path-dots, -.tutorial-path .path-head { - stroke: var(--brand-noyer); +.tutorial-path .direction-head.direction-computer { + fill: var(--checker-computer); + stroke: var(--checker-player); } /* En paysage, comme la position partagée : le plateau à gauche sur toute