Ajoute un avertissement sonore pour la phase cube

This commit is contained in:
2026-04-13 20:39:02 +02:00
parent 851e2a40ba
commit 307085711b

150
app.js
View File

@@ -9,6 +9,7 @@ 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;
const CUBE_START_HOLD_MS = 2000; const CUBE_START_HOLD_MS = 2000;
const CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS = 700;
const PRESETS = { const PRESETS = {
fast: { fast: {
@@ -56,6 +57,8 @@ window.requestAnimationFrame(() => {
let match = readStoredMatch(); let match = readStoredMatch();
let dirty = false; let dirty = false;
let audioContext = null;
let cubePhaseAlertRetryCleanup = null;
if (match) { if (match) {
if (normalizeRecoveredMatch(match)) { if (normalizeRecoveredMatch(match)) {
@@ -67,6 +70,8 @@ if (match) {
} }
window.addEventListener("beforeunload", flushState); window.addEventListener("beforeunload", flushState);
document.addEventListener("pointerdown", tryUnlockAudioContext, { passive: true });
document.addEventListener("keydown", tryUnlockAudioContext);
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.hidden) { if (document.hidden) {
if (match) { if (match) {
@@ -110,6 +115,120 @@ function syncViewportHeight() {
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`); document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
} }
function getAudioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
if (!audioContext) {
try {
audioContext = new AudioContextClass();
} catch {
return null;
}
}
return audioContext;
}
function tryUnlockAudioContext() {
const context = getAudioContext();
if (!context || context.state === "running") {
return;
}
context.resume().catch(() => undefined);
}
function playCubePhaseAlert() {
const context = getAudioContext();
if (!context || context.state !== "running") {
return false;
}
const pattern = [
{ frequency: 740, offset: 0, duration: 0.12, gain: 0.035 },
{ frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 },
{ frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 },
];
const startAt = context.currentTime + 0.02;
pattern.forEach(({ frequency, offset, duration, gain }) => {
const oscillator = context.createOscillator();
const envelope = context.createGain();
const toneStartAt = startAt + offset;
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(frequency, toneStartAt);
envelope.gain.setValueAtTime(0.0001, toneStartAt);
envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02);
envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration);
oscillator.connect(envelope);
envelope.connect(context.destination);
oscillator.start(toneStartAt);
oscillator.stop(toneStartAt + duration + 0.03);
});
return true;
}
function clearCubePhaseAlertRetry() {
if (typeof cubePhaseAlertRetryCleanup === "function") {
cubePhaseAlertRetryCleanup();
cubePhaseAlertRetryCleanup = null;
}
}
function scheduleCubePhaseAlertRetry(storedMatch) {
if (!storedMatch?.cube?.phaseAlertPending || cubePhaseAlertRetryCleanup) {
return;
}
const tryPlayOnGesture = () => {
tryUnlockAudioContext();
if (!playCubePhaseAlert()) {
return;
}
storedMatch.cube.phaseAlertPending = false;
dirty = true;
persistMatch();
clearCubePhaseAlertRetry();
};
const events = ["pointerdown", "keydown"];
cubePhaseAlertRetryCleanup = () => {
events.forEach((eventName) => {
document.removeEventListener(eventName, tryPlayOnGesture);
});
};
events.forEach((eventName) => {
document.addEventListener(eventName, tryPlayOnGesture, { passive: eventName === "pointerdown" });
});
}
function maybePlayPendingCubePhaseAlert(storedMatch) {
if (!storedMatch?.cube?.phaseAlertPending) {
clearCubePhaseAlertRetry();
return false;
}
tryUnlockAudioContext();
if (!playCubePhaseAlert()) {
scheduleCubePhaseAlertRetry(storedMatch);
return false;
}
storedMatch.cube.phaseAlertPending = false;
dirty = true;
persistMatch();
clearCubePhaseAlertRetry();
return true;
}
function initSetupPage() { function initSetupPage() {
const form = document.querySelector("#setupForm"); const form = document.querySelector("#setupForm");
const summary = document.querySelector("#setupSummary"); const summary = document.querySelector("#setupSummary");
@@ -325,7 +444,25 @@ function initChronoPage() {
return; return;
} }
let cubeNavigationTimeoutId = null;
const goToCubePage = () => { const goToCubePage = () => {
if (cubeNavigationTimeoutId !== null) {
return;
}
if (match?.cube?.phaseAlertPending) {
tryUnlockAudioContext();
if (playCubePhaseAlert()) {
match.cube.phaseAlertPending = false;
dirty = true;
persistMatch();
cubeNavigationTimeoutId = window.setTimeout(() => {
replaceTo("cube.html");
}, CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS);
return;
}
}
dirty = true; dirty = true;
persistMatch(); persistMatch();
replaceTo("cube.html"); replaceTo("cube.html");
@@ -681,6 +818,8 @@ function initCubePage() {
return; return;
} }
maybePlayPendingCubePhaseAlert(match);
const refs = { const refs = {
title: document.querySelector("#cubeTitle"), title: document.querySelector("#cubeTitle"),
subtitle: document.querySelector("#cubeSubtitle"), subtitle: document.querySelector("#cubeSubtitle"),
@@ -790,6 +929,7 @@ function initCubePage() {
resultModalKey = null; resultModalKey = null;
replayCubePhase(match); replayCubePhase(match);
dirty = true; dirty = true;
maybePlayPendingCubePhaseAlert(match);
render(); render();
return; return;
} }
@@ -806,6 +946,7 @@ function initCubePage() {
resultModalKey = null; resultModalKey = null;
replayCubePhase(match); replayCubePhase(match);
dirty = true; dirty = true;
maybePlayPendingCubePhaseAlert(match);
render(); render();
}); });
@@ -1303,6 +1444,7 @@ function createMatch(config) {
running: false, running: false,
startedAt: null, startedAt: null,
elapsedMs: 0, elapsedMs: 0,
phaseAlertPending: false,
times: { times: {
white: null, white: null,
black: null, black: null,
@@ -1405,6 +1547,7 @@ function normalizeRecoveredMatch(storedMatch) {
running: false, running: false,
startedAt: null, startedAt: null,
elapsedMs: 0, elapsedMs: 0,
phaseAlertPending: false,
times: { white: null, black: null }, times: { white: null, black: null },
playerState: { playerState: {
white: createCubePlayerState(), white: createCubePlayerState(),
@@ -1424,6 +1567,11 @@ function normalizeRecoveredMatch(storedMatch) {
changed = true; changed = true;
} }
if (typeof storedMatch.cube.phaseAlertPending !== "boolean") {
storedMatch.cube.phaseAlertPending = false;
changed = true;
}
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white); storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black); storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black);
storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch); storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch);
@@ -1537,6 +1685,7 @@ function openCubePhase(storedMatch, reason = "") {
storedMatch.cube.running = false; storedMatch.cube.running = false;
storedMatch.cube.startedAt = null; storedMatch.cube.startedAt = null;
storedMatch.cube.elapsedMs = 0; storedMatch.cube.elapsedMs = 0;
storedMatch.cube.phaseAlertPending = true;
storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.times = { white: null, black: null };
storedMatch.cube.playerState = { storedMatch.cube.playerState = {
white: createCubePlayerState(), white: createCubePlayerState(),
@@ -1730,6 +1879,7 @@ function replayCubePhase(storedMatch) {
storedMatch.cube.running = false; storedMatch.cube.running = false;
storedMatch.cube.startedAt = null; storedMatch.cube.startedAt = null;
storedMatch.cube.elapsedMs = 0; storedMatch.cube.elapsedMs = 0;
storedMatch.cube.phaseAlertPending = true;
storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.times = { white: null, black: null };
storedMatch.cube.playerState = { storedMatch.cube.playerState = {
white: createCubePlayerState(), white: createCubePlayerState(),