Compare commits
7
Commits
1f69635c49
...
57eeb19630
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57eeb19630 | ||
|
|
5a13c40bbb | ||
|
|
8c2043aca5 | ||
|
|
966822084a | ||
|
|
09a2ddf566 | ||
|
|
cfdbc1cedc | ||
|
|
0e17334f45 |
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
+10
-5
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
}.`,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+62
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -8,8 +8,8 @@ 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 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ù
|
||||
|
||||
+11
-9
@@ -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
|
||||
|
||||
|
||||
+15
-4
@@ -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/<nom>.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"
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -167,3 +167,96 @@ 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). contrasts : le résultat de
|
||||
* checkerContrasts. Renvoie la liste des écarts, décrits (vide si tout va
|
||||
* bien).
|
||||
*/
|
||||
export function checkerContrastProblems(contrasts) {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
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(", ");
|
||||
}
|
||||
|
||||
@@ -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 <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Captures (Chromium) dans
|
||||
// <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
|
||||
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);
|
||||
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` : ""}`,
|
||||
);
|
||||
+56
-60
@@ -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: #6e6e69;
|
||||
--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
|
||||
|
||||
Reference in New Issue
Block a user