Coup de l'ordinateur : animation, traces et bouton Revoir

Le domaine donne le dernier coup demi-coup par demi-coup (plateaux
intermédiaires du moteur, pions frappés lus sur la barre) ; l'interface
fait glisser les pions puis laisse flèches, pions entourés et fantômes
jusqu'à l'action suivante. Scénario « Voir le coup de l'ordinateur »,
tests unitaires et test dans le navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 01:53:26 +01:00
co-authored by Claude Opus 5.5
parent 9c241de5c9
commit 7a9b376417
12 changed files with 851 additions and 101 deletions
+60 -7
View File
@@ -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 };
}
/**
@@ -325,6 +370,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;
+128 -35
View File
@@ -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,7 @@ export function startApp(options: AppOptions): void {
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
cancel: byId<HTMLButtonElement>("cancel"),
replay: byId<HTMLButtonElement>("replay"),
again: byId<HTMLButtonElement>("again"),
newGame: byId<HTMLButtonElement>("new-game"),
helpButton: byId<HTMLButtonElement>("help-button"),
@@ -63,6 +67,8 @@ export function startApp(options: AppOptions): void {
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.cancel.textContent = TEXTS.cancel;
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;
@@ -76,11 +82,16 @@ export function startApp(options: AppOptions): void {
let canDouble = 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;
}
@@ -90,6 +101,11 @@ export function startApp(options: AppOptions): void {
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,14 +115,19 @@ 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),
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),
});
}
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
ui.info.textContent = info;
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
@@ -122,7 +143,8 @@ export function startApp(options: AppOptions): void {
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.replay, ui.again, ui.newGame]) {
button.disabled = busy;
}
// Pour le style et pour les tests dans un navigateur : l'écran
@@ -142,14 +164,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), 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 +243,7 @@ export function startApp(options: AppOptions): void {
busy = true;
selected = null;
targets = new Map();
shownMove = null;
render();
try {
await action(game);
@@ -170,8 +252,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);
}
}
@@ -214,6 +295,7 @@ export function startApp(options: AppOptions): void {
if (!busy && game !== null) {
game.cancelHalfMoves();
selected = null;
shownMove = null;
void refresh();
}
};
@@ -247,9 +329,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 +359,7 @@ 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.replay, ui.again]) {
button.hidden = true;
}
ui.newGame.disabled = true;
@@ -276,12 +371,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) => {
@@ -318,18 +414,15 @@ function shownDice(game: Game): ShownDie[] {
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" }));
}
/** 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));
}
/** 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. */
+44
View File
@@ -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 } {
+213 -53
View File
@@ -11,7 +11,7 @@
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";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
export interface ShownDie {
@@ -28,10 +28,18 @@ 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;
}
/** 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,
} as const;
// 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 +81,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. */
@@ -92,6 +141,9 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
drawCube(svg, decor.cube);
if (decor.trace !== null) {
drawTrace(svg, board, decor.trace);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -153,16 +205,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 +215,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 +234,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 +253,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 +264,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 +282,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 +290,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]],
+2
View File
@@ -37,6 +37,8 @@ export const TEXTS = {
take: "Prendre",
drop: "Passer",
cancel: "Annuler",
replay: "Revoir",
replayTitle: "Revoir le dernier coup de l'ordinateur",
newGame: "Nouvelle partie",
help: "Aide",
back: "Retour à la partie",