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:
co-authored by
Claude Opus 5.5
parent
6dd0e93d3d
commit
835a907b9c
@@ -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
@@ -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 " ":
|
||||
|
||||
@@ -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
@@ -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)";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user