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:
co-authored by
Claude Opus 5.5
parent
9c241de5c9
commit
7a9b376417
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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]],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user