/** * Square Clicker: audio / video items become playable rhythm-game maps. For everyone, unless * websrv.square_clicker_enabled is false (src/inc/square_clicker.mjs). * * On an audio or video item an "Square Clicker" button appears next to the other item actions (hotkey o). * - Edit: your own maps (any map for staff) open in an editor (timeline, move / add / delete / nudge). * - Create: the song restarts and the whole page is the play area. Every click places a note at that * spot and time, holding the button makes a hold note. Esc or the song's end finishes the map. * - Play: notes (squares, this site has no round corners) appear with a closing approach frame; hit * them with a click or Z / X at the right time, keep hold notes held until their bar fills. * 300 / 100 / 50 / miss, combo, accuracy, grade; scores go to a per-map top 10. * Maps and scores live on the server (/api/v2/sqc/*). One canvas, drawn only while a session runs. */ (() => { if (window.__f0ckSqc) return; window.__f0ckSqc = true; const WIN_300 = 50, WIN_100 = 100, WIN_50 = 150; // hit windows (± ms) const HOLD_MIN_MS = 220; // a press longer than this records a hold note const HOLD_SLACK_MS = 120; // letting go this close to the end still counts as held const SLIDER_MIN_MOVE = 20; // a hold that moved at least this far (px) becomes a slider const SLIDER_MAX_POINTS = 400; const FOLLOW_GRACE_MS = 250; // outside the follow zone longer than this breaks the slider // ── player settings (sidebar tuner "Square Clicker" tab), saved per device ──────────────── 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 SQC_DEFAULTS = { hitSound: 'tick', volume: 0.35, missSound: 1, countdownTicks: 1, approachMs: 900, noteSize: 70, 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, metronomeRecord: 1, metronomePlay: 0, metronomeVolume: 0.5, snapRecord: 0, // "Custom" auto map preset (advanced tuner) autoAdvanced: 0, autoMethod: 'flux', autoB1Lo: 30, autoB1Hi: 11000, autoB1W: 1, autoB2Lo: 30, autoB2Hi: 250, autoB2W: 0.5, autoB3Lo: 1000, autoB3Hi: 4000, autoB3W: 0, autoK: 1.4, autoGapMs: 280, autoWindowMs: 230, autoFloor: 0.04, autoSnap: 0, autoGridDiv: 2, autoJump: 1, autoSliderChance: 0.45, autoSliderGapMs: 750, }; const O = Object.assign({}, SQC_DEFAULTS); try { Object.assign(O, JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')); } catch (_) {} const saveSettings = () => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(O)); } catch (_) {} }; // Values derived from the settings (updated by applySettings) let APPROACH_MS, NOTE, LEAD_IN_MS, FOLLOW; const applySettings = () => { 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 FOLLOW = NOTE * Math.max(0.8, +O.followMul || 1.6); // half-size of the slider follow zone }; applySettings(); // Input for record and play: left AND right mouse button and every key (except Esc = quit) act like // a click. Each new press is a hit attempt (alternate keys, like rhythm games do); a hold / slider stays held while // ANY of them is still down. Ctrl/Alt/Meta combos and F-keys keep their browser function. // perKey (recording): onPress / onRelease get the input id for every press and release, so a new press // can end the hold another key started and each release only ends its own note. const bindInputs = (s, onPress, onRelease, perKey) => { const held = new Set(); const down = (id, x, y) => { if (held.has(id)) return; held.add(id); if (S && (S.paused || S.resumeUntil)) return; // paused / getting ready: no hits onPress(x, y, id); }; const up = (id) => { if (!held.delete(id)) return; if (perKey) onRelease(id); else if (!held.size) onRelease(); }; s.view.ov.addEventListener('pointerdown', (e) => { if (e.button !== 0 && e.button !== 2) return; e.preventDefault(); down('mouse' + e.button, e.clientX, e.clientY); }); s.view.ov.addEventListener('pointerup', (e) => up('mouse' + e.button)); 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.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); }; s.onKeyUp = (e) => up('key:' + e.code); // Losing focus (alt-tab) counts as letting go of everything s.onBlur = () => { if (held.size) { held.clear(); onRelease(); } pause(); }; s.clearHeld = () => held.clear(); window.addEventListener('blur', s.onBlur); // Keys reach s.onKey / s.onKeyUp through the permanent key guard below if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur(); }; // Key guard: registered once, now, in the capture phase on window, the first point every key event // passes (before all site shortcut listeners on document, and before scripts loaded after this one). // While a game session runs, EVERY key event belongs to the game: it is handed to the session and // stopped (stopImmediatePropagation), so no site shortcut (player keys, theme, comments, navigation, // Esc closing Onara...) reacts. Ctrl/Alt/Meta combos and F-keys keep their browser default (the // session's handler doesn't preventDefault them), the site just never sees them. const keyGuard = (e) => { if (!S || !S.onKey) return; e.stopImmediatePropagation(); // Typing into a field of our own overlay (editor title): let the field have the key const tg = e.target; if (tg && tg.closest && tg.closest('.sqc-overlay') && /^(INPUT|TEXTAREA)$/.test(tg.tagName)) { if (e.type === 'keydown' && (e.key === 'Enter' || e.key === 'Escape')) tg.blur(); 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 }; window.addEventListener('keydown', keyGuard, true); window.addEventListener('keyup', keyGuard, true); window.addEventListener('keypress', keyGuard, true); // Stacking (for play): a note on the same spot as a note still on screen, or on the end of a slider that // was just played, would be hidden under it, so you could only guess it is there. Such notes move a // bit down-right, one step more per note in the stack, and are hit where they are drawn. Sliders move // with their whole path. Imported beatmaps rely on this: back-to-back notes there share one position. const stackNotes = (notes) => { const W = innerWidth, H = innerHeight; const SAME = Math.max(10, NOTE * 0.25); // px: closer than this counts as the same spot const STEP = Math.max(12, NOTE * 0.32); // px per stack level: enough for the number underneath to show const at = (n) => [n.x * W, n.y * H]; const endOf = (n) => { const e = n.p && n.p[n.p.length - 1]; return e ? [e[1] * W, e[2] * H] : at(n); }; const near = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) < SAME; notes.forEach((n, i) => { n.stack = 0; const base = at(n); for (let j = i - 1; j >= 0; j--) { const m = notes[j]; if (n.t - (m.t + (m.d || 0)) > APPROACH_MS) break; // on an earlier note (its original spot) or on the end of an earlier slider if (near(base, m.base || at(m)) || (m.p && near(base, m.baseEnd || endOf(m)))) { n.stack = (m.stack || 0) + 1; break; } } n.base = base; n.baseEnd = endOf(n); if (!n.stack) return; const dx = Math.min(n.stack * STEP, W * 0.2) / W, dy = Math.min(n.stack * STEP, H * 0.2) / H; n.x = Math.max(0.02, Math.min(0.98, n.x + dx)); n.y = Math.max(0.02, Math.min(0.98, n.y + dy)); if (n.p) n.p = n.p.map(q => [q[0], Math.max(0.02, Math.min(0.98, q[1] + dx)), Math.max(0.02, Math.min(0.98, q[2] + dy))]); }); }; // For play: the recorded path is smoothed (moving average, ends kept) and the ball then travels it // at a CONSTANT speed over the slider's duration, like sliders in rhythm games. Replaying the mapper's // exact hand motion made every flick or hesitation a sudden jump that was very hard to follow. // Adds n.sp (smoothed points, viewport fractions), n.cum (cumulative length per point, px) and // n.len (total length, px), measured in screen pixels so both axes count equally. const prepSlider = (n) => { const p = n.p; if (!p || p.length < 2) return; const W = innerWidth, H = innerHeight; let pts = p.map(q => [q[1] * W, q[2] * H]); for (let pass = 0; pass < 3; pass++) { pts = pts.map((q, i) => { if (i === 0 || i === pts.length - 1) return q; const a = pts[i - 1], b = pts[i + 1]; return [(a[0] + 2 * q[0] + b[0]) / 4, (a[1] + 2 * q[1] + b[1]) / 4]; }); } const cum = [0]; for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])); n.sp = pts.map((q, i) => [p[i][0], q[0] / W, q[1] / H]); n.cum = cum; n.len = cum[cum.length - 1]; n.turns = findTurns(n, p.map(q => [q[1] * W, q[2] * H]), cum); }; // Where a slider reverses (the ball goes back over the track it came along), so it can show reverse // arrows: without them a back-and-forth slider looks exactly like a one-way one. Found on the path itself: // - imported repeat sliders retrace their leg point for point: a turn is a point whose neighbours are // the same point (exact mirror), however short the leg // - hand-drawn paths (no exact mirrors): the way in and the way out, each over at least R px, point in // nearly opposite directions AND the way out runs back over the way in (a wide hairpin is no turn) // -> [{ frac: share of the slider's length (= time) at the turn, x, y (px), ang: arrow direction }] const findTurns = (n, P, cum) => { const R = Math.max(12, NOTE * 0.4); const d2 = (a, z) => Math.hypot(a[0] - z[0], a[1] - z[1]); const out = []; const turnAt = (i, from) => out.push({ i, ang: Math.atan2(P[from][1] - P[i][1], P[from][0] - P[i][0]) }); for (let i = 1; i < P.length - 1; i++) { if (d2(P[i - 1], P[i + 1]) < 0.5 && d2(P[i], P[i - 1]) >= 0.5) turnAt(i, i - 1); } if (!out.length) { let best = null; // best candidate of the current run of candidates for (let i = 1; i < P.length - 1; i++) { let a = i - 1, b = i + 1; while (a > 0 && d2(P[i], P[a]) < R) a--; while (b < P.length - 1 && d2(P[b], P[i]) < R) b++; const v1 = [P[i][0] - P[a][0], P[i][1] - P[a][1]], v2 = [P[b][0] - P[i][0], P[b][1] - P[i][1]]; const l1 = Math.hypot(v1[0], v1[1]), l2 = Math.hypot(v2[0], v2[1]); let cos = l1 >= R * 0.8 && l2 >= R * 0.8 ? (v1[0] * v2[0] + v1[1] * v2[1]) / (l1 * l2) : 1; if (cos < -0.75) { // retraces: the point R px further on lies close to the way in let near = Infinity; for (let j = a; j <= i; j++) near = Math.min(near, d2(P[b], P[j])); if (near > R * 0.5) cos = 1; } if (cos < -0.75) { if (!best || cos < best.cos) best = { i, a, cos }; } else if (best) { turnAt(best.i, best.a); best = null; } } if (best) turnAt(best.i, best.a); } const len = cum[cum.length - 1] || 1; return out.map(tr => ({ frac: cum[tr.i] / len, x: P[tr.i][0], y: P[tr.i][1], ang: tr.ang })); }; // Reverse arrows on the turns still ahead of the ball (elapsed: ms since the slider's start; before it // starts all of them). Turns on the same spot draw once, with how many are left there ("x2"). const drawTurns = (ctx, n, elapsed, alpha) => { if (!n.turns || !n.turns.length) return; const groups = []; n.turns.forEach(tr => { if (tr.frac * n.d <= elapsed) return; const g = groups.find(o => Math.hypot(o.x - tr.x, o.y - tr.y) < NOTE * 0.5); if (g) g.count++; else groups.push({ x: tr.x, y: tr.y, ang: tr.ang, count: 1 }); }); ctx.save(); ctx.globalAlpha = alpha; ctx.lineCap = 'square'; ctx.lineJoin = 'miter'; groups.forEach(g => { const c = Math.cos(g.ang), sn = Math.sin(g.ang), size = NOTE * 0.28; // double chevron pointing back along the track const chevron = (off) => { const tx = g.x + c * (size * 1.2 + off), ty = g.y + sn * (size * 1.2 + off); // inside the track end ctx.beginPath(); ctx.moveTo(tx - c * size + sn * size * 0.8, ty - sn * size - c * size * 0.8); ctx.lineTo(tx, ty); ctx.lineTo(tx - c * size - sn * size * 0.8, ty - sn * size + c * size * 0.8); ctx.stroke(); }; for (const [w, stroke] of [[7, 'rgba(0,0,0,0.85)'], [3.5, '#fff']]) { ctx.lineWidth = w; ctx.strokeStyle = stroke; chevron(0); chevron(-size * 0.7); } if (g.count > 1) { ctx.font = 'bold 15px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineWidth = 4; ctx.strokeStyle = 'rgba(0,0,0,0.85)'; const lx = g.x - c * size * 1.6, ly = g.y - sn * size * 1.6; ctx.strokeText('x' + g.count, lx, ly); ctx.fillStyle = '#fff'; ctx.fillText('x' + g.count, lx, ly); } }); ctx.restore(); }; // Slider ball position (viewport fractions) dt ms after the note's start. // Prepared (play): constant speed along the smoothed path. Otherwise: the recorded timing. // p = [[dt, x, y], ...] (first point = the start); linear between points const sliderAt = (n, dt) => { if (n.sp && n.len > 0 && n.d > 0) { const target = Math.max(0, Math.min(1, dt / n.d)) * n.len; const c = n.cum, sp = n.sp; let i = 1; while (i < c.length - 1 && c[i] < target) i++; const f = (target - c[i - 1]) / Math.max(1e-6, c[i] - c[i - 1]); return { x: sp[i - 1][1] + (sp[i][1] - sp[i - 1][1]) * f, y: sp[i - 1][2] + (sp[i][2] - sp[i - 1][2]) * f }; } const p = n.p; if (!p || p.length < 2) return { x: n.x, y: n.y }; if (dt <= p[0][0]) return { x: p[0][1], y: p[0][2] }; for (let i = 1; i < p.length; i++) { if (dt <= p[i][0]) { const a = p[i - 1], b = p[i]; const f = (dt - a[0]) / Math.max(1, b[0] - a[0]); return { x: a[1] + (b[1] - a[1]) * f, y: a[2] + (b[2] - a[2]) * f }; } } const l = p[p.length - 1]; return { x: l[1], y: l[2] }; }; // 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; ctx.save(); ctx.globalAlpha = alpha; ctx.lineJoin = 'bevel'; ctx.lineCap = 'butt'; ctx.beginPath(); pts.forEach((q, i) => { const x = q[1] * innerWidth, y = q[2] * innerHeight; if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.strokeStyle = col; ctx.lineWidth = NOTE * 0.8 + 4; ctx.stroke(); ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = NOTE * 0.8; ctx.stroke(); 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); ctx.restore(); }; // ── helpers ──────────────────────────────────────────────────────────────── const csrf = () => (window.f0ckSession && window.f0ckSession.csrf_token) || ''; const loggedIn = () => !!(window.f0ckSession && window.f0ckSession.user); const api = (url, opts = {}) => fetch(url, Object.assign({ credentials: 'include' }, opts, { headers: Object.assign({ 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, opts.headers || {}), })).then(r => r.json()).catch(() => ({ success: false, msg: 'Network error' })); const scope = () => (document.body.classList.contains('onara-modal-open') && document.getElementById('onara-item-mount')) || document; // The media to play to: the item's own