From 984d6cf0605f310b4b015509fba5444ffaceb3fc Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 02:11:08 +0100 Subject: [PATCH] =?UTF-8?q?Toucher=20les=20d=C3=A9s=20pour=20les=20lancer,?= =?UTF-8?q?=20le=20videau=20pour=20doubler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand vous pouvez lancer, vos dés en attente apparaissent dans la moitié gauche du plateau ; quand vous pouvez doubler, le videau est souligné. Les toucher fait comme les boutons, qui restent. Cibles d'au moins 44 px, avec rôle et nom accessibles. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 14 +++++++ app/src/ui/board-view.ts | 83 +++++++++++++++++++++++++++++++++++++--- tests/browser/play.mjs | 28 ++++++++++++-- web/app.css | 13 +++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 9c2e8de..21c535a 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -131,6 +131,10 @@ export function startApp(options: AppOptions): void { shownMove === null ? null : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), + // Mêmes conditions que les boutons « Lancer les dés » et + // « Doubler ». + canRoll: !busy && state.turn === "player" && state.phase === "rolling", + canDouble: !busy && canDouble, }); } ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); @@ -343,6 +347,16 @@ export function startApp(options: AppOptions): void { // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée. ui.board.addEventListener("click", (event) => { const target = event.target as Element | null; + // Toucher les dés en attente ou le videau : comme leurs boutons. + const action = target?.closest("[data-action]")?.getAttribute("data-action"); + if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) { + ui.roll.click(); + return; + } + if (action === "double" && !ui.double.hidden && !ui.double.disabled) { + ui.double.click(); + return; + } const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null; if (busy || place == null) { return; diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index e742699..4b58493 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -12,6 +12,7 @@ import type { Cube } from "../domain/game/cube.js"; import type { Side } from "../domain/game/side.js"; import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js"; +import { TEXTS } from "./texts.js"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ export interface ShownDie { @@ -32,6 +33,10 @@ export interface BoardDecor { readonly targets: ReadonlySet; /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ readonly trace: MoveTrace | null; + /** Le joueur peut lancer les dés : ses dés en attente se touchent. */ + readonly canRoll: boolean; + /** Le joueur peut doubler : le videau se touche. */ + readonly canDouble: boolean; } /** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */ @@ -40,8 +45,14 @@ export const NO_MARKS = { selected: null, targets: new Set(), trace: null, + canRoll: false, + canDouble: false, } as const; +/** Taille minimale d'une cible à toucher, en unités du dessin : 44 px quand + * le plateau est le plus petit (640 unités sur 360 px de large). */ +const MIN_TARGET = 80; + // Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran). const WIDTH = 640; const HEIGHT = 520; @@ -142,10 +153,18 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD drawBar(svg, hits, board, decor); drawTray(svg, hits, board, decor); drawDice(svg, decor.dice); - drawCube(svg, decor.cube); + const cube = drawCube(svg, decor.cube); if (decor.trace !== null) { drawTrace(svg, board, decor.trace); } + if (decor.canRoll) { + const center = waitingDice(svg); + addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2); + } + if (decor.canDouble) { + cube.classList.add("can-double"); + addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET); + } // Les zones à toucher passent devant tout le reste. svg.append(hits); } @@ -493,18 +512,70 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { /** Le videau, dans le plateau de sortie : au milieu s'il est au centre, * sinon du côté de son propriétaire. Au centre, il montre 64. */ -function drawCube(svg: SVGSVGElement, cube: Cube): void { - const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0; - const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2; - const y = MIDDLE + shift - CUBE / 2; +function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement { + const center = cubeCenter(cube); + const x = center.x - CUBE / 2; + const y = center.y - CUBE / 2; const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" }); add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 }); add( group, "text", - { class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 }, + { class: "cube-value", x: center.x, y: center.y + 6 }, String(cube.owner === null ? 64 : cube.value), ); + return group; +} + +/** Le centre du videau dans le plateau de sortie. */ +function cubeCenter(cube: Cube): Spot { + const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0; + return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift }; +} + +/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la + * moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie + * leur centre. */ +function waitingDice(svg: SVGSVGElement): Spot { + const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE }; + const gap = 8; + const group = add(svg, "g", { class: "die die-waiting" }); + for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) { + add(group, "rect", { + class: "die-player", + x, + y: MIDDLE - DIE / 2, + width: DIE, + height: DIE, + rx: 6, + }); + } + return center; +} + +/** + * Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés, + * doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec + * un rôle et un nom accessibles. Les boutons restent pour le clavier. + */ +function addAction( + hits: Element, + action: string, + label: string, + center: Spot, + width: number, +): void { + add(hits, "rect", { + class: "hit action", + "data-action": action, + role: "button", + "aria-label": label, + // Sans déborder du dessin à droite (le videau est contre le bord). + x: Math.min(center.x - width / 2, WIDTH - width), + y: center.y - MIN_TARGET / 2, + width, + height: MIN_TARGET, + }); } /** Une zone à toucher. Elle porte son état (can-move, can-land) pour les diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 1f1f03a..4ef20f5 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -175,6 +175,20 @@ async function checkTraces() { return traces; } +/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton + * accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */ +async function touchTarget(action, label) { + const selector = `[data-action="${action}"]`; + const zone = page.locator(selector); + check((await zone.count()) === 1, `zone ${action} absente du plateau`); + check((await zone.getAttribute("role")) === "button" && + (await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`); + const box = await zone.boundingBox(); + check(box.width >= 44 && box.height >= 44, + `zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`); + return selector; +} + /** Observe la page pendant ms millisecondes : le plus grand nombre * d'animations en cours à la fois, et le plus petit nombre de flèches. */ async function watchReplay(ms) { @@ -241,6 +255,7 @@ let computerMs = []; let tracesSeen = 0; let replays = 0; let skips = 0; +let rolls = 0; for (let actions = 0; ; actions++) { check(actions < 800, "la partie ne finit pas"); const todo = await waitForPlayer(); @@ -289,15 +304,20 @@ for (let actions = 0; ; actions++) { } else if (todo === "roll") { if (!doubled && (await page.isEnabled("#double"))) { doubled = true; - log("vous doublez"); - await page.click("#double"); + log("vous doublez en touchant le videau"); + await page.click(await touchTarget("double", "Doubler")); await waitForPlayer(); const answer = (await page.textContent("#info")) || (await page.textContent("#prompt")); log(`réponse : ${answer}`); continue; } - await page.click("#roll"); + // Une fois sur deux, en touchant les dés en attente plutôt que le + // bouton. + rolls++; + await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll"); } else { + check((await page.locator("[data-action]").count()) === 0, + "dés ou videau touchables pendant que vous jouez votre coup"); await page.click(".hit.can-move"); await page.waitForSelector(".hit.can-land"); if (!selectionShot) { @@ -334,6 +354,8 @@ log( check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups"); check(reloaded, "la reprise après rechargement n'a pas été essayée"); check(replays === 2, "le coup de l'ordinateur n'a pas été revu"); +check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau"); +log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`); log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`); log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie"); if (computerMs.length > 0) { diff --git a/web/app.css b/web/app.css index cc01530..dc8ad38 100644 --- a/web/app.css +++ b/web/app.css @@ -259,6 +259,19 @@ button:disabled { .pip-player { fill: var(--pip-player); } .pip-computer { fill: var(--pip-computer); } +/* Dés du joueur en attente d'être lancés, et videau quand il peut doubler : + * ils se touchent comme les boutons. */ +.die-waiting rect { + stroke: var(--highlight); + stroke-width: 2.5; + stroke-dasharray: 5 4; +} + +.cube.can-double .cube-face { + stroke: var(--highlight); + stroke-width: 3; +} + /* Jet sans coup possible : dés estompés et barrés. */ .die-blocked { opacity: 0.5; }