Plateau jouable en PWA : interface, Web Worker, stockage, service worker

L'application est à la racine du site : plateau en SVG, saisie au toucher
(pion puis case d'arrivée, proposées à partir du moteur), boutons du jet,
du videau et de nouvelle partie, coup de l'ordinateur affiché avec son
temps, fin de partie, reprise depuis localStorage, aide lue dans les
copies de docs/. Le moteur tourne dans le Web Worker existant ; main.ts
est le seul assembleur (nouvelles règles dependency-cruiser). Le service
worker met tout le site en cache, avec une empreinte du contenu comme
version. Les pages de test du moteur passent sous /moteur/. Politique de
sécurité du contenu dans le Caddyfile ; make test-browser joue une partie
dans Chromium et Firefox sans écran (Playwright, hors de make test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 21:47:12 +01:00
co-authored by Claude Opus 5.5
parent 613ff38254
commit a8636b3f83
37 changed files with 2548 additions and 106 deletions
+283
View File
@@ -0,0 +1,283 @@
/* Style de l'application. Palette provisoire et sobre (bois, ivoire, un
* accent vert), en attendant l'identité définitive : toutes les couleurs et
* tailles sont des variables, redéfinies pour le thème sombre. */
:root {
--text: #2b211a;
--muted: #6b5d50;
--background: #f7f2e8;
--accent: #2e6b4f;
--on-accent: #ffffff;
--border: #cbbfae;
--error: #a3261b;
--frame: #6b4426;
--bed: #e6cfa6;
--bar: #5c3a20;
--tray: #57371e;
--point-a: #9a6439;
--point-b: #c79b69;
--point-number: #f3e7d0;
--checker-player: #fbf6ea;
--checker-player-edge: #7d6a52;
--checker-computer: #2b221c;
--checker-computer-edge: #0d0a08;
--count-player: #2b211a;
--count-computer: #fbf6ea;
--die-player: #fbf6ea;
--pip-player: #2b211a;
--die-computer: #2b221c;
--pip-computer: #fbf6ea;
--cube: #fbf6ea;
--cube-text: #2b211a;
--highlight: #2e6b4f;
--target: rgba(46, 107, 79, 0.35);
--last-move: #d9822b;
--font-size: 17px;
--button-height: 48px;
--radius: 8px;
--page-width: 40rem;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #ece4d6;
--muted: #b3a796;
--background: #1b1714;
--accent: #6fc3a0;
--on-accent: #10221a;
--border: #4d443b;
--error: #f08a7e;
--frame: #3a2616;
--bed: #6d5136;
--bar: #2f1f12;
--tray: #2c1d11;
--point-a: #4a3120;
--point-b: #8b6943;
--point-number: #d9cbb4;
--checker-player: #efe6d2;
--checker-player-edge: #8e7c64;
--checker-computer: #17110d;
--checker-computer-edge: #a8957d;
--highlight: #7fd6b0;
--target: rgba(127, 214, 176, 0.35);
--last-move: #f0a050;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0 auto;
max-width: var(--page-width);
padding: 0 0.5rem 1rem;
font: var(--font-size) / 1.45 system-ui, sans-serif;
color: var(--text);
background: var(--background);
}
[hidden] {
display: none !important;
}
.topbar {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0;
}
.topbar h1 {
flex: 1;
margin: 0;
font-size: 1.25rem;
}
button {
min-height: var(--button-height);
padding: 0 1rem;
font: inherit;
font-weight: 600;
color: var(--on-accent);
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius);
}
button.secondary {
color: var(--text);
background: transparent;
border-color: var(--border);
}
.topbar button {
min-height: 40px;
padding: 0 0.75rem;
font-size: 0.9rem;
}
button:disabled {
opacity: 0.45;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button {
flex: 1;
}
.pips {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--muted);
text-align: center;
}
.info,
.prompt {
min-height: 1.45em;
margin: 0.4rem 0;
}
.info {
color: var(--muted);
}
.prompt {
font-weight: 600;
}
/* L'ordinateur réfléchit : trois points qui apparaissent l'un après l'autre. */
.prompt.thinking::after {
content: "";
animation: thinking 1.2s steps(4, end) infinite;
}
@keyframes thinking {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
/* ------------------------------------------------------------ Plateau */
.board {
display: block;
width: 100%;
height: auto;
aspect-ratio: 640 / 520;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.board-frame { fill: var(--frame); }
.board-bed { fill: var(--bed); }
.board-bar { fill: var(--bar); }
.board-tray { fill: var(--tray); }
.point-a { fill: var(--point-a); }
.point-b { fill: var(--point-b); }
.point-number {
fill: var(--muted);
font-size: 13px;
text-anchor: middle;
}
.checker {
stroke-width: 2;
}
.checker-player {
fill: var(--checker-player);
stroke: var(--checker-player-edge);
}
.checker-computer {
fill: var(--checker-computer);
stroke: var(--checker-computer-edge);
}
.checker.movable {
stroke: var(--highlight);
stroke-width: 4;
}
.checker.selected {
stroke: var(--highlight);
stroke-width: 7;
}
.checker-count {
font-size: 18px;
font-weight: 700;
text-anchor: middle;
}
.count-player { fill: var(--count-player); }
.count-computer { fill: var(--count-computer); }
.target-zone { fill: var(--target); }
.last-move { fill: var(--last-move); }
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); }
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); }
.pip-player { fill: var(--pip-player); }
.pip-computer { fill: var(--pip-computer); }
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
.cube-value {
fill: var(--cube-text);
font-size: 17px;
font-weight: 700;
text-anchor: middle;
}
.hit {
fill: transparent;
cursor: pointer;
}
/* --------------------------------------------------------------- Aide */
.help h2 {
margin-top: 1.5rem;
font-size: 1.2rem;
}
.help h3 {
margin-bottom: 0.25rem;
font-size: 1rem;
}
.steps {
padding-left: 1.25rem;
}
.glossary dt {
margin-top: 0.6rem;
font-weight: 600;
}
.glossary dd {
margin: 0;
}
.about {
margin-top: 2rem;
font-size: 0.85rem;
color: var(--muted);
}
.about a {
color: var(--accent);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

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

After

Width:  |  Height:  |  Size: 449 B

+32 -37
View File
@@ -3,46 +3,41 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : test du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="page.mjs"></script>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#6b4426">
<title>Ustagammon</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
<script type="module" src="app/main.js"></script>
</head>
<body>
<main>
<h1>Test du moteur</h1>
<p>Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.</p>
<p id="status" class="status">Chargement du moteur…</p>
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
<header class="topbar">
<h1>Ustagammon</h1>
<button id="help-button" type="button" class="secondary"></button>
<button id="new-game" type="button" class="secondary"></button>
</header>
<form id="form">
<label>Exemple
<select id="example"><option value="">(choisir une position de référence)</option></select>
</label>
<label>Position ID
<input id="position-id" required autocomplete="off" spellcheck="false" value="4HPwATDgc/ABMA">
</label>
<label>Match ID
<input id="match-id" required autocomplete="off" spellcheck="false" value="cIkFAAAAAAAA">
</label>
<div class="row">
<label>Dé 1 <input id="die1" type="number" min="1" max="6" required value="3"></label>
<label>Dé 2 <input id="die2" type="number" min="1" max="6" required value="1"></label>
<label>Niveau
<select id="ply">
<option value="0">rapide (0 pli)</option>
<option value="2">fort (2 plis)</option>
</select>
</label>
</div>
<button id="best-move" type="submit" disabled>Meilleur coup</button>
</form>
<section id="result" class="result" hidden>
<p>Meilleur coup : <strong id="move"></strong></p>
<p id="details"></p>
<p>Temps de calcul : <strong id="time"></strong></p>
</section>
<p><a href="bench.html">Page de mesure</a></p>
<main id="game">
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="pips" class="pips"></p>
<p id="info" class="info" aria-live="polite"></p>
<p id="prompt" class="prompt" aria-live="polite"></p>
<div class="actions">
<button id="roll" type="button" hidden></button>
<button id="double" type="button" class="secondary" hidden></button>
<button id="take" type="button" hidden></button>
<button id="drop" type="button" class="secondary" hidden></button>
<button id="cancel" type="button" class="secondary" hidden></button>
<button id="again" type="button" hidden></button>
</div>
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
</main>
<section id="help" class="help" hidden>
<button id="help-back" type="button" class="secondary"></button>
<div id="help-content"></div>
</section>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Ustagammon",
"short_name": "Ustagammon",
"description": "Jouer au backgammon contre l'ordinateur, hors ligne.",
"lang": "fr",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f7f2e8",
"theme_color": "#6b4426",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
@@ -12,8 +12,9 @@ const $ = (id) => document.getElementById(id);
// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
// serveur qui compresse ses réponses).
// name est relatif à la racine du site, un dossier au-dessus de cette page.
async function fileSize(name) {
const blob = await (await fetch(name, { cache: "no-store" })).blob();
const blob = await (await fetch("../" + name, { cache: "no-store" })).blob();
const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip"));
const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength;
return { name, bytes: blob.size, gzipBytes };
@@ -37,7 +38,7 @@ async function measure() {
await engine.ready;
const loadMs = performance.now() - start;
const { positions } = await (await fetch("wasm/positions.json")).json();
const { positions } = await (await fetch("../wasm/positions.json")).json();
let memoryBytes = 0;
const times = await measureTimes({
bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
@@ -20,13 +20,13 @@ export const TIME_THRESHOLDS = [
// Taille totale à télécharger, compressée : moins de 40 Mo.
export const DOWNLOAD_LIMIT_BYTES = 40e6;
// Fichiers téléchargés pour faire tourner le moteur dans la page, chemins
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
// Fichiers téléchargés pour faire tourner le moteur dans la page de test,
// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
export const DOWNLOADED_FILES = [
"index.html",
"style.css",
"page.mjs",
"engine-client.mjs",
"moteur/index.html",
"moteur/style.css",
"moteur/page.mjs",
"moteur/engine-client.mjs",
"engine-worker.mjs",
"engine-wasm.mjs",
"wasm/engine.mjs",
@@ -1,5 +1,6 @@
// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker
// (engine-worker.mjs) et lui envoie les commandes.
// engine-client.mjs -- le moteur vu depuis les pages de test : démarre le
// Web Worker (../engine-worker.mjs, partagé avec l'application) et lui envoie
// les commandes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -8,10 +9,10 @@
// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le
// Worker. La promesse est rejetée si le moteur signale une erreur.
import { commands } from "./engine-wasm.mjs";
import { commands } from "../engine-wasm.mjs";
export function startEngine() {
const worker = new Worker(new URL("engine-worker.mjs", import.meta.url), { type: "module" });
const worker = new Worker(new URL("../engine-worker.mjs", import.meta.url), { type: "module" });
const pending = new Map();
let nextId = 1;
let resolveReady, rejectReady;
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ustagammon : test du moteur</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="page.mjs"></script>
</head>
<body>
<main>
<h1>Test du moteur</h1>
<p>Le moteur GNU Backgammon, compilé en WebAssembly, calcule dans le navigateur.</p>
<p id="status" class="status">Chargement du moteur…</p>
<form id="form">
<label>Exemple
<select id="example"><option value="">(choisir une position de référence)</option></select>
</label>
<label>Position ID
<input id="position-id" required autocomplete="off" spellcheck="false" value="4HPwATDgc/ABMA">
</label>
<label>Match ID
<input id="match-id" required autocomplete="off" spellcheck="false" value="cIkFAAAAAAAA">
</label>
<div class="row">
<label>Dé 1 <input id="die1" type="number" min="1" max="6" required value="3"></label>
<label>Dé 2 <input id="die2" type="number" min="1" max="6" required value="1"></label>
<label>Niveau
<select id="ply">
<option value="0">rapide (0 pli)</option>
<option value="2">fort (2 plis)</option>
</select>
</label>
</div>
<button id="best-move" type="submit" disabled>Meilleur coup</button>
</form>
<section id="result" class="result" hidden>
<p>Meilleur coup : <strong id="move"></strong></p>
<p id="details"></p>
<p>Temps de calcul : <strong id="time"></strong></p>
</section>
<p><a href="bench.html">Page de mesure</a> · <a href="../">L'application</a></p>
</main>
</body>
</html>
+1 -1
View File
@@ -21,7 +21,7 @@ engine.ready.then(
);
// Exemples : les positions de référence avec des dés.
fetch("wasm/positions.json")
fetch("../wasm/positions.json")
.then((response) => response.json())
.then(({ positions }) => {
for (const p of positions.filter((p) => p.type === "move")) {
+61
View File
@@ -0,0 +1,61 @@
// sw.js -- service worker d'Ustagammon : l'application hors ligne.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// À l'installation, tous les fichiers du site (dont les 8,7 Mo de données du
// moteur) sont mis en cache ; ensuite, chaque fichier est servi depuis ce
// cache, sans réseau. VERSION et FILES sont écrits en tête de ce fichier par
// scripts/service-worker.sh au moment de make site.
//
// Mise à jour : à chaque publication, VERSION change, donc ce fichier. Le
// navigateur installe alors la nouvelle version, qui télécharge tout dans un
// nouveau cache, prend la main tout de suite et efface l'ancien cache. La
// page déjà ouverte a chargé tous ses fichiers au démarrage : elle continue
// sans rien remarquer, et le prochain lancement utilise la nouvelle version.
/* global VERSION, FILES */
const CACHE = `ustagammon-${VERSION}`;
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
// no-cache : revalider auprès du serveur, jamais une copie
// périmée du cache HTTP du navigateur.
await cache.addAll(FILES.map((file) => new Request(file, { cache: "no-cache" })));
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
if (name !== CACHE) {
await caches.delete(name);
}
}
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) {
return;
}
event.respondWith(
(async () => {
const cached = await caches.match(request, {
cacheName: CACHE,
ignoreSearch: true,
ignoreVary: true,
});
return cached ?? fetch(request);
})(),
);
});