diff --git a/app.js b/app.js index 69e5a0e..5493621 100644 --- a/app.js +++ b/app.js @@ -7,6 +7,7 @@ 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 = 300; const PRESETS = { fast: { @@ -579,6 +580,10 @@ function initCubePage() { replayCubeButton: document.querySelector("#replayCubeButton"), resetButton: document.querySelector("#cubeResetButton"), }; + const cubeHoldState = { + white: createCubeHoldIntent(), + black: createCubeHoldIntent(), + }; const openModal = () => toggleModal(refs.helpModal, true); const closeModal = () => toggleModal(refs.helpModal, false); @@ -592,17 +597,8 @@ function initCubePage() { } }); - refs.whiteButton?.addEventListener("click", () => { - handleCubeTap("white"); - dirty = true; - render(); - }); - - refs.blackButton?.addEventListener("click", () => { - handleCubeTap("black"); - dirty = true; - render(); - }); + bindCubeButton("white", refs.whiteButton); + bindCubeButton("black", refs.blackButton); refs.primaryButton?.addEventListener("click", () => { if (match.result) { @@ -641,7 +637,46 @@ function initCubePage() { replaceTo(SETUP_PAGE); }); - function handleCubeTap(color) { + 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; } @@ -650,6 +685,79 @@ function initCubePage() { return; } + if (match.cube.playerState[color].running) { + return; + } + + const holdIntent = cubeHoldState[color]; + clearCubeHoldTimeout(holdIntent); + holdIntent.armed = true; + holdIntent.ready = false; + 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(); + } + + 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; @@ -658,6 +766,21 @@ function initCubePage() { startCubeTimer(match, color); } + function cancelCubeHold(color) { + const holdIntent = cubeHoldState[color]; + clearCubeHoldTimeout(holdIntent); + holdIntent.armed = false; + holdIntent.ready = false; + holdIntent.pointerId = 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; @@ -666,11 +789,16 @@ function initCubePage() { 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; name.textContent = playerName(match, color); result.textContent = formatCubePlayerTime(match, color); cap.textContent = renderCubeCap(match, time); + button.classList.toggle("cube-hold-arming", holdArmed && !holdReady); + button.classList.toggle("cube-hold-ready", holdReady); if (match.result) { button.textContent = resultText(match); @@ -700,9 +828,23 @@ function initCubePage() { return; } - button.textContent = "Demarrer mon chrono"; + 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..."; + button.disabled = false; + hint.textContent = "Gardez le doigt pose un court instant pour armer le depart."; + return; + } + + button.textContent = "Maintenir pour demarrer"; button.disabled = false; - hint.textContent = "Chaque joueur lance son propre chrono quand il commence vraiment."; + hint.textContent = "Maintenez la grande zone, puis relachez pour lancer votre chrono."; } function render() { @@ -745,7 +887,7 @@ function initCubePage() { refs.centerValue.textContent = "Chronos lances"; refs.spineLabel.textContent = "Arrets"; refs.spineHeadline.textContent = "Chaque joueur se chronometre"; - refs.spineText.textContent = "Chaque joueur demarre quand il veut, puis retape sa zone une fois le cube termine."; + 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) { @@ -753,7 +895,7 @@ function initCubePage() { 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 demarrer puis arreter son propre chrono quand il le souhaite."; + 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 { @@ -761,7 +903,7 @@ function initCubePage() { 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 tape sur sa grande zone pour demarrer son propre 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; } @@ -1641,6 +1783,15 @@ function createCubePlayerState() { }; } +function createCubeHoldIntent() { + return { + armed: false, + ready: false, + pointerId: null, + timeoutId: null, + }; +} + function normalizeCubePlayerState(playerState) { return { running: Boolean(playerState?.running), diff --git a/styles.css b/styles.css index 9f5c2ee..738be76 100644 --- a/styles.css +++ b/styles.css @@ -710,6 +710,26 @@ textarea:focus { text-shadow: 0 0 18px rgba(255, 255, 255, 0.05); } +body[data-page="cube"] .zone-button { + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +body[data-page="cube"] .zone-button.cube-hold-arming { + transform: none; + filter: brightness(0.98); + box-shadow: inset 0 0 0 1px currentColor; +} + +body[data-page="cube"] .zone-button.cube-hold-ready { + transform: none; + filter: brightness(1.08); + box-shadow: + inset 0 0 0 2px currentColor, + 0 0 24px rgba(255, 255, 255, 0.08); +} + .zone-button:hover { transform: translateY(-2px); filter: brightness(1.04);