Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »

- Paysage : pendant la partie Crawford, la consigne de la bande centrale
  commence par « Partie Crawford · » ; la police se resserre si besoin.
- Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la
  barre d'action (type de partie et longueur à gauche, niveau et conseil
  du maître à droite), tout tient en 915x412 et en 844x320.
- Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes
  réglages (un match de même longueur après un match), « Partie suivante »
  dans un match qui continue, lien « Changer les réglages ».
- Recette automatique : aucun élément interactif visible sous une barre
  d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs).
- Scénario « Comment rejouer avec les mêmes réglages ? ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 05:03:59 +01:00
co-authored by Claude Opus 5.5
parent 67bd207337
commit 74693c4277
16 changed files with 484 additions and 51 deletions
+28 -11
View File
@@ -245,6 +245,7 @@ export function startApp(options: AppOptions): void {
panelVeil: byId("panel-veil"),
panelText: byId("panel-text"),
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
banner: byId("banner"),
bannerText: byId("banner-text"),
@@ -318,7 +319,7 @@ 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 = TEXTS.newGame;
ui.again.textContent = MATCH_TEXTS.replay;
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -536,7 +537,11 @@ export function startApp(options: AppOptions): void {
diceAction: action,
canDouble: !busy && canDouble,
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
landscape && !panelShown
? text === null
? TEXTS.gameOver
: bandText(text.short, state.match?.crawford === true)
: null,
controls: landscape ? controls() : null,
});
}
@@ -730,6 +735,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = end.detail;
ui.panelSecondary.textContent = TEXTS.reviewGame;
ui.panelPrimary.textContent = afterGameLabel(g);
ui.panelSettings.hidden = false;
ui.panelVeil.dataset.kind = "over";
} else if (ask) {
overline.textContent = "";
@@ -737,6 +743,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = "";
ui.panelSecondary.textContent = TEXTS.drop;
ui.panelPrimary.textContent = TEXTS.take;
ui.panelSettings.hidden = true;
ui.panelVeil.dataset.kind = "doubled";
}
ui.panelPrimary.disabled = busy;
@@ -1019,14 +1026,11 @@ export function startApp(options: AppOptions): void {
}
/** Après une partie finie : la partie suivante du match s'il continue,
* sinon l'écran « Nouvelle partie ». */
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
* même longueur après un match), tout de suite. */
function afterGame(): void {
const next = game?.nextGame ?? null;
if (next !== null) {
newGame(next);
} else {
closeOverlays();
navigation.go(routeHash({ screen: "newGame" }));
if (game !== null) {
newGame(game.nextGame ?? game.rematch);
}
}
@@ -1304,6 +1308,13 @@ export function startApp(options: AppOptions): void {
afterGame();
}
};
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
// le panneau se ferme, le résultat reste sur l'écran de jeu.
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
ui.panelSettings.addEventListener("click", () => {
panelDismissed = true;
render();
});
ui.panelSecondary.onclick = () => {
if (ui.panelVeil.dataset.kind === "doubled") {
actions.drop();
@@ -2153,10 +2164,16 @@ function matchScore(state: GameState): MatchScore | null {
return { length: state.match.length, computer: score.computer, player: score.player };
}
/** Paysage : la consigne de la bande centrale ; pendant la partie
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
function bandText(prompt: string, crawford: boolean): string {
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
}
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
* qui continue, sinon « Rejouer » (mêmes réglages). */
function afterGameLabel(game: Game): string {
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
}
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
+7 -2
View File
@@ -502,6 +502,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
const width = 6 * g.pointWidth - 24;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
// la police se resserre, sans descendre sous 13 px (charte). Largeur
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
// 0,45 em.
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
add(svg, "rect", {
class: "band-pill",
x,
@@ -517,8 +522,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
class: "band-text",
id: "band-text",
x: x + width / 2,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
y: middle + fontSize * 0.35,
"font-size": fontSize,
},
text,
);
+7 -1
View File
@@ -64,6 +64,8 @@ export const MATCH_TEXTS = {
/** « Match en 5 points », et « · partie Crawford ». */
matchLine: (length: number, crawford: boolean) =>
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** Ce que lit le lecteur d'écran. */
scoreSpoken: (length: number, computer: number, player: number) =>
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
@@ -72,7 +74,11 @@ export const MATCH_TEXTS = {
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// Fin d'une partie de match
// Fin d'une partie
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
nextGame: "Partie suivante",
nextGameStarted: "Partie suivante du match.",
matchWonTitle: (winner: "player" | "computer") =>
+22 -9
View File
@@ -87,10 +87,14 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
closeLink.append(icon("close"));
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(body, "div", { className: "segmented" });
const kinds = element(kindColumn, "div", { className: "segmented" });
kinds.setAttribute("role", "radiogroup");
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
@@ -98,7 +102,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(body, "div", {
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
@@ -119,7 +123,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(body, "div", {
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
@@ -132,8 +136,8 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(body, "div", { className: "level-bars" });
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "div", { className: "level-bars" });
bars.setAttribute("role", "radiogroup");
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
@@ -144,12 +148,21 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
button.addEventListener("click", () => options.change({ level }));
return button;
});
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
const levelName = element(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
advice.type = "button";
advice.setAttribute("role", "switch");
const adviceLabel = element(advice, "span", { className: "item-label" });