Recette design : consigne en texte nu, cases d'arrivée en triangle et anneau, flèches sous les pions, panneau de fin structuré

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:24:03 +01:00
co-authored by Claude Opus 5.5
parent ebaa3b0020
commit b8c2997ec0
8 changed files with 232 additions and 54 deletions
+37 -11
View File
@@ -345,6 +345,11 @@ export function startApp(options: AppOptions): void {
const model = boardModel(sides);
const action = busy ? null : diceAction(game, stuck);
const text = promptFor(game, selected !== null, canDouble, stuck);
// Un panneau (fin de partie, double de l'ordinateur en paysage)
// remplace la consigne : elle ne la répète pas dessous.
const doubledByComputer = state.turn === "computer" && state.phase === "doubled";
const panelShown =
(state.phase === "over" && !panelDismissed) || (landscape && doubledByComputer);
if (!animating) {
renderBoard(ui.board, g, model, {
dice: shownDice(game, stuck),
@@ -358,7 +363,8 @@ export function startApp(options: AppOptions): void {
: moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits),
diceAction: action,
canDouble: !busy && canDouble,
band: landscape ? (text === null ? TEXTS.gameOver : text.short) : null,
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
controls: landscape ? controls() : null,
});
}
@@ -373,7 +379,11 @@ export function startApp(options: AppOptions): void {
} else {
ui.info.textContent = info;
}
ui.prompt.textContent = text === null ? resultText(game) : promptText(text, g);
ui.prompt.textContent = panelShown
? ""
: text === null
? resultText(game)
: promptText(text, g);
const thinking = state.turn === "computer" && isPlaying(game);
ui.prompt.classList.toggle("thinking", thinking);
ui.prompt.classList.toggle("neutral", thinking || state.phase === "over");
@@ -492,13 +502,20 @@ export function startApp(options: AppOptions): void {
const over = g.state.phase === "over" && !panelDismissed;
const ask = landscape && doubled;
ui.panelVeil.hidden = !(over || ask);
const overline = byId("panel-overline");
const detail = byId("panel-detail");
if (over) {
ui.panelText.textContent = resultText(g);
const end = endParts(g);
overline.textContent = TEXTS.endOverline;
ui.panelText.textContent = end.title;
detail.textContent = end.detail;
ui.panelSecondary.textContent = TEXTS.reviewGame;
ui.panelPrimary.textContent = TEXTS.newGame;
ui.panelVeil.dataset.kind = "over";
} else if (ask) {
overline.textContent = "";
ui.panelText.textContent = TEXTS.prompts.computerDoubles.long;
detail.textContent = "";
ui.panelSecondary.textContent = TEXTS.drop;
ui.panelPrimary.textContent = TEXTS.take;
ui.panelVeil.dataset.kind = "doubled";
@@ -1263,18 +1280,27 @@ function describeComputerTurn(game: Game): string {
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
}
/** Le résultat d'une partie finie. */
function resultText(game: Game): string {
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
* (« L'ordinateur gagne un gammon ») et son détail (« 8 points pour lui,
* videau à 4. »). */
function endParts(game: Game): { title: string; detail: string } {
const result = game.state.result;
if (result === null) {
return TEXTS.gameOver;
return { title: TEXTS.gameOver, detail: "" };
}
const { winner, kind, points } = result;
const dropped = game.events.find((e) => e.type === "CubeDropped");
if (dropped !== undefined) {
return winner === "player" ? TEXTS.computerDropped(points) : TEXTS.youDropped(points);
}
return winner === "player" ? TEXTS.youWin(kind, points) : TEXTS.youLose(kind, points);
const dropped = game.events.some((e) => e.type === "CubeDropped");
return {
title: TEXTS.endTitle(winner, kind, dropped),
detail: TEXTS.endDetail(winner, points, game.state.cube.value),
};
}
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
* panneau fermé). */
function resultText(game: Game): string {
const { title, detail } = endParts(game);
return `${title} : ${detail}`;
}
/** Ce que le joueur doit faire maintenant (null : partie finie). */
+60 -20
View File
@@ -163,18 +163,26 @@ export function renderBoard(
svg.dataset.layout = g.layout;
drawFrame(svg, g);
const hits = add(svg, "g", {});
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
for (const point of board.points) {
drawPoint(svg, g, hits, board, point.number, decor);
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
drawBar(svg, g, hits, board, decor);
drawTray(svg, g, hits, board, decor);
drawBar(svg, pieces, g, hits, board, decor);
drawTray(svg, pieces, g, hits, board, decor);
if (decor.trace !== null) {
drawTraceArrows(svg, g, board, decor.trace);
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
if (decor.band !== null) {
drawBand(svg, g, decor.band);
}
drawDice(svg, g, decor.dice);
const cube = drawCube(svg, g, decor.cube);
if (decor.trace !== null) {
drawTrace(svg, g, board, decor.trace);
drawTraceRings(svg, g, board, decor.trace);
}
if (decor.diceAction !== null) {
if (decor.diceAction === "roll" && g.layout === "portrait") {
@@ -240,6 +248,7 @@ function drawFrame(svg: SVGSVGElement, g: Geometry): void {
function drawPoint(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
@@ -283,18 +292,16 @@ function drawPoint(
},
String(number),
);
// Case d'arrivée possible : la flèche seule est voilée (son triangle) ;
// l'endroit où le pion se posera est marqué plus loin (drawLandings).
if (decor.targets.has(point.place)) {
add(svg, "rect", {
add(svg, "polygon", {
class: "target-zone",
x: left + 2,
y: top ? g.edge : middle,
width: g.pointWidth - 4,
height: middle - g.edge,
rx: 4,
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
});
}
if (point.owner !== null) {
drawStack(svg, g, point.owner, point.count, point.place, decor);
drawStack(pieces, g, point.owner, point.count, point.place, decor);
}
addHit(hits, point.place, decor, {
x: left,
@@ -308,7 +315,7 @@ function drawPoint(
* pour les montrer tous. Le pion du dessus porte son endroit dans
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
function drawStack(
svg: SVGSVGElement,
svg: Element,
g: Geometry,
side: Side,
count: number,
@@ -353,6 +360,7 @@ function drawStack(
function drawBar(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
@@ -362,7 +370,7 @@ function drawBar(
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
drawStack(svg, g, side, board.bar[side], `bar-${side}`, decor);
drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor);
}
if (g.layout === "landscape") {
drawChips(svg, g, board);
@@ -407,6 +415,7 @@ function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void {
function drawTray(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
@@ -417,7 +426,7 @@ function drawTray(
const count = board.off[side];
for (let i = 0; i < count; i++) {
const slab = addSlab(
svg,
pieces,
g,
`checker checker-${side}`,
slotCenter(g, `off-${side}`, i),
@@ -538,11 +547,16 @@ function drawControls(
// ------------------------------------------------------ Coup de l'ordinateur
/**
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
* Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : un pion fantôme (contour seul) là où chaque pion
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
* (sur la barre), et une flèche par demi-coup.
*/
function drawTrace(svg: SVGSVGElement, g: Geometry, board: BoardModel, trace: MoveTrace): void {
function drawTraceRings(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
trace: MoveTrace,
): void {
const side = trace.side;
const R = g.radius;
for (const [place, n] of trace.left) {
@@ -564,14 +578,24 @@ function drawTrace(svg: SVGSVGElement, g: Geometry, board: BoardModel, trace: Mo
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3);
}
}
/** Les flèches d'un coup, une par demi-coup : sous les pions. */
function drawTraceArrows(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
trace: MoveTrace,
): void {
const side = trace.side;
const defs = add(svg, "defs", {});
const marker = add(defs, "marker", {
id: "trace-head",
viewBox: "0 0 10 10",
refX: 8,
refY: 5,
markerWidth: 3.5,
markerHeight: 3.5,
markerWidth: 3,
markerHeight: 3,
orient: "auto",
});
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
@@ -583,7 +607,22 @@ function drawTrace(svg: SVGSVGElement, g: Geometry, board: BoardModel, trace: Mo
departures.set(from, k + 1);
const start = slotCenter(g, from, countAt(board, side, from) + k);
const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0));
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : R + 4);
drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4);
}
}
/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se
* posera (sur la pile du joueur, ou au pied de la flèche). */
function drawLandings(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
targets: ReadonlySet<Place>,
): void {
for (const place of targets) {
const count = countAt(board, "player", place);
const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1);
addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius);
}
}
@@ -594,7 +633,7 @@ function topIndex(g: Geometry, board: BoardModel, side: Side, place: Place): num
}
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
function drawArrow(svg: SVGSVGElement, g: Geometry, start: Spot, end: Spot, gap: number): void {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.hypot(dx, dy);
@@ -605,6 +644,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi
y1: start.y,
x2: start.x + dx * keep,
y2: start.y + dy * keep,
"stroke-width": g.arrowWidth,
"marker-end": "url(#trace-head)",
});
}
+3
View File
@@ -56,6 +56,8 @@ export interface Geometry {
readonly cube: number;
readonly cubeFont: number;
readonly countFont: number;
/** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */
readonly arrowWidth: number;
readonly die: number;
/** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un
* nombre sur le dernier) : en paysage, ils s'arrêtent avant les
@@ -90,6 +92,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
cubeFont: text,
cube: Math.max(36, text + 20),
countFont: Math.max(18, text),
arrowWidth: 2.5 / pxPerUnit,
};
if (layout === "portrait") {
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
+15 -5
View File
@@ -134,12 +134,22 @@ export const TEXTS = {
} satisfies Record<string, PromptText | ((dice: readonly number[]) => PromptText)>,
// Fin de partie
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
youLose: (kind: ResultKind, n: number) =>
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
gameOver: "Partie finie",
endOverline: "Fin de partie",
/** Titre du panneau de fin : qui gagne, et comment. */
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
if (dropped) {
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
}
return winner === "player"
? `Vous gagnez ${RESULT_KIND[kind]}`
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
},
/** « 8 points pour lui, videau à 4. » */
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
cube > 1 ? `, videau à ${cube}` : ""
}.`,
// Aide
helpTitle: "Aide",
+5 -5
View File
@@ -207,11 +207,11 @@ test("la partie enregistrée se relit ; un enregistrement illisible est ignoré"
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
assert.equal(
TEXTS.youLose("single", 1),
"L'ordinateur gagne la partie simple : 1 point pour lui.",
);
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",