diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index f638309..aa52e1e 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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 { + // 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(); + const explore = async (sequence: HalfMove[], position: PositionId): Promise => { + 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(); + 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 { diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index fb9559d..6d8de51 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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>(); 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 { 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 { + 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 { + 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): Promise { @@ -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 { + 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 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 { + 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(); + // 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 " ": diff --git a/app/src/ui/board-gestures.ts b/app/src/ui/board-gestures.ts new file mode 100644 index 0000000..52a4fd3 --- /dev/null +++ b/app/src/ui/board-gestures.ts @@ -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(): { + 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, + 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; +} diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index f2e9dd6..de470be 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -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 { + 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)"; diff --git a/app/tests/board-gestures.test.ts b/app/tests/board-gestures.test.ts new file mode 100644 index 0000000..45180e5 --- /dev/null +++ b/app/tests/board-gestures.test.ts @@ -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(); + 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"); +}); diff --git a/app/tests/obvious-move.test.ts b/app/tests/obvious-move.test.ts new file mode 100644 index 0000000..84f49c5 --- /dev/null +++ b/app/tests/obvious-move.test.ts @@ -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 ??= 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 { + 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); +}); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 62c3277..69be889 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -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); +}); diff --git a/docs/etape-1.md b/docs/etape-1.md index 1bd1fc4..3ba5933 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -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 | diff --git a/docs/identite/charte/recette-ux.md b/docs/identite/charte/recette-ux.md index 910bd55..577d2e0 100644 --- a/docs/identite/charte/recette-ux.md +++ b/docs/identite/charte/recette-ux.md @@ -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-*`). diff --git a/docs/plans/2026-11-03-plateau-soigne.md b/docs/plans/2026-11-03-plateau-soigne.md deleted file mode 100644 index 710fcc2..0000000 --- a/docs/plans/2026-11-03-plateau-soigne.md +++ /dev/null @@ -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. diff --git a/docs/scenarios/jouer-une-partie.feature b/docs/scenarios/jouer-une-partie.feature index 5fd8be1..e909de8 100644 --- a/docs/scenarios/jouer-une-partie.feature +++ b/docs/scenarios/jouer-une-partie.feature @@ -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. diff --git a/tests/browser.sh b/tests/browser.sh index 802aee3..55952c8 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -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/.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" diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs index 120bef8..9b67b21 100644 --- a/tests/browser/match.mjs +++ b/tests/browser/match.mjs @@ -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 === "#/" && diff --git a/tests/browser/plateau.mjs b/tests/browser/plateau.mjs new file mode 100644 index 0000000..5909bbe --- /dev/null +++ b/tests/browser/plateau.mjs @@ -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 +// 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 +// /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 "); + 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)`); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index ea75380..4589407 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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 »"); diff --git a/web/app.css b/web/app.css index 7a765b1..aca853f 100644 --- a/web/app.css +++ b/web/app.css @@ -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 {