Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement
« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son identifiant et supprime la nouvelle (GameSession). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+46
-93
@@ -5,120 +5,73 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { switchToBranch } from "./domain/game/branches.js";
|
||||
import type { Level } from "./domain/game/engine.js";
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { newGameId } from "./storage/game-library.js";
|
||||
import { openGameLibrary } from "./storage/open-library.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
import { GAMES_TEXTS } from "./ui/games-texts.js";
|
||||
import { createGamesView } from "./ui/games-view.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
|
||||
// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque
|
||||
// accès au stockage du navigateur peut lever une exception (stockage
|
||||
// Chaque accès au stockage du navigateur peut lever une exception (stockage
|
||||
// interdit) : on fait alors sans.
|
||||
const localStorage = attempt(() => window.localStorage);
|
||||
const settings = createSettingsStore(localStorage ?? memoryStorage());
|
||||
|
||||
// Les parties enregistrées (IndexedDB, ou localStorage en repli).
|
||||
const { library } = await openGameLibrary({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage: attempt(() => window.localStorage),
|
||||
localStorage,
|
||||
});
|
||||
/** Le niveau de l'ordinateur (celui de Game.start, STANDARD_PLY). */
|
||||
const LEVEL: Level = "2ply";
|
||||
const saved = await library.current().catch(() => null);
|
||||
// La partie à l'écran ; un nouvel identifiant à chaque nouvelle partie.
|
||||
let shownId = saved?.summary.id ?? newGameId();
|
||||
// Vrai une fois qu'on recharge la page pour reprendre une autre partie :
|
||||
// plus rien n'est enregistré sous l'identifiant de celle qu'on quitte.
|
||||
let leaving = false;
|
||||
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
|
||||
const session = createGameSession(
|
||||
library,
|
||||
{ start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) },
|
||||
"2ply",
|
||||
);
|
||||
const saved = await session.restore();
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => {
|
||||
shownId = newGameId();
|
||||
return Game.start(engine);
|
||||
newGame: () => session.newGame(),
|
||||
savedGame: () => saved,
|
||||
resumePrevious: () => session.resumePrevious(),
|
||||
saveGame: (journal) => session.save(journal),
|
||||
games: {
|
||||
list: () => library.list(),
|
||||
journal: async (id) => (await library.load(id))?.journal ?? null,
|
||||
shownId: () => session.shownId,
|
||||
open: (id, branchHead) => session.open(id, branchHead),
|
||||
remove: (id) => library.remove(id),
|
||||
exportSgf: async (id) => {
|
||||
const game = await library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const started = new Date(game.summary.startedAt);
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`;
|
||||
const text = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
date,
|
||||
});
|
||||
const time = `${two(started.getHours())}${two(started.getMinutes())}`;
|
||||
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
|
||||
},
|
||||
},
|
||||
savedGame: () => {
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, saved.journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
saveGame: (journal) => {
|
||||
if (!leaving) {
|
||||
// Stockage plein ou interdit : la partie continue, elle ne
|
||||
// pourra seulement pas être reprise.
|
||||
library.save(shownId, journal, { level: LEVEL }).catch(() => {});
|
||||
}
|
||||
},
|
||||
confirm: (question) => window.confirm(question),
|
||||
cachedBoard: () => settings.loadBoard(),
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
});
|
||||
|
||||
// « Mes parties ». Point d'accroche provisoire : un bouton ajouté devant
|
||||
// celui de l'aide, en attendant le menu de l'application (révision UX).
|
||||
// Reprendre une partie la désigne comme partie en cours, puis recharge la
|
||||
// page, qui l'ouvre.
|
||||
const gamesView = createGamesView({
|
||||
list: () => library.list(),
|
||||
journal: async (id) => (await library.load(id))?.journal ?? null,
|
||||
shownId: () => shownId,
|
||||
resume: async (id, branchHead) => {
|
||||
const game = await library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
leaving = true;
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
const journal = switchToBranch(game.journal, branchHead);
|
||||
await library.save(id, journal, { level: game.summary.level });
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
remove: (id) => library.remove(id),
|
||||
exportSgf: async (id) => {
|
||||
const game = await library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const started = new Date(game.summary.startedAt);
|
||||
const date = [started.getFullYear(), started.getMonth() + 1, started.getDate()]
|
||||
.map((n) => String(n).padStart(2, "0"))
|
||||
.join("-");
|
||||
const time = [started.getHours(), started.getMinutes()]
|
||||
.map((n) => String(n).padStart(2, "0"))
|
||||
.join("");
|
||||
const text = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
date,
|
||||
});
|
||||
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
|
||||
},
|
||||
confirm: (question) => window.confirm(question),
|
||||
});
|
||||
const helpButton = document.getElementById("help-button");
|
||||
if (helpButton !== null) {
|
||||
const gamesButton = document.createElement("button");
|
||||
gamesButton.id = "games-button";
|
||||
gamesButton.type = "button";
|
||||
gamesButton.className = "secondary";
|
||||
gamesButton.textContent = GAMES_TEXTS.button;
|
||||
gamesButton.onclick = () => void gamesView.open();
|
||||
helpButton.before(gamesButton);
|
||||
}
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// La partie à l'écran et son enregistrement : quel identifiant elle porte,
|
||||
// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la
|
||||
// reprise d'une partie enregistrée ou de l'une de ses branches.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle
|
||||
// quelle, sur un stockage en mémoire.
|
||||
|
||||
import { switchToBranch } from "../domain/game/branches.js";
|
||||
import type { Level } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
import { type GameLibrary, newGameId } from "./game-library.js";
|
||||
|
||||
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
|
||||
* le journal est illisible). */
|
||||
export interface GameFactory {
|
||||
readonly start: () => Game;
|
||||
readonly resume: (journal: GameJournal) => Game;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
/** L'identifiant de la partie à l'écran (null avant la première). */
|
||||
readonly shownId: string | null;
|
||||
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
|
||||
* si elle est illisible. */
|
||||
restore(): Promise<Game | null>;
|
||||
/** Une nouvelle partie ; celle qu'elle remplace reste reprenable par
|
||||
* resumePrevious, jusqu'au prochain changement de partie. */
|
||||
newGame(): Game;
|
||||
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
|
||||
* un échec est ignoré : la partie continue). */
|
||||
save(journal: GameJournal): void;
|
||||
/** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans
|
||||
* le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */
|
||||
resumePrevious(): Promise<Game | null>;
|
||||
/** Reprend une partie enregistrée, au bout de la branche branchHead si
|
||||
* elle est donnée. Erreur si elle n'existe pas ou est illisible. */
|
||||
open(id: string, branchHead?: string): Promise<Game>;
|
||||
}
|
||||
|
||||
export function createGameSession(
|
||||
library: GameLibrary,
|
||||
games: GameFactory,
|
||||
level: Level,
|
||||
): GameSession {
|
||||
let shownId: string | null = null;
|
||||
let previousId: string | null = null;
|
||||
|
||||
/** Une partie enregistrée, reprise ; null si absente ou illisible. */
|
||||
const resume = async (id: string): Promise<Game | null> => {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return games.resume(saved.journal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get shownId() {
|
||||
return shownId;
|
||||
},
|
||||
async restore() {
|
||||
const saved = await library.current().catch(() => null);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const game = games.resume(saved.journal);
|
||||
shownId = saved.summary.id;
|
||||
return game;
|
||||
} catch {
|
||||
return null; // partie illisible : on en commencera une nouvelle
|
||||
}
|
||||
},
|
||||
newGame() {
|
||||
previousId = shownId;
|
||||
shownId = newGameId();
|
||||
return games.start();
|
||||
},
|
||||
save(journal) {
|
||||
if (shownId !== null) {
|
||||
library.save(shownId, journal, { level }).catch(() => {});
|
||||
}
|
||||
},
|
||||
async resumePrevious() {
|
||||
const id = previousId;
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
const resumed = await resume(id);
|
||||
if (resumed === null) {
|
||||
return null;
|
||||
}
|
||||
const abandoned = shownId;
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
await library.setCurrent(id);
|
||||
if (abandoned !== null) {
|
||||
await library.remove(abandoned);
|
||||
}
|
||||
return resumed;
|
||||
},
|
||||
async open(id, branchHead) {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const journal =
|
||||
branchHead === undefined
|
||||
? saved.journal
|
||||
: switchToBranch(saved.journal, branchHead);
|
||||
const game = games.resume(journal);
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
await library.save(id, journal, { level: saved.summary.level });
|
||||
}
|
||||
return game;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Ce que l'application garde en plus de la partie, dans le même stockage
|
||||
// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier
|
||||
// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que
|
||||
// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import type { KeyValueStorage } from "./key-value-backend.js";
|
||||
|
||||
const SETTINGS_KEY = "ustagammon.settings";
|
||||
const BOARD_KEY = "ustagammon.last-board";
|
||||
|
||||
/** Le dernier plateau affiché, tel que le moteur l'a décodé. */
|
||||
export interface CachedBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
}
|
||||
|
||||
export interface SettingsStore {
|
||||
/** Le texte JSON des réglages (null : jamais enregistrés). */
|
||||
loadSettings(): string | null;
|
||||
saveSettings(json: string): void;
|
||||
loadBoard(): CachedBoard | null;
|
||||
saveBoard(board: CachedBoard): void;
|
||||
}
|
||||
|
||||
function isSide(value: unknown): boolean {
|
||||
const side = value as Record<string, unknown> | null;
|
||||
return (
|
||||
side !== null &&
|
||||
typeof side === "object" &&
|
||||
Array.isArray(side.points) &&
|
||||
side.points.length === 24 &&
|
||||
side.points.every((n) => Number.isInteger(n) && n >= 0) &&
|
||||
Number.isInteger(side.bar) &&
|
||||
Number.isInteger(side.off) &&
|
||||
Number.isInteger(side.pipCount)
|
||||
);
|
||||
}
|
||||
|
||||
export function createSettingsStore(
|
||||
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
|
||||
): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
// Stockage plein ou interdit : l'application marche sans.
|
||||
}
|
||||
};
|
||||
return {
|
||||
loadSettings() {
|
||||
try {
|
||||
return storage.getItem(SETTINGS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveSettings(json) {
|
||||
write(SETTINGS_KEY, json);
|
||||
},
|
||||
loadBoard() {
|
||||
try {
|
||||
const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null");
|
||||
const valid =
|
||||
board !== null &&
|
||||
isSide(board.sides?.player) &&
|
||||
isSide(board.sides?.computer) &&
|
||||
Number.isInteger(board.cube?.value) &&
|
||||
[null, "player", "computer"].includes(board.cube?.owner);
|
||||
return valid ? board : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveBoard(board) {
|
||||
write(BOARD_KEY, JSON.stringify(board));
|
||||
},
|
||||
};
|
||||
}
|
||||
+982
-189
File diff suppressed because it is too large
Load Diff
@@ -155,3 +155,26 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
}
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
* comme dans le dessin de référence de la charte (board.js), pas une règle. */
|
||||
export const OPENING_BOARDS: SideBoards = (() => {
|
||||
const points = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0);
|
||||
const side = { points, bar: 0, off: 0, pipCount: 167 };
|
||||
return { player: side, computer: side };
|
||||
})();
|
||||
|
||||
/** Qui mène la course, et de combien : le plus petit compte de points
|
||||
* (leader null : égalité). */
|
||||
export function pipLead(pipCount: Readonly<Record<Side, number>>): {
|
||||
leader: Side | null;
|
||||
by: number;
|
||||
} {
|
||||
const by = Math.abs(pipCount.player - pipCount.computer);
|
||||
if (by === 0) {
|
||||
return { leader: null, by };
|
||||
}
|
||||
return { leader: pipCount.player < pipCount.computer ? "player" : "computer", by };
|
||||
}
|
||||
|
||||
+420
-173
@@ -7,7 +7,14 @@
|
||||
// il n'y a aucun état caché dans le dessin. Les couleurs viennent des
|
||||
// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque
|
||||
// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus
|
||||
// le reste, qui porte son nom dans data-place.
|
||||
// le reste, qui porte son nom dans data-place (une case, la barre, la
|
||||
// sortie) ou data-action (dés, videau, commandes).
|
||||
//
|
||||
// Les mesures viennent de la géométrie (geometry.ts), calculée d'après la
|
||||
// forme de l'écran. En paysage, le plateau porte aussi les commandes : la
|
||||
// colonne de gauche (menu, videau, annuler ou revoir), les comptes de
|
||||
// points en pastilles aux bouts de la barre et la consigne dans la bande
|
||||
// centrale.
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
@@ -18,6 +25,8 @@ import {
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "./board-model.js";
|
||||
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
|
||||
import { ICON_PATHS, type IconName } from "./icons.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
@@ -30,8 +39,17 @@ export interface ShownDie {
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
|
||||
export type DiceAction = "roll" | "confirm";
|
||||
/** Ce que fait un toucher sur les dés : lancer, jouer le coup saisi, ou
|
||||
* passer son tour quand aucun coup n'est possible. */
|
||||
export type DiceAction = "roll" | "confirm" | "skip";
|
||||
|
||||
/** Les commandes de la colonne de gauche, en paysage. */
|
||||
export interface BoardControls {
|
||||
/** En bas : annuler le demi-coup, ou revoir le coup de l'ordinateur. */
|
||||
readonly back: "undo" | "replay";
|
||||
readonly backEnabled: boolean;
|
||||
readonly menuEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
@@ -44,12 +62,16 @@ export interface BoardDecor {
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
|
||||
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
|
||||
* sont dessinés. */
|
||||
/** Ce que fait un toucher sur la bande centrale, d'un côté ou de
|
||||
* l'autre du plateau (null : rien). En portrait, pour « roll », les dés
|
||||
* du joueur en attente sont dessinés. */
|
||||
readonly diceAction: DiceAction | null;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
/** Paysage : la consigne courte, dans la bande centrale. */
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -62,30 +84,7 @@ export const NO_MARKS = {
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
|
||||
const WIDTH = 640;
|
||||
const HEIGHT = 520;
|
||||
const FRAME = 8;
|
||||
const POINT_WIDTH = 44;
|
||||
const HALF_WIDTH = 6 * POINT_WIDTH;
|
||||
const BAR_LEFT = FRAME + HALF_WIDTH;
|
||||
const BAR_WIDTH = 40;
|
||||
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
|
||||
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
|
||||
const TRAY_WIDTH = 48;
|
||||
const POINT_HEIGHT = 190;
|
||||
const MIDDLE = HEIGHT / 2;
|
||||
const RADIUS = 20;
|
||||
const STEP = 42; // d'un pion au suivant dans une pile
|
||||
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
|
||||
const OFF_SLAB = 12; // épaisseur d'un pion sorti
|
||||
const DIE = 36;
|
||||
const CUBE = 34;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -103,11 +102,6 @@ function add(parent: Element, name: string, attributes: Attributes, text?: strin
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne (0 à 11). */
|
||||
function columnLeft(column: number): number {
|
||||
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
|
||||
}
|
||||
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
@@ -120,27 +114,24 @@ export interface Spot {
|
||||
* 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 {
|
||||
function slotCenter(g: Geometry, place: Place, index: number): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
x: g.barLeft + g.bar / 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 };
|
||||
? g.edge + index * OFF_SLAB
|
||||
: g.height - g.edge - (index + 1) * OFF_SLAB;
|
||||
return { x: g.trayLeft + g.tray / 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,
|
||||
};
|
||||
return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) };
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
@@ -155,63 +146,110 @@ function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** Le nombre de pions dessinés au plus à un endroit. */
|
||||
function stackLimit(g: Geometry, place: Place): number {
|
||||
return place.startsWith("bar-") ? g.barStack : MAX_STACK;
|
||||
}
|
||||
|
||||
/** Redessine tout le plateau dans svg. */
|
||||
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
|
||||
export function renderBoard(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
svg.replaceChildren();
|
||||
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
|
||||
drawFrame(svg);
|
||||
svg.setAttribute("viewBox", `0 0 ${g.width} ${g.height}`);
|
||||
svg.dataset.layout = g.layout;
|
||||
drawFrame(svg, g);
|
||||
const hits = add(svg, "g", {});
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, hits, board, point.number, decor);
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
drawBar(svg, hits, board, decor);
|
||||
drawTray(svg, hits, board, decor);
|
||||
drawDice(svg, decor.dice);
|
||||
const cube = drawCube(svg, decor.cube);
|
||||
drawBar(svg, pieces, g, hits, board, decor);
|
||||
drawTray(svg, pieces, g, hits, board, decor);
|
||||
if (decor.trace !== null) {
|
||||
drawTrace(svg, board, decor.trace);
|
||||
drawTraceArrows(svg, g, board, decor.trace);
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
if (decor.diceAction !== null) {
|
||||
if (decor.diceAction === "roll") {
|
||||
waitingDice(svg);
|
||||
if (decor.diceAction === "roll" && g.layout === "portrait") {
|
||||
waitingDice(svg, g);
|
||||
}
|
||||
// Une zone sur chaque moitié du plateau : les dés en attente (à
|
||||
// gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
|
||||
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
|
||||
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
|
||||
// Une zone sur chaque moitié de la bande centrale : les dés en
|
||||
// attente (à gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = TEXTS.diceActions[decor.diceAction];
|
||||
const width = 6 * g.pointWidth - 2 * g.frame;
|
||||
for (const x of [g.x0 + 3 * g.pointWidth, g.rightHalf + 3 * g.pointWidth]) {
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
|
||||
function drawFrame(svg: SVGSVGElement): void {
|
||||
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
|
||||
for (const left of [FRAME, RIGHT_HALF]) {
|
||||
function drawFrame(svg: SVGSVGElement, g: Geometry): void {
|
||||
const landscape = g.layout === "landscape";
|
||||
add(svg, "rect", {
|
||||
class: "board-frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: g.width,
|
||||
height: g.height,
|
||||
rx: landscape ? 0 : 6,
|
||||
});
|
||||
for (const left of [g.x0, g.rightHalf]) {
|
||||
add(svg, "rect", {
|
||||
class: "board-bed",
|
||||
x: left,
|
||||
y: FRAME,
|
||||
width: HALF_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
y: g.edge,
|
||||
width: 6 * g.pointWidth,
|
||||
height: g.height - 2 * g.edge,
|
||||
});
|
||||
}
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: TRAY_LEFT,
|
||||
y: FRAME,
|
||||
width: TRAY_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
x: g.trayLeft,
|
||||
y: g.edge,
|
||||
width: g.tray,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: landscape ? 4 : 0,
|
||||
});
|
||||
if (g.left > 0) {
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: g.frame,
|
||||
y: g.edge,
|
||||
width: g.left - 4,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawPoint(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
number: number,
|
||||
@@ -222,38 +260,54 @@ function drawPoint(
|
||||
return;
|
||||
}
|
||||
const { top, column } = pointSlot(number);
|
||||
const left = columnLeft(column);
|
||||
const center = left + POINT_WIDTH / 2;
|
||||
const base = top ? FRAME : HEIGHT - FRAME;
|
||||
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
|
||||
const left = columnLeft(g, column);
|
||||
const center = left + g.pointWidth / 2;
|
||||
const middle = g.height / 2;
|
||||
const base = top ? g.edge : g.height - g.edge;
|
||||
const length = middle - g.edge - g.band / 2 + 6;
|
||||
const tip = top ? base + length : base - length;
|
||||
add(svg, "polygon", {
|
||||
class: pointColor(number),
|
||||
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
|
||||
"data-point": number,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
// Numéros : sur le cadre en paysage, dans la bande centrale en
|
||||
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// du dessin : c'est une mesure du dessin, comme x et y.
|
||||
const size = g.numberSize;
|
||||
let y: number;
|
||||
if (g.layout === "landscape") {
|
||||
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
|
||||
} else {
|
||||
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
|
||||
}
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
|
||||
{
|
||||
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
|
||||
x: center,
|
||||
y,
|
||||
"font-size": size,
|
||||
},
|
||||
String(number),
|
||||
);
|
||||
// Case d'arrivée possible : la flèche seule est voilée (son triangle) ;
|
||||
// l'endroit où le pion se posera est marqué plus loin (drawLandings).
|
||||
if (decor.targets.has(point.place)) {
|
||||
add(svg, "rect", {
|
||||
add(svg, "polygon", {
|
||||
class: "target-zone",
|
||||
x: left + 2,
|
||||
y: top ? FRAME : MIDDLE,
|
||||
width: POINT_WIDTH - 4,
|
||||
height: HEIGHT / 2 - FRAME,
|
||||
rx: 4,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
}
|
||||
if (point.owner !== null) {
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
drawStack(pieces, g, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
y: top ? 0 : MIDDLE,
|
||||
width: POINT_WIDTH,
|
||||
height: MIDDLE,
|
||||
y: top ? 0 : middle,
|
||||
width: g.pointWidth,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,13 +315,15 @@ function drawPoint(
|
||||
* 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,
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
side: Side,
|
||||
count: number,
|
||||
place: Place,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
const shown = Math.min(count, MAX_STACK);
|
||||
const limit = stackLimit(g, place);
|
||||
const shown = Math.min(count, limit);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const last = i === shown - 1;
|
||||
const classes = [`checker checker-${side}`];
|
||||
@@ -276,119 +332,270 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
x,
|
||||
y: y + g.countFont * 0.35,
|
||||
"font-size": g.countFont,
|
||||
},
|
||||
String(count),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
add(svg, "rect", {
|
||||
class: "board-bar",
|
||||
x: BAR_LEFT,
|
||||
y: 0,
|
||||
width: BAR_WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
function drawBar(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
add(svg, "rect", { class: "board-bar", x: g.barLeft, y: 0, width: g.bar, height: g.height });
|
||||
// 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) {
|
||||
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
|
||||
drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
|
||||
if (g.layout === "landscape") {
|
||||
drawChips(svg, g, board);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, {
|
||||
x: g.barLeft,
|
||||
y: 0,
|
||||
width: g.bar,
|
||||
height: g.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
/** Paysage : les comptes de points en pastilles aux deux bouts de la barre,
|
||||
* aux couleurs du camp (l'ordinateur en haut, vous en bas). */
|
||||
function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void {
|
||||
const width = g.bar - 6;
|
||||
const h = g.chipHeight;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const y = side === "computer" ? g.edge + 4 : g.height - g.edge - 4 - h;
|
||||
add(svg, "rect", {
|
||||
class: `pip-chip chip-${side}`,
|
||||
x: g.barLeft + 3,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
rx: h / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `pip-chip-text chip-text-${side}`,
|
||||
"data-pips": side,
|
||||
x: g.barLeft + g.bar / 2,
|
||||
y: y + h / 2 + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
},
|
||||
String(board.pipCount[side]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTray(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
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) {
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
const slab = addSlab(
|
||||
pieces,
|
||||
g,
|
||||
`checker checker-${side}`,
|
||||
slotCenter(g, `off-${side}`, i),
|
||||
);
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const middle = g.height / 2;
|
||||
if (decor.targets.has("off-player")) {
|
||||
add(svg, "rect", {
|
||||
class: "target-zone",
|
||||
x: TRAY_LEFT + 2,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH - 4,
|
||||
height: MIDDLE - FRAME,
|
||||
x: g.trayLeft + 2,
|
||||
y: middle,
|
||||
width: g.tray - 4,
|
||||
height: middle - g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH,
|
||||
height: MIDDLE,
|
||||
x: g.trayLeft,
|
||||
y: middle,
|
||||
width: g.width - g.trayLeft,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
x: g.trayLeft + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
width: g.tray - 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 {
|
||||
function addRing(
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
cssClass: string,
|
||||
place: Place,
|
||||
spot: Spot,
|
||||
r: number,
|
||||
): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
addSlab(svg, g, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la consigne courte, dans la moitié gauche de la bande
|
||||
* centrale, sur une pastille accent-soft. */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
controls: BoardControls,
|
||||
): void {
|
||||
const x = g.frame + (g.left - 4) / 2;
|
||||
const r = (g.left - 4) / 2 - 6;
|
||||
const top = g.edge + g.target / 2 + 2;
|
||||
const bottom = g.height - g.edge - g.target / 2 - 2;
|
||||
const control = (name: IconName, action: string, label: string, y: number, on: boolean) => {
|
||||
const group = add(svg, "g", { class: on ? "control" : "control disabled" });
|
||||
add(group, "circle", { class: "control-circle", cx: x, cy: y, r });
|
||||
const size = r * 1.1;
|
||||
const scale = size / 24;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
|
||||
});
|
||||
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
|
||||
if (on) {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
if (controls.back === "replay") {
|
||||
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
|
||||
} else {
|
||||
control("undo", "undo", TEXTS.undoTitle, bottom, controls.backEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
|
||||
* Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : 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 {
|
||||
function drawTraceRings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
const R = g.radius;
|
||||
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);
|
||||
addRing(svg, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
const top = topIndex(g, board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
addRing(svg, g, "trace-landed", place, slotCenter(g, place, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
const top = topIndex(g, 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);
|
||||
addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les flèches d'un coup, une par demi-coup : sous les pions. */
|
||||
function drawTraceArrows(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
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,
|
||||
markerWidth: 3,
|
||||
markerHeight: 3,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
@@ -398,20 +605,35 @@ function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): voi
|
||||
// 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);
|
||||
const start = slotCenter(g, from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0));
|
||||
drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se
|
||||
* posera (sur la pile du joueur, ou au pied de la flèche). */
|
||||
function drawLandings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
targets: ReadonlySet<Place>,
|
||||
): void {
|
||||
for (const place of targets) {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1);
|
||||
addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
function topIndex(g: Geometry, 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;
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, stackLimit(g, place)) - 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 {
|
||||
function drawArrow(svg: SVGSVGElement, g: Geometry, 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);
|
||||
@@ -422,6 +644,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"stroke-width": g.arrowWidth,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
@@ -440,6 +663,9 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
@@ -464,7 +690,7 @@ export async function slideChecker(
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
{ duration: ms, easing: EASING_STANDARD },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
@@ -505,70 +731,90 @@ const PIPS: Record<number, [number, number][]> = {
|
||||
};
|
||||
|
||||
/** Les dés, au milieu de la moitié droite. */
|
||||
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
const center = RIGHT_HALF + HALF_WIDTH / 2;
|
||||
function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): void {
|
||||
const center = g.rightHalf + 3 * g.pointWidth;
|
||||
const size = g.die;
|
||||
const gap = 8;
|
||||
const total = dice.length * DIE + (dice.length - 1) * gap;
|
||||
const total = dice.length * size + (dice.length - 1) * gap;
|
||||
dice.forEach((die, i) => {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const x = center - total / 2 + i * (size + gap);
|
||||
const y = g.height / 2 - size / 2;
|
||||
const group = add(svg, "g", {
|
||||
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: size, height: size, rx: 6 });
|
||||
for (const [px, py] of PIPS[die.value] ?? []) {
|
||||
add(group, "circle", {
|
||||
class: `pip-${die.side}`,
|
||||
cx: x + px * DIE,
|
||||
cy: y + py * DIE,
|
||||
r: 3.5,
|
||||
cx: x + px * size,
|
||||
cy: y + py * size,
|
||||
r: size / 10,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + size, x2: x + size, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
|
||||
* sinon du côté de son propriétaire. Au centre, il montre 64. */
|
||||
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(cube);
|
||||
const x = center.x - CUBE / 2;
|
||||
const y = center.y - CUBE / 2;
|
||||
/** Le videau : dans la colonne des sorties en portrait, dans la colonne de
|
||||
* commandes en paysage ; au milieu s'il est au centre (il montre alors 64),
|
||||
* sinon du côté de son propriétaire. */
|
||||
function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(g, cube);
|
||||
const size = g.cube;
|
||||
const x = center.x - size / 2;
|
||||
const y = center.y - size / 2;
|
||||
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{ class: "cube-value", x: center.x, y: center.y + 6 },
|
||||
{
|
||||
class: "cube-value",
|
||||
x: center.x,
|
||||
y: center.y + g.cubeFont * 0.35,
|
||||
"font-size": g.cubeFont,
|
||||
},
|
||||
String(cube.owner === null ? 64 : cube.value),
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Le centre du videau dans le plateau de sortie. */
|
||||
function cubeCenter(cube: Cube): Spot {
|
||||
/** Le centre du videau. */
|
||||
function cubeCenter(g: Geometry, cube: Cube): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (g.layout === "landscape") {
|
||||
const shift = g.target + 6;
|
||||
const y =
|
||||
cube.owner === "computer"
|
||||
? middle - shift
|
||||
: cube.owner === "player"
|
||||
? middle + shift
|
||||
: middle;
|
||||
return { x: g.frame + (g.left - 4) / 2, y };
|
||||
}
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
|
||||
return { x: g.trayLeft + g.tray / 2, y: middle + shift };
|
||||
}
|
||||
|
||||
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement): void {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
/** Portrait : les dés du joueur en attente d'être lancés, faces vides, au
|
||||
* milieu de la moitié gauche (la droite garde le dernier jet de
|
||||
* l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement, g: Geometry): void {
|
||||
const center = g.x0 + 3 * g.pointWidth;
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
|
||||
for (const x of [center - gap / 2 - g.die, center + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: MIDDLE - DIE / 2,
|
||||
width: DIE,
|
||||
height: DIE,
|
||||
y: g.height / 2 - g.die / 2,
|
||||
width: g.die,
|
||||
height: g.die,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
@@ -576,27 +822,28 @@ function waitingDice(svg: SVGSVGElement): void {
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce
|
||||
* qu'on touche, avec un rôle et un nom accessibles. Les boutons restent
|
||||
* pour le clavier.
|
||||
* jouer le coup, doubler, menu…) : au moins g.target de côté (44 px à
|
||||
* l'écran), centrée sur ce qu'on touche, avec un rôle et un nom accessibles.
|
||||
*/
|
||||
function addAction(
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
action: string,
|
||||
label: string,
|
||||
center: Spot,
|
||||
width: number,
|
||||
): void {
|
||||
const w = Math.max(width, g.target);
|
||||
add(hits, "rect", {
|
||||
class: "hit action",
|
||||
"data-action": action,
|
||||
role: "button",
|
||||
"aria-label": label,
|
||||
// Sans déborder du dessin à droite (le videau est contre le bord).
|
||||
x: Math.min(center.x - width / 2, WIDTH - width),
|
||||
y: center.y - MIN_TARGET / 2,
|
||||
width,
|
||||
height: MIN_TARGET,
|
||||
// Sans déborder du dessin (le videau est contre le bord).
|
||||
x: Math.max(0, Math.min(center.x - w / 2, g.width - w)),
|
||||
y: center.y - g.target / 2,
|
||||
width: w,
|
||||
height: g.target,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const GAMES_TEXTS = {
|
||||
remove: "Supprimer",
|
||||
confirmRemove: "Supprimer définitivement cette partie et toutes ses branches ?",
|
||||
shown: "partie affichée",
|
||||
busy: "Attendez la fin du coup en cours.",
|
||||
failed: (message: string) => `Erreur : ${message}`,
|
||||
|
||||
/** « 3 nov. 2026, 14:05 ». */
|
||||
|
||||
+52
-18
@@ -4,15 +4,16 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un écran autonome, dans une fenêtre (<dialog>) qu'il crée lui-même : il ne
|
||||
// dépend ni de index.html ni de l'écran de jeu (app.ts). Comme tout l'écran,
|
||||
// il ne connaît ni le stockage ni le moteur : main.ts lui passe les
|
||||
// opérations (GamesViewOptions).
|
||||
// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne
|
||||
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
|
||||
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
|
||||
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
|
||||
|
||||
import { branches } from "../domain/game/branches.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
|
||||
export interface GamesViewOptions {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
@@ -33,23 +34,44 @@ export interface GamesViewOptions {
|
||||
export interface GamesView {
|
||||
/** Ouvre la liste, à jour. */
|
||||
open(): Promise<void>;
|
||||
close(): void;
|
||||
readonly isOpen: boolean;
|
||||
}
|
||||
|
||||
/** Crée la fenêtre « Mes parties » (fermée) dans la page. */
|
||||
/** Crée la feuille « Mes parties » (fermée) dans la page. */
|
||||
export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
const dialog = element("dialog", "confirm games");
|
||||
dialog.id = "games-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "games-title");
|
||||
const title = element("h2");
|
||||
const veil = element("div", "veil sheet-veil");
|
||||
veil.id = "games-veil";
|
||||
veil.hidden = true;
|
||||
const sheet = element("div", "sheet games");
|
||||
sheet.id = "games";
|
||||
sheet.setAttribute("role", "dialog");
|
||||
sheet.setAttribute("aria-modal", "true");
|
||||
sheet.setAttribute("aria-labelledby", "games-title");
|
||||
const head = element("div", "sheet-head");
|
||||
const title = element("h2", "sheet-title");
|
||||
title.id = "games-title";
|
||||
title.textContent = GAMES_TEXTS.title;
|
||||
const close = button("", () => hide(), "icon-button");
|
||||
close.id = "games-close";
|
||||
close.setAttribute("aria-label", GAMES_TEXTS.close);
|
||||
close.append(icon("close"));
|
||||
head.append(title, close);
|
||||
const list = element("ul", "games-list");
|
||||
const message = element("p", "games-message");
|
||||
message.setAttribute("aria-live", "polite");
|
||||
const close = button(GAMES_TEXTS.close, () => dialog.close(), "secondary");
|
||||
close.id = "games-close";
|
||||
dialog.append(title, message, list, close);
|
||||
document.body.append(dialog);
|
||||
sheet.append(head, message, list);
|
||||
veil.append(sheet);
|
||||
document.body.append(veil);
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
function hide(): void {
|
||||
veil.hidden = true;
|
||||
}
|
||||
|
||||
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
|
||||
const run = (action: () => Promise<void>) => async () => {
|
||||
@@ -85,7 +107,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
.join(" · ");
|
||||
const branchList = element("ul", "games-branches");
|
||||
branchList.hidden = true;
|
||||
const actions = element("div", "confirm-actions");
|
||||
const actions = element("div", "games-actions");
|
||||
actions.append(
|
||||
button(
|
||||
GAMES_TEXTS.resume,
|
||||
@@ -122,6 +144,12 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
"remove",
|
||||
),
|
||||
);
|
||||
// La partie à l'écran ne se supprime pas : elle continue d'être
|
||||
// enregistrée.
|
||||
const remove = actions.querySelector<HTMLElement>('[data-action="remove"]');
|
||||
if (remove !== null) {
|
||||
remove.hidden = shown;
|
||||
}
|
||||
item.append(heading, actions, branchList);
|
||||
return item;
|
||||
}
|
||||
@@ -144,7 +172,10 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
text,
|
||||
button(
|
||||
GAMES_TEXTS.resumeBranch,
|
||||
run(() => options.resume(id, branch.head)),
|
||||
run(async () => {
|
||||
await options.resume(id, branch.head);
|
||||
hide();
|
||||
}),
|
||||
"secondary",
|
||||
"resume-branch",
|
||||
),
|
||||
@@ -158,9 +189,12 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
async open() {
|
||||
message.textContent = "";
|
||||
await render();
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
veil.hidden = false;
|
||||
close.focus();
|
||||
},
|
||||
close: hide,
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// La géométrie du plateau, calculée d'après la forme de l'écran (fonction
|
||||
// pure, testée sous Node).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le dessin est en unités du viewBox, de hauteur fixe (520). En portrait, le
|
||||
// plateau garde ses proportions habituelles (640 × 520) et suit la largeur de
|
||||
// l'écran. En paysage, « le plateau est l'écran » (ADR 0012, amendements du
|
||||
// 2026-11-03) : sa largeur suit le rapport largeur/hauteur de l'écran, et les
|
||||
// flèches s'élargissent d'autant. Formules du composant Board des maquettes
|
||||
// « Révision UX » : largeur d'une flèche = ce qui reste une fois ôtés le
|
||||
// cadre, les colonnes et la barre, divisé par 12 ; rayon d'un pion = le plus
|
||||
// petit de la demi-largeur d'une flèche (moins 2) et du dixième de la
|
||||
// hauteur d'une demi-table (hors bande des numéros et bande centrale).
|
||||
|
||||
/** Portrait : barre haute, bandeaux et barre d'actions autour du plateau ;
|
||||
* paysage : le plateau seul, commandes comprises. */
|
||||
export type Layout = "portrait" | "landscape";
|
||||
|
||||
export interface Geometry {
|
||||
readonly layout: Layout;
|
||||
/** Taille du dessin, en unités du viewBox. */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
/** Épaisseur du cadre sur les côtés. */
|
||||
readonly frame: number;
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
|
||||
readonly edge: number;
|
||||
/** Colonne de commandes à gauche (0 en portrait). */
|
||||
readonly left: number;
|
||||
/** Colonne des sorties, à droite. */
|
||||
readonly tray: number;
|
||||
readonly bar: number;
|
||||
/** Bande centrale, entre les pointes des flèches (dés, consigne). */
|
||||
readonly band: number;
|
||||
readonly pointWidth: number;
|
||||
readonly radius: number;
|
||||
/** D'un pion au suivant dans une pile. */
|
||||
readonly step: number;
|
||||
/** Bord gauche de la première colonne de flèches. */
|
||||
readonly x0: number;
|
||||
readonly barLeft: number;
|
||||
/** Bord gauche de la moitié droite. */
|
||||
readonly rightHalf: number;
|
||||
readonly trayLeft: number;
|
||||
/** Côté minimal d'une zone à toucher : 44 px à l'écran. */
|
||||
readonly target: number;
|
||||
/** Tailles de texte, en unités : numéros des flèches (12 px au moins à
|
||||
* l'écran), consigne de la bande et pastilles des comptes (13 px). */
|
||||
readonly numberSize: number;
|
||||
readonly bandFont: number;
|
||||
readonly chipFont: number;
|
||||
/** Videau : côté et taille du chiffre ; nombre posé sur une pile trop
|
||||
* haute (13 px au moins à l'écran). */
|
||||
readonly cube: number;
|
||||
readonly cubeFont: number;
|
||||
readonly countFont: number;
|
||||
/** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */
|
||||
readonly arrowWidth: number;
|
||||
readonly die: number;
|
||||
/** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un
|
||||
* nombre sur le dernier) : en paysage, ils s'arrêtent avant les
|
||||
* pastilles des comptes. */
|
||||
readonly barStack: number;
|
||||
/** Hauteur d'une pastille de compte, au bout de la barre (paysage). */
|
||||
readonly chipHeight: number;
|
||||
}
|
||||
|
||||
export const BOARD_HEIGHT = 520;
|
||||
/** Rapport largeur / hauteur du plateau en portrait. */
|
||||
export const PORTRAIT_ASPECT = 640 / 520;
|
||||
/** Pions dessinés au plus sur une flèche ; au-delà, un nombre sur le dernier. */
|
||||
export const MAX_STACK = 5;
|
||||
|
||||
/** Une taille en unités qui fait au moins px pixels à l'écran. */
|
||||
function atLeast(px: number, pxPerUnit: number, min: number): number {
|
||||
return Math.max(min, Math.ceil(px / pxPerUnit));
|
||||
}
|
||||
|
||||
/**
|
||||
* La géométrie du plateau pour une mise en page. aspect : rapport largeur /
|
||||
* hauteur de la place offerte (ignoré en portrait) ; pxPerUnit : pixels à
|
||||
* l'écran par unité du dessin, pour les tailles minimales (textes, cibles).
|
||||
*/
|
||||
export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number): Geometry {
|
||||
const height = BOARD_HEIGHT;
|
||||
const target = atLeast(46, pxPerUnit, 40);
|
||||
const numberSize = atLeast(12.5, pxPerUnit, 13);
|
||||
const text = atLeast(13.5, pxPerUnit, 17);
|
||||
const sizes = {
|
||||
cubeFont: text,
|
||||
cube: Math.max(36, text + 20),
|
||||
countFont: Math.max(18, text),
|
||||
arrowWidth: 2.5 / pxPerUnit,
|
||||
};
|
||||
if (layout === "portrait") {
|
||||
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
|
||||
const frame = 8;
|
||||
const tray = 48;
|
||||
const bar = 40;
|
||||
const width = 640;
|
||||
const pointWidth = (width - 3 * frame - tray - bar) / 12;
|
||||
const band = 84;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
|
||||
const x0 = frame;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge: frame,
|
||||
left: 0,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step: 2 * radius + 2,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth + frame,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 17),
|
||||
chipFont: atLeast(13.5, pxPerUnit, 17),
|
||||
...sizes,
|
||||
die: 36,
|
||||
barStack: MAX_STACK,
|
||||
chipHeight: 0,
|
||||
};
|
||||
}
|
||||
const width = Math.round(height * aspect);
|
||||
const frame = 6;
|
||||
const edge = Math.max(24, numberSize + 8);
|
||||
const left = target + 4;
|
||||
const tray = 56;
|
||||
const bar = 64;
|
||||
const band = 80;
|
||||
const pointWidth = (width - 2 * frame - left - tray - bar) / 12;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2 - 4) / 10);
|
||||
const step = 2 * radius + 1;
|
||||
const chipFont = atLeast(13.5, pxPerUnit, 18);
|
||||
const chipHeight = chipFont + 12;
|
||||
// Pions de la barre, depuis le milieu (voir board-view) : le dernier
|
||||
// s'arrête à 2 unités de la pastille.
|
||||
const room = height / 2 - edge - 4 - chipHeight - 2 - (radius + 4) - radius;
|
||||
const barStack = Math.max(1, Math.min(MAX_STACK, Math.floor(room / step) + 1));
|
||||
const x0 = frame + left;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge,
|
||||
left,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 22),
|
||||
chipFont,
|
||||
...sizes,
|
||||
die: Math.min(40, band - 12),
|
||||
barStack,
|
||||
chipHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne de flèches (0 à 11). */
|
||||
export function columnLeft(g: Geometry, column: number): number {
|
||||
return column < 6 ? g.x0 + column * g.pointWidth : g.rightHalf + (column - 6) * g.pointWidth;
|
||||
}
|
||||
|
||||
/** Ordonnée du centre du pion numéro index (0 : contre le bord) d'une pile
|
||||
* de la rangée du haut ou du bas. */
|
||||
export function stackY(g: Geometry, top: boolean, index: number): number {
|
||||
const offset = g.radius + 1 + Math.min(index, MAX_STACK) * g.step;
|
||||
return top ? g.edge + offset : g.height - g.edge - offset;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Les vibrations (docs/identite/charte/interaction.md) : elles doublent un
|
||||
// événement visible, jamais seules, et se coupent dans le menu. Rien ne vibre
|
||||
// pour un toucher de bouton ni pour le lancer des dés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export type HapticEvent =
|
||||
| "checkerPlaced"
|
||||
| "checkerHit"
|
||||
| "computerDoubles"
|
||||
| "gameWon"
|
||||
| "gameLost";
|
||||
|
||||
/** Motifs en millisecondes (vibration, pause, vibration…). */
|
||||
export const HAPTIC_PATTERNS: Readonly<Record<HapticEvent, readonly number[]>> = {
|
||||
checkerPlaced: [12],
|
||||
checkerHit: [25, 40, 25],
|
||||
computerDoubles: [40, 60, 40],
|
||||
gameWon: [30, 50, 30, 50, 60],
|
||||
gameLost: [60],
|
||||
};
|
||||
|
||||
/** navigator.vibrate, s'il existe. */
|
||||
export type Vibrate = (pattern: number[]) => boolean;
|
||||
|
||||
/**
|
||||
* La fonction qui fait vibrer pour un événement : rien si le navigateur ne
|
||||
* sait pas vibrer ou si le joueur a coupé les vibrations. Une seule vibration
|
||||
* à la fois : chaque appel remplace le précédent (comportement de
|
||||
* navigator.vibrate).
|
||||
*/
|
||||
export function createHaptics(
|
||||
vibrate: Vibrate | undefined,
|
||||
enabled: () => boolean,
|
||||
): (event: HapticEvent) => void {
|
||||
return (event) => {
|
||||
if (vibrate !== undefined && enabled()) {
|
||||
try {
|
||||
vibrate([...HAPTIC_PATTERNS[event]]);
|
||||
} catch {
|
||||
// Navigateur qui refuse (pas encore de geste du joueur) : la
|
||||
// marque visible suffit.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+11
-1
@@ -15,7 +15,11 @@ import {
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = ["docs/scenarios/jouer-une-partie.feature", "docs/scenarios/mes-parties.feature"];
|
||||
const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -32,6 +36,12 @@ async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
|
||||
* copie du site : la bulle du compte de points en affiche le texte. */
|
||||
export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | null> {
|
||||
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Les pictogrammes de l'interface (composant Icons de la charte,
|
||||
// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en
|
||||
// currentColor, dessinés en SVG. Les tracés viennent des maquettes
|
||||
// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la
|
||||
// classe « icon » (app.css) : aucun style dans le SVG (CSP).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ICON_PATHS = {
|
||||
menu: "M4 7h16M4 12h16M4 17h16",
|
||||
help: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5",
|
||||
exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5",
|
||||
undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
|
||||
replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5",
|
||||
close: "M6 6l12 12M18 6L6 18",
|
||||
chevron: "M9 6l6 6-6 6",
|
||||
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||
newGame: "M12 5v14M5 12h14",
|
||||
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
verdictBad: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_PATHS;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Un pictogramme, décoratif (le nom est porté par son bouton). */
|
||||
export function icon(name: IconName): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", `icon icon-${name}`);
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", ICON_PATHS[name]);
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Les réglages de l'application (menu) et ce qui en dépend, sans navigateur
|
||||
// (testé sous Node). Ils sont enregistrés à part de la partie, en JSON.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export type Theme = "auto" | "light" | "dark";
|
||||
|
||||
export interface Settings {
|
||||
/** Vibrations aux moments forts (activées par défaut). */
|
||||
readonly vibrations: boolean;
|
||||
/** Écran maintenu allumé pendant une partie (activé par défaut). */
|
||||
readonly keepScreenOn: boolean;
|
||||
readonly theme: Theme;
|
||||
/** Parties commencées : la consigne est longue pendant les trois
|
||||
* premières, courte ensuite. */
|
||||
readonly gamesStarted: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
vibrations: true,
|
||||
keepScreenOn: true,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
};
|
||||
|
||||
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
|
||||
export const LONG_PROMPT_GAMES = 3;
|
||||
|
||||
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
|
||||
* valeur illisible garde sa valeur par défaut. */
|
||||
export function parseSettings(text: string | null): Settings {
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text ?? "{}");
|
||||
if (parsed !== null && typeof parsed === "object") {
|
||||
raw = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (key: "vibrations" | "keepScreenOn") =>
|
||||
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
const games = raw.gamesStarted;
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted:
|
||||
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */
|
||||
export function nextTheme(theme: Theme): Theme {
|
||||
return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto";
|
||||
}
|
||||
|
||||
/** La consigne doit-elle encore être longue ? (La partie en cours compte.) */
|
||||
export function longPrompts(settings: Settings): boolean {
|
||||
return settings.gamesStarted <= LONG_PROMPT_GAMES;
|
||||
}
|
||||
+92
-29
@@ -22,9 +22,11 @@ function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
|
||||
function seconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
|
||||
/** Une consigne : sa forme longue (les trois premières parties) et sa forme
|
||||
* courte (ensuite, et toujours en paysage, dans la bande du plateau). */
|
||||
export interface PromptText {
|
||||
readonly long: string;
|
||||
readonly short: string;
|
||||
}
|
||||
|
||||
export const TEXTS = {
|
||||
@@ -36,57 +38,118 @@ export const TEXTS = {
|
||||
double: "Doubler",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
confirm: "Valider",
|
||||
confirm: "Jouer ce coup",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
undoTitle: "Annuler le demi-coup",
|
||||
newGame: "Nouvelle partie",
|
||||
reviewGame: "Revoir la partie",
|
||||
help: "Aide",
|
||||
menu: "Menu",
|
||||
fullscreen: "Plein écran",
|
||||
exitFullscreen: "Quitter le plein écran",
|
||||
back: "Retour à la partie",
|
||||
close: "Fermer",
|
||||
/** Ce que fait un toucher sur les dés (nom accessible de la zone). */
|
||||
diceActions: {
|
||||
roll: "Lancer les dés",
|
||||
confirm: "Jouer ce coup",
|
||||
skip: "Passer votre tour",
|
||||
},
|
||||
moveHint: "Le coup fini, touchez les dés pour le jouer.",
|
||||
|
||||
// Menu
|
||||
helpAndGlossary: "Aide et glossaire",
|
||||
theme: "Thème",
|
||||
themes: { auto: "Automatique", light: "Clair", dark: "Sombre" },
|
||||
vibrations: "Vibrations",
|
||||
keepScreenOn: "Écran allumé pendant la partie",
|
||||
newGameStarted: "Nouvelle partie commencée.",
|
||||
resumeOld: "Reprendre l'ancienne",
|
||||
|
||||
// Bandeaux des joueurs et compte de points
|
||||
you: "Vous",
|
||||
computer: "Ordinateur",
|
||||
yourTurn: "à vous de jouer",
|
||||
computerTurn: "joue",
|
||||
lead: (n: number) => `mène de ${n}`,
|
||||
pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`,
|
||||
pipCountFile: "compte-de-points.md",
|
||||
seeGlossary: "Voir le glossaire",
|
||||
|
||||
// Chargement
|
||||
loading: "Chargement du moteur…",
|
||||
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
|
||||
actionFailed: (message: string) => `Erreur : ${message}`,
|
||||
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
|
||||
|
||||
// Déroulement
|
||||
// Déroulement (ligne d'information)
|
||||
opening: (player: number, computer: number) =>
|
||||
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
|
||||
player > computer ? "Vous commencez." : "L'ordinateur commence."
|
||||
}`,
|
||||
yourRoll: "À vous : doublez ou lancez les dés.",
|
||||
yourRollNoDouble: "À vous : lancez les dés.",
|
||||
yourMove: (dice: readonly number[]) =>
|
||||
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
confirmOrUndo:
|
||||
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
computerMoved: (dice: readonly number[], move: string) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move}.`,
|
||||
computerCannotMove: (dice: readonly number[]) =>
|
||||
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
computerTakes: (value: number) =>
|
||||
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
|
||||
youTook: (value: number) =>
|
||||
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
|
||||
// Consignes
|
||||
prompts: {
|
||||
loading: { long: "", short: "Chargement…" },
|
||||
yourRoll: {
|
||||
long: "À vous : touchez les dés pour les lancer, ou le videau pour doubler.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourRollNoDouble: {
|
||||
long: "À vous : touchez les dés pour les lancer.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourMove: (dice: readonly number[]): PromptText => ({
|
||||
long: `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
short: `Jouez ${roll(dice)}.`,
|
||||
}),
|
||||
pickTarget: {
|
||||
long: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
short: "Choisissez la case.",
|
||||
},
|
||||
confirmOrUndo: {
|
||||
long: "Touchez vos dés pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
short: "Touchez les dés pour jouer.",
|
||||
},
|
||||
stuck: {
|
||||
long: "Aucun coup possible : touchez vos dés pour passer votre tour.",
|
||||
short: "Aucun coup : touchez les dés.",
|
||||
},
|
||||
thinking: { long: "L'ordinateur réfléchit", short: "L'ordinateur réfléchit" },
|
||||
computerDoubles: {
|
||||
long: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
short: "L'ordinateur double.",
|
||||
},
|
||||
} satisfies Record<string, PromptText | ((dice: readonly number[]) => PromptText)>,
|
||||
|
||||
// Fin de partie
|
||||
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
|
||||
youLose: (kind: ResultKind, n: number) =>
|
||||
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
|
||||
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
|
||||
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
|
||||
|
||||
// Sous le plateau
|
||||
pipCounts: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
gameOver: "Partie finie",
|
||||
endOverline: "Fin de partie",
|
||||
/** Titre du panneau de fin : qui gagne, et comment. */
|
||||
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
|
||||
if (dropped) {
|
||||
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
|
||||
}
|
||||
return winner === "player"
|
||||
? `Vous gagnez ${RESULT_KIND[kind]}`
|
||||
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
|
||||
},
|
||||
/** « 8 points pour lui, videau à 4. » */
|
||||
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
|
||||
cube > 1 ? `, videau à ${cube}` : ""
|
||||
}.`,
|
||||
|
||||
// Aide
|
||||
helpTitle: "Aide",
|
||||
|
||||
Reference in New Issue
Block a user