Conseil du maître : ligne du maître, feuille d'explication, second moteur pour l'analyse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1330ee6df4
commit
5897925b33
@@ -0,0 +1,55 @@
|
||||
// Les textes de l'interface du conseil du maître (ligne, feuille
|
||||
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
|
||||
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { CubeAction } from "../domain/game/engine.js";
|
||||
|
||||
export const ADVICE_TEXTS = {
|
||||
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
|
||||
menuSwitch: "Conseil du maître",
|
||||
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
|
||||
/** Paysage : la pastille du mot, au lecteur d'écran. */
|
||||
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
|
||||
sheetLabel: "Explication du maître",
|
||||
yourMove: "Votre coup",
|
||||
masterMove: "Le coup du maître",
|
||||
choiceLabel: "Coup montré sur le plateau",
|
||||
legendMine: (move: string) =>
|
||||
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
|
||||
legendBest: (move: string) =>
|
||||
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
|
||||
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
|
||||
legendCube: "La position au moment de la décision.",
|
||||
replay: "Rejouer le coup",
|
||||
numbers: "Voir les chiffres du moteur",
|
||||
you: "(vous)",
|
||||
moveNote: (ply: number) =>
|
||||
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
|
||||
cubeNote: (ply: number) =>
|
||||
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
|
||||
cubeRows: {
|
||||
noDouble: "Pas de double",
|
||||
doubleTake: "Double, prend",
|
||||
doublePass: "Double, passe",
|
||||
},
|
||||
glossary: (term: string) => `En savoir plus : « ${term} »`,
|
||||
close: "Fermer",
|
||||
missing: "Le maître n'a pas d'explication pour ce coup.",
|
||||
} as const;
|
||||
|
||||
/** La fiche du glossaire d'une décision de videau, et son titre. */
|
||||
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
|
||||
double: { file: "doubler", term: "Doubler" },
|
||||
noDouble: { file: "doubler", term: "Doubler" },
|
||||
take: { file: "prendre", term: "Prendre" },
|
||||
pass: { file: "passer", term: "Passer" },
|
||||
};
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Le conseil du maître pendant la partie : quelles décisions du joueur
|
||||
// analyser, quand montrer la ligne, quand l'effacer (charte, 1d « La ligne
|
||||
// du maître »). Sans DOM : testé sous Node (app/tests/advice-tracker.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les analyses partent dès que la décision est jouée, sans être attendues :
|
||||
// l'application les confie à un second moteur (main.ts), et le tour de
|
||||
// l'ordinateur continue sur le premier. Chaque résultat est rattaché à sa
|
||||
// décision dans le journal, pour le bilan (1e). La ligne apparaît quand
|
||||
// l'analyse attendue est prête, si le joueur n'a rien fait entre-temps ;
|
||||
// sinon elle n'apparaît pas, et le coup reste commenté dans le journal.
|
||||
|
||||
import { playerDecisions } from "../domain/analysis/analyse.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { GameEvent, GameJournal } from "../domain/game/events.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
|
||||
/** Analyse une décision du joueur (null : rien à en dire). */
|
||||
export type Analyse = (journal: GameJournal, eventId: string) => Promise<Advice | null>;
|
||||
|
||||
export interface AdviceTracker {
|
||||
/** Le conseil à montrer maintenant (null : rien). */
|
||||
readonly shown: Advice | null;
|
||||
/** Après une action du joueur sur game : before est la ligne de la
|
||||
* partie avant l'action. Les décisions ajoutées sont analysées. */
|
||||
track(game: Game, before: readonly GameEvent[]): void;
|
||||
/** L'action suivante du joueur : la ligne s'efface, et une analyse
|
||||
* encore attendue ne s'affichera pas. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface AdviceTrackerOptions {
|
||||
readonly analyse: Analyse;
|
||||
/** Un conseil a été rattaché au journal de game (à enregistrer). */
|
||||
readonly recorded: (game: Game) => void;
|
||||
/** Le conseil à montrer a changé. */
|
||||
readonly changed: () => void;
|
||||
}
|
||||
|
||||
export function createAdviceTracker(options: AdviceTrackerOptions): AdviceTracker {
|
||||
let shown: Advice | null = null;
|
||||
// Chaque attente de ligne a son jeton : clear() l'annule.
|
||||
let waiting: object | null = null;
|
||||
let tracked: Game | null = null;
|
||||
let pending = new Map<string, Promise<Advice | null>>();
|
||||
|
||||
/** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */
|
||||
function adviceOf(game: Game, id: string): Promise<Advice | null> {
|
||||
const known = game.advice[id];
|
||||
if (known !== undefined) {
|
||||
return Promise.resolve(known);
|
||||
}
|
||||
let running = pending.get(id);
|
||||
if (running === undefined) {
|
||||
running = options
|
||||
.analyse(game.journal, id)
|
||||
.catch(() => null)
|
||||
.then((advice) => {
|
||||
if (advice !== null && game.journal.events.some((e) => e.id === id)) {
|
||||
game.recordAdvice(advice);
|
||||
options.recorded(game);
|
||||
}
|
||||
return advice;
|
||||
});
|
||||
pending.set(id, running);
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
return {
|
||||
get shown() {
|
||||
return shown;
|
||||
},
|
||||
track(game, before) {
|
||||
if (game !== tracked) {
|
||||
tracked = game;
|
||||
pending = new Map();
|
||||
}
|
||||
const known = new Set(before.map((e) => e.id));
|
||||
const line = game.events;
|
||||
const added = line.filter((e) => !known.has(e.id));
|
||||
for (const decision of playerDecisions(added)) {
|
||||
void adviceOf(game, decision.id);
|
||||
}
|
||||
const candidates = toShow(line, added);
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
const token = {};
|
||||
waiting = token;
|
||||
void Promise.all(candidates.map((id) => adviceOf(game, id))).then((results) => {
|
||||
if (waiting !== token || tracked !== game) {
|
||||
return; // trop tard : le joueur a déjà agi
|
||||
}
|
||||
waiting = null;
|
||||
shown = choose(results);
|
||||
if (shown !== null) {
|
||||
options.changed();
|
||||
}
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
waiting = null;
|
||||
if (shown !== null) {
|
||||
shown = null;
|
||||
options.changed();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les décisions dont la ligne peut parler après une action : le coup du
|
||||
* joueur et, juste avant lui, son jet sans doubler (il aurait pu doubler) ;
|
||||
* ou son double, ou sa réponse au double de l'ordinateur. Un jet seul ne
|
||||
* montre rien : le joueur réfléchit alors à son coup.
|
||||
*/
|
||||
function toShow(line: readonly GameEvent[], added: readonly GameEvent[]): string[] {
|
||||
const cube = added.find(
|
||||
(e) =>
|
||||
(e.type === "CubeOffered" || e.type === "CubeTaken" || e.type === "CubeDropped") &&
|
||||
e.side === "player",
|
||||
);
|
||||
if (cube !== undefined) {
|
||||
return [cube.id];
|
||||
}
|
||||
const move = added.find((e) => e.type === "MovePlayed" && e.side === "player");
|
||||
if (move?.type !== "MovePlayed") {
|
||||
return [];
|
||||
}
|
||||
const index = line.findIndex((e) => e.id === move.id);
|
||||
const roll = line
|
||||
.slice(0, index)
|
||||
.findLast((e) => e.type === "DiceRolled" && e.side === "player");
|
||||
const ids = move.halfMoves.length > 0 ? [move.id] : [];
|
||||
return roll === undefined ? ids : [roll.id, ...ids];
|
||||
}
|
||||
|
||||
/** Ce que la ligne montre : un double manqué avant le jet s'il était une
|
||||
* faute, sinon le conseil sur le coup ou sur la décision de videau. */
|
||||
function choose(results: readonly (Advice | null)[]): Advice | null {
|
||||
const advices = results.filter((a): a is Advice => a !== null);
|
||||
const missedDouble = advices.find(
|
||||
(a) => a.kind === "cube" && a.action === "noDouble" && a.skill !== "none",
|
||||
);
|
||||
if (missedDouble !== undefined) {
|
||||
return missedDouble;
|
||||
}
|
||||
return advices.find((a) => !(a.kind === "cube" && a.action === "noDouble")) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// La feuille d'explication du maître (#/explication/<événement>), qui monte
|
||||
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
|
||||
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
|
||||
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
|
||||
// position d'avant, puis les chiffres du moteur, repliés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
} from "../domain/game/advice.js";
|
||||
import type { Game, MoveSteps } from "../domain/game/game.js";
|
||||
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { boardModel, moveTrace, placeOf } from "./board-model.js";
|
||||
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
|
||||
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
|
||||
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
|
||||
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
|
||||
good: "verdictGood",
|
||||
doubtful: "verdictDoubtful",
|
||||
bad: "verdictBad",
|
||||
veryBad: "verdictBad",
|
||||
};
|
||||
|
||||
export interface AdviceViewOptions {
|
||||
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
|
||||
readonly veil: HTMLElement;
|
||||
readonly game: () => Game | null;
|
||||
readonly phrases: () => Promise<MasterPhrases>;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** Fermer : quitter l'adresse de la feuille. */
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
export interface AdviceView {
|
||||
/** Ouvre la feuille sur le conseil de l'événement eventId. */
|
||||
show(eventId: string): Promise<void>;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
type Shown = "mine" | "best";
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const { veil } = options;
|
||||
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
|
||||
sheet.setAttribute("role", "dialog");
|
||||
sheet.setAttribute("aria-modal", "true");
|
||||
sheet.setAttribute("aria-labelledby", "explain-title");
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.close();
|
||||
}
|
||||
});
|
||||
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
|
||||
// fermeture, arrête l'animation en cours.
|
||||
let opened = 0;
|
||||
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
|
||||
let playing = 0;
|
||||
|
||||
async function show(eventId: string): Promise<void> {
|
||||
const id = ++opened;
|
||||
const game = options.game();
|
||||
const advice = game?.advice[eventId] ?? null;
|
||||
sheet.replaceChildren();
|
||||
veil.hidden = false;
|
||||
const head = element(sheet, "div", { className: "sheet-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
const verdictLine = element(head, "p", {
|
||||
id: "explain-verdict",
|
||||
className: "explain-verdict",
|
||||
});
|
||||
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
|
||||
close.type = "button";
|
||||
close.append(icon("close"));
|
||||
close.setAttribute("aria-label", ADVICE_TEXTS.close);
|
||||
close.onclick = () => options.close();
|
||||
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
|
||||
if (game === null || advice === null) {
|
||||
title.textContent = ADVICE_TEXTS.sheetLabel;
|
||||
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
|
||||
closeButton();
|
||||
close.focus();
|
||||
return;
|
||||
}
|
||||
const words = masterWords(await options.phrases(), advice);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
verdictLine.dataset.verdict = words.verdict;
|
||||
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
|
||||
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
|
||||
title.textContent = words.title;
|
||||
element(sheet, "p", {
|
||||
id: "explain-phrase",
|
||||
className: "explain-phrase",
|
||||
text: words.phrase,
|
||||
});
|
||||
|
||||
const body = element(sheet, "div", { className: "explain-body" });
|
||||
const boardBox = element(body, "div", { className: "explain-board-box" });
|
||||
if (advice.kind === "move") {
|
||||
await showMoves(id, game, advice, body, boardBox);
|
||||
} else {
|
||||
await showCube(id, game, advice, boardBox);
|
||||
}
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
numbers(advice);
|
||||
if (advice.kind === "cube") {
|
||||
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
|
||||
const link = element(sheet, "a", {
|
||||
id: "explain-glossary",
|
||||
className: "link-button explain-glossary",
|
||||
text: ADVICE_TEXTS.glossary(entry.term),
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: entry.file });
|
||||
}
|
||||
closeButton();
|
||||
close.focus();
|
||||
}
|
||||
|
||||
function closeButton(): void {
|
||||
const actions = element(sheet, "div", { className: "explain-actions" });
|
||||
const done = element(actions, "button", {
|
||||
id: "explain-done",
|
||||
className: "secondary",
|
||||
text: ADVICE_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
}
|
||||
|
||||
/** Le plateau de la feuille, dans son cadre. */
|
||||
function boardSvg(box: HTMLElement): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.id = "explain-board";
|
||||
svg.setAttribute("class", "board explain-board");
|
||||
svg.setAttribute("role", "img");
|
||||
box.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
|
||||
const boards = steps.boards[index];
|
||||
if (boards === undefined) {
|
||||
return;
|
||||
}
|
||||
const width = svg.getBoundingClientRect().width || 358;
|
||||
renderBoard(
|
||||
svg,
|
||||
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
|
||||
boardModel(boards),
|
||||
{
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: null,
|
||||
band: null,
|
||||
controls: null,
|
||||
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
|
||||
},
|
||||
);
|
||||
// Les flèches des traces renvoient à leur pointe par identifiant :
|
||||
// celui du plateau de jeu serait pris à sa place.
|
||||
for (const marker of svg.querySelectorAll("marker[id]")) {
|
||||
marker.id = `explain-${marker.id}`;
|
||||
}
|
||||
for (const path of svg.querySelectorAll("[marker-end]")) {
|
||||
const ref = path.getAttribute("marker-end") ?? "";
|
||||
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue un coup pion par pion (sans mouvement si le système le
|
||||
* demande), puis laisse ses traces. */
|
||||
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
|
||||
const id = ++playing;
|
||||
if (!options.reducedMotion.matches) {
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
if (id !== playing) {
|
||||
return;
|
||||
}
|
||||
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(svg, steps, i, false);
|
||||
const start = checkerSpot(svg, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
draw(svg, steps, i + 1, false);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (id === playing) {
|
||||
draw(svg, steps, steps.halfMoves.length, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function showMoves(
|
||||
id: number,
|
||||
game: Game,
|
||||
advice: MoveAdvice,
|
||||
body: HTMLElement,
|
||||
boardBox: HTMLElement,
|
||||
): Promise<void> {
|
||||
const same = advice.rank === 1;
|
||||
const [mine, best] = await Promise.all([
|
||||
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
|
||||
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
|
||||
]);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
|
||||
const svg = boardSvg(boardBox);
|
||||
if (best === null) {
|
||||
svg.dataset.move = "mine";
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
|
||||
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
draw(svg, mine, 0, false);
|
||||
void animate(svg, mine);
|
||||
return;
|
||||
}
|
||||
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
|
||||
choice.setAttribute("role", "radiogroup");
|
||||
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
|
||||
body.prepend(choice);
|
||||
const buttons: Record<Shown, HTMLButtonElement> = {
|
||||
mine: element(choice, "button", {
|
||||
id: "explain-mine",
|
||||
className: "segment",
|
||||
text: ADVICE_TEXTS.yourMove,
|
||||
}),
|
||||
best: element(choice, "button", {
|
||||
id: "explain-best",
|
||||
className: "segment",
|
||||
text: ADVICE_TEXTS.masterMove,
|
||||
}),
|
||||
};
|
||||
const select = (shown: Shown) => {
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
buttons[key].setAttribute("aria-checked", String(key === shown));
|
||||
}
|
||||
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
|
||||
const text =
|
||||
shown === "mine"
|
||||
? ADVICE_TEXTS.legendMine(notation)
|
||||
: ADVICE_TEXTS.legendBest(notation);
|
||||
legend.textContent = text;
|
||||
svg.setAttribute("aria-label", text);
|
||||
svg.dataset.move = shown;
|
||||
const steps = shown === "mine" ? mine : best;
|
||||
draw(svg, steps, 0, false);
|
||||
void animate(svg, steps);
|
||||
};
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
const button = buttons[key];
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "radio");
|
||||
button.onclick = () => select(key);
|
||||
}
|
||||
// Flèches gauche et droite entre les deux segments.
|
||||
choice.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
|
||||
select(next);
|
||||
buttons[next].focus();
|
||||
}
|
||||
});
|
||||
// Par défaut, le coup du maître (maquette).
|
||||
select("best");
|
||||
}
|
||||
|
||||
async function showCube(
|
||||
id: number,
|
||||
game: Game,
|
||||
advice: CubeAdvice,
|
||||
boardBox: HTMLElement,
|
||||
): Promise<void> {
|
||||
const steps = await game.stepsBefore(advice.eventId, "player", []);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
const svg = boardSvg(boardBox);
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
|
||||
draw(svg, steps, 0, false);
|
||||
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
|
||||
}
|
||||
|
||||
/** « Voir les chiffres du moteur », replié par défaut. */
|
||||
function numbers(advice: Advice): void {
|
||||
const details = element(sheet, "details", {
|
||||
id: "explain-numbers",
|
||||
className: "explain-numbers",
|
||||
});
|
||||
element(details, "summary", { text: ADVICE_TEXTS.numbers });
|
||||
const table = element(details, "div", { className: "explain-table" });
|
||||
const row = (cells: string[], strong: boolean) => {
|
||||
for (const text of cells) {
|
||||
element(table, "span", { className: strong ? "strong" : "", text });
|
||||
}
|
||||
};
|
||||
if (advice.kind === "move") {
|
||||
const best = advice.best.equity;
|
||||
const listed = advice.ranking.map((move, i) => ({ move, rank: i + 1 }));
|
||||
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
|
||||
listed.push({ move: advice.played, rank: advice.rank });
|
||||
}
|
||||
for (const { move, rank } of listed) {
|
||||
const mine = move.notation === advice.played.notation;
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
|
||||
formatEquity(move.equity - best),
|
||||
],
|
||||
mine,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(advice.ply),
|
||||
});
|
||||
} else {
|
||||
// Les lignes de votre décision, en gras : doubler mène à l'une des
|
||||
// deux lignes « Double », selon la réponse de l'ordinateur.
|
||||
const mine = {
|
||||
noDouble: ["noDouble"],
|
||||
double: ["doubleTake", "doublePass"],
|
||||
take: ["doubleTake"],
|
||||
pass: ["doublePass"],
|
||||
}[advice.action];
|
||||
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
|
||||
const bold = mine.includes(key);
|
||||
const name = ADVICE_TEXTS.cubeRows[key];
|
||||
row(
|
||||
[
|
||||
"",
|
||||
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
|
||||
formatEquity(advice.equities[key]),
|
||||
],
|
||||
bold,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide() {
|
||||
opened++;
|
||||
playing++;
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+161
-1
@@ -21,6 +21,13 @@
|
||||
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
|
||||
// changement. Les actions de jeu restent des boutons.
|
||||
|
||||
import {
|
||||
type MasterPhrases,
|
||||
masterLine,
|
||||
masterWords,
|
||||
parsePhrases,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
@@ -29,6 +36,9 @@ import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -40,6 +50,7 @@ import {
|
||||
placeOf,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardAdvice,
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
@@ -119,8 +130,14 @@ export interface AppOptions {
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
/** Le conseil du maître : l'analyse d'une décision du joueur, sur un
|
||||
* moteur à part (elle ne retarde jamais la partie). */
|
||||
readonly analyse: Analyse;
|
||||
}
|
||||
|
||||
/** La source des phrases du maître, copiée dans le site (make site). */
|
||||
const PHRASES_SOURCE = "docs/maitre/phrases.md";
|
||||
|
||||
/** Ce que « Mes parties » demande au stockage. */
|
||||
export interface SavedGamesAccess {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
@@ -194,6 +211,9 @@ export function startApp(options: AppOptions): void {
|
||||
board: byId("board") as unknown as SVGSVGElement,
|
||||
boardBox: child<HTMLElement>(document.documentElement, ".board-box"),
|
||||
info: byId("info"),
|
||||
adviceLine: byId<HTMLAnchorElement>("advice-line"),
|
||||
explainVeil: byId("explain-veil"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
|
||||
bubble: byId("pip-bubble"),
|
||||
@@ -340,6 +360,8 @@ export function startApp(options: AppOptions): void {
|
||||
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
|
||||
ui.menuTheme.prepend(icon("theme"));
|
||||
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
|
||||
ui.menuAdvice.prepend(icon("verdictGood"));
|
||||
child(ui.menuAdvice, ".item-label").textContent = ADVICE_TEXTS.menuSwitch;
|
||||
ui.menuVibrations.prepend(icon("vibrate"));
|
||||
child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations;
|
||||
ui.menuScreenOn.prepend(icon("screenOn"));
|
||||
@@ -433,6 +455,36 @@ export function startApp(options: AppOptions): void {
|
||||
// Les parties jouées et gagnées, lues dans « Mes parties » pour
|
||||
// l'accueil.
|
||||
let homeProgress: { played: number; won: number } | null = null;
|
||||
// Le conseil du maître : ses phrases (lues une fois), les analyses en
|
||||
// cours et la ligne à montrer.
|
||||
let phrases: MasterPhrases | null = null;
|
||||
const phrasesLoading: Promise<MasterPhrases> = fetch(PHRASES_SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`phrases du maître : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
phrases = parsePhrases(text);
|
||||
render();
|
||||
return phrases;
|
||||
});
|
||||
phrasesLoading.catch(() => undefined);
|
||||
const adviceTracker = createAdviceTracker({
|
||||
analyse: options.analyse,
|
||||
// Le conseil rattaché au journal est enregistré avec la partie, si
|
||||
// elle est toujours à l'écran.
|
||||
recorded: (g) => {
|
||||
if (g === game) {
|
||||
save();
|
||||
}
|
||||
},
|
||||
changed: () => render(),
|
||||
});
|
||||
// La ligne du maître dessinée (identifiant de sa décision) : elle n'est
|
||||
// reconstruite, en fondu, que si elle change.
|
||||
let adviceDrawn: string | null = null;
|
||||
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
|
||||
const portraitQuery = window.matchMedia("(orientation: portrait)");
|
||||
@@ -538,6 +590,7 @@ export function startApp(options: AppOptions): void {
|
||||
band:
|
||||
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
@@ -545,6 +598,8 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
const advice = shownAdvice();
|
||||
renderAdviceLine(notice === null && !stuck ? advice : null);
|
||||
if (notice !== null) {
|
||||
ui.info.textContent = notice;
|
||||
} else if (stuck && state.roll !== null) {
|
||||
@@ -607,6 +662,69 @@ export function startApp(options: AppOptions): void {
|
||||
void keepScreenOn(isPlaying(game) || state.phase === "doubled");
|
||||
}
|
||||
|
||||
/** Le conseil du maître à montrer : le réglage le permet, ses phrases
|
||||
* sont lues, et l'analyse attendue est prête. */
|
||||
function shownAdvice(): {
|
||||
advice: Advice;
|
||||
line: string;
|
||||
words: ReturnType<typeof masterWords>;
|
||||
} | null {
|
||||
const advice = adviceTracker.shown;
|
||||
if (!settings.masterAdvice || phrases === null || advice === null) {
|
||||
return null;
|
||||
}
|
||||
const words = masterWords(phrases, advice);
|
||||
return { advice, line: masterLine(words), words };
|
||||
}
|
||||
|
||||
/** Portrait : la ligne du maître à la place du dernier coup (charte 1d) :
|
||||
* pictogramme, mot en couleur, phrase en italique, chevron ; un lien vers
|
||||
* la feuille d'explication. */
|
||||
function renderAdviceLine(shown: ReturnType<typeof shownAdvice>): void {
|
||||
ui.info.hidden = shown !== null;
|
||||
ui.adviceLine.hidden = shown === null;
|
||||
const id = shown?.advice.eventId ?? null;
|
||||
if (shown === null || id === adviceDrawn) {
|
||||
adviceDrawn = id;
|
||||
return;
|
||||
}
|
||||
adviceDrawn = id;
|
||||
const link = ui.adviceLine;
|
||||
link.replaceChildren();
|
||||
link.href = routeHash({ screen: "explanation", event: shown.advice.eventId });
|
||||
link.dataset.verdict = shown.words.verdict;
|
||||
link.setAttribute("aria-label", ADVICE_TEXTS.lineLabel(shown.line));
|
||||
link.append(icon(VERDICT_ICONS[shown.words.verdict]));
|
||||
const text = document.createElement("span");
|
||||
text.className = "advice-text";
|
||||
const word = document.createElement("strong");
|
||||
word.className = "advice-word";
|
||||
word.textContent = `${shown.words.word}.`;
|
||||
const phrase = document.createElement("em");
|
||||
phrase.className = "advice-phrase";
|
||||
phrase.textContent = shown.words.phrase;
|
||||
text.append(word, " ", phrase);
|
||||
link.append(text, icon("chevron"));
|
||||
// Le fondu d'apparition repart à chaque nouvelle ligne.
|
||||
link.classList.remove("appear");
|
||||
void link.offsetWidth;
|
||||
link.classList.add("appear");
|
||||
}
|
||||
|
||||
/** Paysage : la pastille du conseil dans la bande centrale. */
|
||||
function boardAdvice(shown: ReturnType<typeof shownAdvice>): BoardAdvice | null {
|
||||
if (shown === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
href: routeHash({ screen: "explanation", event: shown.advice.eventId }),
|
||||
verdict: shown.words.verdict,
|
||||
icon: VERDICT_ICONS[shown.words.verdict],
|
||||
word: shown.words.word,
|
||||
label: ADVICE_TEXTS.chipLabel(shown.line),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -751,6 +869,7 @@ export function startApp(options: AppOptions): void {
|
||||
);
|
||||
child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme];
|
||||
ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations));
|
||||
ui.menuAdvice.setAttribute("aria-checked", String(settings.masterAdvice));
|
||||
ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn));
|
||||
}
|
||||
|
||||
@@ -966,11 +1085,19 @@ export function startApp(options: AppOptions): void {
|
||||
bubbleSide = null;
|
||||
notice = null;
|
||||
ui.bubble.hidden = true;
|
||||
// L'action suivante du joueur efface la ligne du maître.
|
||||
adviceTracker.clear();
|
||||
render();
|
||||
const before = game;
|
||||
const lineBefore = game.events;
|
||||
const wasOver = game.state.phase === "over";
|
||||
try {
|
||||
await action(game);
|
||||
// Ses décisions partent à l'analyse, sans être attendues : le
|
||||
// tour de l'ordinateur suit tout de suite.
|
||||
if (game === before) {
|
||||
adviceTracker.track(game, lineBefore);
|
||||
}
|
||||
save();
|
||||
await computerTurns();
|
||||
// Une partie vient de se terminer (pas une partie finie
|
||||
@@ -1078,7 +1205,10 @@ export function startApp(options: AppOptions): void {
|
||||
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
|
||||
const navigation = startNavigation((route) => applyRoute(route));
|
||||
const isSheet = (route: Route) =>
|
||||
route.screen === "menu" || route.screen === "games" || route.screen === "install";
|
||||
route.screen === "menu" ||
|
||||
route.screen === "games" ||
|
||||
route.screen === "install" ||
|
||||
route.screen === "explanation";
|
||||
|
||||
// « Mes parties » : reprendre une partie (ou une branche) en change à
|
||||
// l'écran, sans recharger la page.
|
||||
@@ -1102,6 +1232,15 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
|
||||
|
||||
// La feuille d'explication du maître (#/explication/<événement>).
|
||||
const adviceView = createAdviceView({
|
||||
veil: ui.explainVeil,
|
||||
game: () => game,
|
||||
phrases: () => phrasesLoading,
|
||||
reducedMotion,
|
||||
close: () => leaveSheet(),
|
||||
});
|
||||
|
||||
/** 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 {
|
||||
@@ -1271,6 +1410,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuVeil.hidden = true;
|
||||
ui.installVeil.hidden = true;
|
||||
gamesView?.hide();
|
||||
adviceView.hide();
|
||||
if (menuWasOpen) {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
@@ -1334,6 +1474,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuFullscreen.onclick = actions.fullscreen;
|
||||
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
|
||||
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
|
||||
ui.menuAdvice.onclick = () => saveSettings({ masterAdvice: !settings.masterAdvice });
|
||||
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
|
||||
ui.fullscreenButton.onclick = actions.fullscreen;
|
||||
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
|
||||
@@ -1430,6 +1571,15 @@ export function startApp(options: AppOptions): void {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
}
|
||||
// La feuille d'explication du maître, sur une décision de la partie
|
||||
// à l'écran.
|
||||
if (route.screen === "explanation") {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
void adviceView.show(route.event);
|
||||
} else {
|
||||
adviceView.hide();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
const expanded = route.game;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -1942,6 +2092,10 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
|
||||
name = null;
|
||||
}
|
||||
// La feuille d'explication ouverte : le jeu attend qu'on la ferme.
|
||||
if (!ui.explainVeil.hidden && name !== null && name !== "close") {
|
||||
name = null;
|
||||
}
|
||||
if (name !== null) {
|
||||
event.preventDefault();
|
||||
actions[name]();
|
||||
@@ -1972,6 +2126,12 @@ export function startApp(options: AppOptions): void {
|
||||
announcedEnd = game.journal.head;
|
||||
}
|
||||
}
|
||||
// Ouverte par son adresse avant que la partie soit là : la
|
||||
// feuille d'explication la retrouve maintenant.
|
||||
const route = navigation.route;
|
||||
if (route.screen === "explanation") {
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
// Comme après une action du joueur : l'ordinateur joue si c'est
|
||||
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
|
||||
@@ -83,6 +83,21 @@ export interface BoardDecor {
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
}
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
readonly href: string;
|
||||
/** Appréciation : la couleur et le pictogramme. */
|
||||
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
|
||||
readonly icon: IconName;
|
||||
readonly word: string;
|
||||
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -188,8 +203,10 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
drawBand(svg, g, decor.band, chip);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
@@ -215,6 +232,9 @@ export function renderBoard(
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -496,12 +516,13 @@ function addRing(
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
* centrale, sur une pastille accent-soft ; elle laisse la place before à
|
||||
* gauche (la pastille du conseil du maître). */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const width = 6 * g.pointWidth - 24 - before;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
const x = g.x0 + 12 + before;
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
@@ -524,6 +545,63 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
|
||||
* par lettre, police Alegreya Sans) et marges. */
|
||||
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
|
||||
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le conseil du maître, une pastille au début de la bande
|
||||
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
|
||||
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
|
||||
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
|
||||
*/
|
||||
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
|
||||
const middle = g.height / 2;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const width = adviceChipWidth(g, advice);
|
||||
const x = g.x0 + 12;
|
||||
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
|
||||
add(group, "rect", {
|
||||
class: "advice-chip-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
const size = g.bandFont;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
|
||||
});
|
||||
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: "advice-chip-text",
|
||||
x: x + 16 + size + 6,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
advice.word,
|
||||
);
|
||||
const link = add(hits, "a", {
|
||||
href: advice.href,
|
||||
id: "advice-chip",
|
||||
role: "link",
|
||||
"aria-label": advice.label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x,
|
||||
y: middle - Math.max(height, g.target) / 2,
|
||||
width,
|
||||
height: Math.max(height, g.target),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
|
||||
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TEXTS } from "./texts.js";
|
||||
export const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/jouer-un-match.feature",
|
||||
"docs/scenarios/comprendre-mes-coups.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
|
||||
@@ -31,6 +31,9 @@ export type Route =
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
| { readonly screen: "newGame" }
|
||||
/** #/explication/<événement> : la feuille d'explication du maître sur
|
||||
* une décision de la partie en cours (étape 1d). */
|
||||
| { readonly screen: "explanation"; readonly event: string }
|
||||
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
|
||||
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
|
||||
/** Une adresse que l'application ne connaît pas. */
|
||||
@@ -90,6 +93,8 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "nouvelle-partie":
|
||||
return name === null ? { screen: "newGame" } : unknown;
|
||||
case "explication":
|
||||
return name === null ? unknown : { screen: "explanation", event: name };
|
||||
case "parties":
|
||||
return { screen: "games", game: name };
|
||||
case "installer":
|
||||
@@ -172,6 +177,8 @@ export function routeHash(route: Route): string {
|
||||
return "#/menu";
|
||||
case "newGame":
|
||||
return "#/nouvelle-partie";
|
||||
case "explanation":
|
||||
return `#/explication/${route.event}`;
|
||||
case "position": {
|
||||
const position = `#/position/${encodeURIComponent(route.position)}`;
|
||||
return route.match === null
|
||||
|
||||
Reference in New Issue
Block a user