Fusion d'origin/main (MR !44) ; 1c faite, plan clos
Conflits : l'aide garde l'approche de main (défilement sur la carte du terme) ; styles des deux côtés conservés. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+8
-8
@@ -75,7 +75,7 @@ import {
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import { type Updates, updateBannerShown } from "./updates.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
* (interaction.md : on suit le coup sans l'attendre). */
|
||||
@@ -766,18 +766,17 @@ export function startApp(options: AppOptions): void {
|
||||
ui.helpButton.href = helpLink;
|
||||
ui.menuHelp.href = helpLink;
|
||||
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
|
||||
const moveInProgress =
|
||||
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
|
||||
// Le bandeau de mise à jour attend la fin du tour, même hors de
|
||||
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
|
||||
const quiet =
|
||||
!busy &&
|
||||
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
|
||||
if (updateMessage !== null) {
|
||||
ui.updateBanner.hidden = false;
|
||||
ui.updateText.textContent = updateMessage;
|
||||
ui.updateApply.hidden = true;
|
||||
} else {
|
||||
const shown = updateBannerShown(
|
||||
updates?.available ?? false,
|
||||
moveInProgress,
|
||||
!ui.banner.hidden,
|
||||
);
|
||||
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
|
||||
ui.updateBanner.hidden = !shown;
|
||||
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
|
||||
ui.updateApply.hidden = false;
|
||||
@@ -1542,6 +1541,7 @@ export function startApp(options: AppOptions): void {
|
||||
},
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ export function renderBoard(
|
||||
// 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");
|
||||
pieces.setAttribute("class", "pieces");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
lastOpen = opened;
|
||||
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
|
||||
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.dt);
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
} else if (closed !== null) {
|
||||
|
||||
@@ -68,7 +68,7 @@ export const HOME_TEXTS = {
|
||||
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
|
||||
|
||||
// Mise à jour
|
||||
updateAvailable: "Nouvelle version disponible.",
|
||||
updateAvailable: "Nouvelle version prête.",
|
||||
updateApply: "Mettre à jour",
|
||||
updateCheck: "Rechercher une mise à jour",
|
||||
updateChecking: "Recherche d'une mise à jour…",
|
||||
@@ -80,8 +80,8 @@ export const HOME_TEXTS = {
|
||||
tutorialMenu: "Les règles (tutoriel)",
|
||||
tutorialProgress: "Étapes du tutoriel",
|
||||
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
tutorialSkip: "Passer",
|
||||
tutorialSkipLabel: "Passer le tutoriel",
|
||||
tutorialSkip: "Quitter",
|
||||
tutorialSkipLabel: "Quitter le tutoriel",
|
||||
tutorialOverline: (n: number, total: number, practice: boolean) =>
|
||||
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
tutorialEndOverline: "Les règles · fin",
|
||||
@@ -90,8 +90,8 @@ export const HOME_TEXTS = {
|
||||
next: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayLabel: "Revoir le coup",
|
||||
take: "Je prends",
|
||||
drop: "Je passe",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
|
||||
+38
-14
@@ -95,6 +95,8 @@ export interface TutorialViewOptions {
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
|
||||
readonly landscape: () => boolean;
|
||||
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
|
||||
readonly replace: (hash: string) => void;
|
||||
}
|
||||
@@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
@@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const width = g.pointWidth * 0.22;
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", width * 2.4],
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
@@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = width * 2.2;
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(width * 1.2));
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les zones à toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(group, svg.lastChild);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
}
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
@@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
replay.title = HOME_TEXTS.replayLabel;
|
||||
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(section, "p", {
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
@@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
const width = svg.clientWidth || Math.min(window.innerWidth, 640);
|
||||
const box = svg.getBoundingClientRect();
|
||||
if (options.landscape()) {
|
||||
const width = box.width || window.innerWidth;
|
||||
const height = box.height || window.innerHeight;
|
||||
return boardGeometry("landscape", width / height, height / 520);
|
||||
}
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
|
||||
}
|
||||
|
||||
@@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g);
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
|
||||
function markSolved(): void {
|
||||
solved = true;
|
||||
section.toggleAttribute("data-solved", true);
|
||||
next.classList.remove("secondary");
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
@@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
solved = false;
|
||||
section.toggleAttribute("data-solved", false);
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
task.hidden = true;
|
||||
|
||||
+20
-9
@@ -1,8 +1,9 @@
|
||||
// La mise à jour de l'application : le service worker (web/sw.js) installe
|
||||
// chaque nouvelle version publiée dans son propre cache, puis attend
|
||||
// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle
|
||||
// version disponible » ; « Mettre à jour » active la nouvelle version et
|
||||
// recharge la page, qui reprend la partie enregistrée.
|
||||
// l'accord du joueur. L'application affiche alors, entre deux tours, le
|
||||
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
|
||||
// active la nouvelle version et recharge la page, qui reprend la partie
|
||||
// enregistrée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -25,17 +26,27 @@ export interface Updates {
|
||||
export const SKIP_WAITING = "skip-waiting";
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
|
||||
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
|
||||
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
|
||||
* l'ancienne ». Fonction pure, testée sous Node.
|
||||
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
|
||||
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
|
||||
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
|
||||
* pure, testée sous Node.
|
||||
*/
|
||||
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
|
||||
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
|
||||
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
|
||||
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
|
||||
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
moveInProgress: boolean,
|
||||
betweenTwoTurns: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && !moveInProgress && !otherBanner;
|
||||
return available && betweenTwoTurns && !otherBanner;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,11 +113,11 @@ Then("le tutoriel salue votre coup", () => {
|
||||
assert.ok(screen.right.length > 0);
|
||||
});
|
||||
|
||||
When("vous répondez « Je passe »", async () => {
|
||||
When("vous répondez « Passer »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Je prends »", async () => {
|
||||
When("vous répondez « Prendre »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === true;
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
@@ -164,23 +164,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
// ------------------------------------------------------------ Mise à jour
|
||||
|
||||
// Le bandeau dépend de deux choses : une version attend (le service worker
|
||||
// l'a installée), et aucun coup n'est en cours de saisie. La partie est
|
||||
// celle du domaine, sur le vrai moteur.
|
||||
// l'a installée), et la partie est entre deux tours. La partie est celle du
|
||||
// domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
Given("vous avez déjà déplacé un pion de votre coup", async () => {
|
||||
const shownNow = (otherBanner = false) =>
|
||||
updateBannerShown(
|
||||
updateAvailable,
|
||||
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
|
||||
otherBanner,
|
||||
);
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
When("vous déplacez un pion de votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
assert.ok(!shownNow());
|
||||
});
|
||||
|
||||
When("vous avez joué votre coup", async () => {
|
||||
@@ -189,7 +200,7 @@ When("vous avez joué votre coup", async () => {
|
||||
});
|
||||
|
||||
Then("l'application vous propose de mettre à jour", () => {
|
||||
assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
|
||||
assert.ok(shownNow());
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!updateBannerShown(updateAvailable, false, true));
|
||||
assert.ok(!shownNow(true));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user