@ywkrlerche/richardwho 1.0.26 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +403 -25
- package/config.js +7 -0
- package/index.html +92 -0
- package/package.json +1 -1
- package/style.css +63 -0
package/app.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
const CFG = window.RUG_CONFIG || {};
|
|
5
5
|
const SITE_NAME = "Richards Unblocked Games";
|
|
6
6
|
// The tab/history shows this instead of the real names (set in config.js)
|
|
7
|
-
const
|
|
8
|
-
const tabTitle = (gameName) => TAB_TITLE || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
|
|
7
|
+
const tabTitle = (gameName) => CFG.tabTitle || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
|
|
9
8
|
const RECENT_KEY = "rug-recent";
|
|
10
9
|
const CLOCK_KEY = "rug-clock24";
|
|
11
10
|
const THEME_KEY = "rug-theme";
|
|
@@ -304,11 +303,12 @@
|
|
|
304
303
|
// or pushState() would move the whole tab (and kick the player out). Give games no-op versions first.
|
|
305
304
|
const HISTORY_GUARD = "<script>(function(){try{var h=window.history,n=function(){};" +
|
|
306
305
|
"h.back=h.forward=h.go=h.pushState=h.replaceState=n;}catch(e){}})();<\/script>";
|
|
306
|
+
// Insert only at the very top of the file (after an optional doctype and <html> tag). Searching for the
|
|
307
|
+
// first "<head>" anywhere is unsafe: many self-unpacking games keep their whole page inside a script string,
|
|
308
|
+
// and inserting there cuts that string in half.
|
|
307
309
|
function guardHistory(html) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (/^\s*<!doctype[^>]*>/i.test(html)) return html.replace(/^\s*<!doctype[^>]*>/i, (m) => m + HISTORY_GUARD);
|
|
311
|
-
return HISTORY_GUARD + html;
|
|
310
|
+
const top = html.match(/^(?\s*(?:<!--[\s\S]*?-->\s*)*(?:<!doctype[^>]*>)?\s*(?:<html\b[^>]*>)?)/i)[1];
|
|
311
|
+
return top + HISTORY_GUARD + html.slice(top.length);
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
function stopGame() {
|
|
@@ -346,9 +346,12 @@
|
|
|
346
346
|
els.player.hidden = true;
|
|
347
347
|
document.body.classList.remove("playing");
|
|
348
348
|
document.title = tabTitle();
|
|
349
|
-
const
|
|
349
|
+
const h = location.hash;
|
|
350
|
+
const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : "home";
|
|
350
351
|
els.homeView.hidden = view !== "home";
|
|
351
352
|
els.gamesView.hidden = view !== "games";
|
|
353
|
+
$("view-settings").hidden = view !== "settings";
|
|
354
|
+
if (view === "settings") renderSettings();
|
|
352
355
|
document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
|
|
353
356
|
b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
|
|
354
357
|
if (view === "games") renderRecent();
|
|
@@ -373,40 +376,280 @@
|
|
|
373
376
|
try { window.top.location.replace(url); } catch { location.replace(url); }
|
|
374
377
|
}
|
|
375
378
|
|
|
379
|
+
// The dice button opens the wheel (filtered to the current category on the games page)
|
|
376
380
|
function randomGame() {
|
|
377
381
|
const pool = !els.gamesView.hidden && activeTag !== "All" ? games.filter((g) => g.tag === activeTag) : games;
|
|
378
|
-
if (pool.length)
|
|
382
|
+
if (pool.length) openWheel(pool);
|
|
379
383
|
}
|
|
380
384
|
|
|
385
|
+
let clockIs24 = false, clockTick = () => {};
|
|
386
|
+
function setClock24(on) { clockIs24 = on; store.set(CLOCK_KEY, on); clockTick(); }
|
|
381
387
|
function startClock() {
|
|
382
|
-
|
|
388
|
+
clockIs24 = store.get(CLOCK_KEY, false);
|
|
383
389
|
const timeEl = $("clock-time"), metaEl = $("clock-meta");
|
|
384
|
-
|
|
390
|
+
clockTick = () => {
|
|
385
391
|
const d = new Date();
|
|
386
392
|
let h = d.getHours();
|
|
387
393
|
const m = String(d.getMinutes()).padStart(2, "0");
|
|
388
|
-
if (
|
|
394
|
+
if (clockIs24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
|
|
389
395
|
else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}`; metaEl.textContent = ap; }
|
|
390
396
|
};
|
|
391
|
-
$("clock").addEventListener("click", () =>
|
|
392
|
-
|
|
393
|
-
setInterval(
|
|
397
|
+
$("clock").addEventListener("click", () => setClock24(!clockIs24));
|
|
398
|
+
clockTick();
|
|
399
|
+
setInterval(clockTick, 1000);
|
|
394
400
|
}
|
|
395
401
|
|
|
402
|
+
function applyTheme(t) {
|
|
403
|
+
document.documentElement.dataset.theme = t;
|
|
404
|
+
$("theme-btn").title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
405
|
+
try { localStorage.setItem(THEME_KEY, t); } catch {}
|
|
406
|
+
}
|
|
396
407
|
function initTheme() {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
document.documentElement.dataset.theme
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
applyTheme(document.documentElement.dataset.theme === "light" ? "light" : "dark");
|
|
409
|
+
$("theme-btn").addEventListener("click", () =>
|
|
410
|
+
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------- Settings (saved in this browser) ----------
|
|
414
|
+
const SETTINGS_KEY = "rug-settings";
|
|
415
|
+
const REAL_ICON = "data:image/svg+xml," + encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='black'/><text x='50' y='70' font-size='62' text-anchor='middle' fill='white' font-family='Arial' font-weight='800'>r</text></svg>");
|
|
416
|
+
const DISGUISES = [
|
|
417
|
+
{ id: "classroom", label: "Google Classroom", title: "Google Classroom", icon: "https://ssl.gstatic.com/classroom/favicon.png" },
|
|
418
|
+
{ id: "docs", label: "Google Docs", title: "Untitled document - Google Docs", icon: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico" },
|
|
419
|
+
{ id: "drive", label: "Google Drive", title: "My Drive - Google Drive", icon: "https://ssl.gstatic.com/images/branding/product/1x/drive_2020q4_32dp.png" },
|
|
420
|
+
{ id: "gmail", label: "Gmail", title: "Inbox - Gmail", icon: "https://ssl.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" },
|
|
421
|
+
{ id: "google", label: "Google", title: "Google", icon: "https://www.google.com/favicon.ico" },
|
|
422
|
+
{ id: "canvas", label: "Canvas", title: "Dashboard", icon: "https://du11hjcvx0uqb.cloudfront.net/dist/images/favicon-e10d657a73.ico" },
|
|
423
|
+
{ id: "off", label: "Off (real name)", title: null, icon: REAL_ICON },
|
|
424
|
+
];
|
|
425
|
+
const DEFAULT_PANIC_KEYS = CFG.panicKeys || ["`", "]"];
|
|
426
|
+
const DEFAULT_PANIC_URL = CFG.panicUrl || "https://classroom.google.com/";
|
|
427
|
+
let settings = {};
|
|
428
|
+
let listeningForKey = false;
|
|
429
|
+
|
|
430
|
+
function loadSettings() {
|
|
431
|
+
settings = { disguise: "classroom", typing: true, boot: true, ...store.get(SETTINGS_KEY, {}) };
|
|
432
|
+
applySettings();
|
|
433
|
+
}
|
|
434
|
+
function saveSettings() {
|
|
435
|
+
const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
|
|
436
|
+
// tabTitle/tabIcon are copied in so index.html can apply the disguise before the page finishes loading
|
|
437
|
+
store.set(SETTINGS_KEY, { ...settings, tabTitle: d.title, tabIcon: d.icon });
|
|
438
|
+
applySettings();
|
|
439
|
+
}
|
|
440
|
+
function applySettings() {
|
|
441
|
+
const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
|
|
442
|
+
CFG.tabTitle = d.title;
|
|
443
|
+
$("tab-icon").href = d.icon;
|
|
444
|
+
CFG.panicUrl = settings.panicUrl || DEFAULT_PANIC_URL;
|
|
445
|
+
CFG.panicKeys = settings.panicKey ? [settings.panicKey] : DEFAULT_PANIC_KEYS;
|
|
446
|
+
document.title = tabTitle(els.player.hidden ? "" : els.playerTitle.textContent);
|
|
447
|
+
const keys = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
|
|
448
|
+
$("panic-btn").title = `Panic (${keys})`;
|
|
449
|
+
document.querySelector(".corner-links span").innerHTML = `${CFG.panicKeys.map((k) => `<kbd>${escapeHtml(k === " " ? "Space" : k)}</kbd>`).join(" or ")} panic`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function renderSettings() {
|
|
453
|
+
$("disguises").innerHTML = DISGUISES.map((d) => `<button class="disguise" type="button" data-disguise="${d.id}" aria-pressed="${settings.disguise === d.id}">
|
|
454
|
+
${d.id === "off" ? `<span class="none-icon">🚫</span>` : `<img src="${d.icon}" alt="">`}<span>${escapeHtml(d.label)}</span></button>`).join("");
|
|
455
|
+
const preset = [...$("set-panic-url").options].some((o) => o.value === CFG.panicUrl);
|
|
456
|
+
$("set-panic-url").value = preset ? CFG.panicUrl : "custom";
|
|
457
|
+
$("set-panic-custom-row").hidden = preset;
|
|
458
|
+
$("set-panic-custom").value = preset ? "" : CFG.panicUrl;
|
|
459
|
+
$("set-panic-key").textContent = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
|
|
460
|
+
$("set-theme").value = document.documentElement.dataset.theme;
|
|
461
|
+
$("set-clock").value = clockIs24 ? "24" : "12";
|
|
462
|
+
$("set-per-row").value = String(store.get(PER_ROW_KEY, 6));
|
|
463
|
+
$("set-typing").checked = settings.typing !== false;
|
|
464
|
+
$("set-boot").checked = settings.boot !== false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function bindSettings() {
|
|
468
|
+
$("disguises").addEventListener("click", (e) => {
|
|
469
|
+
const b = e.target.closest("[data-disguise]");
|
|
470
|
+
if (!b) return;
|
|
471
|
+
settings.disguise = b.dataset.disguise;
|
|
472
|
+
saveSettings();
|
|
473
|
+
renderSettings();
|
|
474
|
+
});
|
|
475
|
+
$("set-panic-url").addEventListener("change", (e) => {
|
|
476
|
+
const custom = e.target.value === "custom";
|
|
477
|
+
$("set-panic-custom-row").hidden = !custom;
|
|
478
|
+
if (!custom) { settings.panicUrl = e.target.value; saveSettings(); } else $("set-panic-custom").focus();
|
|
479
|
+
});
|
|
480
|
+
$("set-panic-custom").addEventListener("change", (e) => {
|
|
481
|
+
let url = e.target.value.trim();
|
|
482
|
+
if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
|
|
483
|
+
if (url) { settings.panicUrl = url; e.target.value = url; saveSettings(); }
|
|
484
|
+
});
|
|
485
|
+
const keyBtn = $("set-panic-key");
|
|
486
|
+
keyBtn.addEventListener("click", () => {
|
|
487
|
+
listeningForKey = true;
|
|
488
|
+
keyBtn.classList.add("listening");
|
|
489
|
+
keyBtn.textContent = "Press a key…";
|
|
490
|
+
$("set-panic-key-hint").textContent = "Esc to cancel";
|
|
491
|
+
});
|
|
492
|
+
document.addEventListener("keydown", (e) => {
|
|
493
|
+
if (!listeningForKey) return;
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
e.stopImmediatePropagation();
|
|
496
|
+
listeningForKey = false;
|
|
497
|
+
keyBtn.classList.remove("listening");
|
|
498
|
+
$("set-panic-key-hint").textContent = "Click, then press any key";
|
|
499
|
+
if (e.key !== "Escape" && e.key.length === 1) { settings.panicKey = e.key; saveSettings(); }
|
|
500
|
+
renderSettings();
|
|
501
|
+
}, true);
|
|
502
|
+
$("set-theme").addEventListener("change", (e) => applyTheme(e.target.value));
|
|
503
|
+
$("set-clock").addEventListener("change", (e) => setClock24(e.target.value === "24"));
|
|
504
|
+
$("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
|
|
505
|
+
$("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
|
|
506
|
+
$("set-boot").addEventListener("change", (e) => { settings.boot = e.target.checked; saveSettings(); });
|
|
507
|
+
$("set-clear-recent").addEventListener("click", () => {
|
|
508
|
+
store.set(RECENT_KEY, []);
|
|
509
|
+
renderRecent();
|
|
510
|
+
$("set-clear-recent").textContent = "Cleared ✓";
|
|
511
|
+
setTimeout(() => { $("set-clear-recent").textContent = "Clear"; }, 1500);
|
|
512
|
+
});
|
|
513
|
+
$("set-reset").addEventListener("click", () => {
|
|
514
|
+
settings = { disguise: "classroom", typing: true, boot: true };
|
|
515
|
+
saveSettings();
|
|
516
|
+
applyTheme("dark");
|
|
517
|
+
setClock24(false);
|
|
518
|
+
setPerRow(6);
|
|
519
|
+
startTypingPlaceholder();
|
|
520
|
+
renderSettings();
|
|
407
521
|
});
|
|
408
522
|
}
|
|
409
523
|
|
|
524
|
+
// ---------- Random game wheel ----------
|
|
525
|
+
const WHEEL_COLORS = ["#2563eb", "#7c3aed", "#db2777", "#ea580c", "#16a34a", "#0891b2"];
|
|
526
|
+
const wheel = { items: [], rot: 0, from: 0, target: 0, start: 0, dur: 5200, raf: 0, winner: null, spinning: false };
|
|
527
|
+
function drawWheel() {
|
|
528
|
+
const c = $("wheel"), ctx = c.getContext("2d");
|
|
529
|
+
const R = c.width / 2, n = wheel.items.length, a = (Math.PI * 2) / n;
|
|
530
|
+
ctx.clearRect(0, 0, c.width, c.height);
|
|
531
|
+
for (let i = 0; i < n; i++) {
|
|
532
|
+
const s = wheel.rot + i * a;
|
|
533
|
+
ctx.beginPath();
|
|
534
|
+
ctx.moveTo(R, R);
|
|
535
|
+
ctx.arc(R, R, R - 6, s, s + a);
|
|
536
|
+
ctx.closePath();
|
|
537
|
+
ctx.fillStyle = WHEEL_COLORS[i % WHEEL_COLORS.length];
|
|
538
|
+
ctx.fill();
|
|
539
|
+
ctx.strokeStyle = "rgba(0,0,0,.35)";
|
|
540
|
+
ctx.lineWidth = 3;
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
ctx.save();
|
|
543
|
+
ctx.translate(R, R);
|
|
544
|
+
ctx.rotate(s + a / 2);
|
|
545
|
+
ctx.textAlign = "right";
|
|
546
|
+
ctx.textBaseline = "middle";
|
|
547
|
+
ctx.fillStyle = "#fff";
|
|
548
|
+
ctx.font = `700 ${n > 10 ? 22 : 26}px Inter, Segoe UI, Arial, sans-serif`;
|
|
549
|
+
let name = wheel.items[i].name;
|
|
550
|
+
if (name.length > 20) name = name.slice(0, 19) + "…";
|
|
551
|
+
ctx.fillText(name, R - 26, 0);
|
|
552
|
+
ctx.restore();
|
|
553
|
+
}
|
|
554
|
+
ctx.beginPath();
|
|
555
|
+
ctx.arc(R, R, 46, 0, Math.PI * 2);
|
|
556
|
+
ctx.fillStyle = "#0b0b0f";
|
|
557
|
+
ctx.fill();
|
|
558
|
+
ctx.lineWidth = 6;
|
|
559
|
+
ctx.strokeStyle = "#fff";
|
|
560
|
+
ctx.stroke();
|
|
561
|
+
}
|
|
562
|
+
function spinWheel(pool) {
|
|
563
|
+
const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
|
|
564
|
+
wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
|
|
565
|
+
const n = wheel.items.length, a = (Math.PI * 2) / n;
|
|
566
|
+
const win = Math.floor(Math.random() * n);
|
|
567
|
+
wheel.winner = wheel.items[win];
|
|
568
|
+
// Land the winner's slice center under the pointer (top = -90°), after 6+ full turns
|
|
569
|
+
const base = -Math.PI / 2 - (win + 0.5) * a + (Math.random() - 0.5) * a * 0.6;
|
|
570
|
+
wheel.from = wheel.rot;
|
|
571
|
+
wheel.target = base + Math.PI * 2 * Math.ceil((wheel.from + Math.PI * 12 - base) / (Math.PI * 2));
|
|
572
|
+
wheel.start = performance.now();
|
|
573
|
+
wheel.spinning = true;
|
|
574
|
+
$("wheel-result").hidden = true;
|
|
575
|
+
$("wheel-play").hidden = true;
|
|
576
|
+
$("wheel-again").hidden = true;
|
|
577
|
+
$("wheel-skip").hidden = false;
|
|
578
|
+
cancelAnimationFrame(wheel.raf);
|
|
579
|
+
const step = (t) => {
|
|
580
|
+
const p = Math.min(1, (t - wheel.start) / wheel.dur);
|
|
581
|
+
const ease = 1 - Math.pow(1 - p, 4); // fast start, long slow finish
|
|
582
|
+
wheel.rot = wheel.from + (wheel.target - wheel.from) * ease;
|
|
583
|
+
drawWheel();
|
|
584
|
+
if (p < 1) wheel.raf = requestAnimationFrame(step);
|
|
585
|
+
else finishWheel();
|
|
586
|
+
};
|
|
587
|
+
wheel.raf = requestAnimationFrame(step);
|
|
588
|
+
}
|
|
589
|
+
function finishWheel() {
|
|
590
|
+
cancelAnimationFrame(wheel.raf);
|
|
591
|
+
wheel.rot = wheel.target;
|
|
592
|
+
wheel.spinning = false;
|
|
593
|
+
drawWheel();
|
|
594
|
+
const g = wheel.winner, url = logoUrl(g);
|
|
595
|
+
$("wheel-result-name").textContent = g.name;
|
|
596
|
+
const art = $("wheel-result-art");
|
|
597
|
+
art.style.backgroundImage = url ? `url("${url}"), ${gradient(g)}` : gradient(g);
|
|
598
|
+
art.textContent = url ? "" : initials(g.name);
|
|
599
|
+
$("wheel-result").hidden = false;
|
|
600
|
+
$("wheel-skip").hidden = true;
|
|
601
|
+
$("wheel-again").hidden = false;
|
|
602
|
+
$("wheel-play").hidden = false;
|
|
603
|
+
$("wheel-play").focus();
|
|
604
|
+
}
|
|
605
|
+
let wheelPool = [];
|
|
606
|
+
function openWheel(pool) {
|
|
607
|
+
wheelPool = pool;
|
|
608
|
+
$("wheel-wrap").hidden = false;
|
|
609
|
+
spinWheel(pool);
|
|
610
|
+
}
|
|
611
|
+
function closeWheel() {
|
|
612
|
+
cancelAnimationFrame(wheel.raf);
|
|
613
|
+
wheel.spinning = false;
|
|
614
|
+
$("wheel-wrap").hidden = true;
|
|
615
|
+
}
|
|
616
|
+
function bindWheel() {
|
|
617
|
+
$("wheel-skip").addEventListener("click", finishWheel);
|
|
618
|
+
$("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
|
|
619
|
+
$("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); play(g.id); });
|
|
620
|
+
$("wheel-close").addEventListener("click", closeWheel);
|
|
621
|
+
$("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------- Live "people online" count ----------
|
|
625
|
+
// Needs a free Firebase Realtime Database (see SETUP.md). Without one, the count stays hidden.
|
|
626
|
+
function loadScript(src) {
|
|
627
|
+
return new Promise((ok, fail) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = fail; document.head.appendChild(s); });
|
|
628
|
+
}
|
|
629
|
+
async function startPresence() {
|
|
630
|
+
if (!CFG.firebase || !CFG.firebase.databaseURL) return;
|
|
631
|
+
try {
|
|
632
|
+
const v = "10.12.2";
|
|
633
|
+
await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
|
|
634
|
+
await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`);
|
|
635
|
+
const app = window.firebase.initializeApp(CFG.firebase, "presence");
|
|
636
|
+
const db = app.database();
|
|
637
|
+
const me = db.ref("online").push();
|
|
638
|
+
db.ref(".info/connected").on("value", (snap) => {
|
|
639
|
+
if (!snap.val()) return;
|
|
640
|
+
me.onDisconnect().remove();
|
|
641
|
+
me.set(window.firebase.database.ServerValue.TIMESTAMP);
|
|
642
|
+
});
|
|
643
|
+
db.ref("online").on("value", (snap) => {
|
|
644
|
+
const n = snap.numChildren();
|
|
645
|
+
$("online-count").textContent = n <= 1 ? "Just you here right now" : `${n.toLocaleString()} people on right now`;
|
|
646
|
+
$("online").hidden = false;
|
|
647
|
+
});
|
|
648
|
+
} catch (err) {
|
|
649
|
+
console.warn("Online count unavailable:", err);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
410
653
|
// ---------- Arrow keys and controller move focus between tiles and buttons ----------
|
|
411
654
|
function focusables() {
|
|
412
655
|
const scope = els.player.hidden ? document : els.player;
|
|
@@ -435,8 +678,14 @@
|
|
|
435
678
|
}
|
|
436
679
|
|
|
437
680
|
function onKey(e) {
|
|
681
|
+
if (listeningForKey) return; // the Settings page is capturing a new panic key
|
|
438
682
|
const keys = CFG.panicKeys || ["`", "]"];
|
|
439
683
|
if (keys.includes(e.key)) { e.preventDefault(); panic(); return; }
|
|
684
|
+
if (!$("wheel-wrap").hidden) {
|
|
685
|
+
if (e.key === "Escape") closeWheel();
|
|
686
|
+
else if (e.key === " " && wheel.spinning) { e.preventDefault(); finishWheel(); } // space skips
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
440
689
|
if (!els.player.hidden) {
|
|
441
690
|
if (e.key === "Escape" && document.activeElement !== els.frame) goBack();
|
|
442
691
|
return;
|
|
@@ -532,12 +781,130 @@
|
|
|
532
781
|
window.addEventListener("popstate", route);
|
|
533
782
|
}
|
|
534
783
|
|
|
784
|
+
// ---------- Home search: placeholder that types itself ----------
|
|
785
|
+
let timer = 0; // shared, so restarting (from Settings) replaces the running animation
|
|
786
|
+
function startTypingPlaceholder() {
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
const input = els.homeInput;
|
|
789
|
+
const short = games.filter((g) => g.name.length <= 24); // long names get cut off in the box
|
|
790
|
+
const pick = () => short[Math.floor(Math.random() * short.length)].name;
|
|
791
|
+
const fixed = [
|
|
792
|
+
() => `Search ${games.length} games…`, () => `Try "${pick()}"`, () => "What are we playing today?",
|
|
793
|
+
() => `Try "${pick()}"`, () => "Type a game, any game…", () => `How about "${pick()}"?`,
|
|
794
|
+
() => "Racing, horror, rhythm…", () => `Try "${pick()}"`, () => "Press / to search from anywhere",
|
|
795
|
+
];
|
|
796
|
+
if (!games.length) return;
|
|
797
|
+
if (settings.typing === false || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
798
|
+
input.placeholder = fixed[0]();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
let line = 0, text = fixed[0](), shown = 0, deleting = false;
|
|
802
|
+
const busy = () => document.activeElement === input || input.value;
|
|
803
|
+
function tick() {
|
|
804
|
+
if (busy()) { input.placeholder = `Search ${games.length} games…`; timer = setTimeout(tick, 400); return; }
|
|
805
|
+
if (!deleting) {
|
|
806
|
+
shown++;
|
|
807
|
+
input.placeholder = text.slice(0, shown) + "▍";
|
|
808
|
+
if (shown >= text.length) { deleting = true; input.placeholder = text; timer = setTimeout(tick, 1800); return; }
|
|
809
|
+
timer = setTimeout(tick, 45 + Math.random() * 60); // uneven, like a real person typing
|
|
810
|
+
} else {
|
|
811
|
+
shown--;
|
|
812
|
+
input.placeholder = text.slice(0, shown) + "▍";
|
|
813
|
+
if (shown <= 0) {
|
|
814
|
+
deleting = false;
|
|
815
|
+
line = (line + 1) % fixed.length;
|
|
816
|
+
text = fixed[line]();
|
|
817
|
+
timer = setTimeout(tick, 350);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
timer = setTimeout(tick, 22);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
tick();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------- Boot screen + auto-update ----------
|
|
827
|
+
const BOOT_LINES = [
|
|
828
|
+
"Loading… about 3 hours left", "Downloading more RAM…", "Calculating time left: 7 years…",
|
|
829
|
+
"Loading 99%… still 99%…", "Estimated time: yes", "Skill issue detected, loading anyway…",
|
|
830
|
+
"Installing 47 browser toolbars…", "Running on 3 megabytes and a dream…", "Buffering… (it's not the wifi, it's you)",
|
|
831
|
+
"Spawning in…", "Respawning in 3… 2… 9…", "Rendering grass you'll never touch…",
|
|
832
|
+
"Loading the loading screen…", "Downloading lag…", "Speedrunning the loading bar…",
|
|
833
|
+
"Turning it off and on again…", "Searching for the any key…", "Patching bugs, adding new ones…",
|
|
834
|
+
];
|
|
835
|
+
// Football jokes are mixed in, but never two in a row
|
|
836
|
+
const TEAM_LINES = [
|
|
837
|
+
"The Cowboys suck. Loading anyway…", "Loading… unlike the Cowboys in January",
|
|
838
|
+
"This bar has more wins than the Cowboys…", "Giants are good. Loading respectfully…",
|
|
839
|
+
"Loading at Giants speed (fast, obviously)…", "Buffering like a Browns offense…",
|
|
840
|
+
"Searching for a Browns quarterback… still searching…", "Eagles fans booing the loading bar…",
|
|
841
|
+
"Loading… quieter than Philly after a loss", "Steelers: 8-9 and somehow still loading…",
|
|
842
|
+
"Loading with a Steelers-level 'we'll get 'em next year'…", "Ravens up big… loading the 4th-quarter collapse…",
|
|
843
|
+
"Loading faster than a Ravens playoff exit…",
|
|
844
|
+
];
|
|
845
|
+
const boot = { el: $("boot"), fill: $("boot-fill"), msg: $("boot-msg"), ver: $("boot-ver"), timer: 0, pct: 0 };
|
|
846
|
+
function startBoot() {
|
|
847
|
+
const used = new Set();
|
|
848
|
+
let lastWasTeam = false;
|
|
849
|
+
const next = () => {
|
|
850
|
+
// ~1 in 3 lines is a team joke, but never right after another one
|
|
851
|
+
const team = !lastWasTeam && Math.random() < 0.35;
|
|
852
|
+
const pool = (team ? TEAM_LINES : BOOT_LINES).filter((l) => !used.has(l));
|
|
853
|
+
const line = pool[Math.floor(Math.random() * pool.length)] || BOOT_LINES[0];
|
|
854
|
+
used.add(line);
|
|
855
|
+
lastWasTeam = team;
|
|
856
|
+
return line;
|
|
857
|
+
};
|
|
858
|
+
boot.msg.textContent = next();
|
|
859
|
+
boot.timer = setInterval(() => {
|
|
860
|
+
boot.msg.textContent = next();
|
|
861
|
+
boot.pct = Math.min(90, boot.pct + 8 + Math.random() * 14); // creeps up, saves the last bit for "done"
|
|
862
|
+
boot.fill.style.width = `${boot.pct}%`;
|
|
863
|
+
}, 420);
|
|
864
|
+
}
|
|
865
|
+
function finishBoot(text) {
|
|
866
|
+
clearInterval(boot.timer);
|
|
867
|
+
boot.fill.style.width = "100%";
|
|
868
|
+
if (text) boot.msg.textContent = text;
|
|
869
|
+
setTimeout(() => boot.el.classList.add("done"), 250);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// When opened from a versioned unpkg link (e.g. an old bookmark), jump to the newest version.
|
|
873
|
+
// Returns the URL to move to, or null to stay. Never waits longer than `ms`.
|
|
874
|
+
async function newerVersionUrl(ms) {
|
|
875
|
+
const pkg = CFG.npmPackage || "@ywkrlerche/richardwho";
|
|
876
|
+
const here = location.pathname.match(/@(\d+)\.(\d+)\.(\d+)\//);
|
|
877
|
+
if (location.hostname !== "unpkg.com" || !here) return null;
|
|
878
|
+
try {
|
|
879
|
+
const ctrl = new AbortController();
|
|
880
|
+
const t = setTimeout(() => ctrl.abort(), ms);
|
|
881
|
+
const res = await fetch(`https://unpkg.com/${pkg}/package.json?t=${Date.now()}`, { cache: "no-store", signal: ctrl.signal });
|
|
882
|
+
clearTimeout(t);
|
|
883
|
+
const latest = (await res.json()).version;
|
|
884
|
+
const a = here.slice(1).map(Number), b = latest.split(".").map(Number);
|
|
885
|
+
const newer = b[0] > a[0] || (b[0] === a[0] && (b[1] > a[1] || (b[1] === a[1] && b[2] > a[2])));
|
|
886
|
+
boot.ver.textContent = newer ? `v${a.join(".")} → v${latest}` : `v${latest}`;
|
|
887
|
+
return newer ? `https://unpkg.com/${pkg}@${latest}/index.html${location.hash}` : null;
|
|
888
|
+
} catch {
|
|
889
|
+
return null; // offline or slow: just open this version
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
535
893
|
async function init() {
|
|
894
|
+
loadSettings();
|
|
895
|
+
if (settings.boot !== false) startBoot();
|
|
896
|
+
else boot.el.classList.add("done"); // loading screen turned off in Settings
|
|
536
897
|
initTheme();
|
|
537
898
|
startClock();
|
|
538
899
|
startGamepad();
|
|
539
900
|
setPerRow(store.get(PER_ROW_KEY, 6));
|
|
540
901
|
bind();
|
|
902
|
+
bindSettings();
|
|
903
|
+
bindWheel();
|
|
904
|
+
startPresence();
|
|
905
|
+
// long enough to read a joke or two (skipped when the loading screen is off)
|
|
906
|
+
const minShow = new Promise((r) => setTimeout(r, settings.boot !== false ? 1500 : 0));
|
|
907
|
+
const update = newerVersionUrl(2500);
|
|
541
908
|
try {
|
|
542
909
|
const res = await fetch("games.json", { cache: "no-cache" });
|
|
543
910
|
const data = await res.json();
|
|
@@ -546,9 +913,19 @@
|
|
|
546
913
|
} catch (err) {
|
|
547
914
|
console.error(err);
|
|
548
915
|
els.homeSub.textContent = "Couldn't load the game list. Try refreshing.";
|
|
916
|
+
finishBoot("Couldn't load the game list. Try refreshing.");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const target = await update;
|
|
920
|
+
if (target) {
|
|
921
|
+
clearInterval(boot.timer);
|
|
922
|
+
boot.msg.textContent = "Found a newer version, updating…";
|
|
923
|
+
boot.fill.style.width = "100%";
|
|
924
|
+
location.replace(target);
|
|
549
925
|
return;
|
|
550
926
|
}
|
|
551
|
-
|
|
927
|
+
await minShow;
|
|
928
|
+
startTypingPlaceholder();
|
|
552
929
|
els.homeSub.innerHTML = `${games.length} games · press <kbd>/</kbd> to search`;
|
|
553
930
|
els.footerCount.textContent = `${games.length} games`;
|
|
554
931
|
renderChips();
|
|
@@ -556,6 +933,7 @@
|
|
|
556
933
|
renderGrid();
|
|
557
934
|
startMarquee();
|
|
558
935
|
route();
|
|
936
|
+
finishBoot("Done! (that was not 3 hours)");
|
|
559
937
|
}
|
|
560
938
|
|
|
561
939
|
init();
|
package/config.js
CHANGED
|
@@ -14,6 +14,13 @@ window.RUG_CONFIG = {
|
|
|
14
14
|
// Keys that trigger panic (KeyboardEvent.key values)
|
|
15
15
|
panicKeys: ["`", "]"],
|
|
16
16
|
|
|
17
|
+
// Live "people online" count on the home page. Paste your Firebase web config here
|
|
18
|
+
// (see SETUP.md). Leave null to hide the count.
|
|
19
|
+
firebase: null,
|
|
20
|
+
|
|
21
|
+
// npm package the site is published as (used to find the newest version)
|
|
22
|
+
npmPackage: "@ywkrlerche/richardwho",
|
|
23
|
+
|
|
17
24
|
// What the browser tab and history show instead of the site/game name.
|
|
18
25
|
// Set tabTitle to null to show the real names again.
|
|
19
26
|
tabTitle: "Google Classroom",
|
package/index.html
CHANGED
|
@@ -11,9 +11,24 @@
|
|
|
11
11
|
<script>
|
|
12
12
|
// Apply saved theme before paint so there's no flash
|
|
13
13
|
try { document.documentElement.dataset.theme = localStorage.getItem("rug-theme") || "dark"; } catch (e) {}
|
|
14
|
+
// Apply the saved tab disguise before anything else shows in the tab
|
|
15
|
+
try {
|
|
16
|
+
var s = JSON.parse(localStorage.getItem("rug-settings") || "{}");
|
|
17
|
+
if (s.tabTitle) document.title = s.tabTitle;
|
|
18
|
+
if (s.tabIcon) document.getElementById("tab-icon").href = s.tabIcon;
|
|
19
|
+
} catch (e) {}
|
|
14
20
|
</script>
|
|
15
21
|
</head>
|
|
16
22
|
<body>
|
|
23
|
+
<!-- Boot screen: shows instantly while the site checks for a newer version and loads the game list -->
|
|
24
|
+
<div class="boot" id="boot" aria-live="polite">
|
|
25
|
+
<div class="boot-inner">
|
|
26
|
+
<div class="boot-word">richardwho</div>
|
|
27
|
+
<div class="boot-bar"><div class="boot-fill" id="boot-fill"></div></div>
|
|
28
|
+
<p class="boot-msg" id="boot-msg">Loading… about 3 hours left</p>
|
|
29
|
+
<p class="boot-ver" id="boot-ver"></p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
17
32
|
<!-- Soft blue/orange glow behind everything -->
|
|
18
33
|
<div class="aurora" aria-hidden="true"><span class="a1"></span><span class="a2"></span><span class="a3"></span></div>
|
|
19
34
|
|
|
@@ -36,6 +51,9 @@
|
|
|
36
51
|
</button>
|
|
37
52
|
</div>
|
|
38
53
|
<div class="rail-group bottom">
|
|
54
|
+
<button class="rail-btn" type="button" data-nav="settings" title="Settings" aria-label="Settings">
|
|
55
|
+
<svg viewBox="0 0 24 24"><path d="M19.4 13a7.6 7.6 0 0 0 0-2l2.1-1.6-2-3.5-2.5 1a7.4 7.4 0 0 0-1.7-1L15 3.3h-4l-.4 2.6a7.4 7.4 0 0 0-1.7 1l-2.5-1-2 3.5L6.6 11a7.6 7.6 0 0 0 0 2l-2.1 1.6 2 3.5 2.5-1a7.4 7.4 0 0 0 1.7 1l.4 2.6h4l.4-2.6a7.4 7.4 0 0 0 1.7-1l2.5 1 2-3.5L19.4 13ZM13 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z"/></svg>
|
|
56
|
+
</button>
|
|
39
57
|
<button class="rail-btn" type="button" id="theme-btn" title="Toggle theme" aria-label="Toggle theme">
|
|
40
58
|
<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-3.14-9.8A9.4 9.4 0 0 0 12 3Z"/></svg>
|
|
41
59
|
</button>
|
|
@@ -59,6 +77,7 @@
|
|
|
59
77
|
<span class="sweep" aria-hidden="true"></span>
|
|
60
78
|
</form>
|
|
61
79
|
<p class="home-sub" id="home-sub"></p>
|
|
80
|
+
<p class="online" id="online" hidden><span class="online-dot"></span><span id="online-count"></span></p>
|
|
62
81
|
</section>
|
|
63
82
|
|
|
64
83
|
<!-- GAMES -->
|
|
@@ -109,8 +128,81 @@
|
|
|
109
128
|
<p class="empty" id="empty" hidden>No games match that search.</p>
|
|
110
129
|
</section>
|
|
111
130
|
</section>
|
|
131
|
+
|
|
132
|
+
<!-- SETTINGS -->
|
|
133
|
+
<section id="view-settings" class="view settings" hidden>
|
|
134
|
+
<header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
|
|
135
|
+
|
|
136
|
+
<div class="set-group">
|
|
137
|
+
<h2>Tab disguise</h2>
|
|
138
|
+
<p class="set-help">What the browser tab and your history show instead of the site's name.</p>
|
|
139
|
+
<div class="disguises" id="disguises"></div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="set-group">
|
|
143
|
+
<h2>Panic button</h2>
|
|
144
|
+
<label class="set-row"><span>Goes to</span>
|
|
145
|
+
<select id="set-panic-url">
|
|
146
|
+
<option value="https://classroom.google.com/">Google Classroom</option>
|
|
147
|
+
<option value="https://docs.google.com/">Google Docs</option>
|
|
148
|
+
<option value="https://drive.google.com/">Google Drive</option>
|
|
149
|
+
<option value="https://www.google.com/">Google</option>
|
|
150
|
+
<option value="custom">Custom website…</option>
|
|
151
|
+
</select>
|
|
152
|
+
</label>
|
|
153
|
+
<label class="set-row" id="set-panic-custom-row" hidden><span>Custom website</span>
|
|
154
|
+
<input id="set-panic-custom" type="url" placeholder="https://…" spellcheck="false">
|
|
155
|
+
</label>
|
|
156
|
+
<div class="set-row"><span>Panic key</span>
|
|
157
|
+
<button class="pill-btn key-btn" type="button" id="set-panic-key">`</button>
|
|
158
|
+
<small class="set-hint" id="set-panic-key-hint">Click, then press any key</small>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="set-group">
|
|
163
|
+
<h2>Look & feel</h2>
|
|
164
|
+
<label class="set-row"><span>Theme</span>
|
|
165
|
+
<select id="set-theme"><option value="dark">Dark</option><option value="light">Light</option></select>
|
|
166
|
+
</label>
|
|
167
|
+
<label class="set-row"><span>Clock</span>
|
|
168
|
+
<select id="set-clock"><option value="12">12-hour</option><option value="24">24-hour</option></select>
|
|
169
|
+
</label>
|
|
170
|
+
<label class="set-row"><span>Games per row</span>
|
|
171
|
+
<select id="set-per-row"><option>5</option><option>6</option><option>7</option><option>8</option></select>
|
|
172
|
+
</label>
|
|
173
|
+
<label class="set-row"><span>Typing search box</span><input type="checkbox" id="set-typing"></label>
|
|
174
|
+
<label class="set-row"><span>Funny loading screen</span><input type="checkbox" id="set-boot"></label>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="set-group">
|
|
178
|
+
<h2>Your data</h2>
|
|
179
|
+
<div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div>
|
|
180
|
+
<div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
112
183
|
</main>
|
|
113
184
|
|
|
185
|
+
<!-- RANDOM GAME WHEEL -->
|
|
186
|
+
<div class="wheel-wrap" id="wheel-wrap" hidden role="dialog" aria-label="Random game wheel">
|
|
187
|
+
<div class="wheel-card">
|
|
188
|
+
<button class="wheel-x" type="button" id="wheel-close" aria-label="Close">✕</button>
|
|
189
|
+
<h2>Spin for a game</h2>
|
|
190
|
+
<div class="wheel-stage">
|
|
191
|
+
<div class="wheel-pointer" aria-hidden="true"></div>
|
|
192
|
+
<canvas id="wheel" width="720" height="720"></canvas>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="wheel-result" id="wheel-result" hidden>
|
|
195
|
+
<div class="wheel-result-art" id="wheel-result-art"></div>
|
|
196
|
+
<div><p class="wheel-result-label">You got</p><p class="wheel-result-name" id="wheel-result-name"></p></div>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="wheel-actions">
|
|
199
|
+
<button class="pill-btn" type="button" id="wheel-skip">Skip ⏭</button>
|
|
200
|
+
<button class="pill-btn" type="button" id="wheel-again" hidden>Spin again</button>
|
|
201
|
+
<button class="pill-btn play" type="button" id="wheel-play" hidden>▶ Play</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
114
206
|
<!-- PLAYER -->
|
|
115
207
|
<section class="player" id="player" hidden aria-label="Game player">
|
|
116
208
|
<div class="player-bar">
|
package/package.json
CHANGED
package/style.css
CHANGED
|
@@ -45,6 +45,20 @@ button { cursor: pointer; }
|
|
|
45
45
|
kbd { font: 600 11px ui-monospace, Consolas, monospace; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); background: var(--soft); }
|
|
46
46
|
:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
|
47
47
|
|
|
48
|
+
/* ---------- Boot screen ---------- */
|
|
49
|
+
.boot {
|
|
50
|
+
position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
|
|
51
|
+
background: radial-gradient(60vw 30vh at 50% 30%, var(--glow-blue), transparent 70%), var(--bg);
|
|
52
|
+
transition: opacity .35s ease, visibility .35s;
|
|
53
|
+
}
|
|
54
|
+
.boot.done { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
55
|
+
.boot-inner { width: min(420px, 86vw); text-align: center; }
|
|
56
|
+
.boot-word { font-size: clamp(44px, 8vw, 72px); font-weight: 800; letter-spacing: -.055em; margin-bottom: 26px; }
|
|
57
|
+
.boot-bar { height: 3px; background: var(--soft-2); overflow: hidden; border-radius: 3px; }
|
|
58
|
+
.boot-fill { height: 100%; width: 0; background: var(--text); transition: width .25s ease; }
|
|
59
|
+
.boot-msg { margin: 14px 0 0; color: var(--muted); font-size: 13px; min-height: 1.4em; font-variant-numeric: tabular-nums; }
|
|
60
|
+
.boot-ver { margin: 6px 0 0; color: var(--muted); font-size: 11px; opacity: .6; min-height: 1.2em; }
|
|
61
|
+
|
|
48
62
|
/* ---------- Aurora glow ---------- */
|
|
49
63
|
.aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
|
50
64
|
.aurora span { position: absolute; border-radius: 50%; filter: blur(90px); }
|
|
@@ -179,6 +193,55 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
|
|
|
179
193
|
.marquee .tile { width: clamp(300px, 28vw, 440px); aspect-ratio: 16 / 9; flex: none; }
|
|
180
194
|
.marquee .tile-label { font-size: 17px; bottom: 14px; left: 16px; }
|
|
181
195
|
|
|
196
|
+
/* ---------- Online count (home) ---------- */
|
|
197
|
+
.online { margin: 10px 0 0; display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
|
|
198
|
+
.online-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 rgba(34,197,94,.6); animation: pulse 2s infinite; }
|
|
199
|
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.55); } 70% { box-shadow: 0 0 0 8px rgba(34,197,94,0); } 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); } }
|
|
200
|
+
|
|
201
|
+
/* ---------- Settings ---------- */
|
|
202
|
+
.settings { padding-top: 26px; padding-bottom: 60px; max-width: 760px; }
|
|
203
|
+
.set-group { border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--panel) 85%, transparent); padding: 18px 20px; margin-bottom: 16px; }
|
|
204
|
+
.set-group h2 { margin: 0 0 4px; font-size: 16px; }
|
|
205
|
+
.set-help { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
|
|
206
|
+
.set-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); }
|
|
207
|
+
.set-group h2 + .set-row { margin-top: 10px; }
|
|
208
|
+
.set-row > span { flex: 1; font-size: 14px; }
|
|
209
|
+
.set-row select, .set-row input[type="url"] { height: 36px; min-width: 200px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--panel); color: var(--text); }
|
|
210
|
+
.set-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--text); }
|
|
211
|
+
.set-hint { color: var(--muted); font-size: 12px; }
|
|
212
|
+
.settings .pill-btn { color: var(--text); border-color: var(--line-2); background: var(--soft); }
|
|
213
|
+
.key-btn { min-width: 64px; font-family: ui-monospace, Consolas, monospace; }
|
|
214
|
+
.key-btn.listening { border-color: var(--text); }
|
|
215
|
+
.disguises { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
|
216
|
+
.disguise {
|
|
217
|
+
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; text-align: left;
|
|
218
|
+
border: 1px solid var(--line-2); background: var(--soft); color: var(--text); font-size: 13px; font-weight: 600;
|
|
219
|
+
}
|
|
220
|
+
.disguise img { width: 18px; height: 18px; flex: none; }
|
|
221
|
+
.disguise .none-icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; font-size: 13px; }
|
|
222
|
+
.disguise[aria-pressed="true"] { border-color: var(--text); background: var(--soft-2); box-shadow: inset 0 0 0 1px var(--text); }
|
|
223
|
+
|
|
224
|
+
/* ---------- Random game wheel ---------- */
|
|
225
|
+
.wheel-wrap { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: rgba(0,0,0,.7); backdrop-filter: blur(6px); }
|
|
226
|
+
.wheel-card { position: relative; width: min(440px, 100%); text-align: center; background: var(--panel); border: 1px solid var(--line-2); border-radius: 8px; padding: 20px 20px 18px; box-shadow: 0 30px 80px rgba(0,0,0,.6); }
|
|
227
|
+
.wheel-card h2 { margin: 0 0 12px; font-size: 18px; }
|
|
228
|
+
.wheel-x { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; border: 0; border-radius: 4px; background: none; color: var(--muted); font-size: 16px; }
|
|
229
|
+
.wheel-x:hover { color: var(--text); background: var(--soft); }
|
|
230
|
+
.wheel-stage { position: relative; width: min(360px, 78vw); aspect-ratio: 1; margin: 0 auto; }
|
|
231
|
+
.wheel-stage canvas { width: 100%; height: 100%; display: block; }
|
|
232
|
+
.wheel-pointer {
|
|
233
|
+
position: absolute; left: 50%; top: -6px; z-index: 1; width: 0; height: 0; transform: translateX(-50%);
|
|
234
|
+
border-left: 13px solid transparent; border-right: 13px solid transparent; border-top: 24px solid var(--text);
|
|
235
|
+
filter: drop-shadow(0 2px 4px rgba(0,0,0,.6));
|
|
236
|
+
}
|
|
237
|
+
.wheel-result { display: flex; align-items: center; gap: 12px; margin: 16px auto 0; padding: 10px; border: 1px solid var(--line-2); border-radius: 6px; text-align: left; max-width: 340px; }
|
|
238
|
+
.wheel-result-art { width: 64px; aspect-ratio: 4/3; border-radius: 4px; flex: none; background: #222 center / cover no-repeat; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px; }
|
|
239
|
+
.wheel-result-label { margin: 0; color: var(--muted); font-size: 12px; }
|
|
240
|
+
.wheel-result-name { margin: 2px 0 0; font-size: 16px; font-weight: 800; }
|
|
241
|
+
.wheel-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
|
242
|
+
.wheel-card .pill-btn { color: var(--text); border-color: var(--line-2); background: var(--soft); height: 38px; padding: 0 18px; }
|
|
243
|
+
.wheel-card .pill-btn.play { background: var(--text); color: var(--bg); border-color: var(--text); }
|
|
244
|
+
|
|
182
245
|
/* ---------- Player ---------- */
|
|
183
246
|
.player { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column; background: #000; }
|
|
184
247
|
.player-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: #0a0a0c; border-bottom: 1px solid rgba(255,255,255,.08); color: #fff; }
|