Match, niveaux et réglages : écran Nouvelle partie, Crawford, Jacoby (1c) #49
+7
-10
@@ -40,15 +40,12 @@ const opening = (async () => {
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
|
||||
const session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
// Le niveau et le match de chaque partie viennent de l'écran « Nouvelle
|
||||
// partie » (ou de la partie précédente du match).
|
||||
const session = createGameSession(library, {
|
||||
start: (newGame) => Game.start(engine, newGame ?? {}),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
const saved = await session.restore();
|
||||
return { library, session, saved };
|
||||
})();
|
||||
@@ -68,7 +65,7 @@ startApp({
|
||||
stored = opened;
|
||||
}),
|
||||
]),
|
||||
newGame: (setup) => storage().session.newGame(setup),
|
||||
newGame: (newGame) => storage().session.newGame(newGame),
|
||||
positions: {
|
||||
read: (link) => readPosition(engine, link),
|
||||
linkOf: (game) => linkOf(engine, game.state),
|
||||
|
||||
+156
-35
@@ -23,11 +23,12 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
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 {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
@@ -58,9 +60,18 @@ import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import {
|
||||
chosenGame,
|
||||
isFirstLaunch,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
} from "./settings.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
@@ -84,9 +95,9 @@ export interface CachedBoard {
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
|
||||
* une position donnée (setup). */
|
||||
readonly newGame: (setup?: GameSetup) => Game;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré) : niveau,
|
||||
* match, ou position de départ (setup). */
|
||||
readonly newGame: (newGame?: NewGame) => Game;
|
||||
/** Les liens vers une position (ADR 0013). */
|
||||
readonly positions: PositionsAccess;
|
||||
/** La partie enregistrée, ou null s'il n'y en a pas. */
|
||||
@@ -173,6 +184,7 @@ export function startApp(options: AppOptions): void {
|
||||
strip,
|
||||
who: child<HTMLElement>(strip, ".who"),
|
||||
lead: child<HTMLElement>(strip, ".lead"),
|
||||
score: child<HTMLElement>(strip, ".match-score"),
|
||||
count: child<HTMLButtonElement>(strip, ".count"),
|
||||
};
|
||||
};
|
||||
@@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void {
|
||||
menu: byId("menu"),
|
||||
menuTitle: byId("menu-title"),
|
||||
menuClose: byId<HTMLButtonElement>("menu-close"),
|
||||
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
|
||||
menuNewGame: byId<HTMLAnchorElement>("menu-new-game"),
|
||||
newGameSection: byId("new-game"),
|
||||
matchLine: byId("match-line"),
|
||||
menuGames: byId<HTMLAnchorElement>("menu-games"),
|
||||
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
|
||||
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
|
||||
@@ -510,7 +524,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, g, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
// Partie Crawford : pas de videau.
|
||||
cube: state.match?.crawford === true ? null : state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
@@ -526,6 +541,7 @@ export function startApp(options: AppOptions): void {
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
renderMatch(state);
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
@@ -561,6 +577,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -573,7 +590,6 @@ export function startApp(options: AppOptions): void {
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.menuNewGame,
|
||||
ui.positionPlay,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
@@ -624,7 +640,6 @@ export function startApp(options: AppOptions): void {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.moveHint.hidden = true;
|
||||
ui.menuNewGame.disabled = true;
|
||||
ui.menuCopyLink.disabled = true;
|
||||
ui.positionPlay.disabled = true;
|
||||
if (notice !== null) {
|
||||
@@ -646,9 +661,42 @@ export function startApp(options: AppOptions): void {
|
||||
back: replay ? "replay" : "undo",
|
||||
backEnabled: !busy && (replay || undoable),
|
||||
menuEnabled: true,
|
||||
match: game === null ? null : matchScore(game.state),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le match : la ligne « Match en N points » sous la barre haute, et le
|
||||
* score de chaque camp dans son bandeau (portrait). */
|
||||
function renderMatch(state: GameState): void {
|
||||
const score = matchScore(state);
|
||||
ui.matchLine.hidden = score === null;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const badge = ui.strips[side].score;
|
||||
badge.hidden = score === null;
|
||||
badge.textContent = score === null ? "" : String(score[side]);
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
score === null
|
||||
? ""
|
||||
: side === "player"
|
||||
? MATCH_TEXTS.playerScore(score.player)
|
||||
: MATCH_TEXTS.computerScore(score.computer),
|
||||
);
|
||||
}
|
||||
if (score === null || state.match === null) {
|
||||
ui.matchLine.textContent = "";
|
||||
ui.matchLine.removeAttribute("aria-label");
|
||||
return;
|
||||
}
|
||||
ui.matchLine.textContent = MATCH_TEXTS.matchLine(score.length, state.match.crawford);
|
||||
ui.matchLine.setAttribute(
|
||||
"aria-label",
|
||||
`${MATCH_TEXTS.scoreSpoken(score.length, score.computer, score.player)}${
|
||||
state.match.crawford ? ", partie Crawford" : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */
|
||||
function renderStrips(pips: Readonly<Record<Side, number>>, turn: Side | null): void {
|
||||
const lead = pipLead(pips);
|
||||
@@ -681,7 +729,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.panelText.textContent = end.title;
|
||||
detail.textContent = end.detail;
|
||||
ui.panelSecondary.textContent = TEXTS.reviewGame;
|
||||
ui.panelPrimary.textContent = TEXTS.newGame;
|
||||
ui.panelPrimary.textContent = afterGameLabel(g);
|
||||
ui.panelVeil.dataset.kind = "over";
|
||||
} else if (ask) {
|
||||
overline.textContent = "";
|
||||
@@ -737,6 +785,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
if (navigation.route.screen === "newGame") {
|
||||
newGameView.render(busy || game === null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
|
||||
@@ -851,7 +902,7 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const decor = {
|
||||
dice: shownDice(game, false),
|
||||
cube: game.state.cube,
|
||||
cube: game.state.match?.crawford === true ? null : game.state.cube,
|
||||
...NO_MARKS,
|
||||
band: null,
|
||||
controls: null,
|
||||
@@ -936,24 +987,27 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
function startNewGame(setup?: GameSetup): void {
|
||||
game = options.newGame(setup);
|
||||
/** Commence une partie : celle des choix de l'écran « Nouvelle partie »
|
||||
* si rien d'autre n'est demandé. */
|
||||
function startNewGame(chosen: NewGame = chosenGame(settings)): void {
|
||||
game = options.newGame(chosen);
|
||||
panelDismissed = false;
|
||||
announcedEnd = null;
|
||||
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
|
||||
const opening = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
info =
|
||||
setup !== undefined
|
||||
chosen.setup !== undefined
|
||||
? TEXTS.startedFromPosition
|
||||
: opening?.type === "DiceRolled"
|
||||
? TEXTS.opening(opening.roll[0], opening.roll[1])
|
||||
: "";
|
||||
}
|
||||
|
||||
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
|
||||
* position) : tout de suite, sans confirmation ; une partie en cours
|
||||
* reste reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(setup?: GameSetup): void {
|
||||
/** Une nouvelle partie (« Commencer » de l'écran « Nouvelle partie »,
|
||||
* « Jouer à partir d'ici » depuis une position, « Partie suivante » d'un
|
||||
* match) : tout de suite, sans confirmation ; une partie en cours reste
|
||||
* reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(chosen?: NewGame): void {
|
||||
closeOverlays();
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
@@ -962,7 +1016,19 @@ export function startApp(options: AppOptions): void {
|
||||
if (old !== null) {
|
||||
showBanner(old);
|
||||
}
|
||||
void act(() => startNewGame(setup));
|
||||
void act(() => startNewGame(chosen));
|
||||
}
|
||||
|
||||
/** Après une partie finie : la partie suivante du match s'il continue,
|
||||
* sinon l'écran « Nouvelle partie ». */
|
||||
function afterGame(): void {
|
||||
const next = game?.nextGame ?? null;
|
||||
if (next !== null) {
|
||||
newGame(next);
|
||||
} else {
|
||||
closeOverlays();
|
||||
navigation.go(routeHash({ screen: "newGame" }));
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(journal: GameJournal): void {
|
||||
@@ -1102,8 +1168,8 @@ export function startApp(options: AppOptions): void {
|
||||
drop: () => {
|
||||
const g = canAct();
|
||||
if (g?.state.phase === "doubled" && g.state.turn === "computer") {
|
||||
void act((g) => {
|
||||
g.drop();
|
||||
void act(async (g) => {
|
||||
await g.drop();
|
||||
info = "";
|
||||
});
|
||||
}
|
||||
@@ -1130,7 +1196,7 @@ export function startApp(options: AppOptions): void {
|
||||
selected = null;
|
||||
void settle(true);
|
||||
},
|
||||
again: () => newGame(),
|
||||
again: () => afterGame(),
|
||||
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
|
||||
// lien) ; le refermer, revenir à l'écran d'avant.
|
||||
menu: () => {
|
||||
@@ -1162,6 +1228,8 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave(isHelpScreen);
|
||||
} else if (route.screen === "position") {
|
||||
navigation.leave((r) => r.screen === "position");
|
||||
} else if (route.screen === "newGame") {
|
||||
navigation.leave((r) => r.screen === "newGame");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
}
|
||||
@@ -1234,7 +1302,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.take();
|
||||
} else {
|
||||
newGame();
|
||||
afterGame();
|
||||
}
|
||||
};
|
||||
ui.panelSecondary.onclick = () => {
|
||||
@@ -1251,12 +1319,12 @@ export function startApp(options: AppOptions): void {
|
||||
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
|
||||
// brancher. Les liens du menu remplacent son adresse : le retour du
|
||||
// téléphone ne rouvre pas le menu.
|
||||
replacingLink(ui.menuNewGame, navigation);
|
||||
replacingLink(ui.menuGames, navigation);
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuNewGame.onclick = () => newGame();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
closeOverlays();
|
||||
@@ -1304,7 +1372,8 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const page = help || position || home || tutorial;
|
||||
const choosing = below.screen === "newGame";
|
||||
const page = help || position || home || tutorial || choosing;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1312,9 +1381,16 @@ export function startApp(options: AppOptions): void {
|
||||
ui.position.hidden = !position;
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel et le premier lancement ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
}
|
||||
ui.root.dataset.screen = below.screen;
|
||||
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) {
|
||||
window.scrollTo(0, 0);
|
||||
@@ -1398,18 +1474,29 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const homeView = createHomeView({
|
||||
section: ui.home,
|
||||
newGame: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.go("#/");
|
||||
newGame();
|
||||
},
|
||||
// « Nouvelle partie » : l'écran des choix, qui la commence.
|
||||
newGame: () => navigation.go(routeHash({ screen: "newGame" })),
|
||||
install: () => void install(false),
|
||||
dismissInstall: () => saveSettings(dismissInstall(settings)),
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
change: (changes) => saveSettings(changes),
|
||||
start: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
|
||||
let progressLoading = false;
|
||||
function renderHome(): void {
|
||||
const playing = game === null ? null : game.state.phase !== "over";
|
||||
@@ -1606,7 +1693,7 @@ export function startApp(options: AppOptions): void {
|
||||
// La nouvelle partie est l'écran de jeu, à la place de la position
|
||||
// (le retour du téléphone ne rouvre pas la position).
|
||||
navigation.replace("#/");
|
||||
newGame(setupOf(shared));
|
||||
newGame({ level: chosenGame(settings).level, setup: setupOf(shared) });
|
||||
};
|
||||
ui.positionCopy.onclick = () => {
|
||||
if (shownPosition !== null) {
|
||||
@@ -2022,12 +2109,46 @@ function endParts(game: Game): { title: string; detail: string } {
|
||||
}
|
||||
const { winner, kind, points } = result;
|
||||
const dropped = game.events.some((e) => e.type === "CubeDropped");
|
||||
const { match, matchAfter } = game.state;
|
||||
if (match !== null && matchAfter !== null) {
|
||||
const { player, computer } = matchAfter.score;
|
||||
const detail = MATCH_TEXTS.matchDetail(
|
||||
winner,
|
||||
points,
|
||||
match.length,
|
||||
player,
|
||||
computer,
|
||||
matchAfter.over,
|
||||
);
|
||||
return {
|
||||
title: matchAfter.over
|
||||
? MATCH_TEXTS.matchWonTitle(winner)
|
||||
: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: TEXTS.endDetail(winner, points, game.state.cube.value),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le score du match à montrer : avant la partie, puis après une fois
|
||||
* qu'elle est finie (null en partie libre). */
|
||||
function matchScore(state: GameState): MatchScore | null {
|
||||
if (state.match === null) {
|
||||
return null;
|
||||
}
|
||||
const score = state.matchAfter?.score ?? state.match.score;
|
||||
return { length: state.match.length, computer: score.computer, player: score.player };
|
||||
}
|
||||
|
||||
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
|
||||
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
|
||||
function afterGameLabel(game: Game): string {
|
||||
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
|
||||
* panneau fermé). */
|
||||
function resultText(game: Game): string {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./board-model.js";
|
||||
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
|
||||
import { ICON_PATHS, type IconName } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
@@ -49,12 +50,22 @@ export interface BoardControls {
|
||||
readonly back: "undo" | "replay";
|
||||
readonly backEnabled: boolean;
|
||||
readonly menuEnabled: boolean;
|
||||
/** Le score du match, sous le menu (null : partie libre). */
|
||||
readonly match: MatchScore | null;
|
||||
}
|
||||
|
||||
/** Le score d'un match, à afficher. */
|
||||
export interface MatchScore {
|
||||
readonly length: number;
|
||||
readonly computer: number;
|
||||
readonly player: number;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
readonly dice: readonly ShownDie[];
|
||||
readonly cube: Cube;
|
||||
/** null : pas de videau (partie Crawford). */
|
||||
readonly cube: Cube | null;
|
||||
/** Les pions que le joueur peut déplacer. */
|
||||
readonly movable: ReadonlySet<Place>;
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
@@ -180,7 +191,7 @@ export function renderBoard(
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = drawCube(svg, g, decor.cube);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
@@ -196,7 +207,7 @@ export function renderBoard(
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
if (decor.canDouble && cube !== null && decor.cube !== null) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
@@ -512,6 +523,49 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
|
||||
*/
|
||||
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
|
||||
const line = g.chipFont * 1.2;
|
||||
const width = g.left - 12;
|
||||
const group = add(svg, "g", {
|
||||
class: "match-box",
|
||||
role: "img",
|
||||
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
|
||||
});
|
||||
add(group, "rect", {
|
||||
class: "match-box-frame",
|
||||
x: x - width / 2,
|
||||
y: top,
|
||||
width,
|
||||
height: 3 * line + 8,
|
||||
rx: 6,
|
||||
});
|
||||
const texts: [string, string][] = [
|
||||
[String(match.computer), "match-box-score"],
|
||||
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
|
||||
[String(match.player), "match-box-score"],
|
||||
];
|
||||
for (const [i, [text, cssClass]] of texts.entries()) {
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: cssClass,
|
||||
x,
|
||||
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
"text-anchor": "middle",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
@@ -556,6 +610,9 @@ function drawControls(
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
if (controls.match !== null) {
|
||||
drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match);
|
||||
}
|
||||
if (controls.back === "replay") {
|
||||
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
|
||||
// descriptions des niveaux sont ceux de la charte ; le reste est une
|
||||
// première version, à valider par Pierre.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ComputerLevel } from "../domain/game/level.js";
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const MATCH_TEXTS = {
|
||||
// Écran « Nouvelle partie »
|
||||
title: "Nouvelle partie",
|
||||
close: "Fermer sans rien changer",
|
||||
kindLabel: "Type de partie",
|
||||
free: "Partie libre",
|
||||
match: "Match",
|
||||
/** « Le premier à [3] [5] [7] [11] points ». */
|
||||
lengthBefore: "Le premier à",
|
||||
lengthAfter: "points",
|
||||
lengthLabel: (n: number) => `Match en ${n} points`,
|
||||
crawfordRule:
|
||||
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
|
||||
crawfordLink: "règle de Crawford",
|
||||
jacobyRule:
|
||||
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
|
||||
jacobyLink: "règle de Jacoby",
|
||||
levelTitle: "Niveau de l'ordinateur",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Débutant",
|
||||
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
|
||||
},
|
||||
amateur: {
|
||||
name: "Amateur",
|
||||
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
|
||||
},
|
||||
intermediate: {
|
||||
name: "Intermédiaire",
|
||||
text: "Un habitué de la table : solide, sans être implacable.",
|
||||
},
|
||||
advanced: {
|
||||
name: "Avancé",
|
||||
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
|
||||
},
|
||||
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
|
||||
champion: {
|
||||
name: "Champion",
|
||||
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
|
||||
},
|
||||
} satisfies Record<ComputerLevel, { name: string; text: string }>,
|
||||
duringGame: "Pendant la partie",
|
||||
advice: "Conseil du maître",
|
||||
adviceDetail: "Une ligne après chacun de vos coups",
|
||||
startGame: "Commencer la partie",
|
||||
startMatch: (n: number) => `Commencer le match en ${n} points`,
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
/** « Match en 5 points », et « · partie Crawford ». */
|
||||
matchLine: (length: number, crawford: boolean) =>
|
||||
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
/** Paysage : « sur 5 ». */
|
||||
outOf: (length: number) => `sur ${length}`,
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie de match
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
|
||||
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
|
||||
matchDetail: (
|
||||
winner: "player" | "computer",
|
||||
n: number,
|
||||
length: number,
|
||||
player: number,
|
||||
computer: number,
|
||||
over: boolean,
|
||||
) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
|
||||
over ? "Score final" : `Match en ${length} points`
|
||||
} : vous ${player}, l'ordinateur ${computer}.`,
|
||||
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
|
||||
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
|
||||
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
|
||||
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
|
||||
// en cours ; seul « Commencer » en lance une.
|
||||
|
||||
import { COMPUTER_LEVELS } from "../domain/game/level.js";
|
||||
import { MATCH_LENGTHS } from "../domain/game/match.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
import type { Settings } from "./settings.js";
|
||||
|
||||
export interface NewGameViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly settings: () => Settings;
|
||||
/** Un choix a changé : le garder. */
|
||||
readonly change: (changes: Partial<Settings>) => void;
|
||||
/** « Commencer la partie » ou « Commencer le match ». */
|
||||
readonly start: () => void;
|
||||
}
|
||||
|
||||
export interface NewGameView {
|
||||
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
|
||||
readonly closeLink: HTMLAnchorElement;
|
||||
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
|
||||
render(busy: boolean): void;
|
||||
/** Le premier élément à recevoir le focus à l'ouverture. */
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
|
||||
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
|
||||
const button = element(parent, "button", {
|
||||
id,
|
||||
className,
|
||||
...(text === undefined ? {} : { text }),
|
||||
});
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "radio");
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
|
||||
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
|
||||
const p = element(parent, "p", { id, className: "new-game-rule" });
|
||||
p.append(`${text} `);
|
||||
const a = element(p, "a", { text: link });
|
||||
a.href = routeHash({ screen: "glossary", term });
|
||||
p.append(".");
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
section.setAttribute("aria-labelledby", "new-game-title");
|
||||
|
||||
const head = element(section, "div", { className: "new-game-head" });
|
||||
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
|
||||
closeLink.href = "#/";
|
||||
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
|
||||
closeLink.append(icon("close"));
|
||||
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
|
||||
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(body, "div", { className: "segmented" });
|
||||
kinds.setAttribute("role", "radiogroup");
|
||||
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
|
||||
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
|
||||
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(body, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
const lengths = element(matchOptions, "div", { className: "lengths" });
|
||||
lengths.setAttribute("role", "radiogroup");
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
|
||||
const lengthButtons = MATCH_LENGTHS.map((n) => {
|
||||
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
|
||||
button.addEventListener("click", () => options.change({ matchLength: n }));
|
||||
return button;
|
||||
});
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
|
||||
rule(
|
||||
matchOptions,
|
||||
"new-game-crawford",
|
||||
MATCH_TEXTS.crawfordRule,
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(body, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
rule(
|
||||
freeOptions,
|
||||
"new-game-jacoby",
|
||||
MATCH_TEXTS.jacobyRule,
|
||||
MATCH_TEXTS.jacobyLink,
|
||||
"regle-de-jacoby",
|
||||
);
|
||||
|
||||
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(body, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
|
||||
button.dataset.level = level;
|
||||
element(button, "span", { className: "level-bar" });
|
||||
button.addEventListener("click", () => options.change({ level }));
|
||||
return button;
|
||||
});
|
||||
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
|
||||
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
|
||||
|
||||
// 3. Pendant la partie.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
|
||||
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
|
||||
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
|
||||
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
|
||||
advice.addEventListener("click", () =>
|
||||
options.change({ masterAdvice: !options.settings().masterAdvice }),
|
||||
);
|
||||
|
||||
// 4. Barre d'action collée en bas.
|
||||
const actions = element(section, "div", { className: "new-game-actions" });
|
||||
const start = element(actions, "button", { id: "new-game-start" });
|
||||
start.type = "button";
|
||||
start.addEventListener("click", () => options.start());
|
||||
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
|
||||
|
||||
return {
|
||||
closeLink,
|
||||
render(busy) {
|
||||
const s = options.settings();
|
||||
const isMatch = s.gameKind === "match";
|
||||
free.setAttribute("aria-checked", String(!isMatch));
|
||||
match.setAttribute("aria-checked", String(isMatch));
|
||||
matchOptions.hidden = !isMatch;
|
||||
freeOptions.hidden = isMatch;
|
||||
for (const [i, button] of lengthButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
|
||||
}
|
||||
const chosen = COMPUTER_LEVELS.indexOf(s.level);
|
||||
for (const [i, button] of levelButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(i === chosen));
|
||||
button.classList.toggle("reached", i <= chosen);
|
||||
}
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-12
@@ -4,9 +4,12 @@
|
||||
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
|
||||
# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide,
|
||||
# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs
|
||||
# captures dans build/browser-shots/accueil-tutoriel/.
|
||||
# captures dans build/browser-shots/accueil-tutoriel/. Enfin l'écran « Nouvelle
|
||||
# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs),
|
||||
# captures dans build/browser-shots/match/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test.
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le
|
||||
# test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
@@ -19,7 +22,7 @@ cd "$(dirname "$0")/.."
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil}"
|
||||
scripts="${SCRIPTS:-play accueil match}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -56,7 +59,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
@@ -66,13 +69,21 @@ for browser in $browsers; do
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
[[ " $scripts " == *" accueil "* ]] || continue
|
||||
# Accueil, tutoriel, aide, installation et mise à jour ; captures de la
|
||||
# recette dans build/browser-shots/accueil-tutoriel/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
|
||||
-w /tools "$image" \
|
||||
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
|
||||
"http://localhost:$update_port/" /site || status=1
|
||||
if [[ " $scripts " == *" accueil "* ]]; then
|
||||
# Accueil, tutoriel, aide, installation et mise à jour ; captures de
|
||||
# la recette dans build/browser-shots/accueil-tutoriel/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
|
||||
-w /tools "$image" \
|
||||
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
|
||||
"http://localhost:$update_port/" /site || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" match "* ]]; then
|
||||
# Écran « Nouvelle partie » et match en 3 points ; captures de la
|
||||
# recette dans build/browser-shots/match/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node match.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué
|
||||
// jusqu'au bout par l'interface, dans un vrai navigateur sans écran
|
||||
// (Playwright), sur le site servi par l'image de production
|
||||
// (docs/plans/2026-11-03-match-et-reglages.md, point 6).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node match.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Les dés de l'application viennent de
|
||||
// Math.random : il est remplacé ici par un générateur à graine, pour que le
|
||||
// match soit le même à chaque fois (il passe par une partie Crawford). Le
|
||||
// joueur joue toujours le premier pion jouable vers sa première case
|
||||
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
|
||||
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
|
||||
// partie », score du match et partie Crawford, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node match.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/match`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// La graine des dés : un match qui passe par une partie Crawford.
|
||||
const SEED = 7;
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
// Sans animation : le match va plus vite, rien d'autre ne change.
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript((seed) => {
|
||||
let state = seed;
|
||||
Math.random = () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
}, SEED);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 60_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */
|
||||
async function todo() {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const what = await page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const panel = document.getElementById("panel-veil");
|
||||
if (!panel.hidden) {
|
||||
return panel.dataset.kind;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||||
});
|
||||
if (what !== null) {
|
||||
return what;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`);
|
||||
}
|
||||
|
||||
/** Le match tel que l'écran le montre (portrait). */
|
||||
async function shownMatch() {
|
||||
return page.evaluate(() => ({
|
||||
line: document.getElementById("match-line").textContent,
|
||||
lineShown: !document.getElementById("match-line").hidden,
|
||||
spoken: document.getElementById("match-line").getAttribute("aria-label"),
|
||||
computer: document.querySelector("#strip-computer .match-score").textContent,
|
||||
player: document.querySelector("#strip-player .match-score").textContent,
|
||||
cube: document.querySelectorAll("#board .cube").length,
|
||||
double: !document.getElementById("double").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Écran « Nouvelle partie »
|
||||
|
||||
await page.goto(`${base}#/nouvelle-partie`);
|
||||
await until("moteur chargé", () => !document.getElementById("new-game-start").disabled);
|
||||
const screen = () =>
|
||||
page.evaluate(() => ({
|
||||
free: document.getElementById("new-game-free").getAttribute("aria-checked"),
|
||||
match: document.getElementById("new-game-match").getAttribute("aria-checked"),
|
||||
lengthsShown: !document.getElementById("new-game-match-options").hidden,
|
||||
jacobyShown: !document.getElementById("new-game-free-options").hidden,
|
||||
level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level,
|
||||
reached: document.querySelectorAll(".level-bars .reached").length,
|
||||
name: document.getElementById("new-game-level-name").textContent,
|
||||
advice: document.getElementById("new-game-advice").getAttribute("aria-checked"),
|
||||
start: document.getElementById("new-game-start").textContent,
|
||||
topbar: document.querySelector(".topbar").getClientRects().length > 0,
|
||||
game: !document.getElementById("game").hidden,
|
||||
}));
|
||||
let s = await screen();
|
||||
check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown,
|
||||
`par défaut, partie libre : ${JSON.stringify(s)}`);
|
||||
check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire",
|
||||
`par défaut, Intermédiaire : ${JSON.stringify(s)}`);
|
||||
check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`);
|
||||
check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie");
|
||||
check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby",
|
||||
"lien vers la règle de Jacoby");
|
||||
// Les cibles tactiles : 44 px au moins.
|
||||
const small = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#new-game button, #new-game a")]
|
||||
.filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null)
|
||||
.map((el) => el.getBoundingClientRect())
|
||||
.filter((r) => r.width < 44 || r.height < 44).length,
|
||||
);
|
||||
check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`);
|
||||
|
||||
await page.click("#new-game-match");
|
||||
await page.click("#new-game-length-3");
|
||||
await page.click("#new-game-level-beginner");
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
|
||||
`match en 3 points : ${JSON.stringify(s)}`);
|
||||
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
|
||||
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
|
||||
"lien vers la règle de Crawford");
|
||||
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
|
||||
"description du niveau Débutant");
|
||||
await capture("1-nouvelle-partie");
|
||||
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
|
||||
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
|
||||
await page.click("#new-game-close");
|
||||
await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden);
|
||||
const settings = JSON.parse(before);
|
||||
check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner",
|
||||
`choix gardés : ${before}`);
|
||||
// Le menu y ramène, avec les choix de la dernière fois.
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-new-game");
|
||||
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden);
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
|
||||
`choix retrouvés : ${JSON.stringify(s)}`);
|
||||
await page.click("#new-game-start");
|
||||
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
|
||||
log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés");
|
||||
|
||||
// ------------------------------------------------------------- Le match
|
||||
|
||||
let m = await shownMatch();
|
||||
check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0",
|
||||
`début du match : ${JSON.stringify(m)}`);
|
||||
check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`);
|
||||
|
||||
// Un match ne passe pas toujours par une partie Crawford (un gammon peut
|
||||
// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les
|
||||
// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus).
|
||||
let games = 1;
|
||||
let crawfordGames = 0;
|
||||
let scoreShot = false;
|
||||
let crawfordShot = false;
|
||||
let reloaded = false;
|
||||
let actions = 0;
|
||||
let matches = 1;
|
||||
for (;;) {
|
||||
check(actions++ < 20000, "le match ne finit pas");
|
||||
const what = await todo();
|
||||
const now = await shownMatch();
|
||||
const crawford = now.line.endsWith("· partie Crawford");
|
||||
if (crawford && what !== "over") {
|
||||
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
|
||||
}
|
||||
if (what === "over") {
|
||||
const title = await page.textContent("#panel-text");
|
||||
const detail = await page.textContent("#panel-detail");
|
||||
const primary = await page.textContent("#panel-primary");
|
||||
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
|
||||
if (primary === "Nouvelle partie") {
|
||||
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
|
||||
`fin du match : ${title} ; ${detail}`);
|
||||
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
|
||||
check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`);
|
||||
// Paysage : le score dans la colonne de commandes.
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const box = await page.evaluate(() =>
|
||||
document.querySelector("#board .match-box")?.getAttribute("aria-label"));
|
||||
check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""),
|
||||
`score en paysage : « ${box} »`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
// Après le match, « Nouvelle partie » mène à l'écran des choix.
|
||||
await page.click("#panel-primary");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie");
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
if (crawfordGames > 0 && scoreShot) {
|
||||
break;
|
||||
}
|
||||
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
|
||||
matches++;
|
||||
games = 1;
|
||||
await page.click("#new-game-start");
|
||||
await until("match suivant", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
continue;
|
||||
}
|
||||
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
|
||||
`fin de partie dans un match : ${primary} ; ${detail}`);
|
||||
await page.click("#panel-primary");
|
||||
games++;
|
||||
await until("partie suivante", () => !document.body.classList.contains("busy"));
|
||||
const next = await shownMatch();
|
||||
if (next.line.endsWith("· partie Crawford")) {
|
||||
crawfordGames++;
|
||||
check(next.cube === 0, "partie Crawford : le videau est dessiné");
|
||||
if (!crawfordShot) {
|
||||
crawfordShot = true;
|
||||
await capture("3-partie-crawford");
|
||||
}
|
||||
} else if (!scoreShot) {
|
||||
scoreShot = true;
|
||||
await capture("2-score-du-match");
|
||||
}
|
||||
if (!reloaded) {
|
||||
// Le match continue après un rechargement de la page.
|
||||
reloaded = true;
|
||||
await page.reload();
|
||||
await until("partie reprise", () => !document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === next.line && again.player === next.player && again.computer === next.computer,
|
||||
`après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Les doubles de l'ordinateur sont passés : le match avance d'un point
|
||||
// à la fois, ce qui rend la partie Crawford probable.
|
||||
if (what === "doubled") {
|
||||
await page.click("#panel-secondary");
|
||||
} else if (what === "take") {
|
||||
await page.click("#drop");
|
||||
} else if (what === "skip" || what === "confirm" || what === "roll") {
|
||||
await page.click(`#${what}`);
|
||||
} else if (what === "move") {
|
||||
await page.click(".hit.can-move");
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
await page.click(".hit.can-land");
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` +
|
||||
`${actions} actions`);
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`);
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`);
|
||||
+18
-4
@@ -819,8 +819,7 @@ async function checkAddresses() {
|
||||
for (const selector of ["#menu-games", "#menu-help"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
|
||||
"« Nouvelle partie » n'est plus un bouton");
|
||||
check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : menu fermé", playing);
|
||||
await tab.click("#menu-button");
|
||||
@@ -881,8 +880,20 @@ async function openMenu() {
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
|
||||
* le journal de la partie quittée. */
|
||||
/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie
|
||||
* choisie, et l'adresse redevient celle de la partie. */
|
||||
async function startChosenGame() {
|
||||
await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas");
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`);
|
||||
await page.click("#new-game-start");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
|
||||
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
const before = await savedJournal();
|
||||
const started = () =>
|
||||
@@ -891,6 +902,7 @@ async function newGameFromMenu() {
|
||||
await openMenu();
|
||||
const start = Date.now();
|
||||
await page.click("#menu-new-game");
|
||||
await startChosenGame();
|
||||
check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »");
|
||||
check((await page.textContent("#banner")).includes("Reprendre l'ancienne"),
|
||||
`bandeau : « ${await page.textContent("#banner")} »`);
|
||||
@@ -1454,6 +1466,7 @@ step("mes parties");
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1535,6 +1548,7 @@ log("compte de points : bulle avec la fiche du glossaire");
|
||||
step("thème sombre");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
+245
@@ -2122,3 +2122,248 @@ html.landscape .welcome {
|
||||
padding-inline: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Match */
|
||||
|
||||
/* « Match en 5 points » sous la barre haute (portrait), hauteur fixe. */
|
||||
.match-line {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Le score de chaque camp, dans son bandeau, juste avant son compte de
|
||||
* points : une pastille carrée. */
|
||||
.strip .match-score {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Paysage : le score dans la colonne de commandes, sous le menu. */
|
||||
.match-box-frame {
|
||||
fill: none;
|
||||
stroke: var(--point-number);
|
||||
stroke-opacity: 0.5;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.match-box-score {
|
||||
font-weight: 700;
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
.match-box-of {
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Nouvelle partie */
|
||||
|
||||
/* La page remplit l'écran : la barre d'action reste collée en bas, même
|
||||
* quand le contenu est plus court que l'écran. */
|
||||
.new-game {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.new-game .new-game-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-game-head {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.new-game-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.new-game-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.new-game-body .overline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Sélecteur à deux segments : fond creusé, segment choisi en relief. */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.segmented .segment {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.segmented .segment[aria-checked="true"] {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.new-game-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lengths {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lengths .length {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lengths .length[aria-checked="true"] {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.new-game-rule {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Six barres de hauteur croissante ; celles jusqu'au niveau choisi sont en
|
||||
* brique, le niveau choisi porte l'anneau de focus. */
|
||||
.level-bars {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.level-bars .level {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
min-width: 44px;
|
||||
height: 84px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.level-bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.level-1 .level-bar { height: 18px; }
|
||||
.level-2 .level-bar { height: 28px; }
|
||||
.level-3 .level-bar { height: 38px; }
|
||||
.level-4 .level-bar { height: 50px; }
|
||||
.level-5 .level-bar { height: 62px; }
|
||||
.level-6 .level-bar { height: 76px; }
|
||||
|
||||
.level-bars .level.reached .level-bar {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.level-bars .level[aria-checked="true"] {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.level-name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.new-game-body .item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.new-game-body .item-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.new-game-body .item small {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Barre d'action collée en bas : le seul bouton principal de l'écran. */
|
||||
.new-game-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
padding: var(--space-3) 0 var(--space-4);
|
||||
background: var(--background);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.new-game-kept {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
+9
-1
@@ -39,10 +39,13 @@
|
||||
</header>
|
||||
|
||||
<main id="game" class="game">
|
||||
<!-- Dans un match : « Match en 5 points », et la partie Crawford. -->
|
||||
<p id="match-line" class="match-line overline" hidden></p>
|
||||
<div id="strip-computer" class="strip">
|
||||
<span class="mini mini-computer"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="board-box">
|
||||
@@ -57,6 +60,7 @@
|
||||
<span class="mini mini-player"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="infoline">
|
||||
@@ -89,6 +93,10 @@
|
||||
<section id="home" class="home page" hidden></section>
|
||||
<section id="tutorial" class="tutorial page" hidden></section>
|
||||
|
||||
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
|
||||
app/src/ui/new-game-view.ts. -->
|
||||
<section id="new-game" class="new-game page" hidden></section>
|
||||
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<a id="help-back" href="#/" class="button secondary"></a>
|
||||
@@ -122,7 +130,7 @@
|
||||
<h2 id="menu-title" class="sheet-title"></h2>
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<a id="menu-new-game" href="#/nouvelle-partie" class="item"></a>
|
||||
<a id="menu-games" href="#/parties" class="item"></a>
|
||||
<a id="menu-home" href="#/accueil" class="item"></a>
|
||||
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
|
||||
|
||||
Reference in New Issue
Block a user