Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1eba9e35 |
@@ -365,83 +365,6 @@ export class Game {
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le coup évident qui commence par le pion de from (une case du joueur,
|
||||
* ou la barre : BAR), après les demi-coups already (par défaut, ceux
|
||||
* déjà saisis) : les demi-coups qui le complètent, ou null s'il n'y en
|
||||
* a pas
|
||||
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
|
||||
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
|
||||
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
|
||||
* positions différentes, comme dans la liste des coups de gnubg.
|
||||
*
|
||||
* Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au
|
||||
* moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le
|
||||
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
|
||||
* coups complets différents apparaissent.
|
||||
*/
|
||||
async obviousMove(
|
||||
from: number,
|
||||
already: readonly HalfMove[] = this.pendingHalfMoves,
|
||||
): Promise<HalfMove[] | null> {
|
||||
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
|
||||
const state = this.state;
|
||||
const pending = [...already];
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return null;
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
let displayed = state.position;
|
||||
if (pending.length > 0) {
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, pending);
|
||||
if (!check.valid || check.position === null) {
|
||||
return null;
|
||||
}
|
||||
displayed = check.position;
|
||||
}
|
||||
const legal = await this.#engine.legalMoves(state.position, roll);
|
||||
// Un seul coup légal : il est évident, quel que soit le pion.
|
||||
const anyChecker = legal.length === 1;
|
||||
const found = new Map<PositionId, HalfMove[]>();
|
||||
const explore = async (sequence: HalfMove[], position: PositionId): Promise<void> => {
|
||||
if (found.size > 1 || sequence.length >= limit) {
|
||||
return;
|
||||
}
|
||||
const { player } = await this.#sideBoards(position, "player", state);
|
||||
const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player);
|
||||
const candidates = new Map<string, HalfMove>();
|
||||
for (const start of starts) {
|
||||
for (const die of roll) {
|
||||
const to = Math.max(start - die, OFF);
|
||||
candidates.set(`${start}/${to}`, { from: start, to });
|
||||
}
|
||||
}
|
||||
for (const halfMove of candidates.values()) {
|
||||
if (found.size > 1) {
|
||||
return;
|
||||
}
|
||||
const next = [...sequence, halfMove];
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, [
|
||||
...pending,
|
||||
...next,
|
||||
]);
|
||||
if (!check.valid || check.position === null) {
|
||||
continue;
|
||||
}
|
||||
if (check.complete && !found.has(check.position)) {
|
||||
found.set(check.position, next);
|
||||
}
|
||||
await explore(next, check.position);
|
||||
}
|
||||
};
|
||||
await explore([], displayed);
|
||||
return found.size === 1 ? ([...found.values()][0] ?? null) : null;
|
||||
}
|
||||
|
||||
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
|
||||
* permet.) */
|
||||
async canDouble(): Promise<boolean> {
|
||||
|
||||
+5
-336
@@ -39,7 +39,7 @@ import type { Cube } from "../domain/game/cube.js";
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import { BAR, type HalfMove } from "../domain/game/position.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
@@ -59,7 +59,6 @@ import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import { createAnalysisView } from "./analysis-view.js";
|
||||
import { createPromptView, type PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { attachDrag, createDoubleTap, nextPlace } from "./board-gestures.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -73,16 +72,12 @@ import {
|
||||
import {
|
||||
type BoardAdvice,
|
||||
type BoardDecor,
|
||||
type BoardDrag,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
moveDraggedChecker,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
returnDraggedChecker,
|
||||
type ShownDie,
|
||||
type Spot,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
@@ -264,20 +259,6 @@ type ActionName =
|
||||
| "fullscreen"
|
||||
| "close";
|
||||
|
||||
/** Le premier toucher d'un double toucher : la partie, le pion touché et
|
||||
* les demi-coups saisis à ce moment-là. */
|
||||
interface ObviousTap {
|
||||
readonly game: Game;
|
||||
readonly place: Place;
|
||||
readonly pending: readonly HalfMove[];
|
||||
}
|
||||
|
||||
/** Le numéro, pour le domaine, d'un endroit du joueur : la barre (BAR) ou
|
||||
* une case de 1 à 24. */
|
||||
function pointOf(place: Place): number {
|
||||
return place === "bar-player" ? BAR : Number(place.slice(1));
|
||||
}
|
||||
|
||||
/** Démarre l'écran de jeu dans la page (index.html). */
|
||||
export function startApp(options: AppOptions): void {
|
||||
const stripOf = (id: string) => {
|
||||
@@ -430,57 +411,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
const againLabel = document.createElement("span");
|
||||
againLabel.textContent = MATCH_TEXTS.replay;
|
||||
ui.again.replaceChildren(againLabel);
|
||||
// Les raccourcis du clavier (interaction.md), en petite touche sur
|
||||
// chaque bouton ; visibles seulement quand un clavier est détecté.
|
||||
const shortcuts: [HTMLElement, string, string][] = [
|
||||
[ui.roll, "Espace", "Space"],
|
||||
[ui.confirm, "Espace", "Space"],
|
||||
[ui.skip, "Espace", "Space"],
|
||||
[ui.again, "Espace", "Space"],
|
||||
[ui.double, "D", "D"],
|
||||
[ui.take, "P", "P"],
|
||||
[ui.drop, "N", "N"],
|
||||
[ui.cancel, "Z", "Z"],
|
||||
[ui.helpButton, "?", "?"],
|
||||
[ui.fullscreenButton, "F", "F"],
|
||||
[ui.menuButton, "M", "M"],
|
||||
];
|
||||
for (const [button, key, aria] of shortcuts) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "shortcut";
|
||||
kbd.textContent = key;
|
||||
kbd.setAttribute("aria-hidden", "true");
|
||||
button.append(kbd);
|
||||
button.setAttribute("aria-keyshortcuts", aria);
|
||||
}
|
||||
// Un clavier est détecté : sur un ordinateur (pointeur fin, écran
|
||||
// large), ou dès qu'une touche est frappée ; un toucher du doigt les
|
||||
// cache de nouveau.
|
||||
const desktop = window.matchMedia("(hover: hover) and (pointer: fine) and (min-width: 64rem)");
|
||||
const showShortcuts = (on: boolean) =>
|
||||
document.documentElement.classList.toggle("keyboard", on);
|
||||
showShortcuts(desktop.matches);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key !== "Unidentified") {
|
||||
showShortcuts(true);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch") {
|
||||
showShortcuts(false);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -546,20 +477,6 @@ export function startApp(options: AppOptions): void {
|
||||
let sides: SideBoards | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
// Le pion qu'on fait glisser (charte, 1i), et celui qui vient d'être
|
||||
// lâché sur une case permise : il reste dessiné là où le doigt l'a
|
||||
// posé jusqu'à ce que le moteur ait accepté le demi-coup, puis glisse
|
||||
// à sa place (to).
|
||||
let drag: BoardDrag | null = null;
|
||||
let dropped: (BoardDrag & { readonly to: Place }) | null = null;
|
||||
// Le pion lâché hors d'une case permise rentre à sa place.
|
||||
let returning = false;
|
||||
// Clavier : la case d'arrivée choisie aux flèches haut et bas, pour le
|
||||
// pion choisi (selected) aux flèches gauche et droite.
|
||||
let keyTarget: Place | null = null;
|
||||
// Ceux qui attendent que l'écran soit libre (le double toucher qui suit
|
||||
// un pion parti dès le premier toucher).
|
||||
let idleWaiters: (() => void)[] = [];
|
||||
let canDouble = false;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit passer.
|
||||
let stuck = false;
|
||||
@@ -756,8 +673,6 @@ export function startApp(options: AppOptions): void {
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
drag: drag ?? dropped,
|
||||
focus: selected !== null && keyTarget !== null ? keyTarget : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
@@ -803,7 +718,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
againLabel.textContent = afterGameLabel(game);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -1244,40 +1159,15 @@ export function startApp(options: AppOptions): void {
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
await settleDropped();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
dropped = null;
|
||||
render();
|
||||
const waiters = idleWaiters;
|
||||
idleWaiters = [];
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Le pion lâché sur une case permise : une fois le demi-coup accepté,
|
||||
* il glisse de là où le doigt l'a posé jusqu'à sa place (duration-fast),
|
||||
* ou y apparaît, si le système demande moins de mouvement. */
|
||||
async function settleDropped(): Promise<void> {
|
||||
const landing = dropped;
|
||||
dropped = null;
|
||||
if (landing === null || reducedMotion.matches) {
|
||||
return;
|
||||
}
|
||||
render();
|
||||
await slideChecker(ui.board, landing.to, landing.at, SLIDE_MS);
|
||||
}
|
||||
|
||||
/** Résolue quand l'écran ne travaille plus (moteur, animation). */
|
||||
function whenIdle(): Promise<void> {
|
||||
return busy ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve();
|
||||
}
|
||||
|
||||
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
|
||||
* ignorées pendant ce temps. */
|
||||
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
|
||||
@@ -1286,8 +1176,6 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
keyTarget = null;
|
||||
drag = null;
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
bubbleSide = null;
|
||||
@@ -1749,64 +1637,10 @@ export function startApp(options: AppOptions): void {
|
||||
if (await g.playHalfMove(halfMove)) {
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
} else {
|
||||
dropped = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Double toucher (charte, 1i) : joue le coup évident (le domaine le
|
||||
* cherche, le moteur le juge) qui commence par le pion touché, depuis
|
||||
* l'état du premier toucher. Ce premier toucher a pu faire partir le
|
||||
* pion tout de suite (une seule case d'arrivée) : il ne reste alors que
|
||||
* la suite du coup. Sans coup évident, le second toucher n'est qu'un
|
||||
* toucher : touchers rapides sur une même pile (un double, par exemple)
|
||||
* et pion repris (touché deux fois : plus rien de choisi).
|
||||
* L'appelant a pris l'écran (busy) : le moteur est seul à travailler.
|
||||
*/
|
||||
async function playObvious(tap: ObviousTap): Promise<void> {
|
||||
const g = game;
|
||||
const now = g?.pendingHalfMoves ?? [];
|
||||
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
|
||||
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
|
||||
let rest: HalfMove[] = [];
|
||||
if (
|
||||
g === tap.game &&
|
||||
g.state.phase === "moving" &&
|
||||
now.length >= tap.pending.length &&
|
||||
tap.pending.every((h, i) => same(h, now[i]))
|
||||
) {
|
||||
// Demandé seulement maintenant : un toucher simple ne coûte rien
|
||||
// au moteur.
|
||||
const obvious = await g.obviousMove(pointOf(tap.place), tap.pending).catch(() => null);
|
||||
const done = now.slice(tap.pending.length);
|
||||
if (obvious !== null && done.every((h, i) => same(h, obvious[i]))) {
|
||||
rest = obvious.slice(done.length);
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
if (game !== g || g === null) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
tapPlace(tap.place);
|
||||
return;
|
||||
}
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
const before = (await g.displayedBoard()).computer.bar;
|
||||
for (const halfMove of rest) {
|
||||
if (!(await g.playHalfMove(halfMove))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
});
|
||||
}
|
||||
|
||||
const canAct = (): Game | null => (busy || game === null ? null : game);
|
||||
|
||||
const actions: Record<ActionName, () => void> = {
|
||||
@@ -2805,75 +2639,10 @@ export function startApp(options: AppOptions): void {
|
||||
ui.replay.onclick = actions.replay;
|
||||
ui.again.onclick = actions.again;
|
||||
|
||||
// Glisser un pion (charte, 1i) : il suit le doigt, agrandi et un peu
|
||||
// au-dessus ; un fantôme reste à son départ et ses cases d'arrivée (du
|
||||
// moteur) sont marquées. Lâché sur l'une d'elles, il s'y pose ; ailleurs,
|
||||
// il rentre à sa place, sans vibration ni message.
|
||||
const dragging = attachDrag(ui.board, {
|
||||
canStart: (place) =>
|
||||
!busy && !animating && !returning && game !== null && targets.has(place),
|
||||
start: (place, at) => {
|
||||
closeTransient();
|
||||
selected = place;
|
||||
keyTarget = null;
|
||||
shownMove = null;
|
||||
drag = { place, at };
|
||||
render();
|
||||
},
|
||||
move: (at) => {
|
||||
if (drag !== null) {
|
||||
drag = { place: drag.place, at };
|
||||
moveDraggedChecker(ui.board, at);
|
||||
}
|
||||
},
|
||||
drop: (over, at) => {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
const from = drag.place;
|
||||
const halfMove = over === null ? undefined : targets.get(from)?.get(over);
|
||||
if (halfMove === undefined || over === null || busy) {
|
||||
void dragBack(at);
|
||||
return;
|
||||
}
|
||||
dropped = { place: from, at, to: over };
|
||||
drag = null;
|
||||
playHalfMove(halfMove);
|
||||
},
|
||||
cancel: (at) => void dragBack(at),
|
||||
});
|
||||
|
||||
/** Le pion glissé rentre à sa place (duration-fast), puis le plateau
|
||||
* redevient celui d'avant le glissement. */
|
||||
async function dragBack(at: Spot): Promise<void> {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
drag = { place: drag.place, at };
|
||||
returning = true;
|
||||
try {
|
||||
if (!reducedMotion.matches) {
|
||||
await returnDraggedChecker(ui.board, at, SLIDE_MS);
|
||||
}
|
||||
} finally {
|
||||
returning = false;
|
||||
drag = null;
|
||||
selected = null;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// Double toucher : le premier toucher fait ce que fait un toucher, et
|
||||
// demande déjà au domaine le coup évident de ce pion ; le second le joue.
|
||||
const doubleTap = createDoubleTap<ObviousTap | null>();
|
||||
|
||||
// Toucher le plateau : une commande (dés, videau, colonne de gauche), ou
|
||||
// un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a
|
||||
// qu'une : il y va tout de suite).
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
if (dragging.takeClick()) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
if (!ui.bubble.hidden) {
|
||||
closeOverlays();
|
||||
@@ -2889,29 +2658,9 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
|
||||
if (place == null || game === null) {
|
||||
if (busy || place == null) {
|
||||
return;
|
||||
}
|
||||
const current = game;
|
||||
const second = doubleTap.tap(place, performance.now(), () =>
|
||||
busy || !targets.has(place)
|
||||
? null
|
||||
: { game: current, place, pending: [...current.pendingHalfMoves] },
|
||||
);
|
||||
if (second !== null) {
|
||||
doubleTapped(second);
|
||||
return;
|
||||
}
|
||||
if (!busy) {
|
||||
tapPlace(place);
|
||||
}
|
||||
});
|
||||
|
||||
/** Un toucher simple sur un endroit du plateau : le pion choisi va sur
|
||||
* cette case d'arrivée ; ou ce pion part tout de suite, s'il n'a qu'une
|
||||
* case d'arrivée ; ou il est choisi (touché de nouveau : plus rien). */
|
||||
function tapPlace(place: Place): void {
|
||||
keyTarget = null;
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
|
||||
if (halfMove !== undefined) {
|
||||
playHalfMove(halfMove);
|
||||
@@ -2927,83 +2676,7 @@ export function startApp(options: AppOptions): void {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
/** Le second toucher d'un double toucher : l'écran le prend tout de
|
||||
* suite (réponse immédiate), ou dès qu'il a fini l'effet du premier. */
|
||||
function doubleTapped(tap: ObviousTap): void {
|
||||
const claim = () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
render();
|
||||
void playObvious(tap);
|
||||
};
|
||||
if (busy) {
|
||||
void whenIdle().then(claim);
|
||||
} else {
|
||||
claim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plateau au clavier (interaction.md) : les flèches gauche et droite
|
||||
* choisissent un pion jouable (dans le sens de la course), haut et bas
|
||||
* une de ses cases d'arrivée (anneau focus), Entrée l'y pose. Les cases
|
||||
* sont celles que le moteur a données. Renvoie vrai si la touche a
|
||||
* servi.
|
||||
*/
|
||||
function boardKey(key: string, onButton: boolean): boolean {
|
||||
const g = game;
|
||||
const open =
|
||||
!ui.game.hidden &&
|
||||
ui.menuVeil.hidden &&
|
||||
ui.doubleVeil.hidden &&
|
||||
ui.explainVeil.hidden &&
|
||||
g !== null &&
|
||||
g.state.turn === "player" &&
|
||||
g.state.phase === "moving";
|
||||
if (!open || targets.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const landings = selected === null ? undefined : targets.get(selected);
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight": {
|
||||
if (busy) {
|
||||
return true;
|
||||
}
|
||||
selected = nextPlace(targets.keys(), selected, key === "ArrowRight" ? 1 : -1);
|
||||
const tos = selected === null ? undefined : targets.get(selected);
|
||||
keyTarget = tos === undefined ? null : nextPlace(tos.keys(), null, 1);
|
||||
shownMove = null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
if (busy || landings === undefined) {
|
||||
return landings !== undefined;
|
||||
}
|
||||
keyTarget = nextPlace(landings.keys(), keyTarget, key === "ArrowDown" ? 1 : -1);
|
||||
render();
|
||||
return true;
|
||||
case "Enter": {
|
||||
const halfMove =
|
||||
keyTarget === null || landings === undefined
|
||||
? undefined
|
||||
: landings.get(keyTarget);
|
||||
if (onButton || halfMove === undefined || busy) {
|
||||
return false;
|
||||
}
|
||||
keyTarget = null;
|
||||
playHalfMove(halfMove);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clavier (interaction.md) : les mêmes actions que les touchers.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@@ -3030,10 +2703,6 @@ export function startApp(options: AppOptions): void {
|
||||
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (boardKey(event.key, onButton)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
let name: ActionName | null = null;
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// Les gestes sur le plateau : glisser-déposer et double toucher (charte,
|
||||
// docs/identite/charte/ecrans-etape-1.md, 1i), et l'ordre des pions et des
|
||||
// cases au clavier (interaction.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un seul code pour la souris, le doigt et le stylet : les Pointer Events.
|
||||
// Ce module ne connaît ni la partie ni le moteur : il dit où le pion est
|
||||
// pris, où il passe et où il est lâché ; l'écran décide (app.ts), avec les
|
||||
// cases d'arrivée que le moteur a données.
|
||||
|
||||
import type { Place } from "./board-model.js";
|
||||
import type { Spot } from "./board-view.js";
|
||||
|
||||
/** Au-delà de cette distance (px à l'écran), un appui devient un glissement :
|
||||
* en deçà, c'est un toucher. */
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
/** Le pion glissé est dessiné au-dessus du doigt, pour rester visible
|
||||
* (charte, 1i). */
|
||||
const LIFT_PX = 12;
|
||||
/** Deux touchers sur le même pion à moins de tant de ms : un double
|
||||
* toucher. */
|
||||
export const DOUBLE_TAP_MS = 350;
|
||||
/** Après un glissement, le « click » que le navigateur envoie au lâcher
|
||||
* n'est pas un toucher. */
|
||||
const CLICK_AFTER_DRAG_MS = 400;
|
||||
|
||||
/** Ce que l'écran fait d'un glissement. */
|
||||
export interface DragHandlers {
|
||||
/** Ce pion peut-il partir maintenant (pion jouable, moteur libre) ? */
|
||||
canStart(place: Place): boolean;
|
||||
/** Le pion de place commence à glisser ; at : son centre, dans le repère
|
||||
* du dessin. */
|
||||
start(place: Place, at: Spot): void;
|
||||
move(at: Spot): void;
|
||||
/** Le pion est lâché en at, au-dessus de over (null : hors de toute
|
||||
* zone du plateau). */
|
||||
drop(over: Place | null, at: Spot): void;
|
||||
/** Le glissement est interrompu par le navigateur (appel, geste du
|
||||
* système) : le pion rentre. */
|
||||
cancel(at: Spot): void;
|
||||
}
|
||||
|
||||
/** L'endroit du plateau sous un élément touché (sa zone data-place). */
|
||||
function placeOf(target: EventTarget | null): Place | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
return (target.closest("[data-place]")?.getAttribute("data-place") as Place | null) ?? null;
|
||||
}
|
||||
|
||||
/** Le point du dessin sous le pointeur, remonté de LIFT_PX vers le haut du
|
||||
* plateau (null si le dessin n'est pas affiché). */
|
||||
function liftedPoint(svg: SVGSVGElement, event: PointerEvent): Spot | null {
|
||||
const matrix = svg.getScreenCTM();
|
||||
if (matrix === null) {
|
||||
return null;
|
||||
}
|
||||
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
|
||||
const unit = 1 / (Math.hypot(matrix.a, matrix.b) || 1);
|
||||
return { x: point.x, y: point.y - LIFT_PX * unit };
|
||||
}
|
||||
|
||||
/** La zone du plateau (data-place) qui contient at, dans le repère du
|
||||
* dessin (null : aucune). */
|
||||
export function placeAtSpot(svg: SVGSVGElement, at: Spot): Place | null {
|
||||
for (const hit of svg.querySelectorAll(".hit[data-place]")) {
|
||||
const number = (name: string) => Number(hit.getAttribute(name));
|
||||
const x = number("x");
|
||||
const y = number("y");
|
||||
if (at.x >= x && at.x <= x + number("width") && at.y >= y && at.y <= y + number("height")) {
|
||||
return hit.getAttribute("data-place") as Place;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche le glisser-déposer sur le plateau svg. Renvoie takeClick : vrai
|
||||
* si le « click » reçu suit un glissement (il faut alors l'ignorer).
|
||||
*/
|
||||
export function attachDrag(svg: SVGSVGElement, handlers: DragHandlers): { takeClick(): boolean } {
|
||||
let press: { id: number; place: Place; x: number; y: number } | null = null;
|
||||
let dragging = false;
|
||||
let clickIgnoredUntil = 0;
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
if (!event.isPrimary || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const place = placeOf(event.target);
|
||||
press =
|
||||
place !== null && handlers.canStart(place)
|
||||
? { id: event.pointerId, place, x: event.clientX, y: event.clientY }
|
||||
: null;
|
||||
dragging = false;
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const at = liftedPoint(svg, event);
|
||||
if (at === null) {
|
||||
return;
|
||||
}
|
||||
if (dragging) {
|
||||
handlers.move(at);
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
||||
if (distance < DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
if (!handlers.canStart(press.place)) {
|
||||
press = null;
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
// Le plateau est redessiné pendant le glissement : c'est lui, et non
|
||||
// la zone touchée, qui reçoit la suite du geste.
|
||||
try {
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Sans capture, les événements remontent quand même au plateau.
|
||||
}
|
||||
handlers.start(press.place, at);
|
||||
});
|
||||
const end = (event: PointerEvent, cancelled: boolean) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const wasDragging = dragging;
|
||||
press = null;
|
||||
dragging = false;
|
||||
if (!wasDragging) {
|
||||
return;
|
||||
}
|
||||
clickIgnoredUntil = performance.now() + CLICK_AFTER_DRAG_MS;
|
||||
const at = liftedPoint(svg, event) ?? { x: 0, y: 0 };
|
||||
if (cancelled) {
|
||||
handlers.cancel(at);
|
||||
} else {
|
||||
handlers.drop(placeAtSpot(svg, at), at);
|
||||
}
|
||||
};
|
||||
svg.addEventListener("pointerup", (event) => end(event, false));
|
||||
svg.addEventListener("pointercancel", (event) => end(event, true));
|
||||
return {
|
||||
takeClick: () => {
|
||||
const ignored = performance.now() < clickIgnoredUntil;
|
||||
clickIgnoredUntil = 0;
|
||||
return ignored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnaît le double toucher : tap(place, now, first) renvoie ce que first
|
||||
* a produit au premier toucher quand celui-ci est le second, sur le même
|
||||
* endroit, à moins de DOUBLE_TAP_MS ; sinon null, et ce toucher devient le
|
||||
* premier (first() n'est appelé que pour lui).
|
||||
*/
|
||||
export function createDoubleTap<T>(): {
|
||||
tap(place: Place, now: number, first: () => T): T | null;
|
||||
} {
|
||||
let last: { place: Place; time: number; data: T } | null = null;
|
||||
return {
|
||||
tap(place, now, first) {
|
||||
if (last !== null && last.place === place && now - last.time < DOUBLE_TAP_MS) {
|
||||
const data = last.data;
|
||||
last = null;
|
||||
return data;
|
||||
}
|
||||
last = { place, time: now, data: first() };
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Le numéro d'un endroit dans le sens de la course du joueur : la barre
|
||||
* (25), les cases de 24 à 1, la sortie (0). */
|
||||
function raceOrder(place: Place): number {
|
||||
if (place.startsWith("bar-")) {
|
||||
return 25;
|
||||
}
|
||||
if (place.startsWith("off-")) {
|
||||
return 0;
|
||||
}
|
||||
return Number(place.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clavier : l'endroit suivant (step 1) ou précédent (step -1) de places
|
||||
* après current, dans le sens de la course du joueur (de la barre vers la
|
||||
* sortie), en bouclant. Sans current (ou s'il n'en fait pas partie), le
|
||||
* premier dans ce sens. null si places est vide.
|
||||
*/
|
||||
export function nextPlace(
|
||||
places: Iterable<Place>,
|
||||
current: Place | null,
|
||||
step: 1 | -1,
|
||||
): Place | null {
|
||||
const sorted = [...places].sort((a, b) => raceOrder(b) - raceOrder(a));
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = current === null ? -1 : sorted.indexOf(current);
|
||||
if (index === -1) {
|
||||
return (step === 1 ? sorted[0] : sorted.at(-1)) ?? null;
|
||||
}
|
||||
return sorted[(index + step + sorted.length) % sorted.length] ?? null;
|
||||
}
|
||||
+10
-117
@@ -86,23 +86,8 @@ export interface BoardDecor {
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
/** Le pion qu'on fait glisser (absent ou null : aucun). */
|
||||
readonly drag?: BoardDrag | null;
|
||||
/** Clavier : l'endroit qui a le focus (anneau focus ; absent ou null :
|
||||
* aucun). */
|
||||
readonly focus?: Place | null;
|
||||
}
|
||||
|
||||
/** Un pion qu'on fait glisser : il a quitté place (un fantôme en
|
||||
* pointillés y reste) et son centre est en at, dans le repère du dessin. */
|
||||
export interface BoardDrag {
|
||||
readonly place: Place;
|
||||
readonly at: Spot;
|
||||
}
|
||||
|
||||
/** Le pion qu'on fait glisser est agrandi (charte, 1i : 115 %). */
|
||||
export const DRAG_SCALE = 1.15;
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
@@ -218,9 +203,6 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.focus != null) {
|
||||
drawFocus(svg, g, board, decor.focus, decor.targets.has(decor.focus));
|
||||
}
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
@@ -253,9 +235,6 @@ export function renderBoard(
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
if (decor.drag != null) {
|
||||
drawDrag(svg, g, decor.drag);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -380,13 +359,18 @@ function drawStack(
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
if (last) {
|
||||
piece.setAttribute("data-checker", place);
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
piece,
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
@@ -497,23 +481,6 @@ function drawTray(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Un pion vu de dessus, centré en spot : son disque (classes cssClass) et
|
||||
* son filet intérieur, l'écho du logo (charte, board.js : rayon 12 pour un
|
||||
* pion de 20). Les deux sont groupés : c'est le groupe qui glisse.
|
||||
*/
|
||||
function addPiece(svg: Element, g: Geometry, side: Side, cssClass: string, spot: Spot): SVGElement {
|
||||
const piece = add(svg, "g", { class: "piece" });
|
||||
add(piece, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r: g.radius });
|
||||
add(piece, "circle", {
|
||||
class: `checker-ring ring-${side}`,
|
||||
cx: spot.x,
|
||||
cy: spot.y,
|
||||
r: g.radius * 0.6,
|
||||
});
|
||||
return piece;
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
@@ -843,87 +810,13 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
return shapeCenter(checker.tagName === "g" ? (checker.firstElementChild ?? checker) : checker);
|
||||
}
|
||||
|
||||
/** Le centre d'un disque (cx, cy) ou d'un rectangle (x, y, largeur, hauteur). */
|
||||
function shapeCenter(shape: Element): Spot {
|
||||
const number = (name: string) => Number(shape.getAttribute(name));
|
||||
if (shape.tagName === "circle") {
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pion qu'on fait glisser : le pion de départ devient un fantôme en
|
||||
* pointillés, et un pion agrandi est dessiné en drag.at, devant les autres.
|
||||
* Il se déplace par son attribut transform (moveDraggedChecker), sans
|
||||
* redessiner le plateau.
|
||||
*/
|
||||
function drawDrag(svg: SVGSVGElement, g: Geometry, drag: BoardDrag): void {
|
||||
svg.querySelector(`[data-checker="${drag.place}"]`)?.classList.add("drag-origin");
|
||||
const group = add(svg, "g", {
|
||||
class: "drag-piece",
|
||||
id: "drag-piece",
|
||||
transform: dragTransform(drag.at, DRAG_SCALE),
|
||||
});
|
||||
addPiece(group, g, "player", "checker checker-player dragged", { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function dragTransform(at: Spot, scale: number): string {
|
||||
return `translate(${at.x} ${at.y}) scale(${scale})`;
|
||||
}
|
||||
|
||||
/** Déplace le pion qu'on fait glisser en at (repère du dessin). */
|
||||
export function moveDraggedChecker(svg: SVGSVGElement, at: Spot): void {
|
||||
svg.querySelector("#drag-piece")?.setAttribute("transform", dragTransform(at, DRAG_SCALE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ramène le pion qu'on fait glisser à son départ (le centre du fantôme), en
|
||||
* ms millisecondes, et à sa taille ; résolue à la fin (animation du
|
||||
* navigateur, sans bibliothèque).
|
||||
*/
|
||||
export async function returnDraggedChecker(
|
||||
svg: SVGSVGElement,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const piece = svg.querySelector("#drag-piece");
|
||||
const origin = svg.querySelector(".drag-origin");
|
||||
if (piece === null || origin === null || typeof piece.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
const to = shapeCenter(origin.firstElementChild ?? origin);
|
||||
const animation = piece.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x}px, ${from.y}px) scale(${DRAG_SCALE})` },
|
||||
{ transform: `translate(${to.x}px, ${to.y}px) scale(1)` },
|
||||
],
|
||||
{ duration: ms, easing: EASING_STANDARD, fill: "forwards" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Clavier : l'anneau focus autour de l'endroit choisi, sur la case
|
||||
* d'arrivée (target) ou sur le pion du dessus. */
|
||||
function drawFocus(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
place: Place,
|
||||
target: boolean,
|
||||
): void {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = target
|
||||
? place.startsWith("off-")
|
||||
? count
|
||||
: Math.min(count, MAX_STACK - 1)
|
||||
: Math.max(topIndex(g, board, "player", place), 0);
|
||||
addRing(svg, g, "key-focus", place, slotCenter(g, place, index), g.radius + 5);
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Les gestes du plateau qui se vérifient sans navigateur : le double toucher
|
||||
// et l'ordre des pions et des cases au clavier. Le glisser-déposer lui-même
|
||||
// est joué dans un navigateur (tests/browser/plateau.mjs).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createDoubleTap, DOUBLE_TAP_MS, nextPlace } from "../src/ui/board-gestures.js";
|
||||
import type { Place } from "../src/ui/board-model.js";
|
||||
|
||||
test("double toucher : deux touchers rapprochés sur le même endroit", () => {
|
||||
const taps = createDoubleTap<string>();
|
||||
let firsts = 0;
|
||||
const first = () => {
|
||||
firsts++;
|
||||
return `premier ${firsts}`;
|
||||
};
|
||||
assert.equal(taps.tap("p8", 0, first), null);
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS - 1, first), "premier 1");
|
||||
// Le troisième toucher recommence un double toucher.
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS, first), null);
|
||||
// Trop lent : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p8", 3 * DOUBLE_TAP_MS, first), null);
|
||||
// Un autre endroit : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 10, first), null);
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 20, first), "premier 4");
|
||||
assert.equal(firsts, 4);
|
||||
});
|
||||
|
||||
test("clavier : les pions dans le sens de la course, en boucle", () => {
|
||||
const places: Place[] = ["p6", "bar-player", "p13", "p8"];
|
||||
assert.equal(nextPlace(places, null, 1), "bar-player");
|
||||
assert.equal(nextPlace(places, null, -1), "p6");
|
||||
assert.equal(nextPlace(places, "bar-player", 1), "p13");
|
||||
assert.equal(nextPlace(places, "p8", 1), "p6");
|
||||
assert.equal(nextPlace(places, "p6", 1), "bar-player");
|
||||
assert.equal(nextPlace(places, "bar-player", -1), "p6");
|
||||
// Un endroit qui n'est plus jouable : on repart du début.
|
||||
assert.equal(nextPlace(places, "p24", 1), "bar-player");
|
||||
assert.equal(nextPlace([], null, 1), null);
|
||||
// Les cases d'arrivée, sortie comprise.
|
||||
assert.equal(nextPlace(["off-player", "p2"], "p2", 1), "off-player");
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le
|
||||
// vrai moteur WebAssembly : un seul coup légal complet commence par le pion
|
||||
// touché, ou le moteur ne propose qu'un coup.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1.
|
||||
const SIX_AND_ONE = "BQAACAIAAAAAAA";
|
||||
// Le joueur, au trait : deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
let engine: Engine | null = null;
|
||||
async function loaded(): Promise<Engine> {
|
||||
engine ??= await loadNodeEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Une partie à partir d'une position, joueur au trait, dés lancés. */
|
||||
async function from(position: string, dice: [number, number]): Promise<Game> {
|
||||
const game = Game.start(await loaded(), {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
await game.roll();
|
||||
return game;
|
||||
}
|
||||
|
||||
function text(halfMoves: readonly HalfMove[] | null): string | null {
|
||||
return halfMoves === null ? null : formatHalfMoves(halfMoves);
|
||||
}
|
||||
|
||||
test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) });
|
||||
for (const point of [24, 13, 8, 6]) {
|
||||
assert.equal(await game.obviousMove(point), null, `pion de la ${point}`);
|
||||
}
|
||||
// Rien n'a été saisi.
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
});
|
||||
|
||||
test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => {
|
||||
const game = await from(SIX_AND_ONE, [6, 1]);
|
||||
// 1/off, puis le 6 sort l'autre pion : un seul coup.
|
||||
assert.equal(text(await game.obviousMove(1)), "1/off 6/off");
|
||||
// Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste).
|
||||
assert.equal(await game.obviousMove(6), null);
|
||||
// Une case sans pion n'a pas de coup.
|
||||
assert.equal(await game.obviousMove(3), null);
|
||||
});
|
||||
|
||||
test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => {
|
||||
const game = await from(PLAYER_ALMOST_DONE, [6, 6]);
|
||||
assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1);
|
||||
const fromFive = await game.obviousMove(5);
|
||||
const fromSix = await game.obviousMove(6);
|
||||
assert.ok(fromFive !== null && fromSix !== null);
|
||||
assert.equal(fromFive.length, 2);
|
||||
assert.equal(fromSix.length, 2);
|
||||
// Une fois 6/off saisi, il ne reste que 5/off.
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true);
|
||||
assert.equal(text(await game.obviousMove(5)), "5/off");
|
||||
// Le coup évident se joue tel quel, demi-coup par demi-coup.
|
||||
for (const halfMove of (await game.obviousMove(5)) ?? []) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
});
|
||||
|
||||
test("hors du tour de jeu du joueur, aucun coup évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.equal(await game.obviousMove(13), null);
|
||||
});
|
||||
@@ -511,20 +511,3 @@ Then("le résultat indique le gagnant et les points gagnés", () => {
|
||||
// règle Jacoby, le videau n'a pas bougé.
|
||||
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
|
||||
});
|
||||
|
||||
// ------------------------------------------------- Glisser, double toucher
|
||||
|
||||
When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => {
|
||||
positionBefore = game.displayedPosition;
|
||||
const obvious = await game.obviousMove(point);
|
||||
accepted = obvious !== null;
|
||||
for (const halfMove of obvious ?? []) {
|
||||
accepted &&= await game.playHalfMove(halfMove);
|
||||
}
|
||||
});
|
||||
|
||||
Then("aucun pion n'a bougé", () => {
|
||||
assert.equal(accepted, false);
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.displayedPosition, positionBefore);
|
||||
});
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné (fait) | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test (fait) | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Guide de test de l'étape 1 : « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il sert à la vérification de l'étape 1 avec Pierre, sur
|
||||
le Fairphone 4, et sera supprimé à la clôture de l'étape (ce qui en reste de
|
||||
durable va dans le journal).
|
||||
|
||||
**Critère de fin de l'étape** (cadrage) : un débutant apprend et joue seul ; un
|
||||
joueur de club comprend ses erreurs.
|
||||
|
||||
Le guide suit deux parcours : celui du **débutant** (parties A à C) et celui du
|
||||
**joueur de club** (parties D à F). Une partie G regroupe ce qui est commun
|
||||
(installation, hors ligne, mise à jour, affichage). Comptez environ une heure
|
||||
pour l'ensemble, en plusieurs fois si besoin : l'application reprend là où
|
||||
vous en étiez.
|
||||
|
||||
Pour chaque point : cochez ce qui marche, et notez ce qui coince avec le
|
||||
numéro du point (par exemple « C4 : le bilan s'ouvre deux fois »). Une capture
|
||||
d'écran aide beaucoup.
|
||||
|
||||
## Avant de commencer
|
||||
|
||||
- Adresse : https://ustagammon.sans.pub, dans Chrome sur le Fairphone 4.
|
||||
- Version : le menu, puis « Aide et glossaire », onglet « À propos », donne le
|
||||
numéro de version (un commit). Notez-le en tête de vos retours.
|
||||
- Pour repartir de zéro (comme un nouveau joueur) : dans Chrome, Paramètres du
|
||||
site pour ustagammon.sans.pub, puis « Effacer et réinitialiser ». Toutes les
|
||||
parties et la progression sont alors perdues.
|
||||
|
||||
## A. Premier contact (débutant)
|
||||
|
||||
1. Ouvrez l'application après l'avoir réinitialisée. L'écran de premier
|
||||
lancement propose « Je découvre » et « Je connais les règles ».
|
||||
2. Touchez « Je découvre » : le tutoriel des règles s'ouvre à l'étape 1 sur 8.
|
||||
3. Suivez les huit étapes jusqu'au bout. À chaque écran « à vous » : jouez
|
||||
d'abord un mauvais coup (le pion revient, une phrase explique sans
|
||||
reproche), puis le bon (coche, « Continuer »).
|
||||
4. Étape 2 : deux flèches circulaires montrent le sens de chaque camp, sous
|
||||
les pions.
|
||||
5. Le texte est-il clair pour quelqu'un qui n'a jamais joué ? Notez les mots
|
||||
ou phrases qui bloquent. Les mots soulignés ouvrent leur fiche du glossaire.
|
||||
6. À la fin, l'accueil propose la suite (partie, leçons).
|
||||
|
||||
**À rapporter** : une étape incompréhensible, un coup juste refusé ou un coup
|
||||
faux accepté, un texte coupé ou qui déborde.
|
||||
|
||||
## B. Première partie (débutant)
|
||||
|
||||
1. Depuis l'accueil, « Nouvelle partie » : choisissez « Partie libre » et le
|
||||
niveau « Débutant », conseil du maître activé, puis « Commencer la partie ».
|
||||
2. Touchez les dés pour les lancer. Touchez un pion : s'il n'a qu'une case
|
||||
d'arrivée, il y va tout seul ; sinon, ses cases d'arrivée s'allument.
|
||||
3. Essayez aussi de glisser un pion avec le doigt jusqu'à sa case ; lâché
|
||||
ailleurs, il revient à sa place.
|
||||
4. Un double toucher sur un pion joue le coup évident s'il n'y en a qu'un.
|
||||
5. Avant de valider, « Annuler » reprend le dernier pion, un à la fois. Puis
|
||||
validez en touchant les dés ou « Jouer ce coup ».
|
||||
6. Après votre coup, une ligne discrète du maître apparaît (« Bon coup. »,
|
||||
« Erreur. J'aurais joué… »). Touchez-la : la feuille montre votre coup et
|
||||
celui du maître, joués pion par pion.
|
||||
7. Regardez le coup de l'ordinateur : ses pions glissent, des traces restent,
|
||||
« Revoir » le rejoue. Touchez la notation du coup : la fiche « Notation des
|
||||
coups » s'ouvre.
|
||||
8. Quand aucun coup n'est possible, les dés sont barrés : touchez-les pour
|
||||
passer.
|
||||
9. Jouez la partie jusqu'au bout.
|
||||
|
||||
**À rapporter** : un pion qui ne bouge pas comme prévu, un geste raté, une
|
||||
phrase du maître fausse ou blessante, la durée d'un tour de l'ordinateur si
|
||||
elle dépasse 2 s.
|
||||
|
||||
## C. Bilan, leçons et retour (débutant)
|
||||
|
||||
1. En fin de partie, le bilan s'affiche : l'issue, ce qui a fait la
|
||||
différence, vos décisions comptées, votre jeu et vos dés, le coup à revoir.
|
||||
2. « Rejouer » relance une partie avec les mêmes réglages ; « Changer les
|
||||
réglages » ouvre l'écran « Nouvelle partie ».
|
||||
3. Depuis l'accueil, ouvrez « Toutes les leçons » puis la leçon 1. Suivez-la,
|
||||
en faisant au moins un exercice faux puis juste.
|
||||
4. Revenez à la liste : la leçon affiche « k exercices sur n ». Fermez
|
||||
l'application, rouvrez-la : la progression est gardée.
|
||||
5. Faites les cinq leçons si vous avez le temps : chaque réponse attendue est
|
||||
le meilleur coup du moteur.
|
||||
|
||||
**À rapporter** : une explication de leçon qui vous semble fausse ou trop
|
||||
difficile pour un débutant, un exercice où le refus ne met pas sur la voie.
|
||||
|
||||
## D. Match et niveaux (joueur de club)
|
||||
|
||||
1. « Nouvelle partie » : choisissez « Match », 3 points, niveau « Avancé »,
|
||||
puis « Commencer le match ».
|
||||
2. Le score du match s'affiche (« Match en 3 points ») ; à 2 points d'un
|
||||
joueur, la partie Crawford est signalée et le videau disparaît.
|
||||
3. Doublez au moins une fois (le videau demande confirmation). Répondez au
|
||||
moins une fois à un double de l'ordinateur (Prendre ou Passer).
|
||||
4. Entre deux parties, « Partie suivante » ; à la fin du match, le score final.
|
||||
5. Le niveau de l'ordinateur paraît-il juste ? « Champion » est le plus fort.
|
||||
|
||||
**À rapporter** : une décision de videau de l'ordinateur qui vous surprend
|
||||
(notez la position avec « Copier le lien de la position » dans le menu), un
|
||||
score de match faux.
|
||||
|
||||
## E. Comprendre ses erreurs (joueur de club)
|
||||
|
||||
1. Après une partie, depuis le bilan, « Voir l'analyse ».
|
||||
2. La courbe de vos chances de gagner se parcourt du doigt ; toucher un point
|
||||
ouvre le coup.
|
||||
3. Le visualiseur montre « Votre coup » et « Le coup du maître ». Les chiffres
|
||||
du moteur sont repliés en dessous.
|
||||
4. Filtrez la liste des coups par appréciation (erreurs, grosses erreurs).
|
||||
5. Sur un coup, « Rejouer d'ici » : une branche part de ce coup, la partie
|
||||
d'origine reste dans « Mes parties ».
|
||||
6. Sur une erreur, « Demander à une IA » : le prompt s'affiche, « Copier le
|
||||
prompt » confirme par « ✓ Copié ». Collez-le dans l'IA de votre choix et
|
||||
jugez la réponse.
|
||||
7. Ouvrez la progression (la ligne de l'accueil) après trois parties : la
|
||||
courbe du taux d'erreur, la liste des parties, « Créer des exercices ».
|
||||
8. « Mes parties » (menu) : reprenez une ancienne partie, exportez-en une en
|
||||
SGF et ouvrez-la dans GNU Backgammon de bureau (`load game fichier.sgf`).
|
||||
|
||||
**À rapporter** : une analyse qui contredit ce que vous pensiez (c'est peut-être
|
||||
juste, mais notez-la), un prompt incomplet, une partie absente de la liste.
|
||||
|
||||
## F. Liens et positions (joueur de club)
|
||||
|
||||
1. Dans le menu, « Copier le lien de la position ». Ouvrez ce lien dans un
|
||||
nouvel onglet : la même position s'affiche, avec « Jouer à partir d'ici ».
|
||||
2. L'aide, une question de l'aide, un terme du glossaire, la progression : chaque
|
||||
écran a son adresse ; le bouton retour du téléphone ferme d'abord la feuille
|
||||
ouverte, puis revient à l'écran précédent.
|
||||
|
||||
## G. Commun : installation, hors ligne, affichage
|
||||
|
||||
1. Après une partie terminée, l'accueil propose « Installer l'application » :
|
||||
installez-la, puis ouvrez-la depuis l'écran d'accueil du téléphone.
|
||||
2. Mode avion : l'application s'ouvre et se joue normalement.
|
||||
3. Tournez le téléphone : en paysage, le plateau prend l'écran, la partie
|
||||
continue sans perte. Essayez « Plein écran » dans le menu.
|
||||
4. Menu, thème clair puis sombre : les pions blancs et noirs se distinguent
|
||||
bien partout, y compris sur la barre.
|
||||
5. Vibrations : pion posé, pion frappé, double de l'ordinateur, fin de partie.
|
||||
Coupez-les dans le menu.
|
||||
6. Mise à jour : à la prochaine publication, le bandeau « Nouvelle version
|
||||
prête » apparaît en bas ; « Mettre à jour » vous ramène à votre partie.
|
||||
7. Performances : notez si l'application ralentit, chauffe ou se recharge
|
||||
seule (elle fait tourner deux moteurs : un pour jouer, un pour analyser).
|
||||
|
||||
## Ce qu'il faut rapporter
|
||||
|
||||
- Le numéro de version (onglet « À propos »).
|
||||
- Pour chaque problème : le numéro du point, ce que vous avez fait, ce que vous
|
||||
attendiez, ce qui s'est passé, et si possible une capture ou le lien de la
|
||||
position.
|
||||
- Votre avis sur les deux critères : un débutant peut-il apprendre et jouer
|
||||
seul ? Un joueur de club comprend-il ses erreurs ?
|
||||
@@ -47,6 +47,3 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
|
||||
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
|
||||
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
|
||||
- [ ] Glisser-déposer : le pion suit le doigt, agrandi et au-dessus de lui, un fantôme en pointillés à son départ ; lâché hors d'une case permise, il revient à sa place sans vibration ni message (`tests/browser/plateau.mjs`, captures `plateau/glissement-*`).
|
||||
- [ ] Double toucher : le coup évident seulement (un seul coup commence par ce pion, ou un seul coup possible) ; sinon rien de plus qu'un toucher.
|
||||
- [ ] Clavier : `←` `→` choisissent un pion, `↑` `↓` sa case (anneau `focus`), `Entrée` le pose ; les raccourcis s'affichent en `kbd` sur les boutons dès qu'un clavier est détecté (capture `plateau/clavier-*`).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Plateau soigné : gestes et finitions (1i)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1i (`docs/etape-1.md`)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
|
||||
- les gestes naturels (principe d'expérience 2) ;
|
||||
- les écarts restants avec `docs/identite/charte/board.js`.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
|
||||
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
|
||||
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
|
||||
4. **Clavier.** Faire ce que la révision UX a laissé :
|
||||
- le choix des pions aux flèches ;
|
||||
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
|
||||
5. **Tests.**
|
||||
- Scénarios Gherkin si un nouveau comportement est visible.
|
||||
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -40,24 +40,6 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
@position:BQAACAIAAAAAAA
|
||||
Scénario: Puis-je faire glisser un pion, ou jouer au clavier ?
|
||||
Vous pouvez aussi faire glisser un pion jusqu'à sa case d'arrivée : il
|
||||
suit votre doigt, juste au-dessus pour rester visible, et un pion en
|
||||
pointillés marque sa place de départ. Lâché ailleurs que sur une case
|
||||
permise, il revient à sa place. Touchez deux fois de suite un pion pour
|
||||
jouer le coup évident : quand un seul coup commence par ce pion, ou que
|
||||
vous n'avez qu'un coup possible. Sinon, rien de plus ne se passe. Votre
|
||||
coup attend ensuite votre validation, comme d'habitude. Au clavier, les
|
||||
flèches gauche et droite choisissent un pion, les flèches haut et bas sa
|
||||
case d'arrivée, et Entrée le pose.
|
||||
|
||||
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 6
|
||||
Alors aucun pion n'a bougé
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 1
|
||||
Alors vos deux pions sont sortis
|
||||
|
||||
Scénario: Je me suis trompé de pion
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
|
||||
+4
-15
@@ -20,14 +20,10 @@
|
||||
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
|
||||
# build/browser-shots/retours-joueurs/. Les leçons (tests/browser/lecons.mjs :
|
||||
# carte de l'accueil, liste, explication, exercice refusé puis réussi,
|
||||
# progression gardée), captures dans build/browser-shots/lecons/. Les gestes
|
||||
# du plateau soigné (tests/browser/plateau.mjs : glisser-déposer, pion lâché
|
||||
# hors cible, double toucher, clavier, filet des pions), captures dans
|
||||
# build/browser-shots/plateau/.
|
||||
# progression gardée), captures dans build/browser-shots/lecons/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts, nouvelle-partie, retours-joueurs, lecons, plateau) restreignent le
|
||||
# test.
|
||||
# prompts, nouvelle-partie, retours-joueurs, lecons) 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
|
||||
@@ -40,7 +36,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 retours-joueurs lecons plateau}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -79,7 +75,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 \
|
||||
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs tests/browser/plateau.mjs "$tools/"
|
||||
tests/browser/retours-joueurs.mjs tests/browser/lecons.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
|
||||
@@ -149,12 +145,5 @@ for browser in $browsers; do
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node lecons.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" plateau "* ]]; then
|
||||
# Gestes du plateau : glisser-déposer, lâcher hors cible, double
|
||||
# toucher, clavier ; captures dans build/browser-shots/plateau/.
|
||||
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 plateau.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -329,8 +329,8 @@ for (;;) {
|
||||
await page.click("#summary-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again > span")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte,
|
||||
// ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans
|
||||
// écran (Playwright), sur le site servi par l'image de production :
|
||||
//
|
||||
// - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion
|
||||
// glissé agrandi, cases d'arrivée du moteur) ;
|
||||
// - un pion lâché hors d'une case permise revient à sa place, sans rien
|
||||
// jouer ;
|
||||
// - le double toucher : rien quand plusieurs coups commencent par le pion,
|
||||
// le coup évident sinon ;
|
||||
// - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd
|
||||
// affichés une fois le clavier détecté ;
|
||||
// - le filet intérieur des pions ;
|
||||
// - aucune erreur, aucune violation de la politique de sécurité.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node plateau.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici
|
||||
// par une file de valeurs choisies (window.__dice), puis par un générateur à
|
||||
// graine. Captures de la recette design (Chromium) dans
|
||||
// <dossier des captures>/plateau/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node plateau.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/plateau`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un
|
||||
// sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident).
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
const SIX_AND_ONE = "BQAACAIAAAAAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
window.__dice = [];
|
||||
let state = 7;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
if (localStorage.getItem("ustagammon.settings") === null) {
|
||||
localStorage.setItem("ustagammon.settings", JSON.stringify({ 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()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** 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(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}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une nouvelle partie à partir d'une position, joueur au trait, puis les
|
||||
* dés imposés lancés. */
|
||||
async function startFrom(position, dice) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until(
|
||||
"partie commencée",
|
||||
() =>
|
||||
location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
await page.evaluate((d) => window.__dice.push(...d), dice);
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** L'état du plateau : pions par endroit (pion du dessus marqué), pion
|
||||
* choisi, cases d'arrivée, glissement en cours, dés joués. */
|
||||
function boardState() {
|
||||
return page.evaluate(() => {
|
||||
const board = document.getElementById("board");
|
||||
const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null;
|
||||
return {
|
||||
at: Object.fromEntries(
|
||||
["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]),
|
||||
),
|
||||
selected: board.querySelector(".checker.selected")?.closest("[data-checker]")
|
||||
?.getAttribute("data-checker") ?? null,
|
||||
landing: [...board.querySelectorAll(".hit.can-land")].map((e) =>
|
||||
e.getAttribute("data-place"),
|
||||
),
|
||||
dragging: board.querySelector("#drag-piece") !== null,
|
||||
ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null,
|
||||
used: board.querySelectorAll(".die-used").length,
|
||||
focus: board.querySelector(".key-focus") !== null,
|
||||
rings: board.querySelectorAll(".checker-ring").length,
|
||||
checkers: board.querySelectorAll("circle.checker").length,
|
||||
confirm: !document.getElementById("confirm").hidden,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, de la zone à toucher d'un endroit. */
|
||||
async function center(place) {
|
||||
const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox();
|
||||
check(box !== null, `zone ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, du pion du dessus d'un endroit. */
|
||||
async function checkerCenter(place) {
|
||||
const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox();
|
||||
check(box !== null, `pion ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Prend le pion de from et le fait glisser au-dessus de la zone de to,
|
||||
* sans le lâcher ; renvoie l'état du plateau pendant le glissement. */
|
||||
async function dragOver(from, to) {
|
||||
const start = await checkerCenter(from);
|
||||
const end = await center(to);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 });
|
||||
await page.mouse.move(end.x, end.y, { steps: 8 });
|
||||
await sleep(100);
|
||||
return boardState();
|
||||
}
|
||||
|
||||
/** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre
|
||||
* et sans glissement (le pion rentré ou posé). */
|
||||
async function release() {
|
||||
await page.mouse.up();
|
||||
await until("glissement fini", () => document.getElementById("drag-piece") === null);
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** Une capture de la recette, à la taille du moment (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
const size = page.viewportSize();
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Le filet des pions
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
let state = await boardState();
|
||||
check(state.checkers >= 28 && state.rings === state.checkers,
|
||||
`filet : ${state.rings} filets pour ${state.checkers} pions`);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await capture("plateau-filet");
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
log(`filet intérieur sur chaque pion (${state.rings})`);
|
||||
|
||||
// ------------------------------------- Un coup entier au glisser-déposer
|
||||
|
||||
state = await dragOver("p8", "p5");
|
||||
check(state.dragging && state.ghost === "p8" && state.selected === "p8",
|
||||
`glissement de 8 : ${JSON.stringify(state)}`);
|
||||
check(state.landing.includes("p5") && state.landing.includes("p7"),
|
||||
`glissement de 8 : cases d'arrivée ${state.landing}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null,
|
||||
`8/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await dragOver("p6", "p5");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await page.click("#confirm");
|
||||
await idle();
|
||||
log("un coup entier joué au glisser-déposer (8/5 6/5)");
|
||||
|
||||
// Le glissement en paysage, pour la recette.
|
||||
if (full) {
|
||||
await startFrom(START, [3, 1]);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(300);
|
||||
state = await dragOver("p13", "p10");
|
||||
check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Lâché hors d'une case permise
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
const before = await boardState();
|
||||
// La 12 est tenue par l'ordinateur : 13/12 n'est pas permis.
|
||||
state = await dragOver("p13", "p12");
|
||||
check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`);
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging &&
|
||||
state.checkers === before.checkers,
|
||||
`lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`);
|
||||
log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué");
|
||||
|
||||
// ----------------------------------------------------------- Double toucher
|
||||
|
||||
await startFrom(SIX_AND_ONE, [6, 1]);
|
||||
// Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien.
|
||||
await page.dblclick('#board .hit[data-place="p6"]');
|
||||
await sleep(500);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`);
|
||||
// Depuis la 1 : 1/off puis 6/off, le seul coup.
|
||||
await page.dblclick('#board .hit[data-place="p1"]');
|
||||
await until("coup évident joué", () => !document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"],
|
||||
`double toucher sur la 1 : ${JSON.stringify(state)}`);
|
||||
log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1");
|
||||
|
||||
// ------------------------------------------------------------------ Clavier
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
await page.locator("body").focus().catch(() => {});
|
||||
// Les pions jouables dans le sens de la course : 24, 13, 8, 6.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
state = await boardState();
|
||||
check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`);
|
||||
// Les cases d'arrivée de la 8 : la 7, puis la 5.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`);
|
||||
// En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5.
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
state = await boardState();
|
||||
check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`);
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`);
|
||||
const keys = await page.evaluate(() => {
|
||||
const kbd = document.querySelector("#confirm kbd.shortcut");
|
||||
return {
|
||||
keyboard: document.documentElement.classList.contains("keyboard"),
|
||||
shown: kbd !== null && kbd.getBoundingClientRect().width > 0,
|
||||
text: kbd?.textContent ?? null,
|
||||
};
|
||||
});
|
||||
check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`);
|
||||
await capture("clavier");
|
||||
await page.keyboard.press(" ");
|
||||
await until("coup validé", () => document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`plateau soigné : tout est conforme (${captures} captures)`);
|
||||
+4
-13
@@ -353,8 +353,7 @@ async function checkStable(when) {
|
||||
}
|
||||
|
||||
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte), touche de son
|
||||
* raccourci comprise. Un bouton plus
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
|
||||
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
|
||||
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
|
||||
async function wrappedButtons(target) {
|
||||
@@ -369,9 +368,7 @@ async function wrappedButtons(target) {
|
||||
const tops = new Set();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
||||
// La touche du raccourci (kbd, quand un clavier est détecté)
|
||||
// est un badge en coin, pas une ligne du libellé.
|
||||
if (node.textContent.trim() === "" || node.parentElement?.closest("kbd.shortcut")) {
|
||||
if (node.textContent.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const range = document.createRange();
|
||||
@@ -1196,7 +1193,6 @@ let resumed = false;
|
||||
let landscapeTurn = false;
|
||||
let rotatedChecked = false;
|
||||
let vibrationsChecked = false;
|
||||
let lastTouch = 0;
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
@@ -1424,7 +1420,7 @@ for (let actions = 0; ; actions++) {
|
||||
const confirm = document.getElementById("confirm");
|
||||
const cancel = document.getElementById("cancel");
|
||||
return {
|
||||
text: confirm.firstChild.textContent,
|
||||
text: confirm.textContent,
|
||||
primary: !confirm.classList.contains("secondary"),
|
||||
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
|
||||
inBar: confirm.closest("#actions .slot.primary") !== null,
|
||||
@@ -1500,14 +1496,9 @@ for (let actions = 0; ; actions++) {
|
||||
check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu");
|
||||
}
|
||||
const vibrationsBefore = (await vibrations()).length;
|
||||
// Deux touchers rapprochés sur la même pile seraient un double
|
||||
// toucher (coup évident, tests/browser/plateau.mjs) : ici, un
|
||||
// toucher à la fois, un demi-coup à la fois.
|
||||
await sleep(Math.max(0, lastTouch + 500 - Date.now()));
|
||||
// Un pion à destination unique avance dès qu'on le touche ; sinon,
|
||||
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
|
||||
// l'écran répond dans le gestionnaire du toucher.
|
||||
lastTouch = Date.now();
|
||||
const ms = await page.evaluate(() => {
|
||||
const hit = document.querySelector(".hit.can-move");
|
||||
const t0 = performance.now();
|
||||
@@ -1682,7 +1673,7 @@ await shot("4-fin-de-partie");
|
||||
// reste dans la barre d'actions.
|
||||
await page.click("#summary-close");
|
||||
await page.waitForSelector("#summary-veil", { state: "hidden" });
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
|
||||
+2
-69
@@ -412,42 +412,10 @@ button:disabled {
|
||||
}
|
||||
|
||||
.icon-button.labelled {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
/* Raccourci clavier d'un bouton, en petite touche (interaction.md) :
|
||||
* seulement quand un clavier est détecté (classe keyboard, app.ts). */
|
||||
.shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.keyboard .shortcut {
|
||||
display: inline-block;
|
||||
min-width: 1.6em;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Barre haute : la touche en coin, sans changer la taille du bouton. */
|
||||
.keyboard .icon-button.labelled .shortcut {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 3px;
|
||||
line-height: 15px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.icon-button .label {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
@@ -998,13 +966,6 @@ a.notation {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
/* Le plateau de la partie : le doigt y fait glisser les pions, il ne fait
|
||||
* ni défiler ni zoomer la page (glisser-déposer, double toucher ; charte,
|
||||
* 1i). Les autres plateaux (accueil, tutoriel, position) laissent défiler. */
|
||||
#board {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-frame { fill: var(--frame); }
|
||||
.board-bed { fill: var(--bed); }
|
||||
.board-bar { fill: var(--bar); }
|
||||
@@ -1035,8 +996,8 @@ a.notation {
|
||||
stroke: var(--checker-computer-edge);
|
||||
}
|
||||
|
||||
/* Filet intérieur des pions (bundle.css), l'écho du logo : un cercle aux
|
||||
* trois cinquièmes du rayon, dessiné avec chaque pion (board-view.ts). */
|
||||
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
|
||||
* le dessine. */
|
||||
.checker-ring {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
@@ -1055,34 +1016,6 @@ a.notation {
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* Glisser-déposer (charte, 1i) : le pion parti laisse un fantôme en
|
||||
* pointillés ; le pion glissé, agrandi, passe devant tout. */
|
||||
.drag-origin .checker {
|
||||
fill: none;
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.drag-origin .checker-ring,
|
||||
.drag-origin .checker-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-piece { pointer-events: none; }
|
||||
|
||||
.checker.dragged {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
/* Clavier : le pion ou la case d'arrivée choisis aux flèches. */
|
||||
.key-focus {
|
||||
fill: none;
|
||||
stroke: var(--focus);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Tailles des chiffres du plateau : attribut font-size, calculé par
|
||||
* geometry.ts (13 px au moins à l'écran). */
|
||||
.checker-count {
|
||||
|
||||
Reference in New Issue
Block a user