2319 lines
71 KiB
JavaScript
2319 lines
71 KiB
JavaScript
const PAGE = document.body.dataset.page;
|
|
const SETUP_PAGE = "application.html";
|
|
|
|
const STORAGE_KEY = "chesscubing-arena-state-v2";
|
|
const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:";
|
|
const ASSET_TOKEN_STORAGE_KEY = "chesscubing-arena-asset-token";
|
|
const DEFAULT_BLOCK_DURATION_MS = 180000;
|
|
const DEFAULT_MOVE_LIMIT_MS = 20000;
|
|
const TIME_MODE_INITIAL_CLOCK_MS = 600000;
|
|
const CUBE_TIME_CAP_MS = 120000;
|
|
const CUBE_START_HOLD_MS = 2000;
|
|
|
|
const PRESETS = {
|
|
fast: {
|
|
label: "FAST",
|
|
quota: 6,
|
|
description: "6 coups par joueur.",
|
|
},
|
|
freeze: {
|
|
label: "FREEZE",
|
|
quota: 8,
|
|
description: "8 coups par joueur.",
|
|
},
|
|
masters: {
|
|
label: "MASTERS",
|
|
quota: 10,
|
|
description: "10 coups par joueur.",
|
|
},
|
|
};
|
|
|
|
const MODES = {
|
|
twice: {
|
|
label: "ChessCubing Twice",
|
|
subtitle: "Le gagnant du cube ouvre la partie suivante.",
|
|
},
|
|
time: {
|
|
label: "ChessCubing Time",
|
|
subtitle: "Chronos cumules et alternance bloc - / bloc +.",
|
|
},
|
|
};
|
|
|
|
syncViewportHeight();
|
|
window.addEventListener("load", syncViewportHeight);
|
|
window.addEventListener("resize", syncViewportHeight);
|
|
window.addEventListener("scroll", syncViewportHeight, { passive: true });
|
|
window.addEventListener("pageshow", syncViewportHeight);
|
|
window.addEventListener("orientationchange", syncViewportHeight);
|
|
window.visualViewport?.addEventListener("resize", syncViewportHeight);
|
|
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
|
window.setTimeout(syncViewportHeight, 0);
|
|
window.setTimeout(syncViewportHeight, 150);
|
|
window.setTimeout(syncViewportHeight, 400);
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(syncViewportHeight);
|
|
});
|
|
|
|
let match = readStoredMatch();
|
|
let dirty = false;
|
|
|
|
if (match) {
|
|
if (normalizeRecoveredMatch(match)) {
|
|
dirty = true;
|
|
}
|
|
if (syncRunningState(match)) {
|
|
dirty = true;
|
|
}
|
|
}
|
|
|
|
window.addEventListener("beforeunload", flushState);
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) {
|
|
if (match) {
|
|
syncRunningState(match);
|
|
}
|
|
flushState();
|
|
}
|
|
});
|
|
|
|
window.setInterval(() => {
|
|
if (dirty) {
|
|
persistMatch();
|
|
}
|
|
}, 1000);
|
|
|
|
switch (PAGE) {
|
|
case "setup":
|
|
initSetupPage();
|
|
break;
|
|
case "chrono":
|
|
initChronoPage();
|
|
break;
|
|
case "cube":
|
|
initCubePage();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
function syncViewportHeight() {
|
|
const visibleHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
const viewportTopOffset = window.visualViewport?.offsetTop ?? 0;
|
|
const viewportHeight = Math.max(
|
|
visibleHeight + viewportTopOffset,
|
|
window.innerHeight,
|
|
document.documentElement.clientHeight,
|
|
);
|
|
|
|
document.documentElement.style.setProperty("--app-visible-height", `${Math.round(visibleHeight)}px`);
|
|
document.documentElement.style.setProperty("--app-viewport-top", `${Math.round(viewportTopOffset)}px`);
|
|
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
|
|
}
|
|
|
|
function initSetupPage() {
|
|
const form = document.querySelector("#setupForm");
|
|
const summary = document.querySelector("#setupSummary");
|
|
const loadDemoButton = document.querySelector("#loadDemoButton");
|
|
const resumeCard = document.querySelector("#resumeCard");
|
|
const refreshAppButton = document.querySelector("#refreshAppButton");
|
|
const optionCards = Array.from(document.querySelectorAll(".option-card"));
|
|
const competitionModeInput = document.querySelector("#competitionMode");
|
|
const competitionFields = Array.from(document.querySelectorAll("[data-competition-field]"));
|
|
const moveSecondsField = document.querySelector("#moveSecondsField");
|
|
const timeInitialField = document.querySelector("#timeInitialField");
|
|
const blockSecondsLabel = document.querySelector("#blockSecondsLabel");
|
|
const moveSecondsInput = form?.querySelector('[name="moveSeconds"]');
|
|
const timeInitialInput = form?.querySelector('[name="timeInitialMinutes"]');
|
|
|
|
if (!form || !summary || !loadDemoButton || !resumeCard) {
|
|
return;
|
|
}
|
|
|
|
const syncCompetitionFields = () => {
|
|
const competitionMode = competitionModeInput instanceof HTMLInputElement && competitionModeInput.checked;
|
|
|
|
competitionFields.forEach((field) => {
|
|
if (!(field instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
field.hidden = !competitionMode;
|
|
field
|
|
.querySelectorAll("input, textarea, select")
|
|
.forEach((input) => {
|
|
if (
|
|
input instanceof HTMLInputElement ||
|
|
input instanceof HTMLTextAreaElement ||
|
|
input instanceof HTMLSelectElement
|
|
) {
|
|
input.disabled = !competitionMode;
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const syncOptionCardStates = () => {
|
|
optionCards.forEach((card) => {
|
|
if (!(card instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const input = card.querySelector('input[type="radio"], input[type="checkbox"]');
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
|
|
card.classList.toggle("is-selected", input.checked);
|
|
});
|
|
};
|
|
|
|
const renderSummary = () => {
|
|
const mode = getRadioValue(form, "mode") || "twice";
|
|
const preset = getRadioValue(form, "preset") || "fast";
|
|
const quota = PRESETS[preset].quota;
|
|
const blockDurationMs = getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS);
|
|
const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS);
|
|
const timeInitialMs = getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS);
|
|
const blockLabel = getBlockLabel(mode);
|
|
const moveLimitActive = usesMoveLimit(mode);
|
|
const timeImpact =
|
|
mode === "time"
|
|
? `Chronos cumules de ${formatClock(timeInitialMs)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time.`
|
|
: "Le gagnant du cube commence la partie suivante, avec double coup V2 possible.";
|
|
const timingText = moveLimitActive
|
|
? `Temps configures : partie ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.`
|
|
: `Temps configures : Block ${formatClock(blockDurationMs)}, temps de chaque joueur ${formatClock(timeInitialMs)}.`;
|
|
const quotaText = moveLimitActive
|
|
? `Quota actif : ${quota} coups par joueur.`
|
|
: `Quota actif : ${quota} coups par joueur et par Block.`;
|
|
|
|
if (moveSecondsField instanceof HTMLElement) {
|
|
moveSecondsField.hidden = !moveLimitActive;
|
|
}
|
|
|
|
if (timeInitialField instanceof HTMLElement) {
|
|
timeInitialField.hidden = moveLimitActive;
|
|
}
|
|
|
|
if (moveSecondsInput instanceof HTMLInputElement) {
|
|
moveSecondsInput.disabled = !moveLimitActive;
|
|
}
|
|
|
|
if (timeInitialInput instanceof HTMLInputElement) {
|
|
timeInitialInput.disabled = moveLimitActive;
|
|
}
|
|
|
|
document.body.classList.toggle("time-setup-mode", !moveLimitActive);
|
|
|
|
if (blockSecondsLabel instanceof HTMLElement) {
|
|
blockSecondsLabel.textContent =
|
|
blockLabel === "Block" ? "Temps du Block (secondes)" : "Temps partie (secondes)";
|
|
}
|
|
|
|
syncCompetitionFields();
|
|
syncOptionCardStates();
|
|
|
|
summary.innerHTML = `
|
|
<strong>${MODES[mode].label}</strong>
|
|
<span>${PRESETS[preset].description}</span>
|
|
<span>${timingText}</span>
|
|
<span>${timeImpact}</span>
|
|
<span>${quotaText}</span>
|
|
`;
|
|
};
|
|
|
|
const renderResume = () => {
|
|
if (!match) {
|
|
resumeCard.classList.add("empty");
|
|
resumeCard.innerHTML = "<p>Aucun match en cours pour l'instant.</p>";
|
|
return;
|
|
}
|
|
|
|
resumeCard.classList.remove("empty");
|
|
const phaseLabel = match.result
|
|
? resultText(match)
|
|
: match.phase === "cube"
|
|
? "Page cube prete"
|
|
: "Page chrono prete";
|
|
|
|
resumeCard.innerHTML = `
|
|
<strong>${escapeHtml(match.config.matchLabel)}</strong>
|
|
<p>${escapeHtml(MODES[match.config.mode].label)}</p>
|
|
<p>${escapeHtml(match.config.whiteName)} vs ${escapeHtml(match.config.blackName)}</p>
|
|
<p>${escapeHtml(phaseLabel)}</p>
|
|
<div class="resume-actions">
|
|
<button class="button primary" id="resumeMatchButton" type="button">
|
|
${match.result ? "Voir le match" : "Reprendre la phase"}
|
|
</button>
|
|
<button class="button ghost" id="clearMatchButton" type="button">
|
|
Effacer le match
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
resumeCard.querySelector("#resumeMatchButton")?.addEventListener("click", () => {
|
|
navigateTo(routeForMatch(match));
|
|
});
|
|
|
|
resumeCard.querySelector("#clearMatchButton")?.addEventListener("click", () => {
|
|
clearMatch();
|
|
renderResume();
|
|
});
|
|
};
|
|
|
|
form.addEventListener("input", renderSummary);
|
|
loadDemoButton.addEventListener("click", () => loadDemo(form, renderSummary));
|
|
optionCards.forEach((card) => {
|
|
if (!(card instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const input = card.querySelector('input[type="radio"], input[type="checkbox"]');
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
|
|
input.addEventListener("focus", () => {
|
|
card.classList.add("is-focused");
|
|
});
|
|
|
|
input.addEventListener("blur", () => {
|
|
card.classList.remove("is-focused");
|
|
});
|
|
});
|
|
refreshAppButton?.addEventListener("click", async () => {
|
|
if (!(refreshAppButton instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
refreshAppButton.disabled = true;
|
|
refreshAppButton.textContent = "Mise a jour...";
|
|
await forceRefreshToLatest(SETUP_PAGE);
|
|
});
|
|
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const data = new FormData(form);
|
|
const config = {
|
|
matchLabel: sanitizeText(data.get("matchLabel")) || "Rencontre ChessCubing",
|
|
competitionMode: isCheckboxChecked(form, "competitionMode"),
|
|
mode: getRadioValue(form, "mode") || "twice",
|
|
preset: getRadioValue(form, "preset") || "fast",
|
|
blockDurationMs: getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS),
|
|
moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS),
|
|
timeInitialMs: getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS),
|
|
whiteName: sanitizeText(data.get("whiteName")) || "Blanc",
|
|
blackName: sanitizeText(data.get("blackName")) || "Noir",
|
|
arbiterName: sanitizeText(data.get("arbiterName")),
|
|
eventName: sanitizeText(data.get("eventName")),
|
|
notes: sanitizeText(data.get("notes")),
|
|
};
|
|
|
|
match = createMatch(config);
|
|
dirty = true;
|
|
persistMatch();
|
|
navigateTo("chrono.html");
|
|
});
|
|
|
|
renderSummary();
|
|
renderResume();
|
|
}
|
|
|
|
function initChronoPage() {
|
|
if (!match) {
|
|
replaceTo(SETUP_PAGE);
|
|
return;
|
|
}
|
|
|
|
const goToCubePage = () => {
|
|
dirty = true;
|
|
persistMatch();
|
|
replaceTo("cube.html");
|
|
};
|
|
|
|
if (!match.result && match.phase === "cube") {
|
|
goToCubePage();
|
|
return;
|
|
}
|
|
|
|
const refs = {
|
|
title: document.querySelector("#chronoTitle"),
|
|
subtitle: document.querySelector("#chronoSubtitle"),
|
|
blockTimer: document.querySelector("#blockTimer"),
|
|
blockTimerLabel: document.querySelector("#blockTimerLabel"),
|
|
moveTimer: document.querySelector("#moveTimer"),
|
|
moveTimerCard: document.querySelector("#moveTimerCard"),
|
|
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.running) {
|
|
pauseBlock(match);
|
|
} else {
|
|
startBlock(match);
|
|
}
|
|
|
|
dirty = true;
|
|
render();
|
|
});
|
|
|
|
refs.arbiterCloseBlockButton?.addEventListener("click", () => {
|
|
syncRunningState(match);
|
|
if (match.result || match.phase !== "block") {
|
|
return;
|
|
}
|
|
|
|
requestBlockClosure(match, `Cloture manuelle ${getBlockGenitivePhrase(match)} demandee par l'arbitre.`);
|
|
dirty = true;
|
|
if (!match.result && match.phase === "cube") {
|
|
goToCubePage();
|
|
return;
|
|
}
|
|
render();
|
|
});
|
|
|
|
refs.arbiterTimeoutButton?.addEventListener("click", () => {
|
|
syncRunningState(match);
|
|
if (match.result || match.phase !== "block" || !usesMoveLimit(match)) {
|
|
return;
|
|
}
|
|
|
|
registerMoveTimeout(match, false);
|
|
dirty = true;
|
|
render();
|
|
});
|
|
|
|
refs.arbiterSwitchTurnButton?.addEventListener("click", () => {
|
|
syncRunningState(match);
|
|
if (match.result || match.phase !== "block") {
|
|
return;
|
|
}
|
|
|
|
match.currentTurn = opponentOf(match.currentTurn);
|
|
if (usesMoveLimit(match)) {
|
|
match.moveRemainingMs = getMoveLimitMs(match);
|
|
}
|
|
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(SETUP_PAGE);
|
|
});
|
|
|
|
function handleChronoTap(color) {
|
|
if (!match || match.result || match.phase !== "block") {
|
|
return;
|
|
}
|
|
|
|
syncRunningState(match);
|
|
|
|
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;
|
|
if (!match.result && match.phase === "cube") {
|
|
goToCubePage();
|
|
return;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function handlePrimaryChronoAction() {
|
|
syncRunningState(match);
|
|
|
|
if (match.result) {
|
|
navigateTo(SETUP_PAGE);
|
|
return;
|
|
}
|
|
|
|
if (match.phase !== "block") {
|
|
goToCubePage();
|
|
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 active = match.currentTurn === color;
|
|
const unitLabel = getBlockLabel(match);
|
|
const unitPhrase = getBlockPhrase(match);
|
|
|
|
name.textContent = playerName(match, color);
|
|
moves.textContent = `${match.moves[color]} / ${match.quota}`;
|
|
clock.textContent = match.clocks ? 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);
|
|
zone.classList.toggle("has-player-clock", Boolean(match.clocks));
|
|
clock.classList.toggle("player-clock", Boolean(match.clocks));
|
|
clock.classList.toggle("negative-clock", Boolean(match.clocks) && match.clocks[color] < 0);
|
|
clock.classList.toggle("active-clock", Boolean(match.clocks) && active && !match.result);
|
|
|
|
if (match.result) {
|
|
button.textContent = resultText(match);
|
|
button.disabled = true;
|
|
hint.textContent = "Le match est termine.";
|
|
return;
|
|
}
|
|
|
|
if (!match.running) {
|
|
button.textContent = `${unitLabel} en pause`;
|
|
button.disabled = true;
|
|
hint.textContent = active
|
|
? `${unitPhrase} 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;
|
|
}
|
|
|
|
if (!match.result && match.phase === "cube") {
|
|
goToCubePage();
|
|
return;
|
|
}
|
|
|
|
const timeMode = isTimeMode(match);
|
|
const blockHeading = formatBlockHeading(match, match.blockNumber);
|
|
|
|
document.body.classList.toggle("time-mode", timeMode);
|
|
refs.title.textContent = match.config.matchLabel;
|
|
refs.subtitle.textContent = `${blockHeading} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
|
|
refs.blockTimerLabel.textContent = timeMode ? "Temps Block" : "Temps partie";
|
|
refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
|
|
refs.moveTimer.textContent = usesMoveLimit(match) ? formatClock(match.moveRemainingMs) : "--:--";
|
|
refs.moveTimerCard.hidden = timeMode;
|
|
refs.arbiterTimeoutButton.hidden = timeMode;
|
|
refs.arbiterTimeoutButton.disabled = timeMode;
|
|
if (usesMoveLimit(match)) {
|
|
refs.arbiterTimeoutButton.textContent = `Depassement ${formatClock(getMoveLimitMs(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 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.running) {
|
|
refs.centerLabel.textContent = "Trait";
|
|
refs.centerValue.textContent = playerName(match, match.currentTurn);
|
|
refs.spineLabel.textContent = "Chrono en cours";
|
|
refs.spineHeadline.textContent = `${blockHeading} actif`;
|
|
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.arbiterStatus.textContent = `${blockHeading} en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`;
|
|
} else {
|
|
refs.centerLabel.textContent = "Trait";
|
|
refs.centerValue.textContent = playerName(match, match.currentTurn);
|
|
refs.spineLabel.textContent = timeMode ? "Etat du Block" : "Pret";
|
|
refs.spineHeadline.textContent = blockHeading;
|
|
refs.spineText.textContent =
|
|
"Demarrez la partie, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
|
|
refs.primaryButton.textContent = timeMode ? "Demarrer le Block" : "Demarrer la partie";
|
|
refs.arbiterStatus.textContent = `${blockHeading} pret. ${playerName(match, match.currentTurn)} commencera.`;
|
|
}
|
|
|
|
refs.arbiterCloseBlockButton.textContent = "Passer au cube";
|
|
|
|
renderZone("black");
|
|
renderZone("white");
|
|
}
|
|
|
|
render();
|
|
|
|
window.setInterval(() => {
|
|
if (!match) {
|
|
return;
|
|
}
|
|
|
|
const changed = syncRunningState(match);
|
|
if (changed) {
|
|
dirty = true;
|
|
}
|
|
|
|
if (!match.result && match.phase === "cube") {
|
|
goToCubePage();
|
|
return;
|
|
}
|
|
|
|
render();
|
|
}, 100);
|
|
}
|
|
|
|
function initCubePage() {
|
|
if (!match) {
|
|
replaceTo(SETUP_PAGE);
|
|
return;
|
|
}
|
|
|
|
if (!match.result && match.phase !== "cube") {
|
|
replaceTo("chrono.html");
|
|
return;
|
|
}
|
|
|
|
const refs = {
|
|
title: document.querySelector("#cubeTitle"),
|
|
subtitle: document.querySelector("#cubeSubtitle"),
|
|
blockLabelText: document.querySelector("#cubeBlockLabelText"),
|
|
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"),
|
|
resultModal: document.querySelector("#cubeResultModal"),
|
|
closeResultButton: document.querySelector("#closeCubeResultButton"),
|
|
resultModalTitle: document.querySelector("#cubeResultModalTitle"),
|
|
resultSummary: document.querySelector("#cubeResultSummary"),
|
|
resultWinner: document.querySelector("#cubeResultWinner"),
|
|
resultOutcome: document.querySelector("#cubeResultOutcome"),
|
|
resultWhiteName: document.querySelector("#cubeResultWhiteName"),
|
|
resultBlackName: document.querySelector("#cubeResultBlackName"),
|
|
resultWhiteTime: document.querySelector("#cubeResultWhiteTime"),
|
|
resultBlackTime: document.querySelector("#cubeResultBlackTime"),
|
|
resultWhiteDetail: document.querySelector("#cubeResultWhiteDetail"),
|
|
resultBlackDetail: document.querySelector("#cubeResultBlackDetail"),
|
|
resultWhiteClock: document.querySelector("#cubeResultWhiteClock"),
|
|
resultBlackClock: document.querySelector("#cubeResultBlackClock"),
|
|
resultActionButton: document.querySelector("#cubeResultActionButton"),
|
|
resultDismissButton: document.querySelector("#cubeResultDismissButton"),
|
|
replayCubeButton: document.querySelector("#replayCubeButton"),
|
|
resetButton: document.querySelector("#cubeResetButton"),
|
|
};
|
|
const cubeHoldState = {
|
|
white: createCubeHoldIntent(),
|
|
black: createCubeHoldIntent(),
|
|
};
|
|
let cubeHoldAnimationFrameId = null;
|
|
let resultModalKey = null;
|
|
|
|
const openModal = () => toggleModal(refs.helpModal, true);
|
|
const closeModal = () => toggleModal(refs.helpModal, false);
|
|
const openResultModal = () => toggleModal(refs.resultModal, true);
|
|
const closeResultModal = () => toggleModal(refs.resultModal, 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.closeResultButton?.addEventListener("click", closeResultModal);
|
|
refs.resultDismissButton?.addEventListener("click", closeResultModal);
|
|
refs.resultModal?.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (target instanceof HTMLElement && target.dataset.closeCubeResultModal === "true") {
|
|
closeResultModal();
|
|
}
|
|
});
|
|
|
|
bindCubeButton("white", refs.whiteButton);
|
|
bindCubeButton("black", refs.blackButton);
|
|
|
|
refs.primaryButton?.addEventListener("click", () => {
|
|
if (match.result) {
|
|
navigateTo(SETUP_PAGE);
|
|
return;
|
|
}
|
|
|
|
if (match.phase !== "cube") {
|
|
navigateTo("chrono.html");
|
|
return;
|
|
}
|
|
|
|
if (match.cube.times.white !== null && match.cube.times.black !== null) {
|
|
openResultModal();
|
|
}
|
|
});
|
|
|
|
refs.resultActionButton?.addEventListener("click", () => {
|
|
if (!match || match.result || match.phase !== "cube") {
|
|
closeResultModal();
|
|
return;
|
|
}
|
|
|
|
if (match.cube.times.white === null || match.cube.times.black === null) {
|
|
closeResultModal();
|
|
return;
|
|
}
|
|
|
|
if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) {
|
|
closeResultModal();
|
|
resultModalKey = null;
|
|
replayCubePhase(match);
|
|
dirty = true;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
closeResultModal();
|
|
applyCubeOutcome(match);
|
|
dirty = true;
|
|
persistMatch();
|
|
navigateTo("chrono.html");
|
|
});
|
|
|
|
refs.replayCubeButton?.addEventListener("click", () => {
|
|
closeResultModal();
|
|
resultModalKey = null;
|
|
replayCubePhase(match);
|
|
dirty = true;
|
|
render();
|
|
});
|
|
|
|
refs.resetButton?.addEventListener("click", () => {
|
|
closeResultModal();
|
|
clearMatch();
|
|
replaceTo(SETUP_PAGE);
|
|
});
|
|
|
|
function bindCubeButton(color, button) {
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
button.addEventListener("pointerdown", (event) => {
|
|
handleCubePressStart(color, button, event);
|
|
});
|
|
|
|
button.addEventListener("pointerup", (event) => {
|
|
handleCubePressEnd(color, button, event);
|
|
});
|
|
|
|
button.addEventListener("pointercancel", () => {
|
|
cancelCubeHold(color);
|
|
render();
|
|
});
|
|
|
|
button.addEventListener("lostpointercapture", () => {
|
|
cancelCubeHold(color);
|
|
render();
|
|
});
|
|
|
|
button.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
button.addEventListener("click", (event) => {
|
|
if (event.detail !== 0) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
handleCubeKeyboardAction(color);
|
|
dirty = true;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function handleCubePressStart(color, button, event) {
|
|
if (match.result || match.phase !== "cube") {
|
|
return;
|
|
}
|
|
|
|
if (match.cube.times[color] !== null) {
|
|
return;
|
|
}
|
|
|
|
if (match.cube.playerState[color].running) {
|
|
return;
|
|
}
|
|
|
|
const holdIntent = cubeHoldState[color];
|
|
clearCubeHoldTimeout(holdIntent);
|
|
holdIntent.armed = true;
|
|
holdIntent.ready = false;
|
|
holdIntent.startedAt = Date.now();
|
|
holdIntent.pointerId = event.pointerId;
|
|
holdIntent.timeoutId = window.setTimeout(() => {
|
|
holdIntent.timeoutId = null;
|
|
if (!holdIntent.armed) {
|
|
return;
|
|
}
|
|
|
|
holdIntent.ready = true;
|
|
render();
|
|
}, CUBE_START_HOLD_MS);
|
|
|
|
try {
|
|
button.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// Ignore browsers that do not support pointer capture on buttons.
|
|
}
|
|
|
|
render();
|
|
ensureCubeHoldAnimation();
|
|
}
|
|
|
|
function handleCubePressEnd(color, button, event) {
|
|
if (match.result || match.phase !== "cube") {
|
|
cancelCubeHold(color);
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (match.cube.times[color] !== null) {
|
|
cancelCubeHold(color);
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (match.cube.playerState[color].running) {
|
|
captureCubeTime(match, color);
|
|
dirty = true;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
const holdIntent = cubeHoldState[color];
|
|
const wasReady = holdIntent.pointerId === event.pointerId && holdIntent.ready;
|
|
cancelCubeHold(color);
|
|
|
|
try {
|
|
button.releasePointerCapture(event.pointerId);
|
|
} catch {
|
|
// Ignore browsers that already released the capture.
|
|
}
|
|
|
|
if (!wasReady) {
|
|
render();
|
|
return;
|
|
}
|
|
|
|
startCubeTimer(match, color);
|
|
dirty = true;
|
|
render();
|
|
}
|
|
|
|
function handleCubeKeyboardAction(color) {
|
|
if (match.result || match.phase !== "cube" || match.cube.times[color] !== null) {
|
|
return;
|
|
}
|
|
|
|
if (match.cube.playerState[color].running) {
|
|
captureCubeTime(match, color);
|
|
return;
|
|
}
|
|
|
|
startCubeTimer(match, color);
|
|
}
|
|
|
|
function cancelCubeHold(color) {
|
|
const holdIntent = cubeHoldState[color];
|
|
clearCubeHoldTimeout(holdIntent);
|
|
holdIntent.armed = false;
|
|
holdIntent.ready = false;
|
|
holdIntent.startedAt = 0;
|
|
holdIntent.pointerId = null;
|
|
stopCubeHoldAnimationIfIdle();
|
|
}
|
|
|
|
function ensureCubeHoldAnimation() {
|
|
if (cubeHoldAnimationFrameId !== null) {
|
|
return;
|
|
}
|
|
|
|
const tick = () => {
|
|
cubeHoldAnimationFrameId = null;
|
|
if (!isCubeHoldAnimating()) {
|
|
render();
|
|
return;
|
|
}
|
|
|
|
render();
|
|
cubeHoldAnimationFrameId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
cubeHoldAnimationFrameId = window.requestAnimationFrame(tick);
|
|
}
|
|
|
|
function stopCubeHoldAnimationIfIdle() {
|
|
if (cubeHoldAnimationFrameId === null || isCubeHoldAnimating()) {
|
|
return;
|
|
}
|
|
|
|
window.cancelAnimationFrame(cubeHoldAnimationFrameId);
|
|
cubeHoldAnimationFrameId = null;
|
|
}
|
|
|
|
function isCubeHoldAnimating() {
|
|
return Object.entries(cubeHoldState).some(([color, holdIntent]) => {
|
|
const playerState = match.cube.playerState[color];
|
|
return holdIntent.armed && !holdIntent.ready && !playerState.running && match.cube.times[color] === null;
|
|
});
|
|
}
|
|
|
|
function clearCubeHoldTimeout(holdIntent) {
|
|
if (holdIntent.timeoutId !== null) {
|
|
window.clearTimeout(holdIntent.timeoutId);
|
|
holdIntent.timeoutId = null;
|
|
}
|
|
}
|
|
|
|
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 playerState = match.cube.playerState[color];
|
|
const holdIntent = cubeHoldState[color];
|
|
const time = match.cube.times[color];
|
|
const holdArmed = holdIntent.armed && !playerState.running && time === null && match.phase === "cube" && !match.result;
|
|
const holdReady = holdIntent.ready && holdArmed;
|
|
const holdProgress = holdArmed
|
|
? Math.min((Date.now() - holdIntent.startedAt) / CUBE_START_HOLD_MS, 1)
|
|
: 0;
|
|
|
|
name.textContent = playerName(match, color);
|
|
result.textContent = formatCubePlayerTime(match, color);
|
|
cap.textContent = renderCubeMeta(match, color);
|
|
button.classList.toggle("cube-hold-arming", holdArmed && !holdReady);
|
|
button.classList.toggle("cube-hold-ready", holdReady);
|
|
button.style.setProperty("--cube-hold-progress", `${holdProgress}`);
|
|
|
|
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 (time !== null) {
|
|
button.textContent = "Temps enregistre";
|
|
button.disabled = true;
|
|
hint.textContent = "Ce joueur a deja termine son cube.";
|
|
return;
|
|
}
|
|
|
|
if (playerState.running) {
|
|
button.textContent = "J'ai fini le cube";
|
|
button.disabled = false;
|
|
hint.textContent = "Tape au moment exact ou le cube est resolu.";
|
|
return;
|
|
}
|
|
|
|
if (holdReady) {
|
|
button.textContent = "Relachez pour demarrer";
|
|
button.disabled = false;
|
|
hint.textContent = "Le chrono partira des que vous levez le doigt.";
|
|
return;
|
|
}
|
|
|
|
if (holdArmed) {
|
|
button.textContent = "Maintenez 2 s...";
|
|
button.disabled = false;
|
|
hint.textContent = "Gardez le doigt pose 2 secondes, jusqu'a la fin de la barre.";
|
|
return;
|
|
}
|
|
|
|
button.textContent = "Maintenir 2 s pour demarrer";
|
|
button.disabled = false;
|
|
hint.textContent = "Maintenez la grande zone 2 secondes, puis relachez pour lancer votre chrono.";
|
|
}
|
|
|
|
function getCubeResultState() {
|
|
if (!match || match.phase !== "cube") {
|
|
return null;
|
|
}
|
|
|
|
const white = match.cube.times.white;
|
|
const black = match.cube.times.black;
|
|
if (white === null || black === null) {
|
|
return null;
|
|
}
|
|
|
|
const whiteName = playerName(match, "white");
|
|
const blackName = playerName(match, "black");
|
|
|
|
if (match.config.mode === "time") {
|
|
const preview = getTimeAdjustmentPreview(match, white, black);
|
|
if (!preview) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
key: `time:${match.blockNumber}:${match.cube.round}:${white}:${black}`,
|
|
title: "Résumé du cube",
|
|
winner: preview.winner ? playerName(match, preview.winner) : "Egalite",
|
|
outcome:
|
|
preview.blockType === "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
|
|
summary:
|
|
"Validez ce résumé pour appliquer les impacts chrono puis revenir à la page chrono.",
|
|
actionLabel: "Appliquer et ouvrir la page chrono",
|
|
whiteName,
|
|
blackName,
|
|
whiteTime: `Temps cube ${formatStopwatch(white)}`,
|
|
blackTime: `Temps cube ${formatStopwatch(black)}`,
|
|
whiteDetail: `Impact chrono ${formatSignedStopwatch(preview.whiteDelta)}`,
|
|
blackDetail: `Impact chrono ${formatSignedStopwatch(preview.blackDelta)}`,
|
|
whiteClock: `Chrono apres ${formatSignedClock(preview.whiteAfter)}`,
|
|
blackClock: `Chrono apres ${formatSignedClock(preview.blackAfter)}`,
|
|
};
|
|
}
|
|
|
|
const winner = white < black ? "white" : black < white ? "black" : null;
|
|
const isTie = winner === null;
|
|
|
|
return {
|
|
key: `twice:${match.blockNumber}:${match.cube.round}:${white}:${black}`,
|
|
title: isTie ? "Egalite parfaite" : "Résumé du cube",
|
|
winner: winner ? playerName(match, winner) : "Egalite parfaite",
|
|
outcome: isTie ? "Rejouer la phase cube" : `${playerName(match, winner)} ouvrira la partie suivante`,
|
|
summary: isTie
|
|
? "Le règlement Twice impose de rejouer immédiatement la phase cube."
|
|
: "Validez ce résultat pour préparer la partie suivante.",
|
|
actionLabel: isTie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
|
|
whiteName,
|
|
blackName,
|
|
whiteTime: `Temps cube ${formatStopwatch(white)}`,
|
|
blackTime: `Temps cube ${formatStopwatch(black)}`,
|
|
whiteDetail: winner === "white" ? "Gagne la phase cube" : isTie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
|
|
blackDetail: winner === "black" ? "Gagne la phase cube" : isTie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
|
|
whiteClock: "Aucun impact chrono en mode Twice",
|
|
blackClock: "Aucun impact chrono en mode Twice",
|
|
};
|
|
}
|
|
|
|
function renderResultModal() {
|
|
const resultState = getCubeResultState();
|
|
if (!resultState) {
|
|
closeResultModal();
|
|
resultModalKey = null;
|
|
return;
|
|
}
|
|
|
|
refs.resultModalTitle.textContent = resultState.title;
|
|
refs.resultSummary.textContent = resultState.summary;
|
|
refs.resultWinner.textContent = resultState.winner;
|
|
refs.resultOutcome.textContent = resultState.outcome;
|
|
refs.resultWhiteName.textContent = resultState.whiteName;
|
|
refs.resultBlackName.textContent = resultState.blackName;
|
|
refs.resultWhiteTime.textContent = resultState.whiteTime;
|
|
refs.resultBlackTime.textContent = resultState.blackTime;
|
|
refs.resultWhiteDetail.textContent = resultState.whiteDetail;
|
|
refs.resultBlackDetail.textContent = resultState.blackDetail;
|
|
refs.resultWhiteClock.textContent = resultState.whiteClock;
|
|
refs.resultBlackClock.textContent = resultState.blackClock;
|
|
refs.resultActionButton.textContent = resultState.actionLabel;
|
|
|
|
if (resultState.key !== resultModalKey) {
|
|
resultModalKey = resultState.key;
|
|
openResultModal();
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const blockHeading = formatBlockHeading(match, match.blockNumber);
|
|
const timePreview =
|
|
isTimeMode(match) &&
|
|
match.cube.times.white !== null &&
|
|
match.cube.times.black !== null
|
|
? getTimeAdjustmentPreview(match, match.cube.times.white, match.cube.times.black)
|
|
: null;
|
|
|
|
refs.title.textContent = match.cube.number ? `Cube n${match.cube.number}` : "Phase cube";
|
|
refs.subtitle.textContent = `${blockHeading} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
|
|
refs.blockLabelText.textContent = getBlockLabel(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.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 = "Voir le résumé du cube";
|
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
|
} else if (match.cube.times.white !== null && match.cube.times.black !== null) {
|
|
if (timePreview) {
|
|
refs.centerLabel.textContent = "Vainqueur cube";
|
|
refs.centerValue.textContent = timePreview.winner
|
|
? playerName(match, timePreview.winner)
|
|
: "Egalite";
|
|
refs.spineLabel.textContent = "Impact chrono";
|
|
refs.spineHeadline.textContent =
|
|
timePreview.blockType === "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer";
|
|
refs.spineText.textContent =
|
|
`Blanc ${formatSignedStopwatch(timePreview.whiteDelta)} -> ${formatSignedClock(timePreview.whiteAfter)}. ` +
|
|
`Noir ${formatSignedStopwatch(timePreview.blackDelta)} -> ${formatSignedClock(timePreview.blackAfter)}.`;
|
|
} else {
|
|
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 la partie suivante.";
|
|
}
|
|
refs.primaryButton.textContent = "Voir le résumé du cube";
|
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
|
} else if (match.cube.running) {
|
|
refs.centerLabel.textContent = "Etat";
|
|
refs.centerValue.textContent = "Chronos lances";
|
|
refs.spineLabel.textContent = "Arrets";
|
|
refs.spineHeadline.textContent = "Chaque joueur se chronometre";
|
|
refs.spineText.textContent = "Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.";
|
|
refs.primaryButton.textContent = "Attendre les deux temps";
|
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
|
} else if (match.cube.times.white !== null || match.cube.times.black !== null) {
|
|
refs.centerLabel.textContent = "Etat";
|
|
refs.centerValue.textContent = "Un temps saisi";
|
|
refs.spineLabel.textContent = "Suite";
|
|
refs.spineHeadline.textContent = "Attendre l'autre joueur";
|
|
refs.spineText.textContent = "Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.";
|
|
refs.primaryButton.textContent = "Attendre le deuxieme temps";
|
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
|
} else {
|
|
refs.centerLabel.textContent = "Etat";
|
|
refs.centerValue.textContent = "Pret";
|
|
refs.spineLabel.textContent = "Depart libre";
|
|
refs.spineHeadline.textContent = "Chaque joueur lance son chrono";
|
|
refs.spineText.textContent = "Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.";
|
|
refs.primaryButton.textContent = "En attente des joueurs";
|
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
|
}
|
|
|
|
renderCubeZone("black");
|
|
renderCubeZone("white");
|
|
renderResultModal();
|
|
refs.primaryButton.disabled =
|
|
!match.result &&
|
|
!(
|
|
match.cube.times.white !== null &&
|
|
match.cube.times.black !== null
|
|
);
|
|
}
|
|
|
|
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 newMatch = {
|
|
schemaVersion: 3,
|
|
config,
|
|
phase: "block",
|
|
running: false,
|
|
lastTickAt: null,
|
|
blockNumber: 1,
|
|
currentTurn: "white",
|
|
blockRemainingMs: config.blockDurationMs,
|
|
moveRemainingMs: config.moveLimitMs,
|
|
quota,
|
|
moves: {
|
|
white: 0,
|
|
black: 0,
|
|
},
|
|
clocks:
|
|
config.mode === "time"
|
|
? {
|
|
white: getTimeInitialMs(config),
|
|
black: getTimeInitialMs(config),
|
|
}
|
|
: null,
|
|
lastMover: null,
|
|
awaitingBlockClosure: false,
|
|
closureReason: "",
|
|
result: null,
|
|
cube: {
|
|
number: null,
|
|
running: false,
|
|
startedAt: null,
|
|
elapsedMs: 0,
|
|
times: {
|
|
white: null,
|
|
black: null,
|
|
},
|
|
playerState: {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
},
|
|
round: 1,
|
|
history: [],
|
|
},
|
|
doubleCoup: {
|
|
eligible: false,
|
|
step: 0,
|
|
starter: "white",
|
|
},
|
|
history: [],
|
|
};
|
|
|
|
logEvent(
|
|
newMatch,
|
|
usesMoveLimit(config.mode)
|
|
? `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, partie ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`
|
|
: `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et chrono initial ${formatClock(getTimeInitialMs(config))} par joueur, sans temps par coup.`,
|
|
);
|
|
logEvent(newMatch, `Les Blancs commencent ${formatBlockHeading(config, 1)}.`);
|
|
return newMatch;
|
|
}
|
|
|
|
function readStoredMatch() {
|
|
const fromWindowName = readWindowNameState();
|
|
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) {
|
|
return fromWindowName;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) {
|
|
return fromWindowName;
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
return fromWindowName;
|
|
}
|
|
}
|
|
|
|
function normalizeRecoveredMatch(storedMatch) {
|
|
let changed = false;
|
|
|
|
if (!storedMatch.config) {
|
|
storedMatch.config = {};
|
|
changed = true;
|
|
}
|
|
|
|
const blockDurationMs = getBlockDurationMs(storedMatch);
|
|
const moveLimitMs = getMoveLimitMs(storedMatch);
|
|
const timeInitialMs = getTimeInitialMs(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.config.timeInitialMs !== timeInitialMs) {
|
|
storedMatch.config.timeInitialMs = timeInitialMs;
|
|
changed = true;
|
|
}
|
|
|
|
if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") {
|
|
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) {
|
|
storedMatch.cube = {
|
|
number: null,
|
|
running: false,
|
|
startedAt: null,
|
|
elapsedMs: 0,
|
|
times: { white: null, black: null },
|
|
playerState: {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
},
|
|
round: 1,
|
|
history: [],
|
|
};
|
|
changed = true;
|
|
}
|
|
|
|
if (!storedMatch.cube.playerState) {
|
|
storedMatch.cube.playerState = {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
};
|
|
changed = true;
|
|
}
|
|
|
|
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
|
|
storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black);
|
|
storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch);
|
|
|
|
if (!storedMatch.doubleCoup) {
|
|
storedMatch.doubleCoup = {
|
|
eligible: false,
|
|
step: 0,
|
|
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) {
|
|
if (!storedMatch || storedMatch.result) {
|
|
return false;
|
|
}
|
|
|
|
if (!storedMatch.running || storedMatch.phase !== "block" || !storedMatch.lastTickAt) {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const delta = now - storedMatch.lastTickAt;
|
|
if (delta <= 0) {
|
|
return false;
|
|
}
|
|
|
|
storedMatch.lastTickAt = now;
|
|
storedMatch.blockRemainingMs = Math.max(0, storedMatch.blockRemainingMs - delta);
|
|
if (usesMoveLimit(storedMatch)) {
|
|
storedMatch.moveRemainingMs = Math.max(0, storedMatch.moveRemainingMs - delta);
|
|
}
|
|
|
|
if (storedMatch.clocks) {
|
|
storedMatch.clocks[storedMatch.currentTurn] -= delta;
|
|
}
|
|
|
|
if (storedMatch.blockRemainingMs === 0) {
|
|
requestBlockClosure(
|
|
storedMatch,
|
|
`Le temps ${getBlockGenitivePhrase(storedMatch)} ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`,
|
|
);
|
|
} else if (usesMoveLimit(storedMatch) && storedMatch.moveRemainingMs === 0) {
|
|
registerMoveTimeout(storedMatch, true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function startBlock(storedMatch) {
|
|
if (!storedMatch || storedMatch.phase !== "block" || storedMatch.result) {
|
|
return;
|
|
}
|
|
|
|
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
|
|
? `${formatBlockHeading(storedMatch, 1)} demarre.`
|
|
: `${formatBlockHeading(storedMatch, storedMatch.blockNumber)} relance.`,
|
|
);
|
|
}
|
|
|
|
function pauseBlock(storedMatch) {
|
|
if (!storedMatch || !storedMatch.running) {
|
|
return;
|
|
}
|
|
|
|
storedMatch.running = false;
|
|
storedMatch.lastTickAt = null;
|
|
logEvent(storedMatch, `${formatBlockHeading(storedMatch, storedMatch.blockNumber)} passe en pause.`);
|
|
}
|
|
|
|
function requestBlockClosure(storedMatch, reason) {
|
|
if (!storedMatch || storedMatch.phase !== "block") {
|
|
return;
|
|
}
|
|
|
|
storedMatch.running = false;
|
|
storedMatch.lastTickAt = null;
|
|
storedMatch.awaitingBlockClosure = false;
|
|
storedMatch.closureReason = "";
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`);
|
|
openCubePhase(storedMatch, reason);
|
|
}
|
|
|
|
function openCubePhase(storedMatch, reason = "") {
|
|
if (!storedMatch) {
|
|
return;
|
|
}
|
|
|
|
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.playerState = {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
};
|
|
storedMatch.cube.round = 1;
|
|
logEvent(
|
|
storedMatch,
|
|
`${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`,
|
|
);
|
|
}
|
|
|
|
function startCubeTimer(storedMatch, color) {
|
|
if (!storedMatch || storedMatch.phase !== "cube" || storedMatch.result) {
|
|
return;
|
|
}
|
|
|
|
if (!storedMatch.cube.number) {
|
|
storedMatch.cube.number = pickCubeNumber();
|
|
}
|
|
|
|
if (storedMatch.cube.times[color] !== null || storedMatch.cube.playerState[color].running) {
|
|
return;
|
|
}
|
|
|
|
const playerState = storedMatch.cube.playerState[color];
|
|
playerState.running = true;
|
|
playerState.startedAt = Date.now();
|
|
storedMatch.cube.running = true;
|
|
logEvent(
|
|
storedMatch,
|
|
`${playerName(storedMatch, color)} demarre son chrono cube sur le cube n${storedMatch.cube.number}.`,
|
|
);
|
|
}
|
|
|
|
function captureCubeTime(storedMatch, color) {
|
|
if (
|
|
!storedMatch ||
|
|
storedMatch.phase !== "cube" ||
|
|
!storedMatch.cube.playerState[color].running ||
|
|
storedMatch.cube.times[color] !== null
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const playerState = storedMatch.cube.playerState[color];
|
|
const elapsedMs = playerState.elapsedMs + (Date.now() - playerState.startedAt);
|
|
storedMatch.cube.times[color] = elapsedMs;
|
|
playerState.elapsedMs = elapsedMs;
|
|
playerState.startedAt = null;
|
|
playerState.running = false;
|
|
storedMatch.cube.elapsedMs = Math.max(
|
|
getCubePlayerElapsed(storedMatch, "white"),
|
|
getCubePlayerElapsed(storedMatch, "black"),
|
|
);
|
|
storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch);
|
|
logEvent(storedMatch, `${playerName(storedMatch, color)} arrete le cube en ${formatStopwatch(elapsedMs)}.`);
|
|
|
|
if (
|
|
storedMatch.cube.times.white !== null &&
|
|
storedMatch.cube.times.black !== null
|
|
) {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
function applyCubeOutcome(storedMatch) {
|
|
if (!storedMatch || storedMatch.phase !== "cube") {
|
|
return;
|
|
}
|
|
|
|
const white = storedMatch.cube.times.white;
|
|
const black = storedMatch.cube.times.black;
|
|
|
|
if (white === null || black === null) {
|
|
return;
|
|
}
|
|
|
|
if (storedMatch.config.mode === "twice") {
|
|
const winner = white < black ? "white" : "black";
|
|
prepareNextTwiceBlock(storedMatch, winner);
|
|
return;
|
|
}
|
|
|
|
applyTimeAdjustments(storedMatch, white, black);
|
|
prepareNextTimeBlock(storedMatch);
|
|
}
|
|
|
|
function prepareNextTwiceBlock(storedMatch, winner) {
|
|
const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null;
|
|
logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira la partie suivante.`);
|
|
|
|
storedMatch.blockNumber += 1;
|
|
storedMatch.phase = "block";
|
|
storedMatch.running = false;
|
|
storedMatch.lastTickAt = null;
|
|
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
storedMatch.moves = { white: 0, black: 0 };
|
|
storedMatch.currentTurn = winner;
|
|
storedMatch.doubleCoup = {
|
|
eligible: hadDouble,
|
|
step: hadDouble ? 1 : 0,
|
|
starter: winner,
|
|
};
|
|
storedMatch.cube.running = false;
|
|
storedMatch.cube.startedAt = null;
|
|
storedMatch.cube.elapsedMs = 0;
|
|
storedMatch.cube.times = { white: null, black: null };
|
|
storedMatch.cube.playerState = {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
};
|
|
storedMatch.cube.number = null;
|
|
|
|
if (hadDouble) {
|
|
logEvent(storedMatch, `Double coup disponible pour ${playerName(storedMatch, winner)}.`);
|
|
} else {
|
|
logEvent(storedMatch, "Aucun double coup n'est accorde sur ce depart.");
|
|
}
|
|
}
|
|
|
|
function prepareNextTimeBlock(storedMatch) {
|
|
storedMatch.blockNumber += 1;
|
|
storedMatch.phase = "block";
|
|
storedMatch.running = false;
|
|
storedMatch.lastTickAt = null;
|
|
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
storedMatch.moves = { white: 0, black: 0 };
|
|
storedMatch.doubleCoup = {
|
|
eligible: false,
|
|
step: 0,
|
|
starter: storedMatch.currentTurn,
|
|
};
|
|
storedMatch.cube.running = false;
|
|
storedMatch.cube.startedAt = null;
|
|
storedMatch.cube.elapsedMs = 0;
|
|
storedMatch.cube.times = { white: null, black: null };
|
|
storedMatch.cube.playerState = {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
};
|
|
storedMatch.cube.number = null;
|
|
logEvent(
|
|
storedMatch,
|
|
`${formatBlockHeading(storedMatch, storedMatch.blockNumber)} pret. Le trait est conserve : ${playerName(
|
|
storedMatch,
|
|
storedMatch.currentTurn,
|
|
)} reprend.`,
|
|
);
|
|
}
|
|
|
|
function applyTimeAdjustments(storedMatch, whiteTime, blackTime) {
|
|
const preview = getTimeAdjustmentPreview(storedMatch, whiteTime, blackTime);
|
|
if (!preview) {
|
|
return;
|
|
}
|
|
|
|
storedMatch.clocks.white = preview.whiteAfter;
|
|
storedMatch.clocks.black = preview.blackAfter;
|
|
|
|
if (preview.blockType === "minus") {
|
|
logEvent(
|
|
storedMatch,
|
|
`Bloc - : ${formatStopwatch(preview.cappedWhite)} retire au chrono Blanc, ${formatStopwatch(
|
|
preview.cappedBlack,
|
|
)} retire au chrono Noir.`,
|
|
);
|
|
} else {
|
|
logEvent(
|
|
storedMatch,
|
|
`Bloc + : ${formatStopwatch(preview.cappedBlack)} ajoute au chrono Blanc, ${formatStopwatch(
|
|
preview.cappedWhite,
|
|
)} ajoute au chrono Noir.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function replayCubePhase(storedMatch) {
|
|
if (!storedMatch || storedMatch.phase !== "cube") {
|
|
return;
|
|
}
|
|
|
|
storedMatch.cube.running = false;
|
|
storedMatch.cube.startedAt = null;
|
|
storedMatch.cube.elapsedMs = 0;
|
|
storedMatch.cube.times = { white: null, black: null };
|
|
storedMatch.cube.playerState = {
|
|
white: createCubePlayerState(),
|
|
black: createCubePlayerState(),
|
|
};
|
|
storedMatch.cube.round += 1;
|
|
logEvent(storedMatch, `Phase cube relancee (tentative ${storedMatch.cube.round}).`);
|
|
}
|
|
|
|
function registerCountedMove(storedMatch, { source }) {
|
|
if (!storedMatch || storedMatch.phase !== "block") {
|
|
return;
|
|
}
|
|
|
|
const actor = storedMatch.currentTurn;
|
|
if (storedMatch.moves[actor] >= storedMatch.quota) {
|
|
logEvent(
|
|
storedMatch,
|
|
`${playerName(storedMatch, actor)} a deja atteint son quota. Utiliser le mode hors quota si necessaire.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
storedMatch.moves[actor] += 1;
|
|
storedMatch.lastMover = actor;
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
|
|
if (source === "double") {
|
|
storedMatch.doubleCoup.step = 0;
|
|
}
|
|
|
|
logEvent(
|
|
storedMatch,
|
|
`${playerName(storedMatch, actor)} valide son coup (${storedMatch.moves[actor]} / ${storedMatch.quota}).`,
|
|
);
|
|
|
|
storedMatch.currentTurn = opponentOf(actor);
|
|
verifyQuotaCompletion(storedMatch);
|
|
}
|
|
|
|
function registerFreeDoubleMove(storedMatch) {
|
|
if (!storedMatch || storedMatch.doubleCoup.step !== 1) {
|
|
return;
|
|
}
|
|
|
|
const actor = storedMatch.currentTurn;
|
|
storedMatch.lastMover = actor;
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
storedMatch.doubleCoup.step = 2;
|
|
logEvent(
|
|
storedMatch,
|
|
`Premier coup gratuit du double coup joue par ${playerName(storedMatch, actor)}.`,
|
|
);
|
|
}
|
|
|
|
function registerMoveTimeout(storedMatch, automatic) {
|
|
if (!storedMatch || storedMatch.phase !== "block" || !usesMoveLimit(storedMatch)) {
|
|
return;
|
|
}
|
|
|
|
const actor = storedMatch.currentTurn;
|
|
|
|
if (storedMatch.doubleCoup.step === 1) {
|
|
storedMatch.doubleCoup.step = 0;
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
logEvent(
|
|
storedMatch,
|
|
`Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (storedMatch.moves[actor] < storedMatch.quota) {
|
|
storedMatch.moves[actor] += 1;
|
|
}
|
|
|
|
if (storedMatch.doubleCoup.step === 2) {
|
|
storedMatch.doubleCoup.step = 0;
|
|
}
|
|
|
|
storedMatch.currentTurn = opponentOf(actor);
|
|
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
|
|
logEvent(
|
|
storedMatch,
|
|
`${automatic ? "Temps par coup ecoule." : `Depassement manuel du temps par coup ${formatClock(getMoveLimitMs(storedMatch))}.`} ${playerName(
|
|
storedMatch,
|
|
actor,
|
|
)} perd son coup, qui reste compte dans le quota.`,
|
|
);
|
|
verifyQuotaCompletion(storedMatch);
|
|
}
|
|
|
|
function verifyQuotaCompletion(storedMatch) {
|
|
if (
|
|
storedMatch.moves.white >= storedMatch.quota &&
|
|
storedMatch.moves.black >= storedMatch.quota
|
|
) {
|
|
requestBlockClosure(storedMatch, "Les deux joueurs ont atteint leur quota de coups.");
|
|
}
|
|
}
|
|
|
|
function setResult(storedMatch, winner) {
|
|
if (!storedMatch || storedMatch.result) {
|
|
return;
|
|
}
|
|
|
|
storedMatch.running = false;
|
|
storedMatch.lastTickAt = null;
|
|
storedMatch.result = winner;
|
|
logEvent(storedMatch, `${resultText(storedMatch)}.`);
|
|
}
|
|
|
|
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 la prochaine partie";
|
|
}
|
|
|
|
function renderLastCube(storedMatch, color) {
|
|
const last = storedMatch.cube.history.at(-1);
|
|
if (!last) {
|
|
return "--";
|
|
}
|
|
|
|
return formatStopwatch(last[color]);
|
|
}
|
|
|
|
function renderCubeElapsed(storedMatch) {
|
|
if (storedMatch.phase !== "cube") {
|
|
return "00:00.0";
|
|
}
|
|
|
|
return formatStopwatch(
|
|
Math.max(getCubePlayerElapsed(storedMatch, "white"), getCubePlayerElapsed(storedMatch, "black")),
|
|
);
|
|
}
|
|
|
|
function renderCubeMeta(storedMatch, color) {
|
|
const time = storedMatch.cube.times[color];
|
|
if (time === null) {
|
|
return "";
|
|
}
|
|
|
|
if (storedMatch.config.mode !== "time") {
|
|
return "temps capture";
|
|
}
|
|
|
|
if (
|
|
storedMatch.cube.times.white !== null &&
|
|
storedMatch.cube.times.black !== null
|
|
) {
|
|
const preview = getTimeAdjustmentPreview(
|
|
storedMatch,
|
|
storedMatch.cube.times.white,
|
|
storedMatch.cube.times.black,
|
|
);
|
|
if (!preview) {
|
|
return "";
|
|
}
|
|
|
|
const delta = color === "white" ? preview.whiteDelta : preview.blackDelta;
|
|
const cappedTime = color === "white" ? preview.cappedWhite : preview.cappedBlack;
|
|
const wasCapped = time > cappedTime;
|
|
return wasCapped
|
|
? `Impact chrono ${formatSignedStopwatch(delta)} (cap ${formatStopwatch(cappedTime)})`
|
|
: `Impact chrono ${formatSignedStopwatch(delta)}`;
|
|
}
|
|
|
|
if (time <= CUBE_TIME_CAP_MS) {
|
|
return "impact chrono en attente";
|
|
}
|
|
|
|
return `impact en attente, cap ${formatStopwatch(CUBE_TIME_CAP_MS)}`;
|
|
}
|
|
|
|
function resultText(storedMatch) {
|
|
if (storedMatch.result === "white") {
|
|
return `Victoire de ${playerName(storedMatch, "white")}`;
|
|
}
|
|
|
|
if (storedMatch.result === "black") {
|
|
return `Victoire de ${playerName(storedMatch, "black")}`;
|
|
}
|
|
|
|
return "Partie arretee";
|
|
}
|
|
|
|
function routeForMatch(storedMatch) {
|
|
if (!storedMatch) {
|
|
return SETUP_PAGE;
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
} catch {
|
|
// Ignore storage errors and still clear the window-level fallback.
|
|
}
|
|
window.name = "";
|
|
dirty = false;
|
|
return;
|
|
}
|
|
|
|
const serialized = JSON.stringify(match);
|
|
window.name = `${WINDOW_NAME_KEY}${serialized}`;
|
|
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, serialized);
|
|
} catch {
|
|
// Keep the window.name fallback so cross-page navigation still works.
|
|
}
|
|
|
|
dirty = false;
|
|
}
|
|
|
|
function flushState() {
|
|
if (match) {
|
|
syncRunningState(match);
|
|
}
|
|
|
|
if (dirty) {
|
|
persistMatch();
|
|
}
|
|
}
|
|
|
|
function clearMatch() {
|
|
match = null;
|
|
persistMatch();
|
|
}
|
|
|
|
async function forceRefreshToLatest(path = SETUP_PAGE) {
|
|
const refreshToken = `${Date.now()}`;
|
|
|
|
try {
|
|
window.sessionStorage.setItem(ASSET_TOKEN_STORAGE_KEY, refreshToken);
|
|
} catch {
|
|
// Ignore storage failures in restricted browsers.
|
|
}
|
|
|
|
if ("caches" in window) {
|
|
try {
|
|
const cacheKeys = await window.caches.keys();
|
|
await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey)));
|
|
} catch {
|
|
// Ignore cache API failures when unavailable.
|
|
}
|
|
}
|
|
|
|
if ("serviceWorker" in navigator) {
|
|
try {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(registrations.map((registration) => registration.update().catch(() => undefined)));
|
|
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => undefined)));
|
|
} catch {
|
|
// Ignore service worker failures when none are registered.
|
|
}
|
|
}
|
|
|
|
const targetUrl = new URL(path, window.location.href);
|
|
targetUrl.searchParams.set("refresh", refreshToken);
|
|
window.location.replace(targetUrl.toString());
|
|
}
|
|
|
|
function toggleModal(element, open) {
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
element.classList.toggle("hidden", !open);
|
|
element.setAttribute("aria-hidden", String(!open));
|
|
}
|
|
|
|
function loadDemo(form, onRender) {
|
|
setCheckboxValue(form, "competitionMode", true);
|
|
setInputValue(form, "matchLabel", "Demo officielle ChessCubing");
|
|
setRadioValue(form, "mode", "twice");
|
|
setRadioValue(form, "preset", "freeze");
|
|
setInputValue(form, "blockSeconds", "180");
|
|
setInputValue(form, "moveSeconds", "20");
|
|
setInputValue(form, "timeInitialMinutes", "10");
|
|
setInputValue(form, "whiteName", "Nora");
|
|
setInputValue(form, "blackName", "Leo");
|
|
setInputValue(form, "arbiterName", "Arbitre demo");
|
|
setInputValue(form, "eventName", "Session telephone");
|
|
setInputValue(form, "notes", "8 cubes verifies, variante prete, tirage au sort effectue.");
|
|
onRender();
|
|
}
|
|
|
|
function setInputValue(form, name, value) {
|
|
const input = form.querySelector(`[name="${name}"]`);
|
|
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
|
|
input.value = value;
|
|
}
|
|
}
|
|
|
|
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 setCheckboxValue(form, name, checked) {
|
|
const input = form.querySelector(`input[name="${name}"]`);
|
|
if (input instanceof HTMLInputElement) {
|
|
input.checked = checked;
|
|
}
|
|
}
|
|
|
|
function isCheckboxChecked(form, name) {
|
|
const input = form.querySelector(`input[name="${name}"]`);
|
|
return input instanceof HTMLInputElement ? input.checked : false;
|
|
}
|
|
|
|
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 getMinuteInputMs(form, name, fallbackMs) {
|
|
const input = form.querySelector(`[name="${name}"]`);
|
|
const minutes = Number.parseInt(String(input?.value || ""), 10);
|
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
return fallbackMs;
|
|
}
|
|
|
|
return minutes * 60000;
|
|
}
|
|
|
|
function isTimeMode(matchOrConfig) {
|
|
const mode =
|
|
typeof matchOrConfig === "string"
|
|
? matchOrConfig
|
|
: matchOrConfig?.config?.mode ?? matchOrConfig?.mode;
|
|
return mode === "time";
|
|
}
|
|
|
|
function usesMoveLimit(matchOrConfig) {
|
|
return !isTimeMode(matchOrConfig);
|
|
}
|
|
|
|
function getTimeInitialMs(matchOrConfig) {
|
|
return normalizeDurationMs(
|
|
matchOrConfig?.config?.timeInitialMs ?? matchOrConfig?.timeInitialMs,
|
|
TIME_MODE_INITIAL_CLOCK_MS,
|
|
);
|
|
}
|
|
|
|
function getBlockLabel(matchOrConfig) {
|
|
return isTimeMode(matchOrConfig) ? "Block" : "Partie";
|
|
}
|
|
|
|
function getBlockPhrase(matchOrConfig) {
|
|
return isTimeMode(matchOrConfig) ? "Le Block" : "La partie";
|
|
}
|
|
|
|
function getBlockGenitivePhrase(matchOrConfig) {
|
|
return isTimeMode(matchOrConfig) ? "du Block" : "de la partie";
|
|
}
|
|
|
|
function formatBlockHeading(matchOrConfig, blockNumber) {
|
|
return `${getBlockLabel(matchOrConfig)} ${blockNumber}`;
|
|
}
|
|
|
|
function playerName(storedMatch, color) {
|
|
return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName;
|
|
}
|
|
|
|
function opponentOf(color) {
|
|
return color === "white" ? "black" : "white";
|
|
}
|
|
|
|
function getTimeBlockType(blockNumber) {
|
|
return blockNumber % 2 === 1 ? "minus" : "plus";
|
|
}
|
|
|
|
function pickCubeNumber() {
|
|
return Math.floor(Math.random() * 4) + 1;
|
|
}
|
|
|
|
function logEvent(storedMatch, message) {
|
|
storedMatch.history.push({
|
|
message,
|
|
time: new Date().toLocaleTimeString("fr-FR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}),
|
|
});
|
|
}
|
|
|
|
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 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 formatSignedStopwatch(ms) {
|
|
const sign = ms < 0 ? "-" : "+";
|
|
return `${sign}${formatStopwatch(ms)}`;
|
|
}
|
|
|
|
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) {
|
|
return String(value || "")
|
|
.replace(/[<>]/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function readWindowNameState() {
|
|
try {
|
|
if (!window.name || !window.name.startsWith(WINDOW_NAME_KEY)) {
|
|
return null;
|
|
}
|
|
|
|
const raw = window.name.slice(WINDOW_NAME_KEY.length);
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && isSupportedSchemaVersion(parsed.schemaVersion) ? parsed : null;
|
|
} catch {
|
|
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 getTimeAdjustmentPreview(storedMatch, whiteTime, blackTime) {
|
|
if (!storedMatch?.clocks) {
|
|
return null;
|
|
}
|
|
|
|
const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS);
|
|
const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS);
|
|
const blockType = getTimeBlockType(storedMatch.blockNumber);
|
|
const whiteDelta = blockType === "minus" ? -cappedWhite : cappedBlack;
|
|
const blackDelta = blockType === "minus" ? -cappedBlack : cappedWhite;
|
|
|
|
return {
|
|
blockType,
|
|
winner: whiteTime < blackTime ? "white" : blackTime < whiteTime ? "black" : null,
|
|
cappedWhite,
|
|
cappedBlack,
|
|
whiteDelta,
|
|
blackDelta,
|
|
whiteAfter: storedMatch.clocks.white + whiteDelta,
|
|
blackAfter: storedMatch.clocks.black + blackDelta,
|
|
};
|
|
}
|
|
|
|
function createCubePlayerState() {
|
|
return {
|
|
running: false,
|
|
startedAt: null,
|
|
elapsedMs: 0,
|
|
};
|
|
}
|
|
|
|
function createCubeHoldIntent() {
|
|
return {
|
|
armed: false,
|
|
ready: false,
|
|
startedAt: 0,
|
|
pointerId: null,
|
|
timeoutId: null,
|
|
};
|
|
}
|
|
|
|
function normalizeCubePlayerState(playerState) {
|
|
return {
|
|
running: Boolean(playerState?.running),
|
|
startedAt: typeof playerState?.startedAt === "number" ? playerState.startedAt : null,
|
|
elapsedMs: typeof playerState?.elapsedMs === "number" ? playerState.elapsedMs : 0,
|
|
};
|
|
}
|
|
|
|
function isAnyCubeTimerRunning(storedMatch) {
|
|
return storedMatch.cube.playerState.white.running || storedMatch.cube.playerState.black.running;
|
|
}
|
|
|
|
function getCubePlayerElapsed(storedMatch, color) {
|
|
const playerState = storedMatch.cube.playerState[color];
|
|
|
|
if (storedMatch.cube.times[color] !== null) {
|
|
return storedMatch.cube.times[color];
|
|
}
|
|
|
|
if (playerState.running && playerState.startedAt) {
|
|
return playerState.elapsedMs + (Date.now() - playerState.startedAt);
|
|
}
|
|
|
|
return playerState.elapsedMs;
|
|
}
|
|
|
|
function formatCubePlayerTime(storedMatch, color) {
|
|
const elapsed = getCubePlayerElapsed(storedMatch, color);
|
|
const playerState = storedMatch.cube.playerState[color];
|
|
|
|
if (playerState.running) {
|
|
return formatStopwatch(elapsed);
|
|
}
|
|
|
|
if (elapsed <= 0 && storedMatch.cube.times[color] === null) {
|
|
return "--";
|
|
}
|
|
|
|
return formatStopwatch(elapsed);
|
|
}
|