Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 b4d79576a7 Fin de l'étape 0 : guides temporaires supprimés, paysage dès la rotation (ADR 0012)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:02:02 +01:00
Pierre MARTINandClaude Opus 5.5 3b94475f15 Fusion de la MR !20 : retours du test sur téléphone, feuille de route et plans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:56:13 +01:00
Pierre MARTINandClaude Opus 5.5 aa453b53e1 Retours du test sur téléphone : feuille de route, plans, Playwright validé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:55:52 +01:00
pierre 55f383dc10 Fusion de la MR !19 : coup de l'ordinateur, aucun coup possible, dés et videau touchables 2026-11-03 04:06:50 +00:00
Pierre MARTINandClaude Opus 5.5 9bfc7efb9e Coup de l'ordinateur montré, aucun coup possible, dés et videau touchables
Fusion du travail du sous-agent (branche coup-adverse-travail) avec la charte
graphique de main ; plan supprimé à la clôture, son résultat est repris dans
la merge request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:06:09 +01:00
Pierre MARTINandClaude Opus 5.5 a08c997ac1 Plan coup adverse : résultat des deux ajouts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:57:34 +01:00
pierre 99e87a3f50 Fusion de la MR !18 : charte graphique appliquée 2026-11-03 03:56:56 +00:00
Pierre MARTINandClaude Opus 5.5 02050eff01 Charte appliquée : plan clos ; fluidité ajoutée à la feuille de route
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:56:53 +01:00
Pierre MARTIN 15caf4da6e Merge remote-tracking branch 'origin/main' into charte-ui 2026-11-03 04:51:18 +01:00
Pierre MARTINandClaude Opus 5.5 9b79b54880 Dés et videau : cibles de 44 px sur le plateau de 344 px
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:12:56 +01:00
Pierre MARTINandClaude Opus 5.5 984d6cf060 Toucher les dés pour les lancer, le videau pour doubler
Quand vous pouvez lancer, vos dés en attente apparaissent dans la moitié
gauche du plateau ; quand vous pouvez doubler, le videau est souligné.
Les toucher fait comme les boutons, qui restent. Cibles d'au moins
44 px, avec rôle et nom accessibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:11:08 +01:00
Pierre MARTINandClaude Opus 5.5 39c85172b4 Aucun coup possible : le joueur continue lui-même
Le tour du joueur ne passe plus tout seul quand son jet ne permet aucun
coup : le domaine le dit (cannotMove) et le joueur laisse passer son tour
(skipTurn, bouton « Continuer »). Les dés d'un jet sans coup, du joueur
comme de l'ordinateur, restent affichés barrés avec un message jusqu'à
l'action suivante. Scénario « Aucun coup possible avec votre jet ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:07:27 +01:00
pierre 27ca5bce1e Fusion de la MR !17 : ADR 0012, mode immersif 2026-11-03 01:07:20 +00:00
Pierre MARTINandClaude Opus 5.5 d61efbc9ae Plan : résultat de l'application de la charte
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:07:18 +01:00
Pierre MARTINandClaude Opus 5.5 62dfc1922a Chiffres alignés sur le plateau et dans le compte de points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:07:18 +01:00
Pierre MARTINandClaude Opus 5.5 7ecb2da39f ADR 0012 : mode immersif en paysage, sur demande
Bouton « Plein écran » sur l'écran de jeu : plein écran et verrouillage
de l'orientation en paysage, avec repli par rotation CSS si le
verrouillage est refusé. Choix de Pierre ; feuille de route mise à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:07:13 +01:00
Pierre MARTINandClaude Opus 5.5 1bb9a95551 Plan coup adverse : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:00:58 +01:00
Pierre MARTINandClaude Opus 5.5 797c1724ab Lint : la licence de la police reste identique à l'original
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:59:31 +01:00
Pierre MARTINandClaude Opus 5.5 27ae0285a0 Charte appliquée à l'interface : couleurs, composants, mises en page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:58:16 +01:00
Pierre MARTINandClaude Opus 5.5 5d3dedcc90 Icônes et logo de la charte, copiés depuis docs/identite/logo/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:58:16 +01:00
Pierre MARTINandClaude Opus 5.5 f1c52941e9 Police Alegreya Sans embarquée (woff2, sous-ensemble latin)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:58:16 +01:00
pierre b08c4ffd54 Fusion de la MR !16 : ADR 0011 2026-11-03 00:54:14 +00:00
Pierre MARTINandClaude Opus 5.5 a490e72409 ADR 0011 : leçons numérotées, conseil du maître, bilan de fin de partie
Choix de Pierre sur les maquettes : parcours de leçons numérotées,
conseil du maître donné après le coup (jamais avant), bilan de fin de
partie avec le coup à revoir. Feuille de route mise à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:53:48 +01:00
Pierre MARTINandClaude Opus 5.5 7a9b376417 Coup de l'ordinateur : animation, traces et bouton Revoir
Le domaine donne le dernier coup demi-coup par demi-coup (plateaux
intermédiaires du moteur, pions frappés lus sur la barre) ; l'interface
fait glisser les pions puis laisse flèches, pions entourés et fantômes
jusqu'à l'action suivante. Scénario « Voir le coup de l'ordinateur »,
tests unitaires et test dans le navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:53:26 +01:00
Pierre MARTINandClaude Opus 5.5 e0f811d2b8 Plan : appliquer la charte graphique à l'application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:47:42 +01:00
Pierre MARTINandClaude Opus 5.5 9c241de5c9 Plan : montrer le coup de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:37:50 +01:00
pierre 7e45fde4e4 Fusion de la MR !15 : mode paysage 2026-11-03 00:22:57 +00:00
Pierre MARTINandClaude Opus 5.5 359cdcbb38 Mode paysage : plateau ajusté à la hauteur de l'écran, boutons à droite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:22:54 +01:00
pierre 8e28e26c07 Fusion de la MR !14 : charte graphique 2026-11-03 00:22:30 +00:00
Pierre MARTINandClaude Opus 5.5 f5d8d41a00 Charte graphique : copie du design system et journal
Couleurs des deux thèmes (mêmes noms que web/app.css), styles des
composants, guide de marque et dessin de référence du plateau dans
docs/identite/charte/, avec les liens vers le design system et les
maquettes d'écrans. Coquille corrigée dans l'ADR 0010.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:22:21 +01:00
pierre 65551098db Fusion de la MR !12 : identité visuelle 2026-11-03 00:05:07 +00:00
Pierre MARTINandClaude Opus 5.5 ca1e2a5077 ADR 0010 : accord de Pierre sur Alegreya Sans et fontTools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:05:05 +01:00
pierre f8b89a8c44 Fusion de la MR !13 : étape 0d.2, plateau jouable en PWA 2026-11-03 00:02:38 +00:00
Pierre MARTINandClaude Opus 5.5 1c722cca9f Identité visuelle : logo, icônes et ADR 0010
Symbole P3d (deux pions sur une flèche brique), logotype L3 (« mm » en
flèches alternées), accent brique, icône sur fond ivoire, choisis par
Pierre. Fichiers SVG et PNG dans docs/identite/logo/, générateur et notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:01:06 +01:00
75 changed files with 3322 additions and 590 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# CLAUDE.md — Règles du projet
Ce fichier est lu par Claude Code au début de chaque session. Il fixe le cadre du projet.
Le cadrage complet est dans `docs/cadrage.md`, l'étape en cours dans `docs/etape-0.md`.
Le cadrage complet est dans `docs/cadrage.md`, le travail en cours dans `docs/feuille-de-route.md`.
**En début de session**, lire `docs/organisation.md`, `docs/feuille-de-route.md`, la fin
de `docs/journal.md`, l'index des ADR (`docs/adr/README.md`) et les plans en cours dans `docs/plans/`, puis reprendre là où en
+16
View File
@@ -21,6 +21,22 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
https://github.com/clavierhaus/gnubg-android ; son fichier
`jni-bridge/src/stubs.c` a servi de repère pour isoler le cœur de gnubg.
## Identité visuelle
- **Alegreya Sans**, Copyright 2013 The Alegreya Sans Project Authors
(Juan Pablo del Peral, Huerta Tipográfica), licence
SIL Open Font License 1.1 — https://github.com/google/fonts/tree/main/ofl/alegreyasans
Ses tracés (graisse Bold) composent le logotype, dans `docs/identite/logo/`.
Cinq graisses (Regular, Italic, Medium, Bold, ExtraBold) sont embarquées
dans l'application (`web/fonts/`, avec la licence `OFL.txt`), réduites aux
caractères latins et converties en woff2 par fontTools (143 Ko en tout).
- **Logo et icônes d'Ustagammon** (`docs/identite/logo/`), licence
CC BY-SA 4.0 ; publiés dans `icons/` du site (favicon, icônes de la PWA,
logo de la barre haute).
- **fontTools** (MIT), utilisé par `docs/identite/generer-logo.py` pour
convertir le texte du logotype en tracés, et pour produire les fichiers
woff2 de la police ; avec **Brotli** (MIT), qui les compresse.
## Outils de développement
- **ShellCheck** (GPLv3), analyse des scripts shell, utilisé via Docker.
+12 -2
View File
@@ -115,9 +115,17 @@ deploy: site ## Sert l'application sur le réseau local (Ctrl+C pour arrêter)
deploy-web: deploy ## Comme make deploy (ancien nom, étape 0b)
# Fichiers de docs/identite/logo/ publiés dans icons/ (manifeste, favicon,
# barre haute).
SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone-maskable-512.png \
apple-touch-icon-180.png favicon.svg favicon-32.png \
logo-horizontal-couleur.svg logo-horizontal-sombre.svg symbole-couleur.svg symbole-sombre.svg
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
# - web/ à la racine : la page de l'application, son style, le manifeste, les
# icônes, le Web Worker du moteur, et les pages de test sous moteur/ ;
# - web/ à la racine : la page de l'application, son style, sa police, le
# manifeste, le Web Worker du moteur, et les pages de test sous moteur/ ;
# - sous icons/, le logo et les icônes de la charte, copiés depuis
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
@@ -128,6 +136,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
rm -rf $(BUILD_DIR)/site
mkdir -p $(BUILD_DIR)/site/wasm $(BUILD_DIR)/site/docs
cp -r web/. $(BUILD_DIR)/site/
mkdir -p $(BUILD_DIR)/site/icons
cp $(addprefix docs/identite/logo/,$(SITE_ICONS)) $(BUILD_DIR)/site/icons/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
+84 -14
View File
@@ -47,6 +47,20 @@ export interface SideBoards {
readonly computer: BoardSide;
}
/**
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
* successifs, tels que le moteur les donne.
*/
export interface MoveSteps {
readonly side: Side;
readonly halfMoves: readonly HalfMove[];
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
readonly boards: readonly SideBoards[];
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
* (lu sur les plateaux du moteur, pas déduit des règles). */
readonly hits: readonly boolean[];
}
export interface GameOptions {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
@@ -141,14 +155,45 @@ export class Game {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
const onRoll = state.turn ?? "player";
const board = await this.#engine.board(
this.displayedPosition,
engineCube(state.cube, onRoll),
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
}
/**
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
const events = this.events;
const index = events.findLastIndex((e) => e.type === "MovePlayed");
const played = events[index];
if (played?.type !== "MovePlayed") {
return null;
}
// L'état juste avant le coup : la position vue de celui qui joue, et
// son jet.
const before = replay(events.slice(0, index));
const roll = rollToPlay(before);
const positions = [before.position];
for (let i = 1; i <= played.halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
roll,
played.halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup enregistré refusé par le moteur");
}
positions.push(check.position);
}
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
);
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
}
/**
@@ -199,15 +244,32 @@ export class Game {
// ------------------------------------------------------------- Joueur
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
async roll(): Promise<void> {
const state = this.#expect("player", "rolling");
const roll = rollDice(this.#dice);
this.#append({ type: "DiceRolled", side: "player", roll });
const moves = await this.#engine.legalMoves(state.position, roll);
if (moves.length === 0) {
await this.#playMove("player", [], null);
this.#expect("player", "rolling");
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
}
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
* légal : il ne peut que laisser passer son tour. */
async cannotMove(): Promise<boolean> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving") {
return false;
}
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
return moves.length === 0;
}
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
* Erreur s'il a un coup possible. */
async skipTurn(): Promise<void> {
this.#expect("player", "moving");
if (!(await this.cannotMove())) {
throw new Error("vous avez un coup possible : jouez-le");
}
await this.#playMove("player", [], null);
}
/**
@@ -325,6 +387,14 @@ export class Game {
this.#journal = appendEvent(this.#journal, event);
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
}
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
const state = this.state;
+198 -45
View File
@@ -10,14 +10,17 @@
// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie.
import type { GameJournal } from "../domain/game/events.js";
import type { Game, SideBoards } from "../domain/game/game.js";
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { Side } from "../domain/game/side.js";
import { boardModel, moveTargets, type Place, placeOf } from "./board-model.js";
import { renderBoard, type ShownDie } from "./board-view.js";
import { otherSide, type Side } from "../domain/game/side.js";
import { boardModel, moveTargets, moveTrace, type Place, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, type ShownDie, slideChecker } from "./board-view.js";
import { showHelp } from "./help.js";
import { TEXTS } from "./texts.js";
/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */
const SLIDE_MS = 400;
export interface AppOptions {
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
readonly engineReady: Promise<unknown>;
@@ -49,6 +52,8 @@ export function startApp(options: AppOptions): void {
take: byId<HTMLButtonElement>("take"),
drop: byId<HTMLButtonElement>("drop"),
cancel: byId<HTMLButtonElement>("cancel"),
skip: byId<HTMLButtonElement>("skip"),
replay: byId<HTMLButtonElement>("replay"),
again: byId<HTMLButtonElement>("again"),
newGame: byId<HTMLButtonElement>("new-game"),
helpButton: byId<HTMLButtonElement>("help-button"),
@@ -63,6 +68,9 @@ export function startApp(options: AppOptions): void {
ui.take.textContent = TEXTS.take;
ui.drop.textContent = TEXTS.drop;
ui.cancel.textContent = TEXTS.cancel;
ui.skip.textContent = TEXTS.skip;
ui.replay.textContent = TEXTS.replay;
ui.replay.title = TEXTS.replayTitle;
ui.again.textContent = TEXTS.newGame;
ui.newGame.textContent = TEXTS.newGame;
ui.helpButton.textContent = TEXTS.help;
@@ -74,22 +82,35 @@ export function startApp(options: AppOptions): void {
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let canDouble = false;
// Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer.
let stuck = false;
let busy = true;
let info = "";
// Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir »)
// jusqu'à la prochaine action du joueur ; null : rien à montrer.
let shownMove: MoveSteps | null = null;
// Pendant l'animation d'un coup, render() laisse le plateau à l'animation.
let animating = false;
// ------------------------------------------------------------ Dessin
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
/** Recalcule ce qui demande le moteur, sans redessiner. */
async function update(): Promise<void> {
if (game === null) {
return;
}
sides = await game.displayedBoard();
targets = moveTargets(await game.nextHalfMoves());
canDouble = await game.canDouble();
stuck = await game.cannotMove();
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
/** Recalcule ce qui demande le moteur, puis redessine. */
async function refresh(): Promise<void> {
await update();
render();
}
@@ -99,30 +120,59 @@ export function startApp(options: AppOptions): void {
}
const state = game.state;
const model = boardModel(sides);
renderBoard(ui.board, model, {
dice: shownDice(game),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
lastMove: lastComputerMove(game),
});
if (!animating) {
renderBoard(ui.board, model, {
dice: shownDice(game, stuck),
cube: state.cube,
movable: new Set(targets.keys()),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
shownMove === null
? null
: moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits),
// Mêmes conditions que les boutons « Lancer les dés » et
// « Doubler ».
canRoll: !busy && state.turn === "player" && state.phase === "rolling",
canDouble: !busy && canDouble,
});
}
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
ui.info.textContent = info;
ui.prompt.textContent = prompt(game, selected !== null, canDouble);
// Un jet sans coup possible reste annoncé jusqu'à l'action suivante,
// même après un rechargement de la page.
if (stuck && state.roll !== null) {
ui.info.textContent = TEXTS.youCannotMove(state.roll);
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
} else {
ui.info.textContent = info;
}
ui.prompt.textContent = stuck
? TEXTS.pressContinue
: prompt(game, selected !== null, canDouble);
ui.prompt.classList.toggle("thinking", state.turn === "computer" && isPlaying(game));
const player = state.turn === "player";
show(ui.roll, player && state.phase === "rolling");
show(ui.double, player && state.phase === "rolling");
ui.double.disabled = busy || !canDouble;
show(ui.cancel, player && state.phase === "moving");
show(ui.cancel, player && state.phase === "moving" && !stuck);
show(ui.skip, stuck);
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
const doubled = state.turn === "computer" && state.phase === "doubled";
show(ui.take, doubled);
show(ui.drop, doubled);
show(ui.again, state.phase === "over");
for (const button of [ui.roll, ui.take, ui.drop, ui.again, ui.newGame]) {
show(ui.replay, shownMove !== null);
for (const button of [
ui.roll,
ui.take,
ui.drop,
ui.skip,
ui.replay,
ui.again,
ui.newGame,
]) {
button.disabled = busy;
}
// Pour le style et pour les tests dans un navigateur : l'écran
@@ -142,14 +192,73 @@ export function startApp(options: AppOptions): void {
}
}
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double). */
async function computerTurns(): Promise<void> {
/** L'ordinateur joue tant que c'est son tour (il s'arrête s'il double).
* Son dernier coup, s'il est la dernière chose jouée, devient le coup à
* montrer. Renvoie vrai si l'ordinateur a joué. */
async function computerTurns(): Promise<boolean> {
let played = false;
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
const start = performance.now();
await game.playComputerTurn();
info = describeComputerTurn(game, performance.now() - start);
save();
played = true;
}
if (game !== null) {
shownMove = await computerMoveToShow(game);
}
return played;
}
/**
* Fait glisser les pions du coup de l'ordinateur, un demi-coup après
* l'autre, à partir des plateaux intermédiaires donnés par le domaine. Rien
* si le système demande moins d'animations : les traces suffisent.
*/
async function animateMove(steps: MoveSteps): Promise<void> {
if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS };
const draw = (i: number) => {
const boards = steps.boards[i];
if (boards !== undefined) {
renderBoard(ui.board, boardModel(boards), decor);
}
};
animating = true;
render(); // messages et boutons ; le plateau est à l'animation
try {
for (const [i, halfMove] of steps.halfMoves.entries()) {
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(i);
const start = checkerSpot(ui.board, from);
const victim = steps.hits[i] === true ? checkerSpot(ui.board, to) : null;
draw(i + 1);
await Promise.all([
start && slideChecker(ui.board, to, start, SLIDE_MS),
victim &&
slideChecker(ui.board, `bar-${otherSide(steps.side)}`, victim, SLIDE_MS),
]);
}
} finally {
animating = false;
}
}
/** Rend la main au joueur : recalcule, anime le coup à montrer (si
* animate), puis redessine avec ses traces. */
async function settle(animate: boolean): Promise<void> {
try {
await update();
if (animate && shownMove !== null) {
await animateMove(shownMove);
}
} finally {
busy = false;
render();
}
}
@@ -162,6 +271,7 @@ export function startApp(options: AppOptions): void {
busy = true;
selected = null;
targets = new Map();
shownMove = null;
render();
try {
await action(game);
@@ -170,8 +280,7 @@ export function startApp(options: AppOptions): void {
} catch (error) {
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
await refresh();
await settle(true);
}
}
@@ -186,10 +295,13 @@ export function startApp(options: AppOptions): void {
act(async (g) => {
info = "";
await g.roll();
const last = g.events.at(-1);
if (last?.type === "MovePlayed" && last.halfMoves.length === 0) {
info = TEXTS.youCannotMove(rollBefore(g));
}
});
// Aucun coup possible : le joueur laisse passer son tour.
ui.skip.onclick = () =>
act(async (g) => {
info = "";
await g.skipTurn();
});
ui.double.onclick = () =>
@@ -214,6 +326,7 @@ export function startApp(options: AppOptions): void {
if (!busy && game !== null) {
game.cancelHalfMoves();
selected = null;
shownMove = null;
void refresh();
}
};
@@ -234,6 +347,16 @@ export function startApp(options: AppOptions): void {
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée.
ui.board.addEventListener("click", (event) => {
const target = event.target as Element | null;
// Toucher les dés en attente ou le videau : comme leurs boutons.
const action = target?.closest("[data-action]")?.getAttribute("data-action");
if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) {
ui.roll.click();
return;
}
if (action === "double" && !ui.double.hidden && !ui.double.disabled) {
ui.double.click();
return;
}
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
return;
@@ -247,9 +370,22 @@ export function startApp(options: AppOptions): void {
return;
}
selected = targets.has(place) && place !== selected ? place : null;
if (selected !== null) {
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
}
render();
});
// Revoir le dernier coup de l'ordinateur, puis ses traces.
ui.replay.onclick = async () => {
if (busy || shownMove === null) {
return;
}
busy = true;
selected = null;
await settle(true);
};
ui.helpButton.onclick = () => {
ui.game.hidden = true;
ui.help.hidden = false;
@@ -264,7 +400,16 @@ export function startApp(options: AppOptions): void {
// ---------------------------------------------------------- Démarrage
ui.prompt.textContent = TEXTS.loading;
for (const button of [ui.roll, ui.double, ui.take, ui.drop, ui.cancel, ui.again]) {
for (const button of [
ui.roll,
ui.double,
ui.take,
ui.drop,
ui.cancel,
ui.skip,
ui.replay,
ui.again,
]) {
button.hidden = true;
}
ui.newGame.disabled = true;
@@ -276,12 +421,13 @@ export function startApp(options: AppOptions): void {
save();
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour).
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
// reprise juste après son coup le montre, sans l'animer.
let played = false;
try {
await computerTurns();
played = await computerTurns();
} finally {
busy = false;
await refresh();
await settle(played);
}
},
(error: unknown) => {
@@ -307,29 +453,36 @@ function rollBefore(game: Game): readonly number[] {
}
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé
* (au jet d'ouverture, un dé chacun). */
function shownDice(game: Game): ShownDie[] {
* (au jet d'ouverture, un dé chacun). Ils sont barrés si le jet ne permet
* aucun coup : le joueur est bloqué (stuck), ou l'ordinateur vient de passer
* son tour sans bouger. */
function shownDice(game: Game, stuck: boolean): ShownDie[] {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
if (rolled?.type !== "DiceRolled") {
return [];
}
const blocked = stuck || computerCouldNotMove(game);
const sides: Side[] =
rolled.side === null ? ["player", "computer"] : [rolled.side, rolled.side];
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player" }));
return rolled.roll.map((value, i) => ({ value, side: sides[i] ?? "player", blocked }));
}
/** Les cases de départ et d'arrivée du dernier coup, s'il est de
* l'ordinateur. */
function lastComputerMove(game: Game): Set<Place> {
const played = game.events.findLast((e) => e.type === "MovePlayed");
const places = new Set<Place>();
if (played?.type === "MovePlayed" && played.side === "computer") {
for (const h of played.halfMoves) {
places.add(placeOf("computer", h.from));
places.add(placeOf("computer", h.to));
}
/** La dernière chose jouée (fin de partie mise à part) est un tour de
* l'ordinateur qui n'a pas pu jouer son jet. */
function computerCouldNotMove(game: Game): boolean {
const last = game.events.findLast((e) => e.type !== "GameEnded");
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0;
}
/** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la
* dernière chose jouée (fin de partie mise à part) et qu'il a déplacé des
* pions ; sinon null. */
async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
const last = game.events.findLast((e) => e.type !== "GameEnded");
if (last?.type !== "MovePlayed" || last.side !== "computer" || last.halfMoves.length === 0) {
return null;
}
return places;
return game.lastMoveSteps();
}
/** Ce qu'a fait l'ordinateur pendant son tour. */
+44
View File
@@ -82,6 +82,50 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
return targets;
}
/** Ce qui reste dessiné d'un coup une fois joué. */
export interface MoveTrace {
/** Le camp qui a joué. */
readonly side: Side;
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
readonly landed: ReadonlyMap<Place, number>;
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
readonly left: ReadonlyMap<Place, number>;
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
readonly hits: number;
}
/**
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
*/
export function moveTrace(
side: Side,
halfMoves: readonly HalfMove[],
hits: readonly boolean[],
): MoveTrace {
const balance = new Map<Place, number>();
const arrows = halfMoves.map((h) => {
const from = placeOf(side, h.from);
const to = placeOf(side, h.to);
balance.set(from, (balance.get(from) ?? 0) - 1);
balance.set(to, (balance.get(to) ?? 0) + 1);
return { from, to };
});
const landed = new Map<Place, number>();
const left = new Map<Place, number>();
for (const [place, n] of balance) {
if (n > 0) {
landed.set(place, n);
} else if (n < 0) {
left.set(place, -n);
}
}
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
}
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
export function pointSlot(number: number): { top: boolean; column: number } {
+300 -60
View File
@@ -11,12 +11,15 @@
import type { Cube } from "../domain/game/cube.js";
import type { Side } from "../domain/game/side.js";
import { type BoardModel, type Place, pointSlot } from "./board-model.js";
import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js";
import { TEXTS } from "./texts.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
export interface ShownDie {
readonly value: number;
readonly side: Side;
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
readonly blocked: boolean;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
@@ -28,10 +31,29 @@ export interface BoardDecor {
/** Le pion touché, dont on montre les cases d'arrivée. */
readonly selected: Place | null;
readonly targets: ReadonlySet<Place>;
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
readonly lastMove: ReadonlySet<Place>;
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
readonly trace: MoveTrace | null;
/** Le joueur peut lancer les dés : ses dés en attente se touchent. */
readonly canRoll: boolean;
/** Le joueur peut doubler : le videau se touche. */
readonly canDouble: boolean;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
export const NO_MARKS = {
movable: new Set<Place>(),
selected: null,
targets: new Set<Place>(),
trace: null,
canRoll: false,
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;
@@ -73,10 +95,51 @@ function columnLeft(column: number): number {
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
}
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
function stackY(top: boolean, index: number): number {
const offset = RADIUS + 1 + index * STEP;
return top ? FRAME + offset : HEIGHT - FRAME - offset;
/** Un point du dessin. */
export interface Spot {
readonly x: number;
readonly y: number;
}
/**
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
*/
function slotCenter(place: Place, index: number): Spot {
if (place === "bar-player" || place === "bar-computer") {
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
return {
x: BAR_LEFT + BAR_WIDTH / 2,
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
};
}
if (place === "off-player" || place === "off-computer") {
const y =
place === "off-computer"
? FRAME + index * OFF_SLAB
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
}
const { top, column } = pointSlot(Number(place.slice(1)));
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
return {
x: columnLeft(column) + POINT_WIDTH / 2,
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
};
}
/** Le nombre de pions de side à un endroit. */
function countAt(board: BoardModel, side: Side, place: Place): number {
if (place === `bar-${side}`) {
return board.bar[side];
}
if (place === `off-${side}`) {
return board.off[side];
}
const point = board.points.find((p) => p.place === place);
return point?.owner === side ? point.count : 0;
}
/** Redessine tout le plateau dans svg. */
@@ -91,7 +154,18 @@ export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardD
drawBar(svg, hits, board, decor);
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
drawCube(svg, decor.cube);
const cube = drawCube(svg, decor.cube);
if (decor.trace !== null) {
drawTrace(svg, board, decor.trace);
}
if (decor.canRoll) {
const center = waitingDice(svg);
addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2);
}
if (decor.canDouble) {
cube.classList.add("can-double");
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -153,16 +227,7 @@ function drawPoint(
});
}
if (point.owner !== null) {
const y = (i: number) => stackY(top, i);
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
}
if (decor.lastMove.has(point.place)) {
add(svg, "circle", {
class: "last-move",
cx: center,
cy: top ? MIDDLE - 12 : MIDDLE + 12,
r: 5,
});
drawStack(svg, point.owner, point.count, point.place, decor);
}
addHit(hits, point.place, decor, {
x: left,
@@ -172,14 +237,13 @@ function drawPoint(
});
}
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
* nombre total s'il y en a trop pour les montrer tous. */
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
* pour les montrer tous. Le pion du dessus porte son endroit dans
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
function drawStack(
svg: SVGSVGElement,
side: Side,
count: number,
cx: number,
y: (index: number) => number,
place: Place,
decor: BoardDecor,
): void {
@@ -192,15 +256,13 @@ function drawStack(
} else if (last && decor.movable.has(place)) {
classes.push("movable");
}
const cy = y(i);
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
const { x, y } = slotCenter(place, i);
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
if (last) {
checker.setAttribute("data-checker", place);
}
if (last && count > MAX_STACK) {
add(
svg,
"text",
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
String(count),
);
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
}
}
}
@@ -213,14 +275,10 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
width: BAR_WIDTH,
height: HEIGHT,
});
const cx = BAR_LEFT + BAR_WIDTH / 2;
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
const up = side === "player";
const y = (i: number) =>
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
drawStack(svg, side, board.bar[side], cx, y, `bar-${side}`, decor);
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
}
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
}
@@ -228,17 +286,12 @@ function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: Bo
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
for (const side of ["player", "computer"] as const) {
for (let i = 0; i < board.off[side]; i++) {
const y =
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
add(svg, "rect", {
class: `checker checker-${side}`,
x: TRAY_LEFT + 4,
y: y + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
const count = board.off[side];
for (let i = 0; i < count; i++) {
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
if (i === count - 1) {
slab.setAttribute("data-checker", `off-${side}`);
}
}
}
if (decor.targets.has("off-player")) {
@@ -251,14 +304,6 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
rx: 4,
});
}
if (decor.lastMove.has("off-computer")) {
add(svg, "circle", {
class: "last-move",
cx: TRAY_LEFT + TRAY_WIDTH / 2,
cy: MIDDLE - 70,
r: 5,
});
}
addHit(hits, "off-player", decor, {
x: TRAY_LEFT,
y: MIDDLE,
@@ -267,6 +312,143 @@ function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: B
});
}
/** Une tranche de pion sorti, centrée en spot. */
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
return add(svg, "rect", {
class: cssClass,
x: TRAY_LEFT + 4,
y: spot.y - OFF_SLAB / 2 + 1,
width: TRAY_WIDTH - 8,
height: OFF_SLAB - 2,
rx: 3,
});
}
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
if (place.startsWith("off-")) {
addSlab(svg, cssClass, spot);
} else {
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
}
}
// ------------------------------------------------------ Coup de l'ordinateur
/**
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
* (sur la barre), et une flèche par demi-coup.
*/
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
const side = trace.side;
for (const [place, n] of trace.left) {
// Au-dessus des pions restés sur la case.
const remaining = countAt(board, side, place);
for (let k = 0; k < n; k++) {
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
}
}
for (const [place, n] of trace.landed) {
const top = topIndex(board, side, place);
for (let k = 0; k < n && top - k >= 0; k++) {
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
}
}
const opponent: Side = side === "player" ? "computer" : "player";
const bar: Place = `bar-${opponent}`;
const top = topIndex(board, opponent, bar);
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
}
const defs = add(svg, "defs", {});
const marker = add(defs, "marker", {
id: "trace-head",
viewBox: "0 0 10 10",
refX: 8,
refY: 5,
markerWidth: 3.5,
markerHeight: 3.5,
orient: "auto",
});
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
const departures = new Map<Place, number>();
for (const { from, to } of trace.arrows) {
// De la place libre laissée par le pion (son fantôme) à son pion
// d'arrivée.
const k = departures.get(from) ?? 0;
departures.set(from, k + 1);
const start = slotCenter(from, countAt(board, side, from) + k);
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
}
}
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
function topIndex(board: BoardModel, side: Side, place: Place): number {
const count = countAt(board, side, place);
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
}
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.hypot(dx, dy);
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
add(svg, "line", {
class: "trace-arrow",
x1: start.x,
y1: start.y,
x2: start.x + dx * keep,
y2: start.y + dy * keep,
"marker-end": "url(#trace-head)",
});
}
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
* dessiné (null s'il n'y a pas de pion). */
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
const checker = svg.querySelector(`[data-checker="${place}"]`);
if (checker === null) {
return null;
}
const number = (name: string) => Number(checker.getAttribute(name));
if (checker.tagName === "circle") {
return { x: number("cx"), y: number("cy") };
}
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
}
/**
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
* Résolue à la fin du glissement.
*/
export async function slideChecker(
svg: SVGSVGElement,
place: Place,
from: Spot,
ms: number,
): Promise<void> {
const checker = svg.querySelector(`[data-checker="${place}"]`);
const to = checkerSpot(svg, place);
if (checker === null || to === null || typeof checker.animate !== "function") {
return;
}
// Devant les autres pions pendant le trajet, mais derrière les zones à
// toucher, toujours dessinées en dernier.
svg.insertBefore(checker, svg.lastChild);
const animation = checker.animate(
[
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
{ transform: "translate(0px, 0px)" },
],
{ duration: ms, easing: "ease-in-out" },
);
await animation.finished.catch(() => undefined);
}
// Position des points d'un dé, en fractions de son côté.
const PIPS: Record<number, [number, number][]> = {
1: [[0.5, 0.5]],
@@ -310,7 +492,10 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
dice.forEach((die, i) => {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const group = add(svg, "g", { class: "die", "data-value": die.value });
const group = add(svg, "g", {
class: die.blocked ? "die die-blocked" : "die",
"data-value": die.value,
});
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
for (const [px, py] of PIPS[die.value] ?? []) {
add(group, "circle", {
@@ -320,23 +505,78 @@ function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
r: 3.5,
});
}
if (die.blocked) {
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, 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): void {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
const y = MIDDLE + shift - CUBE / 2;
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
const center = cubeCenter(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" });
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
add(
group,
"text",
{ class: "cube-value", x: x + CUBE / 2, y: y + CUBE / 2 + 6 },
{ class: "cube-value", x: center.x, y: center.y + 6 },
String(cube.owner === null ? 64 : cube.value),
);
return group;
}
/** Le centre du videau dans le plateau de sortie. */
function cubeCenter(cube: Cube): Spot {
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
return { x: TRAY_LEFT + TRAY_WIDTH / 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). Renvoie
* leur centre. */
function waitingDice(svg: SVGSVGElement): Spot {
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
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]) {
add(group, "rect", {
class: "die-player",
x,
y: MIDDLE - DIE / 2,
width: DIE,
height: DIE,
rx: 6,
});
}
return center;
}
/**
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
* 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.
*/
function addAction(
hits: Element,
action: string,
label: string,
center: Spot,
width: number,
): void {
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,
});
}
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
+5 -2
View File
@@ -37,6 +37,9 @@ export const TEXTS = {
take: "Prendre",
drop: "Passer",
cancel: "Annuler",
skip: "Continuer",
replay: "Revoir",
replayTitle: "Revoir le dernier coup de l'ordinateur",
newGame: "Nouvelle partie",
help: "Aide",
back: "Retour à la partie",
@@ -57,8 +60,8 @@ export const TEXTS = {
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.",
youCannotMove: (dice: readonly number[]) =>
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
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)}).`,
+6 -1
View File
@@ -36,7 +36,12 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
} else {
assert.ok(state.roll !== null);
const [first] = await engine.legalMoves(state.position, state.roll);
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
if (first === undefined) {
// Aucun coup possible : le joueur laisse passer son tour.
assert.equal(await game.cannotMove(), true);
await game.skipTurn();
continue;
}
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
+22 -1
View File
@@ -86,19 +86,40 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
assert.equal(game.state.position, "SWAPPED");
});
test("sans coup possible, le tour passe", async () => {
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
dice: fixedDice([6, 6]),
setup: { position: "BLOCKED", firstTurn: "player" },
});
await game.roll();
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
assert.equal(await game.cannotMove(), true);
await game.skipTurn();
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed");
assert.deepEqual(last.halfMoves, []);
assert.equal(game.state.turn, "computer");
});
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
const engine = fakeEngine({
legalMoves: async () => [
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
],
});
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: { position: "X", firstTurn: "player" },
});
await game.roll();
assert.equal(await game.cannotMove(), false);
await assert.rejects(game.skipTurn(), /coup possible/);
assert.equal(game.state.phase, "moving");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
+143
View File
@@ -0,0 +1,143 @@
// Un coup demi-coup par demi-coup (Game.lastMoveSteps), sur le vrai moteur
// WebAssembly : les plateaux intermédiaires que l'interface anime, et les
// pions frappés qu'elle signale.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import {
BAR,
formatHalfMoves,
type PositionId,
parseHalfMoves,
} from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait : un pion sur sa case 13, quatorze sur sa case 6.
// L'ordinateur : un blot sur la case 10 du joueur (sa case 15), quatorze sur
// sa case 6.
const PLAYER_CAN_HIT = "4P8HEADg/wcEAA";
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
// L'ordinateur : un blot sur la case 21 du joueur (sa case 4), quatorze sur
// sa case 6.
const PLAYER_ON_BAR = "yP8PAADg/wcAQA";
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
/** Une partie où le joueur, au trait, joue le jet dice à la position. */
async function playerMove(position: PositionId, dice: [number, number], move: string) {
const engine = await loadNodeEngine();
const game = Game.start(engine, {
dice: fixedDice(dice),
setup: { position, firstTurn: "player" },
});
await game.roll();
for (const halfMove of parseHalfMoves(move)) {
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
}
return game;
}
/** Le nombre de pions d'un camp sur une case (1 à 24), sur la barre ou sortis. */
function at(side: BoardSide, point: number | "bar" | "off"): number {
if (point === "bar") {
return side.bar;
}
return point === "off" ? side.off : (side.points[point - 1] ?? 0);
}
test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "player");
assert.equal(formatHalfMoves(steps.halfMoves), "8/5 6/5");
assert.deepEqual(steps.hits, [false, false]);
const counts = steps.boards.map((b) => [at(b.player, 8), at(b.player, 6), at(b.player, 5)]);
assert.deepEqual(counts, [
[3, 5, 0],
[2, 5, 1],
[2, 4, 2],
]);
// Le dernier plateau est celui de la partie après le coup.
assert.deepEqual(steps.boards[2], await game.displayedBoard());
});
test("un pion frappé part sur la barre au demi-coup qui le frappe", async () => {
const game = await playerMove(PLAYER_CAN_HIT, [3, 1], "13/10 10/9");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [true, false]);
const bar = steps.boards.map((b) => at(b.computer, "bar"));
assert.deepEqual(bar, [0, 1, 1]);
// Le blot était sur la case 10 du joueur, la case 15 de l'ordinateur.
assert.equal(at(steps.boards[0]?.computer as BoardSide, 15), 1);
assert.equal(at(steps.boards[1]?.computer as BoardSide, 15), 0);
assert.equal(at(steps.boards[2]?.player as BoardSide, 9), 1);
});
test("un coup depuis la barre, qui frappe en rentrant", async () => {
const game = await playerMove(PLAYER_ON_BAR, [4, 2], "bar/21 6/4");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [true, false]);
assert.deepEqual(
steps.boards.map((b) => [at(b.player, "bar"), at(b.player, 21), at(b.computer, "bar")]),
[
[1, 0, 0],
[0, 1, 1],
[0, 1, 1],
],
);
});
test("une sortie de pions, jusqu'à la fin de la partie", async () => {
const game = await playerMove(PLAYER_ALMOST_DONE, [6, 5], "6/off 5/off");
assert.equal(game.state.phase, "over");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.deepEqual(steps.hits, [false, false]);
assert.deepEqual(
steps.boards.map((b) => at(b.player, "off")),
[13, 14, 15],
);
});
test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
const engine = await loadNodeEngine();
// Vous commencez avec 3-1, puis l'ordinateur tire 6-4.
const game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
for (const halfMove of parseHalfMoves("8/5 6/5")) {
await game.playHalfMove(halfMove);
}
await game.playComputerTurn();
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
assert.equal(steps.side, "computer");
assert.equal(steps.boards.length, steps.halfMoves.length + 1);
// Chaque demi-coup déplace un pion de l'ordinateur de sa case de départ.
for (const [i, h] of steps.halfMoves.entries()) {
const from = h.from === BAR ? "bar" : h.from;
const before = steps.boards[i]?.computer as BoardSide;
const after = steps.boards[i + 1]?.computer as BoardSide;
assert.equal(at(after, from), at(before, from) - 1);
}
assert.deepEqual(steps.boards.at(-1), await game.displayedBoard());
});
test("avant le premier coup, il n'y a rien à montrer", async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
assert.equal(await game.lastMoveSteps(), null);
});
+117 -3
View File
@@ -7,10 +7,16 @@
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine, Move } from "../../src/domain/game/engine.js";
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
import type { GameEvent } from "../../src/domain/game/events.js";
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
import {
BAR,
OFF,
type PositionId,
parseHalfMoves,
START_POSITION,
} from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { loadNodeEngine } from "../support/node-engine.js";
@@ -27,11 +33,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
// l'ordinateur n'en a sorti aucun.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
// pion devrait rentrer.
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
// est 13/10* 10/9 : il frappe votre blot.
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
let engine: Engine;
let game: Game;
let accepted: boolean;
let positionBefore: PositionId;
let computerTurn: { before: GameState; ms: number };
let computerMove: MoveSteps;
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
* 1 à 24, dans sa numérotation). */
function checkersAt(side: BoardSide, point: number): number {
if (point === BAR) {
return side.bar;
}
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
}
BeforeAll(async () => {
engine = await loadNodeEngine();
@@ -171,6 +198,93 @@ Then(
},
);
// ------------------------------------------------- Aucun coup possible
Given("votre pion sur la barre ne peut pas rentrer", () => {
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
game = Game.start(engine, {
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
});
When("vous lancez les dés", async () => {
await game.roll();
});
Then("aucun coup n'est possible avec votre jet", async () => {
assert.deepEqual(game.state.roll, [6, 5]);
assert.equal(await game.cannotMove(), true);
assert.deepEqual(await game.nextHalfMoves(), []);
});
Then("c'est toujours à vous", () => {
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
assert.equal(lastEvent().type, "DiceRolled");
});
When("vous continuez", async () => {
await game.skipTurn();
const event = lastEvent();
assert.ok(event.type === "MovePlayed" && event.side === "player");
assert.deepEqual(event.halfMoves, []);
});
// ---------------------------------------------- Voir le coup de l'ordinateur
Given("un de vos pions est seul à portée de l'ordinateur", () => {
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
// doubler, il joue.
game = Game.start(engine, {
setup: {
position: COMPUTER_MUST_HIT,
firstTurn: "computer",
cube: { value: 2, owner: "player" },
},
dice: fixedDice([3, 1]),
});
});
When("l'ordinateur a joué", async () => {
await game.playComputerTurn();
const played = lastEvent();
assert.ok(played.type === "MovePlayed" && played.side === "computer");
const steps = await game.lastMoveSteps();
assert.ok(steps !== null);
computerMove = steps;
});
Then("son coup vous est montré case par case", async () => {
const { side, halfMoves, boards } = computerMove;
assert.equal(side, "computer");
assert.ok(halfMoves.length > 0);
assert.equal(boards.length, halfMoves.length + 1);
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
// parti de la case de départ du demi-coup pour sa case d'arrivée.
for (const [i, h] of halfMoves.entries()) {
const before = boards[i]?.computer;
const after = boards[i + 1]?.computer;
assert.ok(before !== undefined && after !== undefined);
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
}
// Le dernier est le plateau de la partie.
assert.deepEqual(boards.at(-1), await game.displayedBoard());
});
Then("votre pion frappé part sur la barre", () => {
const { boards, hits } = computerMove;
const hit = hits.indexOf(true);
assert.ok(hit >= 0, "aucun pion frappé");
assert.equal(boards[hit]?.player.bar, 0);
assert.equal(boards[hit + 1]?.player.bar, 1);
});
Then("vous pouvez revoir son coup", async () => {
assert.deepEqual(await game.lastMoveSteps(), computerMove);
});
// ------------------------------------------------------------------ Videau
Given("l'ordinateur vous propose de doubler", async () => {
+51 -2
View File
@@ -10,9 +10,9 @@ import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js";
import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { TEXTS } from "../src/ui/texts.js";
@@ -67,6 +67,55 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
});
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
// L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et
// 7 sont les cases 12, 16 et 18 du joueur.
const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]);
assert.deepEqual(trace.arrows, [
{ from: "p12", to: "p16" },
{ from: "p12", to: "p18" },
]);
assert.deepEqual([...trace.left], [["p12", 2]]);
assert.deepEqual(
[...trace.landed],
[
["p16", 1],
["p18", 1],
],
);
assert.equal(trace.hits, 0);
});
test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => {
const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]);
assert.equal(trace.arrows.length, 2);
assert.deepEqual([...trace.left], [["p1", 1]]);
assert.deepEqual([...trace.landed], [["p11", 1]]);
assert.equal(trace.hits, 1);
});
test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => {
const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]);
assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" });
assert.deepEqual(
[...entering.left],
[
["bar-computer", 1],
["p19", 1],
],
);
assert.equal(entering.hits, 1);
const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]);
assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]);
assert.deepEqual(
[...bearingOff.left],
[
["p19", 1],
["p20", 1],
],
);
});
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
@@ -52,3 +52,9 @@ construire ou tester ; rien n'est embarqué dans l'application.
Validés par Pierre avec l'ADR 0009, dans Docker, jamais embarqués :
TypeScript (`tsc`, Apache-2.0), Biome (MIT ou Apache-2.0),
`@cucumber/cucumber` (MIT), `dependency-cruiser` (MIT).
### 2026-11-03 — Playwright pour les tests dans le navigateur
Validé par Pierre : Playwright 1.48.2 (Apache-2.0), image
`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisé seulement par
`make test-browser` (Chromium et Firefox sans écran). Rien n'est embarqué.
+62
View File
@@ -0,0 +1,62 @@
# 0010. Identité visuelle : symbole, logotype, palette
- **Statut** : accepté
- **Date** : 2026-11-03
- **Décideurs** : Pierre
## Contexte
Le brief créatif (https://claude.ai/artifact/36WMwZWqvMxXvSSVBJTgwd) fixe la
personnalité d'Ustagammon (transmission, chaleur, maîtrise), une palette de
départ (bois sombre, ivoire, un accent unique à choisir entre rouge brique et
turquoise) et quatre pistes de symbole, avec une contrainte : un 8 caché, clin
d'œil à l'Atelier du Huit, qui se découvre au second regard.
Les propositions ont été faites en trois tours sur une planche de travail
(https://claude.ai/artifact/Cck1SZCrKeYWbqJLhbwWNp), chacune testée en
couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
## Décision
- **Accent** : rouge brique, `#A8432B` en thème clair, `#D0694B` en thème
sombre. La turquoise est écartée.
- **Symbole** (variante « P3d ») : deux pions vus de trois quarts, avec leur
tranche, posés sur une flèche du plateau en brique. Le maître (noyer) est
devant, l'apprenti (ivoire, filet brique) derrière ; leurs silhouettes
dessinent le 8.
- **Logotype** (variante « L3 ») : « Ustagammon » en Alegreya Sans Bold, dont
les deux « m » sont redessinés en quatre flèches au trait qui alternent
noyer et brique, comme les flèches d'un plateau. En monochrome, les quatre
flèches prennent la couleur du texte.
- **Icône d'application** : symbole sur fond ivoire `#F3EAD8`.
- **Police** : Alegreya Sans (SIL Open Font License 1.1), pour le logotype et
pour l'interface. Pierre a validé cette dépendance, ainsi que fontTools
(MIT), outil de développement qui sert à régénérer le logotype, le
2026-11-03.
- Les fichiers de référence sont dans `docs/identite/logo/`, produits par
`docs/identite/generer-logo.py` ; ils sont sous licence CC BY-SA 4.0.
## Alternatives écartées
- **Accent turquoise** : plus froid ; la brique s'accorde mieux au bois et au
café.
- **Piste B (le videau)** : écartée au premier tour, au profit des pions.
- **Pions vus de face (A1) ou en léger relief sans flèche (P1)** : appréciés,
mais la flèche rattache le symbole au plateau et fait écho au logotype.
- **Filets brique sur les deux pions (P3c)** : trop de brique dans un petit
symbole.
- **Logotype aux flèches pleines (L2)** : les « mm » s'y lisaient moins vite ;
le trait (L1) garde la lisibilité, et l'alternance de couleur (L3) apporte
le rappel du plateau.
- **Fond d'icône noyer** : moins lumineux sur un écran d'accueil.
## Conséquences
- Le design system (couleurs des deux thèmes, typographie, composants) part de
cette palette et de cette police.
- La police Alegreya Sans devra être embarquée dans la PWA (fonctionnement hors
ligne) ; sa licence OFL le permet, avec sa notice.
- Le favicon utilise un dessin simplifié du symbole, sans filets, illisibles
en dessous de 32 px.
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
+59
View File
@@ -0,0 +1,59 @@
# 0011. Leçons numérotées, conseil du maître après le coup, bilan de fin de partie
- **Statut** : accepté
- **Date** : 2026-11-03
- **Décideurs** : Pierre
## Contexte
Le cadrage (`docs/cadrage.md`) prévoit pour la V1 un coach en direct, un écran
de fin de partie et des leçons par concept, sans en fixer la forme. Les
maquettes des écrans (https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe),
faites avec la charte graphique (ADR 0010), en ont proposé une. Pierre en a
retenu trois éléments le 2026-11-03.
Un point des maquettes contredisait le cadrage : le conseil apparaissait avant
que le joueur ne joue, en donnant la réponse. Pierre a tranché pour un
commentaire après le coup.
## Décision
- **Leçons numérotées** : un parcours de leçons dans un ordre fixe
(« Leçon 1 », « Leçon 2 »…). Chaque leçon affiche son état (à commencer,
« 3 exercices sur 5 », terminée) et sa progression. L'accueil propose de
continuer la leçon en cours.
- **Conseil du maître** : c'est la forme du coach en direct du cadrage. Il
commente le coup **après** que le joueur l'a joué, jamais avant. Il donne
l'appréciation du moteur avec un mot et une icône (bon coup, imprécision,
erreur), puis une phrase du maître qui dit quel coup il aurait joué et
pourquoi, avec un lien pour en savoir plus. L'écart d'équité n'apparaît que
sur demande. Il reste désactivable, comme le prévoit le cadrage.
- **Bilan de fin de partie** : le résultat, le décompte des coups par
appréciation (bons coups, imprécisions, erreurs), « le coup à revoir »
(la plus grosse erreur, avec sa position, le coup joué et le meilleur coup)
et le prompt prêt à copier pour une IA.
Les maquettes de référence sont les écrans « Plateau de jeu », « Leçon et
aide » et « Fin de partie » du canevas ci-dessus.
## Alternatives écartées
- **Conseil avant le coup, avec la réponse** : le joueur n'essaie plus par
lui-même, et on n'apprend pas en recopiant.
- **Indice avant le coup, sans la réponse, en plus du commentaire après** :
écarté pour l'instant au profit d'un seul mécanisme simple. L'« indice » de
la liste des fonctions du cadrage reste à concevoir séparément.
## Conséquences
- Le commentaire après le coup demande une analyse du coup joué par le moteur
(classement des coups et écart avec le meilleur). Elle doit tenir dans le
budget de temps du téléphone, en arrière-plan, sans retarder le tour
suivant.
- Les seuils entre « bon coup », « imprécision » et « erreur » sont à fixer,
en s'appuyant sur ceux de GNU Backgammon (doubtful, bad, very bad).
- Les phrases du maître doivent rester exactes : elles s'appuient sur les
coups et les chiffres du moteur, jamais sur une explication inventée.
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
progression est stockée localement.
+79
View File
@@ -0,0 +1,79 @@
# 0012. Mode immersif : le plateau en paysage, sur demande
- **Statut** : accepté
- **Date** : 2026-11-03
- **Décideurs** : Pierre
## Contexte
Sur l'écran de jeu en portrait, le plateau n'occupe qu'environ 35 % de l'écran
d'un téléphone (pions de 24 px sur un écran de 390 px de large). Pierre le
trouve peu immersif et a demandé un mode où le plateau prend le maximum de
l'écran.
Deux pistes ont été maquettées
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Mode immersif ») :
le téléphone en paysage, plateau sur toute la hauteur ; ou le portrait avec le
plateau tourné d'un quart de tour. Toutes deux donnent environ 57 % de l'écran
au plateau et des pions de 30 px : le petit côté de l'écran limite la taille
des pions dans les deux cas.
## Décision
- Un **mode immersif en paysage** : le plateau occupe toute la hauteur, dans
son orientation habituelle. À gauche : le menu, les comptes de points et la
consigne. À droite : l'aide et les actions. Le fond est en noyer jusqu'aux
bords de l'écran.
- On y entre **par un geste explicite** : un bouton « Plein écran » sur l'écran
de jeu en portrait. On en sort par le menu ou par le retour du téléphone.
- Le bouton force la vue paysage même si le téléphone est tenu droit ou si sa
rotation automatique est verrouillée : passage en plein écran
(`requestFullscreen`), puis verrouillage de l'orientation
(`screen.orientation.lock("landscape")`).
- Si le verrouillage est refusé (navigateur ou système qui ne le permet pas,
iPhone), l'app affiche la vue paysage tournée d'un quart de tour dans l'écran
portrait. Le résultat est le même en main.
- L'écran de jeu en portrait reste la vue par défaut, pour jouer d'une main.
## Alternatives écartées
- **Plateau tourné en portrait** : il change la lecture du plateau (numéros de
côté, camps à gauche et à droite) et risque de dérouter les débutants habitués
à une vraie table.
- **Bascule automatique quand on tourne le téléphone** : elle ne marche pas si
la rotation est verrouillée, et elle surprend en cours de partie. Elle reste
possible en plus du bouton, plus tard.
## Conséquences
- Le rendu du plateau doit s'adapter à deux mises en page (portrait et paysage)
sans changer le domaine ni le moteur : seule la vue change (ADR 0007).
- `screen.orientation.lock` n'est accepté qu'en plein écran, et pas partout :
la solution de repli par rotation CSS est obligatoire, et elle doit être
testée (`make test-browser`).
- Le plein écran masque la barre d'état du téléphone, ce qui libère encore un
peu de place.
## Amendements
### 2026-11-03 — Vue paysage dès la rotation, plateau = écran
Décidé par Pierre après son premier essai sur le téléphone, où la vue paysage
à deux colonnes (plateau limité à 70 % de la largeur, texte à droite) lui a
paru « vraiment pas optimale ».
- Dès que le téléphone est en paysage, même sans plein écran, l'app applique
la mise en page « plateau = écran » sur la hauteur réelle (`100dvh`, barre
d'adresse comprise). La mise en page à deux colonnes disparaît.
- Dans cette vue, les contrôles sont dans le plateau : comptes de points sur la
barre, videau au milieu de la barre, dés et consigne dans la bande centrale,
pictogrammes (menu, annuler, plein écran) dans la colonne des sorties
(maquette « Immersif : plein écran, contrôles dans le plateau » du canevas
ci-dessus). La géométrie du plateau suit les proportions de l'écran ; les
numéros des flèches font au moins 12 px.
- Le bouton « Plein écran » garde son rôle : plein écran, verrouillage en
paysage, repli par rotation CSS.
La bascule automatique écartée plus haut était celle qui forçait
l'orientation ; ici, seule la mise en page suit l'orientation choisie par le
joueur.
+3
View File
@@ -16,3 +16,6 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
| [0007](0007-architecture-et-conventions-de-code.md) | Architecture et conventions de code | accepté |
| [0008](0008-glossaire-et-scenarios-source-unique.md) | Glossaire et scénarios : une source unique pour le code, l'aide et les tests | accepté |
| [0009](0009-socle-pwa-typescript.md) | Socle : PWA en TypeScript, moteur en WebAssembly | accepté |
| [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté |
| [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté |
| [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté |
-73
View File
@@ -1,73 +0,0 @@
# Étape 0 — Prototype technique
**But** : prouver que la chaîne technique fonctionne avant d'investir dans le design, et trancher entre deux socles : PWA (moteur en WebAssembly) ou Android natif.
**Règle** : chaque sous-étape se termine par une démonstration que le développeur peut lancer lui-même avec une commande `make`.
---
## 0a. Le moteur en ligne de commande
**Objectif** : interroger le moteur GNU Backgammon sur une position, depuis le PC.
À faire :
1. Récupérer les sources du moteur (choisir entre gnubg, gnubg-nn ou le moteur extrait par CBG ; justifier le choix dans un ADR, `docs/adr/`).
2. Définir une **interface C minimale** du moteur (`engine/include/engine.h`, identifiants en anglais : ADR 0007), indépendante de toute interface graphique :
- initialiser le moteur (charger réseaux, base de fin de partie, table d'équité de match) ;
- lister les coups légaux pour une position et des dés ;
- donner le meilleur coup et le classement des coups avec leur équité, pour un niveau d'évaluation donné ;
- donner la décision de videau (pas de double, double/prend, double/passe, avec équités) ;
- évaluer une position (probabilités de gain, gammon, backgammon).
Les positions entrent et sortent au format **GNU Position ID + Match ID**.
3. Écrire `engine-cli`, un petit programme qui expose ces fonctions en ligne de commande, avec une sortie JSON.
4. Générer un jeu de **positions de référence** (au moins 30 : ouvertures, contact, course, sortie, décisions de videau) avec le GNU Backgammon de bureau dans Docker, et stocker les résultats attendus dans `tests/reference/`.
5. `make test` compare `engine-cli` aux références (même meilleur coup, équités à une tolérance près, à fixer).
**Démo** : `make engine && ./build/engine-cli bestmove <position_id> <match_id> 31` affiche le meilleur coup en JSON ; `make test` passe.
---
## 0b. Le test WebAssembly (décisif)
**Objectif** : savoir si le moteur tourne assez vite et léger dans le navigateur du téléphone.
À faire :
1. Compiler le même moteur et la même interface C en WebAssembly avec Emscripten (dans Docker).
2. Charger le moteur dans un **Web Worker** (jamais sur le fil d'affichage).
3. Page HTML minimale : un champ Position ID, des dés, un bouton « meilleur coup », l'affichage du résultat et du temps de calcul.
4. Rejouer les positions de référence dans le navigateur : les résultats doivent concorder avec 0a.
5. `make deploy-web` sert la page sur le réseau local pour l'ouvrir depuis le Fairphone 4.
6. `make bench` (ou une page de mesure) produit un petit rapport : temps par coup selon le niveau d'évaluation, taille téléchargée, mémoire utilisée.
**Critères de réussite (à ajuster après mesure)** :
| Mesure sur le Fairphone 4 | Seuil |
| --- | --- |
| Meilleur coup, niveau rapide (0-ply GNU) | moins de 0,3 s |
| Meilleur coup, niveau fort (2-ply GNU) | moins de 3 s |
| Décision de videau, niveau fort | moins de 3 s |
| Taille totale à télécharger (wasm + données, compressé) | moins de 40 Mo |
| Résultats identiques aux références de 0a | 100 % |
**Démo** : la page ouverte sur le Fairphone donne le meilleur coup d'une position de référence, avec le temps de calcul affiché.
---
## 0c. Android natif (seulement si 0b échoue)
**Objectif** : la même démonstration en application Android.
À faire :
1. Compiler le moteur pour arm64 avec le NDK (dans Docker), en s'appuyant sur la configuration de CBG.
2. Application Android minimale, construite en ligne de commande (Gradle sans Android Studio, `-j2`, démon Gradle limité en mémoire).
3. Même écran de test et mêmes mesures qu'en 0b.
4. `make deploy` installe l'APK sur le Fairphone via adb.
---
## 0d. Plateau minimal et décision
1. Sur le socle retenu, un **plateau minimal** : une partie complète jouable contre le moteur, coups et videau décidés par le moteur, dés tirés par l'app.
2. Rédiger un ADR (`docs/adr/`) : socle retenu (PWA ou Android natif), mesures obtenues, raisons, risques restants.
**Critère de fin de l'étape 0** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert.
+19 -15
View File
@@ -3,33 +3,37 @@
Le travail restant, dans l'ordre. Une ligne réalisée est retirée : ce qui a été
fait se lit dans les merge requests et le journal, ce qui a été décidé dans les
ADR (`docs/adr/`). Les versions et leur contenu sont définis dans
`docs/cadrage.md` ; le détail de l'étape en cours est dans `docs/etape-0.md`.
`docs/cadrage.md`.
Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
## Étape 0 — Prototype technique
## Étape 0 — Prototype technique : terminée
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
page de test, page de mesure et guide de test (0b), mesures sur le Fairphone 4 et
socle PWA en TypeScript (ADR 0009 ; 0c Android natif abandonnée), domaine
« partie » en journal d'événements avec scénario exécuté sur le vrai moteur et
garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub.
Close le 2026-11-03 : partie complète jouée par Pierre sur le Fairphone 4, coups
de l'ordinateur rapides, tous les tests au vert. Acquis : moteur gnubg officiel
(ADR 0005) comparé à 32 positions de référence, moteur en WebAssembly, socle
PWA en TypeScript (ADR 0009 ; Android natif abandonné), domaine « partie » en
journal d'événements, plateau jouable hors ligne avec la charte graphique,
publié sur https://ustagammon.sans.pub. Le détail est dans le journal et les
merge requests.
## Retours du premier test sur téléphone (avant l'étape 1)
Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0d.2 | Plateau minimal jouable (PWA hors ligne), publié ; reste la vérification avec Pierre sur le Fairphone 4 (`docs/guide-test-etape-0.md`, section « Étape 0d ») | 🔄 | |
**Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s
par coup de l'IA en niveau standard, tous les tests au vert.
| 0e.1 | Saisie du coup : pion à destination unique joué en un toucher, coup à valider et annulable demi-coup par demi-coup, dés joués marqués, dés des deux côtés touchables, videau confirmé ; pages de test du moteur retirées | 🔄 | `plans/2026-11-03-saisie-du-coup.md` |
| 0e.2 | Revue UX par un designer UI : compte de points, fluidité (bouton « Nouvelle partie », interruptions), paysage ; propositions à valider par Pierre | 🔄 | `plans/2026-11-03-revue-ux.md` |
| 0e.3 | Paysage sur téléphone : plateau sur presque tout l'écran, contrôles en pictogrammes (ADR 0012 à amender), selon la revue validée | ⏳ | |
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | |
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | |
## Étapes suivantes
| Étape | Contenu | État |
| --- | --- | --- |
| 1. V1 Jouer et comprendre | Plateau soigné, niveaux, coach, analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
| 1. V1 Jouer et comprendre | Charte graphique appliquée à la PWA (ADR 0010, `docs/identite/charte/`), plateau soigné, niveaux, conseil du maître après le coup, bilan de fin de partie, premières leçons numérotées (ADR 0011), mode immersif en paysage (ADR 0012), analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
-171
View File
@@ -1,171 +0,0 @@
# Guide de test : Ustagammon sur le Fairphone 4 (étape 0)
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
Deux tests sur le téléphone :
- **Étape 0d : jouer une partie** (section suivante), sur le site publié ;
- **Étape 0b : mesurer le moteur** (fait le 2026-11-02), sur une page
servie par le PC.
## Étape 0d : jouer une partie sur le téléphone
Le but : installer l'application sur le Fairphone 4, jouer une partie
complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et
rapporter ce que vous avez constaté. Comptez une demi-heure.
### Ouvrir et installer l'application
1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub.
2. Attendez que le plateau s'affiche et que le message « Chargement du
moteur… » laisse place à une consigne (par exemple « À vous : lancez les
dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se
télécharge (environ 7 Mo) : quelques secondes en Wi-Fi.
3. Installez l'application : menu de Chrome (les trois points), puis
« Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône
(deux pions sur du bois) apparaît sur l'écran d'accueil.
4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application
s'ouvre en plein écran, sans barre d'adresse.
### Jouer une partie complète
Suivez le scénario « Jouer une partie contre l'ordinateur »
(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de
l'application : bouton « Aide », en haut.
1. Une partie commence d'elle-même au premier lancement, par le jet
d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle
partie », en haut, en recommence une.
2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour
proposer de doubler avant de lancer).
3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases
d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un
pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup
en cours à leur place.
4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et
le message dit son coup et son temps de calcul, par exemple
« L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points
orange marquent ses cases de départ et d'arrivée (dans sa numérotation :
sa case 24 est votre case 1).
5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ».
Essayez au moins une fois de doubler vous-même.
6. En fin de partie, le message donne le gagnant, la sorte de victoire
(simple, gammon ou backgammon) et les points.
En cours de partie, fermez l'application (balayez-la dans la liste des
applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous
en étiez.
### Vérifier le hors ligne
1. Après avoir ouvert l'application au moins une fois (attendez une
vingtaine de secondes après le premier chargement, le temps que tout soit
mis de côté), fermez-la.
2. Passez le téléphone en mode avion.
3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide
doivent fonctionner comme avant.
4. Repassez en mode normal.
### Ce qu'il faut rapporter
Dans la conversation avec Claude Code :
- les **temps des coups de l'ordinateur** : ceux affichés dans les messages
(le plus long que vous avez vu), et votre ressenti (attente gênante ou
non) ;
- si la partie est allée **jusqu'au bout**, et son résultat ;
- si la **reprise** après fermeture et le **mode avion** ont marché ;
- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte
incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème
clair ou sombre du téléphone) ;
- une **capture d'écran** (bouton marche + volume bas) de tout problème.
Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans
l'installer) et dites si les temps de l'ordinateur sont nettement plus longs.
## Étape 0b : mesurer le moteur
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
calcule, puis lancer une mesure et coller son rapport dans la conversation.
Comptez un quart d'heure.
### 1. Préparer le PC
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
outils. La première fois, c'est long ; ensuite, c'est immédiat.
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
### 2. Lancer la page
1. Tapez `make deploy` puis Entrée.
2. La première fois, le moteur se compile : quelques minutes.
3. Le terminal affiche deux adresses, par exemple :
```
Application : http://192.168.1.21:8080/
Test du moteur : http://192.168.1.21:8080/moteur/
Page de mesure : http://192.168.1.21:8080/moteur/bench.html
```
Vos chiffres peuvent être différents : notez ceux de votre écran.
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
fin : Ctrl+C dans le terminal.
### 3. Essayer un meilleur coup
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement
comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi
publiées : https://ustagammon.sans.pub/moteur/ et
https://ustagammon.sans.pub/moteur/bench.html.
3. Attendez le message « Moteur prêt, chargé en … ms ».
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
5. Dans « Niveau », choisissez « fort (2 plis) ».
6. Touchez « Meilleur coup ».
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
si vous voulez, il sera aussi dans le rapport.
### 4. Lancer la mesure
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
page de mesure).
2. Touchez « Lancer la mesure ».
3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
gardez l'écran allumé et la page ouverte, sans changer d'application.
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
« dépassé » sinon.
### 5. Envoyer le rapport
1. Touchez « Copier les résultats ».
2. Si la page dit « Rapport copié », c'est bon.
3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
puis « Copier ».
4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
ou envoyez-le vous sur le PC).
Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
page de mesure) et collez aussi ce second rapport : cela montre si les temps
sont stables. Si les deux navigateurs sont installés, faites la mesure dans
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
lent, et le rapport indique le navigateur utilisé.
### Si la page ne s'ouvre pas
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
et `:8080` à la fin.
- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
pas sur la 4G ni sur un Wi-Fi invité.
- **Vérifiez que la page tourne** : sur le PC, ouvrez
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
plus, relancez `make deploy` et lisez le message d'erreur.
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
8080/tcp`.
- **« Le moteur n'a pas pu se charger »** : notez le message exact et
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
Firefox), à jour.
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
`make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
+71
View File
@@ -0,0 +1,71 @@
# Identité visuelle d'Ustagammon
Décision et historique des choix : ADR 0010 (`docs/adr/0010-identite-visuelle.md`).
Brief créatif : https://claude.ai/artifact/36WMwZWqvMxXvSSVBJTgwd
Les dessins de ce dossier sont sous licence **CC BY-SA 4.0**. Le logotype
utilise des tracés de la police **Alegreya Sans Bold** (Juan Pablo del Peral,
Huerta Tipográfica), sous licence SIL Open Font License 1.1.
## Fichiers (`logo/`)
| Fichier | Usage |
| --- | --- |
| `symbole-couleur.svg` | Symbole seul, sur fond clair |
| `symbole-sombre.svg` | Symbole seul, sur fond sombre |
| `symbole-noir.svg`, `symbole-blanc.svg` | Monochromes, fond transparent |
| `logotype-*.svg` | Le nom seul, mêmes quatre déclinaisons |
| `logo-horizontal-*.svg` | Symbole et nom côte à côte, mêmes déclinaisons |
| `icone.svg`, `icone-192.png`, `icone-512.png` | Icône de la PWA (`purpose: any`) |
| `icone-maskable.svg`, `icone-maskable-192.png`, `icone-maskable-512.png` | Icône de la PWA (`purpose: maskable`), symbole dans la zone de sécurité |
| `apple-touch-icon-180.png` | Ajout à l'écran d'accueil sur iPhone (via le navigateur) |
| `favicon.svg`, `favicon-16.png`, `favicon-32.png`, `favicon-48.png` | Onglet du navigateur ; dessin simplifié, sans filets |
| `android-premier-plan.svg`, `android-arriere-plan.svg`, `android-monochrome.svg` | Couches de l'icône adaptative Android (canevas 108 dp), pour plus tard |
## Couleurs du logo
| Rôle | Clair | Sombre |
| --- | --- | --- |
| Noyer (pion maître) | `#4A2E1F` | idem |
| Ivoire (pion apprenti, fond d'icône) | `#F3EAD8` | idem |
| Brique (flèche, filet, « mm ») | `#A8432B` | `#D0694B` (flèches du logotype), `#C9583C` (flèche du symbole) |
| Texte du logotype | `#2B1D15` | `#F3EAD8` |
## Régénérer
Les fichiers sont versionnés ; il n'est utile de les régénérer qu'en cas de
retouche du dessin. Il faut Python 3 avec fontTools (licence MIT) et la police
Alegreya Sans Bold :
```sh
mkdir -p build/police
curl -sSLf -o build/police/AlegreyaSans-Bold.ttf \
https://github.com/google/fonts/raw/main/ofl/alegreyasans/AlegreyaSans-Bold.ttf
python3 -I docs/identite/generer-logo.py build/police/AlegreyaSans-Bold.ttf docs/identite/logo
```
Les PNG sont des rendus des SVG par Chrome sans interface
(`google-chrome --headless --screenshot`), à la taille indiquée dans leur nom.
## Police de l'application
`web/fonts/` contient Alegreya Sans en cinq graisses, réduite aux caractères
latins (français compris) et convertie en woff2, soit 143 Ko au lieu de
1,3 Mo en TTF. Pour la refaire, depuis les TTF de
https://github.com/google/fonts/tree/main/ofl/alegreyasans (Python 3,
fontTools et brotli, par exemple dans l'image `emscripten/emsdk`) :
```sh
pip install fonttools brotli
for f in Regular Italic Medium Bold ExtraBold; do
python3 -m fontTools.subset AlegreyaSans-$f.ttf \
--output-file=web/fonts/AlegreyaSans-$f.woff2 --flavor=woff2 \
--unicodes="U+0020-007E,U+00A0-00FF,U+0152-0153,U+0178,U+02BC,U+02C6,U+02DC,U+2009,U+2011,U+2013-2014,U+2018-201E,U+2022,U+2026,U+202F,U+2039-203A,U+20AC,U+2192,U+2212" \
--layout-features='*' --name-IDs='*' --name-languages='*'
done
```
## Charte graphique
La charte complète (couleurs des deux thèmes, typographie, composants) est dans
`charte/`, avec les liens vers le design system et les maquettes d'écrans.
+41
View File
@@ -0,0 +1,41 @@
# Charte graphique d'Ustagammon
Copie de travail du design system, pour l'appliquer à la PWA sans dépendre
d'un accès à claude.ai. La référence vivante, avec aperçus dans les deux thèmes :
- Design system : https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M
- Maquettes des écrans (accueil, plateau, leçon, fin de partie, thème sombre) :
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe
Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
## Fichiers
| Fichier | Contenu |
| --- | --- |
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
| `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur |
| `bundle.css` | Styles des composants ; mêmes noms de classes que `web/app.css` |
| `board.js` | Dessin de référence du plateau (repère 640 × 520), pour comparaison |
## Appliquer la charte à `web/`
1. **Couleurs** : les variables du plateau de `web/app.css` (`--frame`, `--bed`,
`--point-a`, `--checker-player`…) ont les mêmes noms dans `tokens.json` ;
reprendre leurs valeurs, thème clair dans `:root`, thème sombre dans le bloc
`prefers-color-scheme: dark`. Remplacer l'accent vert provisoire par la
brique (`--accent`, `--highlight`, `--target`) et ajouter les couleurs
nouvelles (`surface`, `accent-soft`, `verdict-*`, `focus`, `last-move` en laiton).
2. **Police** : embarquer Alegreya Sans (Regular, Italic, Medium, Bold,
ExtraBold) dans `web/`, avec sa licence OFL, en `@font-face`, et l'ajouter
au cache du service worker (hors ligne).
3. **Composants** : reprendre de `bundle.css` les boutons ronds
(`radius-pill`), le filet intérieur des pions, l'anneau de focus et la zone
de consigne.
4. **Icônes et logo** : remplacer `web/icons/` par les fichiers de
`../logo/` (icônes any et maskable, favicon), mettre le logo horizontal
dans la barre haute, et la couleur `theme-color` du manifeste à `#4A2E1F`.
Les contrastes de tous les textes ont été vérifiés par calcul (4,5:1 au moins
dans les deux thèmes) ; les marques du plateau, à 3:1 contre au moins un de
leurs voisins.
+74
View File
@@ -0,0 +1,74 @@
/* Dessin de référence du plateau Ustagammon, en SVG, avec les classes de bundle.css.
* Repère : 640 × 520, comme web/app.css (.board, aspect-ratio 640 / 520).
* Numérotation du point de vue du joueur : 1 à 12 en bas (de droite à gauche),
* 13 à 24 en haut (de gauche à droite). Le joueur a les pions ivoire. */
window.UstaBoard = (function () {
var W = 640, H = 520, SIDE = 16, TB = 28, PW = 43, BAR = 40, PH = 190, R = 20;
var TRAY_X = 580, TRAY_W = 44;
function colX(col) { return SIDE + col * PW + (col >= 6 ? BAR : 0); }
function place(point) { // point 1..24 → {col, top}
return point <= 12 ? { col: 12 - point, top: false } : { col: point - 13, top: true };
}
function checker(cx, cy, side, extra) {
var c = side === "player" ? "player" : "computer";
return '<circle class="checker checker-' + c + (extra ? " " + extra : "") + '" cx="' + cx + '" cy="' + cy + '" r="' + R + '"/>' +
'<circle class="checker-ring ring-' + c + '" cx="' + cx + '" cy="' + cy + '" r="12"/>';
}
function die(x, y, n, side) {
var s = 36, p = { 1: [[.5, .5]], 2: [[.27, .27], [.73, .73]], 3: [[.27, .27], [.5, .5], [.73, .73]],
4: [[.27, .27], [.73, .27], [.27, .73], [.73, .73]], 5: [[.27, .27], [.73, .27], [.5, .5], [.27, .73], [.73, .73]],
6: [[.27, .25], [.73, .25], [.27, .5], [.73, .5], [.27, .75], [.73, .75]] }[n];
var out = '<rect class="die-' + side + '" x="' + x + '" y="' + y + '" width="' + s + '" height="' + s + '" rx="6"/>';
p.forEach(function (q) { out += '<circle class="pip-' + side + '" cx="' + (x + q[0] * s) + '" cy="' + (y + q[1] * s) + '" r="3.6"/>'; });
return out;
}
/* state = { checkers: {point: [side, n]}, selected: point, movable: [points],
* targets: [points], lastMove: [points], dice: [a, b], diceSide, cube } */
function svg(state) {
var o = '<svg class="board" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Plateau de backgammon" xmlns="http://www.w3.org/2000/svg">';
o += '<rect class="board-frame" x="0" y="0" width="' + W + '" height="' + H + '" rx="10"/>';
o += '<rect class="board-bed" x="' + SIDE + '" y="' + TB + '" width="' + (TRAY_X - 8 - SIDE) + '" height="' + (H - 2 * TB) + '"/>';
o += '<rect class="board-bar" x="' + (SIDE + 6 * PW) + '" y="' + TB + '" width="' + BAR + '" height="' + (H - 2 * TB) + '"/>';
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + TB + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + (H / 2 + 4) + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
for (var point = 1; point <= 24; point++) {
var pl = place(point), x = colX(pl.col), cls = (pl.col % 2 === 0) === pl.top ? "point-a" : "point-b";
var base = pl.top ? TB : H - TB, apex = pl.top ? TB + PH : H - TB - PH;
o += '<polygon class="' + cls + '" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
if ((state.targets || []).indexOf(point) >= 0)
o += '<polygon class="target-zone" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
if ((state.lastMove || []).indexOf(point) >= 0)
o += '<rect class="last-move" x="' + (x + 6) + '" y="' + (pl.top ? TB : H - TB - 5) + '" width="' + (PW - 12) + '" height="5" rx="2.5"/>';
o += '<text class="point-number" x="' + (x + PW / 2) + '" y="' + (pl.top ? 19 : H - 9) + '">' + point + '</text>';
var c = (state.checkers || {})[point];
if (c) {
var shown = Math.min(c[1], 5);
for (var i = 0; i < shown; i++) {
var cy = pl.top ? TB + R + 1 + i * (2 * R + 1) : H - TB - R - 1 - i * (2 * R + 1);
var extra = "";
if (i === shown - 1 && state.selected === point) extra = "selected";
else if (i === shown - 1 && (state.movable || []).indexOf(point) >= 0) extra = "movable";
o += checker(x + PW / 2, cy, c[0], extra);
if (i === shown - 1 && c[1] > 5)
o += '<text class="checker-count count-' + c[0] + '" x="' + (x + PW / 2) + '" y="' + (cy + 6) + '">' + c[1] + '</text>';
}
}
}
if (state.dice) {
var dx = colX(8) + 6, dy = H / 2 - 18, side = state.diceSide || "player";
o += die(dx, dy, state.dice[0], side) + die(dx + 46, dy, state.dice[1], side);
}
if (state.cube) {
var bx = SIDE + 6 * PW + 2;
o += '<rect class="cube-face" x="' + bx + '" y="' + (H / 2 - 18) + '" width="36" height="36" rx="6"/>';
o += '<text class="cube-value" x="' + (bx + 18) + '" y="' + (H / 2 + 6) + '">' + state.cube + '</text>';
}
return o + '</svg>';
}
var OPENING = { 24: ["player", 2], 13: ["player", 5], 8: ["player", 3], 6: ["player", 5],
1: ["computer", 2], 12: ["computer", 5], 17: ["computer", 3], 19: ["computer", 5] };
return { svg: svg, OPENING: OPENING };
})();
+165
View File
@@ -0,0 +1,165 @@
/* Ustagammon — styles des composants.
* Toutes les valeurs viennent de tokens.json (variables CSS du même nom).
* Les noms de classes reprennent ceux de web/app.css : ce fichier peut être
* recopié dans l'application presque tel quel. */
body {
margin: 0;
font-family: var(--font-sans);
font-size: 17px;
line-height: 25px;
color: var(--text);
background: var(--background);
}
a { color: var(--accent); text-underline-offset: 3px; }
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
/* ------------------------------------------------------------ Barre haute */
.topbar {
display: flex;
gap: var(--space-2);
align-items: center;
padding-block: var(--space-2);
}
.topbar .brand { flex: 1; display: flex; align-items: center; min-width: 0; }
.topbar .brand img { height: 32px; width: auto; display: block; }
.topbar h1 { flex: 1; margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
/* ---------------------------------------------------------------- Boutons */
button, .button {
min-height: var(--button-height);
padding: 0 20px;
font: inherit;
font-weight: 700;
color: var(--on-accent);
background: var(--accent);
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
cursor: pointer;
}
button.secondary, .button.secondary {
color: var(--text);
background: transparent;
border-color: var(--border-strong);
}
button.quiet, .button.quiet {
min-height: 44px;
padding: 0 12px;
color: var(--text);
background: transparent;
border-color: transparent;
font-weight: 500;
}
.topbar button { min-height: 44px; padding: 0 14px; font-size: 15px; }
button:disabled { opacity: 0.45; cursor: default; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.actions button { flex: 1; }
/* ------------------------------------------------------- Zone de consigne */
.pips { margin: var(--space-1) 0 0; font-size: 15px; line-height: 20px; color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
.info, .prompt { min-height: 25px; margin: 6px 0; }
.info { color: var(--muted); }
.prompt { font-weight: 700; }
.prompt.thinking::after { content: ""; animation: thinking 1.2s steps(4, end) infinite; }
@keyframes thinking { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }
@media (prefers-reduced-motion: reduce) { .prompt.thinking::after { animation: none; content: "…"; } }
/* ---------------------------------------------------------------- Plateau */
.board { display: block; width: 100%; height: auto; user-select: none; -webkit-tap-highlight-color: transparent; }
.board-frame { fill: var(--frame); }
.board-bed { fill: var(--bed); }
.board-bar { fill: var(--bar); }
.board-tray { fill: var(--tray); }
.point-a { fill: var(--point-a); }
.point-b { fill: var(--point-b); }
.point-number { fill: var(--point-number); font-size: 13px; font-weight: 500; text-anchor: middle; font-family: var(--font-sans); }
.checker { stroke-width: 2; }
.checker-player { fill: var(--checker-player); stroke: var(--checker-player-edge); }
.checker-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); }
.checker-ring { fill: none; stroke-width: 1.5; }
.checker-player + .checker-ring, .ring-player { stroke: var(--checker-player-edge); }
.ring-computer { stroke: var(--checker-computer-edge); }
.checker.movable { stroke: var(--highlight); stroke-width: 3; }
.checker.selected { stroke: var(--highlight); stroke-width: 6; }
.checker-count { font-size: 18px; font-weight: 700; text-anchor: middle; font-family: var(--font-sans); }
.count-player { fill: var(--count-player); }
.count-computer { fill: var(--count-computer); }
.target-zone { fill: var(--target); }
.last-move { fill: var(--last-move); }
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
.pip-player { fill: var(--pip-player); }
.pip-computer { fill: var(--pip-computer); }
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value { fill: var(--cube-text); font-size: 17px; font-weight: 800; text-anchor: middle; font-family: var(--font-sans); }
/* ------------------------------------------------------------------ Cartes */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.overline { font-size: 13px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
/* --------------------------------------------------- Appréciation d'un coup */
.verdict {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-weight: 700;
font-size: 15px;
line-height: 20px;
}
.verdict svg { width: 18px; height: 18px; flex: none; }
.verdict-good { color: var(--verdict-good); }
.verdict-doubtful { color: var(--verdict-doubtful); }
.verdict-bad { color: var(--verdict-bad); }
/* -------------------------------------------------------- Fiche de glossaire */
.glossary-card h3 { margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
.glossary-card .en { color: var(--muted); font-size: 15px; line-height: 20px; font-style: italic; }
.glossary-card p { margin: 0; }
.glossary-card .see { font-size: 15px; line-height: 20px; color: var(--muted); }
/* ------------------------------------------------ Demander à une IA (prompt) */
.ask-ai { display: flex; flex-direction: column; gap: var(--space-2); }
.ask-ai pre {
margin: 0;
max-height: 7.5em;
overflow: auto;
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
font: 15px/20px var(--font-sans);
white-space: pre-wrap;
color: var(--text);
}
.ask-ai .done { color: var(--verdict-good); font-weight: 700; font-size: 15px; }
/* ---------------------------------------------------------- Carte de leçon */
.lesson-card { flex-direction: row; align-items: center; gap: var(--space-3); }
.lesson-card .body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.lesson-card h3 { margin: 0; font-size: 17px; line-height: 25px; font-weight: 700; }
.lesson-card .state { font-size: 15px; line-height: 20px; color: var(--muted); }
.progress { height: 6px; border-radius: var(--radius-pill); background: var(--surface-sunken); overflow: hidden; }
.progress > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
+61
View File
@@ -0,0 +1,61 @@
Ustagammon apprend le backgammon comme on l'apprend à une table de café : quelqu'un qui sait montre à quelqu'un qui découvre. L'interface est **chaleureuse, artisanale, claire et intemporelle**, discrètement turque par la matière (le bois, l'ivoire, la brique), jamais par le folklore. Elle doit donner envie à un débutant sans paraître naïve à un joueur confirmé : un maître bienveillant, pas un logiciel d'analyse, pas un casino.
Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont exactement ceux des variables de `web/app.css` ; `components/bundle.css` reprend ses noms de classes et peut y être recopié.
## Contenus et ton
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
- **Ni émoji, ni point d'exclamation en série, ni majuscules de réclame.** Les capitales sont réservées au style `overline`.
- Le nom s'écrit **Ustagammon**, en un mot, majuscule initiale.
## Couleurs
La palette, c'est le bois, les pions et le café, avec **un seul accent : le rouge brique** (`accent`).
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches alternées `point-a` (noyer clair) et `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
## Typographie
Une seule famille, **Alegreya Sans** (licence SIL OFL, fichiers dans `fonts/`), sans empattement humaniste, chaleureuse et lisible en petit. À embarquer dans l'app : elle doit fonctionner hors ligne.
- `display` (32px, 800) : un seul par écran, accueil et fin de partie.
- `title` (24px) : titre d'écran ; `heading` (20px) : titre de section ou de carte.
- `body` (17px) : tout le texte à lire, jamais plus petit. `body-strong` pour la consigne du moment et les boutons. `body-italic` pour la parole du maître ou un exemple.
- `caption` (15px) : compte de points, légendes. `overline` (13px, capitales espacées) : étiquette au-dessus d'un titre, avec parcimonie.
- Chiffres alignés (`font-variant-numeric: tabular-nums`) dès que des nombres se comparent (compte de points, scores).
## Formes, espace et profondeur
- **Boutons ronds comme des pions** : `radius-pill`, hauteur `button-height` (48px). Aucune cible tactile sous 44px.
- Cartes et fiches : `radius-md`, contour `border`, ombre `shadow-raised` et rien de plus profond. Dés et videau : `radius-sm`.
- Espacements : `space-1` à `space-8`. Gouttière de page `space-4` sur téléphone ; écart entre blocs `space-6`. Contenu limité à `page-width` sur grand écran.
- Le plateau occupe toute la largeur sur téléphone ; c'est lui le héros de l'écran de jeu, pas les panneaux autour.
- Focus clavier : anneau `focus` plein de 3px, décalé de 2px, sur tous les éléments interactifs.
## Mouvement
Sobre et utile : un pion glisse vers sa flèche (150 à 250 ms, sans rebond), les dés roulent brièvement. Les trois points de « l'ordinateur réfléchit » sont la seule animation en boucle. Tout s'arrête avec `prefers-reduced-motion`.
## Iconographie
- Le **symbole** (deux pions sur une flèche, le 8 caché) et le **logotype** (les « mm » en flèches) sont dans le groupe Logos ; versions couleur, sombre, noire et blanche. Ne jamais les redessiner, déformer ou recolorer.
- Barre haute : `logo-horizontal-couleur.svg` en thème clair, `logo-horizontal-sombre.svg` en thème sombre, 32px de haut.
- Icônes d'interface : traits simples de 2px à bouts ronds, dessinés en SVG dans le code (pas de police d'icônes, pas d'émoji). Les seules icônes du système pour l'instant : les trois appréciations de coups (coche, point d'interrogation, croix) et la copie (deux feuillets).
- Dés, pions et videau sont dessinés en SVG avec les couleurs du plateau, jamais en images.
## Composants
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d'un coup (`MoveVerdict`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
+530
View File
@@ -0,0 +1,530 @@
{
"name": "Ustagammon",
"version": 1,
"color": {
"themes": [
{
"id": "light",
"name": "Clair"
},
{
"id": "dark",
"name": "Sombre"
}
],
"tokens": [
{
"name": "background",
"value": {
"light": "#F5EFE5",
"dark": "#17110D"
},
"usage": "Fond de page (papier ivoire / café torréfié)."
},
{
"name": "surface",
"value": {
"light": "#FFFCF6",
"dark": "#221913"
},
"usage": "Fond des cartes, fiches de glossaire, panneaux posés sur `background`."
},
{
"name": "surface-sunken",
"value": {
"light": "#ECE3D4",
"dark": "#2C211A"
},
"usage": "Zones en retrait : bloc de prompt à copier, piste de progression."
},
{
"name": "text",
"value": {
"light": "#2B1D15",
"dark": "#EFE5D5"
},
"usage": "Texte courant et titres, sur `background`, `surface` et `surface-sunken`."
},
{
"name": "muted",
"value": {
"light": "#6B5C50",
"dark": "#A99886"
},
"usage": "Texte secondaire (compte de points, légendes, aide), sur `background` et `surface`."
},
{
"name": "border",
"value": {
"light": "#DED2C0",
"dark": "#3A2D24"
},
"usage": "Séparateurs et contours décoratifs de cartes (ne porte aucun sens)."
},
{
"name": "border-strong",
"value": {
"light": "#8E7D6A",
"dark": "#7D6B5B"
},
"usage": "Contour des boutons secondaires et des champs : 3:1 sur `background` et `surface`."
},
{
"name": "accent",
"value": {
"light": "#A8432B",
"dark": "#D0694B"
},
"usage": "Rouge brique, l'accent unique : bouton principal, liens, case où le pion peut aller, focus. Texte en brique sur `background` et `surface`."
},
{
"name": "on-accent",
"value": {
"light": "#FFFFFF",
"dark": "#1A110C"
},
"usage": "Texte et icônes posés sur un aplat `accent`."
},
{
"name": "accent-soft",
"value": {
"light": "#F1D9CF",
"dark": "#4A2519"
},
"usage": "Fond léger d'accent : puce « à toi de jouer », sélection dans une liste. Texte `text` dessus."
},
{
"name": "focus",
"value": {
"light": "#A8432B",
"dark": "#E58A6E"
},
"usage": "Anneau de focus clavier, 3 px plein, décalé de 2 px : 3:1 sur tous les fonds."
},
{
"name": "error",
"value": {
"light": "#8C1C3A",
"dark": "#F08FA6"
},
"usage": "Message d'erreur technique (texte), toujours accompagné d'un mot. Lie-de-vin, distinct de la brique."
},
{
"name": "verdict-good",
"value": {
"light": "#2F6B4F",
"dark": "#7CCBA4"
},
"usage": "« Bon coup » : texte et icône, toujours avec le mot. Sur `background` et `surface`."
},
{
"name": "verdict-doubtful",
"value": {
"light": "#7E5A0F",
"dark": "#E0B04F"
},
"usage": "« Imprécision » (doubtful) : texte et icône, toujours avec le mot."
},
{
"name": "verdict-bad",
"value": {
"light": "#8C1C3A",
"dark": "#F08FA6"
},
"usage": "« Erreur » et « grosse erreur » (bad, very bad) : texte et icône, toujours avec le mot."
},
{
"name": "brand-noyer",
"value": {
"light": "#4A2E1F",
"dark": "#4A2E1F"
},
"usage": "Noyer du logo : pion maître. Ne sert pas au texte de l'interface."
},
{
"name": "brand-ivoire",
"value": {
"light": "#F3EAD8",
"dark": "#F3EAD8"
},
"usage": "Ivoire du logo : pion apprenti, fond de l'icône d'application."
},
{
"name": "brand-laiton",
"value": {
"light": "#8F6A1C",
"dark": "#D6A64A"
},
"usage": "Laiton, comme les charnières d'un plateau de tavla : marque du dernier coup joué, rien d'autre."
},
{
"name": "frame",
"value": {
"light": "#4A2E1F",
"dark": "#2A1A10"
},
"usage": "Cadre du plateau (noyer)."
},
{
"name": "bed",
"value": {
"light": "#E9DCC4",
"dark": "#4A3626"
},
"usage": "Fond du plateau, entre les flèches."
},
{
"name": "bar",
"value": {
"light": "#3F2619",
"dark": "#24170E"
},
"usage": "Barre centrale."
},
{
"name": "tray",
"value": {
"light": "#3A2418",
"dark": "#22150D"
},
"usage": "Plateaux de sortie, à droite."
},
{
"name": "point-a",
"value": {
"light": "#6B4430",
"dark": "#3A2519"
},
"usage": "Flèches sombres (noyer clair), une sur deux."
},
{
"name": "point-b",
"value": {
"light": "#C9AE88",
"dark": "#7E6142"
},
"usage": "Flèches claires (chêne), une sur deux."
},
{
"name": "point-number",
"value": {
"light": "#F3EAD8",
"dark": "#CDBFA8"
},
"usage": "Numéros des flèches, posés sur `frame`."
},
{
"name": "checker-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
},
"usage": "Pions du joueur : ivoire, l'apprenti."
},
{
"name": "checker-player-edge",
"value": {
"light": "#8C7A62",
"dark": "#8E7C64"
},
"usage": "Contour et filet des pions du joueur."
},
{
"name": "checker-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
},
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
},
{
"name": "checker-computer-edge",
"value": {
"light": "#120A05",
"dark": "#A8957D"
},
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
},
{
"name": "count-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
},
"usage": "Nombre écrit sur une pile de pions du joueur."
},
{
"name": "count-computer",
"value": {
"light": "#F3EAD8",
"dark": "#F3EAD8"
},
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
},
{
"name": "die-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
},
"usage": "Dés du joueur."
},
{
"name": "pip-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
},
"usage": "Points des dés du joueur."
},
{
"name": "die-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
},
"usage": "Dés de l'ordinateur."
},
{
"name": "pip-computer",
"value": {
"light": "#F3EAD8",
"dark": "#EDE3CF"
},
"usage": "Points des dés de l'ordinateur."
},
{
"name": "cube",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
},
"usage": "Face du videau."
},
{
"name": "cube-text",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
},
"usage": "Valeur du videau (2, 4, 8…)."
},
{
"name": "highlight",
"value": {
"light": "#A8432B",
"dark": "#E07A5A"
},
"usage": "Contour d'un pion jouable ou sélectionné du joueur : la brique. Il contraste à 3:1 au moins avec le pion ivoire (clair) ou avec le fond du plateau (sombre)."
},
{
"name": "target",
"value": {
"light": "rgba(168,67,43,0.32)",
"dark": "rgba(224,122,90,0.38)"
},
"usage": "Voile sur la flèche où le pion sélectionné peut aller."
},
{
"name": "last-move",
"value": {
"light": "{brand-laiton}",
"dark": "{brand-laiton}"
},
"usage": "Marque du dernier coup joué (laiton)."
}
]
},
"type": {
"fonts": [
{
"family": "Alegreya Sans",
"file": "fonts/AlegreyaSans-Regular.ttf",
"weight": "400"
},
{
"family": "Alegreya Sans",
"file": "fonts/AlegreyaSans-Italic.ttf",
"weight": "400",
"style": "italic"
},
{
"family": "Alegreya Sans",
"file": "fonts/AlegreyaSans-Medium.ttf",
"weight": "500"
},
{
"family": "Alegreya Sans",
"file": "fonts/AlegreyaSans-Bold.ttf",
"weight": "700"
},
{
"family": "Alegreya Sans",
"file": "fonts/AlegreyaSans-ExtraBold.ttf",
"weight": "800"
}
],
"families": {
"sans": "\"Alegreya Sans\", \"Gill Sans\", \"Segoe UI\", system-ui, sans-serif"
},
"groups": [
{
"name": "Titres",
"family": "sans",
"styles": [
{
"name": "display",
"fontSize": "32px",
"lineHeight": "36px",
"fontWeight": 800,
"letterSpacing": "-0.01em",
"sample": "Une partie contre le maître",
"usage": "Titre d'accueil et de fin de partie, une fois par écran."
},
{
"name": "title",
"fontSize": "24px",
"lineHeight": "30px",
"fontWeight": 700,
"sample": "Le point 8",
"usage": "Titre d'écran (leçon, aide)."
},
{
"name": "heading",
"fontSize": "20px",
"lineHeight": "26px",
"fontWeight": 700,
"sample": "Les ouvertures",
"usage": "Sous-titre de section, titre de carte."
}
]
},
{
"name": "Texte",
"family": "sans",
"styles": [
{
"name": "body",
"fontSize": "17px",
"lineHeight": "25px",
"fontWeight": 400,
"sample": "Ce pion peut rejoindre votre point 8.",
"usage": "Texte courant ; jamais en dessous de 17px pour le contenu à lire."
},
{
"name": "body-strong",
"fontSize": "17px",
"lineHeight": "25px",
"fontWeight": 700,
"sample": "À vous de jouer : 6 et 1.",
"usage": "Consigne du moment (zone prompt), boutons."
},
{
"name": "body-italic",
"fontSize": "17px",
"lineHeight": "25px",
"fontWeight": 400,
"fontStyle": "italic",
"sample": "Philippe aurait joué 13/7 8/7.",
"usage": "Parole du maître, citation, exemple."
},
{
"name": "caption",
"fontSize": "15px",
"lineHeight": "20px",
"fontWeight": 400,
"sample": "Vous : 167 · Ordinateur : 152",
"usage": "Compte de points, légendes, mentions."
},
{
"name": "overline",
"fontSize": "13px",
"lineHeight": "16px",
"fontWeight": 700,
"letterSpacing": "0.08em",
"sample": "LEÇON 3 SUR 12",
"usage": "Étiquette en capitales au-dessus d'un titre ; avec parcimonie."
}
]
}
]
},
"spacing": {
"tokens": [
{
"name": "space-1",
"value": "4px",
"usage": "Écart entre une icône et son texte."
},
{
"name": "space-2",
"value": "8px",
"usage": "Écart entre boutons voisins, marges internes serrées."
},
{
"name": "space-3",
"value": "12px",
"usage": "Marge interne des puces et du bloc prompt."
},
{
"name": "space-4",
"value": "16px",
"usage": "Gouttière latérale de page sur téléphone, marge interne des cartes."
},
{
"name": "space-6",
"value": "24px",
"usage": "Écart entre blocs d'un écran."
},
{
"name": "space-8",
"value": "32px",
"usage": "Écart entre sections."
},
{
"name": "button-height",
"value": "48px",
"usage": "Hauteur des boutons ; aucune cible tactile sous 44px."
},
{
"name": "page-width",
"value": "40rem",
"usage": "Largeur maximale du contenu sur grand écran."
}
]
},
"radius": {
"tokens": [
{
"name": "radius-sm",
"value": "6px",
"usage": "Dés, videau, puces de verdict."
},
{
"name": "radius-md",
"value": "12px",
"usage": "Cartes, fiches, bloc prompt."
},
{
"name": "radius-pill",
"value": "999px",
"usage": "Boutons : ronds comme des pions."
}
]
},
"shadow": {
"tokens": [
{
"name": "shadow-raised",
"value": {
"light": "0 1px 2px rgba(43,29,21,0.10)",
"dark": "0 1px 2px rgba(0,0,0,0.40)"
},
"usage": "Seule ombre permise : cartes posées sur le fond. Rien de plus profond."
}
]
},
"meta": {
"source": "Brief créatif + ADR 0010 + web/app.css (noms des variables du plateau)"
}
}
+192
View File
@@ -0,0 +1,192 @@
"""Génère les fichiers SVG de l'identité Ustagammon : symbole P3d, logotype L3.
Le symbole : deux pions vus de trois quarts (le maître en noyer devant,
l'apprenti en ivoire derrière) posés sur une flèche brique ; leurs deux
silhouettes dessinent un 8, clin d'œil à l'Atelier du Huit.
Le logotype : « Ustagammon » en Alegreya Sans Bold (SIL OFL), dont les deux
« m » sont redessinés en quatre flèches au trait, alternant noyer et brique.
Usage : python3 -I generer-logo.py <AlegreyaSans-Bold.ttf> <dossier_sortie>
Licence des dessins produits : CC BY-SA 4.0.
"""
import os
import sys
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
FONT, OUT = sys.argv[1], sys.argv[2]
os.makedirs(OUT, exist_ok=True)
# ---------- Palettes ----------
# d/ds/de : face, tranche et filet du pion noyer ; l/ls : face et tranche du pion ivoire ;
# o : contour du pion ivoire ; a : flèche et filet brique ; sep : liseré du pion noyer ;
# txt / acc : texte du logotype et flèches brique des « mm ».
COULEUR = dict(d="#4A2E1F", ds="#2B1A10", de="#7A5038", l="#F3EAD8", ls="#CDB898",
o="#2B1A10", a="#A8432B", sep="#F3EAD8", txt="#2B1D15", acc="#A8432B")
SOMBRE = dict(COULEUR, a="#C9583C", txt="#F3EAD8", acc="#D0694B")
# Monochromes : I = encre, H = trou (transparent)
MONO = dict(d="I", ds="I", de="H", l="H", ls="I", o="I", a="I", sep="H", txt="I", acc="I")
def symbol(p, simple=False):
"""Symbole P3d dans une boîte de 64 × 64. `simple` : sans filets, pour le favicon."""
s = [f'<polygon points="6,62 58,62 32,1" fill="{p["a"]}"/>']
ow = 3 if simple else 2.2
s.append(f'<path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="{p["ls"]}" stroke="{p["o"]}" stroke-width="{ow}" stroke-linejoin="round"/>')
s.append(f'<ellipse cx="32" cy="21" rx="10.5" ry="7" fill="{p["l"]}" stroke="{p["o"]}" stroke-width="{ow}"/>')
if not simple:
s.append(f'<ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="{p["a"]}" stroke-width="2"/>')
sw = 2.6 if simple else 2
s.append(f'<path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="{p["ds"]}" stroke="{p["sep"]}" stroke-width="{sw}" stroke-linejoin="round"/>')
s.append(f'<ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="{p["d"]}" stroke="{p["sep"]}" stroke-width="{sw}"/>')
if not simple:
s.append(f'<ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="{p["de"]}" stroke-width="1.6"/>')
return "".join(s)
# ---------- Logotype ----------
font = TTFont(FONT)
gs = font.getGlyphSet()
cmap = font.getBestCmap()
hmtx = font["hmtx"]
XH = font["OS/2"].sxHeight # hauteur d'x (465 pour Alegreya Sans Bold)
STROKE = 0.21 * XH # épaisseur du trait des flèches, proche du fût des lettres
MM_W = 3.0 * XH # largeur du bloc « mm »
GAP = 20
def glyph(ch, x, fill):
g = cmap[ord(ch)]
pen = SVGPathPen(gs)
gs[g].draw(pen)
return f'<path transform="translate({x:.1f},0) scale(1,-1)" d="{pen.getCommands()}" fill="{fill}"/>', hmtx[g][0]
def inter(p1, d1, p2, d2):
"""Intersection de deux droites données par un point et une direction."""
(x1, y1), (dx1, dy1), (x2, y2), (dx2, dy2) = p1, d1, p2, d2
det = dx1 * -dy2 - dy1 * -dx2
t = ((x2 - x1) * -dy2 - (y2 - y1) * -dx2) / det
return (x1 + t * dx1, y1 + t * dy1)
def chevron(x0, x1, top):
"""Flèche « ^ » au trait : pieds coupés sur la ligne de base (y = 0), sommet en onglet."""
peak = ((x0 + x1) / 2, -top)
h = STROKE / 2
lines = []
for a, b in (((x0, 0), peak), (peak, (x1, 0))):
dx, dy = b[0] - a[0], b[1] - a[1]
n = (dx * dx + dy * dy) ** 0.5
nx, ny = -dy / n, dx / n
lines.append(((a[0] + nx * h, a[1] + ny * h), (dx, dy), (a[0] - nx * h, a[1] - ny * h)))
(lo, ld, li), (ro, rd, ri) = lines
base = ((0, 0), (1, 0))
l_ext, l_int = (li, lo) if li[1] < lo[1] else (lo, li)
r_ext, r_int = (ri, ro) if ri[1] < ro[1] else (ro, ri)
pts = [inter(l_ext, ld, *base), inter(l_ext, ld, r_ext, rd), inter(r_ext, rd, *base),
inter(r_int, rd, *base), inter(l_int, ld, r_int, rd), inter(l_int, ld, *base)]
return " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
def logotype(p, x=0.0):
parts = []
for ch in "Ustaga":
s, adv = glyph(ch, x, p["txt"])
parts.append(s)
x += adv
x += GAP
unit = MM_W / 300 # coordonnées de la maquette : boîte de 300 × 100
top = XH * 1.06 # léger dépassement de la hauteur d'x, comme une lettre pointue
for i, (a, b) in enumerate(((8, 74), (74, 140), (160, 226), (226, 292))):
fill = p["txt"] if i % 2 == 0 else p["acc"]
parts.append(f'<polygon points="{chevron(x + a * unit, x + b * unit, top)}" fill="{fill}"/>')
x += MM_W + GAP
for ch in "on":
s, adv = glyph(ch, x, p["txt"])
parts.append(s)
x += adv
return "".join(parts), x
ASC, DESC = 680, 240 # marges verticales autour de la ligne de base
def mono_wrap(body, vb, ink, mid):
"""Transforme un dessin encre/trou en dessin d'une seule couleur, trous transparents."""
body = body.replace('"I"', '"#fff"').replace('"H"', '"#000"')
x, y, w, h = vb
return (f'<mask id="{mid}" maskUnits="userSpaceOnUse" x="{x}" y="{y}" width="{w}" height="{h}">'
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="#000"/>{body}</mask>'
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{ink}" mask="url(#{mid})"/>')
def svg(vb, body, title):
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{" ".join(f"{v:g}" for v in vb)}" '
f'width="{vb[2]:g}" height="{vb[3]:g}" role="img" aria-label="{title}"><title>{title}</title>{body}</svg>\n')
def write(name, content):
with open(os.path.join(OUT, name), "w", encoding="utf-8") as f:
f.write(content)
VARIANTS = {"couleur": COULEUR, "sombre": SOMBRE, "noir": "#000000", "blanc": "#FFFFFF"}
def palette(v):
return VARIANTS[v] if isinstance(VARIANTS[v], dict) else MONO
def ink(body, v):
return body if isinstance(VARIANTS[v], dict) else body.replace('"I"', f'"{VARIANTS[v]}"')
# ---------- Symbole ----------
for v in VARIANTS:
vb = (0, 0, 64, 64)
body = symbol(palette(v))
if not isinstance(VARIANTS[v], dict):
body = mono_wrap(body, vb, VARIANTS[v], "m")
write(f"symbole-{v}.svg", svg(vb, body, "Ustagammon"))
# ---------- Logotype seul ----------
for v in VARIANTS:
body, w = logotype(palette(v))
write(f"logotype-{v}.svg", svg((-10, -ASC, round(w + 20), ASC + DESC), ink(body, v), "Ustagammon"))
# ---------- Logo horizontal ----------
SYM = 1450 # hauteur du symbole, pour un corps de 1000
CENTER = -322 # centre optique : mi-hauteur des capitales
for v in VARIANTS:
p = palette(v)
sym = f'<g transform="translate(0,{CENTER - SYM / 2}) scale({SYM / 64:.4f})">{symbol(p)}</g>'
text, w = logotype(p, x=SYM + 280)
vb = (-20, CENTER - SYM / 2 - 20, round(w + 40), SYM + 40)
if not isinstance(VARIANTS[v], dict):
sym = mono_wrap(sym, vb, VARIANTS[v], "m")
write(f"logo-horizontal-{v}.svg", svg(vb, sym + ink(text, v), "Ustagammon"))
# ---------- Icônes ----------
IVOIRE = "#F3EAD8"
def placed(frac, size, pal=COULEUR, simple=False):
"""Symbole centré dans un carré de `size`, occupant la fraction `frac` du côté."""
s = size * frac / 64
off = (size - 64 * s) / 2
return f'<g transform="translate({off:.2f},{off:.2f}) scale({s:.4f})">{symbol(pal, simple)}</g>'
# Icône « any » : tuile ivoire aux coins arrondis, transparente autour
write("icone.svg", svg((0, 0, 512, 512), f'<rect width="512" height="512" rx="112" fill="{IVOIRE}"/>' + placed(0.70, 512), "Ustagammon"))
# Icône « maskable » : fond plein, symbole dans la zone de sécurité (cercle de 80 %)
write("icone-maskable.svg", svg((0, 0, 512, 512), f'<rect width="512" height="512" fill="{IVOIRE}"/>' + placed(0.56, 512), "Ustagammon"))
# Favicon : dessin simplifié (sans filets), traits épaissis, fond transparent
write("favicon.svg", svg((0, 0, 64, 64), symbol(COULEUR, simple=True), "Ustagammon"))
# Android, icône adaptative : canevas de 108 × 108 dp, zone sûre de 66 dp au centre
write("android-premier-plan.svg", svg((0, 0, 108, 108), placed(0.58, 108), "Ustagammon, premier plan"))
write("android-arriere-plan.svg", svg((0, 0, 108, 108), f'<rect width="108" height="108" fill="{IVOIRE}"/>', "Ustagammon, arrière-plan"))
write("android-monochrome.svg", svg((0, 0, 108, 108), mono_wrap(placed(0.58, 108, MONO), (0, 0, 108, 108), "#000000", "m"), "Ustagammon, monochrome"))
print("\n".join(sorted(os.listdir(OUT))))
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, arrière-plan"><title>Ustagammon, arrière-plan</title><rect width="108" height="108" fill="#F3EAD8"/></svg>

After

Width:  |  Height:  |  Size: 231 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, monochrome"><title>Ustagammon, monochrome</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="108" height="108"><rect x="0" y="0" width="108" height="108" fill="#000"/><g transform="translate(22.68,22.68) scale(0.9787)"><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></g></mask><rect x="0" y="0" width="108" height="108" fill="#000000" mask="url(#m)"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, premier plan"><title>Ustagammon, premier plan</title><g transform="translate(22.68,22.68) scale(0.9787)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="3" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="3"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2.6" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2.6"/></svg>

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Ustagammon"><title>Ustagammon</title><rect width="512" height="512" fill="#F3EAD8"/><g transform="translate(112.64,112.64) scale(4.4800)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>

After

Width:  |  Height:  |  Size: 933 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Ustagammon"><title>Ustagammon</title><rect width="512" height="512" rx="112" fill="#F3EAD8"/><g transform="translate(76.80,76.80) scale(5.6000)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>

After

Width:  |  Height:  |  Size: 940 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -1067 7050 1490" width="7050" height="1490" role="img" aria-label="Ustagammon"><title>Ustagammon</title><g transform="translate(0,-1047.0) scale(22.6562)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g><path transform="translate(1730.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#2B1D15"/><path transform="translate(2368.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#2B1D15"/><path transform="translate(2752.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#2B1D15"/><path transform="translate(3090.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><path transform="translate(3571.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#2B1D15"/><path transform="translate(4071.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><polygon points="4558.1,0.0 4762.6,-657.2 4967.2,0.0 4865.0,0.0 4762.6,-328.6 4660.3,0.0" fill="#2B1D15"/><polygon points="4865.0,0.0 5069.6,-657.2 5274.1,0.0 5171.9,0.0 5069.6,-328.6 4967.2,0.0" fill="#A8432B"/><polygon points="5264.9,0.0 5469.4,-657.2 5674.0,0.0 5571.8,0.0 5469.4,-328.6 5367.1,0.0" fill="#2B1D15"/><polygon points="5571.8,0.0 5776.4,-657.2 5980.9,0.0 5878.7,0.0 5776.4,-328.6 5674.0,0.0" fill="#A8432B"/><path transform="translate(5987.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#2B1D15"/><path transform="translate(6481.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#2B1D15"/></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -1067 7050 1490" width="7050" height="1490" role="img" aria-label="Ustagammon"><title>Ustagammon</title><g transform="translate(0,-1047.0) scale(22.6562)"><polygon points="6,62 58,62 32,1" fill="#C9583C"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#C9583C" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g><path transform="translate(1730.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#F3EAD8"/><path transform="translate(2368.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#F3EAD8"/><path transform="translate(2752.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#F3EAD8"/><path transform="translate(3090.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><path transform="translate(3571.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#F3EAD8"/><path transform="translate(4071.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><polygon points="4558.1,0.0 4762.6,-657.2 4967.2,0.0 4865.0,0.0 4762.6,-328.6 4660.3,0.0" fill="#F3EAD8"/><polygon points="4865.0,0.0 5069.6,-657.2 5274.1,0.0 5171.9,0.0 5069.6,-328.6 4967.2,0.0" fill="#D0694B"/><polygon points="5264.9,0.0 5469.4,-657.2 5674.0,0.0 5571.8,0.0 5469.4,-328.6 5367.1,0.0" fill="#F3EAD8"/><polygon points="5571.8,0.0 5776.4,-657.2 5980.9,0.0 5878.7,0.0 5776.4,-328.6 5674.0,0.0" fill="#D0694B"/><path transform="translate(5987.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#F3EAD8"/><path transform="translate(6481.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#F3EAD8"/></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#FFFFFF"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#FFFFFF"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#FFFFFF"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#FFFFFF"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#FFFFFF"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#FFFFFF"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#FFFFFF"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#FFFFFF"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#FFFFFF"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#FFFFFF"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#FFFFFF"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#2B1D15"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#2B1D15"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#2B1D15"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#2B1D15"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#2B1D15"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#A8432B"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#2B1D15"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#A8432B"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#2B1D15"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#2B1D15"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#000000"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#000000"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#000000"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#000000"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#000000"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#000000"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#000000"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#000000"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#000000"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#000000"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#000000"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#000000"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#F3EAD8"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#F3EAD8"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#F3EAD8"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#F3EAD8"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#F3EAD8"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#D0694B"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#F3EAD8"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#D0694B"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#F3EAD8"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#F3EAD8"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="0" y="0" width="64" height="64" fill="#000"/><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></mask><rect x="0" y="0" width="64" height="64" fill="#FFFFFF" mask="url(#m)"/></svg>

After

Width:  |  Height:  |  Size: 999 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 824 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="0" y="0" width="64" height="64" fill="#000"/><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></mask><rect x="0" y="0" width="64" height="64" fill="#000000" mask="url(#m)"/></svg>

After

Width:  |  Height:  |  Size: 999 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#C9583C"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#C9583C" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 824 B

+46
View File
@@ -89,3 +89,49 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
- Sous Firefox sur PC, un tour de l'ordinateur a pris jusqu'à 5,1 s
(Chromium : 0,4 s) ; Firefox sur le téléphone reste à mesurer.
- Validation par Pierre de Playwright (Apache-2.0), outil de `make test-browser`.
## 2026-11-03 (nuit) : identité visuelle
**Fait**
- Identité choisie par Pierre en trois tours sur une planche de travail :
accent brique, symbole « P3d » (deux pions sur une flèche, le 8 caché),
logotype « L3 » (les « mm » en flèches), icône sur fond ivoire. ADR 0010,
fichiers du logo et des icônes dans `docs/identite/logo/` (MR !12).
- Design system publié (https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M) :
couleurs des deux thèmes reprenant les noms de variables de `web/app.css`,
Alegreya Sans, onze composants, contrastes vérifiés par calcul. Copie dans
`docs/identite/charte/`.
- Maquettes des quatre écrans et du thème sombre
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe).
**Reste à faire**
- Appliquer la charte à la PWA (couleurs, police embarquée, icônes, logo) :
à planifier pour l'agent développeur, voir `docs/identite/charte/README.md`.
- Les maquettes proposent des éléments de produit qui ne sont pas encore
décidés (leçons, « Conseil du maître », bilan de fin de partie) : à valider
par Pierre avant toute mise en œuvre.
## 2026-11-03 (matin) : fin de l'étape 0, retours du test sur téléphone
**Fait**
- Pierre a joué une partie complète sur le Fairphone 4 : « très rapide de la
part de l'ordinateur ». Avec les tests au vert, le critère de fin de l'étape 0
est atteint. Le guide de test et `docs/etape-0.md`, temporaires, sont
supprimés ; leurs décisions sont dans les ADR 0005 et 0009.
- Playwright validé par Pierre (amendement de l'ADR 0004, MR !20).
- Douze retours de Pierre après l'essai, plus l'installation en un toucher et
les retours haptiques, rangés en sous-étapes 0e dans la feuille de route
(MR !20). Lancés : la saisie du coup (0e.1) et une revue UX par un designer
UI (0e.2).
- Coordination avec la session « identité » : elle propose et maquette le
design (mode immersif, compte de points, accueil, tutoriel), Pierre tranche,
la session technique implémente. Numéros des flèches à 12 px minimum.
- Décision de Pierre : la vue paysage « plateau = écran » s'applique dès la
rotation du téléphone, sans plein écran (amendement de l'ADR 0012).
- Nouvel objectif fixé par Pierre : traiter les retours 0e (les siens et ceux
du designer), puis réaliser l'étape 1, livrée avec un plan de test.
**Reste à faire**
- Le test en mode avion sur le téléphone n'a pas été rapporté ; le hors ligne
est vérifié dans Chromium par `make test-browser`.
- Firefox sur le téléphone reste à mesurer.
+37
View File
@@ -0,0 +1,37 @@
# Revue UX : compte de points, fluidité, paysage
- **Date** : 2026-11-03
- **Étape** : 0e (retours du premier test de Pierre sur le téléphone)
- **Statut** : en cours
- **Exécutant** : sous-agent general-purpose, rôle de designer UI, en lecture
seule sur le code
## Objectif
Proposer à Pierre, avec maquettes, une meilleure présentation du compte de
points et une revue de toute l'expérience de jeu (point 10 de son message du
2026-11-03), en couvrant la fluidité (ligne 0d.2+ de la feuille de route) et le
paysage sur téléphone (point 5).
## Consignes
- Partir de la charte (`docs/identite/`, ADR 0010), des ADR 0011 et 0012, et de
l'application publiée (https://ustagammon.sans.pub, ou `make site` puis les
captures de `make test-browser`).
- Ne pas modifier le dépôt. Livrer un rapport en Markdown (chemin indiqué par
la session principale) et, si utile, une page de maquettes.
- Pour chaque proposition : le problème observé, la solution, sa maquette, son
coût technique estimé, et ce qui relève d'un choix de Pierre.
- Sujets imposés : compte de points ; bouton « Nouvelle partie » et tout ce qui
interrompt la partie ; paysage sur téléphone, plateau sur presque tout
l'écran, contrôles en pictogrammes.
- Les changements de saisie du coup en cours (plan
`2026-11-03-saisie-du-coup.md`) sont acquis : ne pas les reproposer.
## Critères de réussite
Un rapport priorisé que Pierre peut valider point par point.
## Résultat
À remplir à la clôture.
+65
View File
@@ -0,0 +1,65 @@
# Saisie du coup, videau confirmé, pages de test du moteur retirées
- **Date** : 2026-11-03
- **Étape** : 0e (retours du premier test de Pierre sur le téléphone)
- **Statut** : en cours
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Rendre la saisie du coup aussi confortable que dans CBG, d'après les retours de
Pierre du 2026-11-03 (points 1, 6, 7, 8, 9 et 11 de son message), et retirer du
site les pages de test du moteur.
## Consignes
Règles de code : ADR 0007 (domaine pur, aucune règle de backgammon
réimplémentée : tout vient du moteur via le domaine). Glossaire et scénarios :
ADR 0008 (chaque comportement visible a son scénario Gherkin dans
`docs/scenarios/`, chaque nouveau terme sa fiche). Aucune nouvelle dépendance.
1. **Pages de test du moteur retirées** (« supprimer la partie test du moteur
totalement ») : plus de `/moteur/` sur le site ni dans `make deploy`.
`make bench` (mesure sous Node) reste : déplacer ce dont il a besoin hors de
`web/`. Mettre à jour `tests/test_site.sh`, le Makefile, `deploy/`, le
service worker, `docs/guide-test-etape-0.md` et ajouter un amendement daté à
l'ADR 0009.
2. **Un seul coup possible, le pion avance** : quand le joueur touche un pion
(ou une case) et qu'une seule case d'arrivée est possible pour ce pion, le
demi-coup est joué aussitôt, sans second toucher. S'il y en a plusieurs, la
sélection actuelle reste.
3. **Coup à valider, annulable** : quand tous les demi-coups sont joués (ou
qu'aucun autre n'est possible), le coup n'est pas envoyé tout de suite. Le
joueur le valide en touchant ses dés (ou un bouton « Valider »). Tant qu'il
ne l'a pas validé, « Annuler » défait le dernier demi-coup, un par un. Le
journal d'événements ne reçoit `MovePlayed` qu'à la validation.
4. **Dés joués marqués** : chaque dé déjà utilisé par un demi-coup est
visiblement estompé. Pour un double, quatre dés, marqués un par un.
5. **Dés de l'adversaire touchables** : toucher la zone des dés de l'un ou
l'autre côté du plateau lance les dés quand le joueur peut lancer (et valide
le coup en attente de validation).
6. **Confirmation du videau** : toucher le videau ou « Doubler » ouvre une
confirmation courte (« Doubler à 2 ? L'ordinateur pourra prendre ou
passer. » : Doubler / Annuler) avant d'envoyer le double.
Tests : scénarios Gherkin mis à jour ou ajoutés (validation du coup, annulation
demi-coup par demi-coup, confirmation du videau), tests unitaires, et
`tests/browser/play.mjs` adapté (Playwright est validé par Pierre). Lancer
`make lint`, `make test` et `make test-browser`.
Rapporter : ce qui a été fait, les écarts, les résultats des trois commandes,
et un brouillon de description de MR (7 sections, voir `docs/organisation.md`).
Commits fréquents sur la branche du worktree ; ne pas pousser, ne pas ouvrir de
MR : la session principale s'en charge.
## Critères de réussite
- `make lint`, `make test` et `make test-browser` passent.
- Dans le navigateur : un pion à destination unique avance en un toucher ; un
coup complet attend la validation ; « Annuler » défait un demi-coup à la
fois ; les dés joués sont estompés ; le videau demande confirmation.
- `https://…/moteur/` n'existe plus dans le site construit (`make site`).
## Résultat
À remplir à la clôture.
+24
View File
@@ -30,6 +30,30 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Quand ses dés sont tirés
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
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 ».
É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
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.
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
Quand l'ordinateur a joué
Alors son coup vous est montré case par case
Et votre pion frappé part sur la barre
Et vous pouvez revoir son coup
Scénario: Prendre le double de l'ordinateur
Étant donné que l'ordinateur vous propose de doubler
Quand vous prenez
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
# Produit les icônes PNG de l'application (web/icons/icon-192.png et
# icon-512.png) à partir des formes de web/icons/icon.svg : un fond de bois
# et deux pions. Icônes provisoires, en attendant l'identité définitive.
#
# Python seul, sans bibliothèque : chaque pixel est échantillonné 4 × 4 fois
# pour adoucir les bords. À relancer seulement si les formes changent, dans
# la chaîne C (Python 3) :
# docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w /src \
# ustagammon/build-c:2 python3 scripts/make-icons.py
#
# Copyright (C) 2026 les auteurs d'Ustagammon.
# Licence : GPLv3 ou ultérieure (voir LICENSE).
import struct
import zlib
BACKGROUND = (0x6B, 0x44, 0x26)
# Pions dessinés dans l'ordre : (centre x, centre y, rayon, remplissage,
# bord), en unités d'une icône de 512 pixels ; bord de 10 unités.
CHECKERS = [
(300, 300, 120, (0x2B, 0x22, 0x1C), (0x0D, 0x0A, 0x08)),
(212, 212, 120, (0xFB, 0xF6, 0xEA), (0x7D, 0x6A, 0x52)),
]
EDGE = 10
SAMPLES = 4
def color_at(x, y):
"""Couleur d'un point de l'icône, en unités de 512."""
color = BACKGROUND
for cx, cy, r, fill, edge in CHECKERS:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 <= (r + EDGE / 2) ** 2:
color = fill if d2 <= (r - EDGE / 2) ** 2 else edge
return color
def render(size):
scale = 512 / size
rows = []
for py in range(size):
row = bytearray([0]) # filtre PNG « aucun »
for px in range(size):
total = [0, 0, 0]
for sy in range(SAMPLES):
for sx in range(SAMPLES):
x = (px + (sx + 0.5) / SAMPLES) * scale
y = (py + (sy + 0.5) / SAMPLES) * scale
for i, c in enumerate(color_at(x, y)):
total[i] += c
row += bytes(round(t / SAMPLES**2) for t in total)
rows.append(bytes(row))
return b"".join(rows)
def png(size, pixels):
def chunk(kind, data):
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # RVB, 8 bits
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(pixels, 9))
+ chunk(b"IEND", b"")
)
for size in (192, 512):
with open(f"web/icons/icon-{size}.png", "wb") as f:
f.write(png(size, render(size)))
+154 -5
View File
@@ -101,7 +101,7 @@ async function waitForPlayer() {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["again", "take", "roll"]) {
for (const id of ["again", "take", "skip", "roll"]) {
if (usable(id)) {
return id;
}
@@ -130,6 +130,101 @@ async function waitForPlayer() {
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
}
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
function computerMoveToShow(journal) {
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
? last
: null;
}
/** Ce que montre le plateau du coup de l'ordinateur. */
async function shownTraces() {
return page.evaluate(() => ({
arrows: document.querySelectorAll(".trace-arrow").length,
landed: document.querySelectorAll(".trace-landed").length,
ghosts: document.querySelectorAll(".trace-ghost").length,
hits: document.querySelectorAll(".trace-hit").length,
replay: !document.getElementById("replay").hidden,
}));
}
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
* dernière chose jouée, et rien sinon. */
async function checkTraces() {
const journal = await savedJournal();
const move = computerMoveToShow(journal);
const traces = await shownTraces();
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
const info = await page.textContent("#info");
const blocked = await page.locator(".die-blocked").count();
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked === 2,
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
}
if (move === null) {
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
return null;
}
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
return traces;
}
/** La zone à toucher d'une action du plateau (dés ou videau) : un bouton
* accessible, d'au moins 44 px de côté. Renvoie son sélecteur. */
async function touchTarget(action, label) {
const selector = `[data-action="${action}"]`;
const zone = page.locator(selector);
check((await zone.count()) === 1, `zone ${action} absente du plateau`);
check((await zone.getAttribute("role")) === "button" &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
return selector;
}
/** Observe la page pendant ms millisecondes : le plus grand nombre
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
async function watchReplay(ms) {
let animations = 0;
let arrows = Infinity;
for (const end = Date.now() + ms; Date.now() < end; ) {
const seen = await page.evaluate(() => ({
animations: document.getAnimations().length,
arrows: document.querySelectorAll(".trace-arrow").length,
}));
animations = Math.max(animations, seen.animations);
arrows = Math.min(arrows, seen.arrows);
await new Promise((resolve) => setTimeout(resolve, 40));
}
return { animations, arrows };
}
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
* remet les mêmes traces. */
async function checkReplay(traces, reducedMotion) {
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
await page.click("#replay");
const seen = await watchReplay(300);
if (reducedMotion) {
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
} else {
check(seen.animations > 0 && seen.arrows === 0,
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
}
await waitForPlayer();
const after = await shownTraces();
check(JSON.stringify(after) === JSON.stringify(traces),
`traces différentes après Revoir : ${JSON.stringify(after)}`);
await page.emulateMedia({ reducedMotion: "no-preference" });
}
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
// capture et l'état de l'écran.
let lastStep = Date.now();
@@ -157,10 +252,30 @@ let doubled = false;
let selectionShot = false;
let reloaded = false;
let computerMs = [];
let tracesSeen = 0;
let replays = 0;
let skips = 0;
let rolls = 0;
for (let actions = 0; ; actions++) {
check(actions < 800, "la partie ne finit pas");
const todo = await waitForPlayer();
step(`partie, action ${actions}`);
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
const traces = await checkTraces();
if (traces !== null) {
tracesSeen++;
if (replays < 2) {
replays++;
await checkReplay(traces, replays === 2);
if (replays === 1) {
await shot("coup-adverse");
}
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
}
}
if (process.env.TRACE) {
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
}
@@ -172,22 +287,37 @@ for (let actions = 0; ; actions++) {
if (todo === "again") {
break;
}
if (todo === "take") {
if (todo === "skip") {
// Aucun coup possible : le jet reste affiché, barré, avec un
// message, et le tour ne passe qu'en touchant « Continuer ».
const info = await page.textContent("#info");
const blocked = await page.locator(".die-blocked").count();
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked === 2,
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
skips++;
log(`aucun coup possible : « ${info} », vous continuez`);
await page.click("#skip");
} else if (todo === "take") {
log("l'ordinateur double : vous prenez");
await shot("videau-propose");
await page.click("#take");
} else if (todo === "roll") {
if (!doubled && (await page.isEnabled("#double"))) {
doubled = true;
log("vous doublez");
await page.click("#double");
log("vous doublez en touchant le videau");
await page.click(await touchTarget("double", "Doubler"));
await waitForPlayer();
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
log(`réponse : ${answer}`);
continue;
}
await page.click("#roll");
// Une fois sur deux, en touchant les dés en attente plutôt que le
// bouton.
rolls++;
await page.click(rolls % 2 === 0 ? await touchTarget("roll", "Lancer les dés") : "#roll");
} else {
check((await page.locator("[data-action]").count()) === 0,
"dés ou videau touchables pendant que vous jouez votre coup");
await page.click(".hit.can-move");
await page.waitForSelector(".hit.can-land");
if (!selectionShot) {
@@ -223,6 +353,11 @@ log(
);
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
check(reloaded, "la reprise après rechargement n'a pas été essayée");
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau");
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, videau touché pour doubler`);
log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`);
log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie");
if (computerMs.length > 0) {
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
`${computerMs.length} tours`);
@@ -248,6 +383,20 @@ await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
step("paysage");
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
for (const [width, height] of [[915, 412], [740, 360]]) {
await page.setViewportSize({ width, height });
const board = await page.locator("#board").boundingBox();
const actions = await page.locator(".actions").boundingBox();
check(board.y + board.height <= height && board.x + board.width <= width,
`paysage ${width}x${height} : plateau hors de l'écran`);
check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`);
}
log("paysage : plateau et boutons visibles en entier");
await shot("6-paysage");
await page.setViewportSize({ width: 360, height: 740 });
step("mise en cache");
// Hors ligne : une fois le cache du service worker rempli, la page se
// recharge sans réseau, et le moteur répond.
+4 -3
View File
@@ -29,10 +29,11 @@ for f in "${fichiers[@]}"; do
if grep -q $'\r' "$f"; then
signaler "$f : fins de ligne Windows (CRLF)"
fi
# Copies fidèles de GNU Backgammon (sources et données) : on ne
# reformate pas leurs espaces, pour rester identique à l'original.
# Copies fidèles de GNU Backgammon (sources et données) et de la licence
# de la police : on ne reformate pas leurs espaces, pour rester
# identique à l'original.
case "$f" in
engine/gnubg/* | engine/data/*) continue ;;
engine/gnubg/* | engine/data/* | web/fonts/OFL.txt) continue ;;
esac
if grep -nE '[[:blank:]]+$' "$f" >/dev/null; then
signaler "$f : espaces en fin de ligne (lignes $(grep -nE '[[:blank:]]+$' "$f" | cut -d: -f1 | paste -sd, -))"
+25 -3
View File
@@ -43,8 +43,28 @@ check "app/main.js" "text/javascript"
check "app/ui/app.js" "text/javascript"
check "app/domain/game/game.js" "text/javascript"
check "manifest.webmanifest" "application/manifest\+json"
check "icons/icon-192.png" "image/png"
check "icons/icon-512.png" "image/png"
# Charte graphique (ADR 0010) : icônes et logo copiés de docs/identite/logo/,
# police Alegreya Sans et sa licence.
check "icons/favicon.svg" "image/svg\+xml"
check "icons/favicon-32.png" "image/png"
check "icons/apple-touch-icon-180.png" "image/png"
check "icons/logo-horizontal-couleur.svg" "image/svg\+xml"
check "icons/logo-horizontal-sombre.svg" "image/svg\+xml"
check "icons/symbole-couleur.svg" "image/svg\+xml"
check "icons/symbole-sombre.svg" "image/svg\+xml"
cmp -s <(curl -s "$base/icons/icone-512.png") docs/identite/logo/icone-512.png \
|| report "/icons/icone-512.png diffère de docs/identite/logo/"
for weight in Regular Italic Medium Bold ExtraBold; do
check "fonts/AlegreyaSans-$weight.woff2" "font/woff2"
done
check "fonts/OFL.txt" "text/plain"
# Chaque fichier désigné par la page (icônes, police, style) est servi.
page="$(curl -s "$base/")"
while read -r ref; do
curl -sf -o /dev/null "$base/$ref" || report "index.html : $ref introuvable"
done < <(grep -oE '(href|src|srcset)="[^"]*"' <<<"$page" | sed -E 's/^[a-z]+="(.*)"$/\1/')
grep -qF "url(\"fonts/AlegreyaSans-Regular.woff2\")" <(curl -s "$base/app.css") \
|| report "app.css : police Alegreya Sans non déclarée"
check "sw.js" "text/javascript"
check "engine-worker.mjs" "text/javascript"
check "wasm/engine.mjs" "text/javascript"
@@ -65,6 +85,8 @@ cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
# Le manifeste désigne des icônes servies, et l'application à la racine.
manifest="$(curl -s "$base/manifest.webmanifest")"
grep -q '"start_url": "./"' <<<"$manifest" || report "manifeste : start_url absent"
grep -q '"theme_color": "#4A2E1F"' <<<"$manifest" || report "manifeste : theme_color autre que le noyer"
grep -q '"purpose": "maskable"' <<<"$manifest" || report "manifeste : pas d'icône maskable"
while read -r icon; do
curl -sf -o /dev/null "$base/$icon" || report "manifeste : icône $icon introuvable"
done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
@@ -73,7 +95,7 @@ done < <(grep -o '"src": "[^"]*"' <<<"$manifest" | sed 's/"src": "\(.*\)"/\1/')
sw="$(curl -s "$base/sw.js")"
grep -qE '^const VERSION = "[0-9a-f]{16}";' <<<"$sw" || report "sw.js : version absente"
for file in ./ index.html app/main.js wasm/engine.data wasm/engine.wasm moteur/ \
docs/glossaire/videau.md version.json; do
docs/glossaire/videau.md version.json fonts/AlegreyaSans-Bold.woff2 icons/favicon.svg; do
grep -qF " \"$file\"," <<<"$sw" || report "sw.js : $file absent de la liste à mettre en cache"
done
listed="$(grep -c '^ "' <<<"$sw")"
+466 -97
View File
@@ -1,81 +1,191 @@
/* Style de l'application. Palette provisoire et sobre (bois, ivoire, un
* accent vert), en attendant l'identité définitive : toutes les couleurs et
* tailles sont des variables, redéfinies pour le thème sombre. */
/* Style de l'application, selon la charte graphique (ADR 0010,
* docs/identite/charte/) : les couleurs, tailles et rayons sont les
* variables de tokens.json, sous le même nom, redéfinies pour le thème
* sombre ; les composants reprennent bundle.css. */
/* ------------------------------------------------------------ Police */
/* Alegreya Sans (SIL Open Font License 1.1, voir fonts/OFL.txt), réduite
* aux caractères latins et convertie en woff2 (voir docs/identite/README.md).
* Embarquée dans le site, donc dans le cache du service worker : elle
* fonctionne hors ligne. */
@font-face {
font-family: "Alegreya Sans";
src: url("fonts/AlegreyaSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Alegreya Sans";
src: url("fonts/AlegreyaSans-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Alegreya Sans";
src: url("fonts/AlegreyaSans-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Alegreya Sans";
src: url("fonts/AlegreyaSans-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Alegreya Sans";
src: url("fonts/AlegreyaSans-ExtraBold.woff2") format("woff2");
font-weight: 800;
font-style: normal;
font-display: swap;
}
/* ------------------------------------------------------------ Couleurs */
:root {
--text: #2b211a;
--muted: #6b5d50;
--background: #f7f2e8;
--accent: #2e6b4f;
--background: #f5efe5;
--surface: #fffcf6;
--surface-sunken: #ece3d4;
--text: #2b1d15;
--muted: #6b5c50;
--border: #ded2c0;
--border-strong: #8e7d6a;
--accent: #a8432b;
--on-accent: #ffffff;
--border: #cbbfae;
--error: #a3261b;
--accent-soft: #f1d9cf;
--focus: #a8432b;
--error: #8c1c3a;
--verdict-good: #2f6b4f;
--verdict-doubtful: #7e5a0f;
--verdict-bad: #8c1c3a;
--brand-noyer: #4a2e1f;
--brand-ivoire: #f3ead8;
--brand-laiton: #8f6a1c;
--frame: #6b4426;
--bed: #e6cfa6;
--bar: #5c3a20;
--tray: #57371e;
--point-a: #9a6439;
--point-b: #c79b69;
--point-number: #f3e7d0;
--checker-player: #fbf6ea;
--checker-player-edge: #7d6a52;
--checker-computer: #2b221c;
--checker-computer-edge: #0d0a08;
--count-player: #2b211a;
--count-computer: #fbf6ea;
--die-player: #fbf6ea;
--pip-player: #2b211a;
--die-computer: #2b221c;
--pip-computer: #fbf6ea;
--cube: #fbf6ea;
--cube-text: #2b211a;
--highlight: #2e6b4f;
--target: rgba(46, 107, 79, 0.35);
--last-move: #d9822b;
--frame: #4a2e1f;
--bed: #e9dcc4;
--bar: #3f2619;
--tray: #3a2418;
--point-a: #6b4430;
--point-b: #c9ae88;
--point-number: #f3ead8;
--checker-player: #f7f0e2;
--checker-player-edge: #8c7a62;
--checker-computer: #2e1c12;
--checker-computer-edge: #120a05;
--count-player: #2b1d15;
--count-computer: #f3ead8;
--die-player: #f7f0e2;
--pip-player: #2b1d15;
--die-computer: #2e1c12;
--pip-computer: #f3ead8;
--cube: #f7f0e2;
--cube-text: #2b1d15;
--highlight: #a8432b;
--target: rgba(168, 67, 43, 0.32);
/* Dernier coup joué (marques et traces) : le laiton, rien d'autre. */
--last-move: var(--brand-laiton);
--font-size: 17px;
/* Numéros des flèches. La charte les pose sur le cadre (point-number,
* ivoire) ; notre plateau les pose sur le fond, où l'ivoire ne se lirait
* pas : texte secondaire en thème clair (4,7:1 sur bed), point-number en
* thème sombre (6,3:1). */
--point-label: var(--muted);
--shadow-raised: 0 1px 2px rgba(43, 29, 21, 0.1);
--font-sans: "Alegreya Sans", "Gill Sans", "Segoe UI", system-ui, sans-serif;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--button-height: 48px;
--radius: 8px;
--page-width: 40rem;
--radius-sm: 6px;
--radius-md: 12px;
--radius-pill: 999px;
/* Hauteur de la barre haute : boutons de 44 px et marges. */
--topbar-height: 60px;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #ece4d6;
--muted: #b3a796;
--background: #1b1714;
--accent: #6fc3a0;
--on-accent: #10221a;
--border: #4d443b;
--error: #f08a7e;
--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: #3a2616;
--bed: #6d5136;
--bar: #2f1f12;
--tray: #2c1d11;
--point-a: #4a3120;
--point-b: #8b6943;
--point-number: #d9cbb4;
--checker-player: #efe6d2;
--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: #17110d;
--checker-computer: #140d09;
--checker-computer-edge: #a8957d;
--highlight: #7fd6b0;
--target: rgba(127, 214, 176, 0.35);
--last-move: #f0a050;
--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);
}
}
/* -------------------------------------------------------------- Page */
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0 auto;
max-width: var(--page-width);
padding: 0 0.5rem 1rem;
font: var(--font-size) / 1.45 system-ui, sans-serif;
padding: 0 var(--space-4) var(--space-6);
font-family: var(--font-sans);
font-size: 17px;
line-height: 25px;
color: var(--text);
background: var(--background);
}
@@ -84,78 +194,148 @@ body {
display: none !important;
}
a {
color: var(--accent);
text-underline-offset: 3px;
}
/* Anneau de focus clavier, sur tout élément interactif. */
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
/* -------------------------------------------------------- Barre haute */
.topbar {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
align-items: center;
padding: 0.5rem 0;
min-height: var(--topbar-height);
padding-block: var(--space-2);
}
.topbar h1 {
.topbar .brand {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
margin: 0;
font-size: 1.25rem;
}
.topbar .brand img {
display: block;
width: auto;
height: 32px;
}
/* ------------------------------------------------------------ Boutons */
/* Ronds comme des pions ; un seul bouton principal (brique) par écran,
* les autres en contour. */
button {
min-height: var(--button-height);
padding: 0 1rem;
padding: 0 20px;
font: inherit;
font-weight: 600;
font-weight: 700;
color: var(--on-accent);
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius);
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
cursor: pointer;
/* Pas de délai ni de zoom sur un double toucher. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
button.secondary {
color: var(--text);
background: transparent;
border-color: var(--border);
border-color: var(--border-strong);
}
.topbar button {
min-height: 40px;
padding: 0 0.75rem;
font-size: 0.9rem;
@media (hover: hover) {
button.secondary:hover:not(:disabled) {
background: var(--surface-sunken);
}
}
/* Retour visuel au toucher. */
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.45;
cursor: default;
}
.topbar button {
min-height: 44px;
padding: 0 14px;
font-size: 15px;
line-height: 20px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: var(--space-2);
}
.actions button {
flex: 1;
flex: 1 1 auto;
}
/* --------------------------------------------- Messages sous le plateau */
/* Compte de points : information secondaire, discrète. */
.pips {
margin: 0.25rem 0 0;
font-size: 0.85rem;
margin: var(--space-2) 0 0;
font-size: 15px;
line-height: 20px;
color: var(--muted);
text-align: center;
font-variant-numeric: tabular-nums lining-nums;
}
.info,
.prompt {
min-height: 1.45em;
margin: 0.4rem 0;
}
/* 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;
color: var(--muted);
}
.prompt {
font-weight: 600;
.info:empty {
margin-top: 0;
}
/* L'ordinateur réfléchit : trois points qui apparaissent l'un après l'autre. */
/* 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. */
.prompt {
min-height: calc(25px + 2 * var(--space-3));
margin: var(--space-3) 0;
padding: var(--space-3) var(--space-4);
font-weight: 700;
background: var(--accent-soft);
border-radius: var(--radius-md);
}
.prompt.thinking,
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 {
content: "";
animation: thinking 1.2s steps(4, end) infinite;
@@ -168,6 +348,17 @@ button:disabled {
75% { content: "..."; }
}
@media (prefers-reduced-motion: reduce) {
.prompt.thinking::after {
animation: none;
content: "…";
}
button:active:not(:disabled) {
transform: none;
}
}
/* ------------------------------------------------------------ Plateau */
.board {
@@ -176,7 +367,23 @@ button:disabled {
height: auto;
aspect-ratio: 640 / 520;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
font-family: var(--font-sans);
/* Alegreya Sans dessine par défaut des chiffres elzéviriens (de hauteurs
* variables) : sur le plateau, des chiffres alignés se lisent mieux. */
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); }
@@ -187,8 +394,9 @@ button:disabled {
.point-b { fill: var(--point-b); }
.point-number {
fill: var(--muted);
fill: var(--point-label);
font-size: 13px;
font-weight: 500;
text-anchor: middle;
}
@@ -206,14 +414,24 @@ button:disabled {
stroke: var(--checker-computer-edge);
}
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
* le dessine. */
.checker-ring {
fill: none;
stroke-width: 1.5;
}
.ring-player { stroke: var(--checker-player-edge); }
.ring-computer { stroke: var(--checker-computer-edge); }
.checker.movable {
stroke: var(--highlight);
stroke-width: 4;
stroke-width: 3;
}
.checker.selected {
stroke: var(--highlight);
stroke-width: 7;
stroke-width: 6;
}
.checker-count {
@@ -226,19 +444,67 @@ button:disabled {
.count-computer { fill: var(--count-computer); }
.target-zone { fill: var(--target); }
.last-move { fill: var(--last-move); }
/* Le dernier coup de l'ordinateur (couleur laiton, charte) : une flèche par
* demi-coup, les pions arrivés et frappés entourés, un pion fantôme là où
* un pion est parti. */
.trace-arrow {
stroke: var(--last-move);
stroke-width: 5;
stroke-linecap: round;
opacity: 0.85;
}
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); }
.trace-head { fill: var(--last-move); }
.trace-landed,
.trace-hit {
fill: none;
stroke: var(--last-move);
stroke-width: 4;
}
.trace-hit { stroke-dasharray: 6 4; }
.trace-ghost {
fill: none;
stroke: var(--last-move);
stroke-width: 2.5;
stroke-dasharray: 5 4;
}
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
.pip-player { fill: var(--pip-player); }
.pip-computer { fill: var(--pip-computer); }
/* Dés du joueur en attente d'être lancés, et videau quand il peut doubler :
* ils se touchent comme les boutons. */
.die-waiting rect {
stroke: var(--highlight);
stroke-width: 2.5;
stroke-dasharray: 5 4;
}
.cube.can-double .cube-face {
stroke: var(--highlight);
stroke-width: 3;
}
/* Jet sans coup possible : dés estompés et barrés. */
.die-blocked { opacity: 0.5; }
.die-strike {
stroke: var(--text);
stroke-width: 3;
stroke-linecap: round;
}
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
fill: var(--cube-text);
font-size: 17px;
font-weight: 700;
font-weight: 800;
text-anchor: middle;
}
@@ -249,35 +515,138 @@ button:disabled {
/* --------------------------------------------------------------- Aide */
.help {
max-width: var(--page-width);
margin: 0 auto;
}
.help h2 {
margin-top: 1.5rem;
font-size: 1.2rem;
margin: var(--space-8) 0 var(--space-2);
font-size: 24px;
line-height: 30px;
font-weight: 700;
}
.help h3 {
margin-bottom: 0.25rem;
font-size: 1rem;
margin: var(--space-6) 0 var(--space-1);
font-size: 20px;
line-height: 26px;
font-weight: 700;
}
.help p {
margin: var(--space-2) 0;
}
.steps {
padding-left: 1.25rem;
margin: var(--space-2) 0;
padding-left: var(--space-6);
}
.steps li + li {
margin-top: var(--space-1);
}
.steps li::marker {
color: var(--muted);
}
/* Glossaire : une liste de définitions, sur une carte. */
.glossary {
margin: var(--space-3) 0 0;
padding: var(--space-1) var(--space-4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
}
.glossary dt {
margin-top: 0.6rem;
font-weight: 600;
padding-top: var(--space-3);
font-weight: 700;
}
.glossary dd {
margin: 0;
padding-bottom: var(--space-3);
}
.glossary dd + dt {
border-top: 1px solid var(--border);
}
.about {
margin-top: 2rem;
font-size: 0.85rem;
margin-top: var(--space-8);
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
/* Une cible tactile confortable pour le lien. */
.about a {
color: var(--accent);
display: inline-block;
padding-block: 12px;
}
/* ------------------------------------------------------- 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;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2013 The Alegreya Sans Project Authors (https://github.com/huertatipografica/Alegreya-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Icône provisoire (pas un logo) : deux pions sur du bois. Les PNG sont
produits à partir des mêmes formes par scripts/make-icons.py. -->
<rect width="512" height="512" fill="#6b4426"/>
<circle cx="300" cy="300" r="120" fill="#2b221c" stroke="#0d0a08" stroke-width="10"/>
<circle cx="212" cy="212" r="120" fill="#fbf6ea" stroke="#7d6a52" stroke-width="10"/>
</svg>

Before

Width:  |  Height:  |  Size: 449 B

+20 -4
View File
@@ -4,17 +4,31 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#6b4426">
<meta name="theme-color" content="#4A2E1F">
<title>Ustagammon</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="icon" href="icons/favicon.svg" type="image/svg+xml">
<link rel="icon" href="icons/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="icons/apple-touch-icon-180.png">
<link rel="preload" href="fonts/AlegreyaSans-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="fonts/AlegreyaSans-Bold.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="app.css">
<script type="module" src="app/main.js"></script>
</head>
<body>
<body class="busy">
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
<header class="topbar">
<h1>Ustagammon</h1>
<!-- 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. -->
<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>
</h1>
<button id="help-button" type="button" class="secondary"></button>
<button id="new-game" type="button" class="secondary"></button>
</header>
@@ -30,6 +44,8 @@
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" 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>
</div>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
+8 -7
View File
@@ -6,13 +6,14 @@
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f7f2e8",
"theme_color": "#6b4426",
"orientation": "any",
"background_color": "#F5EFE5",
"theme_color": "#4A2E1F",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
{ "src": "icons/icone-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icone-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icone-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icone-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icone.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}
+1 -1
View File
@@ -7,7 +7,7 @@
// Sur les positions de référence (tests/reference/positions.json) : temps du
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
// de l'étape 0b (docs/etape-0.md).
// de l'étape 0b (ADR 0009).
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
// toutes les positions, est en dessous (la médiane est donnée en plus).