This commit is contained in:
2026-10-01 00:58:00 +02:00
parent 61d86cf727
commit 3a88bb6ad3
14 changed files with 1076 additions and 43 deletions
+273 -36
View File
@@ -26,9 +26,14 @@
const SETTINGS_KEY = 'f0ck_sqc_settings';
const CUSTOM_SOUND_KEY = 'f0ck_sqc_custom_sound';
const CUSTOM_SOUND_MAX = 700 * 1024; // bytes of the uploaded file
const NOTE_IMAGE_KEY = 'f0ck_sqc_note_image'; // note image / GIF (tuner: note shape "Image"), data URL
const NOTE_IMAGE_MAX = 2 * 1024 * 1024;
const DEFAULT_HIT_SOUND = '/s/sfx/hitsound.mp3';
const LAST_SETTING_KEY = 'f0ck_sqc_last_setting'; // tuner row changed last (the pause menu's tuner jumps there)
let noteImg = null, noteImgFor = null; // loaded note image (see loadNoteImage)
const SQC_DEFAULTS = {
hitSound: 'tick', volume: 0.35, missSound: 1, countdownTicks: 1,
approachMs: 900, noteSize: 70, noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0,
hitSound: 'default', volume: 0.35, missSound: 1, countdownTicks: 1,
approachMs: 900, noteSize: 70, noteShape: 'square', noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0,
dimColor: '#000000', dimAudio: 0.35, dimVideo: 0.15,
autoPreset: 'balanced', autoDifficulty: 'normal',
bpmMode: 'auto', bpm: 120, beatOffsetMs: 0, beatsPerBar: 4,
@@ -40,7 +45,12 @@
autoJump: 1, autoSliderChance: 0.45, autoSliderGapMs: 750,
};
const O = Object.assign({}, SQC_DEFAULTS);
try { Object.assign(O, JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')); } catch (_) {}
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
// settings saved before the hitsound.mp3 default: the old default 'tick' becomes the new default
if (stored && !stored.v && stored.hitSound === 'tick') stored.hitSound = 'default';
Object.assign(O, stored, { v: 2 });
} catch (_) {}
const saveSettings = () => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(O)); } catch (_) {} };
// Values derived from the settings (updated by applySettings)
@@ -49,6 +59,7 @@
APPROACH_MS = Math.max(300, +O.approachMs || 900); // how early a note appears
NOTE = Math.max(30, +O.noteSize || 70); // note size, px
LEAD_IN_MS = Math.max(0, +O.leadInMs || 0); // countdown before the song starts
if (typeof loadNoteImage === 'function') loadNoteImage();
FOLLOW = NOTE * Math.max(0.8, +O.followMul || 1.6); // half-size of the slider follow zone
};
applySettings();
@@ -80,7 +91,7 @@
s.view.ov.addEventListener('contextmenu', (e) => e.preventDefault());
s.onKey = (e) => {
if (!S) return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) (S.paused ? resume() : pause()); return; }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) (S.paused ? resume() : pause(true)); return; }
if (e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return;
e.preventDefault(); e.stopPropagation();
if (!e.repeat) down('key:' + e.code, S.mouse.x, S.mouse.y);
@@ -109,6 +120,8 @@
if (e.type === 'keydown' && (e.key === 'Enter' || e.key === 'Escape')) tg.blur();
return;
}
// Settings open: keys for the tuner's controls (sliders, selects) do their normal thing; Esc resumes
if (S.settings && tg && tg.closest && tg.closest(SIDEBAR_SEL) && e.key !== 'Escape') return;
if (e.type === 'keydown') S.onKey(e);
else if (e.type === 'keyup') { if (S.onKeyUp) S.onKeyUp(e); }
else if (!(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault(); // keypress
@@ -275,6 +288,59 @@
const l = p[p.length - 1];
return { x: l[1], y: l[2] };
};
// ── note shapes (tuner: note shape) ──
// Outline of a note of size s px centred on x, y. "image" draws the uploaded picture / GIF as the note
// body; its outlines (approach frame, ball, follow zone) are circles.
const SHAPES = [['square', 'Square'], ['circle', 'Circle'], ['diamond', 'Diamond'], ['hexagon', 'Hexagon'],
['octagon', 'Octagon'], ['triangle', 'Triangle'], ['star', 'Star'], ['image', 'Image / GIF (upload below)']];
const shapePath = (ctx, x, y, s) => {
const r = s / 2;
const poly = (k, rot, rr) => {
for (let i = 0; i < k; i++) { const a = rot + i * 2 * Math.PI / k; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
};
ctx.beginPath();
switch (O.noteShape) {
case 'circle': case 'image': ctx.arc(x, y, r, 0, 2 * Math.PI); break;
case 'diamond': poly(4, -Math.PI / 2, r * 1.2); break;
case 'hexagon': poly(6, 0, r * 1.08); break;
case 'octagon': poly(8, Math.PI / 8, r * 1.05); break;
case 'triangle': poly(3, -Math.PI / 2, r * 1.25); break;
case 'star':
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r * 0.55 : r * 1.2; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
break;
default: ctx.rect(x - r, y - r, s, s);
}
ctx.closePath();
};
const strokeShape = (ctx, x, y, s) => { shapePath(ctx, x, y, s); ctx.stroke(); };
const fillShape = (ctx, x, y, s) => { shapePath(ctx, x, y, s); ctx.fill(); };
// The uploaded note image. Kept in the page (tiny, invisible) so animated GIFs keep animating when
// drawn onto the canvas (Chromium only advances GIF frames of images that are in the document).
function loadNoteImage() {
let url = null;
if (O.noteShape === 'image') { try { url = localStorage.getItem(NOTE_IMAGE_KEY); } catch (_) {} }
if (url === noteImgFor) return;
if (noteImg) noteImg.remove();
noteImg = null; noteImgFor = url;
if (!url) return;
noteImg = new Image();
noteImg.alt = '';
noteImg.style.cssText = 'position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0.01; pointer-events: none; z-index: -1;';
noteImg.src = url;
document.body.appendChild(noteImg);
}
// A note's body: the image, or the shape filled dark with an accent outline
const drawNoteBody = (ctx, x, y, s, col, lineWidth) => {
if (O.noteShape === 'image' && noteImg && noteImg.complete && noteImg.naturalWidth) {
const k = s * 1.1 / Math.max(noteImg.naturalWidth, noteImg.naturalHeight);
const w = noteImg.naturalWidth * k, h = noteImg.naturalHeight * k;
ctx.drawImage(noteImg, x - w / 2, y - h / 2, w, h);
return;
}
ctx.fillStyle = 'rgba(0,0,0,0.55)'; fillShape(ctx, x, y, s);
ctx.strokeStyle = col; ctx.lineWidth = lineWidth || 3; strokeShape(ctx, x, y, s);
};
// Stroke a slider track (path in viewport fractions) as a wide band: accent edge, dark core
const drawTrack = (ctx, pts, col, alpha) => {
if (!pts || pts.length < 2) return;
@@ -288,7 +354,7 @@
ctx.restore();
const e = pts[pts.length - 1];
ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.strokeRect(e[1] * innerWidth - NOTE * 0.3, e[2] * innerHeight - NOTE * 0.3, NOTE * 0.6, NOTE * 0.6);
strokeShape(ctx, e[1] * innerWidth, e[2] * innerHeight, NOTE * 0.6);
ctx.restore();
};
@@ -341,6 +407,7 @@
const Sfx = (() => {
let ctx = null, noise = null;
let customBuf = null, customFor = null; // decoded upload + the data URL it came from
let defaultBuf = null, defaultLoading = false; // hitsound.mp3, the default hit sound
// Created/resumed from a click (browsers only allow audio after a user gesture)
const ensure = () => {
if (!ctx) {
@@ -382,6 +449,17 @@
case 'soft': tone(c, dest, t0, 'sine', 950 * m, 800 * m, 0.09, 0.6); return;
case 'chip': tone(c, dest, t0, 'square', 880 * m, 870 * m, 0.05, 0.25); return;
case 'wood': tone(c, dest, t0, 'triangle', 620 * m, 560 * m, 0.05, 0.7); snap(c, dest, t0, 820 * m, 6, 0.04, 0.5); return;
case 'default':
if (defaultBuf) {
const src = c.createBufferSource(); src.buffer = defaultBuf; src.playbackRate.value = m;
src.connect(dest); src.start(t0);
return;
}
loadDefault();
// not decoded (yet): the synthesized tick meanwhile
snap(c, dest, t0, 4200 * m, 1.2, 0.05, 0.9);
tone(c, dest, t0, 'triangle', 1400 * m, 700 * m, 0.08, 0.5);
return;
case 'custom':
if (customBuf) {
const src = c.createBufferSource(); src.buffer = customBuf; src.playbackRate.value = m;
@@ -399,6 +477,14 @@
if (!c) return;
tone(c, out(c, 0.9), c.currentTime, 'sine', 170, 55, 0.18, 1);
};
const loadDefault = async () => {
if (defaultBuf || defaultLoading) return;
const c = ensure();
if (!c) return;
defaultLoading = true;
try { defaultBuf = await c.decodeAudioData(await (await fetch(DEFAULT_HIT_SOUND)).arrayBuffer()); } catch (_) {}
defaultLoading = false;
};
// Decode the uploaded sound (data URL in localStorage) once an audio context exists
const loadCustom = async () => {
let url = null;
@@ -425,7 +511,7 @@
};
return {
metronome,
unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); },
unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); if (O.hitSound === 'default') loadDefault(); },
loadCustom,
play(kind) {
switch (kind) {
@@ -443,7 +529,7 @@
// ── styles (this script is only loaded when the feature is on) ─────────────
const style = el('style');
style.textContent = `
.sqc-panel { position: fixed; right: 20px; bottom: 20px; z-index: 2147482000; width: 340px; max-height: 70vh;
.sqc-panel { position: fixed; right: 20px; bottom: 20px; transition: right 600ms cubic-bezier(0.45, 0, 0.2, 1); z-index: 2147482000; width: 340px; max-height: 70vh;
overflow-y: auto; background: var(--dropdown-bg, #232323); border: 1px solid var(--accent, #9f0);
color: var(--white, #fff); font-family: var(--font, monospace); font-size: 13px; }
.sqc-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px;
@@ -464,8 +550,18 @@
.sqc-panel table { width: 100%; border-collapse: collapse; font-size: 12px; }
.sqc-panel td { padding: 3px 4px; border-bottom: 1px solid var(--nav-border-color, #333); }
.sqc-panel .sqc-grade { font-size: 44px; font-weight: bold; color: var(--accent, #9f0); line-height: 1; }
.sqc-overlay { position: fixed; inset: 0; z-index: 2147481000; cursor: default; touch-action: none; }
.sqc-overlay canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.sqc-overlay { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; z-index: 2147481000; cursor: default; touch-action: none; overflow: hidden; }
.sqc-overlay canvas { position: absolute; left: 0; top: 0; display: block; }
.sqc-pausebtn { position: absolute; left: 8px; top: 42px; z-index: 3; width: 44px; height: 44px; padding: 0;
display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
background: rgba(0,0,0,0.55); color: var(--white, #fff); border: 1px solid var(--accent, #9f0); }
.sqc-overlay.sqc-is-paused .sqc-pausebtn { display: none; }
body.sqc-settings .global-sidebar-right, body.sqc-settings .item-sidebar-right, body.sqc-settings .index-sidebar-right { z-index: 2147481500 !important; }
.sqc-settings-back { display: none; position: fixed; left: 12px; top: 12px; z-index: 2147482100; padding: 8px 14px; cursor: pointer;
background: var(--accent, #9f0); color: var(--black, #000); border: 1px solid var(--accent, #9f0); font-family: var(--font, monospace); font-weight: bold; }
/* phones: the sidebar covers the game and its pause menu, this gets back to them */
@media (max-width: 599px), (hover: none) and (pointer: coarse) { .sqc-settings-back { display: block; } }
.sqc-last-setting { outline: 1px solid var(--accent, #9f0); outline-offset: 2px; transition: outline-color 1.5s; }
.sqc-pause { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; width: 260px;
display: flex; flex-direction: column; gap: 8px; padding: 16px; background: var(--dropdown-bg, #232323);
border: 1px solid var(--accent, #9f0); font-family: var(--font, monospace); color: var(--white, #fff); cursor: default; }
@@ -840,15 +936,17 @@
const SCHEMA = [
{ section: 'Sounds' },
{ key: 'hitSound', label: 'Hit sound', type: 'select', options: [
['tick', 'Tick'], ['clap', 'Clap'], ['drum', 'Drum'], ['soft', 'Soft'], ['chip', '8-bit'], ['wood', 'Wood'], ['custom', 'Custom sound file'], ['none', 'None'],
['default', 'Default (hitsound.mp3)'], ['tick', 'Tick'], ['clap', 'Clap'], ['drum', 'Drum'], ['soft', 'Soft'], ['chip', '8-bit'], ['wood', 'Wood'], ['custom', 'Custom sound file'], ['none', 'None'],
] },
{ key: 'customSound', label: 'Custom sound file', type: 'file', accept: 'audio/*' },
{ key: 'customSound', label: 'Custom sound file', type: 'file', accept: 'audio/*', activeWhen: ['hitSound', 'custom'] },
{ key: 'volume', label: 'Volume', type: 'range', min: 0, max: 1, step: 0.05 },
{ key: 'missSound', label: 'Combo break sound', type: 'toggle' },
{ key: 'countdownTicks', label: 'Countdown ticks', type: 'toggle' },
{ section: 'Gameplay' },
{ key: 'approachMs', label: 'Approach time (lower = faster notes)', type: 'range', min: 400, max: 1800, step: 50, unit: 'ms' },
{ key: 'noteSize', label: 'Note size', type: 'range', min: 40, max: 130, step: 2, unit: 'px' },
{ key: 'noteShape', label: 'Note shape', type: 'select', options: SHAPES },
{ key: 'noteImage', label: 'Note image / GIF', type: 'file', accept: 'image/*', activeWhen: ['noteShape', 'image'] },
{ key: 'noteNumbers', label: 'Numbers on notes', type: 'toggle' },
{ key: 'followMul', label: 'Slider follow zone', type: 'range', min: 1, max: 3, step: 0.1, unit: 'x' },
{ key: 'leadInMs', label: 'Countdown before the song', type: 'range', min: 0, max: 5000, step: 250, unit: 'ms' },
@@ -891,17 +989,45 @@
{ key: 'autoSliderGapMs', label: 'Gap needed for a slider', type: 'range', min: 200, max: 3000, step: 50, unit: 'ms', showIf: 'autoAdvanced' },
];
const customSoundName = () => { try { return localStorage.getItem(CUSTOM_SOUND_KEY + '_name') || ''; } catch (_) { return ''; } };
const noteImageName = () => { try { return localStorage.getItem(NOTE_IMAGE_KEY + '_name') || ''; } catch (_) { return ''; } };
const readDataUrl = (file) => new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); });
const settingsChanged = () => {
saveSettings();
applySettings();
window.dispatchEvent(new CustomEvent('f0ck:sqc_settings_changed', { detail: settingsApi.getState() }));
};
const rememberSetting = (key) => { try { localStorage.setItem(LAST_SETTING_KEY, key); } catch (_) {} };
const settingsApi = {
schema: SCHEMA,
defaults: Object.assign({}, SQC_DEFAULTS),
getState: () => Object.assign({}, O, { customSound: customSoundName() }),
getState: () => Object.assign({}, O, { customSound: customSoundName(), noteImage: noteImageName() }),
// File rows of the schema: the hit sound (customSound) or the note image (noteImage)
async setFile(key, file) {
rememberSetting(key);
if (key === 'customSound') return settingsApi.setCustomSound(file);
if (key !== 'noteImage') return { ok: false, msg: 'Unknown setting' };
if (!file || !/^image\//.test(file.type)) return { ok: false, msg: 'Pick an image or GIF' };
if (file.size > NOTE_IMAGE_MAX) return { ok: false, msg: 'Image too large (max 2 MB)' };
const url = await readDataUrl(file);
try {
localStorage.setItem(NOTE_IMAGE_KEY, url);
localStorage.setItem(NOTE_IMAGE_KEY + '_name', file.name);
} catch (_) { return { ok: false, msg: 'Not enough browser storage' }; }
O.noteShape = 'image';
noteImgFor = null; // reload even if the same shape was already on
settingsChanged();
return { ok: true };
},
clearFile(key) {
if (key === 'customSound') return settingsApi.clearCustomSound();
if (key !== 'noteImage') return;
try { localStorage.removeItem(NOTE_IMAGE_KEY); localStorage.removeItem(NOTE_IMAGE_KEY + '_name'); } catch (_) {}
if (O.noteShape === 'image') O.noteShape = 'square';
settingsChanged();
},
set(key, val) {
if (!(key in SQC_DEFAULTS)) return;
rememberSetting(key);
O[key] = typeof SQC_DEFAULTS[key] === 'number' ? (Number(val) || 0) : String(val);
settingsChanged();
if (key === 'hitSound' && val === 'custom') Sfx.loadCustom();
@@ -911,7 +1037,7 @@
async setCustomSound(file) {
if (!file) return { ok: false, msg: 'No file' };
if (file.size > CUSTOM_SOUND_MAX) return { ok: false, msg: 'File too large (max 700 KB)' };
const url = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); });
const url = await readDataUrl(file);
try {
localStorage.setItem(CUSTOM_SOUND_KEY, url);
localStorage.setItem(CUSTOM_SOUND_KEY + '_name', file.name);
@@ -923,7 +1049,7 @@
},
clearCustomSound() {
try { localStorage.removeItem(CUSTOM_SOUND_KEY); localStorage.removeItem(CUSTOM_SOUND_KEY + '_name'); } catch (_) {}
if (O.hitSound === 'custom') O.hitSound = 'tick';
if (O.hitSound === 'custom') O.hitSound = 'default';
Sfx.loadCustom();
settingsChanged();
},
@@ -1038,6 +1164,16 @@
input.click();
};
const closePanel = () => { if (panel) panel.remove(); panel = null; };
// The maps panel sits left of the right sidebar when that is open (on phones the sidebar covers the
// screen: there the panel stays at the edge). Follows the sidebar opening / closing.
const placePanel = () => {
if (!panel) return;
const bar = document.querySelector(SIDEBAR_SEL);
const open = bar && !document.body.classList.contains('sidebar-right-hidden') && innerWidth > 599;
panel.style.right = (open ? (bar.offsetWidth || 300) + 20 : 20) + 'px';
};
new MutationObserver(placePanel).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('resize', placePanel);
const openPanel = (title) => {
closePanel();
panelFor = null; // set again by showMaps; other panels (draft, results) stay when the album entry changes
@@ -1052,6 +1188,7 @@
const body = el('div', 'sqc-body');
panel.appendChild(body);
document.body.appendChild(panel);
placePanel();
return body;
};
@@ -1171,8 +1308,14 @@
ov.appendChild(cv);
document.body.appendChild(ov);
const ctx = cv.getContext('2d');
// The overlay is sized to exactly innerWidth x innerHeight (the coordinates everything is drawn and
// hit-tested in) and the canvas backing store to that at the real pixel ratio. A plain inset: 0
// overlay leaves out the page scrollbar in Chromium, so the canvas was squeezed by the scrollbar
// width: resampled every frame (shimmering, smeared edges) and drawn slightly off from where clicks count.
const size = () => {
const dpr = Math.min(1.5, window.devicePixelRatio || 1);
const dpr = Math.min(2, window.devicePixelRatio || 1);
ov.style.width = innerWidth + 'px'; ov.style.height = innerHeight + 'px';
cv.style.width = innerWidth + 'px'; cv.style.height = innerHeight + 'px';
cv.width = Math.round(innerWidth * dpr); cv.height = Math.round(innerHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
@@ -1186,6 +1329,7 @@
window.removeEventListener('blur', S.onBlur);
window.removeEventListener('resize', S.view.size);
S.audio.removeEventListener('ended', S.onEnded);
closeSettings();
S.view.ov.remove();
document.body.classList.remove('sqc-session');
const s = S;
@@ -1201,6 +1345,14 @@
const view = makeOverlay();
S = Object.assign({ mode, audio, view, lastAudioT: -1, lastPerf: 0, mouse: { x: innerWidth / 2, y: innerHeight / 2 }, fx: [] }, extra);
window.addEventListener('resize', view.size);
// Pause button: the way to pause (and from the menu stop) without a keyboard, e.g. on phones
const pb = el('button', 'sqc-pausebtn');
pb.type = 'button';
pb.title = 'Pause (Esc)';
pb.setAttribute('aria-label', 'Pause');
pb.innerHTML = '<i class="fa-solid fa-pause"></i>';
pb.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); pause(true); });
view.ov.appendChild(pb);
// No player controls popping up while playing (v0ck ignores hover during a session, see v0ck.js)
document.body.classList.add('sqc-session');
document.querySelectorAll('.v0ck_hover').forEach(e => e.classList.remove('v0ck_hover'));
@@ -1251,7 +1403,7 @@
ctx.globalAlpha = 1 - p;
ctx.strokeStyle = f.color; ctx.lineWidth = 2;
const s = NOTE * (1 + p * 0.6);
ctx.strokeRect(f.x - s / 2, f.y - s / 2, s, s);
strokeShape(ctx, f.x, f.y, s);
if (f.text) {
ctx.fillStyle = f.color; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(f.text, f.x, f.y - NOTE * 0.8 - p * 14);
@@ -1274,7 +1426,8 @@
// Esc (or leaving the window) pauses: song, notes, metronome and countdown freeze, hits are ignored.
// Esc again / Resume continues after a short get-ready countdown.
const RESUME_MS = 1000;
const pause = () => {
// byUser (Esc, the pause button): the tuner opens next to the menu. Pauses from leaving the window don't.
const pause = (byUser) => {
if (!S || S.paused || S.finished) return;
S.paused = true;
if (S.leadUntil) S.leadRemaining = Math.max(0, S.leadUntil - performance.now());
@@ -1309,16 +1462,71 @@
// Buttons must not count as hits
menu.addEventListener('pointerdown', (e) => e.stopPropagation());
S.view.ov.appendChild(menu);
S.view.ov.classList.add('sqc-is-paused');
S.pauseMenu = menu;
if (byUser === true) openSettings();
};
const resume = () => {
if (!S || !S.paused) return;
closeSettings();
if (S.pauseMenu) { S.pauseMenu.remove(); S.pauseMenu = null; }
S.view.ov.classList.remove('sqc-is-paused');
S.paused = false;
if (S.mode === 'edit') return; // the editor stays stopped; Space plays
if (S.leadRemaining != null) { S.leadUntil = performance.now() + S.leadRemaining; S.leadRemaining = null; }
else S.resumeUntil = performance.now() + RESUME_MS;
};
// Pausing (Esc / pause button) opens the sidebar's tuner, Square Clicker tab, raised above the game so it
// can be used right there (changes apply at once), and scrolls to the setting changed last unless that is
// already in view. Resume / Esc (and "Back to the game" on phones, where the sidebar covers the game)
// close it again: previous sidebar tab back, sidebar hidden again if it was hidden.
const SIDEBAR_SEL = '.global-sidebar-right, .item-sidebar-right, .index-sidebar-right';
const openSettings = () => {
if (!S || S.settings) return;
if (!document.querySelector(SIDEBAR_SEL)) { flash('The settings sidebar is not available on this page', 'error'); return; }
S.settings = { hidSidebar: document.body.classList.contains('sidebar-right-hidden'), prevTab: document.querySelector('.sidebar-tab.active') };
document.body.classList.add('sqc-settings');
if (S.settings.hidSidebar && window.toggleSidebarRight) window.toggleSidebarRight();
const tab = document.querySelector('#sidebar-tab-tuner');
if (tab && !tab.classList.contains('active')) tab.click();
setTimeout(() => {
const sub = document.querySelector('.f0ck-tuner-subtab-btn[data-subtab="sqc"]');
if (sub && !sub.classList.contains('active')) sub.click();
// after the tab switch (and its own "pane start into view" scroll) has settled
setTimeout(jumpToLastSetting, 250);
}, 60);
const back = el('button', 'sqc-settings-back', 'Back to the game');
back.type = 'button';
back.addEventListener('click', () => closeSettings());
document.body.appendChild(back);
S.settings.back = back;
};
const jumpToLastSetting = () => {
let key = null;
try { key = localStorage.getItem(LAST_SETTING_KEY); } catch (_) {}
const row = key && document.querySelector('#f0ck-tuner-pane-sqc [data-sqc-key="' + key + '"]');
if (!row || !row.offsetParent) return;
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(row) : null;
if (!sc) return;
const rr = row.getBoundingClientRect(), sr = sc.getBoundingClientRect();
const pane = document.querySelector('#f0ck-tuner-pane-sqc');
const header = pane && pane.parentElement && pane.parentElement.querySelector('.f0ck-tuner-header');
const top = header && getComputedStyle(header).position === 'sticky' ? Math.max(sr.top, header.getBoundingClientRect().bottom) : sr.top;
if (rr.top >= top && rr.bottom <= sr.bottom) return; // already in view: leave the scroll position alone
sc.scrollBy({ top: rr.top - top - (sr.bottom - top) / 3, behavior: 'smooth' });
row.classList.add('sqc-last-setting');
setTimeout(() => row.classList.remove('sqc-last-setting'), 1500);
};
function closeSettings() {
if (!S || !S.settings) return;
const st = S.settings;
S.settings = null;
st.back.remove();
document.body.classList.remove('sqc-settings');
if (st.prevTab && st.prevTab.id !== 'sidebar-tab-tuner' && document.contains(st.prevTab)) st.prevTab.click();
if (st.hidSidebar && !document.body.classList.contains('sidebar-right-hidden') && window.toggleSidebarRight) window.toggleSidebarRight();
}
// "Get ready" countdown after a resume
const drawResume = (ctx) => {
if (!S.resumeUntil) return;
@@ -1432,7 +1640,7 @@
ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8;
if (n.p) { drawTrack(ctx, n.p, accent(), ctx.globalAlpha * 0.6); ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8; }
ctx.strokeStyle = accent(); ctx.lineWidth = 2;
ctx.strokeRect(n.x * innerWidth - NOTE / 2, n.y * innerHeight - NOTE / 2, NOTE, NOTE);
strokeShape(ctx, n.x * innerWidth, n.y * innerHeight, NOTE);
if (+O.noteNumbers) {
ctx.fillStyle = '#fff'; ctx.font = 'bold 16px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), n.x * innerWidth, n.y * innerHeight);
@@ -1627,7 +1835,7 @@
// keys (through the key guard; typing in the title field is left to the field)
s.onKey = (e) => {
if (S !== s) return;
if (e.key === 'Escape') { e.preventDefault(); if (!e.repeat) (s.paused ? resume() : pause()); return; }
if (e.key === 'Escape') { e.preventDefault(); if (!e.repeat) (s.paused ? resume() : pause(true)); return; }
if (s.paused || e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return;
e.preventDefault();
const t = songTime();
@@ -1675,15 +1883,14 @@
const dt = n.t - t;
ctx.globalAlpha = dt >= 0 ? Math.max(0.2, 1 - dt / EDIT_AFTER) : Math.max(0.2, 1 + (dt + n.d) / EDIT_BEFORE);
if (n.p) drawTrack(ctx, n.p, col, 0.6 * ctx.globalAlpha);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(px - NOTE / 2, py - NOTE / 2, NOTE, NOTE);
ctx.strokeStyle = col; ctx.lineWidth = i === s.sel ? 4 : 2;
ctx.strokeRect(px - NOTE / 2, py - NOTE / 2, NOTE, NOTE);
if (i === s.sel) { ctx.setLineDash([5, 4]); ctx.strokeRect(px - NOTE / 2 - 8, py - NOTE / 2 - 8, NOTE + 16, NOTE + 16); ctx.setLineDash([]); }
drawNoteBody(ctx, px, py, NOTE, col, i === s.sel ? 4 : 2);
ctx.strokeStyle = col;
if (i === s.sel) { ctx.lineWidth = 2; ctx.setLineDash([5, 4]); strokeShape(ctx, px, py, NOTE + 16); ctx.setLineDash([]); }
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), px, py);
if (n.d && !n.p) { ctx.fillStyle = col; ctx.fillRect(px - NOTE / 2, py + NOTE / 2 + 6, NOTE, 6); }
// approach frame for notes still to come
if (dt > 0 && dt < APPROACH_MS) { const s2 = NOTE * (1 + 2.2 * dt / APPROACH_MS); ctx.lineWidth = 1; ctx.strokeRect(px - s2 / 2, py - s2 / 2, s2, s2); }
if (dt > 0 && dt < APPROACH_MS) { const s2 = NOTE * (1 + 2.2 * dt / APPROACH_MS); ctx.lineWidth = 1; strokeShape(ctx, px, py, s2); }
ctx.globalAlpha = 1;
}
// timeline: every note a tick, the selected one in the accent, the playhead in white
@@ -1764,14 +1971,47 @@
};
// Play any map: { id (null for a generated auto map), title, notes }
// Auto map settings changed while an auto map plays (tuner, e.g. via Settings in the pause menu): the
// rest of the map is rebuilt with them and the game goes on. Notes up to a little ahead of now (what is
// on screen, plus a margin) stay as they are; the new notes start after that. Score and combo carry on.
const autoSig = () => JSON.stringify(Object.keys(O).filter(k => /^auto/.test(k)).map(k => [k, O[k]]));
let regenTimer = null;
const regenAuto = async () => {
const s = S;
if (!s || s.mode !== 'play' || !s.map.auto || s.finished) return;
const sig = autoSig();
if (sig === s.autoSig) return;
s.autoSig = sig;
let map;
try { map = await Auto.build(s.audio, s.map.itemId, O.autoPreset, O.autoDifficulty); } catch (e) { flash('Could not rebuild the auto map', 'error'); return; }
if (S !== s || s.finished || s.autoSig !== sig) return; // left, ended, or changed again meanwhile
const from = Math.max(0, songTime()) + Math.max(APPROACH_MS, 1500);
const keep = s.notes.filter(n => n.t < from);
const fresh = toPlayNotes(map.notes.filter(n => n.t >= from));
s.notes = keep.concat(fresh);
s.notes.forEach((n, i) => { n.i = i; });
map.subId = s.map.subId;
s.map = map; // Retry and "Save as map" use the new map
flash('Auto map updated: ' + map.title + ' (' + fresh.length + ' notes from ' + fmtTime(from) + ')');
};
window.addEventListener('f0ck:sqc_settings_changed', () => {
if (!S || S.mode !== 'play' || !S.map.auto || autoSig() === S.autoSig) return;
clearTimeout(regenTimer);
regenTimer = setTimeout(regenAuto, 400); // sliders fire on every step: rebuild once they settle
});
// opts.returnTo: where to go when the game ends or is left (playtests go back to the draft / editor)
const playMap = (map, opts = {}) => {
const notes = (typeof map.notes === 'string' ? JSON.parse(map.notes) : map.notes)
.map((n, i) => ({ t: n.t, x: n.x, y: n.y, d: n.d || 0, p: Array.isArray(n.p) && n.p.length > 1 ? n.p : null, i, state: 'pending', judge: 0, outSince: 0 }));
// Map notes -> game notes (judging state, stacking, prepared sliders)
const toPlayNotes = (raw) => {
const notes = raw.map((n, i) => ({ t: n.t, x: n.x, y: n.y, d: n.d || 0, p: Array.isArray(n.p) && n.p.length > 1 ? n.p : null, i, state: 'pending', judge: 0, outSince: 0 }));
stackNotes(notes);
notes.forEach(prepSlider);
return notes;
};
const playMap = (map, opts = {}) => {
const notes = toPlayNotes(typeof map.notes === 'string' ? JSON.parse(map.notes) : map.notes);
const s = startSession('play', {
map, notes, next: 0, score: 0, combo: 0, maxCombo: 0, returnTo: opts.returnTo || null,
map, notes, next: 0, score: 0, combo: 0, maxCombo: 0, returnTo: opts.returnTo || null, autoSig: map.auto ? autoSig() : null,
hits: { 300: 0, 100: 0, 50: 0, miss: 0 }, leadUntil: performance.now() + LEAD_IN_MS, finished: false,
});
if (!s) return;
@@ -1902,10 +2142,7 @@
const going = n.state === 'holding' || n.state === 'broken';
if (going) ctx.globalAlpha = n.p ? 0.35 : n.state === 'broken' ? 0.5 : 1; // the ball takes over from the start square
// body
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(p.x - NOTE / 2, p.y - NOTE / 2, NOTE, NOTE);
ctx.strokeStyle = col; ctx.lineWidth = 3;
ctx.strokeRect(p.x - NOTE / 2, p.y - NOTE / 2, NOTE, NOTE);
drawNoteBody(ctx, p.x, p.y, NOTE, col, 3);
if (+O.noteNumbers) { // off by default (tuner: numbers on notes)
ctx.fillStyle = '#fff'; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(n.i + 1), p.x, p.y);
@@ -1918,10 +2155,10 @@
const inside = Math.abs(S.mouse.x - b.x) <= FOLLOW && Math.abs(S.mouse.y - b.y) <= FOLLOW;
ctx.globalAlpha = broken ? 0.5 : 1;
ctx.fillStyle = broken ? '#ff4d4d' : col;
ctx.fillRect(b.x - NOTE * 0.28, b.y - NOTE * 0.28, NOTE * 0.56, NOTE * 0.56);
fillShape(ctx, b.x, b.y, NOTE * 0.56);
ctx.setLineDash([6, 5]);
ctx.strokeStyle = (broken ? !inside : n.outSince) ? '#ff4d4d' : col; ctx.lineWidth = 2;
ctx.strokeRect(b.x - FOLLOW, b.y - FOLLOW, FOLLOW * 2, FOLLOW * 2);
strokeShape(ctx, b.x, b.y, FOLLOW * 2);
ctx.setLineDash([]);
}
// plain hold notes: a bar under the note that fills while held
@@ -1936,7 +2173,7 @@
const sc = 1 + 2.2 * (until / APPROACH_MS);
const s2 = NOTE * sc;
ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.strokeRect(p.x - s2 / 2, p.y - s2 / 2, s2, s2);
strokeShape(ctx, p.x, p.y, s2);
}
ctx.globalAlpha = 1;
}
@@ -1951,7 +2188,7 @@
// done once every note is judged and the last effects faded
const last = S.notes[S.notes.length - 1];
if (S.next >= S.notes.length && t > last.t + last.d + 800) { finishPlay(); return; }
if (S.next >= S.notes.length && (!last || t > last.t + last.d + 800)) { finishPlay(); return; }
S.raf = requestAnimationFrame(frame);
};
S.raf = requestAnimationFrame(frame);