Saisie du coup : pion à destination unique, validation, Annuler, dés estompés, videau confirmé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c5278965b
commit
2d934529d5
+111
-22
@@ -13,8 +13,22 @@ import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js";
|
||||
import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
type Place,
|
||||
placeOf,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { showHelp } from "./help.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
@@ -51,6 +65,7 @@ export function startApp(options: AppOptions): void {
|
||||
double: byId<HTMLButtonElement>("double"),
|
||||
take: byId<HTMLButtonElement>("take"),
|
||||
drop: byId<HTMLButtonElement>("drop"),
|
||||
confirm: byId<HTMLButtonElement>("confirm"),
|
||||
cancel: byId<HTMLButtonElement>("cancel"),
|
||||
skip: byId<HTMLButtonElement>("skip"),
|
||||
replay: byId<HTMLButtonElement>("replay"),
|
||||
@@ -61,12 +76,19 @@ export function startApp(options: AppOptions): void {
|
||||
game: byId("game"),
|
||||
help: byId("help"),
|
||||
helpContent: byId("help-content"),
|
||||
doubleDialog: byId<HTMLDialogElement>("double-dialog"),
|
||||
doubleQuestion: byId("double-question"),
|
||||
doubleConfirm: byId<HTMLButtonElement>("double-confirm"),
|
||||
doubleCancel: byId<HTMLButtonElement>("double-cancel"),
|
||||
};
|
||||
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
|
||||
ui.roll.textContent = TEXTS.roll;
|
||||
ui.double.textContent = TEXTS.double;
|
||||
ui.take.textContent = TEXTS.take;
|
||||
ui.drop.textContent = TEXTS.drop;
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
ui.cancel.textContent = TEXTS.cancel;
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.replay.textContent = TEXTS.replay;
|
||||
@@ -131,9 +153,9 @@ 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",
|
||||
// Mêmes conditions que les boutons « Lancer les dés »,
|
||||
// « Valider » et « Doubler ».
|
||||
diceAction: busy ? null : diceAction(game),
|
||||
canDouble: !busy && canDouble,
|
||||
});
|
||||
}
|
||||
@@ -156,6 +178,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.roll, player && state.phase === "rolling");
|
||||
show(ui.double, player && state.phase === "rolling");
|
||||
ui.double.disabled = busy || !canDouble;
|
||||
show(ui.confirm, player && state.phase === "moving" && game.moveComplete);
|
||||
show(ui.cancel, player && state.phase === "moving" && !stuck);
|
||||
show(ui.skip, stuck);
|
||||
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
|
||||
@@ -168,6 +191,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.roll,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.confirm,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
@@ -304,11 +328,22 @@ export function startApp(options: AppOptions): void {
|
||||
await g.skipTurn();
|
||||
});
|
||||
|
||||
ui.double.onclick = () =>
|
||||
act(async (g) => {
|
||||
// Doubler demande confirmation : un double proposé ne se reprend pas.
|
||||
ui.double.onclick = () => {
|
||||
if (busy || game === null || !canDouble) {
|
||||
return;
|
||||
}
|
||||
ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value);
|
||||
ui.doubleDialog.showModal();
|
||||
};
|
||||
ui.doubleCancel.onclick = () => ui.doubleDialog.close();
|
||||
ui.doubleConfirm.onclick = () => {
|
||||
ui.doubleDialog.close();
|
||||
void act(async (g) => {
|
||||
await g.double();
|
||||
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
|
||||
});
|
||||
};
|
||||
|
||||
ui.take.onclick = () =>
|
||||
act((g) => {
|
||||
@@ -322,9 +357,17 @@ export function startApp(options: AppOptions): void {
|
||||
info = "";
|
||||
});
|
||||
|
||||
// Le coup saisi, complet, entre dans la partie : le tour passe.
|
||||
ui.confirm.onclick = () =>
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.confirmMove();
|
||||
});
|
||||
|
||||
// Annuler défait le dernier demi-coup saisi, un par un.
|
||||
ui.cancel.onclick = () => {
|
||||
if (!busy && game !== null) {
|
||||
game.cancelHalfMoves();
|
||||
game.undoLastHalfMove();
|
||||
selected = null;
|
||||
shownMove = null;
|
||||
void refresh();
|
||||
@@ -344,15 +387,30 @@ export function startApp(options: AppOptions): void {
|
||||
ui.newGame.onclick = newGame;
|
||||
ui.again.onclick = newGame;
|
||||
|
||||
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
|
||||
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
|
||||
* validation. */
|
||||
function playHalfMove(halfMove: HalfMove): void {
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
await g.playHalfMove(halfMove);
|
||||
});
|
||||
}
|
||||
|
||||
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée
|
||||
// (ou aucune, s'il n'en a qu'une : il y va tout de suite).
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
const target = event.target as Element | null;
|
||||
// Toucher les dés en attente ou le videau : comme leurs boutons.
|
||||
// Toucher les dés (d'un côté ou de l'autre du plateau) 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 === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) {
|
||||
ui.confirm.click();
|
||||
return;
|
||||
}
|
||||
if (action === "double" && !ui.double.hidden && !ui.double.disabled) {
|
||||
ui.double.click();
|
||||
return;
|
||||
@@ -363,10 +421,12 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
|
||||
if (halfMove !== undefined) {
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
await g.playHalfMove(halfMove);
|
||||
});
|
||||
playHalfMove(halfMove);
|
||||
return;
|
||||
}
|
||||
const only = place === selected ? null : onlyHalfMove(targets, place);
|
||||
if (only !== null) {
|
||||
playHalfMove(only);
|
||||
return;
|
||||
}
|
||||
selected = targets.has(place) && place !== selected ? place : null;
|
||||
@@ -405,6 +465,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.double,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.confirm,
|
||||
ui.cancel,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
@@ -452,19 +513,44 @@ function rollBefore(game: Game): readonly number[] {
|
||||
return rolled?.type === "DiceRolled" ? rolled.roll : [];
|
||||
}
|
||||
|
||||
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
|
||||
* (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet
|
||||
* aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer
|
||||
* son tour sans bouger. */
|
||||
/** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer,
|
||||
* valider quand son coup est complet. */
|
||||
function diceAction(game: Game): DiceAction | null {
|
||||
const state = game.state;
|
||||
if (state.turn !== "player") {
|
||||
return null;
|
||||
}
|
||||
if (state.phase === "rolling") {
|
||||
return "roll";
|
||||
}
|
||||
return state.phase === "moving" && game.moveComplete ? "confirm" : null;
|
||||
}
|
||||
|
||||
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé (au
|
||||
* jet d'ouverture, un dé chacun ; quatre pour un double). Pendant que le
|
||||
* joueur saisit son coup, chaque dé déjà utilisé est estompé. Ils sont
|
||||
* barrés si le jet ne permet aucun coup : le joueur est bloqué (stuck), ou
|
||||
* l'ordinateur vient de passer son tour sans bouger. */
|
||||
function shownDice(game: Game, stuck: boolean): ShownDie[] {
|
||||
const toPlay = game.diceToPlay;
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
return [];
|
||||
}
|
||||
const blocked = stuck || computerCouldNotMove(game);
|
||||
const sides: Side[] =
|
||||
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
|
||||
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
|
||||
// Au jet d'ouverture, un dé chacun.
|
||||
const sideOf = (i: number): Side => rolled.side ?? (i === 0 ? "player" : "computer");
|
||||
if (toPlay.length > 0) {
|
||||
return toPlay.map((die, i) => ({
|
||||
value: die.value,
|
||||
side: sideOf(i),
|
||||
blocked: stuck,
|
||||
used: die.used,
|
||||
}));
|
||||
}
|
||||
const blocked = computerCouldNotMove(game);
|
||||
const [a, b] = rolled.roll;
|
||||
const values = a === b ? [a, a, a, a] : [a, b];
|
||||
return values.map((value, i) => ({ value, side: sideOf(i), blocked, used: false }));
|
||||
}
|
||||
|
||||
/** La dernière chose jouée (fin de partie mise à part) est un tour de
|
||||
@@ -519,6 +605,9 @@ function prompt(game: Game, selecting: boolean, canDouble: boolean): string {
|
||||
if (state.phase === "rolling") {
|
||||
return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble;
|
||||
}
|
||||
if (state.phase === "moving" && game.moveComplete) {
|
||||
return TEXTS.confirmOrUndo;
|
||||
}
|
||||
if (state.phase === "moving" && state.roll !== null) {
|
||||
return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Le seul demi-coup possible depuis place, s'il n'a qu'une case d'arrivée
|
||||
* (le pion y va dès qu'on le touche) ; null sinon. */
|
||||
export function onlyHalfMove(
|
||||
targets: ReadonlyMap<Place, ReadonlyMap<Place, HalfMove>>,
|
||||
place: Place,
|
||||
): HalfMove | null {
|
||||
const tos = targets.get(place);
|
||||
if (tos === undefined || tos.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
return [...tos.values()][0] ?? null;
|
||||
}
|
||||
|
||||
/** Ce qui reste dessiné d'un coup une fois joué. */
|
||||
export interface MoveTrace {
|
||||
/** Le camp qui a joué. */
|
||||
|
||||
+28
-13
@@ -20,8 +20,13 @@ export interface ShownDie {
|
||||
readonly side: Side;
|
||||
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
|
||||
readonly blocked: boolean;
|
||||
/** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
|
||||
export type DiceAction = "roll" | "confirm";
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
readonly dice: readonly ShownDie[];
|
||||
@@ -33,8 +38,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;
|
||||
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
|
||||
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
|
||||
* sont dessinés. */
|
||||
readonly diceAction: DiceAction | null;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
}
|
||||
@@ -45,7 +52,7 @@ export const NO_MARKS = {
|
||||
selected: null,
|
||||
targets: new Set<Place>(),
|
||||
trace: null,
|
||||
canRoll: false,
|
||||
diceAction: null,
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
@@ -158,9 +165,16 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
|
||||
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.diceAction !== null) {
|
||||
if (decor.diceAction === "roll") {
|
||||
waitingDice(svg);
|
||||
}
|
||||
// Une zone sur chaque moitié du plateau : les dés en attente (à
|
||||
// gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
|
||||
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
|
||||
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
cube.classList.add("can-double");
|
||||
@@ -493,7 +507,9 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const group = add(svg, "g", {
|
||||
class: die.blocked ? "die die-blocked" : "die",
|
||||
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
@@ -535,9 +551,8 @@ function cubeCenter(cube: Cube): Spot {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement): void {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
@@ -551,13 +566,13 @@ function waitingDice(svg: SVGSVGElement): Spot {
|
||||
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.
|
||||
* valider le coup, 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,
|
||||
|
||||
+1
-3
@@ -91,10 +91,8 @@ export async function showHelp(container: HTMLElement): Promise<void> {
|
||||
renderFeature(container, feature);
|
||||
}
|
||||
renderGlossary(container, glossary);
|
||||
const about = element(container, "p", `${TEXTS.about} `);
|
||||
const about = element(container, "p", TEXTS.about);
|
||||
about.className = "about";
|
||||
const link = element(about, "a", TEXTS.engineTestLink);
|
||||
link.href = "moteur/";
|
||||
container.dataset.loaded = "true";
|
||||
} catch {
|
||||
status.textContent = TEXTS.helpFailed;
|
||||
|
||||
+4
-1
@@ -36,6 +36,7 @@ export const TEXTS = {
|
||||
double: "Doubler",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
confirm: "Valider",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
@@ -60,6 +61,8 @@ export const TEXTS = {
|
||||
yourMove: (dice: readonly number[]) =>
|
||||
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
confirmOrUndo:
|
||||
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
@@ -67,6 +70,7 @@ export const TEXTS = {
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
computerCannotMove: (dice: readonly number[]) =>
|
||||
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
computerTakes: (value: number) =>
|
||||
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
|
||||
@@ -90,5 +94,4 @@ export const TEXTS = {
|
||||
helpLoading: "Chargement de l'aide…",
|
||||
helpFailed: "L'aide n'a pas pu se charger.",
|
||||
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
|
||||
engineTestLink: "Test du moteur",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user