Toucher les dés pour les lancer, le videau pour doubler

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 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 02:11:08 +01:00
co-authored by Claude Opus 5.5
parent 39c85172b4
commit 984d6cf060
4 changed files with 129 additions and 9 deletions
+14
View File
@@ -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;
+77 -6
View File
@@ -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<Place>;
/** 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<Place>(),
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
+25 -3
View File
@@ -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) {
+13
View File
@@ -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; }