Compare commits

..
19 changed files with 1343 additions and 68 deletions
+77
View File
@@ -365,6 +365,83 @@ 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> {
+336 -5
View File
@@ -44,7 +44,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 type { HalfMove } from "../domain/game/position.js";
import { BAR, 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";
@@ -64,6 +64,7 @@ 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,
@@ -77,12 +78,16 @@ 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";
@@ -265,6 +270,20 @@ 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) => {
@@ -418,7 +437,57 @@ export function startApp(options: AppOptions): void {
ui.confirm.textContent = TEXTS.confirm;
label(ui.cancel, TEXTS.cancel, "undo");
ui.skip.textContent = TEXTS.skip;
ui.again.textContent = MATCH_TEXTS.replay;
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.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -484,6 +553,20 @@ 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;
@@ -680,6 +763,8 @@ 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);
@@ -725,7 +810,7 @@ export function startApp(options: AppOptions): void {
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, over && panelDismissed);
ui.again.textContent = afterGameLabel(game);
againLabel.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);
@@ -1166,15 +1251,40 @@ 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> {
@@ -1183,6 +1293,8 @@ export function startApp(options: AppOptions): void {
}
busy = true;
selected = null;
keyTarget = null;
drag = null;
targets = new Map();
shownMove = null;
bubbleSide = null;
@@ -1779,10 +1891,64 @@ 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> = {
@@ -2798,10 +2964,75 @@ 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();
@@ -2817,9 +3048,29 @@ export function startApp(options: AppOptions): void {
return;
}
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
if (place == null || game === 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);
@@ -2835,7 +3086,83 @@ 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) => {
@@ -2862,6 +3189,10 @@ 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 " ":
+212
View File
@@ -0,0 +1,212 @@
// 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;
}
+117 -10
View File
@@ -86,8 +86,23 @@ 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 {
@@ -203,6 +218,9 @@ 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) {
@@ -235,6 +253,9 @@ 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);
}
@@ -359,18 +380,13 @@ function drawStack(
classes.push("movable");
}
const { x, y } = slotCenter(g, place, i);
const checker = add(svg, "circle", {
class: classes.join(" "),
cx: x,
cy: y,
r: g.radius,
});
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
if (last) {
checker.setAttribute("data-checker", place);
piece.setAttribute("data-checker", place);
}
if (last && count > limit) {
add(
svg,
piece,
"text",
{
class: `checker-count count-${side}`,
@@ -481,6 +497,23 @@ 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", {
@@ -810,13 +843,87 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
if (checker === null) {
return null;
}
const number = (name: string) => Number(checker.getAttribute(name));
if (checker.tagName === "circle") {
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") {
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)";
+45
View File
@@ -0,0 +1,45 @@
// 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");
});
+82
View File
@@ -0,0 +1,82 @@
// 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);
});
+17
View File
@@ -511,3 +511,20 @@ 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);
});
+1 -1
View File
@@ -25,7 +25,7 @@ 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 (fait) | Historique des parties et courbe du taux d'erreur | 1b, 1e |
| 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 |
| 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 |
| 1k. Retours des joueurs, 2e lot | Crawford signalé sur la barre ; abandon (simple, gammon, backgammon) accepté ou refusé par l'ordinateur selon GNU Backgammon (plan `2026-11-12-retours-joueurs-2.md`) | 1c, 1e |
| 1l. Analyse du match | Bilan et courbe d'un match entier (chances de gagner le match), erreurs de videau sur tout le match, prompt et export SGF du match (plan `2026-11-12-analyse-du-match.md`) | 1c, 1e, 1f |
| 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 |
+5 -5
View File
@@ -160,7 +160,7 @@ remplace en place, sans saut.
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
la chance donnés par le moteur, jamais inventée.
2. Trois tuiles côte à côte : le nombre de bons coups, d'imprécisions et
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
couleur d'appréciation.
3. Deux jauges sur une ligne chacune :
@@ -284,15 +284,15 @@ Maquette « Progression ».
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
fantôme en pointillés ; les flèches d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une flèche permise : il s'y pose
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
sa place en `duration-fast`, sans vibration ni message.
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
vaut un simple toucher, sans message d'erreur.
- **Toucher simple** : inchangé (pion puis flèche, ou avance directe s'il n'a
qu'une flèche d'arrivée).
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
qu'une case d'arrivée).
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
tutoriel et l'aide en parlent.
+2 -2
View File
@@ -5,7 +5,7 @@ Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont
## Contenus et ton
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Une flèche (le triangle) s’appelle toujours « flèche », jamais « case » ; un « point » est une flèche tenue par au moins deux pions du même joueur. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
@@ -18,7 +18,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la flèche où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
+1 -1
View File
@@ -49,7 +49,7 @@ Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) q
| --- | --- |
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
| `↑` `↓` | Choisir sa flèche d'arrivée |
| `↑` `↓` | Choisir sa case d'arrivée |
| `Entrée` | Poser le pion |
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
| `D` | Doubler |
+3
View File
@@ -47,3 +47,6 @@ 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-*`).
-32
View File
@@ -1,32 +0,0 @@
# 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.
+18
View File
@@ -40,6 +40,24 @@ 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.
+15 -4
View File
@@ -23,10 +23,13 @@
# progression gardée), captures dans build/browser-shots/lecons/. La
# progression (tests/browser/progression.mjs : cas vide, trois parties, courbe
# égale aux taux des bilans, ligne de l'accueil, « Créer des exercices »),
# captures dans build/browser-shots/progression/.
# captures dans build/browser-shots/progression/. 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/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts, nouvelle-partie, retours-joueurs, lecons, progression) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs, lecons, progression, plateau)
# 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
@@ -39,7 +42,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 progression}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression plateau}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -78,7 +81,8 @@ 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/progression.mjs "$tools/"
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs tests/browser/progression.mjs tests/browser/plateau.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
@@ -155,5 +159,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node progression.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"
+2 -2
View File
@@ -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")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
check((await page.textContent("#again > span")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
+328
View File
@@ -0,0 +1,328 @@
// 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)`);
+13 -4
View File
@@ -353,7 +353,8 @@ 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). Un bouton plus
* bouton tient sur une ligne et fait 48 px de haut (charte), touche de son
* raccourci comprise. 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) {
@@ -368,7 +369,9 @@ 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()) {
if (node.textContent.trim() === "") {
// 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")) {
continue;
}
const range = document.createRange();
@@ -1193,6 +1196,7 @@ let resumed = false;
let landscapeTurn = false;
let rotatedChecked = false;
let vibrationsChecked = false;
let lastTouch = 0;
let tracesSeen = 0;
let replays = 0;
let skips = 0;
@@ -1420,7 +1424,7 @@ for (let actions = 0; ; actions++) {
const confirm = document.getElementById("confirm");
const cancel = document.getElementById("cancel");
return {
text: confirm.textContent,
text: confirm.firstChild.textContent,
primary: !confirm.classList.contains("secondary"),
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
inBar: confirm.closest("#actions .slot.primary") !== null,
@@ -1496,9 +1500,14 @@ 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();
@@ -1673,7 +1682,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")) === "Rejouer" &&
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
(await page.isVisible("#again")),
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
+69 -2
View File
@@ -412,10 +412,42 @@ 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;
@@ -966,6 +998,13 @@ 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); }
@@ -996,8 +1035,8 @@ a.notation {
stroke: var(--checker-computer-edge);
}
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
* le dessine. */
/* 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). */
.checker-ring {
fill: none;
stroke-width: 1.5;
@@ -1016,6 +1055,34 @@ 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 {