@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 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 TAB_TITLE = CFG.tabTitle || null;
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
- if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + HISTORY_GUARD);
309
- if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + HISTORY_GUARD);
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 view = location.hash === "#games" || location.hash === "#recent" ? "games" : "home";
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) play(pool[Math.floor(Math.random() * pool.length)].id);
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
- let is24 = store.get(CLOCK_KEY, false);
388
+ clockIs24 = store.get(CLOCK_KEY, false);
383
389
  const timeEl = $("clock-time"), metaEl = $("clock-meta");
384
- const tick = () => {
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 (is24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
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", () => { is24 = !is24; store.set(CLOCK_KEY, is24); tick(); });
392
- tick();
393
- setInterval(tick, 1000);
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
- const btn = $("theme-btn");
398
- const apply = (t) => {
399
- document.documentElement.dataset.theme = t;
400
- btn.title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
401
- };
402
- apply(document.documentElement.dataset.theme === "light" ? "light" : "dark");
403
- btn.addEventListener("click", () => {
404
- const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
405
- apply(next);
406
- try { localStorage.setItem(THEME_KEY, next); } catch {}
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
- els.homeInput.placeholder = `Search ${games.length} games…`;
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 &amp; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywkrlerche/richardwho",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Richards Unblocked Games",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
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; }