Révision UX : portrait stable, bandeaux, menu, paysage plateau = écran, vibrations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 15:05:27 +01:00
co-authored by Claude Opus 5.5
parent 2be5bdcb4f
commit c6649f94c0
20 changed files with 3064 additions and 554 deletions
+7 -1
View File
@@ -9,6 +9,7 @@ import { Game } from "./domain/game/game.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
@@ -16,6 +17,7 @@ import { startApp } from "./ui/app.js";
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
const settings = createSettingsStore(window.localStorage);
startApp({
engineReady: worker.ready,
@@ -31,8 +33,12 @@ startApp({
return null; // partie illisible : on en commence une nouvelle
}
},
resumeGame: (journal) => Game.resume(engine, journal),
saveGame: (journal) => store.save(journal),
confirm: (question) => window.confirm(question),
cachedBoard: () => settings.loadBoard(),
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
+81
View File
@@ -0,0 +1,81 @@
// 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 "./game-store.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: KeyValueStorage): 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));
},
};
}
+879 -189
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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 };
}
+358 -161
View File
@@ -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,8 @@ 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 CUBE = 36;
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -103,11 +103,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 +115,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 +147,101 @@ 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", {});
for (const point of board.points) {
drawPoint(svg, hits, board, point.number, decor);
drawPoint(svg, 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, g, hits, board, decor);
drawTray(svg, g, hits, board, decor);
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) {
drawTrace(svg, board, decor.trace);
drawTrace(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,
g: Geometry,
hits: Element,
board: BoardModel,
number: number,
@@ -222,38 +252,56 @@ 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),
);
if (decor.targets.has(point.place)) {
add(svg, "rect", {
class: "target-zone",
x: left + 2,
y: top ? FRAME : MIDDLE,
width: POINT_WIDTH - 4,
height: HEIGHT / 2 - FRAME,
y: top ? g.edge : middle,
width: g.pointWidth - 4,
height: middle - g.edge,
rx: 4,
});
}
if (point.owner !== null) {
drawStack(svg, point.owner, point.count, point.place, decor);
drawStack(svg, 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,
});
}
@@ -262,12 +310,14 @@ function drawPoint(
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
function drawStack(
svg: SVGSVGElement,
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,83 +326,211 @@ 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.radius * 0.3 },
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,
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(svg, 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,
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(
svg,
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
/**
@@ -360,26 +538,27 @@ function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: nu
* 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 drawTrace(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);
}
const defs = add(svg, "defs", {});
const marker = add(defs, "marker", {
@@ -398,16 +577,16 @@ 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, start, end, to.startsWith("off-") ? OFF_SLAB : R + 4);
}
}
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
function topIndex(board: BoardModel, side: Side, place: Place): number {
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. */
@@ -440,6 +619,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 +646,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,38 +687,40 @@ 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);
/** 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 x = center.x - CUBE / 2;
const y = center.y - CUBE / 2;
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
@@ -550,25 +734,37 @@ function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
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 +772,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,
});
}
+175
View File
@@ -0,0 +1,175 @@
// 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;
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);
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),
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,
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;
}
+47
View File
@@ -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.
}
}
};
}
+10 -1
View File
@@ -15,7 +15,10 @@ 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"];
const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/regler-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
async function fetchText(url: string): Promise<string> {
@@ -32,6 +35,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,
+44
View File
@@ -0,0 +1,44 @@
// 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",
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;
}
+63
View File
@@ -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;
}
+77 -24
View File
@@ -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,46 +38,100 @@ 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)}.`,
@@ -83,10 +139,7 @@ export const TEXTS = {
`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",
// Aide
helpTitle: "Aide",
+268
View File
@@ -0,0 +1,268 @@
// Tests de la révision UX qui se vérifient sans navigateur : géométrie du
// plateau selon l'écran, vibrations, réglages, compte de points, plateau
// gardé pour le chargement, et règles de la charte vérifiables dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { test } from "node:test";
import { createSettingsStore } from "../src/storage/settings-store.js";
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
import { ICON_PATHS } from "../src/ui/icons.js";
import {
DEFAULT_SETTINGS,
LONG_PROMPT_GAMES,
longPrompts,
nextTheme,
parseSettings,
} from "../src/ui/settings.js";
const root = new URL("../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
// --------------------------------------------------------------- Géométrie
/** Les écrans en paysage de la recette : largeur et hauteur en pixels,
* dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large
* encore. */
const LANDSCAPES: [number, number][] = [
[915, 412],
[844, 390],
[844, 320],
[740, 360],
[800, 308],
];
test("géométrie en portrait : les mesures du plateau d'origine", () => {
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
assert.equal(g.width, 640);
assert.equal(g.pointWidth, 44);
assert.equal(g.radius, 20);
assert.equal(g.step, 42);
assert.equal(g.trayLeft + g.tray + g.frame, 640);
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
});
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
for (const [width, height] of LANDSCAPES) {
const px = height / BOARD_HEIGHT;
const g = boardGeometry("landscape", width / height, px);
const where = `${width}x${height}`;
assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`);
// Les colonnes, la barre et les flèches occupent toute la largeur.
assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`);
assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`);
assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`);
assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`);
assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`);
assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`);
assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`);
assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`);
// Les numéros tiennent dans le cadre.
assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`);
}
});
test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => {
const cases = [
boardGeometry("portrait", 640 / 520, 0.62),
...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)),
];
for (const g of cases) {
const band = g.height / 2 - g.die / 2;
const last = stackY(g, true, MAX_STACK - 1) + g.radius;
assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`);
}
});
test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => {
for (const [width, height] of LANDSCAPES) {
const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT);
const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius;
const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight);
assert.ok(g.barStack >= 1);
assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`);
}
});
// -------------------------------------------------------------- Vibrations
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
const calls: number[][] = [];
let on = true;
const haptic = createHaptics(
(pattern) => {
calls.push(pattern);
return true;
},
() => on,
);
haptic("checkerPlaced");
haptic("checkerHit");
haptic("computerDoubles");
haptic("gameWon");
haptic("gameLost");
assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]);
on = false;
haptic("checkerHit");
assert.equal(calls.length, 5);
// Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur.
assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon"));
const refusing = createHaptics(
() => {
throw new Error("refusé");
},
() => true,
);
assert.doesNotThrow(() => refusing("gameWon"));
// Aucun motif ne dépasse 60 ms par impulsion (interaction.md).
for (const pattern of Object.values(HAPTIC_PATTERNS)) {
assert.ok(pattern.every((ms) => ms > 0 && ms <= 60));
}
});
// ---------------------------------------------------------------- Réglages
test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => {
assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS);
assert.equal(DEFAULT_SETTINGS.vibrations, true);
assert.equal(DEFAULT_SETTINGS.keepScreenOn, true);
assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS);
assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), {
...DEFAULT_SETTINGS,
vibrations: false,
theme: "dark",
gamesStarted: 4,
});
assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), {
...DEFAULT_SETTINGS,
});
assert.equal(nextTheme("auto"), "light");
assert.equal(nextTheme("light"), "dark");
assert.equal(nextTheme("dark"), "auto");
});
test("consigne longue pendant les trois premières parties, courte ensuite", () => {
assert.equal(LONG_PROMPT_GAMES, 3);
for (const games of [0, 1, 2, 3]) {
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true);
}
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false);
});
test("stockage des réglages et du dernier plateau affiché", () => {
const data = new Map<string, string>();
const store = createSettingsStore({
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
});
assert.equal(store.loadSettings(), null);
store.saveSettings('{"vibrations":false}');
assert.equal(store.loadSettings(), '{"vibrations":false}');
assert.equal(store.loadBoard(), null);
const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } };
store.saveBoard(board);
assert.deepEqual(store.loadBoard(), board);
// Un plateau abîmé est ignoré : la position de départ sera dessinée.
data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}');
assert.equal(store.loadBoard(), null);
const full = createSettingsStore({
getItem: () => {
throw new Error("interdit");
},
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.saveBoard(board));
assert.equal(full.loadBoard(), null);
assert.equal(full.loadSettings(), null);
});
// ------------------------------------------------------- Compte de points
test("compte de points : le plus petit mène, de l'écart", () => {
assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 });
assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 });
assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 });
// La position de départ : 15 pions, 167 points chacun.
for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) {
assert.equal(
side.points.reduce((a, b) => a + b, 0),
15,
);
assert.equal(
side.points.reduce((sum, n, i) => sum + n * (i + 1), 0),
side.pipCount,
);
}
});
// ------------------------------------------------------------------ Charte
/** Les fichiers .ts d'un dossier, récursivement. */
function sources(dir: string): string[] {
const url = new URL(dir, root);
return readdirSync(url).flatMap((name) => {
const path = `${dir}${name}`;
return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path];
});
}
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
for (const path of sources("app/src/")) {
const text = read(path);
assert.doesNotMatch(
text,
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
`${path} appelle une boîte native`,
);
}
});
test("les deux blocs du thème sombre de app.css sont identiques", () => {
const css = read("web/app.css");
const block = (start: string) => {
const from = css.indexOf(start);
assert.ok(from >= 0, `bloc ${start} absent`);
const body = css.slice(css.indexOf("{", from + start.length - 1) + 1);
return body
.slice(0, body.indexOf("}"))
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
};
assert.deepEqual(
block(':root:not([data-theme="light"]) {'),
block(':root[data-theme="dark"] {'),
);
});
test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => {
assert.doesNotMatch(read("web/index.html"), /\sstyle=|<style/);
for (const path of sources("app/src/")) {
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
}
});
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
for (const name of [
"menu",
"help",
"fullscreen",
"exitFullscreen",
"undo",
"replay",
"close",
]) {
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
}
for (const d of Object.values(ICON_PATHS)) {
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
}
});
+23 -1
View File
@@ -19,6 +19,7 @@ import {
} from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { boardModel, pipLead } from "../../src/ui/board-model.js";
import { loadNodeEngine } from "../support/node-engine.js";
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
@@ -283,7 +284,7 @@ Then("c'est toujours à vous", () => {
assert.equal(lastEvent().type, "DiceRolled");
});
When("vous continuez", async () => {
When("vous laissez passer votre tour", async () => {
await game.skipTurn();
const event = lastEvent();
assert.ok(event.type === "MovePlayed" && event.side === "player");
@@ -344,6 +345,27 @@ Then("vous pouvez revoir son coup", async () => {
assert.deepEqual(await game.lastMoveSteps(), computerMove);
});
// ------------------------------------------------------- Compte de points
// Les bandeaux lisent le compte de points du plateau donné par le moteur ;
// qui mène se calcule dans l'interface (board-model.ts).
async function pips() {
return boardModel(await game.displayedBoard()).pipCount;
}
Then("chaque camp a un compte de points de {int}", async (n: number) => {
assert.deepEqual(await pips(), { player: n, computer: n });
});
Then("personne ne mène la course", async () => {
assert.equal(pipLead(await pips()).leader, null);
});
Then("vous menez la course de {int}", async (n: number) => {
assert.deepEqual(pipLead(await pips()), { leader: "player", by: n });
});
// ------------------------------------------------------------------ Videau
// La confirmation elle-même est une question de l'interface (une fenêtre
@@ -0,0 +1,143 @@
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
// la partie reprise passe par le domaine et le vrai moteur, et par le même
// enregistrement que l'application ; les réglages et les vibrations par les
// modules de l'interface qui les portent (settings.ts, haptics.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createGameStore } from "../../src/storage/game-store.js";
import { BANNER_MS } from "../../src/ui/app.js";
import { createHaptics } from "../../src/ui/haptics.js";
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
let engine: Engine;
let game: Game;
let oldJournal: GameJournal;
let oldBoard: unknown;
let settings: Settings;
let vibrations: number[][] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
// -------------------------------------------------- Nouvelle partie annulable
// Comme dans l'application : chaque partie est enregistrée (localStorage,
// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal.
const data = new Map<string, string>();
const store = createGameStore({
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
});
Given("une partie est en cours depuis quelques coups", async () => {
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) });
for (const halfMove of parseHalfMoves("8/5 6/5")) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
await game.playComputerTurn();
store.save(game.journal);
assert.equal(game.state.turn, "player");
});
When("vous commencez une nouvelle partie depuis le menu", async () => {
oldJournal = game.journal;
oldBoard = await game.displayedBoard();
game = Game.start(engine);
store.save(game.journal);
});
Then("une nouvelle partie commence aussitôt", () => {
assert.equal(game.state.position, START_POSITION);
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
});
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
assert.equal(BANNER_MS, seconds * 1000);
});
When("vous reprenez l'ancienne partie", () => {
game = Game.resume(engine, oldJournal);
store.save(game.journal);
});
Then("vous retrouvez exactement la partie quittée", async () => {
assert.deepEqual(store.load(), oldJournal);
assert.deepEqual(game.journal, oldJournal);
assert.deepEqual(await game.displayedBoard(), oldBoard);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
// --------------------------------------------------------------- Réglages
Given("vous ouvrez l'application pour la première fois", () => {
settings = parseSettings(null);
vibrations = [];
});
const haptic = createHaptics(
(pattern) => {
vibrations.push(pattern);
return true;
},
() => settings.vibrations,
);
When("un pion est frappé", () => {
vibrations = [];
haptic("checkerHit");
});
Then("le téléphone vibre deux fois, brièvement", () => {
assert.deepEqual(vibrations, [[25, 40, 25]]);
});
When("vous coupez les vibrations dans le menu", () => {
settings = { ...settings, vibrations: false };
});
Then("le téléphone ne vibre pas", () => {
assert.deepEqual(vibrations, []);
});
Then("l'écran reste allumé pendant une partie", () => {
assert.equal(settings.keepScreenOn, true);
});
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
});
Then("l'écran peut se mettre en veille comme d'habitude", () => {
assert.equal(settings.keepScreenOn, false);
});
Then("le thème suit celui du téléphone", () => {
assert.equal(settings.theme, "auto");
});
When("vous changez de thème dans le menu", () => {
// Rien : l'étape suivante fait le tour des thèmes.
});
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
const seen = [settings.theme];
for (let i = 0; i < 3; i++) {
seen.push(nextTheme(seen.at(-1) ?? "auto"));
}
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
});
+2 -2
View File
@@ -213,7 +213,7 @@ test("les textes de fin de partie emploient les mots du glossaire", () => {
"L'ordinateur gagne la partie simple : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",
);
});
+1
View File
@@ -47,3 +47,4 @@ Règles d'écriture :
| Fichier | Tâche | Depuis |
| --- | --- | --- |
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
+18 -5
View File
@@ -14,7 +14,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
ou de l'autre du plateau, ou « Valider ».
ou de l'autre du plateau, ou « Jouer ce coup ».
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5 6/5
@@ -61,21 +61,22 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Scénario: Aucun coup possible avec votre jet
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
que vous avez touché « Continuer ».
affiché, barré, avec un message. L'application ne joue jamais à votre
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
(ou « Continuer »).
Étant donné que votre pion sur la barre ne peut pas rentrer
Quand vous lancez les dés
Alors aucun coup n'est possible avec votre jet
Et c'est toujours à vous
Quand vous continuez
Quand vous laissez passer votre tour
Alors c'est ensuite au tour de l'ordinateur
Scénario: Voir le coup de l'ordinateur
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
étaient. Le bouton « Revoir » rejoue son coup.
étaient. « Revoir » (la flèche circulaire) rejoue son coup.
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
Quand l'ordinateur a joué
@@ -83,6 +84,18 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Voir le compte de points
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
comptes sont aux deux bouts de la barre.
Quand vous commencez une nouvelle partie
Alors chaque camp a un compte de points de 167
Et personne ne mène la course
Quand vous jouez 13/8 13/11
Alors vous menez la course de 7
Scénario: Doubler, après confirmation
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
« Doubler ». L'application vous demande de confirmer : une fois proposé,
@@ -0,0 +1,48 @@
# language: fr
Fonctionnalité: Régler l'application
Le menu, qui monte du bas de l'écran, regroupe ce qui ne sert pas à chaque
coup : commencer une nouvelle partie, passer en plein écran, ouvrir l'aide
et le glossaire, choisir le thème, couper les vibrations, et laisser ou non
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
question : ce qui se fait se rattrape.
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
Une nouvelle partie commence tout de suite, sans question. Pendant 10
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
vous venez de quitter, exactement là où vous l'aviez laissée.
Étant donné qu'une partie est en cours depuis quelques coups
Quand vous commencez une nouvelle partie depuis le menu
Alors une nouvelle partie commence aussitôt
Et l'ancienne partie peut être reprise pendant 10 secondes
Quand vous reprenez l'ancienne partie
Alors vous retrouvez exactement la partie quittée
Scénario: Couper les vibrations
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
ne vibre quand vous lancez les dés. Elles se coupent dans le menu.
Étant donné que vous ouvrez l'application pour la première fois
Quand un pion est frappé
Alors le téléphone vibre deux fois, brièvement
Quand vous coupez les vibrations dans le menu
Et qu'un pion est frappé
Alors le téléphone ne vibre pas
Scénario: Garder l'écran allumé pendant la partie
Pour que le téléphone ne se mette pas en veille pendant que vous
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
Étant donné que vous ouvrez l'application pour la première fois
Alors l'écran reste allumé pendant une partie
Quand vous coupez « Écran allumé pendant la partie » dans le menu
Alors l'écran peut se mettre en veille comme d'habitude
Scénario: Choisir le thème
Étant donné que vous ouvrez l'application pour la première fois
Alors le thème suit celui du téléphone
Quand vous changez de thème dans le menu
Alors il passe à clair, puis à sombre, puis revient à automatique
+695 -136
View File
@@ -51,6 +51,9 @@
/* ------------------------------------------------------------ Couleurs */
/* Thème clair par défaut ; thème sombre selon le système, ou forcé par le
* menu (data-theme sur <html>). Les deux blocs sombres sont identiques
* (vérifié par app/tests/ui.test.ts). */
:root {
--background: #f5efe5;
--surface: #fffcf6;
@@ -117,12 +120,29 @@
--radius-md: 12px;
--radius-pill: 999px;
/* Hauteur de la barre haute : boutons de 44 px et marges. */
/* Mouvement (interaction.md). */
--duration-instant: 90ms;
--duration-fast: 160ms;
--duration-moderate: 240ms;
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
--easing-exit: cubic-bezier(0.4, 0, 1, 1);
/* Voile sous le menu et les panneaux. */
--scrim: rgba(43, 29, 21, 0.5);
/* Hauteurs fixes de l'écran de jeu en portrait : rien ne bouge sous le
* doigt (interaction.md, principe 2). */
--topbar-height: 60px;
--strip-height: 44px;
--infoline-height: 44px;
--prompt-height: 74px;
--actions-height: 76px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme="light"]) {
--background: #17110d;
--surface: #221913;
--surface-sunken: #2c211a;
@@ -165,29 +185,84 @@
--point-label: var(--point-number);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
--scrim: rgba(0, 0, 0, 0.6);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--background: #17110d;
--surface: #221913;
--surface-sunken: #2c211a;
--text: #efe5d5;
--muted: #a99886;
--border: #3a2d24;
--border-strong: #7d6b5b;
--accent: #d0694b;
--on-accent: #1a110c;
--accent-soft: #4a2519;
--focus: #e58a6e;
--error: #f08fa6;
--verdict-good: #7ccba4;
--verdict-doubtful: #e0b04f;
--verdict-bad: #f08fa6;
--brand-laiton: #d6a64a;
--frame: #2a1a10;
--bed: #4a3626;
--bar: #24170e;
--tray: #22150d;
--point-a: #3a2519;
--point-b: #7e6142;
--point-number: #cdbfa8;
--checker-player: #ede3cf;
--checker-player-edge: #8e7c64;
--checker-computer: #140d09;
--checker-computer-edge: #a8957d;
--count-player: #2b1d15;
--count-computer: #f3ead8;
--die-player: #ede3cf;
--pip-player: #2b1d15;
--die-computer: #140d09;
--pip-computer: #ede3cf;
--cube: #ede3cf;
--cube-text: #2b1d15;
--highlight: #e07a5a;
--target: rgba(224, 122, 90, 0.38);
--point-label: var(--point-number);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
--scrim: rgba(0, 0, 0, 0.6);
color-scheme: dark;
}
/* -------------------------------------------------------------- Page */
* {
box-sizing: border-box;
}
/* Pas de « tirer pour rafraîchir » ni de rebond qui recharge la page en
* pleine partie ; pas de texte sélectionné par un appui long. */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
overscroll-behavior: none;
}
body {
margin: 0 auto;
max-width: var(--page-width);
padding: 0 var(--space-4) var(--space-6);
padding: 0 var(--space-4);
font-family: var(--font-sans);
font-size: 17px;
line-height: 25px;
color: var(--text);
background: var(--background);
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
[hidden] {
@@ -205,14 +280,46 @@ a {
outline-offset: 2px;
}
/* L'écran de jeu en portrait : des zones de hauteur fixe, de haut en bas ;
* la barre d'actions reste collée en bas de l'écran. */
.screen {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
.game {
display: flex;
flex: 1;
flex-direction: column;
}
.spacer {
flex: 1;
}
/* Logo clair ou sombre selon le thème. */
.logo-dark {
display: none;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .logo-light { display: none; }
:root:not([data-theme="light"]) .logo-dark { display: block; }
}
:root[data-theme="dark"] .logo-light { display: none; }
:root[data-theme="dark"] .logo-dark { display: block; }
/* -------------------------------------------------------- Barre haute */
.topbar {
display: flex;
gap: var(--space-2);
flex: none;
gap: var(--space-1);
align-items: center;
min-height: var(--topbar-height);
padding-block: var(--space-2);
height: var(--topbar-height);
}
.topbar .brand {
@@ -224,9 +331,22 @@ a {
}
.topbar .brand img {
display: block;
width: auto;
height: 32px;
max-width: 100%;
height: 28px;
}
/* ------------------------------------------------------- Pictogrammes */
.icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
/* ------------------------------------------------------------ Boutons */
@@ -246,6 +366,8 @@ button {
/* Pas de délai ni de zoom sur un double toucher. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-instant) var(--easing-standard),
filter var(--duration-instant) var(--easing-standard);
}
button.secondary {
@@ -260,9 +382,11 @@ button.secondary {
}
}
/* Retour visuel au toucher. */
/* Retour visuel au toucher : le bouton descend de 1 px et s'assombrit de
* 6 %, sans ombre. */
button:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(0.94);
}
button:disabled {
@@ -270,70 +394,210 @@ button:disabled {
cursor: default;
}
.topbar button {
/* Pictogramme seul (ou avec son libellé de 13 px dessous, barre haute). */
.icon-button {
display: inline-flex;
flex-direction: column;
flex: none;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 14px;
font-size: 15px;
line-height: 20px;
padding: 0;
color: var(--text);
background: transparent;
border: 0;
border-radius: var(--radius-md);
}
.actions {
.icon-button.labelled {
width: 60px;
height: 52px;
}
.icon-button .label {
font-size: 13px;
line-height: 15px;
font-weight: 500;
}
@media (hover: hover) {
.icon-button:hover:not(:disabled) {
background: var(--surface-sunken);
}
}
/* ------------------------------------------- Bandeaux des joueurs */
/* Un bandeau par camp, au-dessus du plateau pour l'ordinateur, dessous pour
* vous ; celui qui a la main est teinté. */
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
flex: none;
gap: 10px;
align-items: center;
height: var(--strip-height);
padding: 0 4px 0 12px;
border-radius: var(--radius-md);
}
.actions button {
flex: 1 1 auto;
.strip.turn {
background: var(--accent-soft);
}
/* --------------------------------------------- Messages sous le plateau */
.strip .who {
flex: 1;
min-width: 0;
overflow: hidden;
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Compte de points : information secondaire, discrète. */
.pips {
margin: var(--space-2) 0 0;
.strip .lead {
font-size: 15px;
line-height: 20px;
color: var(--muted);
text-align: center;
white-space: nowrap;
}
/* Le compte de points : 20 px gras, chiffres alignés ; il se touche pour
* ouvrir la bulle du glossaire. */
.strip .count {
min-width: 56px;
min-height: 44px;
padding: 0 8px;
font-size: 20px;
line-height: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums lining-nums;
text-align: right;
color: var(--text);
background: transparent;
border: 0;
border-radius: var(--radius-md);
}
.mini {
flex: none;
width: 18px;
height: 18px;
border-radius: 50%;
}
.mini-player {
background: var(--checker-player);
border: 2px solid var(--checker-player-edge);
}
.mini-computer {
background: var(--checker-computer);
border: 2px solid var(--checker-computer-edge);
}
/* La bulle du compte de points, posée sur le plateau, du côté du bandeau
* touché : elle ne déplace rien. */
.board-box {
position: relative;
flex: none;
}
/* Téléphone en portrait : le plateau, héros de l'écran, prend la largeur
* presque entière (8 px de marge au lieu de 16). */
@media (max-width: 40rem) {
html:not(.landscape) .board-box {
margin-inline: calc(var(--space-2) - var(--space-4));
}
}
.bubble {
position: absolute;
left: var(--space-2);
right: var(--space-2);
z-index: 5;
margin: 0;
padding: 10px 14px;
font-size: 15px;
line-height: 20px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
animation: fade-in var(--duration-fast) var(--easing-standard);
}
.bubble[data-side="computer"] { top: var(--space-2); }
.bubble[data-side="player"] { bottom: var(--space-2); }
.bubble p {
margin: 0 0 var(--space-1);
}
.bubble-term {
font-weight: 700;
}
.link-button {
min-height: 44px;
padding: 0;
color: var(--accent);
background: transparent;
border: 0;
border-radius: 0;
text-decoration: underline;
text-underline-offset: 3px;
}
/* ----------------------------------------------- Sous le plateau */
/* Ligne d'information, toujours réservée : ce qui vient de se passer (coup
* de l'ordinateur, jet d'ouverture, chargement), et « Revoir » au bout. */
.infoline {
display: flex;
flex: none;
gap: var(--space-2);
align-items: center;
height: var(--infoline-height);
padding-left: 12px;
}
/* Ce qui vient de se passer (coup de l'ordinateur, jet d'ouverture) :
* secondaire, mais lisible. */
.info {
min-height: 25px;
margin: var(--space-3) 0 0;
display: -webkit-box;
flex: 1;
margin: 0;
overflow: hidden;
font-size: 15px;
line-height: 20px;
color: var(--muted);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.info:empty {
margin-top: 0;
.infoline .replay {
color: var(--accent);
}
/* La consigne du moment : ce qui doit ressortir de l'écran. Fond d'accent
* léger quand c'est au joueur, neutre pendant que l'ordinateur réfléchit. */
/* La consigne du moment : ce qui doit ressortir de l'écran. Hauteur fixe
* (deux lignes) ; fond d'accent léger quand c'est au joueur. */
.prompt {
min-height: calc(25px + 2 * var(--space-3));
margin: var(--space-3) 0;
flex: none;
height: var(--prompt-height);
margin: 0;
padding: var(--space-3) var(--space-4);
overflow: hidden;
font-weight: 700;
background: var(--accent-soft);
border-radius: var(--radius-md);
}
.prompt.thinking,
.prompt.neutral,
body.busy .prompt {
font-weight: 400;
background: var(--surface-sunken);
}
/* Fin de partie : le résultat en titre. */
#game:has(#again:not([hidden])) .prompt {
font-size: 24px;
line-height: 30px;
}
/* L'ordinateur réfléchit : trois points qui apparaissent l'un après
* l'autre (la seule animation en boucle). */
.prompt.thinking::after {
@@ -348,12 +612,288 @@ body.busy .prompt {
75% { content: "..."; }
}
/* Barre d'actions collée en bas : l'action secondaire à gauche, la
* principale à droite, sous le pouce. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
flex: none;
grid-template-columns: 1fr 1.6fr;
gap: var(--space-2);
height: var(--actions-height);
padding: var(--space-3) 0 var(--space-4);
background: var(--background);
border-top: 1px solid var(--border);
}
.actions .slot {
display: flex;
min-width: 0;
}
.actions .slot button {
flex: 1;
min-width: 0;
padding: 0 12px;
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
}
.actions .slot .icon {
width: 20px;
height: 20px;
}
.move-hint {
align-self: center;
margin: 0;
font-size: 15px;
line-height: 20px;
color: var(--muted);
text-align: center;
}
/* --------------------------------------------------- Fenêtres */
/* Voile sous le menu et les panneaux : il assombrit l'écran derrière. */
.veil {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: var(--scrim);
animation: fade-in var(--duration-fast) var(--easing-standard);
}
/* Le menu : une feuille qui monte du bas. */
.sheet-veil {
align-items: flex-end;
padding: 0;
}
.sheet {
width: min(100%, var(--page-width));
max-height: 100%;
overflow-y: auto;
padding: var(--space-2) var(--space-4) var(--space-6);
color: var(--text);
background: var(--surface);
border-radius: 20px 20px 0 0;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.2);
animation: sheet-up var(--duration-moderate) var(--easing-standard);
}
.sheet-head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
}
.grip {
position: absolute;
top: 0;
left: 50%;
width: 40px;
height: 4px;
margin-left: -20px;
background: var(--border-strong);
border-radius: 2px;
}
.sheet-title {
margin: 0;
font-size: 20px;
line-height: 26px;
}
.sheet .item {
display: flex;
gap: 14px;
align-items: center;
width: 100%;
min-height: 52px;
padding: 0;
font-weight: 500;
text-align: left;
color: var(--text);
background: transparent;
border: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.sheet .item:last-child {
border-bottom: 0;
}
.sheet .item .icon {
color: var(--muted);
}
.item-value {
margin-left: auto;
font-size: 15px;
color: var(--muted);
}
/* Interrupteur (Vibrations, écran allumé). */
.switch {
position: relative;
flex: none;
width: 44px;
height: 26px;
margin-left: auto;
background: var(--border-strong);
border-radius: 13px;
transition: background-color var(--duration-fast) var(--easing-standard);
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: var(--surface);
border-radius: 50%;
transition: transform var(--duration-fast) var(--easing-standard);
}
.item[aria-checked="true"] .switch {
background: var(--accent);
}
.item[aria-checked="true"] .switch::after {
background: var(--on-accent);
transform: translateX(18px);
}
/* Panneau centré : confirmation du double, fin de partie, double de
* l'ordinateur en paysage. */
.panel {
width: min(24rem, 100%);
padding: var(--space-4);
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
animation: panel-in var(--duration-moderate) var(--easing-standard);
}
.panel p {
margin: 0 0 var(--space-4);
font-size: 20px;
line-height: 26px;
font-weight: 700;
}
.panel-actions {
display: flex;
gap: var(--space-2);
}
.panel-actions button {
flex: 1 1 0;
padding: 0 12px;
}
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
* barre de temps. */
.banner {
position: fixed;
left: 50%;
bottom: calc(var(--actions-height) + var(--space-2));
z-index: 9;
display: flex;
gap: var(--space-3);
align-items: center;
width: min(24rem, calc(100% - 2 * var(--space-4)));
padding: var(--space-2) var(--space-3) var(--space-3);
overflow: hidden;
font-size: 15px;
line-height: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
transform: translateX(-50%);
animation: fade-in var(--duration-fast) var(--easing-standard);
}
.banner span:first-child {
flex: 1;
}
.banner button {
flex: none;
min-height: 44px;
padding: 0 14px;
}
.banner-timer {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 4px;
background: var(--accent);
transform-origin: left;
}
.banner-timer.running {
animation: banner-timer 10s linear forwards;
}
@keyframes banner-timer {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes sheet-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes panel-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Mouvement réduit : tout est instantané, rien ne roule ni ne glisse ; la
* barre de temps reste visible, immobile. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
.prompt.thinking::after {
animation: none;
content: "…";
}
.banner-timer.running {
animation: none;
}
button:active:not(:disabled) {
transform: none;
}
@@ -376,16 +916,6 @@ body.busy .prompt {
font-variant-numeric: tabular-nums lining-nums;
}
/* Téléphone en portrait : le plateau, héros de l'écran, prend la largeur
* presque entière (8 px de marge au lieu de 16) ; chaque pixel compte pour
* toucher un pion. */
@media (max-width: 40rem) and (orientation: portrait) {
.board {
width: calc(100% + 2 * (var(--space-4) - var(--space-2)));
margin-inline: calc(var(--space-2) - var(--space-4));
}
}
.board-frame { fill: var(--frame); }
.board-bed { fill: var(--bed); }
.board-bar { fill: var(--bar); }
@@ -393,9 +923,10 @@ body.busy .prompt {
.point-a { fill: var(--point-a); }
.point-b { fill: var(--point-b); }
/* Numéros des flèches : leur taille est une mesure du dessin (attribut
* font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts). */
.point-number {
fill: var(--point-label);
font-size: 13px;
font-weight: 500;
text-anchor: middle;
}
@@ -517,37 +1048,123 @@ body.busy .prompt {
cursor: pointer;
}
/* ------------------------------------------------ Confirmation (videau) */
/* Paysage : numéros sur le cadre (ivoire), comptes de points en pastilles
* aux bouts de la barre, consigne dans la bande, commandes à gauche. */
.point-number.on-frame { fill: var(--point-number); }
/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page
* s'assombrit derrière. */
.confirm {
width: min(22rem, calc(100vw - 2 * var(--space-4)));
padding: var(--space-4);
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
}
.pip-chip { stroke-width: 1.5; }
.chip-player { fill: var(--checker-player); stroke: var(--checker-player-edge); }
.chip-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); }
.confirm::backdrop {
background: rgba(43, 29, 21, 0.5);
}
.confirm p {
margin: 0 0 var(--space-4);
.pip-chip-text {
font-weight: 700;
text-anchor: middle;
}
.confirm-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
.chip-text-player { fill: var(--pip-player); }
.chip-text-computer { fill: var(--pip-computer); }
.band-pill { fill: var(--accent-soft); }
.band-text {
fill: var(--text);
font-weight: 700;
text-anchor: middle;
}
.confirm-actions button {
flex: 1 1 auto;
.control-circle {
fill: none;
stroke: var(--point-number);
stroke-width: 2;
}
.control-icon {
fill: none;
stroke: var(--point-number);
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.control.disabled { opacity: 0.35; }
/* ------------------------------------------------------- Mode paysage */
/* Téléphone tourné (ADR 0012) : le plateau est l'écran, sur la hauteur
* réelle (barre d'adresse comprise), cadre noyer jusqu'aux bords. La
* classe vient de app.ts (rotation du téléphone, ou repli du plein écran). */
html.landscape,
html.landscape body {
height: 100%;
overflow: hidden;
}
html.landscape body {
max-width: none;
padding: 0;
background: var(--frame);
}
html.landscape .topbar,
html.landscape .strip,
html.landscape .infoline,
html.landscape .prompt,
html.landscape .spacer,
html.landscape .actions {
display: none;
}
html.landscape .board-box {
position: fixed;
inset: 0;
}
html.landscape .board {
width: 100%;
height: 100%;
aspect-ratio: auto;
}
html.landscape .banner {
bottom: var(--space-2);
}
/* L'aide reste une page qu'on fait défiler. */
html.landscape.help-open,
html.landscape.help-open body {
height: auto;
overflow: visible;
}
html.landscape.help-open body {
padding: 0 var(--space-4);
background: var(--background);
}
/* Repli du plein écran : le paysage ne peut pas être verrouillé, l'écran
* entier tourne d'un quart de tour dans le portrait. Les touchers suivent
* la rotation ; le plateau et les fenêtres sont posés dans #screen. */
html.rotated .screen {
position: fixed;
top: 0;
left: 0;
width: 100vh;
width: 100dvh;
height: 100vw;
min-height: 0;
overflow: hidden;
transform-origin: top left;
transform: rotate(90deg) translateY(-100%);
}
html.rotated .board-box,
html.rotated .veil {
position: absolute;
}
html.rotated .banner {
position: absolute;
}
/* --------------------------------------------------------------- Aide */
@@ -619,65 +1236,7 @@ body.busy .prompt {
color: var(--muted);
}
/* ------------------------------------------------------- Mode paysage */
/* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à
* gauche ; la barre haute, les messages et les boutons passent dans une
* colonne à sa droite. */
@media (orientation: landscape) and (max-height: 40rem) {
body {
max-width: none;
padding: 0 var(--space-4);
}
/* Le jeu en deux colonnes : #game laisse ses enfants dans la grille de
* la page. Quand l'aide est affichée, #game est caché, la première
* colonne n'a plus de largeur et l'aide prend toute la place. */
body {
display: grid;
grid-template-columns: auto minmax(13rem, 1fr);
grid-template-rows: auto auto auto auto auto 1fr;
column-gap: var(--space-4);
align-items: start;
}
#game {
display: contents;
}
.topbar {
grid-column: 2;
grid-row: 1;
min-height: 0;
padding-block: var(--space-2) 0;
}
.board {
grid-column: 1;
grid-row: 1 / -1;
width: auto;
height: calc(100vh - 2 * var(--space-2));
height: calc(100dvh - 2 * var(--space-2));
max-width: 70vw;
margin-block: var(--space-2);
}
.pips,
.info,
.prompt,
.actions {
grid-column: 2;
}
.pips {
text-align: left;
}
.actions {
margin-bottom: var(--space-2);
}
.help {
grid-column: 1 / -1;
}
.help {
user-select: text;
-webkit-user-select: text;
}
+102 -34
View File
@@ -2,7 +2,7 @@
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#4A2E1F">
<title>Ustagammon</title>
@@ -16,47 +16,66 @@
<script type="module" src="app/main.js"></script>
</head>
<body class="busy">
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts ;
les pictogrammes, de app/src/ui/icons.ts. Tout l'écran est dans #screen :
c'est lui qui tourne d'un quart de tour quand le plein écran ne peut pas
verrouiller le paysage (ADR 0012). -->
<div id="screen" class="screen">
<header class="topbar">
<!-- Logo de la charte (docs/identite/logo/, copié dans icons/ par make
site) : horizontal, ou symbole seul quand la place manque (téléphone
étroit, colonne du mode paysage) ; version sombre en thème sombre. -->
site), version sombre en thème sombre (choix par app.css). -->
<h1 class="brand">
<picture>
<source media="(prefers-color-scheme: dark) and (max-width: 24.5rem), (prefers-color-scheme: dark) and (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-sombre.svg">
<source media="(max-width: 24.5rem), (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-couleur.svg">
<source media="(prefers-color-scheme: dark)" srcset="icons/logo-horizontal-sombre.svg">
<img src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
</picture>
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
</h1>
<button id="help-button" type="button" class="secondary"></button>
<button id="new-game" type="button" class="secondary"></button>
<button id="help-button" type="button" class="icon-button labelled"></button>
<button id="fullscreen-button" type="button" class="icon-button labelled"></button>
<button id="menu-button" type="button" class="icon-button labelled"></button>
</header>
<main id="game">
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="pips" class="pips"></p>
<p id="info" class="info" aria-live="polite"></p>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="actions">
<button id="roll" type="button" hidden></button>
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="confirm" type="button" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="skip" type="button" hidden></button>
<button id="replay" type="button" class="secondary" hidden></button>
<button id="again" type="button" hidden></button>
<main id="game" class="game">
<div id="strip-computer" class="strip">
<span class="mini mini-computer"></span>
<span class="who"></span>
<span class="lead"></span>
<button type="button" class="count"></button>
</div>
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
<p id="double-question"></p>
<div class="confirm-actions">
<button id="double-confirm" type="button"></button>
<button id="double-cancel" type="button" class="secondary"></button>
<div class="board-box">
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="pip-bubble" class="bubble" role="dialog" aria-labelledby="pip-bubble-term" hidden>
<p id="pip-bubble-term" class="bubble-term"></p>
<p class="bubble-text"></p>
<button type="button" class="link-button"></button>
</div>
</dialog>
</div>
<div id="strip-player" class="strip">
<span class="mini mini-player"></span>
<span class="who"></span>
<span class="lead"></span>
<button type="button" class="count"></button>
</div>
<div class="infoline">
<p id="info" class="info" aria-live="polite"></p>
<button id="replay" type="button" class="icon-button replay" hidden></button>
</div>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="spacer"></div>
<div id="actions" class="actions">
<div class="slot">
<button id="double" type="button" class="secondary" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
</div>
<div class="slot primary">
<button id="roll" type="button" hidden></button>
<button id="confirm" type="button" hidden></button>
<button id="skip" type="button" hidden></button>
<button id="take" type="button" hidden></button>
<button id="again" type="button" hidden></button>
<p id="move-hint" class="move-hint" hidden></p>
</div>
</div>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
@@ -64,5 +83,54 @@
<button id="help-back" type="button" class="secondary"></button>
<div id="help-content"></div>
</section>
<!-- Menu : une feuille qui monte du bas. -->
<div id="menu-veil" class="veil sheet-veil" hidden>
<div id="menu" class="sheet" role="dialog" aria-modal="true" aria-labelledby="menu-title">
<div class="sheet-head">
<span class="grip" aria-hidden="true"></span>
<h2 id="menu-title" class="sheet-title"></h2>
<button id="menu-close" type="button" class="icon-button"></button>
</div>
<button id="menu-new-game" type="button" class="item"></button>
<button id="menu-fullscreen" type="button" class="item"></button>
<button id="menu-help" type="button" class="item"></button>
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
</div>
</div>
<!-- Confirmation avant de doubler : la seule question posée au joueur. -->
<div id="double-veil" class="veil" hidden>
<div id="double-dialog" class="panel" role="dialog" aria-modal="true" aria-labelledby="double-question">
<p id="double-question"></p>
<div class="panel-actions">
<button id="double-cancel" type="button" class="secondary"></button>
<button id="double-confirm" type="button"></button>
</div>
</div>
</div>
<!-- Fin de partie, et double de l'ordinateur en paysage : un panneau centré
sur un voile. -->
<div id="panel-veil" class="veil" hidden>
<div id="panel" class="panel" role="dialog" aria-modal="true" aria-labelledby="panel-text">
<p id="panel-text"></p>
<div class="panel-actions">
<button id="panel-secondary" type="button" class="secondary"></button>
<button id="panel-primary" type="button"></button>
</div>
</div>
</div>
<!-- « Nouvelle partie commencée. Reprendre l'ancienne », 10 s. -->
<div id="banner" class="banner" role="status" hidden>
<span id="banner-text"></span>
<button id="resume-old" type="button" class="secondary"></button>
<span class="banner-timer" aria-hidden="true"></span>
</div>
</div>
</body>
</html>