Automatise le passage au cube et rend les temps configurables

This commit is contained in:
2026-04-12 12:26:57 +02:00
parent c10efa872c
commit d01e132782
5 changed files with 198 additions and 131 deletions

View File

@@ -6,13 +6,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica
- configure une rencontre `Twice` ou `Time` - configure une rencontre `Twice` ou `Time`
- sépare l'application en pages dédiées : configuration, phase chrono, phase cube - 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 - permet de définir librement le temps de block et le temps par coup
- suit les quotas `FAST`, `FREEZE` et `MASTERS` - 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 - 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 la logique du double coup V2 en `Twice`
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte - applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
- conserve un historique local dans le navigateur - 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 - propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur
- ouvre automatiquement la page cube dès que la phase chess du block est terminée
## Hypothèse de produit ## Hypothèse de produit

241
app.js
View File

@@ -2,8 +2,8 @@ const PAGE = document.body.dataset.page;
const STORAGE_KEY = "chesscubing-arena-state-v2"; const STORAGE_KEY = "chesscubing-arena-state-v2";
const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:"; const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:";
const BLOCK_DURATION_MS = 180000; const DEFAULT_BLOCK_DURATION_MS = 180000;
const MOVE_LIMIT_MS = 20000; const DEFAULT_MOVE_LIMIT_MS = 20000;
const TIME_MODE_INITIAL_CLOCK_MS = 600000; const TIME_MODE_INITIAL_CLOCK_MS = 600000;
const CUBE_TIME_CAP_MS = 120000; const CUBE_TIME_CAP_MS = 120000;
@@ -40,7 +40,9 @@ let match = readStoredMatch();
let dirty = false; let dirty = false;
if (match) { if (match) {
normalizeRecoveredMatch(match); if (normalizeRecoveredMatch(match)) {
dirty = true;
}
if (syncRunningState(match)) { if (syncRunningState(match)) {
dirty = true; dirty = true;
} }
@@ -90,6 +92,8 @@ function initSetupPage() {
const mode = getRadioValue(form, "mode") || "twice"; const mode = getRadioValue(form, "mode") || "twice";
const preset = getRadioValue(form, "preset") || "fast"; const preset = getRadioValue(form, "preset") || "fast";
const quota = PRESETS[preset].quota; const quota = PRESETS[preset].quota;
const blockDurationMs = getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS);
const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS);
const timeImpact = const timeImpact =
mode === "time" mode === "time"
? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte." ? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte."
@@ -98,7 +102,7 @@ function initSetupPage() {
summary.innerHTML = ` summary.innerHTML = `
<strong>${MODES[mode].label}</strong> <strong>${MODES[mode].label}</strong>
<span>${PRESETS[preset].description}</span> <span>${PRESETS[preset].description}</span>
<span>Chaque block dure 180 secondes, chaque coup est limite a 20 secondes.</span> <span>Temps configures : block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.</span>
<span>${timeImpact}</span> <span>${timeImpact}</span>
<span>Quota actif : ${quota} coups par joueur.</span> <span>Quota actif : ${quota} coups par joueur.</span>
`; `;
@@ -116,8 +120,6 @@ function initSetupPage() {
? resultText(match) ? resultText(match)
: match.phase === "cube" : match.phase === "cube"
? "Page cube prete" ? "Page cube prete"
: match.awaitingBlockClosure
? "Fin de block a confirmer"
: "Page chrono prete"; : "Page chrono prete";
resumeCard.innerHTML = ` resumeCard.innerHTML = `
@@ -155,6 +157,8 @@ function initSetupPage() {
matchLabel: sanitizeText(data.get("matchLabel")) || "Rencontre ChessCubing", matchLabel: sanitizeText(data.get("matchLabel")) || "Rencontre ChessCubing",
mode: getRadioValue(form, "mode") || "twice", mode: getRadioValue(form, "mode") || "twice",
preset: getRadioValue(form, "preset") || "fast", preset: getRadioValue(form, "preset") || "fast",
blockDurationMs: getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS),
moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS),
whiteName: sanitizeText(data.get("whiteName")) || "Blanc", whiteName: sanitizeText(data.get("whiteName")) || "Blanc",
blackName: sanitizeText(data.get("blackName")) || "Noir", blackName: sanitizeText(data.get("blackName")) || "Noir",
arbiterName: sanitizeText(data.get("arbiterName")), arbiterName: sanitizeText(data.get("arbiterName")),
@@ -242,14 +246,6 @@ function initChronoPage() {
return; return;
} }
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) { if (match.running) {
pauseBlock(match); pauseBlock(match);
} else { } else {
@@ -266,14 +262,6 @@ function initChronoPage() {
return; return;
} }
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre."); requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre.");
dirty = true; dirty = true;
render(); render();
@@ -297,7 +285,7 @@ function initChronoPage() {
} }
match.currentTurn = opponentOf(match.currentTurn); match.currentTurn = opponentOf(match.currentTurn);
match.moveRemainingMs = MOVE_LIMIT_MS; match.moveRemainingMs = getMoveLimitMs(match);
logEvent(match, "Trait corrige manuellement par l'arbitre."); logEvent(match, "Trait corrige manuellement par l'arbitre.");
dirty = true; dirty = true;
render(); render();
@@ -336,17 +324,6 @@ function initChronoPage() {
syncRunningState(match); syncRunningState(match);
if (match.awaitingBlockClosure) {
if (match.currentTurn !== color) {
return;
}
registerReliefMove(match);
dirty = true;
render();
return;
}
if (!match.running || match.currentTurn !== color) { if (!match.running || match.currentTurn !== color) {
return; return;
} }
@@ -376,14 +353,6 @@ function initChronoPage() {
return; return;
} }
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) { if (match.running) {
pauseBlock(match); pauseBlock(match);
} else { } else {
@@ -421,19 +390,6 @@ function initChronoPage() {
return; 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) { if (!match.running) {
button.textContent = "Block en pause"; button.textContent = "Block en pause";
button.disabled = true; button.disabled = true;
@@ -474,10 +430,17 @@ function initChronoPage() {
return; return;
} }
if (!match.result && match.phase === "cube") {
persistMatch();
navigateTo("cube.html");
return;
}
refs.title.textContent = match.config.matchLabel; refs.title.textContent = match.config.matchLabel;
refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`; refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
refs.blockTimer.textContent = formatClock(match.blockRemainingMs); refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
refs.moveTimer.textContent = formatClock(match.moveRemainingMs); refs.moveTimer.textContent = formatClock(match.moveRemainingMs);
refs.arbiterTimeoutButton.textContent = `Depassement ${formatClock(getMoveLimitMs(match))}`;
if (match.result) { if (match.result) {
refs.centerLabel.textContent = "Resultat"; refs.centerLabel.textContent = "Resultat";
@@ -487,20 +450,13 @@ function initChronoPage() {
refs.spineText.textContent = "Retournez a la configuration pour lancer une nouvelle rencontre."; refs.spineText.textContent = "Retournez a la configuration pour lancer une nouvelle rencontre.";
refs.primaryButton.textContent = "Retour a l'accueil"; refs.primaryButton.textContent = "Retour a l'accueil";
refs.arbiterStatus.textContent = "Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser."; 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) { } else if (match.running) {
refs.centerLabel.textContent = "Trait"; refs.centerLabel.textContent = "Trait";
refs.centerValue.textContent = playerName(match, match.currentTurn); refs.centerValue.textContent = playerName(match, match.currentTurn);
refs.spineLabel.textContent = "Chrono en cours"; refs.spineLabel.textContent = "Chrono en cours";
refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`; refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`;
refs.spineText.textContent = "Chaque joueur tape sur sa grande zone quand son coup est termine."; refs.spineText.textContent =
"Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.";
refs.primaryButton.textContent = "Pause arbitre"; refs.primaryButton.textContent = "Pause arbitre";
refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`; refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`;
} else { } else {
@@ -508,14 +464,13 @@ function initChronoPage() {
refs.centerValue.textContent = playerName(match, match.currentTurn); refs.centerValue.textContent = playerName(match, match.currentTurn);
refs.spineLabel.textContent = "Pret"; refs.spineLabel.textContent = "Pret";
refs.spineHeadline.textContent = `Block ${match.blockNumber}`; refs.spineHeadline.textContent = `Block ${match.blockNumber}`;
refs.spineText.textContent = "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs."; refs.spineText.textContent =
"Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
refs.primaryButton.textContent = "Demarrer le block"; refs.primaryButton.textContent = "Demarrer le block";
refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`; refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`;
} }
refs.arbiterCloseBlockButton.textContent = match.awaitingBlockClosure refs.arbiterCloseBlockButton.textContent = "Passer au cube";
? "Ouvrir la page cube"
: "Clore le block";
renderZone("black"); renderZone("black");
renderZone("white"); renderZone("white");
@@ -799,15 +754,15 @@ function initCubePage() {
function createMatch(config) { function createMatch(config) {
const quota = PRESETS[config.preset].quota; const quota = PRESETS[config.preset].quota;
const newMatch = { const newMatch = {
schemaVersion: 2, schemaVersion: 3,
config, config,
phase: "block", phase: "block",
running: false, running: false,
lastTickAt: null, lastTickAt: null,
blockNumber: 1, blockNumber: 1,
currentTurn: "white", currentTurn: "white",
blockRemainingMs: BLOCK_DURATION_MS, blockRemainingMs: config.blockDurationMs,
moveRemainingMs: MOVE_LIMIT_MS, moveRemainingMs: config.moveLimitMs,
quota, quota,
moves: { moves: {
white: 0, white: 0,
@@ -848,7 +803,10 @@ function createMatch(config) {
history: [], history: [],
}; };
logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`); logEvent(
newMatch,
`Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`,
);
logEvent(newMatch, "Les Blancs commencent le block 1."); logEvent(newMatch, "Les Blancs commencent le block 1.");
return newMatch; return newMatch;
} }
@@ -863,7 +821,7 @@ function readStoredMatch() {
} }
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!parsed || parsed.schemaVersion !== 2) { if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) {
return fromWindowName; return fromWindowName;
} }
@@ -874,8 +832,44 @@ function readStoredMatch() {
} }
function normalizeRecoveredMatch(storedMatch) { function normalizeRecoveredMatch(storedMatch) {
let changed = false;
if (!storedMatch.config) {
storedMatch.config = {};
changed = true;
}
const blockDurationMs = getBlockDurationMs(storedMatch);
const moveLimitMs = getMoveLimitMs(storedMatch);
if (storedMatch.schemaVersion !== 3) {
storedMatch.schemaVersion = 3;
changed = true;
}
if (storedMatch.config.blockDurationMs !== blockDurationMs) {
storedMatch.config.blockDurationMs = blockDurationMs;
changed = true;
}
if (storedMatch.config.moveLimitMs !== moveLimitMs) {
storedMatch.config.moveLimitMs = moveLimitMs;
changed = true;
}
if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") { if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") {
storedMatch.lastTickAt = null; storedMatch.lastTickAt = null;
changed = true;
}
if (typeof storedMatch.blockRemainingMs !== "number") {
storedMatch.blockRemainingMs = blockDurationMs;
changed = true;
}
if (typeof storedMatch.moveRemainingMs !== "number") {
storedMatch.moveRemainingMs = moveLimitMs;
changed = true;
} }
if (!storedMatch.cube) { if (!storedMatch.cube) {
@@ -892,6 +886,7 @@ function normalizeRecoveredMatch(storedMatch) {
round: 1, round: 1,
history: [], history: [],
}; };
changed = true;
} }
if (!storedMatch.cube.playerState) { if (!storedMatch.cube.playerState) {
@@ -899,6 +894,7 @@ function normalizeRecoveredMatch(storedMatch) {
white: createCubePlayerState(), white: createCubePlayerState(),
black: createCubePlayerState(), black: createCubePlayerState(),
}; };
changed = true;
} }
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white); storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
@@ -911,7 +907,15 @@ function normalizeRecoveredMatch(storedMatch) {
step: 0, step: 0,
starter: "white", starter: "white",
}; };
changed = true;
} }
if (storedMatch.awaitingBlockClosure && storedMatch.phase === "block") {
openCubePhase(storedMatch, storedMatch.closureReason || "La phase chess etait deja terminee.");
changed = true;
}
return changed;
} }
function syncRunningState(storedMatch) { function syncRunningState(storedMatch) {
@@ -938,7 +942,10 @@ function syncRunningState(storedMatch) {
} }
if (storedMatch.blockRemainingMs === 0) { if (storedMatch.blockRemainingMs === 0) {
requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees."); requestBlockClosure(
storedMatch,
`Le temps de block ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`,
);
} else if (storedMatch.moveRemainingMs === 0) { } else if (storedMatch.moveRemainingMs === 0) {
registerMoveTimeout(storedMatch, true); registerMoveTimeout(storedMatch, true);
} }
@@ -974,19 +981,20 @@ function pauseBlock(storedMatch) {
} }
function requestBlockClosure(storedMatch, reason) { function requestBlockClosure(storedMatch, reason) {
if (!storedMatch || storedMatch.awaitingBlockClosure) { if (!storedMatch || storedMatch.phase !== "block") {
return; return;
} }
storedMatch.running = false; storedMatch.running = false;
storedMatch.lastTickAt = null; storedMatch.lastTickAt = null;
storedMatch.awaitingBlockClosure = true; storedMatch.awaitingBlockClosure = false;
storedMatch.closureReason = reason; storedMatch.closureReason = "";
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(storedMatch, `${reason} Verifier si des coups hors quota sont encore necessaires avant la page cube.`); logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`);
openCubePhase(storedMatch, reason);
} }
function openCubePhase(storedMatch) { function openCubePhase(storedMatch, reason = "") {
if (!storedMatch) { if (!storedMatch) {
return; return;
} }
@@ -1006,7 +1014,10 @@ function openCubePhase(storedMatch) {
black: createCubePlayerState(), black: createCubePlayerState(),
}; };
storedMatch.cube.round = 1; storedMatch.cube.round = 1;
logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`); logEvent(
storedMatch,
`${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`,
);
} }
function startCubeTimer(storedMatch, color) { function startCubeTimer(storedMatch, color) {
@@ -1099,8 +1110,8 @@ function prepareNextTwiceBlock(storedMatch, winner) {
storedMatch.phase = "block"; storedMatch.phase = "block";
storedMatch.running = false; storedMatch.running = false;
storedMatch.lastTickAt = null; storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS; storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 }; storedMatch.moves = { white: 0, black: 0 };
storedMatch.currentTurn = winner; storedMatch.currentTurn = winner;
storedMatch.doubleCoup = { storedMatch.doubleCoup = {
@@ -1130,8 +1141,8 @@ function prepareNextTimeBlock(storedMatch) {
storedMatch.phase = "block"; storedMatch.phase = "block";
storedMatch.running = false; storedMatch.running = false;
storedMatch.lastTickAt = null; storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS; storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 }; storedMatch.moves = { white: 0, black: 0 };
storedMatch.doubleCoup = { storedMatch.doubleCoup = {
eligible: false, eligible: false,
@@ -1219,7 +1230,7 @@ function registerCountedMove(storedMatch, { source }) {
storedMatch.moves[actor] += 1; storedMatch.moves[actor] += 1;
storedMatch.lastMover = actor; storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
if (source === "double") { if (source === "double") {
storedMatch.doubleCoup.step = 0; storedMatch.doubleCoup.step = 0;
@@ -1241,7 +1252,7 @@ function registerFreeDoubleMove(storedMatch) {
const actor = storedMatch.currentTurn; const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor; storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.doubleCoup.step = 2; storedMatch.doubleCoup.step = 2;
logEvent( logEvent(
storedMatch, storedMatch,
@@ -1249,21 +1260,6 @@ function registerFreeDoubleMove(storedMatch) {
); );
} }
function registerReliefMove(storedMatch) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
}
const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor;
storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
storedMatch,
`Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`,
);
}
function registerMoveTimeout(storedMatch, automatic) { function registerMoveTimeout(storedMatch, automatic) {
if (!storedMatch || storedMatch.phase !== "block") { if (!storedMatch || storedMatch.phase !== "block") {
return; return;
@@ -1273,7 +1269,7 @@ function registerMoveTimeout(storedMatch, automatic) {
if (storedMatch.doubleCoup.step === 1) { if (storedMatch.doubleCoup.step === 1) {
storedMatch.doubleCoup.step = 0; storedMatch.doubleCoup.step = 0;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent( logEvent(
storedMatch, storedMatch,
`Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`, `Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`,
@@ -1290,10 +1286,10 @@ function registerMoveTimeout(storedMatch, automatic) {
} }
storedMatch.currentTurn = opponentOf(actor); storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent( logEvent(
storedMatch, storedMatch,
`${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName( `${automatic ? "Temps par coup ecoule." : `Depassement manuel du temps par coup ${formatClock(getMoveLimitMs(storedMatch))}.`} ${playerName(
storedMatch, storedMatch,
actor, actor,
)} perd son coup, qui reste compte dans le quota.`, )} perd son coup, qui reste compte dans le quota.`,
@@ -1455,6 +1451,8 @@ function loadDemo(form, onRender) {
setInputValue(form, "matchLabel", "Demo officielle ChessCubing"); setInputValue(form, "matchLabel", "Demo officielle ChessCubing");
setRadioValue(form, "mode", "twice"); setRadioValue(form, "mode", "twice");
setRadioValue(form, "preset", "freeze"); setRadioValue(form, "preset", "freeze");
setInputValue(form, "blockSeconds", "180");
setInputValue(form, "moveSeconds", "20");
setInputValue(form, "whiteName", "Nora"); setInputValue(form, "whiteName", "Nora");
setInputValue(form, "blackName", "Leo"); setInputValue(form, "blackName", "Leo");
setInputValue(form, "arbiterName", "Arbitre demo"); setInputValue(form, "arbiterName", "Arbitre demo");
@@ -1482,6 +1480,16 @@ function setRadioValue(form, name, value) {
} }
} }
function getDurationInputMs(form, name, fallbackMs) {
const input = form.querySelector(`[name="${name}"]`);
const seconds = Number.parseInt(String(input?.value || ""), 10);
if (!Number.isFinite(seconds) || seconds <= 0) {
return fallbackMs;
}
return seconds * 1000;
}
function playerName(storedMatch, color) { function playerName(storedMatch, color) {
return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName; return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName;
} }
@@ -1556,12 +1564,33 @@ function readWindowNameState() {
const raw = window.name.slice(WINDOW_NAME_KEY.length); const raw = window.name.slice(WINDOW_NAME_KEY.length);
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return parsed && parsed.schemaVersion === 2 ? parsed : null; return parsed && isSupportedSchemaVersion(parsed.schemaVersion) ? parsed : null;
} catch { } catch {
return null; return null;
} }
} }
function isSupportedSchemaVersion(version) {
return version === 2 || version === 3;
}
function getBlockDurationMs(storedMatch) {
return normalizeDurationMs(storedMatch?.config?.blockDurationMs, DEFAULT_BLOCK_DURATION_MS);
}
function getMoveLimitMs(storedMatch) {
return normalizeDurationMs(storedMatch?.config?.moveLimitMs, DEFAULT_MOVE_LIMIT_MS);
}
function normalizeDurationMs(value, fallbackMs) {
const duration = Number(value);
if (!Number.isFinite(duration) || duration <= 0) {
return fallbackMs;
}
return Math.round(duration);
}
function createCubePlayerState() { function createCubePlayerState() {
return { return {
running: false, running: false,

View File

@@ -114,10 +114,10 @@
Pause / reprise Pause / reprise
</button> </button>
<button class="button secondary" id="arbiterCloseBlockButton" type="button"> <button class="button secondary" id="arbiterCloseBlockButton" type="button">
Clore le block Passer au cube
</button> </button>
<button class="button secondary" id="arbiterTimeoutButton" type="button"> <button class="button secondary" id="arbiterTimeoutButton" type="button">
Dépassement 20 s Dépassement temps coup
</button> </button>
<button class="button secondary" id="arbiterSwitchTurnButton" type="button"> <button class="button secondary" id="arbiterSwitchTurnButton" type="button">
Corriger le trait Corriger le trait

View File

@@ -21,7 +21,8 @@
<h1>ChessCubing Arena</h1> <h1>ChessCubing Arena</h1>
<p class="lead"> <p class="lead">
Une version mobile et tablette organisée par phases : configuration, Une version mobile et tablette organisée par phases : configuration,
page chrono ultra lisible, puis page cube dédiée. page chrono ultra lisible, puis page cube dédiée qui s'ouvre
automatiquement à la fin de la phase chess.
</p> </p>
<div class="hero-pills"> <div class="hero-pills">
<span>Pages séparées</span> <span>Pages séparées</span>
@@ -118,6 +119,34 @@
</div> </div>
</fieldset> </fieldset>
<fieldset class="field span-2">
<legend>Temps personnalisés</legend>
<div class="timing-grid">
<label class="field">
<span>Temps block (secondes)</span>
<input
name="blockSeconds"
type="number"
min="30"
max="1800"
step="5"
value="180"
/>
</label>
<label class="field">
<span>Temps coup (secondes)</span>
<input
name="moveSeconds"
type="number"
min="5"
max="300"
step="1"
value="20"
/>
</label>
</div>
</fieldset>
<label class="field"> <label class="field">
<span>Joueur blanc</span> <span>Joueur blanc</span>
<input name="whiteName" type="text" maxlength="40" placeholder="Blanc" value="Blanc" /> <input name="whiteName" type="text" maxlength="40" placeholder="Blanc" value="Blanc" />
@@ -188,15 +217,16 @@
<strong>Gros boutons uniquement</strong> <strong>Gros boutons uniquement</strong>
<p> <p>
Chaque joueur dispose d'une grande zone tactile pour signaler la 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. fin de son coup, puis l'app ouvre automatiquement la phase cube
quand le block chess est terminé.
</p> </p>
</article> </article>
<article class="rule-card"> <article class="rule-card">
<span class="micro-label">Page cube</span> <span class="micro-label">Page cube</span>
<strong>Une page dédiée</strong> <strong>Une page dédiée</strong>
<p> <p>
Le cube désigné, le chrono commun et les arrêts Blanc / Noir Les deux joueurs lancent et arrêtent leur propre chrono cube sur
sont isolés sur leur propre écran. un écran séparé, toujours en face-à-face sur mobile.
</p> </p>
</article> </article>
<article class="rule-card"> <article class="rule-card">

View File

@@ -8,14 +8,14 @@
--grid-line: rgba(92, 234, 255, 0.09); --grid-line: rgba(92, 234, 255, 0.09);
--text: #e4fbff; --text: #e4fbff;
--muted: #7da9b6; --muted: #7da9b6;
--warm: #ffb45d; --warm: #7df7bd;
--warm-strong: #ff7a1f; --warm-strong: #18d97f;
--cool: #75f2ff; --cool: #75f2ff;
--cool-strong: #1ed6ff; --cool-strong: #1ed6ff;
--white-seat: #ffd08a; --white-seat: #b6ffd8;
--dark-seat: #8ef6ff; --dark-seat: #8ef6ff;
--danger: #ff647f; --danger: #ff647f;
--success: #9ff68e; --success: #7df7bd;
--shadow: --shadow:
0 0 0 1px rgba(98, 242, 255, 0.08), 0 0 0 1px rgba(98, 242, 255, 0.08),
0 26px 80px rgba(0, 0, 0, 0.56), 0 26px 80px rgba(0, 0, 0, 0.56),
@@ -39,9 +39,9 @@ body {
font-family: "Segoe UI", "Trebuchet MS", sans-serif; font-family: "Segoe UI", "Trebuchet MS", sans-serif;
color: var(--text); color: var(--text);
background: background:
radial-gradient(circle at top left, rgba(255, 122, 31, 0.18), transparent 22%), radial-gradient(circle at top left, rgba(24, 217, 127, 0.18), transparent 22%),
radial-gradient(circle at 78% 18%, rgba(30, 214, 255, 0.18), transparent 20%), radial-gradient(circle at 78% 18%, rgba(30, 214, 255, 0.18), transparent 20%),
radial-gradient(circle at bottom center, rgba(30, 214, 255, 0.1), transparent 26%), radial-gradient(circle at bottom center, rgba(24, 217, 127, 0.1), transparent 26%),
linear-gradient(180deg, #01040a 0%, #03101b 48%, #041522 100%); linear-gradient(180deg, #01040a 0%, #03101b 48%, #041522 100%);
} }
@@ -63,7 +63,7 @@ body::after {
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(circle at 50% 110%, rgba(30, 214, 255, 0.24), transparent 24%), radial-gradient(circle at 50% 110%, rgba(24, 217, 127, 0.18), transparent 24%),
linear-gradient(180deg, transparent 0 58%, rgba(30, 214, 255, 0.06) 72%, transparent 100%); linear-gradient(180deg, transparent 0 58%, rgba(30, 214, 255, 0.06) 72%, transparent 100%);
mix-blend-mode: screen; mix-blend-mode: screen;
} }
@@ -180,9 +180,9 @@ p {
} }
.button.secondary { .button.secondary {
background: linear-gradient(135deg, rgba(255, 122, 31, 0.18), rgba(255, 180, 93, 0.08)); background: linear-gradient(135deg, rgba(24, 217, 127, 0.18), rgba(125, 247, 189, 0.08));
border-color: rgba(255, 180, 93, 0.32); border-color: rgba(125, 247, 189, 0.32);
box-shadow: 0 0 18px rgba(255, 122, 31, 0.1); box-shadow: 0 0 18px rgba(24, 217, 127, 0.1);
} }
.button.ghost { .button.ghost {
@@ -343,6 +343,12 @@ p {
gap: 1rem; gap: 1rem;
} }
.timing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.field { .field {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
@@ -435,7 +441,7 @@ textarea:focus {
.option-card:has(input:checked) { .option-card:has(input:checked) {
border-color: rgba(30, 214, 255, 0.52); border-color: rgba(30, 214, 255, 0.52);
background: background:
linear-gradient(160deg, rgba(30, 214, 255, 0.16), rgba(255, 122, 31, 0.08)); linear-gradient(160deg, rgba(30, 214, 255, 0.16), rgba(24, 217, 127, 0.09));
box-shadow: box-shadow:
inset 0 0 0 1px rgba(30, 214, 255, 0.06), inset 0 0 0 1px rgba(30, 214, 255, 0.06),
0 0 24px rgba(30, 214, 255, 0.08); 0 0 24px rgba(30, 214, 255, 0.08);
@@ -561,7 +567,7 @@ textarea:focus {
.status-card.wide { .status-card.wide {
background: background:
linear-gradient(160deg, rgba(30, 214, 255, 0.14), rgba(255, 122, 31, 0.09)); linear-gradient(160deg, rgba(30, 214, 255, 0.14), rgba(24, 217, 127, 0.09));
border-color: rgba(30, 214, 255, 0.22); border-color: rgba(30, 214, 255, 0.22);
} }
@@ -615,9 +621,9 @@ textarea:focus {
} }
.light-seat { .light-seat {
background: rgba(255, 180, 93, 0.12); background: rgba(125, 247, 189, 0.12);
color: var(--white-seat); color: var(--white-seat);
box-shadow: 0 0 16px rgba(255, 122, 31, 0.12); box-shadow: 0 0 16px rgba(24, 217, 127, 0.12);
} }
.dark-seat { .dark-seat {
@@ -661,12 +667,12 @@ textarea:focus {
.light-button { .light-button {
background: background:
linear-gradient(165deg, rgba(255, 122, 31, 0.24), rgba(255, 180, 93, 0.08)), linear-gradient(165deg, rgba(24, 217, 127, 0.24), rgba(125, 247, 189, 0.08)),
linear-gradient(180deg, rgba(11, 19, 24, 0.96), rgba(5, 10, 14, 0.98)); linear-gradient(180deg, rgba(11, 19, 24, 0.96), rgba(5, 10, 14, 0.98));
color: var(--white-seat); color: var(--white-seat);
box-shadow: box-shadow:
inset 0 0 0 1px rgba(255, 180, 93, 0.08), inset 0 0 0 1px rgba(125, 247, 189, 0.08),
0 0 28px rgba(255, 122, 31, 0.08); 0 0 28px rgba(24, 217, 127, 0.08);
} }
.dark-button { .dark-button {
@@ -786,6 +792,7 @@ textarea:focus {
.setup-grid, .setup-grid,
.mode-grid, .mode-grid,
.preset-grid, .preset-grid,
.timing-grid,
.faceoff-board { .faceoff-board {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }