@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 +406 -25
- package/config.js +12 -0
- package/games.json +1 -1
- package/index.html +94 -2
- package/package.json +1 -1
- package/style.css +63 -0
- package/logos/gm-test-game.png +0 -0
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 =
|
|
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
|
-
|
|
306
|
-
|
|
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 =
|
|
346
|
-
const
|
|
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)
|
|
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
|
-
|
|
388
|
+
clockIs24 = store.get(CLOCK_KEY, false);
|
|
380
389
|
const timeEl = $("clock-time"), metaEl = $("clock-meta");
|
|
381
|
-
|
|
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 (
|
|
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", () =>
|
|
389
|
-
|
|
390
|
-
setInterval(
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
document.documentElement.dataset.theme
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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,
|