Plateau : glisser-déposer, double toucher, clavier, raccourcis et filet des pions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 09:59:34 +01:00
co-authored by Claude Opus 5.5
parent 6dd0e93d3d
commit 835a907b9c
11 changed files with 1127 additions and 42 deletions
+20 -6
View File
@@ -367,8 +367,9 @@ export class Game {
/**
* Le coup évident qui commence par le pion de from (une case du joueur,
* ou la barre : BAR), après les demi-coups déjà saisis : les demi-coups
* qui restent à jouer pour le compléter, ou null s'il n'y en a pas
* ou la barre : BAR), après les demi-coups already (par défaut, ceux
* déjà saisis) : les demi-coups qui le complètent, ou null s'il n'y en
* a pas
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
@@ -379,16 +380,29 @@ export class Game {
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
* coups complets différents apparaissent.
*/
async obviousMove(from: number): Promise<HalfMove[] | null> {
async obviousMove(
from: number,
already: readonly HalfMove[] = this.pendingHalfMoves,
): Promise<HalfMove[] | null> {
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
const state = this.state;
const pending = this.pendingHalfMoves;
const displayed = this.displayedPosition;
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
const pending = [...already];
if (state.turn !== "player" || state.phase !== "moving") {
return null;
}
const roll = rollToPlay(state);
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
if (limit <= 0) {
return null;
}
let displayed = state.position;
if (pending.length > 0) {
const check = await this.#engine.checkHalfMoves(state.position, roll, pending);
if (!check.valid || check.position === null) {
return null;
}
displayed = check.position;
}
const legal = await this.#engine.legalMoves(state.position, roll);
// Un seul coup légal : il est évident, quel que soit le pion.
const anyChecker = legal.length === 1;
+312 -4
View File
@@ -39,7 +39,7 @@ import type { Cube } from "../domain/game/cube.js";
import type { MoveNotation } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import { BAR, type HalfMove } from "../domain/game/position.js";
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
@@ -50,6 +50,7 @@ import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
import { createAnalysisView } from "./analysis-view.js";
import { createPromptView, type PromptResult } from "./ask-ai.js";
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import { attachDrag, createDoubleTap, nextPlace } from "./board-gestures.js";
import {
boardModel,
moveTargets,
@@ -63,12 +64,16 @@ import {
import {
type BoardAdvice,
type BoardDecor,
type BoardDrag,
checkerSpot,
type DiceAction,
type MatchScore,
moveDraggedChecker,
NO_MARKS,
renderBoard,
returnDraggedChecker,
type ShownDie,
type Spot,
slideChecker,
} from "./board-view.js";
import { GAMES_TEXTS } from "./games-texts.js";
@@ -226,6 +231,20 @@ type ActionName =
| "fullscreen"
| "close";
/** Le premier toucher d'un double toucher : la partie, le pion touché et
* les demi-coups saisis à ce moment-là. */
interface ObviousTap {
readonly game: Game;
readonly place: Place;
readonly pending: readonly HalfMove[];
}
/** Le numéro, pour le domaine, d'un endroit du joueur : la barre (BAR) ou
* une case de 1 à 24. */
function pointOf(place: Place): number {
return place === "bar-player" ? BAR : Number(place.slice(1));
}
/** Démarre l'écran de jeu dans la page (index.html). */
export function startApp(options: AppOptions): void {
const stripOf = (id: string) => {
@@ -375,7 +394,57 @@ export function startApp(options: AppOptions): void {
ui.confirm.textContent = TEXTS.confirm;
label(ui.cancel, TEXTS.cancel, "undo");
ui.skip.textContent = TEXTS.skip;
ui.again.textContent = MATCH_TEXTS.replay;
const againLabel = document.createElement("span");
againLabel.textContent = MATCH_TEXTS.replay;
ui.again.replaceChildren(againLabel);
// Les raccourcis du clavier (interaction.md), en petite touche sur
// chaque bouton ; visibles seulement quand un clavier est détecté.
const shortcuts: [HTMLElement, string, string][] = [
[ui.roll, "Espace", "Space"],
[ui.confirm, "Espace", "Space"],
[ui.skip, "Espace", "Space"],
[ui.again, "Espace", "Space"],
[ui.double, "D", "D"],
[ui.take, "P", "P"],
[ui.drop, "N", "N"],
[ui.cancel, "Z", "Z"],
[ui.helpButton, "?", "?"],
[ui.fullscreenButton, "F", "F"],
[ui.menuButton, "M", "M"],
];
for (const [button, key, aria] of shortcuts) {
const kbd = document.createElement("kbd");
kbd.className = "shortcut";
kbd.textContent = key;
kbd.setAttribute("aria-hidden", "true");
button.append(kbd);
button.setAttribute("aria-keyshortcuts", aria);
}
// Un clavier est détecté : sur un ordinateur (pointeur fin, écran
// large), ou dès qu'une touche est frappée ; un toucher du doigt les
// cache de nouveau.
const desktop = window.matchMedia("(hover: hover) and (pointer: fine) and (min-width: 64rem)");
const showShortcuts = (on: boolean) =>
document.documentElement.classList.toggle("keyboard", on);
showShortcuts(desktop.matches);
document.addEventListener(
"keydown",
(event) => {
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key !== "Unidentified") {
showShortcuts(true);
}
},
{ capture: true },
);
document.addEventListener(
"pointerdown",
(event) => {
if (event.pointerType === "touch") {
showShortcuts(false);
}
},
{ capture: true },
);
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -440,6 +509,20 @@ export function startApp(options: AppOptions): void {
let sides: SideBoards | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
// Le pion qu'on fait glisser (charte, 1i), et celui qui vient d'être
// lâché sur une case permise : il reste dessiné là où le doigt l'a
// posé jusqu'à ce que le moteur ait accepté le demi-coup, puis glisse
// à sa place (to).
let drag: BoardDrag | null = null;
let dropped: (BoardDrag & { readonly to: Place }) | null = null;
// Le pion lâché hors d'une case permise rentre à sa place.
let returning = false;
// Clavier : la case d'arrivée choisie aux flèches haut et bas, pour le
// pion choisi (selected) aux flèches gauche et droite.
let keyTarget: Place | null = null;
// Ceux qui attendent que l'écran soit libre (le double toucher qui suit
// un pion parti dès le premier toucher).
let idleWaiters: (() => void)[] = [];
let canDouble = false;
// Le joueur a lancé les dés mais ne peut pas jouer : il doit passer.
let stuck = false;
@@ -636,6 +719,8 @@ export function startApp(options: AppOptions): void {
: null,
controls: landscape ? controls() : null,
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
drag: drag ?? dropped,
focus: selected !== null && keyTarget !== null ? keyTarget : null,
});
}
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
@@ -681,7 +766,7 @@ export function startApp(options: AppOptions): void {
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, over && panelDismissed);
ui.again.textContent = afterGameLabel(game);
againLabel.textContent = afterGameLabel(game);
show(ui.replay, shownMove !== null);
// Le coup de l'ordinateur montré : sa position se copie en lien.
show(ui.copyLink, shownMove !== null && currentLink !== null);
@@ -1122,15 +1207,40 @@ export function startApp(options: AppOptions): void {
async function settle(animate: boolean): Promise<void> {
try {
await update();
await settleDropped();
if (animate && shownMove !== null) {
await animateMove(shownMove);
}
} finally {
busy = false;
dropped = null;
render();
const waiters = idleWaiters;
idleWaiters = [];
for (const resolve of waiters) {
resolve();
}
}
}
/** Le pion lâché sur une case permise : une fois le demi-coup accepté,
* il glisse de là où le doigt l'a posé jusqu'à sa place (duration-fast),
* ou y apparaît, si le système demande moins de mouvement. */
async function settleDropped(): Promise<void> {
const landing = dropped;
dropped = null;
if (landing === null || reducedMotion.matches) {
return;
}
render();
await slideChecker(ui.board, landing.to, landing.at, SLIDE_MS);
}
/** Résolue quand l'écran ne travaille plus (moteur, animation). */
function whenIdle(): Promise<void> {
return busy ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve();
}
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
* ignorées pendant ce temps. */
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
@@ -1139,6 +1249,8 @@ export function startApp(options: AppOptions): void {
}
busy = true;
selected = null;
keyTarget = null;
drag = null;
targets = new Map();
shownMove = null;
bubbleSide = null;
@@ -1600,10 +1712,65 @@ export function startApp(options: AppOptions): void {
if (await g.playHalfMove(halfMove)) {
const after = (await g.displayedBoard()).computer.bar;
haptic(after > before ? "checkerHit" : "checkerPlaced");
} else {
dropped = null;
}
});
}
/**
* Double toucher : joue le coup évident (le domaine le cherche, le
* moteur le juge) commencé au premier toucher, si le joueur n'a rien
* fait d'autre entre-temps. Le premier toucher a pu faire partir le
* pion tout de suite (une seule case d'arrivée) : il ne reste alors que
* la suite du coup. Sinon, rien : le double toucher vaut un toucher.
*/
async function playObvious(tap: ObviousTap): Promise<void> {
await whenIdle();
const g = game;
if (g !== tap.game || busy || g.state.phase !== "moving") {
return;
}
const now = g.pendingHalfMoves;
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
if (now.length < tap.pending.length || !tap.pending.every((h, i) => same(h, now[i]))) {
return;
}
// Demandé seulement maintenant : un toucher simple ne coûte rien au
// moteur.
busy = true;
render();
let obvious: HalfMove[] | null = null;
try {
obvious = await g.obviousMove(pointOf(tap.place), tap.pending);
} catch {
obvious = null;
}
busy = false;
const done = now.slice(tap.pending.length);
const rest = obvious?.slice(done.length) ?? [];
const unchanged =
game === g &&
g.pendingHalfMoves.length === now.length &&
done.every((h, i) => same(h, obvious?.[i]));
if (!unchanged || rest.length === 0) {
render();
return;
}
void act(async (g) => {
info = "";
const before = (await g.displayedBoard()).computer.bar;
for (const halfMove of rest) {
if (!(await g.playHalfMove(halfMove))) {
break;
}
}
const after = (await g.displayedBoard()).computer.bar;
haptic(after > before ? "checkerHit" : "checkerPlaced");
});
}
const canAct = (): Game | null => (busy || game === null ? null : game);
const actions: Record<ActionName, () => void> = {
@@ -2412,10 +2579,75 @@ export function startApp(options: AppOptions): void {
ui.replay.onclick = actions.replay;
ui.again.onclick = actions.again;
// Glisser un pion (charte, 1i) : il suit le doigt, agrandi et un peu
// au-dessus ; un fantôme reste à son départ et ses cases d'arrivée (du
// moteur) sont marquées. Lâché sur l'une d'elles, il s'y pose ; ailleurs,
// il rentre à sa place, sans vibration ni message.
const dragging = attachDrag(ui.board, {
canStart: (place) =>
!busy && !animating && !returning && game !== null && targets.has(place),
start: (place, at) => {
closeTransient();
selected = place;
keyTarget = null;
shownMove = null;
drag = { place, at };
render();
},
move: (at) => {
if (drag !== null) {
drag = { place: drag.place, at };
moveDraggedChecker(ui.board, at);
}
},
drop: (over, at) => {
if (drag === null) {
return;
}
const from = drag.place;
const halfMove = over === null ? undefined : targets.get(from)?.get(over);
if (halfMove === undefined || over === null || busy) {
void dragBack(at);
return;
}
dropped = { place: from, at, to: over };
drag = null;
playHalfMove(halfMove);
},
cancel: (at) => void dragBack(at),
});
/** Le pion glissé rentre à sa place (duration-fast), puis le plateau
* redevient celui d'avant le glissement. */
async function dragBack(at: Spot): Promise<void> {
if (drag === null) {
return;
}
drag = { place: drag.place, at };
returning = true;
try {
if (!reducedMotion.matches) {
await returnDraggedChecker(ui.board, at, SLIDE_MS);
}
} finally {
returning = false;
drag = null;
selected = null;
render();
}
}
// Double toucher : le premier toucher fait ce que fait un toucher, et
// demande déjà au domaine le coup évident de ce pion ; le second le joue.
const doubleTap = createDoubleTap<ObviousTap | null>();
// Toucher le plateau : une commande (dés, videau, colonne de gauche), ou
// un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a
// qu'une : il y va tout de suite).
ui.board.addEventListener("click", (event) => {
if (dragging.takeClick()) {
return;
}
const target = event.target as Element | null;
if (!ui.bubble.hidden) {
closeOverlays();
@@ -2431,9 +2663,23 @@ export function startApp(options: AppOptions): void {
return;
}
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
if (place == null || game === null) {
return;
}
const current = game;
const second = doubleTap.tap(place, performance.now(), () =>
busy || !targets.has(place)
? null
: { game: current, place, pending: [...current.pendingHalfMoves] },
);
if (second !== null) {
void playObvious(second);
return;
}
if (busy) {
return;
}
keyTarget = null;
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
playHalfMove(halfMove);
@@ -2451,6 +2697,64 @@ export function startApp(options: AppOptions): void {
render();
});
/**
* Le plateau au clavier (interaction.md) : les flèches gauche et droite
* choisissent un pion jouable (dans le sens de la course), haut et bas
* une de ses cases d'arrivée (anneau focus), Entrée l'y pose. Les cases
* sont celles que le moteur a données. Renvoie vrai si la touche a
* servi.
*/
function boardKey(key: string, onButton: boolean): boolean {
const g = game;
const open =
!ui.game.hidden &&
ui.menuVeil.hidden &&
ui.doubleVeil.hidden &&
ui.explainVeil.hidden &&
g !== null &&
g.state.turn === "player" &&
g.state.phase === "moving";
if (!open || targets.size === 0) {
return false;
}
const landings = selected === null ? undefined : targets.get(selected);
switch (key) {
case "ArrowLeft":
case "ArrowRight": {
if (busy) {
return true;
}
selected = nextPlace(targets.keys(), selected, key === "ArrowRight" ? 1 : -1);
const tos = selected === null ? undefined : targets.get(selected);
keyTarget = tos === undefined ? null : nextPlace(tos.keys(), null, 1);
shownMove = null;
render();
return true;
}
case "ArrowUp":
case "ArrowDown":
if (busy || landings === undefined) {
return landings !== undefined;
}
keyTarget = nextPlace(landings.keys(), keyTarget, key === "ArrowDown" ? 1 : -1);
render();
return true;
case "Enter": {
const halfMove =
keyTarget === null || landings === undefined
? undefined
: landings.get(keyTarget);
if (onButton || halfMove === undefined || busy) {
return false;
}
keyTarget = null;
playHalfMove(halfMove);
return true;
}
}
return false;
}
// Clavier (interaction.md) : les mêmes actions que les touchers.
document.addEventListener("keydown", (event) => {
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
@@ -2476,6 +2780,10 @@ export function startApp(options: AppOptions): void {
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
return;
}
if (boardKey(event.key, onButton)) {
event.preventDefault();
return;
}
let name: ActionName | null = null;
switch (event.key) {
case " ":
+212
View File
@@ -0,0 +1,212 @@
// Les gestes sur le plateau : glisser-déposer et double toucher (charte,
// docs/identite/charte/ecrans-etape-1.md, 1i), et l'ordre des pions et des
// cases au clavier (interaction.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un seul code pour la souris, le doigt et le stylet : les Pointer Events.
// Ce module ne connaît ni la partie ni le moteur : il dit où le pion est
// pris, où il passe et où il est lâché ; l'écran décide (app.ts), avec les
// cases d'arrivée que le moteur a données.
import type { Place } from "./board-model.js";
import type { Spot } from "./board-view.js";
/** Au-delà de cette distance (px à l'écran), un appui devient un glissement :
* en deçà, c'est un toucher. */
const DRAG_THRESHOLD_PX = 8;
/** Le pion glissé est dessiné au-dessus du doigt, pour rester visible
* (charte, 1i). */
const LIFT_PX = 12;
/** Deux touchers sur le même pion à moins de tant de ms : un double
* toucher. */
export const DOUBLE_TAP_MS = 350;
/** Après un glissement, le « click » que le navigateur envoie au lâcher
* n'est pas un toucher. */
const CLICK_AFTER_DRAG_MS = 400;
/** Ce que l'écran fait d'un glissement. */
export interface DragHandlers {
/** Ce pion peut-il partir maintenant (pion jouable, moteur libre) ? */
canStart(place: Place): boolean;
/** Le pion de place commence à glisser ; at : son centre, dans le repère
* du dessin. */
start(place: Place, at: Spot): void;
move(at: Spot): void;
/** Le pion est lâché en at, au-dessus de over (null : hors de toute
* zone du plateau). */
drop(over: Place | null, at: Spot): void;
/** Le glissement est interrompu par le navigateur (appel, geste du
* système) : le pion rentre. */
cancel(at: Spot): void;
}
/** L'endroit du plateau sous un élément touché (sa zone data-place). */
function placeOf(target: EventTarget | null): Place | null {
if (!(target instanceof Element)) {
return null;
}
return (target.closest("[data-place]")?.getAttribute("data-place") as Place | null) ?? null;
}
/** Le point du dessin sous le pointeur, remonté de LIFT_PX vers le haut du
* plateau (null si le dessin n'est pas affiché). */
function liftedPoint(svg: SVGSVGElement, event: PointerEvent): Spot | null {
const matrix = svg.getScreenCTM();
if (matrix === null) {
return null;
}
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
const unit = 1 / (Math.hypot(matrix.a, matrix.b) || 1);
return { x: point.x, y: point.y - LIFT_PX * unit };
}
/** La zone du plateau (data-place) qui contient at, dans le repère du
* dessin (null : aucune). */
export function placeAtSpot(svg: SVGSVGElement, at: Spot): Place | null {
for (const hit of svg.querySelectorAll(".hit[data-place]")) {
const number = (name: string) => Number(hit.getAttribute(name));
const x = number("x");
const y = number("y");
if (at.x >= x && at.x <= x + number("width") && at.y >= y && at.y <= y + number("height")) {
return hit.getAttribute("data-place") as Place;
}
}
return null;
}
/**
* Branche le glisser-déposer sur le plateau svg. Renvoie takeClick : vrai
* si le « click » reçu suit un glissement (il faut alors l'ignorer).
*/
export function attachDrag(svg: SVGSVGElement, handlers: DragHandlers): { takeClick(): boolean } {
let press: { id: number; place: Place; x: number; y: number } | null = null;
let dragging = false;
let clickIgnoredUntil = 0;
svg.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0) {
return;
}
const place = placeOf(event.target);
press =
place !== null && handlers.canStart(place)
? { id: event.pointerId, place, x: event.clientX, y: event.clientY }
: null;
dragging = false;
});
svg.addEventListener("pointermove", (event) => {
if (press === null || event.pointerId !== press.id) {
return;
}
const at = liftedPoint(svg, event);
if (at === null) {
return;
}
if (dragging) {
handlers.move(at);
return;
}
const distance = Math.hypot(event.clientX - press.x, event.clientY - press.y);
if (distance < DRAG_THRESHOLD_PX) {
return;
}
if (!handlers.canStart(press.place)) {
press = null;
return;
}
dragging = true;
// Le plateau est redessiné pendant le glissement : c'est lui, et non
// la zone touchée, qui reçoit la suite du geste.
try {
svg.setPointerCapture(event.pointerId);
} catch {
// Sans capture, les événements remontent quand même au plateau.
}
handlers.start(press.place, at);
});
const end = (event: PointerEvent, cancelled: boolean) => {
if (press === null || event.pointerId !== press.id) {
return;
}
const wasDragging = dragging;
press = null;
dragging = false;
if (!wasDragging) {
return;
}
clickIgnoredUntil = performance.now() + CLICK_AFTER_DRAG_MS;
const at = liftedPoint(svg, event) ?? { x: 0, y: 0 };
if (cancelled) {
handlers.cancel(at);
} else {
handlers.drop(placeAtSpot(svg, at), at);
}
};
svg.addEventListener("pointerup", (event) => end(event, false));
svg.addEventListener("pointercancel", (event) => end(event, true));
return {
takeClick: () => {
const ignored = performance.now() < clickIgnoredUntil;
clickIgnoredUntil = 0;
return ignored;
},
};
}
/**
* Reconnaît le double toucher : tap(place, now, first) renvoie ce que first
* a produit au premier toucher quand celui-ci est le second, sur le même
* endroit, à moins de DOUBLE_TAP_MS ; sinon null, et ce toucher devient le
* premier (first() n'est appelé que pour lui).
*/
export function createDoubleTap<T>(): {
tap(place: Place, now: number, first: () => T): T | null;
} {
let last: { place: Place; time: number; data: T } | null = null;
return {
tap(place, now, first) {
if (last !== null && last.place === place && now - last.time < DOUBLE_TAP_MS) {
const data = last.data;
last = null;
return data;
}
last = { place, time: now, data: first() };
return null;
},
};
}
/** Le numéro d'un endroit dans le sens de la course du joueur : la barre
* (25), les cases de 24 à 1, la sortie (0). */
function raceOrder(place: Place): number {
if (place.startsWith("bar-")) {
return 25;
}
if (place.startsWith("off-")) {
return 0;
}
return Number(place.slice(1));
}
/**
* Clavier : l'endroit suivant (step 1) ou précédent (step -1) de places
* après current, dans le sens de la course du joueur (de la barre vers la
* sortie), en bouclant. Sans current (ou s'il n'en fait pas partie), le
* premier dans ce sens. null si places est vide.
*/
export function nextPlace(
places: Iterable<Place>,
current: Place | null,
step: 1 | -1,
): Place | null {
const sorted = [...places].sort((a, b) => raceOrder(b) - raceOrder(a));
if (sorted.length === 0) {
return null;
}
const index = current === null ? -1 : sorted.indexOf(current);
if (index === -1) {
return (step === 1 ? sorted[0] : sorted.at(-1)) ?? null;
}
return sorted[(index + step + sorted.length) % sorted.length] ?? null;
}
+117 -10
View File
@@ -86,8 +86,23 @@ export interface BoardDecor {
/** Paysage : le conseil du maître, une pastille avant la consigne dans
* la bande centrale (absent ou null : rien). */
readonly advice?: BoardAdvice | null;
/** Le pion qu'on fait glisser (absent ou null : aucun). */
readonly drag?: BoardDrag | null;
/** Clavier : l'endroit qui a le focus (anneau focus ; absent ou null :
* aucun). */
readonly focus?: Place | null;
}
/** Un pion qu'on fait glisser : il a quitté place (un fantôme en
* pointillés y reste) et son centre est en at, dans le repère du dessin. */
export interface BoardDrag {
readonly place: Place;
readonly at: Spot;
}
/** Le pion qu'on fait glisser est agrandi (charte, 1i : 115 %). */
export const DRAG_SCALE = 1.15;
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
* la feuille d'explication. */
export interface BoardAdvice {
@@ -203,6 +218,9 @@ export function renderBoard(
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
if (decor.focus != null) {
drawFocus(svg, g, board, decor.focus, decor.targets.has(decor.focus));
}
const advice = decor.band !== null ? (decor.advice ?? null) : null;
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
if (decor.band !== null) {
@@ -235,6 +253,9 @@ export function renderBoard(
if (advice !== null) {
drawAdviceChip(svg, g, hits, advice);
}
if (decor.drag != null) {
drawDrag(svg, g, decor.drag);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -359,18 +380,13 @@ function drawStack(
classes.push("movable");
}
const { x, y } = slotCenter(g, place, i);
const checker = add(svg, "circle", {
class: classes.join(" "),
cx: x,
cy: y,
r: g.radius,
});
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
if (last) {
checker.setAttribute("data-checker", place);
piece.setAttribute("data-checker", place);
}
if (last && count > limit) {
add(
svg,
piece,
"text",
{
class: `checker-count count-${side}`,
@@ -481,6 +497,23 @@ function drawTray(
});
}
/**
* Un pion vu de dessus, centré en spot : son disque (classes cssClass) et
* son filet intérieur, l'écho du logo (charte, board.js : rayon 12 pour un
* pion de 20). Les deux sont groupés : c'est le groupe qui glisse.
*/
function addPiece(svg: Element, g: Geometry, side: Side, cssClass: string, spot: Spot): SVGElement {
const piece = add(svg, "g", { class: "piece" });
add(piece, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r: g.radius });
add(piece, "circle", {
class: `checker-ring ring-${side}`,
cx: spot.x,
cy: spot.y,
r: g.radius * 0.6,
});
return piece;
}
/** Une tranche de pion sorti, centrée en spot. */
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
return add(svg, "rect", {
@@ -810,13 +843,87 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
if (checker === null) {
return null;
}
const number = (name: string) => Number(checker.getAttribute(name));
if (checker.tagName === "circle") {
return shapeCenter(checker.tagName === "g" ? (checker.firstElementChild ?? checker) : checker);
}
/** Le centre d'un disque (cx, cy) ou d'un rectangle (x, y, largeur, hauteur). */
function shapeCenter(shape: Element): Spot {
const number = (name: string) => Number(shape.getAttribute(name));
if (shape.tagName === "circle") {
return { x: number("cx"), y: number("cy") };
}
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
}
/**
* Le pion qu'on fait glisser : le pion de départ devient un fantôme en
* pointillés, et un pion agrandi est dessiné en drag.at, devant les autres.
* Il se déplace par son attribut transform (moveDraggedChecker), sans
* redessiner le plateau.
*/
function drawDrag(svg: SVGSVGElement, g: Geometry, drag: BoardDrag): void {
svg.querySelector(`[data-checker="${drag.place}"]`)?.classList.add("drag-origin");
const group = add(svg, "g", {
class: "drag-piece",
id: "drag-piece",
transform: dragTransform(drag.at, DRAG_SCALE),
});
addPiece(group, g, "player", "checker checker-player dragged", { x: 0, y: 0 });
}
function dragTransform(at: Spot, scale: number): string {
return `translate(${at.x} ${at.y}) scale(${scale})`;
}
/** Déplace le pion qu'on fait glisser en at (repère du dessin). */
export function moveDraggedChecker(svg: SVGSVGElement, at: Spot): void {
svg.querySelector("#drag-piece")?.setAttribute("transform", dragTransform(at, DRAG_SCALE));
}
/**
* Ramène le pion qu'on fait glisser à son départ (le centre du fantôme), en
* ms millisecondes, et à sa taille ; résolue à la fin (animation du
* navigateur, sans bibliothèque).
*/
export async function returnDraggedChecker(
svg: SVGSVGElement,
from: Spot,
ms: number,
): Promise<void> {
const piece = svg.querySelector("#drag-piece");
const origin = svg.querySelector(".drag-origin");
if (piece === null || origin === null || typeof piece.animate !== "function") {
return;
}
const to = shapeCenter(origin.firstElementChild ?? origin);
const animation = piece.animate(
[
{ transform: `translate(${from.x}px, ${from.y}px) scale(${DRAG_SCALE})` },
{ transform: `translate(${to.x}px, ${to.y}px) scale(1)` },
],
{ duration: ms, easing: EASING_STANDARD, fill: "forwards" },
);
await animation.finished.catch(() => undefined);
}
/** Clavier : l'anneau focus autour de l'endroit choisi, sur la case
* d'arrivée (target) ou sur le pion du dessus. */
function drawFocus(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
place: Place,
target: boolean,
): void {
const count = countAt(board, "player", place);
const index = target
? place.startsWith("off-")
? count
: Math.min(count, MAX_STACK - 1)
: Math.max(topIndex(g, board, "player", place), 0);
addRing(svg, g, "key-focus", place, slotCenter(g, place, index), g.radius + 5);
}
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
+45
View File
@@ -0,0 +1,45 @@
// Les gestes du plateau qui se vérifient sans navigateur : le double toucher
// et l'ordre des pions et des cases au clavier. Le glisser-déposer lui-même
// est joué dans un navigateur (tests/browser/plateau.mjs).
//
// 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 { createDoubleTap, DOUBLE_TAP_MS, nextPlace } from "../src/ui/board-gestures.js";
import type { Place } from "../src/ui/board-model.js";
test("double toucher : deux touchers rapprochés sur le même endroit", () => {
const taps = createDoubleTap<string>();
let firsts = 0;
const first = () => {
firsts++;
return `premier ${firsts}`;
};
assert.equal(taps.tap("p8", 0, first), null);
assert.equal(taps.tap("p8", DOUBLE_TAP_MS - 1, first), "premier 1");
// Le troisième toucher recommence un double toucher.
assert.equal(taps.tap("p8", DOUBLE_TAP_MS, first), null);
// Trop lent : un nouveau premier toucher.
assert.equal(taps.tap("p8", 3 * DOUBLE_TAP_MS, first), null);
// Un autre endroit : un nouveau premier toucher.
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 10, first), null);
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 20, first), "premier 4");
assert.equal(firsts, 4);
});
test("clavier : les pions dans le sens de la course, en boucle", () => {
const places: Place[] = ["p6", "bar-player", "p13", "p8"];
assert.equal(nextPlace(places, null, 1), "bar-player");
assert.equal(nextPlace(places, null, -1), "p6");
assert.equal(nextPlace(places, "bar-player", 1), "p13");
assert.equal(nextPlace(places, "p8", 1), "p6");
assert.equal(nextPlace(places, "p6", 1), "bar-player");
assert.equal(nextPlace(places, "bar-player", -1), "p6");
// Un endroit qui n'est plus jouable : on repart du début.
assert.equal(nextPlace(places, "p24", 1), "bar-player");
assert.equal(nextPlace([], null, 1), null);
// Les cases d'arrivée, sortie comprise.
assert.equal(nextPlace(["off-player", "p2"], "p2", 1), "off-player");
});