Plateau : glisser-déposer, double toucher, clavier, raccourcis et filet des pions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 09:59:34 +01:00
co-authored by Claude Opus 5.5
parent 6dd0e93d3d
commit 835a907b9c
11 changed files with 1127 additions and 42 deletions
+20 -6
View File
@@ -367,8 +367,9 @@ export class Game {
/**
* Le coup évident qui commence par le pion de from (une case du joueur,
* ou la barre : BAR), après les demi-coups déjà saisis : les demi-coups
* qui restent à jouer pour le compléter, ou null s'il n'y en a pas
* 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
@@ -379,16 +380,29 @@ export class Game {
* 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): Promise<HalfMove[] | null> {
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 = this.pendingHalfMoves;
const displayed = this.displayedPosition;
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(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;
+312 -4
View File
@@ -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 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";
@@ -50,6 +50,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,
@@ -63,12 +64,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";
@@ -226,6 +231,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) => {
@@ -375,7 +394,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;
@@ -440,6 +509,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;
@@ -636,6 +719,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);
@@ -681,7 +766,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);
@@ -1122,15 +1207,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> {
@@ -1139,6 +1249,8 @@ export function startApp(options: AppOptions): void {
}
busy = true;
selected = null;
keyTarget = null;
drag = null;
targets = new Map();
shownMove = null;
bubbleSide = null;
@@ -1600,10 +1712,65 @@ 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 : joue le coup évident (le domaine le cherche, le
* moteur le juge) commencé au premier toucher, si le joueur n'a rien
* fait d'autre entre-temps. Le 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. Sinon, rien : le double toucher vaut un toucher.
*/
async function playObvious(tap: ObviousTap): Promise<void> {
await whenIdle();
const g = game;
if (g !== tap.game || busy || g.state.phase !== "moving") {
return;
}
const now = g.pendingHalfMoves;
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
if (now.length < tap.pending.length || !tap.pending.every((h, i) => same(h, now[i]))) {
return;
}
// Demandé seulement maintenant : un toucher simple ne coûte rien au
// moteur.
busy = true;
render();
let obvious: HalfMove[] | null = null;
try {
obvious = await g.obviousMove(pointOf(tap.place), tap.pending);
} catch {
obvious = null;
}
busy = false;
const done = now.slice(tap.pending.length);
const rest = obvious?.slice(done.length) ?? [];
const unchanged =
game === g &&
g.pendingHalfMoves.length === now.length &&
done.every((h, i) => same(h, obvious?.[i]));
if (!unchanged || rest.length === 0) {
render();
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> = {
@@ -2412,10 +2579,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();
@@ -2431,9 +2663,23 @@ 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) {
void playObvious(second);
return;
}
if (busy) {
return;
}
keyTarget = null;
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
playHalfMove(halfMove);
@@ -2451,6 +2697,64 @@ export function startApp(options: AppOptions): void {
render();
});
/**
* 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) => {
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
@@ -2476,6 +2780,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");
});
+11 -11
View File
@@ -30,8 +30,18 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors votre coup est accepté
Et c'est ensuite au tour de l'ordinateur
Scénario: Pourquoi mon pion part-il dès que je le touche ?
Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le
touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois
8/5 joué, il ne vous reste que le 1.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5
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: Jouer un pion en le faisant glisser, ou d'un double toucher
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
@@ -48,16 +58,6 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand vous demandez le coup évident de votre pion de la flèche 1
Alors vos deux pions sont sortis
Scénario: Pourquoi mon pion part-il dès que je le touche ?
Un pion qui n'a qu'une case d'arrivée possible y va dès que vous le
touchez, sans attendre un second toucher. Par exemple, avec 3-1, une fois
8/5 joué, il ne vous reste que le 1.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5
Alors seul votre 3 est marqué comme joué
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
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.
+13 -4
View File
@@ -18,10 +18,12 @@
# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs
# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
# build/browser-shots/retours-joueurs/.
# build/browser-shots/retours-joueurs/. 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) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs, 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
@@ -34,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}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs plateau}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -73,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 "$tools/"
tests/browser/retours-joueurs.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
@@ -136,5 +138,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
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)`);
+2 -2
View File
@@ -1420,7 +1420,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,
@@ -1673,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")) === "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 »");
+65 -3
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;
@@ -958,7 +990,9 @@ a.notation {
aspect-ratio: 640 / 520;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
/* Le doigt fait glisser les pions : le plateau ne fait ni défiler ni
* zoomer la page (glisser-déposer, double toucher ; charte, 1i). */
touch-action: none;
-webkit-tap-highlight-color: transparent;
font-family: var(--font-sans);
/* Alegreya Sans dessine par défaut des chiffres elzéviriens (de hauteurs
@@ -996,8 +1030,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 +1050,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 {