Coup de l'ordinateur montré, aucun coup possible, dés et videau touchables #19
+84
-14
@@ -47,6 +47,20 @@ export interface SideBoards {
|
||||
readonly computer: BoardSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
|
||||
* successifs, tels que le moteur les donne.
|
||||
*/
|
||||
export interface MoveSteps {
|
||||
readonly side: Side;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
|
||||
readonly boards: readonly SideBoards[];
|
||||
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
|
||||
* (lu sur les plateaux du moteur, pas déduit des règles). */
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
@@ -141,14 +155,45 @@ export class Game {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
const onRoll = state.turn ?? "player";
|
||||
const board = await this.#engine.board(
|
||||
this.displayedPosition,
|
||||
engineCube(state.cube, onRoll),
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
|
||||
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
);
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,15 +244,32 @@ export class Game {
|
||||
|
||||
// ------------------------------------------------------------- Joueur
|
||||
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
|
||||
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
|
||||
async roll(): Promise<void> {
|
||||
const state = this.#expect("player", "rolling");
|
||||
const roll = rollDice(this.#dice);
|
||||
this.#append({ type: "DiceRolled", side: "player", roll });
|
||||
const moves = await this.#engine.legalMoves(state.position, roll);
|
||||
if (moves.length === 0) {
|
||||
await this.#playMove("player", [], null);
|
||||
this.#expect("player", "rolling");
|
||||
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
|
||||
}
|
||||
|
||||
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
|
||||
* légal : il ne peut que laisser passer son tour. */
|
||||
async cannotMove(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return false;
|
||||
}
|
||||
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
|
||||
return moves.length === 0;
|
||||
}
|
||||
|
||||
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
|
||||
* Erreur s'il a un coup possible. */
|
||||
async skipTurn(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
if (!(await this.cannotMove())) {
|
||||
throw new Error("vous avez un coup possible : jouez-le");
|
||||
}
|
||||
await this.#playMove("player", [], null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +387,14 @@ export class Game {
|
||||
this.#journal = appendEvent(this.#journal, event);
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(cube, onRoll));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
}
|
||||
|
||||
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
|
||||
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
|
||||
const state = this.state;
|
||||
|
||||
+198
-45
@@ -10,14 +10,17 @@
|
||||
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
|
||||
import { renderBoard, type ShownDie } from "./board-view.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 { showHelp } from "./help.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */
|
||||
const SLIDE_MS = 400;
|
||||
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
@@ -49,6 +52,8 @@ export function startApp(options: AppOptions): void {
|
||||
take: byId<HTMLButtonElement>("take"),
|
||||
drop: byId<HTMLButtonElement>("drop"),
|
||||
cancel: byId<HTMLButtonElement>("cancel"),
|
||||
skip: byId<HTMLButtonElement>("skip"),
|
||||
replay: byId<HTMLButtonElement>("replay"),
|
||||
again: byId<HTMLButtonElement>("again"),
|
||||
newGame: byId<HTMLButtonElement>("new-game"),
|
||||
helpButton: byId<HTMLButtonElement>("help-button"),
|
||||
@@ -63,6 +68,9 @@ export function startApp(options: AppOptions): void {
|
||||
ui.take.textContent = TEXTS.take;
|
||||
ui.drop.textContent = TEXTS.drop;
|
||||
ui.cancel.textContent = TEXTS.cancel;
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.replay.textContent = TEXTS.replay;
|
||||
ui.replay.title = TEXTS.replayTitle;
|
||||
ui.again.textContent = TEXTS.newGame;
|
||||
ui.newGame.textContent = TEXTS.newGame;
|
||||
ui.helpButton.textContent = TEXTS.help;
|
||||
@@ -74,22 +82,35 @@ export function startApp(options: AppOptions): void {
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
let canDouble = false;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer.
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info = "";
|
||||
// Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir »)
|
||||
// jusqu'à la prochaine action du joueur ; null : rien à montrer.
|
||||
let shownMove: MoveSteps | null = null;
|
||||
// Pendant l'animation d'un coup, render() laisse le plateau à l'animation.
|
||||
let animating = false;
|
||||
|
||||
// ------------------------------------------------------------ Dessin
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
async function refresh(): Promise<void> {
|
||||
/** Recalcule ce qui demande le moteur, sans redessiner. */
|
||||
async function update(): Promise<void> {
|
||||
if (game === null) {
|
||||
return;
|
||||
}
|
||||
sides = await game.displayedBoard();
|
||||
targets = moveTargets(await game.nextHalfMoves());
|
||||
canDouble = await game.canDouble();
|
||||
stuck = await game.cannotMove();
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
async function refresh(): Promise<void> {
|
||||
await update();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -99,30 +120,59 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const state = game.state;
|
||||
const model = boardModel(sides);
|
||||
renderBoard(ui.board, model, {
|
||||
dice: shownDice(game),
|
||||
cube: state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
lastMove: lastComputerMove(game),
|
||||
});
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
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);
|
||||
ui.info.textContent = info;
|
||||
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
|
||||
// Un jet sans coup possible reste annoncé jusqu'à l'action suivante,
|
||||
// même après un rechargement de la page.
|
||||
if (stuck && state.roll !== null) {
|
||||
ui.info.textContent = TEXTS.youCannotMove(state.roll);
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
}
|
||||
ui.prompt.textContent = stuck
|
||||
? TEXTS.pressContinue
|
||||
: prompt(game, selected !== null, canDouble);
|
||||
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
|
||||
|
||||
const player = state.turn === "player";
|
||||
show(ui.roll, player && state.phase === "rolling");
|
||||
show(ui.double, player && state.phase === "rolling");
|
||||
ui.double.disabled = busy || !canDouble;
|
||||
show(ui.cancel, player && state.phase === "moving");
|
||||
show(ui.cancel, player && state.phase === "moving" && !stuck);
|
||||
show(ui.skip, stuck);
|
||||
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
|
||||
const doubled = state.turn === "computer" && state.phase === "doubled";
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, state.phase === "over");
|
||||
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
|
||||
show(ui.replay, shownMove !== null);
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.newGame,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
}
|
||||
// Pour le style et pour les tests dans un navigateur : l'écran
|
||||
@@ -142,14 +192,73 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
|
||||
async function computerTurns(): Promise<void> {
|
||||
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double).
|
||||
* Son dernier coup, s'il est la dernière chose jouée, devient le coup à
|
||||
* montrer. Renvoie vrai si l'ordinateur a joué. */
|
||||
async function computerTurns(): Promise<boolean> {
|
||||
let played = false;
|
||||
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
|
||||
await refresh();
|
||||
const start = performance.now();
|
||||
await game.playComputerTurn();
|
||||
info = describeComputerTurn(game, performance.now() - start);
|
||||
save();
|
||||
played = true;
|
||||
}
|
||||
if (game !== null) {
|
||||
shownMove = await computerMoveToShow(game);
|
||||
}
|
||||
return played;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser les pions du coup de l'ordinateur, un demi-coup après
|
||||
* l'autre, à partir des plateaux intermédiaires donnés par le domaine. Rien
|
||||
* si le système demande moins d'animations : les traces suffisent.
|
||||
*/
|
||||
async function animateMove(steps: MoveSteps): Promise<void> {
|
||||
if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
return;
|
||||
}
|
||||
const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS };
|
||||
const draw = (i: number) => {
|
||||
const boards = steps.boards[i];
|
||||
if (boards !== undefined) {
|
||||
renderBoard(ui.board, boardModel(boards), decor);
|
||||
}
|
||||
};
|
||||
animating = true;
|
||||
render(); // messages et boutons ; le plateau est à l'animation
|
||||
try {
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
const from = placeOf(steps.side, halfMove.from);
|
||||
const to = placeOf(steps.side, halfMove.to);
|
||||
draw(i);
|
||||
const start = checkerSpot(ui.board, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(ui.board, to) : null;
|
||||
draw(i + 1);
|
||||
await Promise.all([
|
||||
start && slideChecker(ui.board, to, start, SLIDE_MS),
|
||||
victim &&
|
||||
slideChecker(ui.board, `bar-${otherSide(steps.side)}`, victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rend la main au joueur : recalcule, anime le coup à montrer (si
|
||||
* animate), puis redessine avec ses traces. */
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +271,7 @@ export function startApp(options: AppOptions): void {
|
||||
busy = true;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
render();
|
||||
try {
|
||||
await action(game);
|
||||
@@ -170,8 +280,7 @@ export function startApp(options: AppOptions): void {
|
||||
} catch (error) {
|
||||
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
await settle(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +295,13 @@ export function startApp(options: AppOptions): void {
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.roll();
|
||||
const last = g.events.at(-1);
|
||||
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
|
||||
info = TEXTS.youCannotMove(rollBefore(g));
|
||||
}
|
||||
});
|
||||
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
ui.skip.onclick = () =>
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.skipTurn();
|
||||
});
|
||||
|
||||
ui.double.onclick = () =>
|
||||
@@ -214,6 +326,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (!busy && game !== null) {
|
||||
game.cancelHalfMoves();
|
||||
selected = null;
|
||||
shownMove = null;
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
@@ -234,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;
|
||||
@@ -247,9 +370,22 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
selected = targets.has(place) && place !== selected ? place : null;
|
||||
if (selected !== null) {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// Revoir le dernier coup de l'ordinateur, puis ses traces.
|
||||
ui.replay.onclick = async () => {
|
||||
if (busy || shownMove === null) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
await settle(true);
|
||||
};
|
||||
|
||||
ui.helpButton.onclick = () => {
|
||||
ui.game.hidden = true;
|
||||
ui.help.hidden = false;
|
||||
@@ -264,7 +400,16 @@ export function startApp(options: AppOptions): void {
|
||||
// ---------------------------------------------------------- Démarrage
|
||||
|
||||
ui.prompt.textContent = TEXTS.loading;
|
||||
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.double,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.cancel,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
]) {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.newGame.disabled = true;
|
||||
@@ -276,12 +421,13 @@ export function startApp(options: AppOptions): void {
|
||||
save();
|
||||
}
|
||||
// Comme après une action du joueur : l'ordinateur joue si c'est
|
||||
// à lui (partie nouvelle ou reprise pendant son tour).
|
||||
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
let played = false;
|
||||
try {
|
||||
await computerTurns();
|
||||
played = await computerTurns();
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
await settle(played);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
@@ -307,29 +453,36 @@ function rollBefore(game: Game): readonly number[] {
|
||||
}
|
||||
|
||||
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
|
||||
* (au jet d'ouverture, un dé chacun). */
|
||||
function shownDice(game: Game): ShownDie[] {
|
||||
* (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. */
|
||||
function shownDice(game: Game, stuck: boolean): ShownDie[] {
|
||||
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" }));
|
||||
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
|
||||
}
|
||||
|
||||
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
|
||||
* l'ordinateur. */
|
||||
function lastComputerMove(game: Game): Set<Place> {
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
const places = new Set<Place>();
|
||||
if (played?.type === "MovePlayed" && played.side === "computer") {
|
||||
for (const h of played.halfMoves) {
|
||||
places.add(placeOf("computer", h.from));
|
||||
places.add(placeOf("computer", h.to));
|
||||
}
|
||||
/** La dernière chose jouée (fin de partie mise à part) est un tour de
|
||||
* l'ordinateur qui n'a pas pu jouer son jet. */
|
||||
function computerCouldNotMove(game: Game): boolean {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0;
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la
|
||||
* dernière chose jouée (fin de partie mise à part) et qu'il a déplacé des
|
||||
* pions ; sinon null. */
|
||||
async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
if (last?.type !== "MovePlayed" || last.side !== "computer" || last.halfMoves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return places;
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour. */
|
||||
|
||||
@@ -82,6 +82,50 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Ce qui reste dessiné d'un coup une fois joué. */
|
||||
export interface MoveTrace {
|
||||
/** Le camp qui a joué. */
|
||||
readonly side: Side;
|
||||
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
|
||||
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
|
||||
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
|
||||
readonly landed: ReadonlyMap<Place, number>;
|
||||
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
|
||||
readonly left: ReadonlyMap<Place, number>;
|
||||
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
|
||||
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
|
||||
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
|
||||
*/
|
||||
export function moveTrace(
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
hits: readonly boolean[],
|
||||
): MoveTrace {
|
||||
const balance = new Map<Place, number>();
|
||||
const arrows = halfMoves.map((h) => {
|
||||
const from = placeOf(side, h.from);
|
||||
const to = placeOf(side, h.to);
|
||||
balance.set(from, (balance.get(from) ?? 0) - 1);
|
||||
balance.set(to, (balance.get(to) ?? 0) + 1);
|
||||
return { from, to };
|
||||
});
|
||||
const landed = new Map<Place, number>();
|
||||
const left = new Map<Place, number>();
|
||||
for (const [place, n] of balance) {
|
||||
if (n > 0) {
|
||||
landed.set(place, n);
|
||||
} else if (n < 0) {
|
||||
left.set(place, -n);
|
||||
}
|
||||
}
|
||||
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
|
||||
}
|
||||
|
||||
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
|
||||
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
|
||||
export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
|
||||
+300
-60
@@ -11,12 +11,15 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import { type BoardModel, type Place, pointSlot } from "./board-model.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 {
|
||||
readonly value: number;
|
||||
readonly side: Side;
|
||||
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
|
||||
readonly blocked: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
@@ -28,10 +31,29 @@ export interface BoardDecor {
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
readonly selected: Place | null;
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
|
||||
readonly lastMove: 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. */
|
||||
export const NO_MARKS = {
|
||||
movable: new Set<Place>(),
|
||||
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 : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
|
||||
const WIDTH = 640;
|
||||
const HEIGHT = 520;
|
||||
@@ -73,10 +95,51 @@ function columnLeft(column: number): number {
|
||||
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
|
||||
}
|
||||
|
||||
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
|
||||
function stackY(top: boolean, index: number): number {
|
||||
const offset = RADIUS + 1 + index * STEP;
|
||||
return top ? FRAME + offset : HEIGHT - FRAME - offset;
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
|
||||
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
|
||||
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
|
||||
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
|
||||
*/
|
||||
function slotCenter(place: Place, index: number): Spot {
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
};
|
||||
}
|
||||
if (place === "off-player" || place === "off-computer") {
|
||||
const y =
|
||||
place === "off-computer"
|
||||
? FRAME + index * OFF_SLAB
|
||||
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
|
||||
}
|
||||
const { top, column } = pointSlot(Number(place.slice(1)));
|
||||
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: columnLeft(column) + POINT_WIDTH / 2,
|
||||
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
|
||||
};
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
if (place === `bar-${side}`) {
|
||||
return board.bar[side];
|
||||
}
|
||||
if (place === `off-${side}`) {
|
||||
return board.off[side];
|
||||
}
|
||||
const point = board.points.find((p) => p.place === place);
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** Redessine tout le plateau dans svg. */
|
||||
@@ -91,7 +154,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);
|
||||
}
|
||||
@@ -153,16 +227,7 @@ function drawPoint(
|
||||
});
|
||||
}
|
||||
if (point.owner !== null) {
|
||||
const y = (i: number) => stackY(top, i);
|
||||
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
|
||||
}
|
||||
if (decor.lastMove.has(point.place)) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: center,
|
||||
cy: top ? MIDDLE - 12 : MIDDLE + 12,
|
||||
r: 5,
|
||||
});
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
@@ -172,14 +237,13 @@ function drawPoint(
|
||||
});
|
||||
}
|
||||
|
||||
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
|
||||
* nombre total s'il y en a trop pour les montrer tous. */
|
||||
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
|
||||
* 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,
|
||||
side: Side,
|
||||
count: number,
|
||||
cx: number,
|
||||
y: (index: number) => number,
|
||||
place: Place,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
@@ -192,15 +256,13 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const cy = y(i);
|
||||
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
|
||||
const { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
|
||||
String(count),
|
||||
);
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,14 +275,10 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
|
||||
width: BAR_WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
const cx = BAR_LEFT + BAR_WIDTH / 2;
|
||||
// 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) {
|
||||
const up = side === "player";
|
||||
const y = (i: number) =>
|
||||
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
|
||||
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
|
||||
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
|
||||
}
|
||||
@@ -228,17 +286,12 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
|
||||
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
for (let i = 0; i < board.off[side]; i++) {
|
||||
const y =
|
||||
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
|
||||
add(svg, "rect", {
|
||||
class: `checker checker-${side}`,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: y + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (decor.targets.has("off-player")) {
|
||||
@@ -251,14 +304,6 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
if (decor.lastMove.has("off-computer")) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: TRAY_LEFT + TRAY_WIDTH / 2,
|
||||
cy: MIDDLE - 70,
|
||||
r: 5,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
@@ -267,6 +312,143 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
|
||||
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : 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, board: BoardModel, trace: MoveTrace): void {
|
||||
const side = trace.side;
|
||||
for (const [place, n] of trace.left) {
|
||||
// Au-dessus des pions restés sur la case.
|
||||
const remaining = countAt(board, side, place);
|
||||
for (let k = 0; k < n; k++) {
|
||||
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
|
||||
}
|
||||
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,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
const departures = new Map<Place, number>();
|
||||
for (const { from, to } of trace.arrows) {
|
||||
// De la place libre laissée par le pion (son fantôme) à son pion
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
const start = slotCenter(from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
|
||||
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
|
||||
function topIndex(board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
|
||||
add(svg, "line", {
|
||||
class: "trace-arrow",
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
|
||||
* dessiné (null s'il n'y a pas de pion). */
|
||||
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
* Résolue à la fin du glissement.
|
||||
*/
|
||||
export async function slideChecker(
|
||||
svg: SVGSVGElement,
|
||||
place: Place,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
const to = checkerSpot(svg, place);
|
||||
if (checker === null || to === null || typeof checker.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
// Devant les autres pions pendant le trajet, mais derrière les zones à
|
||||
// toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(checker, svg.lastChild);
|
||||
const animation = checker.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
// Position des points d'un dé, en fractions de son côté.
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[0.5, 0.5]],
|
||||
@@ -310,7 +492,10 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
dice.forEach((die, i) => {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const group = add(svg, "g", { class: "die", "data-value": die.value });
|
||||
const group = add(svg, "g", {
|
||||
class: die.blocked ? "die die-blocked" : "die",
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
for (const [px, py] of PIPS[die.value] ?? []) {
|
||||
add(group, "circle", {
|
||||
@@ -320,23 +505,78 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
r: 3.5,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
+5
-2
@@ -37,6 +37,9 @@ export const TEXTS = {
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
newGame: "Nouvelle partie",
|
||||
help: "Aide",
|
||||
back: "Retour à la partie",
|
||||
@@ -57,8 +60,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.",
|
||||
youCannotMove: (dice: readonly number[]) =>
|
||||
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
|
||||
@@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
} else {
|
||||
assert.ok(state.roll !== null);
|
||||
const [first] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
|
||||
if (first === undefined) {
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
continue;
|
||||
}
|
||||
for (const halfMove of first.halfMoves) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
|
||||
+22
-1
@@ -86,19 +86,40 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
|
||||
assert.equal(game.state.position, "SWAPPED");
|
||||
});
|
||||
|
||||
test("sans coup possible, le tour passe", async () => {
|
||||
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
|
||||
const engine = fakeEngine({ legalMoves: async () => [] });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([6, 6]),
|
||||
setup: { position: "BLOCKED", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed");
|
||||
assert.deepEqual(last.halfMoves, []);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
|
||||
const engine = fakeEngine({
|
||||
legalMoves: async () => [
|
||||
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
|
||||
],
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await game.cannotMove(), false);
|
||||
await assert.rejects(game.skipTurn(), /coup possible/);
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: true }),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Un coup demi-coup par demi-coup (Game.lastMoveSteps), sur le vrai moteur
|
||||
// WebAssembly : les plateaux intermédiaires que l'interface anime, et les
|
||||
// pions frappés qu'elle signale.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
formatHalfMoves,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
} from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 13, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 10 du joueur (sa case 15), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_CAN_HIT = "4P8HEADg/wcEAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 21 du joueur (sa case 4), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_ON_BAR = "yP8PAADg/wcAQA";
|
||||
|
||||
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
/** Une partie où le joueur, au trait, joue le jet dice à la position. */
|
||||
async function playerMove(position: PositionId, dice: [number, number], move: string) {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice(dice),
|
||||
setup: { position, firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Le nombre de pions d'un camp sur une case (1 à 24), sur la barre ou sortis. */
|
||||
function at(side: BoardSide, point: number | "bar" | "off"): number {
|
||||
if (point === "bar") {
|
||||
return side.bar;
|
||||
}
|
||||
return point === "off" ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.equal(steps.side, "player");
|
||||
assert.equal(formatHalfMoves(steps.halfMoves), "8/5 6/5");
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
const counts = steps.boards.map((b) => [at(b.player, 8), at(b.player, 6), at(b.player, 5)]);
|
||||
assert.deepEqual(counts, [
|
||||
[3, 5, 0],
|
||||
[2, 5, 1],
|
||||
[2, 4, 2],
|
||||
]);
|
||||
// Le dernier plateau est celui de la partie après le coup.
|
||||
assert.deepEqual(steps.boards[2], await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("un pion frappé part sur la barre au demi-coup qui le frappe", async () => {
|
||||
const game = await playerMove(PLAYER_CAN_HIT, [3, 1], "13/10 10/9");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
const bar = steps.boards.map((b) => at(b.computer, "bar"));
|
||||
assert.deepEqual(bar, [0, 1, 1]);
|
||||
// Le blot était sur la case 10 du joueur, la case 15 de l'ordinateur.
|
||||
assert.equal(at(steps.boards[0]?.computer as BoardSide, 15), 1);
|
||||
assert.equal(at(steps.boards[1]?.computer as BoardSide, 15), 0);
|
||||
assert.equal(at(steps.boards[2]?.player as BoardSide, 9), 1);
|
||||
});
|
||||
|
||||
test("un coup depuis la barre, qui frappe en rentrant", async () => {
|
||||
const game = await playerMove(PLAYER_ON_BAR, [4, 2], "bar/21 6/4");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => [at(b.player, "bar"), at(b.player, 21), at(b.computer, "bar")]),
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, 1, 1],
|
||||
[0, 1, 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("une sortie de pions, jusqu'à la fin de la partie", async () => {
|
||||
const game = await playerMove(PLAYER_ALMOST_DONE, [6, 5], "6/off 5/off");
|
||||
assert.equal(game.state.phase, "over");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => at(b.player, "off")),
|
||||
[13, 14, 15],
|
||||
);
|
||||
});
|
||||
|
||||
test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Vous commencez avec 3-1, puis l'ordinateur tire 6-4.
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.playComputerTurn();
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.equal(steps.side, "computer");
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1);
|
||||
// Chaque demi-coup déplace un pion de l'ordinateur de sa case de départ.
|
||||
for (const [i, h] of steps.halfMoves.entries()) {
|
||||
const from = h.from === BAR ? "bar" : h.from;
|
||||
const before = steps.boards[i]?.computer as BoardSide;
|
||||
const after = steps.boards[i + 1]?.computer as BoardSide;
|
||||
assert.equal(at(after, from), at(before, from) - 1);
|
||||
}
|
||||
assert.deepEqual(steps.boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("avant le premier coup, il n'y a rien à montrer", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
assert.equal(await game.lastMoveSteps(), null);
|
||||
});
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { GameEvent } from "../../src/domain/game/events.js";
|
||||
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
OFF,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
START_POSITION,
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
@@ -27,11 +33,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
|
||||
// l'ordinateur n'en a sorti aucun.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
|
||||
// pion devrait rentrer.
|
||||
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
|
||||
|
||||
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
|
||||
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
|
||||
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
|
||||
// est 13/10* 10/9 : il frappe votre blot.
|
||||
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let accepted: boolean;
|
||||
let positionBefore: PositionId;
|
||||
let computerTurn: { before: GameState; ms: number };
|
||||
let computerMove: MoveSteps;
|
||||
|
||||
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
|
||||
* 1 à 24, dans sa numérotation). */
|
||||
function checkersAt(side: BoardSide, point: number): number {
|
||||
if (point === BAR) {
|
||||
return side.bar;
|
||||
}
|
||||
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
@@ -171,6 +198,93 @@ Then(
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------- Aucun coup possible
|
||||
|
||||
Given("votre pion sur la barre ne peut pas rentrer", () => {
|
||||
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("vous lancez les dés", async () => {
|
||||
await game.roll();
|
||||
});
|
||||
|
||||
Then("aucun coup n'est possible avec votre jet", async () => {
|
||||
assert.deepEqual(game.state.roll, [6, 5]);
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("c'est toujours à vous", () => {
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
assert.deepEqual(event.halfMoves, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------- Voir le coup de l'ordinateur
|
||||
|
||||
Given("un de vos pions est seul à portée de l'ordinateur", () => {
|
||||
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
|
||||
// doubler, il joue.
|
||||
game = Game.start(engine, {
|
||||
setup: {
|
||||
position: COMPUTER_MUST_HIT,
|
||||
firstTurn: "computer",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
dice: fixedDice([3, 1]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur a joué", async () => {
|
||||
await game.playComputerTurn();
|
||||
const played = lastEvent();
|
||||
assert.ok(played.type === "MovePlayed" && played.side === "computer");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
computerMove = steps;
|
||||
});
|
||||
|
||||
Then("son coup vous est montré case par case", async () => {
|
||||
const { side, halfMoves, boards } = computerMove;
|
||||
assert.equal(side, "computer");
|
||||
assert.ok(halfMoves.length > 0);
|
||||
assert.equal(boards.length, halfMoves.length + 1);
|
||||
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
|
||||
// parti de la case de départ du demi-coup pour sa case d'arrivée.
|
||||
for (const [i, h] of halfMoves.entries()) {
|
||||
const before = boards[i]?.computer;
|
||||
const after = boards[i + 1]?.computer;
|
||||
assert.ok(before !== undefined && after !== undefined);
|
||||
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
|
||||
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
|
||||
}
|
||||
// Le dernier est le plateau de la partie.
|
||||
assert.deepEqual(boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
Then("votre pion frappé part sur la barre", () => {
|
||||
const { boards, hits } = computerMove;
|
||||
const hit = hits.indexOf(true);
|
||||
assert.ok(hit >= 0, "aucun pion frappé");
|
||||
assert.equal(boards[hit]?.player.bar, 0);
|
||||
assert.equal(boards[hit + 1]?.player.bar, 1);
|
||||
});
|
||||
|
||||
Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
Given("l'ordinateur vous propose de doubler", async () => {
|
||||
|
||||
+51
-2
@@ -10,9 +10,9 @@ import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js";
|
||||
import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js";
|
||||
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
|
||||
import { TEXTS } from "../src/ui/texts.js";
|
||||
|
||||
@@ -67,6 +67,55 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv
|
||||
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
|
||||
});
|
||||
|
||||
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
|
||||
// L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et
|
||||
// 7 sont les cases 12, 16 et 18 du joueur.
|
||||
const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]);
|
||||
assert.deepEqual(trace.arrows, [
|
||||
{ from: "p12", to: "p16" },
|
||||
{ from: "p12", to: "p18" },
|
||||
]);
|
||||
assert.deepEqual([...trace.left], [["p12", 2]]);
|
||||
assert.deepEqual(
|
||||
[...trace.landed],
|
||||
[
|
||||
["p16", 1],
|
||||
["p18", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(trace.hits, 0);
|
||||
});
|
||||
|
||||
test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => {
|
||||
const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]);
|
||||
assert.equal(trace.arrows.length, 2);
|
||||
assert.deepEqual([...trace.left], [["p1", 1]]);
|
||||
assert.deepEqual([...trace.landed], [["p11", 1]]);
|
||||
assert.equal(trace.hits, 1);
|
||||
});
|
||||
|
||||
test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => {
|
||||
const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]);
|
||||
assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" });
|
||||
assert.deepEqual(
|
||||
[...entering.left],
|
||||
[
|
||||
["bar-computer", 1],
|
||||
["p19", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(entering.hits, 1);
|
||||
const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]);
|
||||
assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]);
|
||||
assert.deepEqual(
|
||||
[...bearingOff.left],
|
||||
[
|
||||
["p19", 1],
|
||||
["p20", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
|
||||
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
|
||||
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
|
||||
|
||||
@@ -30,6 +30,30 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand ses dés sont tirés
|
||||
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
|
||||
que vous avez touché « Continuer ».
|
||||
|
||||
Étant donné que votre pion sur la barre ne peut pas rentrer
|
||||
Quand vous lancez les dés
|
||||
Alors aucun coup n'est possible avec votre jet
|
||||
Et c'est toujours à vous
|
||||
Quand vous continuez
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
étaient. Le bouton « Revoir » rejoue son coup.
|
||||
|
||||
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
|
||||
Quand l'ordinateur a joué
|
||||
Alors son coup vous est montré case par case
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Prendre le double de l'ordinateur
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous prenez
|
||||
|
||||
+140
-5
@@ -101,7 +101,7 @@ async function waitForPlayer() {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["again", "take", "roll"]) {
|
||||
for (const id of ["again", "take", "skip", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
@@ -130,6 +130,101 @@ async function waitForPlayer() {
|
||||
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
|
||||
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
|
||||
function computerMoveToShow(journal) {
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
|
||||
? last
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Ce que montre le plateau du coup de l'ordinateur. */
|
||||
async function shownTraces() {
|
||||
return page.evaluate(() => ({
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
landed: document.querySelectorAll(".trace-landed").length,
|
||||
ghosts: document.querySelectorAll(".trace-ghost").length,
|
||||
hits: document.querySelectorAll(".trace-hit").length,
|
||||
replay: !document.getElementById("replay").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
|
||||
* dernière chose jouée, et rien sinon. */
|
||||
async function checkTraces() {
|
||||
const journal = await savedJournal();
|
||||
const move = computerMoveToShow(journal);
|
||||
const traces = await shownTraces();
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
|
||||
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2,
|
||||
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
|
||||
}
|
||||
if (move === null) {
|
||||
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
|
||||
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
|
||||
return null;
|
||||
}
|
||||
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
|
||||
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
|
||||
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) {
|
||||
let animations = 0;
|
||||
let arrows = Infinity;
|
||||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||||
const seen = await page.evaluate(() => ({
|
||||
animations: document.getAnimations().length,
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
}));
|
||||
animations = Math.max(animations, seen.animations);
|
||||
arrows = Math.min(arrows, seen.arrows);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return { animations, arrows };
|
||||
}
|
||||
|
||||
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
|
||||
* remet les mêmes traces. */
|
||||
async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||||
await page.click("#replay");
|
||||
const seen = await watchReplay(300);
|
||||
if (reducedMotion) {
|
||||
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
|
||||
} else {
|
||||
check(seen.animations > 0 && seen.arrows === 0,
|
||||
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
|
||||
}
|
||||
await waitForPlayer();
|
||||
const after = await shownTraces();
|
||||
check(JSON.stringify(after) === JSON.stringify(traces),
|
||||
`traces différentes après Revoir : ${JSON.stringify(after)}`);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
|
||||
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
|
||||
// capture et l'état de l'écran.
|
||||
let lastStep = Date.now();
|
||||
@@ -157,10 +252,30 @@ let doubled = false;
|
||||
let selectionShot = false;
|
||||
let reloaded = false;
|
||||
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();
|
||||
step(`partie, action ${actions}`);
|
||||
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
|
||||
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
|
||||
const traces = await checkTraces();
|
||||
if (traces !== null) {
|
||||
tracesSeen++;
|
||||
if (replays < 2) {
|
||||
replays++;
|
||||
await checkReplay(traces, replays === 2);
|
||||
if (replays === 1) {
|
||||
await shot("coup-adverse");
|
||||
}
|
||||
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
|
||||
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
|
||||
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
|
||||
}
|
||||
}
|
||||
if (process.env.TRACE) {
|
||||
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
|
||||
}
|
||||
@@ -172,22 +287,37 @@ for (let actions = 0; ; actions++) {
|
||||
if (todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "take") {
|
||||
if (todo === "skip") {
|
||||
// Aucun coup possible : le jet reste affiché, barré, avec un
|
||||
// message, et le tour ne passe qu'en touchant « Continuer ».
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2,
|
||||
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
skips++;
|
||||
log(`aucun coup possible : « ${info} », vous continuez`);
|
||||
await page.click("#skip");
|
||||
} else if (todo === "take") {
|
||||
log("l'ordinateur double : vous prenez");
|
||||
await shot("videau-propose");
|
||||
await page.click("#take");
|
||||
} 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) {
|
||||
@@ -223,6 +353,11 @@ 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) {
|
||||
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
||||
`${computerMs.length} tours`);
|
||||
|
||||
+49
-1
@@ -444,13 +444,61 @@ body.busy .prompt {
|
||||
.count-computer { fill: var(--count-computer); }
|
||||
|
||||
.target-zone { fill: var(--target); }
|
||||
.last-move { fill: var(--last-move); }
|
||||
/* Le dernier coup de l'ordinateur (couleur laiton, charte) : une flèche par
|
||||
* demi-coup, les pions arrivés et frappés entourés, un pion fantôme là où
|
||||
* un pion est parti. */
|
||||
.trace-arrow {
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.trace-head { fill: var(--last-move); }
|
||||
|
||||
.trace-landed,
|
||||
.trace-hit {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.trace-hit { stroke-dasharray: 6 4; }
|
||||
|
||||
.trace-ghost {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
|
||||
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
|
||||
.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; }
|
||||
|
||||
.die-strike {
|
||||
stroke: var(--text);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
|
||||
|
||||
.cube-value {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
<button id="take" type="button" hidden></button>
|
||||
<button id="drop" type="button" class="secondary" hidden></button>
|
||||
<button id="cancel" type="button" class="secondary" hidden></button>
|
||||
<button id="skip" type="button" hidden></button>
|
||||
<button id="replay" type="button" class="secondary" hidden></button>
|
||||
<button id="again" type="button" hidden></button>
|
||||
</div>
|
||||
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
|
||||
|
||||
Reference in New Issue
Block a user