@ywkrlerche/richardwho 1.0.25 → 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
@@ -3,6 +3,8 @@
3
3
 
4
4
  const CFG = window.RUG_CONFIG || {};
5
5
  const SITE_NAME = "Richards Unblocked Games";
6
+ // The tab/history shows this instead of the real names (set in config.js)
7
+ const tabTitle = (gameName) => CFG.tabTitle || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
6
8
  const RECENT_KEY = "rug-recent";
7
9
  const CLOCK_KEY = "rug-clock24";
8
10
  const THEME_KEY = "rug-theme";
@@ -202,7 +204,7 @@
202
204
  els.retryBtn.hidden = true;
203
205
  els.barFill.style.width = "0%";
204
206
  els.loaderStatus.textContent = "Connecting…";
205
- document.title = `${game.name} · ${SITE_NAME}`;
207
+ document.title = tabTitle(game.name);
206
208
 
207
209
  const urls = gameUrls(game);
208
210
  const loaded = new Array(urls.length).fill(0);
@@ -301,11 +303,12 @@
301
303
  // or pushState() would move the whole tab (and kick the player out). Give games no-op versions first.
302
304
  const HISTORY_GUARD = "<script>(function(){try{var h=window.history,n=function(){};" +
303
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.
304
309
  function guardHistory(html) {
305
- if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + HISTORY_GUARD);
306
- if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + HISTORY_GUARD);
307
- if (/^\s*<!doctype[^>]*>/i.test(html)) return html.replace(/^\s*<!doctype[^>]*>/i, (m) => m + HISTORY_GUARD);
308
- 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);
309
312
  }
310
313
 
311
314
  function stopGame() {
@@ -342,10 +345,13 @@
342
345
  stopGame();
343
346
  els.player.hidden = true;
344
347
  document.body.classList.remove("playing");
345
- document.title = SITE_NAME;
346
- const view = location.hash === "#games" || location.hash === "#recent" ? "games" : "home";
348
+ document.title = tabTitle();
349
+ const h = location.hash;
350
+ const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : "home";
347
351
  els.homeView.hidden = view !== "home";
348
352
  els.gamesView.hidden = view !== "games";
353
+ $("view-settings").hidden = view !== "settings";
354
+ if (view === "settings") renderSettings();
349
355
  document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
350
356
  b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
351
357
  if (view === "games") renderRecent();
@@ -370,40 +376,280 @@
370
376
  try { window.top.location.replace(url); } catch { location.replace(url); }
371
377
  }
372
378
 
379
+ // The dice button opens the wheel (filtered to the current category on the games page)
373
380
  function randomGame() {
374
381
  const pool = !els.gamesView.hidden && activeTag !== "All" ? games.filter((g) => g.tag === activeTag) : games;
375
- if (pool.length) play(pool[Math.floor(Math.random() * pool.length)].id);
382
+ if (pool.length) openWheel(pool);
376
383
  }
377
384
 
385
+ let clockIs24 = false, clockTick = () => {};
386
+ function setClock24(on) { clockIs24 = on; store.set(CLOCK_KEY, on); clockTick(); }
378
387
  function startClock() {
379
- let is24 = store.get(CLOCK_KEY, false);
388
+ clockIs24 = store.get(CLOCK_KEY, false);
380
389
  const timeEl = $("clock-time"), metaEl = $("clock-meta");
381
- const tick = () => {
390
+ clockTick = () => {
382
391
  const d = new Date();
383
392
  let h = d.getHours();
384
393
  const m = String(d.getMinutes()).padStart(2, "0");
385
- 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"; }
386
395
  else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}`; metaEl.textContent = ap; }
387
396
  };
388
- $("clock").addEventListener("click", () => { is24 = !is24; store.set(CLOCK_KEY, is24); tick(); });
389
- tick();
390
- setInterval(tick, 1000);
397
+ $("clock").addEventListener("click", () => setClock24(!clockIs24));
398
+ clockTick();
399
+ setInterval(clockTick, 1000);
391
400
  }
392
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
+ }
393
407
  function initTheme() {
394
- const btn = $("theme-btn");
395
- const apply = (t) => {
396
- document.documentElement.dataset.theme = t;
397
- btn.title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
398
- };
399
- apply(document.documentElement.dataset.theme === "light" ? "light" : "dark");
400
- btn.addEventListener("click", () => {
401
- const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
402
- apply(next);
403
- 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();
404
521
  });
405
522
  }
406
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
+
407
653
  // ---------- Arrow keys and controller move focus between tiles and buttons ----------
408
654
  function focusables() {
409
655
  const scope = els.player.hidden ? document : els.player;
@@ -432,8 +678,14 @@
432
678
  }
433
679
 
434
680
  function onKey(e) {
681
+ if (listeningForKey) return; // the Settings page is capturing a new panic key
435
682
  const keys = CFG.panicKeys || ["`", "]"];
436
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
+ }
437
689
  if (!els.player.hidden) {
438
690
  if (e.key === "Escape" && document.activeElement !== els.frame) goBack();
439
691
  return;
@@ -529,12 +781,130 @@
529
781
  window.addEventListener("popstate", route);
530
782
  }
531
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
+
532
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
533
897
  initTheme();
534
898
  startClock();
535
899
  startGamepad();
536
900
  setPerRow(store.get(PER_ROW_KEY, 6));
537
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);
538
908
  try {
539
909
  const res = await fetch("games.json", { cache: "no-cache" });
540
910
  const data = await res.json();
@@ -543,9 +913,19 @@
543
913
  } catch (err) {
544
914
  console.error(err);
545
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);
546
925
  return;
547
926
  }
548
- els.homeInput.placeholder = `Search ${games.length} games…`;
927
+ await minShow;
928
+ startTypingPlaceholder();
549
929
  els.homeSub.innerHTML = `${games.length} games · press <kbd>/</kbd> to search`;
550
930
  els.footerCount.textContent = `${games.length} games`;
551
931
  renderChips();
@@ -553,6 +933,7 @@
553
933
  renderGrid();
554
934
  startMarquee();
555
935
  route();
936
+ finishBoot("Done! (that was not 3 hours)");
556
937
  }
557
938
 
558
939
  init();
package/config.js CHANGED
@@ -14,6 +14,18 @@ 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
+
24
+ // What the browser tab and history show instead of the site/game name.
25
+ // Set tabTitle to null to show the real names again.
26
+ tabTitle: "Google Classroom",
27
+ tabIcon: "https://ssl.gstatic.com/classroom/favicon.png",
28
+
17
29
  // For local testing only: load games from a folder on a local server
18
30
  // instead of jsDelivr, e.g. "/repos". Leave null when published.
19
31
  localBase: null,