Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre

# Conflicts:
#	app/src/ui/board-view.ts
#	tests/browser.sh
This commit is contained in:
Pierre MARTIN
2026-11-10 05:08:23 +01:00
16 changed files with 485 additions and 51 deletions
+12 -1
View File
@@ -25,7 +25,7 @@ import {
type NewGameEvent,
withAdvice,
} from "./events.js";
import { type Match, nextGameOf } from "./match.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
@@ -168,6 +168,17 @@ export class Game {
return match === null ? null : { level: state.level, match };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
+28 -11
View File
@@ -265,6 +265,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"),
@@ -338,7 +339,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;
@@ -588,7 +589,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,
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
});
@@ -848,6 +853,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 = "";
@@ -855,6 +861,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;
@@ -1146,14 +1153,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);
}
}
@@ -1444,6 +1448,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();
@@ -2313,10 +2324,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
@@ -523,6 +523,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
const width = 6 * g.pointWidth - 24 - before;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12 + before;
// 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,
@@ -538,8 +543,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
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" });