diff --git a/README.md b/README.md
index 75707dd..95ef7d8 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica
## Ce que fait cette première version
- configure une rencontre `Twice` ou `Time`
+- sépare l'application en pages dédiées : configuration, phase chrono, phase cube
- gère les blocks de 180 secondes et le temps par coup de 20 secondes
- suit les quotas `FAST`, `FREEZE` et `MASTERS`
- orchestre la phase cube avec désignation du cube, capture des temps et préparation du block suivant
- applique la logique du double coup V2 en `Twice`
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
- conserve un historique local dans le navigateur
+- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur
## Hypothèse de produit
@@ -27,7 +29,9 @@ L'application est ensuite disponible sur `http://localhost:8080`.
## Fichiers clés
-- `index.html` : structure de l'interface
+- `index.html` : page de configuration et reprise de match
+- `chrono.html` : page dédiée à la phase chrono
+- `cube.html` : page dédiée à la phase cube
- `styles.css` : design mobile/tablette
- `app.js` : logique de match et arbitrage
- `docker-compose.yml` + `Dockerfile` : exécution locale
diff --git a/app.js b/app.js
index 0c41290..e2c63b6 100644
--- a/app.js
+++ b/app.js
@@ -1,227 +1,793 @@
+const PAGE = document.body.dataset.page;
+
+const STORAGE_KEY = "chesscubing-arena-state-v2";
const BLOCK_DURATION_MS = 180000;
const MOVE_LIMIT_MS = 20000;
const TIME_MODE_INITIAL_CLOCK_MS = 600000;
const CUBE_TIME_CAP_MS = 120000;
-const STORAGE_KEY = "chesscubing-arena-state-v1";
const PRESETS = {
fast: {
label: "FAST",
quota: 6,
- description: "Format nerveux : 6 coups par joueur et par block.",
+ description: "6 coups par joueur et par block.",
},
freeze: {
label: "FREEZE",
quota: 8,
- description: "Format intermédiaire : 8 coups par joueur et par block.",
+ description: "8 coups par joueur et par block.",
},
masters: {
label: "MASTERS",
quota: 10,
- description: "Format long : 10 coups par joueur et par block.",
+ description: "10 coups par joueur et par block.",
},
};
const MODES = {
twice: {
label: "ChessCubing Twice",
- subtitle:
- "Le gagnant du cube démarre le block suivant. Double coup V2 possible.",
+ subtitle: "Le gagnant du cube ouvre le block suivant.",
},
time: {
label: "ChessCubing Time",
- subtitle:
- "Blocks identiques au Twice avec chronos cumulés et alternance bloc - / +.",
+ subtitle: "Chronos cumules et alternance bloc - / bloc +.",
},
};
-const refs = {
- setupForm: document.querySelector("#setupForm"),
- setupSummary: document.querySelector("#setupSummary"),
- loadDemoButton: document.querySelector("#loadDemoButton"),
- livePanel: document.querySelector("#livePanel"),
- heroModeHint: document.querySelector("#heroModeHint"),
- matchTitle: document.querySelector("#matchTitle"),
- phaseBadge: document.querySelector("#phaseBadge"),
- blockLabel: document.querySelector("#blockLabel"),
- modeLabel: document.querySelector("#modeLabel"),
- blockMeta: document.querySelector("#blockMeta"),
- blockTimer: document.querySelector("#blockTimer"),
- moveTimer: document.querySelector("#moveTimer"),
- blockStatusText: document.querySelector("#blockStatusText"),
- turnLabel: document.querySelector("#turnLabel"),
- whiteNameDisplay: document.querySelector("#whiteNameDisplay"),
- blackNameDisplay: document.querySelector("#blackNameDisplay"),
- whiteMoveCount: document.querySelector("#whiteMoveCount"),
- blackMoveCount: document.querySelector("#blackMoveCount"),
- whiteClockLabel: document.querySelector("#whiteClockLabel"),
- blackClockLabel: document.querySelector("#blackClockLabel"),
- whiteCubeLabel: document.querySelector("#whiteCubeLabel"),
- blackCubeLabel: document.querySelector("#blackCubeLabel"),
- whiteCard: document.querySelector("#whiteCard"),
- blackCard: document.querySelector("#blackCard"),
- startPauseButton: document.querySelector("#startPauseButton"),
- confirmBlockButton: document.querySelector("#confirmBlockButton"),
- moveActionButton: document.querySelector("#moveActionButton"),
- reliefMoveButton: document.querySelector("#reliefMoveButton"),
- timeoutMoveButton: document.querySelector("#timeoutMoveButton"),
- switchTurnButton: document.querySelector("#switchTurnButton"),
- contextNotice: document.querySelector("#contextNotice"),
- doubleCard: document.querySelector("#doubleCard"),
- cubeNumber: document.querySelector("#cubeNumber"),
- startCubeButton: document.querySelector("#startCubeButton"),
- cubeElapsed: document.querySelector("#cubeElapsed"),
- cubeStatusText: document.querySelector("#cubeStatusText"),
- captureWhiteCubeButton: document.querySelector("#captureWhiteCubeButton"),
- captureBlackCubeButton: document.querySelector("#captureBlackCubeButton"),
- whiteCubeResult: document.querySelector("#whiteCubeResult"),
- blackCubeResult: document.querySelector("#blackCubeResult"),
- whiteCubeCap: document.querySelector("#whiteCubeCap"),
- blackCubeCap: document.querySelector("#blackCubeCap"),
- applyCubeButton: document.querySelector("#applyCubeButton"),
- redoCubeButton: document.querySelector("#redoCubeButton"),
- historyList: document.querySelector("#historyList"),
- resetMatchButton: document.querySelector("#resetMatchButton"),
- whiteWinButton: document.querySelector("#whiteWinButton"),
- blackWinButton: document.querySelector("#blackWinButton"),
- drawStopButton: document.querySelector("#drawStopButton"),
-};
+let match = readStoredMatch();
+let dirty = false;
-const state = {
- match: restoreState(),
- lastTickAt: null,
-};
+if (match) {
+ normalizeRecoveredMatch(match);
+ if (syncRunningState(match)) {
+ dirty = true;
+ }
+}
-hydrateRecoveredState();
-bindEvents();
-renderSetupSummary();
-render();
-startTicker();
+window.addEventListener("beforeunload", flushState);
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) {
+ if (match) {
+ syncRunningState(match);
+ }
+ flushState();
+ }
+});
-function bindEvents() {
- document.querySelectorAll("[data-scroll-target]").forEach((button) => {
- button.addEventListener("click", () => {
- const target = document.getElementById(button.dataset.scrollTarget);
- target?.scrollIntoView({ behavior: "smooth", block: "start" });
+window.setInterval(() => {
+ if (dirty) {
+ persistMatch();
+ }
+}, 1000);
+
+switch (PAGE) {
+ case "setup":
+ initSetupPage();
+ break;
+ case "chrono":
+ initChronoPage();
+ break;
+ case "cube":
+ initCubePage();
+ break;
+ default:
+ break;
+}
+
+function initSetupPage() {
+ const form = document.querySelector("#setupForm");
+ const summary = document.querySelector("#setupSummary");
+ const loadDemoButton = document.querySelector("#loadDemoButton");
+ const resumeCard = document.querySelector("#resumeCard");
+
+ if (!form || !summary || !loadDemoButton || !resumeCard) {
+ return;
+ }
+
+ const renderSummary = () => {
+ const mode = getRadioValue(form, "mode") || "twice";
+ const preset = getRadioValue(form, "preset") || "fast";
+ const quota = PRESETS[preset].quota;
+ const timeImpact =
+ mode === "time"
+ ? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte."
+ : "Le gagnant du cube commence le block suivant, avec double coup V2 possible.";
+
+ summary.innerHTML = `
+ ${MODES[mode].label}
+ ${PRESETS[preset].description}
+ Chaque block dure 180 secondes, chaque coup est limite a 20 secondes.
+ ${timeImpact}
+ Quota actif : ${quota} coups par joueur.
+ `;
+ };
+
+ const renderResume = () => {
+ if (!match) {
+ resumeCard.classList.add("empty");
+ resumeCard.innerHTML = "
Aucun match en cours pour l'instant.
";
+ return;
+ }
+
+ resumeCard.classList.remove("empty");
+ const phaseLabel = match.result
+ ? resultText(match)
+ : match.phase === "cube"
+ ? "Page cube prete"
+ : match.awaitingBlockClosure
+ ? "Fin de block a confirmer"
+ : "Page chrono prete";
+
+ resumeCard.innerHTML = `
+ ${escapeHtml(match.config.matchLabel)}
+ ${escapeHtml(MODES[match.config.mode].label)}
+ ${escapeHtml(match.config.whiteName)} vs ${escapeHtml(match.config.blackName)}
+ ${escapeHtml(phaseLabel)}
+
+
+ ${match.result ? "Voir le match" : "Reprendre la phase"}
+
+
+ Effacer le match
+
+
+ `;
+
+ resumeCard.querySelector("#resumeMatchButton")?.addEventListener("click", () => {
+ navigateTo(routeForMatch(match));
});
- });
- refs.setupForm.addEventListener("submit", (event) => {
+ resumeCard.querySelector("#clearMatchButton")?.addEventListener("click", () => {
+ clearMatch();
+ renderResume();
+ });
+ };
+
+ form.addEventListener("input", renderSummary);
+ loadDemoButton.addEventListener("click", () => loadDemo(form, renderSummary));
+
+ form.addEventListener("submit", (event) => {
event.preventDefault();
- const formData = new FormData(refs.setupForm);
const config = {
- matchLabel: sanitizeText(formData.get("matchLabel")) || "Rencontre ChessCubing",
- mode: formData.get("mode") || "twice",
- preset: formData.get("preset") || "fast",
- whiteName: sanitizeText(formData.get("whiteName")) || "Blanc",
- blackName: sanitizeText(formData.get("blackName")) || "Noir",
- arbiterName: sanitizeText(formData.get("arbiterName")) || "",
- eventName: sanitizeText(formData.get("eventName")) || "",
- notes: sanitizeText(formData.get("notes")) || "",
+ matchLabel: sanitizeText(form.elements.matchLabel.value) || "Rencontre ChessCubing",
+ mode: getRadioValue(form, "mode") || "twice",
+ preset: getRadioValue(form, "preset") || "fast",
+ whiteName: sanitizeText(form.elements.whiteName.value) || "Blanc",
+ blackName: sanitizeText(form.elements.blackName.value) || "Noir",
+ arbiterName: sanitizeText(form.elements.arbiterName.value),
+ eventName: sanitizeText(form.elements.eventName.value),
+ notes: sanitizeText(form.elements.notes.value),
};
- state.match = createMatch(config);
- persistState();
- refs.livePanel.classList.remove("hidden");
- refs.livePanel.scrollIntoView({ behavior: "smooth", block: "start" });
+ match = createMatch(config);
+ dirty = true;
+ persistMatch();
+ navigateTo("chrono.html");
+ });
+
+ renderSummary();
+ renderResume();
+}
+
+function initChronoPage() {
+ if (!match) {
+ replaceTo("index.html");
+ return;
+ }
+
+ if (!match.result && match.phase === "cube") {
+ replaceTo("cube.html");
+ return;
+ }
+
+ const refs = {
+ title: document.querySelector("#chronoTitle"),
+ subtitle: document.querySelector("#chronoSubtitle"),
+ blockTimer: document.querySelector("#blockTimer"),
+ moveTimer: document.querySelector("#moveTimer"),
+ centerLabel: document.querySelector("#chronoCenterLabel"),
+ centerValue: document.querySelector("#chronoCenterValue"),
+ spineLabel: document.querySelector("#spineLabel"),
+ spineHeadline: document.querySelector("#spineHeadline"),
+ spineText: document.querySelector("#spineText"),
+ primaryButton: document.querySelector("#primaryChronoButton"),
+ whiteName: document.querySelector("#whiteNameChrono"),
+ blackName: document.querySelector("#blackNameChrono"),
+ whiteMoves: document.querySelector("#whiteMovesChrono"),
+ blackMoves: document.querySelector("#blackMovesChrono"),
+ whiteClock: document.querySelector("#whiteClockChrono"),
+ blackClock: document.querySelector("#blackClockChrono"),
+ whiteHint: document.querySelector("#whiteHintChrono"),
+ blackHint: document.querySelector("#blackHintChrono"),
+ whiteButton: document.querySelector("#whiteMoveButton"),
+ blackButton: document.querySelector("#blackMoveButton"),
+ whiteZone: document.querySelector("#whiteZone"),
+ blackZone: document.querySelector("#blackZone"),
+ openArbiterButton: document.querySelector("#openArbiterButton"),
+ closeArbiterButton: document.querySelector("#closeArbiterButton"),
+ arbiterModal: document.querySelector("#arbiterModal"),
+ arbiterStatus: document.querySelector("#arbiterStatus"),
+ arbiterPauseButton: document.querySelector("#arbiterPauseButton"),
+ arbiterCloseBlockButton: document.querySelector("#arbiterCloseBlockButton"),
+ arbiterTimeoutButton: document.querySelector("#arbiterTimeoutButton"),
+ arbiterSwitchTurnButton: document.querySelector("#arbiterSwitchTurnButton"),
+ arbiterWhiteWinButton: document.querySelector("#arbiterWhiteWinButton"),
+ arbiterBlackWinButton: document.querySelector("#arbiterBlackWinButton"),
+ arbiterStopButton: document.querySelector("#arbiterStopButton"),
+ arbiterResetButton: document.querySelector("#arbiterResetButton"),
+ };
+
+ const openModal = () => toggleModal(refs.arbiterModal, true);
+ const closeModal = () => toggleModal(refs.arbiterModal, false);
+
+ refs.openArbiterButton?.addEventListener("click", openModal);
+ refs.closeArbiterButton?.addEventListener("click", closeModal);
+ refs.arbiterModal?.addEventListener("click", (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.closeModal === "true") {
+ closeModal();
+ }
+ });
+
+ refs.whiteButton?.addEventListener("click", () => handleChronoTap("white"));
+ refs.blackButton?.addEventListener("click", () => handleChronoTap("black"));
+ refs.primaryButton?.addEventListener("click", handlePrimaryChronoAction);
+
+ refs.arbiterPauseButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ if (match.result || match.phase !== "block") {
+ return;
+ }
+
+ if (match.awaitingBlockClosure) {
+ openCubePhase(match);
+ dirty = true;
+ persistMatch();
+ navigateTo("cube.html");
+ return;
+ }
+
+ if (match.running) {
+ pauseBlock(match);
+ } else {
+ startBlock(match);
+ }
+
+ dirty = true;
render();
});
- refs.setupForm.addEventListener("input", renderSetupSummary);
- refs.loadDemoButton.addEventListener("click", loadDemo);
-
- refs.startPauseButton.addEventListener("click", () => {
- if (!state.match || state.match.result) {
+ refs.arbiterCloseBlockButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ if (match.result || match.phase !== "block") {
return;
}
- if (state.match.phase === "block" && state.match.running) {
- pauseBlock();
- } else {
- startBlock();
- }
- });
-
- refs.confirmBlockButton.addEventListener("click", () => {
- if (!state.match || state.match.result) {
+ if (match.awaitingBlockClosure) {
+ openCubePhase(match);
+ dirty = true;
+ persistMatch();
+ navigateTo("cube.html");
return;
}
- if (state.match.phase === "cube") {
- return;
- }
-
- syncRunningTimers();
-
- if (!state.match.awaitingBlockClosure && state.match.phase === "block") {
- requestBlockClosure("Clôture manuelle du block demandée par l'arbitre.");
- } else {
- openCubePhase();
- }
- });
-
- refs.moveActionButton.addEventListener("click", () => {
- if (!state.match || state.match.phase !== "block" || !state.match.running) {
- return;
- }
-
- if (state.match.doubleCoup.step === 1) {
- registerFreeDoubleMove();
- return;
- }
-
- const isSecondDoubleMove = state.match.doubleCoup.step === 2;
- registerCountedMove({
- label: isSecondDoubleMove
- ? "Deuxième coup du double coup enregistré."
- : "Coup compté enregistré.",
- source: isSecondDoubleMove ? "double" : "standard",
- });
- });
-
- refs.reliefMoveButton.addEventListener("click", () => {
- if (!state.match || state.match.phase !== "block") {
- return;
- }
- registerReliefMove();
- });
-
- refs.timeoutMoveButton.addEventListener("click", () => {
- if (!state.match || state.match.phase !== "block") {
- return;
- }
- registerMoveTimeout(false);
- });
-
- refs.switchTurnButton.addEventListener("click", () => {
- if (!state.match || state.match.result) {
- return;
- }
- state.match.currentTurn = opponentOf(state.match.currentTurn);
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
- logEvent("Trait corrigé manuellement par l'arbitre.");
- persistState();
+ requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre.");
+ dirty = true;
render();
});
- refs.startCubeButton.addEventListener("click", startCubePhase);
- refs.captureWhiteCubeButton.addEventListener("click", () => captureCubeTime("white"));
- refs.captureBlackCubeButton.addEventListener("click", () => captureCubeTime("black"));
- refs.applyCubeButton.addEventListener("click", applyCubeOutcome);
- refs.redoCubeButton.addEventListener("click", replayCubePhase);
+ refs.arbiterTimeoutButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ if (match.result || match.phase !== "block") {
+ return;
+ }
- refs.resetMatchButton.addEventListener("click", resetMatch);
- refs.whiteWinButton.addEventListener("click", () => setResult("white"));
- refs.blackWinButton.addEventListener("click", () => setResult("black"));
- refs.drawStopButton.addEventListener("click", () => setResult("stopped"));
+ registerMoveTimeout(match, false);
+ dirty = true;
+ render();
+ });
+
+ refs.arbiterSwitchTurnButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ if (match.result || match.phase !== "block") {
+ return;
+ }
+
+ match.currentTurn = opponentOf(match.currentTurn);
+ match.moveRemainingMs = MOVE_LIMIT_MS;
+ logEvent(match, "Trait corrige manuellement par l'arbitre.");
+ dirty = true;
+ render();
+ });
+
+ refs.arbiterWhiteWinButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ setResult(match, "white");
+ dirty = true;
+ render();
+ });
+
+ refs.arbiterBlackWinButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ setResult(match, "black");
+ dirty = true;
+ render();
+ });
+
+ refs.arbiterStopButton?.addEventListener("click", () => {
+ syncRunningState(match);
+ setResult(match, "stopped");
+ dirty = true;
+ render();
+ });
+
+ refs.arbiterResetButton?.addEventListener("click", () => {
+ clearMatch();
+ replaceTo("index.html");
+ });
+
+ function handleChronoTap(color) {
+ if (!match || match.result || match.phase !== "block") {
+ return;
+ }
+
+ syncRunningState(match);
+
+ if (match.awaitingBlockClosure) {
+ if (match.currentTurn !== color) {
+ return;
+ }
+
+ registerReliefMove(match);
+ dirty = true;
+ render();
+ return;
+ }
+
+ if (!match.running || match.currentTurn !== color) {
+ return;
+ }
+
+ if (match.doubleCoup.step === 1) {
+ registerFreeDoubleMove(match);
+ } else {
+ registerCountedMove(match, {
+ source: match.doubleCoup.step === 2 ? "double" : "standard",
+ });
+ }
+
+ dirty = true;
+ render();
+ }
+
+ function handlePrimaryChronoAction() {
+ syncRunningState(match);
+
+ if (match.result) {
+ navigateTo("index.html");
+ return;
+ }
+
+ if (match.phase !== "block") {
+ navigateTo("cube.html");
+ return;
+ }
+
+ if (match.awaitingBlockClosure) {
+ openCubePhase(match);
+ dirty = true;
+ persistMatch();
+ navigateTo("cube.html");
+ return;
+ }
+
+ if (match.running) {
+ pauseBlock(match);
+ } else {
+ startBlock(match);
+ }
+
+ dirty = true;
+ render();
+ }
+
+ function renderZone(color) {
+ const isWhite = color === "white";
+ const button = isWhite ? refs.whiteButton : refs.blackButton;
+ const name = isWhite ? refs.whiteName : refs.blackName;
+ const moves = isWhite ? refs.whiteMoves : refs.blackMoves;
+ const clock = isWhite ? refs.whiteClock : refs.blackClock;
+ const hint = isWhite ? refs.whiteHint : refs.blackHint;
+ const zone = isWhite ? refs.whiteZone : refs.blackZone;
+ const actorName = playerName(match, color);
+ const active = match.currentTurn === color;
+
+ name.textContent = actorName;
+ moves.textContent = `${match.moves[color]} / ${match.quota}`;
+ clock.textContent = match.clocks
+ ? `Chrono ${formatSignedClock(match.clocks[color])}`
+ : `Dernier cube ${renderLastCube(match, color)}`;
+
+ button.classList.toggle("active-turn", active && !match.result);
+ zone.classList.toggle("active-zone", active && !match.result);
+
+ if (match.result) {
+ button.textContent = resultText(match);
+ button.disabled = true;
+ hint.textContent = "Le match est termine.";
+ return;
+ }
+
+ if (match.awaitingBlockClosure) {
+ if (active) {
+ button.textContent = "Coup hors quota";
+ button.disabled = false;
+ hint.textContent = "A utiliser seulement si un roi est encore en echec avant la page cube.";
+ } else {
+ button.textContent = "En attente";
+ button.disabled = true;
+ hint.textContent = "Attente de la reponse adverse ou du passage a la page cube.";
+ }
+ return;
+ }
+
+ if (!match.running) {
+ button.textContent = "Block en pause";
+ button.disabled = true;
+ hint.textContent = active
+ ? "Le block n'a pas encore demarre ou a ete mis en pause."
+ : `${playerName(match, match.currentTurn)} reprendra au demarrage.`;
+ return;
+ }
+
+ if (!active) {
+ button.textContent = "Attends";
+ button.disabled = true;
+ hint.textContent = `${playerName(match, match.currentTurn)} est en train de jouer.`;
+ return;
+ }
+
+ if (match.doubleCoup.step === 1) {
+ button.textContent = "1er coup gratuit";
+ button.disabled = false;
+ hint.textContent = "Ce coup ne compte pas et ne doit pas donner echec.";
+ return;
+ }
+
+ if (match.doubleCoup.step === 2) {
+ button.textContent = "2e coup du double";
+ button.disabled = false;
+ hint.textContent = "Ce coup compte dans le quota et l'echec redevient autorise.";
+ return;
+ }
+
+ button.textContent = "J'ai fini mon coup";
+ button.disabled = false;
+ hint.textContent = "Tape des que ton coup est joue sur l'echiquier.";
+ }
+
+ function render() {
+ if (!match) {
+ return;
+ }
+
+ refs.title.textContent = match.config.matchLabel;
+ refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
+ refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
+ refs.moveTimer.textContent = formatClock(match.moveRemainingMs);
+
+ if (match.result) {
+ refs.centerLabel.textContent = "Resultat";
+ refs.centerValue.textContent = resultText(match);
+ refs.spineLabel.textContent = "Termine";
+ refs.spineHeadline.textContent = resultText(match);
+ refs.spineText.textContent = "Retournez a la configuration pour lancer une nouvelle rencontre.";
+ refs.primaryButton.textContent = "Retour a l'accueil";
+ refs.arbiterStatus.textContent = "Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser.";
+ } else if (match.awaitingBlockClosure) {
+ refs.centerLabel.textContent = "Transition";
+ refs.centerValue.textContent = "Fin de block";
+ refs.spineLabel.textContent = "Page suivante";
+ refs.spineHeadline.textContent = "Passer a la page cube";
+ refs.spineText.textContent = `${match.closureReason} Jouez encore des coups hors quota si necessaire, puis ouvrez la page cube.`;
+ refs.primaryButton.textContent = "Ouvrir la page cube";
+ refs.arbiterStatus.textContent = refs.spineText.textContent;
+ } else if (match.running) {
+ refs.centerLabel.textContent = "Trait";
+ refs.centerValue.textContent = playerName(match, match.currentTurn);
+ refs.spineLabel.textContent = "Chrono en cours";
+ refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`;
+ refs.spineText.textContent = "Chaque joueur tape sur sa grande zone quand son coup est termine.";
+ refs.primaryButton.textContent = "Pause arbitre";
+ refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`;
+ } else {
+ refs.centerLabel.textContent = "Trait";
+ refs.centerValue.textContent = playerName(match, match.currentTurn);
+ refs.spineLabel.textContent = "Pret";
+ refs.spineHeadline.textContent = `Block ${match.blockNumber}`;
+ refs.spineText.textContent = "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs.";
+ refs.primaryButton.textContent = "Demarrer le block";
+ refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`;
+ }
+
+ refs.arbiterCloseBlockButton.textContent = match.awaitingBlockClosure
+ ? "Ouvrir la page cube"
+ : "Clore le block";
+
+ renderZone("black");
+ renderZone("white");
+ }
+
+ render();
+
+ window.setInterval(() => {
+ if (!match) {
+ return;
+ }
+
+ const changed = syncRunningState(match);
+ if (changed) {
+ dirty = true;
+ }
+
+ if (!match.result && match.phase === "cube") {
+ persistMatch();
+ navigateTo("cube.html");
+ return;
+ }
+
+ render();
+ }, 100);
+}
+
+function initCubePage() {
+ if (!match) {
+ replaceTo("index.html");
+ return;
+ }
+
+ if (!match.result && match.phase !== "cube") {
+ replaceTo("chrono.html");
+ return;
+ }
+
+ const refs = {
+ title: document.querySelector("#cubeTitle"),
+ subtitle: document.querySelector("#cubeSubtitle"),
+ blockLabel: document.querySelector("#cubeBlockLabel"),
+ elapsed: document.querySelector("#cubeElapsed"),
+ centerLabel: document.querySelector("#cubeCenterLabel"),
+ centerValue: document.querySelector("#cubeCenterValue"),
+ spineLabel: document.querySelector("#cubeSpineLabel"),
+ spineHeadline: document.querySelector("#cubeSpineHeadline"),
+ spineText: document.querySelector("#cubeSpineText"),
+ primaryButton: document.querySelector("#primaryCubeButton"),
+ whiteName: document.querySelector("#whiteNameCube"),
+ blackName: document.querySelector("#blackNameCube"),
+ whiteButton: document.querySelector("#whiteCubeButton"),
+ blackButton: document.querySelector("#blackCubeButton"),
+ whiteResult: document.querySelector("#whiteCubeResult"),
+ blackResult: document.querySelector("#blackCubeResult"),
+ whiteCap: document.querySelector("#whiteCubeCap"),
+ blackCap: document.querySelector("#blackCubeCap"),
+ whiteHint: document.querySelector("#whiteHintCube"),
+ blackHint: document.querySelector("#blackHintCube"),
+ openHelpButton: document.querySelector("#openCubeHelpButton"),
+ closeHelpButton: document.querySelector("#closeCubeHelpButton"),
+ helpModal: document.querySelector("#cubeHelpModal"),
+ helpStatus: document.querySelector("#cubeHelpStatus"),
+ replayCubeButton: document.querySelector("#replayCubeButton"),
+ resetButton: document.querySelector("#cubeResetButton"),
+ };
+
+ const openModal = () => toggleModal(refs.helpModal, true);
+ const closeModal = () => toggleModal(refs.helpModal, false);
+
+ refs.openHelpButton?.addEventListener("click", openModal);
+ refs.closeHelpButton?.addEventListener("click", closeModal);
+ refs.helpModal?.addEventListener("click", (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.dataset.closeCubeModal === "true") {
+ closeModal();
+ }
+ });
+
+ refs.whiteButton?.addEventListener("click", () => {
+ captureCubeTime(match, "white");
+ dirty = true;
+ render();
+ });
+
+ refs.blackButton?.addEventListener("click", () => {
+ captureCubeTime(match, "black");
+ dirty = true;
+ render();
+ });
+
+ refs.primaryButton?.addEventListener("click", () => {
+ if (match.result) {
+ navigateTo("index.html");
+ return;
+ }
+
+ if (match.phase !== "cube") {
+ navigateTo("chrono.html");
+ return;
+ }
+
+ if (!match.cube.running && match.cube.times.white === null && match.cube.times.black === null) {
+ startCubePhase(match);
+ dirty = true;
+ render();
+ return;
+ }
+
+ if (match.cube.running) {
+ return;
+ }
+
+ if (match.cube.times.white !== null && match.cube.times.black !== null) {
+ if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) {
+ replayCubePhase(match);
+ dirty = true;
+ render();
+ return;
+ }
+
+ applyCubeOutcome(match);
+ dirty = true;
+ persistMatch();
+ navigateTo("chrono.html");
+ }
+ });
+
+ refs.replayCubeButton?.addEventListener("click", () => {
+ replayCubePhase(match);
+ dirty = true;
+ render();
+ });
+
+ refs.resetButton?.addEventListener("click", () => {
+ clearMatch();
+ replaceTo("index.html");
+ });
+
+ function renderCubeZone(color) {
+ const isWhite = color === "white";
+ const button = isWhite ? refs.whiteButton : refs.blackButton;
+ const name = isWhite ? refs.whiteName : refs.blackName;
+ const result = isWhite ? refs.whiteResult : refs.blackResult;
+ const cap = isWhite ? refs.whiteCap : refs.blackCap;
+ const hint = isWhite ? refs.whiteHint : refs.blackHint;
+ const time = match.cube.times[color];
+
+ name.textContent = playerName(match, color);
+ result.textContent = time === null ? "--" : formatStopwatch(time);
+ cap.textContent = renderCubeCap(match, time);
+
+ if (match.result) {
+ button.textContent = resultText(match);
+ button.disabled = true;
+ hint.textContent = "Le match est termine.";
+ return;
+ }
+
+ if (match.phase !== "cube") {
+ button.textContent = "Retour chrono";
+ button.disabled = true;
+ hint.textContent = "La page cube est terminee.";
+ return;
+ }
+
+ if (!match.cube.running) {
+ if (time !== null) {
+ button.textContent = "Temps capture";
+ button.disabled = true;
+ hint.textContent = "Arret deja enregistre pour ce joueur.";
+ } else {
+ button.textContent = "Pret";
+ button.disabled = true;
+ hint.textContent = "Attente du demarrage de la phase cube.";
+ }
+ return;
+ }
+
+ if (time !== null) {
+ button.textContent = "Temps capture";
+ button.disabled = true;
+ hint.textContent = "Le temps de ce joueur est deja enregistre.";
+ return;
+ }
+
+ button.textContent = "J'ai fini le cube";
+ button.disabled = false;
+ hint.textContent = "Tape au moment exact ou le cube est resolu.";
+ }
+
+ function render() {
+ refs.title.textContent = match.cube.number ? `Cube n${match.cube.number}` : "Phase cube";
+ refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
+ refs.blockLabel.textContent = `${match.blockNumber}`;
+ refs.elapsed.textContent = renderCubeElapsed(match);
+
+ if (match.result) {
+ refs.centerLabel.textContent = "Resultat";
+ refs.centerValue.textContent = resultText(match);
+ refs.spineLabel.textContent = "Termine";
+ refs.spineHeadline.textContent = resultText(match);
+ refs.spineText.textContent = "Retournez a la configuration pour relancer une rencontre.";
+ refs.primaryButton.textContent = "Retour a l'accueil";
+ refs.helpStatus.textContent = "Le match est termine.";
+ } else if (match.cube.running) {
+ refs.centerLabel.textContent = "Etat";
+ refs.centerValue.textContent = "Cube en cours";
+ refs.spineLabel.textContent = "Arrets";
+ refs.spineHeadline.textContent = "Attendre les deux fins";
+ refs.spineText.textContent = "Chaque joueur tape sur sa grande zone des qu'il a resolu son cube.";
+ refs.primaryButton.textContent = "Chrono cube en cours";
+ refs.helpStatus.textContent = "Phase cube en cours. Les arrets sont saisis directement par les joueurs.";
+ } else if (
+ match.cube.times.white !== null &&
+ match.cube.times.black !== null &&
+ match.config.mode === "twice" &&
+ match.cube.times.white === match.cube.times.black
+ ) {
+ refs.centerLabel.textContent = "Decision";
+ refs.centerValue.textContent = "Egalite parfaite";
+ refs.spineLabel.textContent = "Reglement";
+ refs.spineHeadline.textContent = "Rejouer la phase cube";
+ refs.spineText.textContent = "Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.";
+ refs.primaryButton.textContent = "Rejouer la phase cube";
+ refs.helpStatus.textContent = refs.spineText.textContent;
+ } else if (match.cube.times.white !== null && match.cube.times.black !== null) {
+ refs.centerLabel.textContent = "Decision";
+ refs.centerValue.textContent = "Phase cube complete";
+ refs.spineLabel.textContent = "Suite";
+ refs.spineHeadline.textContent = "Ouvrir la page chrono";
+ refs.spineText.textContent = "Appliquer le resultat du cube pour preparer le block suivant.";
+ refs.primaryButton.textContent = "Appliquer et ouvrir la page chrono";
+ refs.helpStatus.textContent = refs.spineText.textContent;
+ } else {
+ refs.centerLabel.textContent = "Etat";
+ refs.centerValue.textContent = "Pret";
+ refs.spineLabel.textContent = "Depart";
+ refs.spineHeadline.textContent = "Lancer la phase cube";
+ refs.spineText.textContent = "Demarrez le chrono commun, puis laissez chaque joueur utiliser uniquement sa grande zone.";
+ refs.primaryButton.textContent = "Demarrer la phase cube";
+ refs.helpStatus.textContent = "La phase cube n'a pas encore commence.";
+ }
+
+ renderCubeZone("black");
+ renderCubeZone("white");
+ refs.primaryButton.disabled = match.result ? false : match.cube.running;
+ }
+
+ render();
+
+ window.setInterval(() => {
+ if (!match) {
+ return;
+ }
+
+ if (!match.result && match.phase !== "cube") {
+ persistMatch();
+ navigateTo("chrono.html");
+ return;
+ }
+
+ render();
+ }, 100);
}
function createMatch(config) {
const quota = PRESETS[config.preset].quota;
- const match = {
+ const newMatch = {
+ schemaVersion: 2,
config,
phase: "block",
running: false,
+ lastTickAt: null,
blockNumber: 1,
currentTurn: "white",
blockRemainingMs: BLOCK_DURATION_MS,
@@ -231,12 +797,13 @@ function createMatch(config) {
white: 0,
black: 0,
},
- clocks: config.mode === "time"
- ? {
- white: TIME_MODE_INITIAL_CLOCK_MS,
- black: TIME_MODE_INITIAL_CLOCK_MS,
- }
- : null,
+ clocks:
+ config.mode === "time"
+ ? {
+ white: TIME_MODE_INITIAL_CLOCK_MS,
+ black: TIME_MODE_INITIAL_CLOCK_MS,
+ }
+ : null,
lastMover: null,
awaitingBlockClosure: false,
closureReason: "",
@@ -261,830 +828,597 @@ function createMatch(config) {
history: [],
};
- logEvent(
- `Match créé en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`,
- match,
- );
- logEvent("Les Blancs commencent le block 1.", match);
-
- return match;
+ logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`);
+ logEvent(newMatch, "Les Blancs commencent le block 1.");
+ return newMatch;
}
-function restoreState() {
+function readStoredMatch() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
+
const parsed = JSON.parse(raw);
- return parsed && typeof parsed === "object" ? parsed : null;
+ if (!parsed || parsed.schemaVersion !== 2) {
+ return null;
+ }
+
+ return parsed;
} catch {
return null;
}
}
-function hydrateRecoveredState() {
- if (!state.match) {
- return;
+function normalizeRecoveredMatch(storedMatch) {
+ if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") {
+ storedMatch.lastTickAt = null;
}
- if (state.match.running) {
- state.match.running = false;
- state.match.awaitingBlockClosure = false;
- state.match.closureReason = "";
- state.match.moveRemainingMs = Math.max(state.match.moveRemainingMs, 0);
+ if (!storedMatch.cube) {
+ storedMatch.cube = {
+ number: null,
+ running: false,
+ startedAt: null,
+ elapsedMs: 0,
+ times: { white: null, black: null },
+ round: 1,
+ history: [],
+ };
}
- if (state.match.cube?.running) {
- state.match.cube.running = false;
- state.match.cube.startedAt = null;
+ if (!storedMatch.doubleCoup) {
+ storedMatch.doubleCoup = {
+ eligible: false,
+ step: 0,
+ starter: "white",
+ };
}
}
-function renderSetupSummary() {
- const formData = new FormData(refs.setupForm);
- const mode = formData.get("mode") || "twice";
- const preset = formData.get("preset") || "fast";
- const quota = PRESETS[preset].quota;
-
- refs.setupSummary.innerHTML = `
- ${MODES[mode].label}
- ${PRESETS[preset].description}
- Chaque block dure 180 secondes et chaque coup est limité à 20 secondes.
- ${
- mode === "time"
- ? "Chronos cumulés de 10 minutes par joueur, ajustés par les temps cube avec plafond de 120 s pris en compte."
- : "Le gagnant du cube ouvre le block suivant, avec double coup éventuel selon la règle V2."
- }
- Quota actif : ${quota} coups par joueur et par block.
- `;
-
- refs.heroModeHint.textContent = MODES[mode].subtitle;
-}
-
-function startTicker() {
- window.setInterval(() => {
- if (!state.match || state.match.result) {
- return;
- }
-
- syncRunningTimers();
- render();
- }, 100);
-}
-
-function startBlock() {
- if (!state.match || state.match.result) {
- return;
+function syncRunningState(storedMatch) {
+ if (!storedMatch || storedMatch.result) {
+ return false;
}
- if (state.match.phase !== "block") {
- return;
- }
-
- state.match.running = true;
- state.match.awaitingBlockClosure = false;
- state.match.closureReason = "";
- state.lastTickAt = Date.now();
-
- const intro =
- state.match.blockNumber === 1 && state.match.moves.white === 0 && state.match.moves.black === 0
- ? "Block 1 démarré."
- : `Block ${state.match.blockNumber} relancé.`;
-
- logEvent(intro);
- persistState();
- render();
-}
-
-function pauseBlock() {
- if (!state.match || !state.match.running) {
- return;
- }
-
- syncRunningTimers();
- state.match.running = false;
- logEvent(`Block ${state.match.blockNumber} mis en pause.`);
- persistState();
- render();
-}
-
-function syncRunningTimers() {
- if (!state.match || !state.match.running || state.match.phase !== "block") {
- state.lastTickAt = Date.now();
- return;
+ if (!storedMatch.running || storedMatch.phase !== "block" || !storedMatch.lastTickAt) {
+ return false;
}
const now = Date.now();
- const delta = now - (state.lastTickAt || now);
- state.lastTickAt = now;
-
+ const delta = now - storedMatch.lastTickAt;
if (delta <= 0) {
- return;
+ return false;
}
- state.match.blockRemainingMs = Math.max(0, state.match.blockRemainingMs - delta);
- state.match.moveRemainingMs = Math.max(0, state.match.moveRemainingMs - delta);
+ storedMatch.lastTickAt = now;
+ storedMatch.blockRemainingMs = Math.max(0, storedMatch.blockRemainingMs - delta);
+ storedMatch.moveRemainingMs = Math.max(0, storedMatch.moveRemainingMs - delta);
- if (state.match.clocks) {
- state.match.clocks[state.match.currentTurn] -= delta;
+ if (storedMatch.clocks) {
+ storedMatch.clocks[storedMatch.currentTurn] -= delta;
}
- if (state.match.blockRemainingMs === 0) {
- requestBlockClosure("Les 180 secondes du block sont écoulées.");
- return;
+ if (storedMatch.blockRemainingMs === 0) {
+ requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees.");
+ } else if (storedMatch.moveRemainingMs === 0) {
+ registerMoveTimeout(storedMatch, true);
}
- if (state.match.moveRemainingMs === 0) {
- registerMoveTimeout(true);
- }
+ return true;
}
-function requestBlockClosure(reason) {
- if (!state.match || state.match.awaitingBlockClosure) {
+function startBlock(storedMatch) {
+ if (!storedMatch || storedMatch.phase !== "block" || storedMatch.result) {
return;
}
- state.match.running = false;
- state.match.awaitingBlockClosure = true;
- state.match.closureReason = reason;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
- logEvent(`${reason} Vérifier un éventuel échec à parer avant la phase cube.`);
- persistState();
- render();
+ storedMatch.running = true;
+ storedMatch.awaitingBlockClosure = false;
+ storedMatch.closureReason = "";
+ storedMatch.lastTickAt = Date.now();
+ logEvent(
+ storedMatch,
+ storedMatch.blockNumber === 1 && storedMatch.moves.white === 0 && storedMatch.moves.black === 0
+ ? "Block 1 demarre."
+ : `Block ${storedMatch.blockNumber} relance.`,
+ );
}
-function openCubePhase() {
- if (!state.match) {
+function pauseBlock(storedMatch) {
+ if (!storedMatch || !storedMatch.running) {
return;
}
- state.match.phase = "cube";
- state.match.running = false;
- state.match.awaitingBlockClosure = false;
- state.match.closureReason = "";
- state.match.cube.number = pickCubeNumber();
- state.match.cube.running = false;
- state.match.cube.startedAt = null;
- state.match.cube.elapsedMs = 0;
- state.match.cube.times = { white: null, black: null };
- state.match.cube.round = 1;
- logEvent(`Phase cube ouverte. Cube n°${state.match.cube.number} désigné par l'application.`);
- persistState();
- render();
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ logEvent(storedMatch, `Block ${storedMatch.blockNumber} mis en pause.`);
}
-function startCubePhase() {
- if (!state.match || state.match.phase !== "cube" || state.match.result) {
+function requestBlockClosure(storedMatch, reason) {
+ if (!storedMatch || storedMatch.awaitingBlockClosure) {
return;
}
- if (!state.match.cube.number) {
- state.match.cube.number = pickCubeNumber();
- }
-
- state.match.cube.running = true;
- state.match.cube.startedAt = Date.now();
- state.match.cube.elapsedMs = 0;
- logEvent(`Phase cube démarrée sur le cube n°${state.match.cube.number}.`);
- persistState();
- render();
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ storedMatch.awaitingBlockClosure = true;
+ storedMatch.closureReason = reason;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
+ logEvent(storedMatch, `${reason} Verifier si des coups hors quota sont encore necessaires avant la page cube.`);
}
-function captureCubeTime(color) {
- if (!state.match || state.match.phase !== "cube" || !state.match.cube.running) {
+function openCubePhase(storedMatch) {
+ if (!storedMatch) {
return;
}
- if (state.match.cube.times[color] !== null) {
+ storedMatch.phase = "cube";
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ storedMatch.awaitingBlockClosure = false;
+ storedMatch.closureReason = "";
+ storedMatch.cube.number = pickCubeNumber();
+ storedMatch.cube.running = false;
+ storedMatch.cube.startedAt = null;
+ storedMatch.cube.elapsedMs = 0;
+ storedMatch.cube.times = { white: null, black: null };
+ storedMatch.cube.round = 1;
+ logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`);
+}
+
+function startCubePhase(storedMatch) {
+ if (!storedMatch || storedMatch.phase !== "cube" || storedMatch.result) {
return;
}
- const elapsedMs = Date.now() - state.match.cube.startedAt;
- state.match.cube.times[color] = elapsedMs;
- state.match.cube.elapsedMs = Math.max(state.match.cube.elapsedMs, elapsedMs);
- logEvent(`${playerLabel(color)} arrêté en ${formatStopwatch(elapsedMs)} sur la phase cube.`);
+ if (!storedMatch.cube.number) {
+ storedMatch.cube.number = pickCubeNumber();
+ }
+
+ storedMatch.cube.running = true;
+ storedMatch.cube.startedAt = Date.now();
+ storedMatch.cube.elapsedMs = 0;
+ logEvent(storedMatch, `Phase cube demarree sur le cube n${storedMatch.cube.number}.`);
+}
+
+function captureCubeTime(storedMatch, color) {
+ if (
+ !storedMatch ||
+ storedMatch.phase !== "cube" ||
+ !storedMatch.cube.running ||
+ storedMatch.cube.times[color] !== null
+ ) {
+ return;
+ }
+
+ const elapsedMs = Date.now() - storedMatch.cube.startedAt;
+ storedMatch.cube.times[color] = elapsedMs;
+ storedMatch.cube.elapsedMs = Math.max(storedMatch.cube.elapsedMs, elapsedMs);
+ logEvent(storedMatch, `${playerName(storedMatch, color)} arrete le cube en ${formatStopwatch(elapsedMs)}.`);
if (
- state.match.cube.times.white !== null &&
- state.match.cube.times.black !== null
+ storedMatch.cube.times.white !== null &&
+ storedMatch.cube.times.black !== null
) {
- state.match.cube.running = false;
- state.match.cube.history.push({
- blockNumber: state.match.blockNumber,
- number: state.match.cube.number,
- white: state.match.cube.times.white,
- black: state.match.cube.times.black,
+ storedMatch.cube.running = false;
+ storedMatch.cube.history.push({
+ blockNumber: storedMatch.blockNumber,
+ number: storedMatch.cube.number,
+ white: storedMatch.cube.times.white,
+ black: storedMatch.cube.times.black,
});
}
-
- persistState();
- render();
}
-function applyCubeOutcome() {
- if (!state.match || state.match.phase !== "cube") {
+function applyCubeOutcome(storedMatch) {
+ if (!storedMatch || storedMatch.phase !== "cube") {
return;
}
- const { white, black } = state.match.cube.times;
+ const white = storedMatch.cube.times.white;
+ const black = storedMatch.cube.times.black;
if (white === null || black === null) {
return;
}
- if (state.match.config.mode === "twice") {
- if (white === black) {
- logEvent("Égalité parfaite sur la phase cube : le règlement impose de rejouer la phase.");
- render();
- return;
- }
-
+ if (storedMatch.config.mode === "twice") {
const winner = white < black ? "white" : "black";
- prepareNextTwiceBlock(winner);
- } else {
- applyTimeAdjustments(white, black);
- prepareNextTimeBlock();
- }
-
- persistState();
- render();
-}
-
-function prepareNextTwiceBlock(winner) {
- if (!state.match) {
+ prepareNextTwiceBlock(storedMatch, winner);
return;
}
- const hadDouble = state.match.lastMover !== winner && state.match.lastMover !== null;
- logEvent(`${playerLabel(winner)} gagne la phase cube et commencera le block suivant.`);
+ applyTimeAdjustments(storedMatch, white, black);
+ prepareNextTimeBlock(storedMatch);
+}
- state.match.blockNumber += 1;
- state.match.phase = "block";
- state.match.running = false;
- state.match.blockRemainingMs = BLOCK_DURATION_MS;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
- state.match.moves = { white: 0, black: 0 };
- state.match.currentTurn = winner;
- state.match.doubleCoup = {
+function prepareNextTwiceBlock(storedMatch, winner) {
+ const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null;
+ logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira le block suivant.`);
+
+ storedMatch.blockNumber += 1;
+ storedMatch.phase = "block";
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
+ storedMatch.moves = { white: 0, black: 0 };
+ storedMatch.currentTurn = winner;
+ storedMatch.doubleCoup = {
eligible: hadDouble,
step: hadDouble ? 1 : 0,
starter: winner,
};
- state.match.cube.running = false;
- state.match.cube.startedAt = null;
- state.match.cube.elapsedMs = 0;
- state.match.cube.times = { white: null, black: null };
- state.match.cube.number = null;
+ storedMatch.cube.running = false;
+ storedMatch.cube.startedAt = null;
+ storedMatch.cube.elapsedMs = 0;
+ storedMatch.cube.times = { white: null, black: null };
+ storedMatch.cube.number = null;
if (hadDouble) {
- logEvent(
- `Double coup disponible pour ${playerLabel(
- winner,
- )} : premier coup gratuit sans échec, puis second coup compté.`,
- );
+ logEvent(storedMatch, `Double coup disponible pour ${playerName(storedMatch, winner)}.`);
} else {
- logEvent("Aucun double coup accordé sur ce départ.");
+ logEvent(storedMatch, "Aucun double coup n'est accorde sur ce depart.");
}
}
-function prepareNextTimeBlock() {
- if (!state.match) {
- return;
- }
-
- state.match.blockNumber += 1;
- state.match.phase = "block";
- state.match.running = false;
- state.match.blockRemainingMs = BLOCK_DURATION_MS;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
- state.match.moves = { white: 0, black: 0 };
- state.match.doubleCoup = {
+function prepareNextTimeBlock(storedMatch) {
+ storedMatch.blockNumber += 1;
+ storedMatch.phase = "block";
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
+ storedMatch.moves = { white: 0, black: 0 };
+ storedMatch.doubleCoup = {
eligible: false,
step: 0,
- starter: state.match.currentTurn,
+ starter: storedMatch.currentTurn,
};
- state.match.cube.running = false;
- state.match.cube.startedAt = null;
- state.match.cube.elapsedMs = 0;
- state.match.cube.times = { white: null, black: null };
- state.match.cube.number = null;
+ storedMatch.cube.running = false;
+ storedMatch.cube.startedAt = null;
+ storedMatch.cube.elapsedMs = 0;
+ storedMatch.cube.times = { white: null, black: null };
+ storedMatch.cube.number = null;
logEvent(
- `Block ${state.match.blockNumber} prêt. Le trait est conservé en mode Time : ${playerLabel(
- state.match.currentTurn,
+ storedMatch,
+ `Block ${storedMatch.blockNumber} pret. Le trait est conserve : ${playerName(
+ storedMatch,
+ storedMatch.currentTurn,
)} reprend.`,
);
}
-function applyTimeAdjustments(whiteTime, blackTime) {
- if (!state.match || !state.match.clocks) {
+function applyTimeAdjustments(storedMatch, whiteTime, blackTime) {
+ if (!storedMatch.clocks) {
return;
}
- const blockType = getTimeBlockType(state.match.blockNumber);
const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS);
const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS);
+ const blockType = getTimeBlockType(storedMatch.blockNumber);
if (blockType === "minus") {
- state.match.clocks.white -= cappedWhite;
- state.match.clocks.black -= cappedBlack;
+ storedMatch.clocks.white -= cappedWhite;
+ storedMatch.clocks.black -= cappedBlack;
logEvent(
- `Bloc - : ${formatStopwatch(cappedWhite)} retiré au chrono Blanc, ${formatStopwatch(
+ storedMatch,
+ `Bloc - : ${formatStopwatch(cappedWhite)} retire au chrono Blanc, ${formatStopwatch(
cappedBlack,
- )} retiré au chrono Noir.`,
+ )} retire au chrono Noir.`,
);
} else {
- state.match.clocks.white += cappedBlack;
- state.match.clocks.black += cappedWhite;
+ storedMatch.clocks.white += cappedBlack;
+ storedMatch.clocks.black += cappedWhite;
logEvent(
- `Bloc + : ${formatStopwatch(cappedBlack)} ajouté au chrono Blanc, ${formatStopwatch(
+ storedMatch,
+ `Bloc + : ${formatStopwatch(cappedBlack)} ajoute au chrono Blanc, ${formatStopwatch(
cappedWhite,
- )} ajouté au chrono Noir.`,
+ )} ajoute au chrono Noir.`,
);
}
}
-function replayCubePhase() {
- if (!state.match || state.match.phase !== "cube") {
+function replayCubePhase(storedMatch) {
+ if (!storedMatch || storedMatch.phase !== "cube") {
return;
}
- state.match.cube.running = false;
- state.match.cube.startedAt = null;
- state.match.cube.elapsedMs = 0;
- state.match.cube.times = { white: null, black: null };
- state.match.cube.round += 1;
- logEvent(`Phase cube relancée (tentative ${state.match.cube.round}).`);
- persistState();
- render();
+ storedMatch.cube.running = false;
+ storedMatch.cube.startedAt = null;
+ storedMatch.cube.elapsedMs = 0;
+ storedMatch.cube.times = { white: null, black: null };
+ storedMatch.cube.round += 1;
+ logEvent(storedMatch, `Phase cube relancee (tentative ${storedMatch.cube.round}).`);
}
-function registerCountedMove({ label, source }) {
- if (!state.match || state.match.phase !== "block") {
+function registerCountedMove(storedMatch, { source }) {
+ if (!storedMatch || storedMatch.phase !== "block") {
return;
}
- const actor = state.match.currentTurn;
- if (state.match.moves[actor] >= state.match.quota) {
+ const actor = storedMatch.currentTurn;
+ if (storedMatch.moves[actor] >= storedMatch.quota) {
logEvent(
- `${playerLabel(actor)} a déjà atteint son quota de coups pour ce block. Utiliser un coup hors quota si nécessaire.`,
+ storedMatch,
+ `${playerName(storedMatch, actor)} a deja atteint son quota. Utiliser le mode hors quota si necessaire.`,
);
- render();
return;
}
- state.match.moves[actor] += 1;
- state.match.lastMover = actor;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
+ storedMatch.moves[actor] += 1;
+ storedMatch.lastMover = actor;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
if (source === "double") {
- state.match.doubleCoup.step = 0;
+ storedMatch.doubleCoup.step = 0;
}
logEvent(
- `${label} ${playerLabel(actor)} est à ${state.match.moves[actor]} / ${state.match.quota}.`,
+ storedMatch,
+ `${playerName(storedMatch, actor)} valide son coup (${storedMatch.moves[actor]} / ${storedMatch.quota}).`,
);
- state.match.currentTurn = opponentOf(actor);
- verifyQuotaCompletion();
- persistState();
- render();
+ storedMatch.currentTurn = opponentOf(actor);
+ verifyQuotaCompletion(storedMatch);
}
-function registerFreeDoubleMove() {
- if (!state.match || state.match.doubleCoup.step !== 1) {
+function registerFreeDoubleMove(storedMatch) {
+ if (!storedMatch || storedMatch.doubleCoup.step !== 1) {
return;
}
- const actor = state.match.currentTurn;
- state.match.lastMover = actor;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
- state.match.doubleCoup.step = 2;
+ const actor = storedMatch.currentTurn;
+ storedMatch.lastMover = actor;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
+ storedMatch.doubleCoup.step = 2;
logEvent(
- `Premier coup gratuit du double coup joué par ${playerLabel(
- actor,
- )}. Rappel arbitre : il ne doit pas donner échec.`,
+ storedMatch,
+ `Premier coup gratuit du double coup joue par ${playerName(storedMatch, actor)}.`,
);
- persistState();
- render();
}
-function registerReliefMove() {
- if (!state.match) {
+function registerReliefMove(storedMatch) {
+ if (!storedMatch || storedMatch.phase !== "block") {
return;
}
- const actor = state.match.currentTurn;
- state.match.lastMover = actor;
- state.match.currentTurn = opponentOf(actor);
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
+ const actor = storedMatch.currentTurn;
+ storedMatch.lastMover = actor;
+ storedMatch.currentTurn = opponentOf(actor);
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
- `Coup hors quota joué par ${playerLabel(
- actor,
- )} pour gérer une situation arbitrale ou parer un échec.`,
+ storedMatch,
+ `Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`,
);
- persistState();
- render();
}
-function registerMoveTimeout(fromTimer) {
- if (!state.match) {
+function registerMoveTimeout(storedMatch, automatic) {
+ if (!storedMatch || storedMatch.phase !== "block") {
return;
}
- const actor = state.match.currentTurn;
+ const actor = storedMatch.currentTurn;
- if (state.match.doubleCoup.step === 1) {
- state.match.doubleCoup.step = 0;
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
+ if (storedMatch.doubleCoup.step === 1) {
+ storedMatch.doubleCoup.step = 0;
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
- `Dépassement sur le premier coup gratuit de ${playerLabel(
- actor,
- )} : le double coup est annulé, mais le joueur conserve son coup normal.`,
+ storedMatch,
+ `Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`,
);
- persistState();
- render();
return;
}
- if (state.match.moves[actor] < state.match.quota) {
- state.match.moves[actor] += 1;
+ if (storedMatch.moves[actor] < storedMatch.quota) {
+ storedMatch.moves[actor] += 1;
}
- if (state.match.doubleCoup.step === 2) {
- state.match.doubleCoup.step = 0;
+ if (storedMatch.doubleCoup.step === 2) {
+ storedMatch.doubleCoup.step = 0;
}
- state.match.currentTurn = opponentOf(actor);
- state.match.moveRemainingMs = MOVE_LIMIT_MS;
+ storedMatch.currentTurn = opponentOf(actor);
+ storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
- `${fromTimer ? "Temps par coup écoulé." : "Dépassement manuel 20 s."} ${playerLabel(
+ storedMatch,
+ `${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName(
+ storedMatch,
actor,
- )} perd son coup, qui est comptabilisé dans le quota.`,
+ )} perd son coup, qui reste compte dans le quota.`,
);
- verifyQuotaCompletion();
- persistState();
- render();
+ verifyQuotaCompletion(storedMatch);
}
-function verifyQuotaCompletion() {
- if (!state.match) {
- return;
- }
-
+function verifyQuotaCompletion(storedMatch) {
if (
- state.match.moves.white >= state.match.quota &&
- state.match.moves.black >= state.match.quota
+ storedMatch.moves.white >= storedMatch.quota &&
+ storedMatch.moves.black >= storedMatch.quota
) {
- requestBlockClosure("Les deux joueurs ont atteint leur quota de coups.");
+ requestBlockClosure(storedMatch, "Les deux joueurs ont atteint leur quota de coups.");
}
}
-function setResult(winner) {
- if (!state.match || state.match.result) {
+function setResult(storedMatch, winner) {
+ if (!storedMatch || storedMatch.result) {
return;
}
- syncRunningTimers();
- state.match.running = false;
- state.match.result = winner;
-
- if (winner === "white" || winner === "black") {
- logEvent(`${playerLabel(winner)} remporte la partie par décision arbitrale, mat ou abandon.`);
- } else {
- logEvent("La partie est arrêtée par abandon ou décision arbitrale.");
- }
-
- persistState();
- render();
+ storedMatch.running = false;
+ storedMatch.lastTickAt = null;
+ storedMatch.result = winner;
+ logEvent(storedMatch, `${resultText(storedMatch)}.`);
}
-function resetMatch() {
- localStorage.removeItem(STORAGE_KEY);
- state.match = null;
- refs.livePanel.classList.add("hidden");
- render();
+function renderModeContext(storedMatch) {
+ if (storedMatch.config.mode === "time") {
+ return getTimeBlockType(storedMatch.blockNumber) === "minus"
+ ? "Bloc - : temps cube retire a son propre chrono"
+ : "Bloc + : temps cube ajoute au chrono adverse";
+ }
+
+ if (storedMatch.doubleCoup.step === 1) {
+ return `Double coup actif pour ${playerName(storedMatch, storedMatch.doubleCoup.starter)}`;
+ }
+
+ if (storedMatch.doubleCoup.step === 2) {
+ return "Deuxieme coup du double en attente";
+ }
+
+ return "Le gagnant du cube ouvrira le prochain block";
}
-function pickCubeNumber() {
- return Math.floor(Math.random() * 4) + 1;
-}
-
-function render() {
- const match = state.match;
- refs.livePanel.classList.toggle("hidden", !match);
-
- if (!match) {
- return;
- }
-
- refs.matchTitle.textContent = match.config.matchLabel;
- refs.modeLabel.textContent = MODES[match.config.mode].label;
- refs.phaseBadge.textContent = renderPhaseBadge(match);
- refs.blockLabel.textContent = `Block ${match.blockNumber}`;
- refs.blockMeta.textContent = renderBlockMeta(match);
- refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
- refs.moveTimer.textContent = formatClock(match.moveRemainingMs);
- refs.blockStatusText.textContent = renderBlockStatus(match);
- refs.turnLabel.textContent = `Trait : ${playerLabel(match.currentTurn)}`;
-
- refs.whiteNameDisplay.textContent = match.config.whiteName;
- refs.blackNameDisplay.textContent = match.config.blackName;
- refs.whiteMoveCount.textContent = `${match.moves.white} / ${match.quota} coups`;
- refs.blackMoveCount.textContent = `${match.moves.black} / ${match.quota} coups`;
-
- refs.whiteClockLabel.textContent = renderClockLabel(match, "white");
- refs.blackClockLabel.textContent = renderClockLabel(match, "black");
- refs.whiteCubeLabel.textContent = renderLastCubeLabel(match, "white");
- refs.blackCubeLabel.textContent = renderLastCubeLabel(match, "black");
-
- refs.whiteCard.classList.toggle("active", match.currentTurn === "white" && !match.result);
- refs.blackCard.classList.toggle("active", match.currentTurn === "black" && !match.result);
-
- refs.startPauseButton.textContent =
- match.phase === "block" && match.running ? "Mettre en pause" : "Démarrer le block";
- refs.startPauseButton.disabled = match.phase !== "block" || Boolean(match.result);
- refs.confirmBlockButton.disabled = match.phase !== "block" || Boolean(match.result);
- refs.confirmBlockButton.textContent = match.awaitingBlockClosure
- ? "Ouvrir la phase cube"
- : "Clore le block";
-
- refs.moveActionButton.disabled =
- match.phase !== "block" || !match.running || Boolean(match.result);
- refs.moveActionButton.textContent = renderMoveButtonLabel(match);
-
- refs.reliefMoveButton.disabled = match.phase !== "block" || Boolean(match.result);
- refs.timeoutMoveButton.disabled = match.phase !== "block" || Boolean(match.result);
- refs.switchTurnButton.disabled = Boolean(match.result);
-
- refs.contextNotice.innerHTML = `Contexte du moment ${renderContextNotice(
- match,
- )}`;
- refs.doubleCard.innerHTML = renderDoubleCard(match);
-
- refs.cubeNumber.textContent = match.cube.number || "-";
- refs.cubeElapsed.textContent = renderCubeElapsed(match);
- refs.cubeStatusText.textContent = renderCubeStatus(match);
- refs.startCubeButton.disabled =
- match.phase !== "cube" || match.cube.running || Boolean(match.result);
- refs.captureWhiteCubeButton.disabled =
- match.phase !== "cube" || !match.cube.running || match.cube.times.white !== null;
- refs.captureBlackCubeButton.disabled =
- match.phase !== "cube" || !match.cube.running || match.cube.times.black !== null;
- refs.applyCubeButton.disabled =
- match.phase !== "cube" ||
- match.cube.times.white === null ||
- match.cube.times.black === null ||
- Boolean(match.result);
- refs.redoCubeButton.disabled = match.phase !== "cube" || Boolean(match.result);
-
- refs.whiteCubeResult.textContent =
- match.cube.times.white === null ? "--" : formatStopwatch(match.cube.times.white);
- refs.blackCubeResult.textContent =
- match.cube.times.black === null ? "--" : formatStopwatch(match.cube.times.black);
- refs.whiteCubeCap.textContent = renderCubeCap(match.cube.times.white);
- refs.blackCubeCap.textContent = renderCubeCap(match.cube.times.black);
-
- refs.historyList.innerHTML = [...match.history]
- .slice(-18)
- .reverse()
- .map(
- (item) => `
-
- ${escapeHtml(item.time)}
- ${escapeHtml(item.message)}
-
- `,
- )
- .join("");
-}
-
-function renderPhaseBadge(match) {
- if (match.result) {
- return "Partie terminée";
- }
-
- if (match.phase === "cube") {
- return "Phase cube";
- }
-
- if (match.awaitingBlockClosure) {
- return "Fin de block à confirmer";
- }
-
- return match.running ? "Block en cours" : "Block prêt";
-}
-
-function renderBlockMeta(match) {
- const preset = PRESETS[match.config.preset];
- if (match.config.mode === "time") {
- const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +";
- return `${type} • ${preset.label} • ${preset.quota} coups par joueur • Le trait est conservé après le cube.`;
- }
-
- return `${preset.label} • ${preset.quota} coups par joueur • Le gagnant du cube démarrera le block suivant.`;
-}
-
-function renderBlockStatus(match) {
- if (match.result) {
- return "La rencontre est archivée dans l'historique.";
- }
-
- if (match.phase === "cube") {
- return "Le jeu d'échecs est suspendu, aucun coup n'est autorisé.";
- }
-
- if (match.awaitingBlockClosure) {
- return match.closureReason;
- }
-
- return match.running
- ? "Le block tourne. Utiliser les commandes d'arbitrage à chaque coup."
- : "Le block est prêt, les chronos sont arrêtés.";
-}
-
-function renderClockLabel(match, color) {
- if (!match.clocks) {
- return "Mode Twice : pas de chrono cumulé.";
- }
-
- return `Chrono cumulé : ${formatSignedClock(match.clocks[color])}`;
-}
-
-function renderLastCubeLabel(match, color) {
- const last = match.cube.history.at(-1);
+function renderLastCube(storedMatch, color) {
+ const last = storedMatch.cube.history.at(-1);
if (!last) {
- return "Dernière phase cube : --";
+ return "--";
}
- return `Dernière phase cube : ${formatStopwatch(last[color])}`;
+ return formatStopwatch(last[color]);
}
-function renderMoveButtonLabel(match) {
- if (match.doubleCoup.step === 1) {
- return "1er coup gratuit du double";
- }
-
- if (match.doubleCoup.step === 2) {
- return "2e coup du double";
- }
-
- return "Coup compté";
-}
-
-function renderContextNotice(match) {
- if (match.result) {
- return "Le match est terminé. L'historique conserve les grandes étapes pour une reprise ultérieure.";
- }
-
- if (match.phase === "cube") {
- if (match.config.mode === "twice") {
- return "Le gagnant du cube déterminera le départ du block suivant. En cas d'égalité parfaite, la phase doit être rejouée.";
- }
-
- const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +";
- return `${type} : le temps cube sera ${
- type === "Bloc -"
- ? "retiré du chrono du joueur concerné"
- : "ajouté au chrono adverse"
- }, avec plafond pris en compte de 120 secondes.`;
- }
-
- if (match.awaitingBlockClosure) {
- return "Le block est théoriquement terminé. Si un roi est en échec, jouer les coups nécessaires hors quota, puis ouvrir la phase cube.";
- }
-
- if (match.config.mode === "time") {
- return "Mode Time : la partie ne se termine jamais au temps. Les chronos cumulés servent de ressource stratégique, mais seule la victoire par mat ou abandon compte.";
- }
-
- return "Mode Twice : surveiller la fin du block, la victoire en cube, et la règle du double coup V2 pour le départ suivant.";
-}
-
-function renderDoubleCard(match) {
- if (match.config.mode !== "twice") {
- return "Mode Time Aucun système de priorité ou de double coup n'existe dans cette variante. ";
- }
-
- if (!match.doubleCoup.eligible && match.doubleCoup.step === 0) {
- return "Double coup Pas de double coup actif pour ce départ. ";
- }
-
- if (match.doubleCoup.step === 1) {
- return `Double coup actif pour ${escapeHtml(
- playerLabel(match.doubleCoup.starter),
- )} Premier coup gratuit, non compté, sans échec autorisé. Utiliser le bouton principal pour l'enregistrer. `;
- }
-
- if (match.doubleCoup.step === 2) {
- return `Deuxième coup du double Ce coup compte comme premier coup du block. Capture autorisée uniquement sur pion ou pièce mineure. L'échec redevient autorisé. `;
- }
-
- return "Double coup Le départ est standard sur ce block. ";
-}
-
-function renderCubeElapsed(match) {
- if (match.phase !== "cube") {
+function renderCubeElapsed(storedMatch) {
+ if (storedMatch.phase !== "cube") {
return "00:00.0";
}
- if (match.cube.running) {
- const live = Date.now() - match.cube.startedAt;
- return formatStopwatch(live);
+ if (storedMatch.cube.running) {
+ return formatStopwatch(Date.now() - storedMatch.cube.startedAt);
}
- if (match.cube.elapsedMs > 0) {
- return formatStopwatch(match.cube.elapsedMs);
+ if (storedMatch.cube.elapsedMs > 0) {
+ return formatStopwatch(storedMatch.cube.elapsedMs);
}
return "00:00.0";
}
-function renderCubeStatus(match) {
- if (match.phase !== "cube") {
- return "La phase cube se déclenche à la fin du block.";
- }
-
- if (match.cube.running) {
- return "Les deux joueurs sont en résolution. Arrêter chaque côté au moment de la fin.";
- }
-
- if (match.cube.times.white !== null && match.cube.times.black !== null) {
- if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) {
- return "Égalité parfaite : le règlement impose de rejouer immédiatement la phase cube.";
- }
-
- return "Les deux temps sont saisis. Appliquer l'issue pour préparer le block suivant.";
- }
-
- return "Lancer la phase cube puis capturer chaque fin de résolution.";
-}
-
-function renderCubeCap(time) {
+function renderCubeCap(storedMatch, time) {
if (time === null) {
return "";
}
- if (!state.match || state.match.config.mode !== "time") {
- return "";
+ if (storedMatch.config.mode !== "time") {
+ return "temps capture";
}
if (time <= CUBE_TIME_CAP_MS) {
return "plafond non atteint";
}
- return `pris en compte : ${formatStopwatch(CUBE_TIME_CAP_MS)}`;
+ return `pris en compte ${formatStopwatch(CUBE_TIME_CAP_MS)}`;
}
-function formatClock(ms) {
- const totalSeconds = Math.floor(ms / 1000);
- const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
- const seconds = String(totalSeconds % 60).padStart(2, "0");
- return `${minutes}:${seconds}`;
-}
-
-function formatSignedClock(ms) {
- const negative = ms < 0;
- const absolute = Math.abs(ms);
- const totalSeconds = Math.floor(absolute / 1000);
- const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
- const seconds = String(totalSeconds % 60).padStart(2, "0");
- return `${negative ? "-" : ""}${minutes}:${seconds}`;
-}
-
-function formatStopwatch(ms) {
- const totalSeconds = Math.floor(ms / 1000);
- const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
- const seconds = String(totalSeconds % 60).padStart(2, "0");
- const tenths = Math.floor((ms % 1000) / 100);
- return `${minutes}:${seconds}.${tenths}`;
-}
-
-function getTimeBlockType(blockNumber) {
- return blockNumber % 2 === 1 ? "minus" : "plus";
-}
-
-function playerLabel(color) {
- if (!state.match) {
- return color === "white" ? "Blanc" : "Noir";
+function resultText(storedMatch) {
+ if (storedMatch.result === "white") {
+ return `Victoire de ${playerName(storedMatch, "white")}`;
}
- return color === "white" ? state.match.config.whiteName : state.match.config.blackName;
+ if (storedMatch.result === "black") {
+ return `Victoire de ${playerName(storedMatch, "black")}`;
+ }
+
+ return "Partie arretee";
+}
+
+function routeForMatch(storedMatch) {
+ if (!storedMatch) {
+ return "index.html";
+ }
+
+ return storedMatch.phase === "cube" && !storedMatch.result ? "cube.html" : "chrono.html";
+}
+
+function navigateTo(target) {
+ persistMatch();
+ window.location.href = target;
+}
+
+function replaceTo(target) {
+ window.location.replace(target);
+}
+
+function persistMatch() {
+ if (!match) {
+ localStorage.removeItem(STORAGE_KEY);
+ dirty = false;
+ return;
+ }
+
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(match));
+ dirty = false;
+}
+
+function flushState() {
+ if (match) {
+ syncRunningState(match);
+ }
+
+ if (dirty) {
+ persistMatch();
+ }
+}
+
+function clearMatch() {
+ match = null;
+ persistMatch();
+}
+
+function toggleModal(element, open) {
+ if (!element) {
+ return;
+ }
+
+ element.classList.toggle("hidden", !open);
+ element.setAttribute("aria-hidden", String(!open));
+}
+
+function loadDemo(form, onRender) {
+ form.elements.matchLabel.value = "Demo officielle ChessCubing";
+ setRadioValue(form, "mode", "twice");
+ setRadioValue(form, "preset", "freeze");
+ form.elements.whiteName.value = "Nora";
+ form.elements.blackName.value = "Leo";
+ form.elements.arbiterName.value = "Arbitre demo";
+ form.elements.eventName.value = "Session telephone";
+ form.elements.notes.value = "8 cubes verifies, variante prete, tirage au sort effectue.";
+ onRender();
+}
+
+function getRadioValue(form, name) {
+ const selected = form.querySelector(`input[name="${name}"]:checked`);
+ return selected ? selected.value : "";
+}
+
+function setRadioValue(form, name, value) {
+ const input = form.querySelector(`input[name="${name}"][value="${value}"]`);
+ if (input) {
+ input.checked = true;
+ }
+}
+
+function playerName(storedMatch, color) {
+ return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName;
}
function opponentOf(color) {
return color === "white" ? "black" : "white";
}
-function logEvent(message, targetMatch = state.match) {
- if (!targetMatch) {
- return;
- }
+function getTimeBlockType(blockNumber) {
+ return blockNumber % 2 === 1 ? "minus" : "plus";
+}
- const now = new Date();
- targetMatch.history.push({
+function pickCubeNumber() {
+ return Math.floor(Math.random() * 4) + 1;
+}
+
+function logEvent(storedMatch, message) {
+ storedMatch.history.push({
message,
- time: now.toLocaleTimeString("fr-FR", {
+ time: new Date().toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
@@ -1092,21 +1426,27 @@ function logEvent(message, targetMatch = state.match) {
});
}
-function persistState() {
- if (!state.match) {
- localStorage.removeItem(STORAGE_KEY);
- return;
- }
-
- localStorage.setItem(STORAGE_KEY, JSON.stringify(state.match));
+function formatClock(ms) {
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
+ return `${minutes}:${seconds}`;
}
-function escapeHtml(value) {
- return String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """);
+function formatSignedClock(ms) {
+ const negative = ms < 0 ? "-" : "";
+ const totalSeconds = Math.floor(Math.abs(ms) / 1000);
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
+ return `${negative}${minutes}:${seconds}`;
+}
+
+function formatStopwatch(ms) {
+ const totalSeconds = Math.floor(Math.abs(ms) / 1000);
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
+ const tenths = Math.floor((Math.abs(ms) % 1000) / 100);
+ return `${minutes}:${seconds}.${tenths}`;
}
function sanitizeText(value) {
@@ -1116,14 +1456,11 @@ function sanitizeText(value) {
.replace(/\s+/g, " ");
}
-function loadDemo() {
- refs.setupForm.matchLabel.value = "Démo officielle ChessCubing";
- refs.setupForm.mode.value = "twice";
- refs.setupForm.preset.value = "freeze";
- refs.setupForm.whiteName.value = "Nora";
- refs.setupForm.blackName.value = "Léo";
- refs.setupForm.arbiterName.value = "Arbitre demo";
- refs.setupForm.eventName.value = "Session tablette";
- refs.setupForm.notes.value = "8 cubes vérifiés, variantes prêtes, tirage au sort effectué.";
- renderSetupSummary();
+function escapeHtml(value) {
+ return String(value || "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
diff --git a/chrono.html b/chrono.html
new file mode 100644
index 0000000..0e8b294
--- /dev/null
+++ b/chrono.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+ ChessCubing Arena | Phase Chrono
+
+
+
+
+
+
+
+
+ Temps block
+ 03:00
+
+
+ Temps coup
+ 00:20
+
+
+ Trait
+ Blanc
+
+
+
+
+
+
+
+
+ Noir
+
Noir
+
+
+ 0 / 6
+
+
+
+
+
+ En attente
+
+
+
+
+
+
+
+
+
État du block
+
Prêt à démarrer
+
+
+
+
+ Démarrer le block
+
+
+
+
+
+
+
+ Blanc
+
Blanc
+
+
+ 0 / 6
+
+
+
+
+
+ En attente
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Outils arbitre
+
Contrôles avancés
+
+
Fermer
+
+
+
+
+
+
+ Pause / reprise
+
+
+ Clore le block
+
+
+ Dépassement 20 s
+
+
+ Corriger le trait
+
+
+ Blanc gagne
+
+
+ Noir gagne
+
+
+ Abandon / arrêt
+
+
+ Réinitialiser le match
+
+
+
+
+
+
+
+
diff --git a/cube.html b/cube.html
new file mode 100644
index 0000000..c58b424
--- /dev/null
+++ b/cube.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+ ChessCubing Arena | Phase Cube
+
+
+
+
+
+
+
+
+ Block
+ 1
+
+
+ Chrono cube
+ 00:00.0
+
+
+ État
+ Prêt
+
+
+
+
+
+
+
+
+ Noir
+
Noir
+
+
+ --
+
+
+
+
+
+ Prêt
+
+
+
+
+
+
+
+
+
Blocage
+
Lancer la phase cube
+
+
+
+
+ Démarrer la phase cube
+
+
+
+
+
+
+
+ Blanc
+
Blanc
+
+
+ --
+
+
+
+
+
+ Prêt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Outils arbitre
+
Phase cube
+
+
+ Fermer
+
+
+
+
+
+
+
+ Rejouer la phase cube
+
+
+ Réinitialiser le match
+
+
+
+
+
+
+
+
diff --git a/index.html b/index.html
index 65b4bb4..f5aff09 100644
--- a/index.html
+++ b/index.html
@@ -5,72 +5,62 @@
- ChessCubing Arena
+ ChessCubing Arena | Configuration
-
+
-
-
+
+
+
Application officielle de match
ChessCubing Arena
- Une web app pensée pour l'arbitrage sur téléphone et tablette,
- directement dérivée des règlements de
- ChessCubing Twice et
- ChessCubing Time .
+ Une version mobile et tablette organisée par phases : configuration,
+ page chrono ultra lisible, puis page cube dédiée.
-
-
- Configurer une rencontre
-
-
- Voir la synthèse du règlement
-
-
- Mobile-first
+ Pages séparées
+ Face-à-face téléphone
Twice + Time
- Arbitrage en direct
- Fonctionne hors build
+ Arbitrage tactile
-
Ce que l'application gère
-
- Blocks de 180 s et quotas FAST / FREEZE / MASTERS
- Temps par coup de 20 s
- Phase cube, égalité, gagnant et block suivant
- Mode Time avec chronos cumulés et blocs - / +
-
+
Flux de match
+
+ Configurer la rencontre
+ Passer à la page chrono
+ Basculer automatiquement sur la page cube
+ Revenir sur la page chrono pour le block suivant
+
-
Block 1
-
Twice ou Time, selon la configuration
+
Téléphone
+
Un joueur en haut, un joueur en bas
- L'app sert de chef d'orchestre pendant la partie sans imposer un
- échiquier numérique.
+ La zone du haut s'inverse automatiquement pour que les deux
+ joueurs se fassent face autour de l'appareil.
-
-
+
+
-
Préparer la rencontre
-
Configuration de match
+
Nouvelle rencontre
+
Configuration
- Lancement rapide pour club, démo ou tournoi. Les choix pilotent le
- tableau d'arbitrage en direct.
+ Les réglages ci-dessous préparent les pages chrono et cube.
@@ -92,16 +82,16 @@
ChessCubing Twice
- Le gagnant du cube commence le block suivant et peut obtenir
- un double coup selon la règle V2.
+ Le gagnant du cube ouvre le block suivant et peut obtenir un
+ double coup V2.
ChessCubing Time
- Même structure de blocks, avec deux chronos cumulés et un
- impact cube en bloc - / +.
+ Même structure de blocks, avec chronos cumulés et alternance
+ bloc - / bloc +.
@@ -113,41 +103,29 @@
FAST
- 6 coups par joueur et par block
+ 6 coups par joueur
FREEZE
- 8 coups par joueur et par block
+ 8 coups par joueur
MASTERS
- 10 coups par joueur et par block
+ 10 coups par joueur
Joueur blanc
-
+
Joueur noir
-
+
@@ -166,243 +144,77 @@
name="eventName"
type="text"
maxlength="60"
- placeholder="Club local, tournoi, démonstration"
+ placeholder="Club, tournoi, démonstration"
/>
- Notes de mise en place
+ Notes
-
+
- Lancer le match
+ Ouvrir la page chrono
-
+
Charger une démo
-
+
-
Direction de match
-
Rencontre en direct
+
Match en mémoire
+
Reprise rapide
-
Prêt
-
-
-
-
-
Block 1
-
ChessCubing Twice
-
- Les Blancs commencent le block 1.
-
-
-
- Réinitialiser
-
-
+
+
Aucun match en cours pour l'instant.
+
-
-
-
Chrono du block
-
03:00
-
En attente du démarrage.
-
-
-
Temps par coup
-
00:20
-
Trait : Blanc
-
-
-
-
-
-
- Blanc
- Blanc
-
- 0 / 6 coups
-
-
-
-
-
-
- Noir
- Noir
-
- 0 / 6 coups
-
-
-
-
+
+
+ Page chrono
+ Gros boutons uniquement
+
+ Chaque joueur dispose d'une grande zone tactile pour signaler la
+ fin de son coup, avec le haut de l'écran inversé sur téléphone.
+
-
-
- Commandes d'arbitrage
-
-
- Démarrer le block
-
-
- Clore le block
-
-
- Coup compté
-
-
- Coup hors quota
-
-
- Dépassement 20 s
-
-
- Corriger le trait
-
-
-
-
-
-
-
-
- Blanc gagne
-
-
- Noir gagne
-
-
- Abandon / arrêt
-
-
+
+ Page cube
+ Une page dédiée
+
+ Le cube désigné, le chrono commun et les arrêts Blanc / Noir
+ sont isolés sur leur propre écran.
+
-
-
- Phase cube
-
-
- Cube désigné
- -
-
-
- Démarrer la phase cube
-
-
-
-
-
00:00.0
-
- La phase cube se déclenche à la fin du block.
-
-
-
-
-
- Arrêt Blanc
-
-
- Arrêt Noir
-
-
-
-
-
- Blanc
- --
-
-
-
- Noir
- --
-
-
-
-
-
-
- Appliquer et préparer le block suivant
-
-
- Rejouer la phase cube
-
-
-
-
-
- Historique
-
-
-
-
- Synthèse règlementaire
-
-
-
Twice
-
Blocks et cube
-
- Block de 180 s, 20 s max par coup, phase cube obligatoire
- entre chaque block, gagnant du cube au départ suivant.
-
-
-
-
Twice
-
Double coup V2
-
- Accordé si le gagnant du cube n'a pas joué le dernier coup
- du block précédent. Premier coup gratuit sans échec,
- deuxième coup compté.
-
-
-
-
Time
-
Impact cube
-
- Block impair : le temps cube est retiré de son propre
- chrono. Block pair : il est ajouté au chrono adverse, avec
- plafond de 120 s pris en compte.
-
-
-
-
Arbitrage
-
Vérifications clés
-
- Huit cubes, caches numérotés, mélanges identiques,
- application lancée, variante choisie, tirage au sort fait,
- aucun coup pendant la phase cube.
-
-
-
+
+ Sources
+ Règlements intégrés
+
+ Règlement ChessCubing Twice
+
+ Règlement ChessCubing Time
+
-
+
-
-
diff --git a/styles.css b/styles.css
index 7568788..82e32d8 100644
--- a/styles.css
+++ b/styles.css
@@ -1,18 +1,20 @@
:root {
- --bg: #07151f;
- --bg-soft: rgba(12, 31, 42, 0.84);
- --panel: rgba(10, 26, 37, 0.86);
+ --bg: #06131d;
+ --bg-soft: rgba(11, 26, 38, 0.88);
+ --panel: rgba(9, 22, 34, 0.9);
--panel-border: rgba(255, 255, 255, 0.08);
- --panel-highlight: rgba(255, 193, 124, 0.22);
- --text: #eef5f2;
- --muted: #97adb0;
- --warm: #ffb86c;
- --warm-strong: #ff8f3c;
- --cool: #5de2d8;
- --cool-strong: #23bdb0;
- --danger: #ff6b6b;
+ --text: #edf6f3;
+ --muted: #9bb1b6;
+ --warm: #ffbd6d;
+ --warm-strong: #ff8f37;
+ --cool: #64dfd4;
+ --cool-strong: #1bbcae;
+ --white-seat: #ffd9b0;
+ --dark-seat: #baf5ef;
+ --danger: #ff7272;
+ --success: #8fe388;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
- --radius: 26px;
+ --radius: 28px;
}
*,
@@ -31,9 +33,9 @@ body {
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: var(--text);
background:
- radial-gradient(circle at top left, rgba(255, 184, 108, 0.15), transparent 30%),
- radial-gradient(circle at bottom right, rgba(93, 226, 216, 0.16), transparent 26%),
- linear-gradient(160deg, #030c12 0%, #07151f 46%, #0a2331 100%);
+ radial-gradient(circle at top left, rgba(255, 189, 109, 0.16), transparent 28%),
+ radial-gradient(circle at bottom right, rgba(100, 223, 212, 0.18), transparent 26%),
+ linear-gradient(165deg, #020a10 0%, #07141d 45%, #0a2433 100%);
}
body::before {
@@ -42,79 +44,46 @@ body::before {
inset: 0;
pointer-events: none;
background-image:
- linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
- linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
- background-size: 42px 42px;
- mask-image: radial-gradient(circle at center, black 48%, transparent 100%);
+ linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
+ background-size: 40px 40px;
+ mask-image: radial-gradient(circle at center, black 50%, transparent 100%);
}
.ambient {
position: fixed;
- width: 28rem;
- height: 28rem;
+ width: 30rem;
+ height: 30rem;
border-radius: 50%;
- filter: blur(70px);
+ filter: blur(80px);
opacity: 0.22;
pointer-events: none;
}
.ambient-left {
- top: -10rem;
+ top: -12rem;
left: -8rem;
background: var(--warm-strong);
}
.ambient-right {
- right: -10rem;
- bottom: -8rem;
+ right: -12rem;
+ bottom: -9rem;
background: var(--cool-strong);
}
-.layout {
- position: relative;
- width: min(1200px, calc(100% - 2rem));
- margin: 0 auto;
- padding: 1.2rem 0 2.4rem;
+a {
+ color: var(--warm);
+ text-decoration: none;
}
-.hero,
-.panel,
-.footer {
- animation: rise 0.7s ease both;
-}
-
-.hero {
- display: grid;
- grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.95fr);
- gap: 1.2rem;
- align-items: stretch;
- margin-bottom: 1.2rem;
-}
-
-.hero-copy,
-.hero-preview,
-.panel,
-.panel.inset {
- border: 1px solid var(--panel-border);
- border-radius: calc(var(--radius) + 4px);
- background: var(--panel);
- backdrop-filter: blur(18px);
- box-shadow: var(--shadow);
-}
-
-.hero-copy {
- padding: 2rem;
-}
-
-.hero-preview {
- padding: 1.4rem;
- display: grid;
- gap: 1rem;
+a:hover {
+ text-decoration: underline;
}
.eyebrow,
.micro-label {
- margin: 0 0 0.4rem;
+ margin: 0 0 0.45rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--cool);
@@ -132,95 +101,175 @@ strong {
font-family: "Baskerville", "Georgia", serif;
}
-h1 {
- margin: 0;
- font-size: clamp(2.8rem, 7vw, 4.8rem);
- line-height: 0.96;
-}
-
+h1,
h2,
-h3 {
- margin: 0;
+h3,
+p {
+ margin-top: 0;
}
.lead,
.section-copy,
.preview-banner p,
.rule-card p,
-.preview-list,
-.footer p {
+.phase-subtitle,
+.zone-foot,
+.resume-card,
+.setup-summary,
+.modal-card p {
color: var(--muted);
}
-.hero-actions,
-.setup-actions,
-.action-grid,
-.result-grid,
-.capture-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
+.button {
+ appearance: none;
+ border: 0;
+ border-radius: 18px;
+ padding: 0.95rem 1.15rem;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 160ms ease, filter 160ms ease, background 160ms ease;
+ color: var(--text);
}
-.hero-actions {
- margin: 1.6rem 0 1rem;
+.button:hover {
+ transform: translateY(-2px);
+ filter: brightness(1.05);
+}
+
+.button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.button.primary {
+ background: linear-gradient(135deg, var(--warm-strong), var(--warm));
+ color: #20150a;
+}
+
+.button.secondary {
+ background: linear-gradient(135deg, rgba(100, 223, 212, 0.18), rgba(100, 223, 212, 0.08));
+ border: 1px solid rgba(100, 223, 212, 0.2);
+}
+
+.button.ghost {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.09);
+}
+
+.button.danger {
+ color: #ffd8d8;
+ border-color: rgba(255, 114, 114, 0.3);
+}
+
+.button.small {
+ padding: 0.7rem 0.9rem;
+}
+
+.setup-shell {
+ position: relative;
+ width: min(1220px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 1.2rem 0 2rem;
+}
+
+.hero,
+.panel {
+ border: 1px solid var(--panel-border);
+ border-radius: calc(var(--radius) + 4px);
+ background: var(--panel);
+ backdrop-filter: blur(20px);
+ box-shadow: var(--shadow);
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.9fr);
+ gap: 1.2rem;
+ padding: 1.3rem;
+}
+
+.hero-copy,
+.hero-preview {
+ padding: 1rem;
+}
+
+.hero-preview {
+ display: grid;
+ gap: 1rem;
+}
+
+.hero-setup h1 {
+ margin: 0;
+ font-size: clamp(2.7rem, 6.5vw, 4.9rem);
+ line-height: 0.95;
}
.hero-pills {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
+ margin-top: 1.2rem;
}
.hero-pills span,
.mini-chip,
-.status-badge {
+.seat-tag {
display: inline-flex;
align-items: center;
- gap: 0.4rem;
- padding: 0.5rem 0.85rem;
+ justify-content: center;
+ padding: 0.42rem 0.78rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
- color: var(--text);
+ background: rgba(255, 255, 255, 0.05);
}
.preview-card,
.preview-banner,
-.notice-card,
-.double-card,
.rule-card,
+.resume-card,
.setup-summary,
-.timer-card,
-.player-card,
-.cube-results > div {
- padding: 1rem;
- border-radius: 22px;
+.status-card,
+.spine-card,
+.modal-card,
+.zone-inner {
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
- border: 1px solid rgba(255, 255, 255, 0.07);
}
-.preview-list {
+.preview-card,
+.preview-banner,
+.rule-card,
+.resume-card,
+.setup-summary,
+.status-card,
+.spine-card,
+.modal-card {
+ padding: 1rem;
+}
+
+.phase-list {
margin: 0.8rem 0 0;
- padding-left: 1rem;
+ padding-left: 1.1rem;
+ color: var(--muted);
}
-.preview-list li + li {
+.phase-list li + li {
margin-top: 0.45rem;
}
-.workspace {
+.setup-grid {
display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.82fr);
gap: 1.2rem;
+ margin-top: 1.2rem;
}
.panel {
- padding: 1.4rem;
-}
-
-.panel.inset {
- padding: 1.15rem;
+ padding: 1.35rem;
}
.section-heading {
@@ -228,7 +277,7 @@ h3 {
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
- margin-bottom: 1.2rem;
+ margin-bottom: 1.1rem;
}
.setup-form {
@@ -246,18 +295,24 @@ h3 {
grid-column: 1 / -1;
}
-legend,
-.field > span {
- font-weight: 600;
+.field > span,
+legend {
+ font-weight: 700;
+}
+
+fieldset {
+ border: 0;
+ margin: 0;
+ padding: 0;
}
input,
textarea {
width: 100%;
padding: 0.95rem 1rem;
- border-radius: 16px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(0, 0, 0, 0.18);
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ background: rgba(0, 0, 0, 0.2);
color: var(--text);
font: inherit;
}
@@ -269,16 +324,10 @@ textarea {
input:focus,
textarea:focus {
- outline: 2px solid rgba(93, 226, 216, 0.35);
+ outline: 2px solid rgba(100, 223, 212, 0.35);
outline-offset: 1px;
}
-fieldset {
- margin: 0;
- padding: 0;
- border: 0;
-}
-
.option-grid {
display: grid;
gap: 0.85rem;
@@ -296,351 +345,435 @@ fieldset {
position: relative;
display: grid;
gap: 0.45rem;
- min-height: 9rem;
+ min-height: 8.7rem;
padding: 1rem 1rem 1rem 3rem;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
- transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.option-card:hover {
transform: translateY(-3px);
- border-color: rgba(255, 184, 108, 0.35);
+ border-color: rgba(255, 189, 109, 0.3);
}
.option-card input {
position: absolute;
- top: 1.2rem;
+ top: 1.1rem;
left: 1rem;
width: 1.1rem;
height: 1.1rem;
}
.option-card:has(input:checked) {
+ border-color: rgba(255, 189, 109, 0.55);
background:
- linear-gradient(160deg, rgba(255, 184, 108, 0.18), rgba(93, 226, 216, 0.08));
- border-color: rgba(255, 184, 108, 0.55);
- box-shadow: inset 0 0 0 1px rgba(255, 184, 108, 0.14);
-}
-
-.button {
- appearance: none;
- border: 0;
- border-radius: 16px;
- padding: 0.95rem 1.1rem;
- font: inherit;
- font-weight: 700;
- cursor: pointer;
- color: var(--text);
- transition: transform 160ms ease, filter 160ms ease, background 160ms ease;
-}
-
-.button:hover {
- transform: translateY(-2px);
- filter: brightness(1.04);
-}
-
-.button:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- transform: none;
-}
-
-.button.primary {
- background: linear-gradient(135deg, var(--warm-strong), var(--warm));
- color: #1d140a;
-}
-
-.button.secondary {
- background: linear-gradient(135deg, rgba(93, 226, 216, 0.18), rgba(93, 226, 216, 0.08));
- border: 1px solid rgba(93, 226, 216, 0.25);
-}
-
-.button.ghost {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.08);
-}
-
-.button.capture {
- flex: 1 1 12rem;
- background: linear-gradient(135deg, rgba(255, 184, 108, 0.16), rgba(255, 184, 108, 0.08));
- border: 1px solid rgba(255, 184, 108, 0.25);
-}
-
-.button.danger {
- border-color: rgba(255, 107, 107, 0.25);
- color: #ffd7d7;
-}
-
-.button.small {
- padding: 0.7rem 0.95rem;
+ linear-gradient(160deg, rgba(255, 189, 109, 0.17), rgba(100, 223, 212, 0.08));
}
.setup-summary {
display: grid;
- gap: 0.35rem;
+ gap: 0.38rem;
+}
+
+.setup-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.side-panel {
+ display: grid;
+ gap: 1rem;
+ align-content: start;
+}
+
+.resume-card.empty {
+ text-align: center;
+}
+
+.resume-card strong {
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.resume-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 0.95rem;
+}
+
+.rules-stack {
+ display: grid;
+ gap: 0.95rem;
+}
+
+.phase-body {
+ min-height: 100dvh;
+ overflow: hidden;
+}
+
+.phase-shell {
+ position: relative;
+ width: min(1400px, calc(100% - 1.2rem));
+ min-height: 100dvh;
+ margin: 0 auto;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 0.9rem;
+ padding: 0.8rem 0 0.9rem;
+}
+
+.phase-header {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 0.8rem;
+ align-items: center;
+}
+
+.brand-link {
+ justify-self: start;
+ padding: 0.75rem 1rem;
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+}
+
+.brand-link:hover {
+ text-decoration: none;
+}
+
+.phase-title {
+ text-align: center;
+}
+
+.phase-title h1 {
+ margin: 0;
+ font-size: clamp(1.9rem, 4vw, 3rem);
+}
+
+.phase-subtitle {
+ margin: 0.25rem 0 0;
+}
+
+.utility-button {
+ justify-self: end;
+}
+
+.status-strip {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.8rem;
+}
+
+.status-card {
+ min-height: 6.4rem;
+ display: grid;
+ align-content: center;
+ gap: 0.28rem;
+ text-align: center;
+}
+
+.status-card span {
color: var(--muted);
}
-.live-grid {
+.status-card strong {
+ font-size: clamp(1.6rem, 3.8vw, 2.7rem);
+ line-height: 0.94;
+}
+
+.status-card.wide {
+ background:
+ linear-gradient(160deg, rgba(255, 189, 109, 0.14), rgba(100, 223, 212, 0.08));
+}
+
+.faceoff-board {
display: grid;
- grid-template-columns: 1.2fr 0.95fr;
- gap: 1rem;
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 340px) minmax(0, 1fr);
+ gap: 0.8rem;
+ min-height: 0;
}
-.live-grid > .panel.inset:nth-child(4),
-.live-grid > .panel.inset:nth-child(5) {
- grid-column: span 1;
+.player-zone {
+ min-height: 0;
}
-.score-panel {
- grid-column: 1 / -1;
+.zone-inner {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 0.9rem;
+ height: 100%;
+ padding: 1rem;
}
-.score-head {
+.zone-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.8rem;
+ align-items: flex-start;
+}
+
+.zone-head h2 {
+ margin: 0.2rem 0 0;
+ font-size: clamp(1.45rem, 3vw, 2.4rem);
+}
+
+.zone-stats {
+ text-align: right;
+ display: grid;
+ gap: 0.2rem;
+ color: var(--muted);
+}
+
+.zone-stats strong {
+ font-size: 1.4rem;
+ color: var(--text);
+}
+
+.seat-tag {
+ font-size: 0.8rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.light-seat {
+ background: rgba(255, 189, 109, 0.14);
+ color: var(--white-seat);
+}
+
+.dark-seat {
+ background: rgba(100, 223, 212, 0.14);
+ color: var(--dark-seat);
+}
+
+.zone-button {
+ width: 100%;
+ min-height: clamp(220px, 44vh, 560px);
+ padding: 1rem;
+ border-radius: 28px;
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ font: inherit;
+ font-weight: 800;
+ letter-spacing: 0.01em;
+ line-height: 1.02;
+ font-size: clamp(2.1rem, 5vw, 4.5rem);
+ cursor: pointer;
+ color: var(--text);
+ transition: transform 150ms ease, filter 150ms ease, opacity 150ms ease;
+ touch-action: manipulation;
+}
+
+.zone-button:hover {
+ transform: translateY(-2px);
+ filter: brightness(1.04);
+}
+
+.zone-button:disabled {
+ cursor: not-allowed;
+ transform: none;
+ opacity: 0.52;
+}
+
+.light-button {
+ background:
+ linear-gradient(165deg, rgba(255, 143, 55, 0.24), rgba(255, 189, 109, 0.1));
+ box-shadow: inset 0 0 0 1px rgba(255, 189, 109, 0.08);
+}
+
+.dark-button {
+ background:
+ linear-gradient(165deg, rgba(27, 188, 174, 0.22), rgba(100, 223, 212, 0.09));
+ box-shadow: inset 0 0 0 1px rgba(100, 223, 212, 0.08);
+}
+
+.zone-button.active-turn {
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 0 40px rgba(255, 189, 109, 0.18);
+ animation: pulse 1.6s ease-in-out infinite;
+}
+
+.active-zone .zone-inner {
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
+}
+
+.zone-foot {
+ min-height: 3.4rem;
+ margin: 0;
+}
+
+.phase-spine {
+ display: grid;
+ align-content: center;
+ gap: 0.8rem;
+}
+
+.spine-card {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.spine-card strong {
+ font-size: clamp(1.45rem, 3vw, 2.2rem);
+}
+
+.spine-card p {
+ margin-bottom: 0;
+}
+
+.spine-button {
+ min-height: 4.4rem;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: grid;
+ place-items: center;
+ padding: 1rem;
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(2, 8, 14, 0.72);
+ backdrop-filter: blur(6px);
+}
+
+.modal-card {
+ position: relative;
+ z-index: 1;
+ width: min(760px, 100%);
+ background: rgba(10, 23, 35, 0.96);
+ box-shadow: var(--shadow);
+}
+
+.modal-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
- margin-bottom: 1rem;
+ margin-bottom: 0.8rem;
}
-.timer-grid,
-.player-grid,
-.cube-results,
-.rules-grid {
- display: grid;
- gap: 0.85rem;
+.modal-head h2 {
+ margin-bottom: 0;
}
-.timer-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-bottom: 0.85rem;
-}
-
-.timer-card strong,
-.cube-clock strong {
- display: block;
- margin: 0.4rem 0;
- font-size: clamp(2rem, 6vw, 3.6rem);
- line-height: 0.95;
-}
-
-.timer-card.emphasized {
- background:
- linear-gradient(160deg, rgba(255, 184, 108, 0.22), rgba(255, 255, 255, 0.03));
-}
-
-.player-grid,
-.cube-results,
-.rules-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
-}
-
-.player-card {
- position: relative;
- overflow: hidden;
-}
-
-.player-card::after {
- content: "";
- position: absolute;
- inset: auto -10% -50% auto;
- width: 9rem;
- height: 9rem;
- border-radius: 50%;
- opacity: 0.18;
-}
-
-.white-seat::after {
- background: var(--warm);
-}
-
-.black-seat::after {
- background: var(--cool);
-}
-
-.player-card.active {
- border-color: var(--panel-highlight);
- box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14);
- animation: pulse 1.8s ease-in-out infinite;
-}
-
-.player-name-row {
+.modal-actions {
display: flex;
- justify-content: space-between;
- gap: 1rem;
- align-items: center;
- margin-bottom: 0.35rem;
-}
-
-.player-color {
- padding: 0.25rem 0.55rem;
- border-radius: 999px;
- font-size: 0.82rem;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- background: rgba(255, 255, 255, 0.08);
-}
-
-.muted {
- color: var(--muted);
-}
-
-.notice-card,
-.double-card {
- margin-top: 1rem;
- color: var(--muted);
-}
-
-.double-card strong {
- display: block;
- margin-bottom: 0.35rem;
- color: var(--warm);
-}
-
-.cube-head {
- display: flex;
- justify-content: space-between;
- gap: 1rem;
- align-items: center;
- margin-bottom: 1rem;
-}
-
-.cube-clock {
- padding: 1rem;
- border-radius: 22px;
- background:
- linear-gradient(160deg, rgba(93, 226, 216, 0.18), rgba(255, 255, 255, 0.03));
- border: 1px solid rgba(93, 226, 216, 0.18);
- margin-bottom: 1rem;
-}
-
-.cube-results {
- margin: 1rem 0;
-}
-
-.cube-results strong {
- font-size: 1.35rem;
- display: block;
- margin-top: 0.2rem;
-}
-
-.compact {
- margin-top: 0.8rem;
-}
-
-.history-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: grid;
+ flex-wrap: wrap;
gap: 0.75rem;
- max-height: 24rem;
- overflow: auto;
-}
-
-.history-list li {
- padding: 0.85rem 0.95rem;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.history-list small {
- display: block;
- margin-bottom: 0.25rem;
- color: var(--cool);
-}
-
-.footer {
margin-top: 1rem;
- padding: 1rem 0 0;
-}
-
-.footer a {
- color: var(--warm);
-}
-
-.hidden {
- display: none;
-}
-
-@keyframes rise {
- from {
- opacity: 0;
- transform: translateY(14px);
- }
-
- to {
- opacity: 1;
- transform: translateY(0);
- }
}
@keyframes pulse {
0%,
100% {
- box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.2), inset 0 0 0 1px rgba(255, 184, 108, 0.14);
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 0 28px rgba(255, 189, 109, 0.12);
}
50% {
- box-shadow: 0 0 0 1px rgba(255, 184, 108, 0.32), 0 0 32px rgba(255, 184, 108, 0.16);
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.1),
+ 0 0 38px rgba(255, 189, 109, 0.2);
}
}
-@media (max-width: 960px) {
+@media (max-width: 1100px) {
.hero,
- .live-grid,
+ .setup-grid,
.mode-grid,
.preset-grid,
- .player-grid,
- .cube-results,
- .rules-grid,
- .timer-grid {
+ .faceoff-board,
+ .status-strip {
grid-template-columns: 1fr;
}
- .setup-form,
- .live-grid {
+ .phase-header {
+ grid-template-columns: 1fr;
+ text-align: center;
+ }
+
+ .brand-link,
+ .utility-button {
+ justify-self: center;
+ }
+}
+
+@media (max-width: 900px) {
+ .setup-shell,
+ .phase-shell {
+ width: min(100% - 1rem, 100%);
+ }
+
+ .panel,
+ .hero {
+ padding: 1rem;
+ }
+
+ .setup-form {
grid-template-columns: 1fr;
}
- .section-heading,
- .score-head,
- .cube-head {
- flex-direction: column;
+ .setup-actions,
+ .resume-actions,
+ .modal-actions {
+ display: grid;
+ }
+
+ .faceoff-board {
+ grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 0.7rem;
+ }
+
+ .opponent-zone .mirrored-mobile {
+ transform: rotate(180deg);
+ }
+
+ .zone-button {
+ min-height: clamp(180px, 28vh, 340px);
+ font-size: clamp(1.9rem, 7vw, 3.2rem);
}
}
@media (max-width: 640px) {
- .layout {
- width: min(100% - 1rem, 100%);
+ .hero-setup h1 {
+ font-size: 2.7rem;
}
- .hero-copy,
- .hero-preview,
- .panel {
- padding: 1rem;
- border-radius: 22px;
+ .status-card {
+ min-height: 5.6rem;
}
- h1 {
- font-size: 2.65rem;
+ .status-card strong {
+ font-size: 1.9rem;
}
- .button {
+ .zone-head,
+ .modal-head,
+ .section-heading {
+ flex-direction: column;
+ }
+
+ .zone-stats {
+ text-align: left;
+ }
+
+ .button,
+ .spine-button {
width: 100%;
- justify-content: center;
- }
-
- .hero-actions,
- .setup-actions,
- .action-grid,
- .result-grid,
- .capture-grid {
- display: grid;
}
}