diff --git a/app/src/main.ts b/app/src/main.ts index f41cfd6..05fa035 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -5,120 +5,73 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). -import { switchToBranch } from "./domain/game/branches.js"; -import type { Level } from "./domain/game/engine.js"; import { Game } from "./domain/game/game.js"; import { exportSgf } from "./domain/game/sgf.js"; import { createWasmEngine } from "./engine/wasm-engine.js"; import { startEngineWorker } from "./engine/worker-run.js"; -import { newGameId } from "./storage/game-library.js"; -import { openGameLibrary } from "./storage/open-library.js"; +import { createGameSession } from "./storage/game-session.js"; +import { memoryStorage, openGameLibrary } from "./storage/open-library.js"; +import { createSettingsStore } from "./storage/settings-store.js"; import { startApp } from "./ui/app.js"; -import { GAMES_TEXTS } from "./ui/games-texts.js"; -import { createGamesView } from "./ui/games-view.js"; // Ce fichier est servi sous app/ ; le Worker et le service worker sont à la // racine du site. const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url)); const engine = createWasmEngine(worker.run); -// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque -// accès au stockage du navigateur peut lever une exception (stockage +// Chaque accès au stockage du navigateur peut lever une exception (stockage // interdit) : on fait alors sans. +const localStorage = attempt(() => window.localStorage); +const settings = createSettingsStore(localStorage ?? memoryStorage()); + +// Les parties enregistrées (IndexedDB, ou localStorage en repli). const { library } = await openGameLibrary({ indexedDB: attempt(() => window.indexedDB), - localStorage: attempt(() => window.localStorage), + localStorage, }); -/** Le niveau de l'ordinateur (celui de Game.start, STANDARD_PLY). */ -const LEVEL: Level = "2ply"; -const saved = await library.current().catch(() => null); -// La partie à l'écran ; un nouvel identifiant à chaque nouvelle partie. -let shownId = saved?.summary.id ?? newGameId(); -// Vrai une fois qu'on recharge la page pour reprendre une autre partie : -// plus rien n'est enregistré sous l'identifiant de celle qu'on quitte. -let leaving = false; +// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY). +const session = createGameSession( + library, + { start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) }, + "2ply", +); +const saved = await session.restore(); startApp({ engineReady: worker.ready, - newGame: () => { - shownId = newGameId(); - return Game.start(engine); + newGame: () => session.newGame(), + savedGame: () => saved, + resumePrevious: () => session.resumePrevious(), + saveGame: (journal) => session.save(journal), + games: { + list: () => library.list(), + journal: async (id) => (await library.load(id))?.journal ?? null, + shownId: () => session.shownId, + open: (id, branchHead) => session.open(id, branchHead), + remove: (id) => library.remove(id), + exportSgf: async (id) => { + const game = await library.load(id); + if (game === null) { + throw new Error("partie introuvable"); + } + const started = new Date(game.summary.startedAt); + const two = (n: number) => String(n).padStart(2, "0"); + const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`; + const text = await exportSgf(engine, game.journal, { + playerName: "Vous", + computerName: "Ustagammon", + date, + }); + const time = `${two(started.getHours())}${two(started.getMinutes())}`; + return { fileName: `ustagammon-${date}-${time}.sgf`, text }; + }, }, - savedGame: () => { - if (saved === null) { - return null; - } - try { - return Game.resume(engine, saved.journal); - } catch { - return null; // partie illisible : on en commence une nouvelle - } - }, - saveGame: (journal) => { - if (!leaving) { - // Stockage plein ou interdit : la partie continue, elle ne - // pourra seulement pas être reprise. - library.save(shownId, journal, { level: LEVEL }).catch(() => {}); - } - }, - confirm: (question) => window.confirm(question), + cachedBoard: () => settings.loadBoard(), + saveBoard: (board) => settings.saveBoard(board), + loadSettings: () => settings.loadSettings(), + saveSettings: (json) => settings.saveSettings(json), }); -// « Mes parties ». Point d'accroche provisoire : un bouton ajouté devant -// celui de l'aide, en attendant le menu de l'application (révision UX). -// Reprendre une partie la désigne comme partie en cours, puis recharge la -// page, qui l'ouvre. -const gamesView = createGamesView({ - list: () => library.list(), - journal: async (id) => (await library.load(id))?.journal ?? null, - shownId: () => shownId, - resume: async (id, branchHead) => { - const game = await library.load(id); - if (game === null) { - throw new Error("partie introuvable"); - } - leaving = true; - if (branchHead === undefined) { - await library.setCurrent(id); - } else { - const journal = switchToBranch(game.journal, branchHead); - await library.save(id, journal, { level: game.summary.level }); - } - window.location.reload(); - }, - remove: (id) => library.remove(id), - exportSgf: async (id) => { - const game = await library.load(id); - if (game === null) { - throw new Error("partie introuvable"); - } - const started = new Date(game.summary.startedAt); - const date = [started.getFullYear(), started.getMonth() + 1, started.getDate()] - .map((n) => String(n).padStart(2, "0")) - .join("-"); - const time = [started.getHours(), started.getMinutes()] - .map((n) => String(n).padStart(2, "0")) - .join(""); - const text = await exportSgf(engine, game.journal, { - playerName: "Vous", - computerName: "Ustagammon", - date, - }); - return { fileName: `ustagammon-${date}-${time}.sgf`, text }; - }, - confirm: (question) => window.confirm(question), -}); -const helpButton = document.getElementById("help-button"); -if (helpButton !== null) { - const gamesButton = document.createElement("button"); - gamesButton.id = "games-button"; - gamesButton.type = "button"; - gamesButton.className = "secondary"; - gamesButton.textContent = GAMES_TEXTS.button; - gamesButton.onclick = () => void gamesView.open(); - helpButton.before(gamesButton); -} - // Hors ligne : le service worker met tout le site en cache. Le navigateur le // refuse hors https (sauf localhost) : l'application marche alors sans. // Il est enregistré une fois le moteur chargé : si le Worker et le service diff --git a/app/src/storage/game-session.ts b/app/src/storage/game-session.ts new file mode 100644 index 0000000..26b6c4f --- /dev/null +++ b/app/src/storage/game-session.ts @@ -0,0 +1,130 @@ +// La partie à l'écran et son enregistrement : quel identifiant elle porte, +// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la +// reprise d'une partie enregistrée ou de l'une de ses branches. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle +// quelle, sur un stockage en mémoire. + +import { switchToBranch } from "../domain/game/branches.js"; +import type { Level } from "../domain/game/engine.js"; +import type { GameJournal } from "../domain/game/events.js"; +import type { Game } from "../domain/game/game.js"; +import { type GameLibrary, newGameId } from "./game-library.js"; + +/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si + * le journal est illisible). */ +export interface GameFactory { + readonly start: () => Game; + readonly resume: (journal: GameJournal) => Game; +} + +export interface GameSession { + /** L'identifiant de la partie à l'écran (null avant la première). */ + readonly shownId: string | null; + /** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou + * si elle est illisible. */ + restore(): Promise; + /** Une nouvelle partie ; celle qu'elle remplace reste reprenable par + * resumePrevious, jusqu'au prochain changement de partie. */ + newGame(): Game; + /** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ; + * un échec est ignoré : la partie continue). */ + save(journal: GameJournal): void; + /** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans + * le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */ + resumePrevious(): Promise; + /** Reprend une partie enregistrée, au bout de la branche branchHead si + * elle est donnée. Erreur si elle n'existe pas ou est illisible. */ + open(id: string, branchHead?: string): Promise; +} + +export function createGameSession( + library: GameLibrary, + games: GameFactory, + level: Level, +): GameSession { + let shownId: string | null = null; + let previousId: string | null = null; + + /** Une partie enregistrée, reprise ; null si absente ou illisible. */ + const resume = async (id: string): Promise => { + const saved = await library.load(id); + if (saved === null) { + return null; + } + try { + return games.resume(saved.journal); + } catch { + return null; + } + }; + + return { + get shownId() { + return shownId; + }, + async restore() { + const saved = await library.current().catch(() => null); + if (saved === null) { + return null; + } + try { + const game = games.resume(saved.journal); + shownId = saved.summary.id; + return game; + } catch { + return null; // partie illisible : on en commencera une nouvelle + } + }, + newGame() { + previousId = shownId; + shownId = newGameId(); + return games.start(); + }, + save(journal) { + if (shownId !== null) { + library.save(shownId, journal, { level }).catch(() => {}); + } + }, + async resumePrevious() { + const id = previousId; + if (id === null) { + return null; + } + const resumed = await resume(id); + if (resumed === null) { + return null; + } + const abandoned = shownId; + previousId = null; + shownId = id; + await library.setCurrent(id); + if (abandoned !== null) { + await library.remove(abandoned); + } + return resumed; + }, + async open(id, branchHead) { + const saved = await library.load(id); + if (saved === null) { + throw new Error("partie introuvable"); + } + const journal = + branchHead === undefined + ? saved.journal + : switchToBranch(saved.journal, branchHead); + const game = games.resume(journal); + previousId = null; + shownId = id; + if (branchHead === undefined) { + await library.setCurrent(id); + } else { + await library.save(id, journal, { level: saved.summary.level }); + } + return game; + }, + }; +} diff --git a/app/src/storage/settings-store.ts b/app/src/storage/settings-store.ts new file mode 100644 index 0000000..c31afaf --- /dev/null +++ b/app/src/storage/settings-store.ts @@ -0,0 +1,83 @@ +// Ce que l'application garde en plus de la partie, dans le même stockage +// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier +// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que +// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { Cube } from "../domain/game/cube.js"; +import type { SideBoards } from "../domain/game/game.js"; +import type { KeyValueStorage } from "./key-value-backend.js"; + +const SETTINGS_KEY = "ustagammon.settings"; +const BOARD_KEY = "ustagammon.last-board"; + +/** Le dernier plateau affiché, tel que le moteur l'a décodé. */ +export interface CachedBoard { + readonly sides: SideBoards; + readonly cube: Cube; +} + +export interface SettingsStore { + /** Le texte JSON des réglages (null : jamais enregistrés). */ + loadSettings(): string | null; + saveSettings(json: string): void; + loadBoard(): CachedBoard | null; + saveBoard(board: CachedBoard): void; +} + +function isSide(value: unknown): boolean { + const side = value as Record | null; + return ( + side !== null && + typeof side === "object" && + Array.isArray(side.points) && + side.points.length === 24 && + side.points.every((n) => Number.isInteger(n) && n >= 0) && + Number.isInteger(side.bar) && + Number.isInteger(side.off) && + Number.isInteger(side.pipCount) + ); +} + +export function createSettingsStore( + storage: Pick, +): SettingsStore { + const write = (key: string, value: string) => { + try { + storage.setItem(key, value); + } catch { + // Stockage plein ou interdit : l'application marche sans. + } + }; + return { + loadSettings() { + try { + return storage.getItem(SETTINGS_KEY); + } catch { + return null; + } + }, + saveSettings(json) { + write(SETTINGS_KEY, json); + }, + loadBoard() { + try { + const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null"); + const valid = + board !== null && + isSide(board.sides?.player) && + isSide(board.sides?.computer) && + Number.isInteger(board.cube?.value) && + [null, "player", "computer"].includes(board.cube?.owner); + return valid ? board : null; + } catch { + return null; + } + }, + saveBoard(board) { + write(BOARD_KEY, JSON.stringify(board)); + }, + }; +} diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 445a478..b5917c8 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1,5 +1,5 @@ -// L'écran de jeu : le plateau, les boutons et les messages, branchés sur une -// partie du domaine. +// L'écran de jeu : le plateau, les bandeaux, les boutons, le menu et les +// messages, branchés sur une partie du domaine. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -7,21 +7,32 @@ // Tout passe par la partie (Game) : l'interface ne connaît ni le moteur ni le // stockage, qu'elle reçoit déjà branchés (main.ts). Après chaque action du // joueur, la partie est enregistrée, puis l'ordinateur joue tant que c'est -// son tour ; le plateau est ensuite redessiné à partir de l'état de la partie. +// son tour ; l'écran est ensuite redessiné à partir de l'état de la partie. +// +// Mises en page (docs/identite/charte/interaction.md) : en portrait, des +// zones de hauteur fixe (barre haute, bandeaux, plateau, ligne +// d'information, consigne, barre d'actions) ; en paysage, le plateau est +// l'écran et porte ses commandes (ADR 0012). Toutes les actions passent par +// une même table (actions), qu'on les déclenche au toucher ou au clavier. +import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; +import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; import { boardModel, moveTargets, moveTrace, + OPENING_BOARDS, onlyHalfMove, type Place, + pipLead, placeOf, } from "./board-model.js"; import { + type BoardDecor, checkerSpot, type DiceAction, NO_MARKS, @@ -29,11 +40,29 @@ import { type ShownDie, slideChecker, } from "./board-view.js"; -import { showHelp } from "./help.js"; -import { TEXTS } from "./texts.js"; +import { GAMES_TEXTS } from "./games-texts.js"; +import { createGamesView, type GamesView } from "./games-view.js"; +import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js"; +import { createHaptics, type Vibrate } from "./haptics.js"; +import { loadGlossaryEntry, showHelp } from "./help.js"; +import { icon } from "./icons.js"; +import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js"; +import { type PromptText, TEXTS } from "./texts.js"; -/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */ -const SLIDE_MS = 400; +/** Glissement d'un pion de l'ordinateur, et pause entre deux pions + * (interaction.md : on suit le coup sans l'attendre). */ +const SLIDE_MS = 160; +const PAUSE_MS = 120; +/** Durée du bandeau « Reprendre l'ancienne ». */ +export const BANNER_MS = 10_000; +/** Attente au plus de la réponse à une demande de plein écran. */ +const FULLSCREEN_WAIT_MS = 1000; + +/** Le dernier plateau affiché, gardé pour le redessiner dès l'ouverture. */ +export interface CachedBoard { + readonly sides: SideBoards; + readonly cube: Cube; +} export interface AppOptions { /** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */ @@ -42,8 +71,32 @@ export interface AppOptions { readonly newGame: () => Game; /** La partie enregistrée, ou null s'il n'y en a pas. */ readonly savedGame: () => Game | null; + /** « Reprendre l'ancienne » : la partie quittée par la dernière + * nouvelle partie (null s'il n'y en a plus). */ + readonly resumePrevious: () => Promise; readonly saveGame: (journal: GameJournal) => void; - readonly confirm: (question: string) => boolean; + /** Les parties enregistrées (« Mes parties »). */ + readonly games: SavedGamesAccess; + /** Le dernier plateau affiché (null : jamais) et son enregistrement. */ + readonly cachedBoard: () => CachedBoard | null; + readonly saveBoard: (board: CachedBoard) => void; + /** Les réglages, en JSON (null : jamais enregistrés). */ + readonly loadSettings: () => string | null; + readonly saveSettings: (json: string) => void; +} + +/** Ce que « Mes parties » demande au stockage. */ +export interface SavedGamesAccess { + /** Les parties, la dernière jouée d'abord. */ + readonly list: () => Promise; + readonly journal: (id: string) => Promise; + /** L'identifiant de la partie à l'écran. */ + readonly shownId: () => string | null; + /** Une partie reprise, au bout de la branche branchHead si elle est + * donnée ; elle devient la partie à l'écran. */ + readonly open: (id: string, branchHead?: string) => Promise; + readonly remove: (id: string) => Promise; + readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>; } function byId(id: string): T { @@ -54,13 +107,51 @@ function byId(id: string): T { return element as T; } +function child(parent: Element, selector: string): T { + const element = parent.querySelector(selector); + if (element === null) { + throw new Error(`${selector} absent de la page`); + } + return element as T; +} + +/** Les actions déclenchées par un toucher (boutons, zones du plateau) ou + * une touche du clavier. */ +type ActionName = + | "roll" + | "confirm" + | "skip" + | "double" + | "take" + | "drop" + | "undo" + | "replay" + | "again" + | "menu" + | "help" + | "fullscreen" + | "close"; + /** Démarre l'écran de jeu dans la page (index.html). */ export function startApp(options: AppOptions): void { + const stripOf = (id: string) => { + const strip = byId(id); + return { + strip, + who: child(strip, ".who"), + lead: child(strip, ".lead"), + count: child(strip, ".count"), + }; + }; const ui = { + root: document.documentElement, + screen: byId("screen"), board: byId("board") as unknown as SVGSVGElement, + boardBox: child(document.documentElement, ".board-box"), info: byId("info"), prompt: byId("prompt"), - pips: byId("pips"), + strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") }, + bubble: byId("pip-bubble"), roll: byId("roll"), double: byId("double"), take: byId("take"), @@ -68,51 +159,176 @@ export function startApp(options: AppOptions): void { confirm: byId("confirm"), cancel: byId("cancel"), skip: byId("skip"), - replay: byId("replay"), again: byId("again"), - newGame: byId("new-game"), + moveHint: byId("move-hint"), + replay: byId("replay"), helpButton: byId("help-button"), + fullscreenButton: byId("fullscreen-button"), + menuButton: byId("menu-button"), helpBack: byId("help-back"), game: byId("game"), help: byId("help"), helpContent: byId("help-content"), - doubleDialog: byId("double-dialog"), + menuVeil: byId("menu-veil"), + menu: byId("menu"), + menuTitle: byId("menu-title"), + menuClose: byId("menu-close"), + menuNewGame: byId("menu-new-game"), + menuGames: byId("menu-games"), + menuFullscreen: byId("menu-fullscreen"), + menuHelp: byId("menu-help"), + menuTheme: byId("menu-theme"), + menuVibrations: byId("menu-vibrations"), + menuScreenOn: byId("menu-screen-on"), + doubleVeil: byId("double-veil"), doubleQuestion: byId("double-question"), doubleConfirm: byId("double-confirm"), doubleCancel: byId("double-cancel"), + panelVeil: byId("panel-veil"), + panelText: byId("panel-text"), + panelPrimary: byId("panel-primary"), + panelSecondary: byId("panel-secondary"), + banner: byId("banner"), + bannerText: byId("banner-text"), + resumeOld: byId("resume-old"), }; + + // ------------------------------------------------------ Réglages + + let settings: Settings = parseSettings(options.loadSettings()); + const saveSettings = (changes: Partial) => { + settings = { ...settings, ...changes }; + options.saveSettings(JSON.stringify(settings)); + applyTheme(); + render(); + }; + function applyTheme(): void { + if (settings.theme === "auto") { + delete ui.root.dataset.theme; + } else { + ui.root.dataset.theme = settings.theme; + } + } + applyTheme(); + + const vibrate: Vibrate | undefined = + typeof navigator.vibrate === "function" ? (p) => navigator.vibrate(p) : undefined; + const haptic = createHaptics(vibrate, () => settings.vibrations); + + // ---------------------------------------------------- Textes fixes + ui.board.setAttribute("aria-label", TEXTS.boardLabel); + const label = (button: HTMLButtonElement, text: string, name?: Parameters[0]) => { + button.replaceChildren(); + if (name !== undefined) { + button.append(icon(name)); + } + const span = document.createElement("span"); + span.className = "label"; + span.textContent = text; + button.append(span); + }; + label(ui.helpButton, TEXTS.help, "help"); + label(ui.fullscreenButton, TEXTS.fullscreen, "fullscreen"); + label(ui.menuButton, TEXTS.menu, "menu"); + ui.replay.append(icon("replay")); + ui.replay.setAttribute("aria-label", TEXTS.replayTitle); + ui.replay.title = TEXTS.replayTitle; ui.roll.textContent = TEXTS.roll; ui.double.textContent = TEXTS.double; ui.take.textContent = TEXTS.take; ui.drop.textContent = TEXTS.drop; ui.confirm.textContent = TEXTS.confirm; + label(ui.cancel, TEXTS.cancel, "undo"); + ui.skip.textContent = TEXTS.skip; + ui.again.textContent = TEXTS.newGame; + ui.moveHint.textContent = TEXTS.moveHint; ui.doubleConfirm.textContent = TEXTS.double; ui.doubleCancel.textContent = TEXTS.cancel; - 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; ui.helpBack.textContent = TEXTS.back; + ui.menuTitle.textContent = TEXTS.menu; + ui.menuClose.append(icon("close")); + ui.menuClose.setAttribute("aria-label", TEXTS.close); + label(ui.menuNewGame, TEXTS.newGame, "newGame"); + label(ui.menuGames, GAMES_TEXTS.button, "games"); + label(ui.menuHelp, TEXTS.helpAndGlossary, "help"); + ui.menuTheme.prepend(icon("theme")); + child(ui.menuTheme, ".item-label").textContent = TEXTS.theme; + ui.menuVibrations.prepend(icon("vibrate")); + child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations; + ui.menuScreenOn.prepend(icon("screenOn")); + child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn; + ui.bannerText.textContent = TEXTS.newGameStarted; + ui.resumeOld.textContent = TEXTS.resumeOld; + child(ui.bubble, ".link-button").textContent = TEXTS.seeGlossary; + + // ------------------------------------------------ État de l'écran - // État de l'écran, en plus de la partie elle-même. let game: Game | null = null; let sides: SideBoards | null = null; let targets = new Map>(); let selected: Place | null = null; let canDouble = false; - // Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer. + // Le joueur a lancé les dés mais ne peut pas jouer : il doit passer. 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 info: string = TEXTS.loading; + let engineFailed = false; + // Le dernier coup de l'ordinateur, montré (traces, « 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; + let lastGeometry: Geometry | null = null; + // Fin de partie : le panneau a été fermé (« Revoir la partie »). + let panelDismissed = false; + // La fin de partie déjà signalée par une vibration (tête du journal). + let announcedEnd: string | null = null; + // « Nouvelle partie » : l'ancienne partie, reprenable pendant 10 s. + let previous: GameJournal | null = null; + // « Mes parties », créé plus bas (il a besoin des actions de l'écran). + let gamesView: GamesView | null = null; + let bannerTimer: ReturnType | null = null; + // Plein écran demandé ; rotated : repli, l'écran tourné d'un quart de + // tour par CSS, quand le paysage ne peut pas être verrouillé. + let immersive = false; + let rotated = false; + let bubbleSide: Side | null = null; + + const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)"); + const portraitQuery = window.matchMedia("(orientation: portrait)"); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + + // ----------------------------------------------------------- Mise en page + + function layout(): Layout { + // Plein écran demandé (« le plateau est l'écran ») : paysage dès que + // l'écran est plus large que haut, quelle que soit sa taille. + const immersiveLandscape = immersive && !portraitQuery.matches; + return rotatedActive() || landscapeQuery.matches || immersiveLandscape + ? "landscape" + : "portrait"; + } + + function rotatedActive(): boolean { + return rotated && portraitQuery.matches; + } + + /** La géométrie du plateau pour la place qu'il a maintenant. */ + function geometry(): Geometry { + const current = layout(); + ui.root.classList.toggle("landscape", current === "landscape"); + ui.root.classList.toggle("rotated", rotatedActive()); + if (current === "landscape") { + // Le plateau occupe tout l'écran (tourné en cas de repli). + const turned = rotatedActive(); + const width = turned ? window.innerHeight : window.innerWidth; + const height = turned ? window.innerWidth : window.innerHeight; + return boardGeometry("landscape", width / height, height / 520); + } + const width = ui.boardBox.clientWidth || window.innerWidth; + return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640); + } // ------------------------------------------------------------ Dessin @@ -128,6 +344,11 @@ export function startApp(options: AppOptions): void { if (selected !== null && !targets.has(selected)) { selected = null; } + // Le plateau de la partie enregistrée (sans demi-coup en cours de + // saisie), redessiné dès la prochaine ouverture. + if (game.pendingHalfMoves.length === 0) { + options.saveBoard({ sides, cube: game.state.cube }); + } } /** Recalcule ce qui demande le moteur, puis redessine. */ @@ -136,14 +357,32 @@ export function startApp(options: AppOptions): void { render(); } + function promptText(text: PromptText, g: Geometry): string { + if (g.layout === "landscape") { + return text.short; + } + return longPrompts(settings) ? text.long : text.short; + } + function render(): void { + const g = geometry(); + lastGeometry = g; + const landscape = g.layout === "landscape"; if (game === null || sides === null) { + renderLoading(g); return; } const state = game.state; const model = boardModel(sides); + const action = busy ? null : diceAction(game, stuck); + const text = promptFor(game, selected !== null, canDouble, stuck); + // Un panneau (fin de partie, double de l'ordinateur en paysage) + // remplace la consigne : elle ne la répète pas dessous. + const doubledByComputer = state.turn === "computer" && state.phase === "doubled"; + const panelShown = + (state.phase === "over" && !panelDismissed) || (landscape && doubledByComputer); if (!animating) { - renderBoard(ui.board, model, { + renderBoard(ui.board, g, model, { dice: shownDice(game, stuck), cube: state.cube, movable: new Set(targets.keys()), @@ -153,15 +392,17 @@ export function startApp(options: AppOptions): void { shownMove === null ? null : moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits), - // Mêmes conditions que les boutons « Lancer les dés », - // « Valider » et « Doubler ». - diceAction: busy ? null : diceAction(game), + diceAction: action, canDouble: !busy && canDouble, + band: + landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null, + controls: landscape ? controls() : null, }); } - ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer); - // Un jet sans coup possible reste annoncé jusqu'à l'action suivante, - // même après un rechargement de la page. + renderStrips(model.pipCount, state.phase === "over" ? null : state.turn); + + // Ligne d'information : 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)) { @@ -169,45 +410,211 @@ export function startApp(options: AppOptions): void { } 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)); + ui.prompt.textContent = panelShown + ? "" + : text === null + ? resultText(game) + : promptText(text, g); + const thinking = state.turn === "computer" && isPlaying(game); + ui.prompt.classList.toggle("thinking", thinking); + ui.prompt.classList.toggle("neutral", thinking || state.phase === "over"); 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.confirm, player && state.phase === "moving" && game.moveComplete); - show(ui.cancel, player && state.phase === "moving" && !stuck); - show(ui.skip, stuck); - ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; + const rolling = player && state.phase === "rolling"; + const moving = player && state.phase === "moving"; const doubled = state.turn === "computer" && state.phase === "doubled"; + const over = state.phase === "over"; + show(ui.roll, rolling); + show(ui.double, rolling && canDouble); + show(ui.cancel, moving && !stuck); + show(ui.confirm, moving && game.moveComplete); + show(ui.moveHint, moving && !stuck && !game.moveComplete); + show(ui.skip, stuck); show(ui.take, doubled); show(ui.drop, doubled); - show(ui.again, state.phase === "over"); + show(ui.again, over && panelDismissed); show(ui.replay, shownMove !== null); for (const button of [ ui.roll, + ui.double, ui.take, ui.drop, ui.confirm, ui.skip, ui.replay, ui.again, - ui.newGame, + ui.menuNewGame, ]) { button.disabled = busy; } + ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0; + renderPanel(game, landscape, doubled); + renderMenu(); + ui.resumeOld.disabled = busy; // Pour le style et pour les tests dans un navigateur : l'écran // attend le moteur. document.body.classList.toggle("busy", busy); + announceEnd(game); + void keepScreenOn(isPlaying(game) || state.phase === "doubled"); } - function show(button: HTMLButtonElement, visible: boolean): void { - button.hidden = !visible; + /** Avant que le moteur réponde : le dernier plateau affiché (ou la + * position de départ), non jouable, et les zones à leur place. */ + function renderLoading(g: Geometry): void { + const cached = options.cachedBoard(); + const boards = cached?.sides ?? OPENING_BOARDS; + const model = boardModel(boards); + const message = engineFailed ? info : TEXTS.prompts.loading.short; + renderBoard(ui.board, g, model, { + dice: [], + cube: cached?.cube ?? { value: 1, owner: null }, + ...NO_MARKS, + band: g.layout === "landscape" ? message : null, + controls: g.layout === "landscape" ? controls() : null, + }); + renderStrips(model.pipCount, null); + ui.info.textContent = info; + ui.prompt.textContent = ""; + ui.prompt.classList.add("neutral"); + for (const button of [ + ui.roll, + ui.double, + ui.take, + ui.drop, + ui.confirm, + ui.cancel, + ui.skip, + ui.again, + ui.replay, + ]) { + button.hidden = true; + } + ui.moveHint.hidden = true; + ui.menuNewGame.disabled = true; + renderMenu(); } + /** Les commandes de la colonne de gauche, en paysage. */ + function controls(): BoardDecor["controls"] { + const replay = shownMove !== null; + const undoable = + game !== null && + game.state.turn === "player" && + game.state.phase === "moving" && + game.pendingHalfMoves.length > 0; + return { + back: replay ? "replay" : "undo", + backEnabled: !busy && (replay || undoable), + menuEnabled: true, + }; + } + + /** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */ + function renderStrips(pips: Readonly>, turn: Side | null): void { + const lead = pipLead(pips); + for (const side of ["computer", "player"] as const) { + const strip = ui.strips[side]; + const name = side === "player" ? TEXTS.you : TEXTS.computer; + const mine = turn === side; + strip.strip.classList.toggle("turn", mine); + strip.who.textContent = mine + ? `${name} · ${side === "player" ? TEXTS.yourTurn : TEXTS.computerTurn}` + : name; + strip.lead.textContent = lead.leader === side ? TEXTS.lead(lead.by) : ""; + strip.count.textContent = String(pips[side]); + strip.count.setAttribute("aria-label", TEXTS.pipCountButton(name, pips[side])); + strip.count.setAttribute("aria-expanded", String(bubbleSide === side)); + } + } + + /** Le panneau centré : fin de partie, ou double de l'ordinateur en + * paysage (en portrait, la barre d'actions s'en charge). */ + function renderPanel(g: Game, landscape: boolean, doubled: boolean): void { + const over = g.state.phase === "over" && !panelDismissed; + const ask = landscape && doubled; + ui.panelVeil.hidden = !(over || ask); + const overline = byId("panel-overline"); + const detail = byId("panel-detail"); + if (over) { + const end = endParts(g); + overline.textContent = TEXTS.endOverline; + ui.panelText.textContent = end.title; + detail.textContent = end.detail; + ui.panelSecondary.textContent = TEXTS.reviewGame; + ui.panelPrimary.textContent = TEXTS.newGame; + ui.panelVeil.dataset.kind = "over"; + } else if (ask) { + overline.textContent = ""; + ui.panelText.textContent = TEXTS.prompts.computerDoubles.long; + detail.textContent = ""; + ui.panelSecondary.textContent = TEXTS.drop; + ui.panelPrimary.textContent = TEXTS.take; + ui.panelVeil.dataset.kind = "doubled"; + } + ui.panelPrimary.disabled = busy; + ui.panelSecondary.disabled = busy; + } + + function renderMenu(): void { + label( + ui.menuFullscreen, + immersive ? TEXTS.exitFullscreen : TEXTS.fullscreen, + immersive ? "exitFullscreen" : "fullscreen", + ); + child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme]; + ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations)); + ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn)); + } + + function show(element: HTMLElement, visible: boolean): void { + element.hidden = !visible; + } + + // --------------------------------------------- Vibrations, écran allumé + + /** Une seule vibration par fin de partie. */ + function announceEnd(g: Game): void { + const head = g.journal.head; + if (g.state.phase !== "over" || head === announcedEnd) { + return; + } + announcedEnd = head; + haptic(g.state.result?.winner === "player" ? "gameWon" : "gameLost"); + } + + let wakeLock: { release(): Promise } | null = null; + let wakeLockPending = false; + async function keepScreenOn(playing: boolean): Promise { + const api = ( + navigator as unknown as { + wakeLock?: { request(type: "screen"): Promise }; + } + ).wakeLock; + const wanted = settings.keepScreenOn && playing && document.visibilityState === "visible"; + if (api === undefined || wakeLockPending) { + return; + } + if (wanted && wakeLock === null) { + wakeLockPending = true; + try { + const sentinel = await api.request("screen"); + wakeLock = sentinel; + sentinel.addEventListener("release", () => { + wakeLock = null; + }); + } catch { + // Refusé (économie d'énergie, onglet caché) : tant pis. + } finally { + wakeLockPending = false; + } + } else if (!wanted && wakeLock !== null) { + const lock = wakeLock; + wakeLock = null; + await lock.release().catch(() => undefined); + } + } + document.addEventListener("visibilitychange", () => render()); + // ----------------------------------------------------------- Actions function save(): void { @@ -223,44 +630,65 @@ export function startApp(options: AppOptions): void { 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); + info = describeComputerTurn(game); save(); played = true; } if (game !== null) { shownMove = await computerMoveToShow(game); + if (game.state.turn === "computer" && game.state.phase === "doubled" && played) { + haptic("computerDoubles"); + } } 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. + * l'autre (160 ms par pion, 120 ms de pause), à partir des plateaux + * intermédiaires donnés par le domaine. Sans animation si le système + * demande moins de mouvement : les traces suffisent. */ async function animateMove(steps: MoveSteps): Promise { - if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + if (game === null) { return; } - const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS }; + if (reducedMotion.matches) { + if (steps.hits.some(Boolean)) { + haptic("checkerHit"); + } + return; + } + const decor = { + dice: shownDice(game, false), + cube: game.state.cube, + ...NO_MARKS, + band: null, + controls: null, + }; const draw = (i: number) => { const boards = steps.boards[i]; if (boards !== undefined) { - renderBoard(ui.board, boardModel(boards), decor); + renderBoard(ui.board, lastGeometry ?? geometry(), boardModel(boards), decor); } }; animating = true; render(); // messages et boutons ; le plateau est à l'animation try { for (const [i, halfMove] of steps.halfMoves.entries()) { + if (i > 0) { + await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); + } 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); + if (victim !== null) { + haptic("checkerHit"); + } await Promise.all([ start && slideChecker(ui.board, to, start, SLIDE_MS), victim && @@ -296,6 +724,8 @@ export function startApp(options: AppOptions): void { selected = null; targets = new Map(); shownMove = null; + bubbleSide = null; + ui.bubble.hidden = true; render(); try { await action(game); @@ -310,112 +740,421 @@ export function startApp(options: AppOptions): void { function startNewGame(): void { game = options.newGame(); + panelDismissed = false; + saveSettings({ gamesStarted: settings.gamesStarted + 1 }); const opening = game.events.findLast((e) => e.type === "DiceRolled"); info = opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : ""; } - ui.roll.onclick = () => - act(async (g) => { - info = ""; - await g.roll(); - }); - - // Aucun coup possible : le joueur laisse passer son tour. - ui.skip.onclick = () => - act(async (g) => { - info = ""; - await g.skipTurn(); - }); - - // Doubler demande confirmation : un double proposé ne se reprend pas. - ui.double.onclick = () => { - if (busy || game === null || !canDouble) { + /** « Nouvelle partie » : tout de suite, sans confirmation ; une partie en + * cours reste reprenable pendant 10 s (interaction.md). */ + function newGame(): void { + closeOverlays(); + if (busy || game === null) { return; } - ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value); - ui.doubleDialog.showModal(); + const old = game.state.phase === "over" ? null : game.journal; + if (old !== null) { + showBanner(old); + } + void act(() => startNewGame()); + } + + function showBanner(journal: GameJournal): void { + previous = journal; + ui.banner.hidden = false; + // La barre de temps repart de zéro (animation CSS de 10 s). + const timer = child(ui.banner, ".banner-timer"); + timer.classList.remove("running"); + void timer.offsetWidth; + timer.classList.add("running"); + if (bannerTimer !== null) { + clearTimeout(bannerTimer); + } + bannerTimer = setTimeout(hideBanner, BANNER_MS); + } + + function hideBanner(): void { + previous = null; + ui.banner.hidden = true; + if (bannerTimer !== null) { + clearTimeout(bannerTimer); + bannerTimer = null; + } + } + + ui.resumeOld.onclick = () => { + const journal = previous; + if (busy || journal === null) { + return; + } + hideBanner(); + void act(async () => { + const old = await options.resumePrevious(); + if (old !== null) { + showGame(old); + } + }); }; - ui.doubleCancel.onclick = () => ui.doubleDialog.close(); + + /** Une autre partie à l'écran (reprise). */ + function showGame(other: Game): void { + game = other; + panelDismissed = false; + announcedEnd = null; + info = ""; + } + + // « Mes parties » : reprendre une partie (ou une branche) en change à + // l'écran, sans recharger la page. + gamesView = createGamesView({ + list: options.games.list, + journal: options.games.journal, + shownId: options.games.shownId, + resume: async (id, branchHead) => { + if (busy) { + throw new Error(GAMES_TEXTS.busy); + } + const other = await options.games.open(id, branchHead); + hideBanner(); + await act(() => showGame(other)); + }, + remove: options.games.remove, + exportSgf: options.games.exportSgf, + confirm: (question) => window.confirm(question), + }); + + /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa + * validation. Le pion qui se pose vibre (deux fois s'il frappe). */ + function playHalfMove(halfMove: HalfMove): void { + void act(async (g) => { + info = ""; + const before = (await g.displayedBoard()).computer.bar; + if (await g.playHalfMove(halfMove)) { + const after = (await g.displayedBoard()).computer.bar; + haptic(after > before ? "checkerHit" : "checkerPlaced"); + } + }); + } + + const canAct = (): Game | null => (busy || game === null ? null : game); + + const actions: Record void> = { + roll: () => { + const g = canAct(); + if (g?.state.turn === "player" && g.state.phase === "rolling") { + void act(async (g) => { + info = ""; + await g.roll(); + }); + } + }, + // Le coup saisi, complet, entre dans la partie : le tour passe. + confirm: () => { + const g = canAct(); + if (g?.state.phase === "moving" && g.moveComplete) { + void act(async (g) => { + info = ""; + await g.confirmMove(); + }); + } + }, + // Aucun coup possible : le joueur laisse passer son tour. + skip: () => { + if (canAct() !== null && stuck) { + void act(async (g) => { + info = ""; + await g.skipTurn(); + }); + } + }, + // Doubler demande confirmation : un double proposé ne se reprend pas. + double: () => { + const g = canAct(); + if (g === null || !canDouble) { + return; + } + ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * g.state.cube.value); + openOverlay(ui.doubleVeil, ui.doubleConfirm); + }, + take: () => { + const g = canAct(); + if (g?.state.phase === "doubled" && g.state.turn === "computer") { + void act((g) => { + g.take(); + info = TEXTS.youTook(g.state.cube.value); + }); + } + }, + drop: () => { + const g = canAct(); + if (g?.state.phase === "doubled" && g.state.turn === "computer") { + void act((g) => { + g.drop(); + info = ""; + }); + } + }, + // Annuler défait le dernier demi-coup saisi, un par un. L'écran est + // occupé le temps que le moteur recalcule les coups possibles. + undo: () => { + const g = canAct(); + if (g !== null && g.pendingHalfMoves.length > 0) { + g.undoLastHalfMove(); + selected = null; + shownMove = null; + busy = true; + render(); + void settle(false); + } + }, + // Revoir le dernier coup de l'ordinateur, puis ses traces. + replay: () => { + if (canAct() === null || shownMove === null) { + return; + } + busy = true; + selected = null; + void settle(true); + }, + again: () => newGame(), + menu: () => { + if (ui.menuVeil.hidden) { + closeOverlays(); + renderMenu(); + openOverlay(ui.menuVeil, ui.menuNewGame); + } else { + closeOverlays(); + } + }, + help: () => { + closeOverlays(); + ui.game.hidden = true; + ui.help.hidden = false; + ui.root.classList.add("help-open"); + window.scrollTo(0, 0); + void showHelp(ui.helpContent); + }, + fullscreen: () => { + closeOverlays(); + void (immersive ? exitImmersive() : enterImmersive()); + }, + close: () => { + if (!ui.help.hidden) { + closeHelp(); + } else { + closeOverlays(); + } + }, + }; + + // ------------------------------------------------------- Fenêtres + + let lastFocus: HTMLElement | null = null; + function openOverlay(veil: HTMLElement, focus: HTMLElement): void { + lastFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + veil.hidden = false; + focus.focus(); + } + + /** Ferme le menu, la confirmation du double et la bulle ; ne touche + * jamais à la partie. */ + function closeOverlays(): void { + const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden; + gamesView?.close(); + ui.menuVeil.hidden = true; + ui.doubleVeil.hidden = true; + bubbleSide = null; + ui.bubble.hidden = true; + if (open) { + lastFocus?.focus(); + } + } + + function closeHelp(): void { + ui.help.hidden = true; + ui.game.hidden = false; + ui.root.classList.remove("help-open"); + render(); + } + + for (const veil of [ui.menuVeil, ui.doubleVeil]) { + veil.addEventListener("click", (event) => { + if (event.target === veil) { + closeOverlays(); + } + }); + } + + ui.doubleCancel.onclick = () => closeOverlays(); ui.doubleConfirm.onclick = () => { - ui.doubleDialog.close(); + closeOverlays(); void act(async (g) => { await g.double(); info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value); }); }; - ui.take.onclick = () => - act((g) => { - g.take(); - info = TEXTS.youTook(g.state.cube.value); - }); - - ui.drop.onclick = () => - act((g) => { - g.drop(); - info = ""; - }); - - // Le coup saisi, complet, entre dans la partie : le tour passe. - ui.confirm.onclick = () => - act(async (g) => { - info = ""; - await g.confirmMove(); - }); - - // Annuler défait le dernier demi-coup saisi, un par un. L'écran est - // occupé le temps que le moteur recalcule les coups possibles. - ui.cancel.onclick = () => { - if (!busy && game !== null) { - game.undoLastHalfMove(); - selected = null; - shownMove = null; - busy = true; + ui.panelPrimary.onclick = () => { + if (ui.panelVeil.dataset.kind === "doubled") { + actions.take(); + } else { + newGame(); + } + }; + ui.panelSecondary.onclick = () => { + if (ui.panelVeil.dataset.kind === "doubled") { + actions.drop(); + } else { + panelDismissed = true; render(); - void settle(false); } }; - const newGame = () => { - if (busy) { - return; - } - const inProgress = game !== null && game.state.phase !== "over"; - if (inProgress && !options.confirm(TEXTS.confirmNewGame)) { - return; - } - void act(() => startNewGame()); - }; - ui.newGame.onclick = newGame; - ui.again.onclick = newGame; + // --------------------------------------------------------- Menu - /** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa - * validation. */ - function playHalfMove(halfMove: HalfMove): void { - void act(async (g) => { - info = ""; - await g.playHalfMove(halfMove); - }); + ui.menuButton.onclick = actions.menu; + ui.menuClose.onclick = () => closeOverlays(); + ui.menuNewGame.onclick = () => newGame(); + ui.menuGames.onclick = () => { + closeOverlays(); + void gamesView?.open(); + }; + ui.menuFullscreen.onclick = actions.fullscreen; + ui.menuHelp.onclick = actions.help; + ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) }); + ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations }); + ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn }); + ui.helpButton.onclick = actions.help; + ui.helpBack.onclick = closeHelp; + ui.fullscreenButton.onclick = actions.fullscreen; + + // ------------------------------------------------- Plein écran (ADR 0012) + + async function enterImmersive(): Promise { + // Certains navigateurs ne répondent jamais à la demande (Firefox sans + // écran) : on n'attend pas plus d'une seconde, la vue paysage + // s'applique quand même. + try { + await Promise.race([ + document.documentElement.requestFullscreen?.({ navigationUI: "hide" }), + new Promise((resolve) => setTimeout(resolve, FULLSCREEN_WAIT_MS)), + ]); + } catch { + // Refusé : la vue paysage s'applique quand même. + } + let locked = false; // vrai : le navigateur a accepté de verrouiller + try { + const orientation = screen.orientation as unknown as { + lock?: (orientation: string) => Promise; + }; + if (orientation.lock !== undefined) { + await Promise.race([ + orientation.lock("landscape"), + new Promise((_, reject) => setTimeout(reject, FULLSCREEN_WAIT_MS)), + ]); + locked = true; + } + } catch { + // Verrouillage refusé : repli par rotation CSS ci-dessous. + } + // Verrouillage accepté : l'écran tourne dans l'instant. S'il est + // toujours en portrait (navigateur qui accepte sans tourner), repli. + for (let i = 0; locked && portraitQuery.matches && i < 6; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + immersive = true; + rotated = portraitQuery.matches; + render(); } - // Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée - // (ou aucune, s'il n'en a qu'une : il y va tout de suite). + async function exitImmersive(): Promise { + immersive = false; + rotated = false; + try { + screen.orientation.unlock(); + } catch { + // Rien à déverrouiller. + } + render(); + if (document.fullscreenElement) { + await document.exitFullscreen().catch(() => undefined); + } + } + + // Sortie du plein écran par le retour du téléphone : la vue revient. + document.addEventListener("fullscreenchange", () => { + if (document.fullscreenElement === null && immersive) { + void exitImmersive(); + } + }); + + // Rotation, barre d'adresse, fenêtre redimensionnée : on redessine. + // L'état (partie, sélection, dés) ne bouge pas. + let resizeFrame = 0; + const relayout = () => { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => { + if (!animating) { + render(); + } + }); + }; + window.addEventListener("resize", relayout); + landscapeQuery.addEventListener("change", relayout); + portraitQuery.addEventListener("change", relayout); + + // --------------------------------------------- Compte de points + + let pipEntry: Promise<{ term: string; brief: string } | null> | null = null; + async function toggleBubble(side: Side): Promise { + if (bubbleSide === side) { + closeOverlays(); + render(); + return; + } + bubbleSide = side; + pipEntry ??= loadGlossaryEntry(TEXTS.pipCountFile).catch(() => null); + const entry = await pipEntry; + if (bubbleSide !== side) { + return; + } + child(ui.bubble, ".bubble-term").textContent = entry?.term ?? ""; + child(ui.bubble, ".bubble-text").textContent = entry?.brief ?? TEXTS.helpFailed; + ui.bubble.dataset.side = side; + ui.bubble.hidden = false; + render(); + } + for (const side of ["computer", "player"] as const) { + ui.strips[side].count.onclick = () => void toggleBubble(side); + } + child(ui.bubble, ".link-button").onclick = actions.help; + + // ------------------------------------------------------ Touchers + + ui.roll.onclick = actions.roll; + ui.skip.onclick = actions.skip; + ui.double.onclick = actions.double; + ui.take.onclick = actions.take; + ui.drop.onclick = actions.drop; + ui.confirm.onclick = actions.confirm; + ui.cancel.onclick = actions.undo; + ui.replay.onclick = actions.replay; + ui.again.onclick = actions.again; + + // Toucher le plateau : une commande (dés, videau, colonne de gauche), ou + // un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a + // qu'une : il y va tout de suite). ui.board.addEventListener("click", (event) => { const target = event.target as Element | null; - // Toucher les dés (d'un côté ou de l'autre du plateau) ou le videau : - // comme leurs boutons. + if (!ui.bubble.hidden) { + closeOverlays(); + render(); + } const action = target?.closest("[data-action]")?.getAttribute("data-action"); - if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) { - ui.roll.click(); - return; - } - if (action === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) { - ui.confirm.click(); - return; - } - if (action === "double" && !ui.double.hidden && !ui.double.disabled) { - ui.double.click(); + if (action != null && action in actions) { + actions[action as ActionName](); return; } const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null; @@ -439,50 +1178,73 @@ export function startApp(options: AppOptions): void { render(); }); - // Revoir le dernier coup de l'ordinateur, puis ses traces. - ui.replay.onclick = async () => { - if (busy || shownMove === null) { + // Clavier (interaction.md) : les mêmes actions que les touchers. + document.addEventListener("keydown", (event) => { + if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) { return; } - busy = true; - selected = null; - await settle(true); - }; - - ui.helpButton.onclick = () => { - ui.game.hidden = true; - ui.help.hidden = false; - window.scrollTo(0, 0); - void showHelp(ui.helpContent); - }; - ui.helpBack.onclick = () => { - ui.help.hidden = true; - ui.game.hidden = false; - }; + const onButton = event.target instanceof HTMLButtonElement; + let name: ActionName | null = null; + switch (event.key) { + case " ": + if (!onButton && game !== null) { + const dice = diceAction(game, stuck); + name = dice ?? (game.state.phase === "over" ? "again" : null); + } + break; + case "z": + case "Z": + name = "undo"; + break; + case "d": + case "D": + name = "double"; + break; + case "p": + case "P": + name = "take"; + break; + case "n": + case "N": + name = "drop"; + break; + case "f": + case "F": + name = "fullscreen"; + break; + case "m": + case "M": + name = "menu"; + break; + case "?": + name = "help"; + break; + case "Escape": + name = "close"; + break; + } + if (name !== null) { + event.preventDefault(); + actions[name](); + } + }); // ---------------------------------------------------------- Démarrage - ui.prompt.textContent = TEXTS.loading; - for (const button of [ - ui.roll, - ui.double, - ui.take, - ui.drop, - ui.confirm, - ui.cancel, - ui.skip, - ui.replay, - ui.again, - ]) { - button.hidden = true; - } - ui.newGame.disabled = true; + // Réponse immédiate : le plateau est dessiné avant que le moteur arrive. + render(); options.engineReady.then( async () => { game = options.savedGame(); if (game === null) { startNewGame(); save(); + } else { + info = ""; + // Une partie finie avant la fermeture ne vibre pas à nouveau. + if (game.state.phase === "over") { + announcedEnd = game.journal.head; + } } // Comme après une action du joueur : l'ordinateur joue si c'est // à lui (partie nouvelle ou reprise pendant son tour). Une partie @@ -495,9 +1257,9 @@ export function startApp(options: AppOptions): void { } }, (error: unknown) => { - ui.prompt.textContent = TEXTS.engineFailed( - error instanceof Error ? error.message : String(error), - ); + engineFailed = true; + info = TEXTS.engineFailed(error instanceof Error ? error.message : String(error)); + render(); }, ); } @@ -517,8 +1279,9 @@ function rollBefore(game: Game): readonly number[] { } /** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer, - * valider quand son coup est complet. */ -function diceAction(game: Game): DiceAction | null { + * jouer le coup quand il est complet, passer quand aucun coup n'est + * possible. */ +function diceAction(game: Game, stuck: boolean): DiceAction | null { const state = game.state; if (state.turn !== "player") { return null; @@ -526,6 +1289,9 @@ function diceAction(game: Game): DiceAction | null { if (state.phase === "rolling") { return "roll"; } + if (stuck) { + return "skip"; + } return state.phase === "moving" && game.moveComplete ? "confirm" : null; } @@ -574,8 +1340,8 @@ async function computerMoveToShow(game: Game): Promise { return game.lastMoveSteps(); } -/** Ce qu'a fait l'ordinateur pendant son tour. */ -function describeComputerTurn(game: Game, ms: number): string { +/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */ +function describeComputerTurn(game: Game): string { const last = game.events.at(-1); if (last?.type === "CubeOffered") { return ""; @@ -588,31 +1354,58 @@ function describeComputerTurn(game: Game, ms: number): string { if (played.halfMoves.length === 0) { return TEXTS.computerCannotMove(dice); } - return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves), ms); + return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves)); } -/** Ce que le joueur doit faire maintenant, ou le résultat. */ -function prompt(game: Game, selecting: boolean, canDouble: boolean): string { +/** Le résultat d'une partie finie, en deux parties : le titre du panneau + * (« L'ordinateur gagne un gammon ») et son détail (« 8 points pour lui, + * videau à 4. »). */ +function endParts(game: Game): { title: string; detail: string } { + const result = game.state.result; + if (result === null) { + return { title: TEXTS.gameOver, detail: "" }; + } + const { winner, kind, points } = result; + const dropped = game.events.some((e) => e.type === "CubeDropped"); + return { + title: TEXTS.endTitle(winner, kind, dropped), + detail: TEXTS.endDetail(winner, points, game.state.cube.value), + }; +} + +/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le + * panneau fermé). */ +function resultText(game: Game): string { + const { title, detail } = endParts(game); + return `${title} : ${detail}`; +} + +/** Ce que le joueur doit faire maintenant (null : partie finie). */ +function promptFor( + game: Game, + selecting: boolean, + canDouble: boolean, + stuck: boolean, +): PromptText | null { const state = game.state; - if (state.phase === "over" && state.result !== null) { - const { winner, kind, points } = state.result; - const dropped = game.events.find((e) => e.type === "CubeDropped"); - if (dropped !== undefined) { - return winner === "player" ? TEXTS.computerDropped(points) : TEXTS.youDropped(points); - } - return winner === "player" ? TEXTS.youWin(kind, points) : TEXTS.youLose(kind, points); + const p = TEXTS.prompts; + if (state.phase === "over") { + return null; } if (state.turn === "computer") { - return state.phase === "doubled" ? TEXTS.computerDoubles : TEXTS.thinking; + return state.phase === "doubled" ? p.computerDoubles : p.thinking; } if (state.phase === "rolling") { - return canDouble ? TEXTS.yourRoll : TEXTS.yourRollNoDouble; + return canDouble ? p.yourRoll : p.yourRollNoDouble; + } + if (stuck) { + return p.stuck; } if (state.phase === "moving" && game.moveComplete) { - return TEXTS.confirmOrUndo; + return p.confirmOrUndo; } if (state.phase === "moving" && state.roll !== null) { - return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll); + return selecting ? p.pickTarget : p.yourMove(state.roll); } - return ""; + return { long: "", short: "" }; } diff --git a/app/src/ui/board-model.ts b/app/src/ui/board-model.ts index a8e1465..bc3277a 100644 --- a/app/src/ui/board-model.ts +++ b/app/src/ui/board-model.ts @@ -155,3 +155,26 @@ export function pointSlot(number: number): { top: boolean; column: number } { } return { top: true, column: number - 13 }; } + +/** Le plateau de la position de départ, pour le dessiner avant que le + * moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case + * 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée, + * comme dans le dessin de référence de la charte (board.js), pas une règle. */ +export const OPENING_BOARDS: SideBoards = (() => { + const points = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0); + const side = { points, bar: 0, off: 0, pipCount: 167 }; + return { player: side, computer: side }; +})(); + +/** Qui mène la course, et de combien : le plus petit compte de points + * (leader null : égalité). */ +export function pipLead(pipCount: Readonly>): { + leader: Side | null; + by: number; +} { + const by = Math.abs(pipCount.player - pipCount.computer); + if (by === 0) { + return { leader: null, by }; + } + return { leader: pipCount.player < pipCount.computer ? "player" : "computer", by }; +} diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index b17a1aa..ca3b0a2 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -7,7 +7,14 @@ // il n'y a aucun état caché dans le dessin. Les couleurs viennent des // variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque // endroit qu'on peut toucher est un rectangle transparent, posé par-dessus -// le reste, qui porte son nom dans data-place. +// le reste, qui porte son nom dans data-place (une case, la barre, la +// sortie) ou data-action (dés, videau, commandes). +// +// Les mesures viennent de la géométrie (geometry.ts), calculée d'après la +// forme de l'écran. En paysage, le plateau porte aussi les commandes : la +// colonne de gauche (menu, videau, annuler ou revoir), les comptes de +// points en pastilles aux bouts de la barre et la consigne dans la bande +// centrale. import type { Cube } from "../domain/game/cube.js"; import type { Side } from "../domain/game/side.js"; @@ -18,6 +25,8 @@ import { pointColor, pointSlot, } from "./board-model.js"; +import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js"; +import { ICON_PATHS, type IconName } from "./icons.js"; import { TEXTS } from "./texts.js"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ @@ -30,8 +39,17 @@ export interface ShownDie { readonly used: boolean; } -/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */ -export type DiceAction = "roll" | "confirm"; +/** Ce que fait un toucher sur les dés : lancer, jouer le coup saisi, ou + * passer son tour quand aucun coup n'est possible. */ +export type DiceAction = "roll" | "confirm" | "skip"; + +/** Les commandes de la colonne de gauche, en paysage. */ +export interface BoardControls { + /** En bas : annuler le demi-coup, ou revoir le coup de l'ordinateur. */ + readonly back: "undo" | "replay"; + readonly backEnabled: boolean; + readonly menuEnabled: boolean; +} /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ export interface BoardDecor { @@ -44,12 +62,16 @@ export interface BoardDecor { readonly targets: ReadonlySet; /** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */ readonly trace: MoveTrace | null; - /** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre - * du plateau (null : rien). Pour « roll », les dés du joueur en attente - * sont dessinés. */ + /** Ce que fait un toucher sur la bande centrale, d'un côté ou de + * l'autre du plateau (null : rien). En portrait, pour « roll », les dés + * du joueur en attente sont dessinés. */ readonly diceAction: DiceAction | null; /** Le joueur peut doubler : le videau se touche. */ readonly canDouble: boolean; + /** Paysage : la consigne courte, dans la bande centrale. */ + readonly band: string | null; + /** Paysage : la colonne de commandes (null : pas de colonne). */ + readonly controls: BoardControls | null; } /** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */ @@ -62,30 +84,7 @@ export const NO_MARKS = { canDouble: false, } as const; -/** Taille minimale d'une cible à toucher, en unités du dessin : au moins - * 44 px sur un téléphone de 360 px de large, où le plateau en fait 344 - * (640 unités). */ -const MIN_TARGET = 88; - -// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran). -const WIDTH = 640; -const HEIGHT = 520; -const FRAME = 8; -const POINT_WIDTH = 44; -const HALF_WIDTH = 6 * POINT_WIDTH; -const BAR_LEFT = FRAME + HALF_WIDTH; -const BAR_WIDTH = 40; -const RIGHT_HALF = BAR_LEFT + BAR_WIDTH; -const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME; -const TRAY_WIDTH = 48; -const POINT_HEIGHT = 190; -const MIDDLE = HEIGHT / 2; -const RADIUS = 20; -const STEP = 42; // d'un pion au suivant dans une pile -const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion const OFF_SLAB = 12; // épaisseur d'un pion sorti -const DIE = 36; -const CUBE = 34; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -103,11 +102,6 @@ function add(parent: Element, name: string, attributes: Attributes, text?: strin return element; } -/** Abscisse du bord gauche d'une colonne (0 à 11). */ -function columnLeft(column: number): number { - return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH; -} - /** Un point du dessin. */ export interface Spot { readonly x: number; @@ -120,27 +114,24 @@ export interface Spot { * joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la * sortie, une tranche de pion vu de profil (l'ordinateur en haut). */ -function slotCenter(place: Place, index: number): Spot { +function slotCenter(g: Geometry, place: Place, index: number): Spot { + const middle = g.height / 2; if (place === "bar-player" || place === "bar-computer") { - const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP; + const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step; return { - x: BAR_LEFT + BAR_WIDTH / 2, - y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset, + x: g.barLeft + g.bar / 2, + y: place === "bar-player" ? middle - offset : middle + offset, }; } if (place === "off-player" || place === "off-computer") { const y = place === "off-computer" - ? FRAME + index * OFF_SLAB - : HEIGHT - FRAME - (index + 1) * OFF_SLAB; - return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 }; + ? g.edge + index * OFF_SLAB + : g.height - g.edge - (index + 1) * OFF_SLAB; + return { x: g.trayLeft + g.tray / 2, y: y + OFF_SLAB / 2 }; } const { top, column } = pointSlot(Number(place.slice(1))); - const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP; - return { - x: columnLeft(column) + POINT_WIDTH / 2, - y: top ? FRAME + offset : HEIGHT - FRAME - offset, - }; + return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) }; } /** Le nombre de pions de side à un endroit. */ @@ -155,63 +146,110 @@ function countAt(board: BoardModel, side: Side, place: Place): number { return point?.owner === side ? point.count : 0; } +/** Le nombre de pions dessinés au plus à un endroit. */ +function stackLimit(g: Geometry, place: Place): number { + return place.startsWith("bar-") ? g.barStack : MAX_STACK; +} + /** Redessine tout le plateau dans svg. */ -export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void { +export function renderBoard( + svg: SVGSVGElement, + g: Geometry, + board: BoardModel, + decor: BoardDecor, +): void { svg.replaceChildren(); - svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`); - drawFrame(svg); + svg.setAttribute("viewBox", `0 0 ${g.width} ${g.height}`); + svg.dataset.layout = g.layout; + drawFrame(svg, g); const hits = add(svg, "g", {}); + // Les pions, dessinés à part puis posés par-dessus les flèches du coup + // de l'ordinateur : une flèche ne cache jamais un pion. + const pieces = document.createElementNS(SVG_NS, "g"); for (const point of board.points) { - drawPoint(svg, hits, board, point.number, decor); + drawPoint(svg, pieces, g, hits, board, point.number, decor); } - drawBar(svg, hits, board, decor); - drawTray(svg, hits, board, decor); - drawDice(svg, decor.dice); - const cube = drawCube(svg, decor.cube); + drawBar(svg, pieces, g, hits, board, decor); + drawTray(svg, pieces, g, hits, board, decor); if (decor.trace !== null) { - drawTrace(svg, board, decor.trace); + drawTraceArrows(svg, g, board, decor.trace); + } + svg.append(pieces); + drawLandings(svg, g, board, decor.targets); + if (decor.band !== null) { + drawBand(svg, g, decor.band); + } + drawDice(svg, g, decor.dice); + const cube = drawCube(svg, g, decor.cube); + if (decor.trace !== null) { + drawTraceRings(svg, g, board, decor.trace); } if (decor.diceAction !== null) { - if (decor.diceAction === "roll") { - waitingDice(svg); + if (decor.diceAction === "roll" && g.layout === "portrait") { + waitingDice(svg, g); } - // Une zone sur chaque moitié du plateau : les dés en attente (à - // gauche) comme le dernier jet (à droite) se touchent. - const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm; - for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) { - addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME); + // Une zone sur chaque moitié de la bande centrale : les dés en + // attente (à gauche) comme le dernier jet (à droite) se touchent. + const label = TEXTS.diceActions[decor.diceAction]; + const width = 6 * g.pointWidth - 2 * g.frame; + for (const x of [g.x0 + 3 * g.pointWidth, g.rightHalf + 3 * g.pointWidth]) { + addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width); } } if (decor.canDouble) { cube.classList.add("can-double"); - addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET); + addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target); + } + if (decor.controls !== null) { + drawControls(svg, g, hits, decor.controls); } // Les zones à toucher passent devant tout le reste. svg.append(hits); } -function drawFrame(svg: SVGSVGElement): void { - add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 }); - for (const left of [FRAME, RIGHT_HALF]) { +function drawFrame(svg: SVGSVGElement, g: Geometry): void { + const landscape = g.layout === "landscape"; + add(svg, "rect", { + class: "board-frame", + x: 0, + y: 0, + width: g.width, + height: g.height, + rx: landscape ? 0 : 6, + }); + for (const left of [g.x0, g.rightHalf]) { add(svg, "rect", { class: "board-bed", x: left, - y: FRAME, - width: HALF_WIDTH, - height: HEIGHT - 2 * FRAME, + y: g.edge, + width: 6 * g.pointWidth, + height: g.height - 2 * g.edge, }); } add(svg, "rect", { class: "board-tray", - x: TRAY_LEFT, - y: FRAME, - width: TRAY_WIDTH, - height: HEIGHT - 2 * FRAME, + x: g.trayLeft, + y: g.edge, + width: g.tray, + height: g.height - 2 * g.edge, + rx: landscape ? 4 : 0, }); + if (g.left > 0) { + add(svg, "rect", { + class: "board-tray", + x: g.frame, + y: g.edge, + width: g.left - 4, + height: g.height - 2 * g.edge, + rx: 4, + }); + } } function drawPoint( svg: SVGSVGElement, + pieces: Element, + g: Geometry, hits: Element, board: BoardModel, number: number, @@ -222,38 +260,54 @@ function drawPoint( return; } const { top, column } = pointSlot(number); - const left = columnLeft(column); - const center = left + POINT_WIDTH / 2; - const base = top ? FRAME : HEIGHT - FRAME; - const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT; + const left = columnLeft(g, column); + const center = left + g.pointWidth / 2; + const middle = g.height / 2; + const base = top ? g.edge : g.height - g.edge; + const length = middle - g.edge - g.band / 2 + 6; + const tip = top ? base + length : base - length; add(svg, "polygon", { class: pointColor(number), - points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`, + "data-point": number, + points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`, }); + // Numéros : sur le cadre en paysage, dans la bande centrale en + // portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle + // du dessin : c'est une mesure du dessin, comme x et y. + const size = g.numberSize; + let y: number; + if (g.layout === "landscape") { + y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36; + } else { + y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72; + } add( svg, "text", - { class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 }, + { + class: g.layout === "landscape" ? "point-number on-frame" : "point-number", + x: center, + y, + "font-size": size, + }, String(number), ); + // Case d'arrivée possible : la flèche seule est voilée (son triangle) ; + // l'endroit où le pion se posera est marqué plus loin (drawLandings). if (decor.targets.has(point.place)) { - add(svg, "rect", { + add(svg, "polygon", { class: "target-zone", - x: left + 2, - y: top ? FRAME : MIDDLE, - width: POINT_WIDTH - 4, - height: HEIGHT / 2 - FRAME, - rx: 4, + points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`, }); } if (point.owner !== null) { - drawStack(svg, point.owner, point.count, point.place, decor); + drawStack(pieces, g, point.owner, point.count, point.place, decor); } addHit(hits, point.place, decor, { x: left, - y: top ? 0 : MIDDLE, - width: POINT_WIDTH, - height: MIDDLE, + y: top ? 0 : middle, + width: g.pointWidth, + height: middle, }); } @@ -261,13 +315,15 @@ function drawPoint( * pour les montrer tous. Le pion du dessus porte son endroit dans * data-checker : c'est lui qu'on fait glisser quand un pion arrive. */ function drawStack( - svg: SVGSVGElement, + svg: Element, + g: Geometry, side: Side, count: number, place: Place, decor: BoardDecor, ): void { - const shown = Math.min(count, MAX_STACK); + const limit = stackLimit(g, place); + const shown = Math.min(count, limit); for (let i = 0; i < shown; i++) { const last = i === shown - 1; const classes = [`checker checker-${side}`]; @@ -276,119 +332,270 @@ function drawStack( } else if (last && decor.movable.has(place)) { classes.push("movable"); } - const { x, y } = slotCenter(place, i); - const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS }); + const { x, y } = slotCenter(g, place, i); + const checker = add(svg, "circle", { + class: classes.join(" "), + cx: x, + cy: y, + r: g.radius, + }); if (last) { checker.setAttribute("data-checker", place); } - if (last && count > MAX_STACK) { - add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count)); + if (last && count > limit) { + add( + svg, + "text", + { + class: `checker-count count-${side}`, + x, + y: y + g.countFont * 0.35, + "font-size": g.countFont, + }, + String(count), + ); } } } -function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void { - add(svg, "rect", { - class: "board-bar", - x: BAR_LEFT, - y: 0, - width: BAR_WIDTH, - height: HEIGHT, - }); +function drawBar( + svg: SVGSVGElement, + pieces: Element, + g: Geometry, + hits: Element, + board: BoardModel, + decor: BoardDecor, +): void { + add(svg, "rect", { class: "board-bar", x: g.barLeft, y: 0, width: g.bar, height: g.height }); // Les pions du joueur sur la barre entrent en haut à droite : on les met // dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas. for (const side of ["player", "computer"] as const) { - drawStack(svg, side, board.bar[side], `bar-${side}`, decor); + drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor); } - addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE }); + if (g.layout === "landscape") { + drawChips(svg, g, board); + } + addHit(hits, "bar-player", decor, { + x: g.barLeft, + y: 0, + width: g.bar, + height: g.height / 2, + }); } -function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void { +/** Paysage : les comptes de points en pastilles aux deux bouts de la barre, + * aux couleurs du camp (l'ordinateur en haut, vous en bas). */ +function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void { + const width = g.bar - 6; + const h = g.chipHeight; + for (const side of ["computer", "player"] as const) { + const y = side === "computer" ? g.edge + 4 : g.height - g.edge - 4 - h; + add(svg, "rect", { + class: `pip-chip chip-${side}`, + x: g.barLeft + 3, + y, + width, + height: h, + rx: h / 2, + }); + add( + svg, + "text", + { + class: `pip-chip-text chip-text-${side}`, + "data-pips": side, + x: g.barLeft + g.bar / 2, + y: y + h / 2 + g.chipFont * 0.35, + "font-size": g.chipFont, + }, + String(board.pipCount[side]), + ); + } +} + +function drawTray( + svg: SVGSVGElement, + pieces: Element, + g: Geometry, + hits: Element, + board: BoardModel, + decor: BoardDecor, +): void { // Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas. for (const side of ["player", "computer"] as const) { const count = board.off[side]; for (let i = 0; i < count; i++) { - const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i)); + const slab = addSlab( + pieces, + g, + `checker checker-${side}`, + slotCenter(g, `off-${side}`, i), + ); if (i === count - 1) { slab.setAttribute("data-checker", `off-${side}`); } } } + const middle = g.height / 2; if (decor.targets.has("off-player")) { add(svg, "rect", { class: "target-zone", - x: TRAY_LEFT + 2, - y: MIDDLE, - width: TRAY_WIDTH - 4, - height: MIDDLE - FRAME, + x: g.trayLeft + 2, + y: middle, + width: g.tray - 4, + height: middle - g.edge, rx: 4, }); } addHit(hits, "off-player", decor, { - x: TRAY_LEFT, - y: MIDDLE, - width: TRAY_WIDTH, - height: MIDDLE, + x: g.trayLeft, + y: middle, + width: g.width - g.trayLeft, + height: middle, }); } /** Une tranche de pion sorti, centrée en spot. */ -function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement { +function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement { return add(svg, "rect", { class: cssClass, - x: TRAY_LEFT + 4, + x: g.trayLeft + 4, y: spot.y - OFF_SLAB / 2 + 1, - width: TRAY_WIDTH - 8, + width: g.tray - 8, height: OFF_SLAB - 2, rx: 3, }); } /** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */ -function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void { +function addRing( + svg: Element, + g: Geometry, + cssClass: string, + place: Place, + spot: Spot, + r: number, +): void { if (place.startsWith("off-")) { - addSlab(svg, cssClass, spot); + addSlab(svg, g, cssClass, spot); } else { add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r }); } } +/** Paysage : la consigne courte, dans la moitié gauche de la bande + * centrale, sur une pastille accent-soft. */ +function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void { + const middle = g.height / 2; + const width = 6 * g.pointWidth - 24; + const height = Math.max(44, g.bandFont + 20); + const x = g.x0 + 12; + add(svg, "rect", { + class: "band-pill", + x, + y: middle - height / 2, + width, + height, + rx: height / 2, + }); + add( + svg, + "text", + { + class: "band-text", + id: "band-text", + x: x + width / 2, + y: middle + g.bandFont * 0.35, + "font-size": g.bandFont, + }, + text, + ); +} + +/** Paysage : la colonne de commandes à gauche, dans le cadre. */ +function drawControls( + svg: SVGSVGElement, + g: Geometry, + hits: Element, + controls: BoardControls, +): void { + const x = g.frame + (g.left - 4) / 2; + const r = (g.left - 4) / 2 - 6; + const top = g.edge + g.target / 2 + 2; + const bottom = g.height - g.edge - g.target / 2 - 2; + const control = (name: IconName, action: string, label: string, y: number, on: boolean) => { + const group = add(svg, "g", { class: on ? "control" : "control disabled" }); + add(group, "circle", { class: "control-circle", cx: x, cy: y, r }); + const size = r * 1.1; + const scale = size / 24; + const icon = add(group, "g", { + transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`, + }); + add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] }); + if (on) { + addAction(g, hits, action, label, { x, y }, g.target); + } + }; + control("menu", "menu", TEXTS.menu, top, controls.menuEnabled); + if (controls.back === "replay") { + control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled); + } else { + control("undo", "undo", TEXTS.undoTitle, bottom, controls.backEnabled); + } +} + // ------------------------------------------------------ Coup de l'ordinateur /** - * Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion + * Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : un pion fantôme (contour seul) là où chaque pion * est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé * (sur la barre), et une flèche par demi-coup. */ -function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void { +function drawTraceRings( + svg: SVGSVGElement, + g: Geometry, + board: BoardModel, + trace: MoveTrace, +): void { const side = trace.side; + const R = g.radius; for (const [place, n] of trace.left) { // Au-dessus des pions restés sur la case. const remaining = countAt(board, side, place); for (let k = 0; k < n; k++) { - addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1); + addRing(svg, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1); } } for (const [place, n] of trace.landed) { - const top = topIndex(board, side, place); + const top = topIndex(g, board, side, place); for (let k = 0; k < n && top - k >= 0; k++) { - addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3); + addRing(svg, g, "trace-landed", place, slotCenter(g, place, top - k), R + 3); } } const opponent: Side = side === "player" ? "computer" : "player"; const bar: Place = `bar-${opponent}`; - const top = topIndex(board, opponent, bar); + const top = topIndex(g, board, opponent, bar); for (let k = 0; k < trace.hits && top - k >= 0; k++) { - addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3); + addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3); } +} + +/** Les flèches d'un coup, une par demi-coup : sous les pions. */ +function drawTraceArrows( + svg: SVGSVGElement, + g: Geometry, + board: BoardModel, + trace: MoveTrace, +): void { + const side = trace.side; const defs = add(svg, "defs", {}); const marker = add(defs, "marker", { id: "trace-head", viewBox: "0 0 10 10", refX: 8, refY: 5, - markerWidth: 3.5, - markerHeight: 3.5, + markerWidth: 3, + markerHeight: 3, orient: "auto", }); add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" }); @@ -398,20 +605,35 @@ function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): voi // d'arrivée. const k = departures.get(from) ?? 0; departures.set(from, k + 1); - const start = slotCenter(from, countAt(board, side, from) + k); - const end = slotCenter(to, Math.max(topIndex(board, side, to), 0)); - drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4); + const start = slotCenter(g, from, countAt(board, side, from) + k); + const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0)); + drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4); + } +} + +/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se + * posera (sur la pile du joueur, ou au pied de la flèche). */ +function drawLandings( + svg: SVGSVGElement, + g: Geometry, + board: BoardModel, + targets: ReadonlySet, +): void { + for (const place of targets) { + const count = countAt(board, "player", place); + const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1); + addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius); } } /** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */ -function topIndex(board: BoardModel, side: Side, place: Place): number { +function topIndex(g: Geometry, board: BoardModel, side: Side, place: Place): number { const count = countAt(board, side, place); - return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1; + return place.startsWith("off-") ? count - 1 : Math.min(count, stackLimit(g, place)) - 1; } /** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */ -function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void { +function drawArrow(svg: SVGSVGElement, g: Geometry, start: Spot, end: Spot, gap: number): void { const dx = end.x - start.x; const dy = end.y - start.y; const length = Math.hypot(dx, dy); @@ -422,6 +644,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi y1: start.y, x2: start.x + dx * keep, y2: start.y + dy * keep, + "stroke-width": g.arrowWidth, "marker-end": "url(#trace-head)", }); } @@ -440,6 +663,9 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null { return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 }; } +/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */ +export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)"; + /** * Fait glisser le pion du dessus de place depuis from jusqu'à sa position * dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque). @@ -464,7 +690,7 @@ export async function slideChecker( { transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` }, { transform: "translate(0px, 0px)" }, ], - { duration: ms, easing: "ease-in-out" }, + { duration: ms, easing: EASING_STANDARD }, ); await animation.finished.catch(() => undefined); } @@ -505,70 +731,90 @@ const PIPS: Record = { }; /** Les dés, au milieu de la moitié droite. */ -function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void { - const center = RIGHT_HALF + HALF_WIDTH / 2; +function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): void { + const center = g.rightHalf + 3 * g.pointWidth; + const size = g.die; const gap = 8; - const total = dice.length * DIE + (dice.length - 1) * gap; + const total = dice.length * size + (dice.length - 1) * gap; dice.forEach((die, i) => { - const x = center - total / 2 + i * (DIE + gap); - const y = MIDDLE - DIE / 2; + const x = center - total / 2 + i * (size + gap); + const y = g.height / 2 - size / 2; const group = add(svg, "g", { class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""] .filter(Boolean) .join(" "), "data-value": die.value, }); - add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 }); + add(group, "rect", { class: `die-${die.side}`, x, y, width: size, height: size, rx: 6 }); for (const [px, py] of PIPS[die.value] ?? []) { add(group, "circle", { class: `pip-${die.side}`, - cx: x + px * DIE, - cy: y + py * DIE, - r: 3.5, + cx: x + px * size, + cy: y + py * size, + r: size / 10, }); } if (die.blocked) { - add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y }); + add(group, "line", { class: "die-strike", x1: x, y1: y + size, x2: x + size, y2: y }); } }); } -/** Le videau, dans le plateau de sortie : au milieu s'il est au centre, - * sinon du côté de son propriétaire. Au centre, il montre 64. */ -function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement { - const center = cubeCenter(cube); - const x = center.x - CUBE / 2; - const y = center.y - CUBE / 2; +/** Le videau : dans la colonne des sorties en portrait, dans la colonne de + * commandes en paysage ; au milieu s'il est au centre (il montre alors 64), + * sinon du côté de son propriétaire. */ +function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement { + const center = cubeCenter(g, cube); + const size = g.cube; + const x = center.x - size / 2; + const y = center.y - size / 2; const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" }); - add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 }); + add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 }); add( group, "text", - { class: "cube-value", x: center.x, y: center.y + 6 }, + { + class: "cube-value", + x: center.x, + y: center.y + g.cubeFont * 0.35, + "font-size": g.cubeFont, + }, String(cube.owner === null ? 64 : cube.value), ); return group; } -/** Le centre du videau dans le plateau de sortie. */ -function cubeCenter(cube: Cube): Spot { +/** Le centre du videau. */ +function cubeCenter(g: Geometry, cube: Cube): Spot { + const middle = g.height / 2; + if (g.layout === "landscape") { + const shift = g.target + 6; + const y = + cube.owner === "computer" + ? middle - shift + : cube.owner === "player" + ? middle + shift + : middle; + return { x: g.frame + (g.left - 4) / 2, y }; + } const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0; - return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift }; + return { x: g.trayLeft + g.tray / 2, y: middle + shift }; } -/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la - * moitié gauche (la droite garde le dernier jet de l'ordinateur). */ -function waitingDice(svg: SVGSVGElement): void { - const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE }; +/** Portrait : les dés du joueur en attente d'être lancés, faces vides, au + * milieu de la moitié gauche (la droite garde le dernier jet de + * l'ordinateur). */ +function waitingDice(svg: SVGSVGElement, g: Geometry): void { + const center = g.x0 + 3 * g.pointWidth; const gap = 8; const group = add(svg, "g", { class: "die die-waiting" }); - for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) { + for (const x of [center - gap / 2 - g.die, center + gap / 2]) { add(group, "rect", { class: "die-player", x, - y: MIDDLE - DIE / 2, - width: DIE, - height: DIE, + y: g.height / 2 - g.die / 2, + width: g.die, + height: g.die, rx: 6, }); } @@ -576,27 +822,28 @@ function waitingDice(svg: SVGSVGElement): void { /** * Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés, - * valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce - * qu'on touche, avec un rôle et un nom accessibles. Les boutons restent - * pour le clavier. + * jouer le coup, doubler, menu…) : au moins g.target de côté (44 px à + * l'écran), centrée sur ce qu'on touche, avec un rôle et un nom accessibles. */ function addAction( + g: Geometry, hits: Element, action: string, label: string, center: Spot, width: number, ): void { + const w = Math.max(width, g.target); add(hits, "rect", { class: "hit action", "data-action": action, role: "button", "aria-label": label, - // Sans déborder du dessin à droite (le videau est contre le bord). - x: Math.min(center.x - width / 2, WIDTH - width), - y: center.y - MIN_TARGET / 2, - width, - height: MIN_TARGET, + // Sans déborder du dessin (le videau est contre le bord). + x: Math.max(0, Math.min(center.x - w / 2, g.width - w)), + y: center.y - g.target / 2, + width: w, + height: g.target, }); } diff --git a/app/src/ui/games-texts.ts b/app/src/ui/games-texts.ts index d467f0c..8525884 100644 --- a/app/src/ui/games-texts.ts +++ b/app/src/ui/games-texts.ts @@ -33,6 +33,7 @@ export const GAMES_TEXTS = { remove: "Supprimer", confirmRemove: "Supprimer définitivement cette partie et toutes ses branches ?", shown: "partie affichée", + busy: "Attendez la fin du coup en cours.", failed: (message: string) => `Erreur : ${message}`, /** « 3 nov. 2026, 14:05 ». */ diff --git a/app/src/ui/games-view.ts b/app/src/ui/games-view.ts index 63dd3cf..2d180ab 100644 --- a/app/src/ui/games-view.ts +++ b/app/src/ui/games-view.ts @@ -4,15 +4,16 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). // -// Un écran autonome, dans une fenêtre () qu'il crée lui-même : il ne -// dépend ni de index.html ni de l'écran de jeu (app.ts). Comme tout l'écran, -// il ne connaît ni le stockage ni le moteur : main.ts lui passe les -// opérations (GamesViewOptions). +// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne +// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le +// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations +// (GamesViewOptions), et change lui-même de partie quand on en reprend une. import { branches } from "../domain/game/branches.js"; import type { GameJournal } from "../domain/game/events.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { GAMES_TEXTS } from "./games-texts.js"; +import { icon } from "./icons.js"; export interface GamesViewOptions { /** Les parties, la dernière jouée d'abord. */ @@ -33,23 +34,44 @@ export interface GamesViewOptions { export interface GamesView { /** Ouvre la liste, à jour. */ open(): Promise; + close(): void; + readonly isOpen: boolean; } -/** Crée la fenêtre « Mes parties » (fermée) dans la page. */ +/** Crée la feuille « Mes parties » (fermée) dans la page. */ export function createGamesView(options: GamesViewOptions): GamesView { - const dialog = element("dialog", "confirm games"); - dialog.id = "games-dialog"; - dialog.setAttribute("aria-labelledby", "games-title"); - const title = element("h2"); + const veil = element("div", "veil sheet-veil"); + veil.id = "games-veil"; + veil.hidden = true; + const sheet = element("div", "sheet games"); + sheet.id = "games"; + sheet.setAttribute("role", "dialog"); + sheet.setAttribute("aria-modal", "true"); + sheet.setAttribute("aria-labelledby", "games-title"); + const head = element("div", "sheet-head"); + const title = element("h2", "sheet-title"); title.id = "games-title"; title.textContent = GAMES_TEXTS.title; + const close = button("", () => hide(), "icon-button"); + close.id = "games-close"; + close.setAttribute("aria-label", GAMES_TEXTS.close); + close.append(icon("close")); + head.append(title, close); const list = element("ul", "games-list"); const message = element("p", "games-message"); message.setAttribute("aria-live", "polite"); - const close = button(GAMES_TEXTS.close, () => dialog.close(), "secondary"); - close.id = "games-close"; - dialog.append(title, message, list, close); - document.body.append(dialog); + sheet.append(head, message, list); + veil.append(sheet); + document.body.append(veil); + veil.addEventListener("click", (event) => { + if (event.target === veil) { + hide(); + } + }); + + function hide(): void { + veil.hidden = true; + } /** Lance une action ; une erreur s'affiche dans la fenêtre. */ const run = (action: () => Promise) => async () => { @@ -85,7 +107,7 @@ export function createGamesView(options: GamesViewOptions): GamesView { .join(" · "); const branchList = element("ul", "games-branches"); branchList.hidden = true; - const actions = element("div", "confirm-actions"); + const actions = element("div", "games-actions"); actions.append( button( GAMES_TEXTS.resume, @@ -122,6 +144,12 @@ export function createGamesView(options: GamesViewOptions): GamesView { "remove", ), ); + // La partie à l'écran ne se supprime pas : elle continue d'être + // enregistrée. + const remove = actions.querySelector('[data-action="remove"]'); + if (remove !== null) { + remove.hidden = shown; + } item.append(heading, actions, branchList); return item; } @@ -144,7 +172,10 @@ export function createGamesView(options: GamesViewOptions): GamesView { text, button( GAMES_TEXTS.resumeBranch, - run(() => options.resume(id, branch.head)), + run(async () => { + await options.resume(id, branch.head); + hide(); + }), "secondary", "resume-branch", ), @@ -158,9 +189,12 @@ export function createGamesView(options: GamesViewOptions): GamesView { async open() { message.textContent = ""; await render(); - if (!dialog.open) { - dialog.showModal(); - } + veil.hidden = false; + close.focus(); + }, + close: hide, + get isOpen() { + return !veil.hidden; }, }; } diff --git a/app/src/ui/geometry.ts b/app/src/ui/geometry.ts new file mode 100644 index 0000000..bcacc61 --- /dev/null +++ b/app/src/ui/geometry.ts @@ -0,0 +1,191 @@ +// La géométrie du plateau, calculée d'après la forme de l'écran (fonction +// pure, testée sous Node). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le dessin est en unités du viewBox, de hauteur fixe (520). En portrait, le +// plateau garde ses proportions habituelles (640 × 520) et suit la largeur de +// l'écran. En paysage, « le plateau est l'écran » (ADR 0012, amendements du +// 2026-11-03) : sa largeur suit le rapport largeur/hauteur de l'écran, et les +// flèches s'élargissent d'autant. Formules du composant Board des maquettes +// « Révision UX » : largeur d'une flèche = ce qui reste une fois ôtés le +// cadre, les colonnes et la barre, divisé par 12 ; rayon d'un pion = le plus +// petit de la demi-largeur d'une flèche (moins 2) et du dixième de la +// hauteur d'une demi-table (hors bande des numéros et bande centrale). + +/** Portrait : barre haute, bandeaux et barre d'actions autour du plateau ; + * paysage : le plateau seul, commandes comprises. */ +export type Layout = "portrait" | "landscape"; + +export interface Geometry { + readonly layout: Layout; + /** Taille du dessin, en unités du viewBox. */ + readonly width: number; + readonly height: number; + /** Épaisseur du cadre sur les côtés. */ + readonly frame: number; + /** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */ + readonly edge: number; + /** Colonne de commandes à gauche (0 en portrait). */ + readonly left: number; + /** Colonne des sorties, à droite. */ + readonly tray: number; + readonly bar: number; + /** Bande centrale, entre les pointes des flèches (dés, consigne). */ + readonly band: number; + readonly pointWidth: number; + readonly radius: number; + /** D'un pion au suivant dans une pile. */ + readonly step: number; + /** Bord gauche de la première colonne de flèches. */ + readonly x0: number; + readonly barLeft: number; + /** Bord gauche de la moitié droite. */ + readonly rightHalf: number; + readonly trayLeft: number; + /** Côté minimal d'une zone à toucher : 44 px à l'écran. */ + readonly target: number; + /** Tailles de texte, en unités : numéros des flèches (12 px au moins à + * l'écran), consigne de la bande et pastilles des comptes (13 px). */ + readonly numberSize: number; + readonly bandFont: number; + readonly chipFont: number; + /** Videau : côté et taille du chiffre ; nombre posé sur une pile trop + * haute (13 px au moins à l'écran). */ + readonly cube: number; + readonly cubeFont: number; + readonly countFont: number; + /** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */ + readonly arrowWidth: number; + readonly die: number; + /** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un + * nombre sur le dernier) : en paysage, ils s'arrêtent avant les + * pastilles des comptes. */ + readonly barStack: number; + /** Hauteur d'une pastille de compte, au bout de la barre (paysage). */ + readonly chipHeight: number; +} + +export const BOARD_HEIGHT = 520; +/** Rapport largeur / hauteur du plateau en portrait. */ +export const PORTRAIT_ASPECT = 640 / 520; +/** Pions dessinés au plus sur une flèche ; au-delà, un nombre sur le dernier. */ +export const MAX_STACK = 5; + +/** Une taille en unités qui fait au moins px pixels à l'écran. */ +function atLeast(px: number, pxPerUnit: number, min: number): number { + return Math.max(min, Math.ceil(px / pxPerUnit)); +} + +/** + * La géométrie du plateau pour une mise en page. aspect : rapport largeur / + * hauteur de la place offerte (ignoré en portrait) ; pxPerUnit : pixels à + * l'écran par unité du dessin, pour les tailles minimales (textes, cibles). + */ +export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number): Geometry { + const height = BOARD_HEIGHT; + const target = atLeast(46, pxPerUnit, 40); + const numberSize = atLeast(12.5, pxPerUnit, 13); + const text = atLeast(13.5, pxPerUnit, 17); + const sizes = { + cubeFont: text, + cube: Math.max(36, text + 20), + countFont: Math.max(18, text), + arrowWidth: 2.5 / pxPerUnit, + }; + if (layout === "portrait") { + // Les mesures du plateau d'origine : flèches de 44, pions de 20. + const frame = 8; + const tray = 48; + const bar = 40; + const width = 640; + const pointWidth = (width - 3 * frame - tray - bar) / 12; + const band = 84; + const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10); + const x0 = frame; + const barLeft = x0 + 6 * pointWidth; + return { + layout, + width, + height, + frame, + edge: frame, + left: 0, + tray, + bar, + band, + pointWidth, + radius, + step: 2 * radius + 2, + x0, + barLeft, + rightHalf: barLeft + bar, + trayLeft: barLeft + bar + 6 * pointWidth + frame, + target, + numberSize, + bandFont: atLeast(13.5, pxPerUnit, 17), + chipFont: atLeast(13.5, pxPerUnit, 17), + ...sizes, + die: 36, + barStack: MAX_STACK, + chipHeight: 0, + }; + } + const width = Math.round(height * aspect); + const frame = 6; + const edge = Math.max(24, numberSize + 8); + const left = target + 4; + const tray = 56; + const bar = 64; + const band = 80; + const pointWidth = (width - 2 * frame - left - tray - bar) / 12; + const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2 - 4) / 10); + const step = 2 * radius + 1; + const chipFont = atLeast(13.5, pxPerUnit, 18); + const chipHeight = chipFont + 12; + // Pions de la barre, depuis le milieu (voir board-view) : le dernier + // s'arrête à 2 unités de la pastille. + const room = height / 2 - edge - 4 - chipHeight - 2 - (radius + 4) - radius; + const barStack = Math.max(1, Math.min(MAX_STACK, Math.floor(room / step) + 1)); + const x0 = frame + left; + const barLeft = x0 + 6 * pointWidth; + return { + layout, + width, + height, + frame, + edge, + left, + tray, + bar, + band, + pointWidth, + radius, + step, + x0, + barLeft, + rightHalf: barLeft + bar, + trayLeft: barLeft + bar + 6 * pointWidth, + target, + numberSize, + bandFont: atLeast(13.5, pxPerUnit, 22), + chipFont, + ...sizes, + die: Math.min(40, band - 12), + barStack, + chipHeight, + }; +} + +/** Abscisse du bord gauche d'une colonne de flèches (0 à 11). */ +export function columnLeft(g: Geometry, column: number): number { + return column < 6 ? g.x0 + column * g.pointWidth : g.rightHalf + (column - 6) * g.pointWidth; +} + +/** Ordonnée du centre du pion numéro index (0 : contre le bord) d'une pile + * de la rangée du haut ou du bas. */ +export function stackY(g: Geometry, top: boolean, index: number): number { + const offset = g.radius + 1 + Math.min(index, MAX_STACK) * g.step; + return top ? g.edge + offset : g.height - g.edge - offset; +} diff --git a/app/src/ui/haptics.ts b/app/src/ui/haptics.ts new file mode 100644 index 0000000..235e49e --- /dev/null +++ b/app/src/ui/haptics.ts @@ -0,0 +1,47 @@ +// Les vibrations (docs/identite/charte/interaction.md) : elles doublent un +// événement visible, jamais seules, et se coupent dans le menu. Rien ne vibre +// pour un toucher de bouton ni pour le lancer des dés. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +export type HapticEvent = + | "checkerPlaced" + | "checkerHit" + | "computerDoubles" + | "gameWon" + | "gameLost"; + +/** Motifs en millisecondes (vibration, pause, vibration…). */ +export const HAPTIC_PATTERNS: Readonly> = { + checkerPlaced: [12], + checkerHit: [25, 40, 25], + computerDoubles: [40, 60, 40], + gameWon: [30, 50, 30, 50, 60], + gameLost: [60], +}; + +/** navigator.vibrate, s'il existe. */ +export type Vibrate = (pattern: number[]) => boolean; + +/** + * La fonction qui fait vibrer pour un événement : rien si le navigateur ne + * sait pas vibrer ou si le joueur a coupé les vibrations. Une seule vibration + * à la fois : chaque appel remplace le précédent (comportement de + * navigator.vibrate). + */ +export function createHaptics( + vibrate: Vibrate | undefined, + enabled: () => boolean, +): (event: HapticEvent) => void { + return (event) => { + if (vibrate !== undefined && enabled()) { + try { + vibrate([...HAPTIC_PATTERNS[event]]); + } catch { + // Navigateur qui refuse (pas encore de geste du joueur) : la + // marque visible suffit. + } + } + }; +} diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index ec14422..67301fb 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -15,7 +15,11 @@ import { import { TEXTS } from "./texts.js"; /** Les scénarios montrés dans l'aide, dans l'ordre. */ -const FEATURES = ["docs/scenarios/jouer-une-partie.feature", "docs/scenarios/mes-parties.feature"]; +const FEATURES = [ + "docs/scenarios/jouer-une-partie.feature", + "docs/scenarios/mes-parties.feature", + "docs/scenarios/regler-l-application.feature", +]; const GLOSSARY = "docs/glossaire/"; async function fetchText(url: string): Promise { @@ -32,6 +36,12 @@ async function loadGlossary(): Promise { return texts.map(parseGlossaryEntry).filter((entry) => entry !== null); } +/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la + * copie du site : la bulle du compte de points en affiche le texte. */ +export async function loadGlossaryEntry(file: string): Promise { + return parseGlossaryEntry(await fetchText(GLOSSARY + file)); +} + function element( parent: HTMLElement, tag: K, diff --git a/app/src/ui/icons.ts b/app/src/ui/icons.ts new file mode 100644 index 0000000..4e748bf --- /dev/null +++ b/app/src/ui/icons.ts @@ -0,0 +1,45 @@ +// Les pictogrammes de l'interface (composant Icons de la charte, +// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en +// currentColor, dessinés en SVG. Les tracés viennent des maquettes +// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la +// classe « icon » (app.css) : aucun style dans le SVG (CSP). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +export const ICON_PATHS = { + menu: "M4 7h16M4 12h16M4 17h16", + help: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1", + fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5", + exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5", + undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3", + replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5", + close: "M6 6l12 12M18 6L6 18", + chevron: "M9 6l6 6-6 6", + copy: "M9 9h11v11H9zM5 15H4V4h11v1", + newGame: "M12 5v14M5 12h14", + games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01", + vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4", + theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18", + screenOn: "M7 3h10v18H7zM11 18h2", + verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6", + verdictDoubtful: + "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1", + verdictBad: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6", +} as const; + +export type IconName = keyof typeof ICON_PATHS; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** Un pictogramme, décoratif (le nom est porté par son bouton). */ +export function icon(name: IconName): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", `icon icon-${name}`); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", ICON_PATHS[name]); + svg.append(path); + return svg; +} diff --git a/app/src/ui/settings.ts b/app/src/ui/settings.ts new file mode 100644 index 0000000..b7646a8 --- /dev/null +++ b/app/src/ui/settings.ts @@ -0,0 +1,63 @@ +// Les réglages de l'application (menu) et ce qui en dépend, sans navigateur +// (testé sous Node). Ils sont enregistrés à part de la partie, en JSON. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +export type Theme = "auto" | "light" | "dark"; + +export interface Settings { + /** Vibrations aux moments forts (activées par défaut). */ + readonly vibrations: boolean; + /** Écran maintenu allumé pendant une partie (activé par défaut). */ + readonly keepScreenOn: boolean; + readonly theme: Theme; + /** Parties commencées : la consigne est longue pendant les trois + * premières, courte ensuite. */ + readonly gamesStarted: number; +} + +export const DEFAULT_SETTINGS: Settings = { + vibrations: true, + keepScreenOn: true, + theme: "auto", + gamesStarted: 0, +}; + +/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */ +export const LONG_PROMPT_GAMES = 3; + +/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une + * valeur illisible garde sa valeur par défaut. */ +export function parseSettings(text: string | null): Settings { + let raw: Record = {}; + try { + const parsed: unknown = JSON.parse(text ?? "{}"); + if (parsed !== null && typeof parsed === "object") { + raw = parsed as Record; + } + } catch { + // Réglages illisibles : valeurs par défaut. + } + const bool = (key: "vibrations" | "keepScreenOn") => + typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]; + const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto"; + const games = raw.gamesStarted; + return { + vibrations: bool("vibrations"), + keepScreenOn: bool("keepScreenOn"), + theme, + gamesStarted: + typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0, + }; +} + +/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */ +export function nextTheme(theme: Theme): Theme { + return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto"; +} + +/** La consigne doit-elle encore être longue ? (La partie en cours compte.) */ +export function longPrompts(settings: Settings): boolean { + return settings.gamesStarted <= LONG_PROMPT_GAMES; +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index 22c62b5..182ca99 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -22,9 +22,11 @@ function points(n: number): string { return n > 1 ? `${n} points` : `${n} point`; } -/** Une durée en secondes, avec une décimale : « 0,3 s ». */ -function seconds(ms: number): string { - return `${(ms / 1000).toFixed(1).replace(".", ",")} s`; +/** Une consigne : sa forme longue (les trois premières parties) et sa forme + * courte (ensuite, et toujours en paysage, dans la bande du plateau). */ +export interface PromptText { + readonly long: string; + readonly short: string; } export const TEXTS = { @@ -36,57 +38,118 @@ export const TEXTS = { double: "Doubler", take: "Prendre", drop: "Passer", - confirm: "Valider", + confirm: "Jouer ce coup", cancel: "Annuler", skip: "Continuer", - replay: "Revoir", replayTitle: "Revoir le dernier coup de l'ordinateur", + undoTitle: "Annuler le demi-coup", newGame: "Nouvelle partie", + reviewGame: "Revoir la partie", help: "Aide", + menu: "Menu", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", back: "Retour à la partie", + close: "Fermer", + /** Ce que fait un toucher sur les dés (nom accessible de la zone). */ + diceActions: { + roll: "Lancer les dés", + confirm: "Jouer ce coup", + skip: "Passer votre tour", + }, + moveHint: "Le coup fini, touchez les dés pour le jouer.", + + // Menu + helpAndGlossary: "Aide et glossaire", + theme: "Thème", + themes: { auto: "Automatique", light: "Clair", dark: "Sombre" }, + vibrations: "Vibrations", + keepScreenOn: "Écran allumé pendant la partie", + newGameStarted: "Nouvelle partie commencée.", + resumeOld: "Reprendre l'ancienne", + + // Bandeaux des joueurs et compte de points + you: "Vous", + computer: "Ordinateur", + yourTurn: "à vous de jouer", + computerTurn: "joue", + lead: (n: number) => `mène de ${n}`, + pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`, + pipCountFile: "compte-de-points.md", + seeGlossary: "Voir le glossaire", // Chargement loading: "Chargement du moteur…", engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`, actionFailed: (message: string) => `Erreur : ${message}`, - confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?", - // Déroulement + // Déroulement (ligne d'information) opening: (player: number, computer: number) => `Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${ player > computer ? "Vous commencez." : "L'ordinateur commence." }`, - yourRoll: "À vous : doublez ou lancez les dés.", - yourRollNoDouble: "À vous : lancez les dés.", - yourMove: (dice: readonly number[]) => - `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`, - pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.", - confirmOrUndo: - "Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.", youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`, - pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.", - thinking: "L'ordinateur réfléchit…", - computerMoved: (dice: readonly number[], move: string, ms: number) => - `L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`, + computerMoved: (dice: readonly number[], move: string) => + `L'ordinateur a joué ${roll(dice)} : ${move}.`, computerCannotMove: (dice: readonly number[]) => `L'ordinateur ne peut pas jouer ${roll(dice)}.`, - confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`, - computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.", computerTakes: (value: number) => `L'ordinateur prend : la partie vaut maintenant ${points(value)}.`, youTook: (value: number) => `Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`, + confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`, + + // Consignes + prompts: { + loading: { long: "", short: "Chargement…" }, + yourRoll: { + long: "À vous : touchez les dés pour les lancer, ou le videau pour doubler.", + short: "À vous : lancez les dés.", + }, + yourRollNoDouble: { + long: "À vous : touchez les dés pour les lancer.", + short: "À vous : lancez les dés.", + }, + yourMove: (dice: readonly number[]): PromptText => ({ + long: `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`, + short: `Jouez ${roll(dice)}.`, + }), + pickTarget: { + long: "Touchez une case d'arrivée en surbrillance, ou un autre pion.", + short: "Choisissez la case.", + }, + confirmOrUndo: { + long: "Touchez vos dés pour jouer ce coup, ou « Annuler » pour reprendre un pion.", + short: "Touchez les dés pour jouer.", + }, + stuck: { + long: "Aucun coup possible : touchez vos dés pour passer votre tour.", + short: "Aucun coup : touchez les dés.", + }, + thinking: { long: "L'ordinateur réfléchit", short: "L'ordinateur réfléchit" }, + computerDoubles: { + long: "L'ordinateur vous propose de doubler : prenez ou passez.", + short: "L'ordinateur double.", + }, + } satisfies Record PromptText)>, // Fin de partie - youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`, - youLose: (kind: ResultKind, n: number) => - `L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`, - computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`, - youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`, - - // Sous le plateau - pipCounts: (player: number, computer: number) => - `Compte de points : vous ${player}, l'ordinateur ${computer}`, + gameOver: "Partie finie", + endOverline: "Fin de partie", + /** Titre du panneau de fin : qui gagne, et comment. */ + endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => { + if (dropped) { + return winner === "player" ? "L'ordinateur passe" : "Vous passez"; + } + return winner === "player" + ? `Vous gagnez ${RESULT_KIND[kind]}` + : `L'ordinateur gagne ${RESULT_KIND[kind]}`; + }, + /** « 8 points pour lui, videau à 4. » */ + endDetail: (winner: "player" | "computer", n: number, cube: number) => + `${points(n)} pour ${winner === "player" ? "vous" : "lui"}${ + cube > 1 ? `, videau à ${cube}` : "" + }.`, // Aide helpTitle: "Aide", diff --git a/app/tests/layout.test.ts b/app/tests/layout.test.ts new file mode 100644 index 0000000..1cb6348 --- /dev/null +++ b/app/tests/layout.test.ts @@ -0,0 +1,271 @@ +// Tests de la révision UX qui se vérifient sans navigateur : géométrie du +// plateau selon l'écran, vibrations, réglages, compte de points, plateau +// gardé pour le chargement, et règles de la charte vérifiables dans le code. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { test } from "node:test"; +import { createSettingsStore } from "../src/storage/settings-store.js"; +import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js"; +import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js"; +import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js"; +import { ICON_PATHS } from "../src/ui/icons.js"; +import { + DEFAULT_SETTINGS, + LONG_PROMPT_GAMES, + longPrompts, + nextTheme, + parseSettings, +} from "../src/ui/settings.js"; + +const root = new URL("../../../", import.meta.url); +const read = (path: string) => readFileSync(new URL(path, root), "utf8"); + +// --------------------------------------------------------------- Géométrie + +/** Les écrans en paysage de la recette : largeur et hauteur en pixels, + * dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large + * encore. */ +const LANDSCAPES: [number, number][] = [ + [915, 412], + [844, 390], + [844, 320], + [740, 360], + [800, 308], +]; + +test("géométrie en portrait : les mesures du plateau d'origine", () => { + const g = boardGeometry("portrait", 640 / 520, 396 / 640); + assert.equal(g.width, 640); + assert.equal(g.pointWidth, 44); + assert.equal(g.radius, 20); + assert.equal(g.step, 42); + assert.equal(g.trayLeft + g.tray + g.frame, 640); + // Numéros d'au moins 12 px à l'écran, à 412 px de large. + assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`); +}); + +test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => { + for (const [width, height] of LANDSCAPES) { + const px = height / BOARD_HEIGHT; + const g = boardGeometry("landscape", width / height, px); + const where = `${width}x${height}`; + assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`); + // Les colonnes, la barre et les flèches occupent toute la largeur. + assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`); + assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`); + assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`); + assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`); + assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`); + assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`); + assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`); + assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`); + assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`); + assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`); + assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`); + // Les numéros tiennent dans le cadre. + assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`); + } +}); + +test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => { + const cases = [ + boardGeometry("portrait", 640 / 520, 0.62), + ...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)), + ]; + for (const g of cases) { + const band = g.height / 2 - g.die / 2; + const last = stackY(g, true, MAX_STACK - 1) + g.radius; + assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`); + } +}); + +test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => { + for (const [width, height] of LANDSCAPES) { + const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT); + const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius; + const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight); + assert.ok(g.barStack >= 1); + assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`); + } +}); + +// -------------------------------------------------------------- Vibrations + +test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => { + const calls: number[][] = []; + let on = true; + const haptic = createHaptics( + (pattern) => { + calls.push(pattern); + return true; + }, + () => on, + ); + haptic("checkerPlaced"); + haptic("checkerHit"); + haptic("computerDoubles"); + haptic("gameWon"); + haptic("gameLost"); + assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]); + on = false; + haptic("checkerHit"); + assert.equal(calls.length, 5); + // Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur. + assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon")); + const refusing = createHaptics( + () => { + throw new Error("refusé"); + }, + () => true, + ); + assert.doesNotThrow(() => refusing("gameWon")); + // Aucun motif ne dépasse 60 ms par impulsion (interaction.md). + for (const pattern of Object.values(HAPTIC_PATTERNS)) { + assert.ok(pattern.every((ms) => ms > 0 && ms <= 60)); + } +}); + +// ---------------------------------------------------------------- Réglages + +test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => { + assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS); + assert.equal(DEFAULT_SETTINGS.vibrations, true); + assert.equal(DEFAULT_SETTINGS.keepScreenOn, true); + assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS); + assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), { + ...DEFAULT_SETTINGS, + vibrations: false, + theme: "dark", + gamesStarted: 4, + }); + assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), { + ...DEFAULT_SETTINGS, + }); + assert.equal(nextTheme("auto"), "light"); + assert.equal(nextTheme("light"), "dark"); + assert.equal(nextTheme("dark"), "auto"); +}); + +test("consigne longue pendant les trois premières parties, courte ensuite", () => { + assert.equal(LONG_PROMPT_GAMES, 3); + for (const games of [0, 1, 2, 3]) { + assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true); + } + assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false); +}); + +test("stockage des réglages et du dernier plateau affiché", () => { + const data = new Map(); + const store = createSettingsStore({ + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => void data.set(key, value), + }); + assert.equal(store.loadSettings(), null); + store.saveSettings('{"vibrations":false}'); + assert.equal(store.loadSettings(), '{"vibrations":false}'); + assert.equal(store.loadBoard(), null); + const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } }; + store.saveBoard(board); + assert.deepEqual(store.loadBoard(), board); + // Un plateau abîmé est ignoré : la position de départ sera dessinée. + data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}'); + assert.equal(store.loadBoard(), null); + const full = createSettingsStore({ + getItem: () => { + throw new Error("interdit"); + }, + setItem: () => { + throw new Error("plein"); + }, + }); + assert.doesNotThrow(() => full.saveBoard(board)); + assert.equal(full.loadBoard(), null); + assert.equal(full.loadSettings(), null); +}); + +// ------------------------------------------------------- Compte de points + +test("compte de points : le plus petit mène, de l'écart", () => { + assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 }); + assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 }); + assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 }); + // La position de départ : 15 pions, 167 points chacun. + for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) { + assert.equal( + side.points.reduce((a, b) => a + b, 0), + 15, + ); + assert.equal( + side.points.reduce((sum, n, i) => sum + n * (i + 1), 0), + side.pipCount, + ); + } +}); + +// ------------------------------------------------------------------ Charte + +/** Les fichiers .ts d'un dossier, récursivement. */ +function sources(dir: string): string[] { + const url = new URL(dir, root); + return readdirSync(url).flatMap((name) => { + const path = `${dir}${name}`; + return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path]; + }); +} + +test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => { + for (const path of sources("app/src/")) { + const text = read(path); + assert.doesNotMatch( + text, + /\b(window\.)?(confirm|alert|prompt)\s*\(/, + `${path} appelle une boîte native`, + ); + } +}); + +test("les deux blocs du thème sombre de app.css sont identiques", () => { + const css = read("web/app.css"); + const block = (start: string) => { + const from = css.indexOf(start); + assert.ok(from >= 0, `bloc ${start} absent`); + const body = css.slice(css.indexOf("{", from + start.length - 1) + 1); + return body + .slice(0, body.indexOf("}")) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + }; + assert.deepEqual( + block(':root:not([data-theme="light"]) {'), + block(':root[data-theme="dark"] {'), + ); +}); + +test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => { + assert.doesNotMatch(read("web/index.html"), /\sstyle=|