@ywkrlerche/richardwho 1.0.26 → 1.0.28
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 +712 -36
- package/config.js +20 -1
- package/index.html +124 -0
- package/package.json +1 -1
- package/style.css +88 -0
package/app.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
const CFG = window.RUG_CONFIG || {};
|
|
5
5
|
const SITE_NAME = "Richards Unblocked Games";
|
|
6
6
|
// The tab/history shows this instead of the real names (set in config.js)
|
|
7
|
-
const
|
|
8
|
-
const tabTitle = (gameName) => TAB_TITLE || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
|
|
7
|
+
const tabTitle = (gameName) => CFG.tabTitle || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
|
|
9
8
|
const RECENT_KEY = "rug-recent";
|
|
10
9
|
const CLOCK_KEY = "rug-clock24";
|
|
11
10
|
const THEME_KEY = "rug-theme";
|
|
@@ -223,6 +222,14 @@
|
|
|
223
222
|
};
|
|
224
223
|
|
|
225
224
|
try {
|
|
225
|
+
// Played before? Start straight from the browser's saved copy, no download
|
|
226
|
+
const cached = await gameCache.get(urls[0]);
|
|
227
|
+
if (cached && currentLoad?.id === game.id) {
|
|
228
|
+
els.barFill.style.width = "100%";
|
|
229
|
+
els.loaderStatus.textContent = "Starting (saved copy)…";
|
|
230
|
+
startGameHtml(game, await cached.text());
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
226
233
|
const pieces = await Promise.all(urls.map(async (url, i) => {
|
|
227
234
|
// Try the main link, then backups. jsDelivr sometimes fails the first fetch of a file it
|
|
228
235
|
// hasn't cached yet, and a download can drop mid-way, so a retry usually succeeds.
|
|
@@ -258,17 +265,9 @@
|
|
|
258
265
|
}));
|
|
259
266
|
if (currentLoad?.id !== game.id) return;
|
|
260
267
|
els.loaderStatus.textContent = "Starting…";
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
html = guardHistory(html);
|
|
265
|
-
const doc = freshFrame().contentDocument;
|
|
266
|
-
doc.open();
|
|
267
|
-
doc.write(html);
|
|
268
|
-
doc.close();
|
|
269
|
-
els.loader.hidden = true;
|
|
270
|
-
hookFrameKeys();
|
|
271
|
-
try { els.frame.contentWindow.focus(); } catch {}
|
|
268
|
+
const blob = new Blob(pieces.flat());
|
|
269
|
+
gameCache.put(urls[0], blob); // in the background; a full disk just means no saved copy
|
|
270
|
+
startGameHtml(game, await blob.text());
|
|
272
271
|
} catch (err) {
|
|
273
272
|
if (err.name === "AbortError") return;
|
|
274
273
|
console.error(err);
|
|
@@ -277,6 +276,52 @@
|
|
|
277
276
|
}
|
|
278
277
|
}
|
|
279
278
|
|
|
279
|
+
function startGameHtml(game, html) {
|
|
280
|
+
if (currentLoad?.id !== game.id) return;
|
|
281
|
+
if (game.src === "gn") html = stripTracking(html);
|
|
282
|
+
html = guardHistory(html);
|
|
283
|
+
const doc = freshFrame().contentDocument;
|
|
284
|
+
doc.open();
|
|
285
|
+
doc.write(html);
|
|
286
|
+
doc.close();
|
|
287
|
+
els.loader.hidden = true;
|
|
288
|
+
hookFrameKeys();
|
|
289
|
+
try { els.frame.contentWindow.focus(); } catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Saved copies of recently played games (browser Cache Storage), so replays start instantly.
|
|
293
|
+
// Keyed by the game's first file URL, which includes the version tag, so new versions don't reuse old copies.
|
|
294
|
+
const gameCache = {
|
|
295
|
+
NAME: "rug-games-v1",
|
|
296
|
+
KEEP: 12,
|
|
297
|
+
async get(key) {
|
|
298
|
+
try {
|
|
299
|
+
if (!window.caches) return null;
|
|
300
|
+
const hit = await (await caches.open(this.NAME)).match(key);
|
|
301
|
+
if (hit) this.touch(key);
|
|
302
|
+
return hit || null;
|
|
303
|
+
} catch { return null; }
|
|
304
|
+
},
|
|
305
|
+
async put(key, blob) {
|
|
306
|
+
try {
|
|
307
|
+
if (!window.caches) return;
|
|
308
|
+
await (await caches.open(this.NAME)).put(key, new Response(blob, { headers: { "Content-Type": "text/html" } }));
|
|
309
|
+
await this.touch(key);
|
|
310
|
+
} catch { /* out of space or not allowed: just skip saving */ }
|
|
311
|
+
},
|
|
312
|
+
async touch(key) {
|
|
313
|
+
// most-recent-first list; drop anything past KEEP
|
|
314
|
+
const list = store.get("rug-cache-list", []).filter((k) => k !== key);
|
|
315
|
+
list.unshift(key);
|
|
316
|
+
const drop = list.splice(this.KEEP);
|
|
317
|
+
store.set("rug-cache-list", list);
|
|
318
|
+
if (drop.length) {
|
|
319
|
+
const c = await caches.open(this.NAME);
|
|
320
|
+
await Promise.all(drop.map((k) => c.delete(k)));
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
280
325
|
// Backup sources for a jsDelivr GitHub link, in the order to try them:
|
|
281
326
|
// the same link again, jsDelivr's second network, then GitHub's own file server.
|
|
282
327
|
// The extra collection's files are also published as gn-math/html at the same commit.
|
|
@@ -304,11 +349,12 @@
|
|
|
304
349
|
// or pushState() would move the whole tab (and kick the player out). Give games no-op versions first.
|
|
305
350
|
const HISTORY_GUARD = "<script>(function(){try{var h=window.history,n=function(){};" +
|
|
306
351
|
"h.back=h.forward=h.go=h.pushState=h.replaceState=n;}catch(e){}})();<\/script>";
|
|
352
|
+
// Insert only at the very top of the file (after an optional doctype and <html> tag). Searching for the
|
|
353
|
+
// first "<head>" anywhere is unsafe: many self-unpacking games keep their whole page inside a script string,
|
|
354
|
+
// and inserting there cuts that string in half.
|
|
307
355
|
function guardHistory(html) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (/^\s*<!doctype[^>]*>/i.test(html)) return html.replace(/^\s*<!doctype[^>]*>/i, (m) => m + HISTORY_GUARD);
|
|
311
|
-
return HISTORY_GUARD + html;
|
|
356
|
+
const top = html.match(/^(?\s*(?:<!--[\s\S]*?-->\s*)*(?:<!doctype[^>]*>)?\s*(?:<html\b[^>]*>)?)/i)[1];
|
|
357
|
+
return top + HISTORY_GUARD + html.slice(top.length);
|
|
312
358
|
}
|
|
313
359
|
|
|
314
360
|
function stopGame() {
|
|
@@ -339,16 +385,25 @@
|
|
|
339
385
|
els.player.hidden = false;
|
|
340
386
|
document.body.classList.add("playing");
|
|
341
387
|
addRecent(game.id);
|
|
342
|
-
if (currentLoad?.id !== game.id)
|
|
388
|
+
if (currentLoad?.id !== game.id) {
|
|
389
|
+
loadGame(game);
|
|
390
|
+
track(`Play: ${game.name}`, { name: "play_game", params: { game_name: game.name, game_category: game.tag } });
|
|
391
|
+
}
|
|
343
392
|
return;
|
|
344
393
|
}
|
|
345
394
|
stopGame();
|
|
346
395
|
els.player.hidden = true;
|
|
347
396
|
document.body.classList.remove("playing");
|
|
348
397
|
document.title = tabTitle();
|
|
349
|
-
const
|
|
398
|
+
const h = location.hash;
|
|
399
|
+
const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : "home";
|
|
350
400
|
els.homeView.hidden = view !== "home";
|
|
351
401
|
els.gamesView.hidden = view !== "games";
|
|
402
|
+
$("view-settings").hidden = view !== "settings";
|
|
403
|
+
$("view-chat").hidden = view !== "chat";
|
|
404
|
+
if (view === "settings") { renderSettings(); renderAdmin(); }
|
|
405
|
+
if (view === "chat") openChat();
|
|
406
|
+
track(view[0].toUpperCase() + view.slice(1));
|
|
352
407
|
document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
|
|
353
408
|
b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
|
|
354
409
|
if (view === "games") renderRecent();
|
|
@@ -373,40 +428,523 @@
|
|
|
373
428
|
try { window.top.location.replace(url); } catch { location.replace(url); }
|
|
374
429
|
}
|
|
375
430
|
|
|
431
|
+
// The dice button opens the wheel (filtered to the current category on the games page)
|
|
376
432
|
function randomGame() {
|
|
377
433
|
const pool = !els.gamesView.hidden && activeTag !== "All" ? games.filter((g) => g.tag === activeTag) : games;
|
|
378
|
-
if (pool.length)
|
|
434
|
+
if (pool.length) openWheel(pool);
|
|
379
435
|
}
|
|
380
436
|
|
|
437
|
+
let clockIs24 = false, clockTick = () => {};
|
|
438
|
+
function setClock24(on) { clockIs24 = on; store.set(CLOCK_KEY, on); clockTick(); }
|
|
381
439
|
function startClock() {
|
|
382
|
-
|
|
440
|
+
clockIs24 = store.get(CLOCK_KEY, false);
|
|
383
441
|
const timeEl = $("clock-time"), metaEl = $("clock-meta");
|
|
384
|
-
|
|
442
|
+
clockTick = () => {
|
|
385
443
|
const d = new Date();
|
|
386
444
|
let h = d.getHours();
|
|
387
445
|
const m = String(d.getMinutes()).padStart(2, "0");
|
|
388
|
-
if (
|
|
446
|
+
if (clockIs24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
|
|
389
447
|
else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}`; metaEl.textContent = ap; }
|
|
390
448
|
};
|
|
391
|
-
$("clock").addEventListener("click", () =>
|
|
392
|
-
|
|
393
|
-
setInterval(
|
|
449
|
+
$("clock").addEventListener("click", () => setClock24(!clockIs24));
|
|
450
|
+
clockTick();
|
|
451
|
+
setInterval(clockTick, 1000);
|
|
394
452
|
}
|
|
395
453
|
|
|
454
|
+
function applyTheme(t) {
|
|
455
|
+
document.documentElement.dataset.theme = t;
|
|
456
|
+
$("theme-btn").title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
457
|
+
try { localStorage.setItem(THEME_KEY, t); } catch {}
|
|
458
|
+
}
|
|
396
459
|
function initTheme() {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
document.documentElement.dataset.theme
|
|
400
|
-
|
|
460
|
+
applyTheme(document.documentElement.dataset.theme === "light" ? "light" : "dark");
|
|
461
|
+
$("theme-btn").addEventListener("click", () =>
|
|
462
|
+
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- Settings (saved in this browser) ----------
|
|
466
|
+
const SETTINGS_KEY = "rug-settings";
|
|
467
|
+
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>");
|
|
468
|
+
const DISGUISES = [
|
|
469
|
+
{ id: "classroom", label: "Google Classroom", title: "Google Classroom", icon: "https://ssl.gstatic.com/classroom/favicon.png" },
|
|
470
|
+
{ id: "docs", label: "Google Docs", title: "Untitled document - Google Docs", icon: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico" },
|
|
471
|
+
{ id: "drive", label: "Google Drive", title: "My Drive - Google Drive", icon: "https://ssl.gstatic.com/images/branding/product/1x/drive_2020q4_32dp.png" },
|
|
472
|
+
{ id: "gmail", label: "Gmail", title: "Inbox - Gmail", icon: "https://ssl.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" },
|
|
473
|
+
{ id: "google", label: "Google", title: "Google", icon: "https://www.google.com/favicon.ico" },
|
|
474
|
+
{ id: "canvas", label: "Canvas", title: "Dashboard", icon: "https://du11hjcvx0uqb.cloudfront.net/dist/images/favicon-e10d657a73.ico" },
|
|
475
|
+
{ id: "off", label: "Off (real name)", title: null, icon: REAL_ICON },
|
|
476
|
+
];
|
|
477
|
+
const DEFAULT_PANIC_KEYS = CFG.panicKeys || ["`", "]"];
|
|
478
|
+
const DEFAULT_PANIC_URL = CFG.panicUrl || "https://classroom.google.com/";
|
|
479
|
+
let settings = {};
|
|
480
|
+
let listeningForKey = false;
|
|
481
|
+
|
|
482
|
+
function loadSettings() {
|
|
483
|
+
settings = { disguise: "classroom", typing: true, boot: true, ...store.get(SETTINGS_KEY, {}) };
|
|
484
|
+
applySettings();
|
|
485
|
+
}
|
|
486
|
+
function saveSettings() {
|
|
487
|
+
const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
|
|
488
|
+
// tabTitle/tabIcon are copied in so index.html can apply the disguise before the page finishes loading
|
|
489
|
+
store.set(SETTINGS_KEY, { ...settings, tabTitle: d.title, tabIcon: d.icon });
|
|
490
|
+
applySettings();
|
|
491
|
+
}
|
|
492
|
+
function applySettings() {
|
|
493
|
+
const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
|
|
494
|
+
CFG.tabTitle = d.title;
|
|
495
|
+
$("tab-icon").href = d.icon;
|
|
496
|
+
CFG.panicUrl = settings.panicUrl || DEFAULT_PANIC_URL;
|
|
497
|
+
CFG.panicKeys = settings.panicKey ? [settings.panicKey] : DEFAULT_PANIC_KEYS;
|
|
498
|
+
document.title = tabTitle(els.player.hidden ? "" : els.playerTitle.textContent);
|
|
499
|
+
const keys = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
|
|
500
|
+
$("panic-btn").title = `Panic (${keys})`;
|
|
501
|
+
document.querySelector(".corner-links span").innerHTML = `${CFG.panicKeys.map((k) => `<kbd>${escapeHtml(k === " " ? "Space" : k)}</kbd>`).join(" or ")} panic`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function renderSettings() {
|
|
505
|
+
$("disguises").innerHTML = DISGUISES.map((d) => `<button class="disguise" type="button" data-disguise="${d.id}" aria-pressed="${settings.disguise === d.id}">
|
|
506
|
+
${d.id === "off" ? `<span class="none-icon">🚫</span>` : `<img src="${d.icon}" alt="">`}<span>${escapeHtml(d.label)}</span></button>`).join("");
|
|
507
|
+
const preset = [...$("set-panic-url").options].some((o) => o.value === CFG.panicUrl);
|
|
508
|
+
$("set-panic-url").value = preset ? CFG.panicUrl : "custom";
|
|
509
|
+
$("set-panic-custom-row").hidden = preset;
|
|
510
|
+
$("set-panic-custom").value = preset ? "" : CFG.panicUrl;
|
|
511
|
+
$("set-panic-key").textContent = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
|
|
512
|
+
$("set-theme").value = document.documentElement.dataset.theme;
|
|
513
|
+
$("set-clock").value = clockIs24 ? "24" : "12";
|
|
514
|
+
$("set-per-row").value = String(store.get(PER_ROW_KEY, 6));
|
|
515
|
+
$("set-typing").checked = settings.typing !== false;
|
|
516
|
+
$("set-boot").checked = settings.boot !== false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function bindSettings() {
|
|
520
|
+
$("disguises").addEventListener("click", (e) => {
|
|
521
|
+
const b = e.target.closest("[data-disguise]");
|
|
522
|
+
if (!b) return;
|
|
523
|
+
settings.disguise = b.dataset.disguise;
|
|
524
|
+
saveSettings();
|
|
525
|
+
renderSettings();
|
|
526
|
+
});
|
|
527
|
+
$("set-panic-url").addEventListener("change", (e) => {
|
|
528
|
+
const custom = e.target.value === "custom";
|
|
529
|
+
$("set-panic-custom-row").hidden = !custom;
|
|
530
|
+
if (!custom) { settings.panicUrl = e.target.value; saveSettings(); } else $("set-panic-custom").focus();
|
|
531
|
+
});
|
|
532
|
+
$("set-panic-custom").addEventListener("change", (e) => {
|
|
533
|
+
let url = e.target.value.trim();
|
|
534
|
+
if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
|
|
535
|
+
if (url) { settings.panicUrl = url; e.target.value = url; saveSettings(); }
|
|
536
|
+
});
|
|
537
|
+
const keyBtn = $("set-panic-key");
|
|
538
|
+
keyBtn.addEventListener("click", () => {
|
|
539
|
+
listeningForKey = true;
|
|
540
|
+
keyBtn.classList.add("listening");
|
|
541
|
+
keyBtn.textContent = "Press a key…";
|
|
542
|
+
$("set-panic-key-hint").textContent = "Esc to cancel";
|
|
543
|
+
});
|
|
544
|
+
document.addEventListener("keydown", (e) => {
|
|
545
|
+
if (!listeningForKey) return;
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
e.stopImmediatePropagation();
|
|
548
|
+
listeningForKey = false;
|
|
549
|
+
keyBtn.classList.remove("listening");
|
|
550
|
+
$("set-panic-key-hint").textContent = "Click, then press any key";
|
|
551
|
+
if (e.key !== "Escape" && e.key.length === 1) { settings.panicKey = e.key; saveSettings(); }
|
|
552
|
+
renderSettings();
|
|
553
|
+
}, true);
|
|
554
|
+
$("set-theme").addEventListener("change", (e) => applyTheme(e.target.value));
|
|
555
|
+
$("set-clock").addEventListener("change", (e) => setClock24(e.target.value === "24"));
|
|
556
|
+
$("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
|
|
557
|
+
$("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
|
|
558
|
+
$("set-boot").addEventListener("change", (e) => { settings.boot = e.target.checked; saveSettings(); });
|
|
559
|
+
$("set-clear-recent").addEventListener("click", () => {
|
|
560
|
+
store.set(RECENT_KEY, []);
|
|
561
|
+
renderRecent();
|
|
562
|
+
$("set-clear-recent").textContent = "Cleared ✓";
|
|
563
|
+
setTimeout(() => { $("set-clear-recent").textContent = "Clear"; }, 1500);
|
|
564
|
+
});
|
|
565
|
+
$("set-reset").addEventListener("click", () => {
|
|
566
|
+
settings = { disguise: "classroom", typing: true, boot: true };
|
|
567
|
+
saveSettings();
|
|
568
|
+
applyTheme("dark");
|
|
569
|
+
setClock24(false);
|
|
570
|
+
setPerRow(6);
|
|
571
|
+
startTypingPlaceholder();
|
|
572
|
+
renderSettings();
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------- Random game wheel ----------
|
|
577
|
+
const WHEEL_COLORS = ["#2563eb", "#7c3aed", "#db2777", "#ea580c", "#16a34a", "#0891b2"];
|
|
578
|
+
const wheel = { items: [], rot: 0, from: 0, target: 0, start: 0, dur: 5200, raf: 0, winner: null, spinning: false };
|
|
579
|
+
function drawWheel() {
|
|
580
|
+
const c = $("wheel"), ctx = c.getContext("2d");
|
|
581
|
+
const R = c.width / 2, n = wheel.items.length, a = (Math.PI * 2) / n;
|
|
582
|
+
ctx.clearRect(0, 0, c.width, c.height);
|
|
583
|
+
for (let i = 0; i < n; i++) {
|
|
584
|
+
const s = wheel.rot + i * a;
|
|
585
|
+
ctx.beginPath();
|
|
586
|
+
ctx.moveTo(R, R);
|
|
587
|
+
ctx.arc(R, R, R - 6, s, s + a);
|
|
588
|
+
ctx.closePath();
|
|
589
|
+
ctx.fillStyle = WHEEL_COLORS[i % WHEEL_COLORS.length];
|
|
590
|
+
ctx.fill();
|
|
591
|
+
ctx.strokeStyle = "rgba(0,0,0,.35)";
|
|
592
|
+
ctx.lineWidth = 3;
|
|
593
|
+
ctx.stroke();
|
|
594
|
+
ctx.save();
|
|
595
|
+
ctx.translate(R, R);
|
|
596
|
+
ctx.rotate(s + a / 2);
|
|
597
|
+
ctx.textAlign = "right";
|
|
598
|
+
ctx.textBaseline = "middle";
|
|
599
|
+
ctx.fillStyle = "#fff";
|
|
600
|
+
ctx.font = `700 ${n > 10 ? 22 : 26}px Inter, Segoe UI, Arial, sans-serif`;
|
|
601
|
+
let name = wheel.items[i].name;
|
|
602
|
+
if (name.length > 20) name = name.slice(0, 19) + "…";
|
|
603
|
+
ctx.fillText(name, R - 26, 0);
|
|
604
|
+
ctx.restore();
|
|
605
|
+
}
|
|
606
|
+
ctx.beginPath();
|
|
607
|
+
ctx.arc(R, R, 46, 0, Math.PI * 2);
|
|
608
|
+
ctx.fillStyle = "#0b0b0f";
|
|
609
|
+
ctx.fill();
|
|
610
|
+
ctx.lineWidth = 6;
|
|
611
|
+
ctx.strokeStyle = "#fff";
|
|
612
|
+
ctx.stroke();
|
|
613
|
+
}
|
|
614
|
+
function spinWheel(pool) {
|
|
615
|
+
const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
|
|
616
|
+
wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
|
|
617
|
+
const n = wheel.items.length, a = (Math.PI * 2) / n;
|
|
618
|
+
const win = Math.floor(Math.random() * n);
|
|
619
|
+
wheel.winner = wheel.items[win];
|
|
620
|
+
// Land the winner's slice center under the pointer (top = -90°), after 6+ full turns
|
|
621
|
+
const base = -Math.PI / 2 - (win + 0.5) * a + (Math.random() - 0.5) * a * 0.6;
|
|
622
|
+
wheel.from = wheel.rot;
|
|
623
|
+
wheel.target = base + Math.PI * 2 * Math.ceil((wheel.from + Math.PI * 12 - base) / (Math.PI * 2));
|
|
624
|
+
wheel.start = performance.now();
|
|
625
|
+
wheel.spinning = true;
|
|
626
|
+
$("wheel-result").hidden = true;
|
|
627
|
+
$("wheel-play").hidden = true;
|
|
628
|
+
$("wheel-again").hidden = true;
|
|
629
|
+
$("wheel-skip").hidden = false;
|
|
630
|
+
cancelAnimationFrame(wheel.raf);
|
|
631
|
+
const step = (t) => {
|
|
632
|
+
const p = Math.min(1, (t - wheel.start) / wheel.dur);
|
|
633
|
+
const ease = 1 - Math.pow(1 - p, 4); // fast start, long slow finish
|
|
634
|
+
wheel.rot = wheel.from + (wheel.target - wheel.from) * ease;
|
|
635
|
+
drawWheel();
|
|
636
|
+
if (p < 1) wheel.raf = requestAnimationFrame(step);
|
|
637
|
+
else finishWheel();
|
|
401
638
|
};
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
639
|
+
wheel.raf = requestAnimationFrame(step);
|
|
640
|
+
}
|
|
641
|
+
function finishWheel() {
|
|
642
|
+
cancelAnimationFrame(wheel.raf);
|
|
643
|
+
wheel.rot = wheel.target;
|
|
644
|
+
wheel.spinning = false;
|
|
645
|
+
drawWheel();
|
|
646
|
+
const g = wheel.winner, url = logoUrl(g);
|
|
647
|
+
$("wheel-result-name").textContent = g.name;
|
|
648
|
+
const art = $("wheel-result-art");
|
|
649
|
+
art.style.backgroundImage = url ? `url("${url}"), ${gradient(g)}` : gradient(g);
|
|
650
|
+
art.textContent = url ? "" : initials(g.name);
|
|
651
|
+
$("wheel-result").hidden = false;
|
|
652
|
+
$("wheel-skip").hidden = true;
|
|
653
|
+
$("wheel-again").hidden = false;
|
|
654
|
+
$("wheel-play").hidden = false;
|
|
655
|
+
$("wheel-play").focus();
|
|
656
|
+
}
|
|
657
|
+
let wheelPool = [];
|
|
658
|
+
function openWheel(pool) {
|
|
659
|
+
wheelPool = pool;
|
|
660
|
+
$("wheel-wrap").hidden = false;
|
|
661
|
+
spinWheel(pool);
|
|
662
|
+
}
|
|
663
|
+
function closeWheel() {
|
|
664
|
+
cancelAnimationFrame(wheel.raf);
|
|
665
|
+
wheel.spinning = false;
|
|
666
|
+
$("wheel-wrap").hidden = true;
|
|
667
|
+
}
|
|
668
|
+
function bindWheel() {
|
|
669
|
+
$("wheel-skip").addEventListener("click", finishWheel);
|
|
670
|
+
$("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
|
|
671
|
+
$("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); play(g.id); });
|
|
672
|
+
$("wheel-close").addEventListener("click", closeWheel);
|
|
673
|
+
$("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------- Google Analytics (only when a measurement ID is set in config.js) ----------
|
|
677
|
+
// The tab title is disguised, so pages and games are reported by their real names instead.
|
|
678
|
+
function track(page, extra) {
|
|
679
|
+
if (!window.gtag) return;
|
|
680
|
+
window.gtag("event", "page_view", { page_title: page, page_location: location.href.split("#")[0] + (location.hash || "#home") });
|
|
681
|
+
if (extra) window.gtag("event", extra.name, extra.params);
|
|
682
|
+
}
|
|
683
|
+
function startAnalytics() {
|
|
684
|
+
const id = CFG.googleAnalytics;
|
|
685
|
+
if (!id || !/^G-[A-Z0-9]+$/i.test(id)) return;
|
|
686
|
+
window.dataLayer = window.dataLayer || [];
|
|
687
|
+
window.gtag = function () { window.dataLayer.push(arguments); };
|
|
688
|
+
window.gtag("js", new Date());
|
|
689
|
+
// We send page views ourselves (see track); no ad features
|
|
690
|
+
window.gtag("config", id, { send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false });
|
|
691
|
+
loadScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`).catch(() => {});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---------- Live "people online" count ----------
|
|
695
|
+
// Needs a free Firebase Realtime Database (see SETUP.md). Without one, the count stays hidden.
|
|
696
|
+
function loadScript(src) {
|
|
697
|
+
return new Promise((ok, fail) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = fail; document.head.appendChild(s); });
|
|
698
|
+
}
|
|
699
|
+
// One shared Firebase connection for the online count and the chat (null when not configured)
|
|
700
|
+
let firebaseApp = null;
|
|
701
|
+
function getFirebase() {
|
|
702
|
+
if (!CFG.firebase || !CFG.firebase.databaseURL) return Promise.resolve(null);
|
|
703
|
+
if (!firebaseApp) {
|
|
704
|
+
firebaseApp = (async () => {
|
|
705
|
+
const v = "10.12.2";
|
|
706
|
+
await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
|
|
707
|
+
await Promise.all([
|
|
708
|
+
loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`),
|
|
709
|
+
loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-auth-compat.js`),
|
|
710
|
+
]);
|
|
711
|
+
return window.firebase.initializeApp(CFG.firebase, "rug");
|
|
712
|
+
})().catch((err) => { console.warn("Firebase unavailable:", err); return null; });
|
|
713
|
+
}
|
|
714
|
+
return firebaseApp;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function startPresence() {
|
|
718
|
+
try {
|
|
719
|
+
const app = await getFirebase();
|
|
720
|
+
if (!app) return;
|
|
721
|
+
const db = app.database();
|
|
722
|
+
const me = db.ref("online").push();
|
|
723
|
+
db.ref(".info/connected").on("value", (snap) => {
|
|
724
|
+
if (!snap.val()) return;
|
|
725
|
+
me.onDisconnect().remove();
|
|
726
|
+
me.set(window.firebase.database.ServerValue.TIMESTAMP);
|
|
727
|
+
});
|
|
728
|
+
db.ref("online").on("value", (snap) => {
|
|
729
|
+
const n = snap.numChildren();
|
|
730
|
+
$("online-count").textContent = n <= 1 ? "Just you here right now" : `${n.toLocaleString()} people on right now`;
|
|
731
|
+
$("online").hidden = false;
|
|
732
|
+
});
|
|
733
|
+
} catch (err) {
|
|
734
|
+
console.warn("Online count unavailable:", err);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------- Censored chat room ----------
|
|
739
|
+
// Two layers: this filter (stars out swears, refuses links/personal info) and the database's own
|
|
740
|
+
// rules (see SETUP.md), which reject the same things even if someone skips this page's code.
|
|
741
|
+
const BAD_ANYWHERE = ["fuck", "shit", "bitch", "cunt", "nigg", "faggot", "fagot", "whore", "slut", "pussy",
|
|
742
|
+
"penis", "vagina", "porn", "retard", "asshole", "bastard", "molest", "pedo", "dildo", "boob", "nazi", "hitler",
|
|
743
|
+
"kill yourself", "killyourself"];
|
|
744
|
+
const BAD_WHOLE_WORD = ["ass", "fag", "cum", "tit", "tits", "hoe", "kys", "sex", "nig", "dick", "cock", "rape",
|
|
745
|
+
"raped", "rapist", "raping", "damn", "piss", "crap", "wank", "twat", "prick"];
|
|
746
|
+
const LEET = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t", 8: "b", "@": "a", $: "s", "!": "i", "|": "i" };
|
|
747
|
+
// Real words that happen to contain a filtered one
|
|
748
|
+
const SAFE_WORDS = ["scunthorpe", "shiitake", "cockpit", "peacock", "hancock", "dickens", "assassin", "cocktail", "therapist"];
|
|
749
|
+
const plain = (s) => s.toLowerCase().replace(/[0134578@$!|]/g, (c) => LEET[c] || c).replace(/[^a-z ]/g, "");
|
|
750
|
+
const squash = (s) => plain(s).replace(/(.)\1+/g, "$1"); // "fuuuck" -> "fuck"
|
|
751
|
+
function isBadWord(word) {
|
|
752
|
+
const p = plain(word).replace(/ /g, ""), w = squash(word).replace(/ /g, "");
|
|
753
|
+
if (!w || SAFE_WORDS.includes(p)) return false;
|
|
754
|
+
// whole-word list: check before and after squashing, since squashing turns "ass" into "as"
|
|
755
|
+
return BAD_WHOLE_WORD.includes(p) || BAD_WHOLE_WORD.includes(w) ||
|
|
756
|
+
BAD_ANYWHERE.some((b) => w.includes(squash(b).replace(/ /g, "")));
|
|
757
|
+
}
|
|
758
|
+
// Returns { text } (with swears starred out) or { error } when the message can't be sent at all
|
|
759
|
+
function cleanMessage(raw) {
|
|
760
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
761
|
+
if (!text) return { error: "Type a message first." };
|
|
762
|
+
if (/(https?:|www\.|discord\.gg|\b[a-z0-9-]+\.(com|net|org|io|gg|xyz|co|me|tv|ly|app|dev|link)\b)/i.test(text)) return { error: "Links aren't allowed in chat." };
|
|
763
|
+
if (/\S+@\S+\.\S+/.test(text)) return { error: "Don't share email addresses." };
|
|
764
|
+
if (/(\d[\s().-]*){7,}/.test(text)) return { error: "Don't share phone numbers." };
|
|
765
|
+
const masked = text.split(" ").map((w) => (isBadWord(w) ? "*".repeat(w.length) : w)).join(" ");
|
|
766
|
+
// spaced-out tricks like "f u c k" survive word-by-word checks; catch them on the whole line
|
|
767
|
+
const joined = squash(masked.split(" ").filter((w) => !SAFE_WORDS.includes(plain(w))).join(" ")).replace(/ /g, "");
|
|
768
|
+
if (BAD_ANYWHERE.some((b) => joined.includes(squash(b).replace(/ /g, "")))) return { error: "That message was blocked." };
|
|
769
|
+
return { text: masked };
|
|
770
|
+
}
|
|
771
|
+
function cleanName(raw) {
|
|
772
|
+
// Refuse (don't silently strip) other characters: a wrong or not-yet-saved admin code like "Code123!"
|
|
773
|
+
// must never be posted as a nickname
|
|
774
|
+
if (/[^\w .-]/.test(raw)) return { error: "Nicknames can only use letters, numbers, spaces, . _ and -." };
|
|
775
|
+
const name = raw.replace(/\s+/g, " ").trim().slice(0, 16);
|
|
776
|
+
if (name.length < 2) return { error: "Pick a nickname (2–16 letters)." };
|
|
777
|
+
if (name.split(/[ ._-]/).some(isBadWord) || isBadWord(name.replace(/[ ._-]/g, ""))) return { error: "Pick a different nickname." };
|
|
778
|
+
return { name };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const chat = { started: false, uid: null, lastSent: 0, admin: false, triedName: "" };
|
|
782
|
+
const ADMIN_NAME = "Richard"; // what the admin's messages show as (reserved: the database refuses it from non-admins)
|
|
783
|
+
function setAdminName() {
|
|
784
|
+
$("chat-name").value = ADMIN_NAME;
|
|
785
|
+
$("chat-name").readOnly = !!chat.admin;
|
|
786
|
+
}
|
|
787
|
+
function chatNote(text, err) { const n = $("chat-note"); n.textContent = text || ""; n.classList.toggle("err", !!err); }
|
|
788
|
+
|
|
789
|
+
// Who is this browser? Reuses a saved sign-in (Google for admins), otherwise signs in anonymously:
|
|
790
|
+
// no account, just a random per-browser id used for rate limits and bans.
|
|
791
|
+
async function ensureUser(app) {
|
|
792
|
+
const auth = app.auth();
|
|
793
|
+
const existing = await new Promise((ok) => { const off = auth.onAuthStateChanged((u) => { off(); ok(u); }); });
|
|
794
|
+
const user = existing || (await auth.signInAnonymously()).user;
|
|
795
|
+
chat.uid = user.uid;
|
|
796
|
+
// Admins are listed under chat/admins in the database (only editable from the Firebase console)
|
|
797
|
+
// The entry holds the admin code (the database only accepts the right one), so any entry means admin.
|
|
798
|
+
// If the code is changed later, old entries stop working on the database side.
|
|
799
|
+
try { chat.admin = (await app.database().ref(`chat/admins/${user.uid}`).once("value")).val() !== null; } catch { chat.admin = false; }
|
|
800
|
+
$("chat-log").classList.toggle("admin", chat.admin);
|
|
801
|
+
return user;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function openChat() {
|
|
805
|
+
if (chat.started) return;
|
|
806
|
+
const app = await getFirebase();
|
|
807
|
+
if (!app) { $("chat-off").hidden = false; $("chat-log").hidden = true; $("chat-form").hidden = true; return; }
|
|
808
|
+
chat.started = true;
|
|
809
|
+
$("chat-form").hidden = false;
|
|
810
|
+
$("chat-name").value = store.get("rug-chat-name", "");
|
|
811
|
+
chatNote("Connecting…");
|
|
812
|
+
try {
|
|
813
|
+
await ensureUser(app);
|
|
814
|
+
} catch (err) {
|
|
815
|
+
chatNote("Chat couldn't connect. Try again later.", true);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (chat.admin) setAdminName();
|
|
819
|
+
chatNote(chat.admin ? "You're admin: 🗑 deletes a message, 🚫 bans its sender." : "");
|
|
820
|
+
const log = $("chat-log");
|
|
821
|
+
log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
|
|
822
|
+
if (chat.ref) chat.ref.off(); // reconnecting (signed in/out): drop the old listener so messages don't double
|
|
823
|
+
const ref = chat.ref = app.database().ref("chat/messages").orderByChild("ts").limitToLast(60);
|
|
824
|
+
ref.on("child_added", (snap) => {
|
|
825
|
+
const m = snap.val();
|
|
826
|
+
if (!m || typeof m.text !== "string") return;
|
|
827
|
+
$("chat-empty")?.remove();
|
|
828
|
+
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
|
829
|
+
const t = new Date(m.ts || Date.now());
|
|
830
|
+
const div = document.createElement("div");
|
|
831
|
+
div.className = "chat-msg" + (m.uid === chat.uid ? " me" : "");
|
|
832
|
+
div.dataset.key = snap.key;
|
|
833
|
+
div.dataset.uid = m.uid || "";
|
|
834
|
+
// filter again on display, in case anything slipped past older rules
|
|
835
|
+
const shown = cleanMessage(m.text).text || "[removed]";
|
|
836
|
+
div.innerHTML = `${m.admin ? `<span class="badge-admin">⭐ ADMIN</span>` : ""}<span class="who">${escapeHtml(m.name || "?")}</span>${escapeHtml(shown)}<span class="when">${t.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}</span>` +
|
|
837
|
+
`<button class="mod" type="button" data-mod="delete" title="Delete message">🗑</button><button class="mod" type="button" data-mod="ban" title="Ban this person">🚫</button>`;
|
|
838
|
+
log.appendChild(div);
|
|
839
|
+
while (log.children.length > 80) log.firstChild.remove();
|
|
840
|
+
if (nearBottom || m.uid === chat.uid) log.scrollTop = log.scrollHeight;
|
|
841
|
+
});
|
|
842
|
+
// Deleted by an admin: disappears for everyone
|
|
843
|
+
ref.on("child_removed", (snap) => {
|
|
844
|
+
log.querySelector(`[data-key="${CSS.escape(snap.key)}"]`)?.remove();
|
|
845
|
+
if (!log.querySelector(".chat-msg")) log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
|
|
407
846
|
});
|
|
408
847
|
}
|
|
409
848
|
|
|
849
|
+
// ---------- Admin tools (chat) ----------
|
|
850
|
+
async function adminAction(e) {
|
|
851
|
+
const btn = e.target.closest("[data-mod]");
|
|
852
|
+
if (!btn || !chat.admin) return;
|
|
853
|
+
const row = btn.closest(".chat-msg");
|
|
854
|
+
const db = (await getFirebase()).database();
|
|
855
|
+
try {
|
|
856
|
+
if (btn.dataset.mod === "delete") {
|
|
857
|
+
await db.ref(`chat/messages/${row.dataset.key}`).remove();
|
|
858
|
+
} else if (btn.dataset.mod === "ban" && row.dataset.uid && row.dataset.uid !== chat.uid) {
|
|
859
|
+
if (!confirm("Ban this person from chat? They'll still be able to read.")) return;
|
|
860
|
+
await db.ref(`chat/banned/${row.dataset.uid}`).set(true);
|
|
861
|
+
chatNote("Banned. Their old messages stay until you delete them.");
|
|
862
|
+
}
|
|
863
|
+
} catch {
|
|
864
|
+
chatNote("That didn't work. Are you still signed in as an admin?", true);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function renderAdmin() {
|
|
869
|
+
const app = await getFirebase();
|
|
870
|
+
$("admin-group").hidden = !app;
|
|
871
|
+
if (!app) return;
|
|
872
|
+
await ensureUser(app);
|
|
873
|
+
$("admin-status").textContent = chat.admin ? "Admin on this browser ✓" : "Not an admin on this browser";
|
|
874
|
+
$("admin-leave").hidden = !chat.admin;
|
|
875
|
+
$("admin-tools-row").hidden = !chat.admin;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// "#code" as a nickname: the database compares it with the secret code (which visitors can't read).
|
|
879
|
+
// Returns true if this browser is now admin.
|
|
880
|
+
async function claimAdmin(code) {
|
|
881
|
+
const app = await getFirebase();
|
|
882
|
+
try {
|
|
883
|
+
await app.database().ref(`chat/admins/${chat.uid}`).set(code);
|
|
884
|
+
} catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
chat.admin = true;
|
|
888
|
+
$("chat-log").classList.add("admin");
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function bindAdmin() {
|
|
893
|
+
$("chat-log").addEventListener("click", adminAction);
|
|
894
|
+
$("admin-leave").addEventListener("click", async () => {
|
|
895
|
+
try { await (await getFirebase()).database().ref(`chat/admins/${chat.uid}`).remove(); } catch {}
|
|
896
|
+
chat.admin = false;
|
|
897
|
+
chat.triedName = "";
|
|
898
|
+
$("chat-log").classList.remove("admin");
|
|
899
|
+
$("chat-name").readOnly = false;
|
|
900
|
+
$("chat-name").value = store.get("rug-chat-name", "");
|
|
901
|
+
await renderAdmin();
|
|
902
|
+
});
|
|
903
|
+
$("admin-clear").addEventListener("click", async () => {
|
|
904
|
+
if (!chat.admin || !confirm("Delete every chat message for everyone?")) return;
|
|
905
|
+
try { await (await getFirebase()).database().ref("chat/messages").remove(); }
|
|
906
|
+
catch { alert("Couldn't clear the chat."); }
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
async function sendChat(e) {
|
|
910
|
+
e.preventDefault();
|
|
911
|
+
const app = await getFirebase();
|
|
912
|
+
if (!app || !chat.uid) return;
|
|
913
|
+
// Typing the admin code as your nickname makes this browser admin (the database checks it;
|
|
914
|
+
// the code itself is never posted). Each new nickname is tried once.
|
|
915
|
+
const rawName = $("chat-name").value.trim();
|
|
916
|
+
if (!chat.admin && rawName && rawName !== chat.triedName) {
|
|
917
|
+
chat.triedName = rawName;
|
|
918
|
+
if (await claimAdmin(rawName)) {
|
|
919
|
+
setAdminName();
|
|
920
|
+
chatNote("You're admin now ✓ Your messages show as Richard, with 🗑 delete and 🚫 ban on every message.");
|
|
921
|
+
if (!$("chat-text").value.trim()) return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const n = chat.admin ? { name: ADMIN_NAME } : cleanName(rawName);
|
|
925
|
+
if (n.error) { chatNote(n.error, true); $("chat-name").focus(); return; }
|
|
926
|
+
if (!chat.admin && n.name.toLowerCase() === ADMIN_NAME.toLowerCase()) { chatNote("That name is taken.", true); return; }
|
|
927
|
+
const m = cleanMessage($("chat-text").value);
|
|
928
|
+
if (m.error) { chatNote(m.error, true); return; }
|
|
929
|
+
const wait = 3000 - (Date.now() - chat.lastSent);
|
|
930
|
+
if (wait > 0) { chatNote(`Slow down. Wait ${Math.ceil(wait / 1000)}s.`, true); return; }
|
|
931
|
+
if (!chat.admin) store.set("rug-chat-name", n.name); // never save the admin code as a remembered name
|
|
932
|
+
const db = app.database();
|
|
933
|
+
const key = db.ref("chat/messages").push().key;
|
|
934
|
+
const TS = window.firebase.database.ServerValue.TIMESTAMP;
|
|
935
|
+
chat.lastSent = Date.now();
|
|
936
|
+
try {
|
|
937
|
+
await db.ref().update({
|
|
938
|
+
[`chat/messages/${key}`]: { name: n.name, text: m.text, ts: TS, uid: chat.uid, ...(chat.admin ? { admin: true } : {}) },
|
|
939
|
+
[`chat/last/${chat.uid}`]: TS,
|
|
940
|
+
});
|
|
941
|
+
$("chat-text").value = "";
|
|
942
|
+
chatNote("");
|
|
943
|
+
} catch {
|
|
944
|
+
chatNote("Message blocked (filtered word, or sending too fast).", true);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
410
948
|
// ---------- Arrow keys and controller move focus between tiles and buttons ----------
|
|
411
949
|
function focusables() {
|
|
412
950
|
const scope = els.player.hidden ? document : els.player;
|
|
@@ -435,8 +973,14 @@
|
|
|
435
973
|
}
|
|
436
974
|
|
|
437
975
|
function onKey(e) {
|
|
976
|
+
if (listeningForKey) return; // the Settings page is capturing a new panic key
|
|
438
977
|
const keys = CFG.panicKeys || ["`", "]"];
|
|
439
978
|
if (keys.includes(e.key)) { e.preventDefault(); panic(); return; }
|
|
979
|
+
if (!$("wheel-wrap").hidden) {
|
|
980
|
+
if (e.key === "Escape") closeWheel();
|
|
981
|
+
else if (e.key === " " && wheel.spinning) { e.preventDefault(); finishWheel(); } // space skips
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
440
984
|
if (!els.player.hidden) {
|
|
441
985
|
if (e.key === "Escape" && document.activeElement !== els.frame) goBack();
|
|
442
986
|
return;
|
|
@@ -532,12 +1076,133 @@
|
|
|
532
1076
|
window.addEventListener("popstate", route);
|
|
533
1077
|
}
|
|
534
1078
|
|
|
1079
|
+
// ---------- Home search: placeholder that types itself ----------
|
|
1080
|
+
let timer = 0; // shared, so restarting (from Settings) replaces the running animation
|
|
1081
|
+
function startTypingPlaceholder() {
|
|
1082
|
+
clearTimeout(timer);
|
|
1083
|
+
const input = els.homeInput;
|
|
1084
|
+
const short = games.filter((g) => g.name.length <= 24); // long names get cut off in the box
|
|
1085
|
+
const pick = () => short[Math.floor(Math.random() * short.length)].name;
|
|
1086
|
+
const fixed = [
|
|
1087
|
+
() => `Search ${games.length} games…`, () => `Try "${pick()}"`, () => "What are we playing today?",
|
|
1088
|
+
() => `Try "${pick()}"`, () => "Type a game, any game…", () => `How about "${pick()}"?`,
|
|
1089
|
+
() => "Racing, horror, rhythm…", () => `Try "${pick()}"`, () => "Press / to search from anywhere",
|
|
1090
|
+
];
|
|
1091
|
+
if (!games.length) return;
|
|
1092
|
+
if (settings.typing === false || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
1093
|
+
input.placeholder = fixed[0]();
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
let line = 0, text = fixed[0](), shown = 0, deleting = false;
|
|
1097
|
+
const busy = () => document.activeElement === input || input.value;
|
|
1098
|
+
function tick() {
|
|
1099
|
+
if (busy()) { input.placeholder = `Search ${games.length} games…`; timer = setTimeout(tick, 400); return; }
|
|
1100
|
+
if (!deleting) {
|
|
1101
|
+
shown++;
|
|
1102
|
+
input.placeholder = text.slice(0, shown) + "▍";
|
|
1103
|
+
if (shown >= text.length) { deleting = true; input.placeholder = text; timer = setTimeout(tick, 1800); return; }
|
|
1104
|
+
timer = setTimeout(tick, 45 + Math.random() * 60); // uneven, like a real person typing
|
|
1105
|
+
} else {
|
|
1106
|
+
shown--;
|
|
1107
|
+
input.placeholder = text.slice(0, shown) + "▍";
|
|
1108
|
+
if (shown <= 0) {
|
|
1109
|
+
deleting = false;
|
|
1110
|
+
line = (line + 1) % fixed.length;
|
|
1111
|
+
text = fixed[line]();
|
|
1112
|
+
timer = setTimeout(tick, 350);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
timer = setTimeout(tick, 22);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
tick();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ---------- Boot screen + auto-update ----------
|
|
1122
|
+
const BOOT_LINES = [
|
|
1123
|
+
"Loading… about 3 hours left", "Downloading more RAM…", "Calculating time left: 7 years…",
|
|
1124
|
+
"Loading 99%… still 99%…", "Estimated time: yes", "Skill issue detected, loading anyway…",
|
|
1125
|
+
"Installing 47 browser toolbars…", "Running on 3 megabytes and a dream…", "Buffering… (it's not the wifi, it's you)",
|
|
1126
|
+
"Spawning in…", "Respawning in 3… 2… 9…", "Rendering grass you'll never touch…",
|
|
1127
|
+
"Loading the loading screen…", "Downloading lag…", "Speedrunning the loading bar…",
|
|
1128
|
+
"Turning it off and on again…", "Searching for the any key…", "Patching bugs, adding new ones…",
|
|
1129
|
+
];
|
|
1130
|
+
// Football jokes are mixed in, but never two in a row
|
|
1131
|
+
const TEAM_LINES = [
|
|
1132
|
+
"The Cowboys suck. Loading anyway…", "Loading… unlike the Cowboys in January",
|
|
1133
|
+
"This bar has more wins than the Cowboys…", "Giants are good. Loading respectfully…",
|
|
1134
|
+
"Loading at Giants speed (fast, obviously)…", "Buffering like a Browns offense…",
|
|
1135
|
+
"Searching for a Browns quarterback… still searching…", "Eagles fans booing the loading bar…",
|
|
1136
|
+
"Loading… quieter than Philly after a loss", "Steelers: 8-9 and somehow still loading…",
|
|
1137
|
+
"Loading with a Steelers-level 'we'll get 'em next year'…", "Ravens up big… loading the 4th-quarter collapse…",
|
|
1138
|
+
"Loading faster than a Ravens playoff exit…",
|
|
1139
|
+
];
|
|
1140
|
+
const boot = { el: $("boot"), fill: $("boot-fill"), msg: $("boot-msg"), ver: $("boot-ver"), timer: 0, pct: 0 };
|
|
1141
|
+
function startBoot() {
|
|
1142
|
+
const used = new Set();
|
|
1143
|
+
let lastWasTeam = false;
|
|
1144
|
+
const next = () => {
|
|
1145
|
+
// ~1 in 3 lines is a team joke, but never right after another one
|
|
1146
|
+
const team = !lastWasTeam && Math.random() < 0.35;
|
|
1147
|
+
const pool = (team ? TEAM_LINES : BOOT_LINES).filter((l) => !used.has(l));
|
|
1148
|
+
const line = pool[Math.floor(Math.random() * pool.length)] || BOOT_LINES[0];
|
|
1149
|
+
used.add(line);
|
|
1150
|
+
lastWasTeam = team;
|
|
1151
|
+
return line;
|
|
1152
|
+
};
|
|
1153
|
+
boot.msg.textContent = next();
|
|
1154
|
+
boot.timer = setInterval(() => {
|
|
1155
|
+
boot.msg.textContent = next();
|
|
1156
|
+
boot.pct = Math.min(90, boot.pct + 8 + Math.random() * 14); // creeps up, saves the last bit for "done"
|
|
1157
|
+
boot.fill.style.width = `${boot.pct}%`;
|
|
1158
|
+
}, 420);
|
|
1159
|
+
}
|
|
1160
|
+
function finishBoot(text) {
|
|
1161
|
+
clearInterval(boot.timer);
|
|
1162
|
+
boot.fill.style.width = "100%";
|
|
1163
|
+
if (text) boot.msg.textContent = text;
|
|
1164
|
+
setTimeout(() => boot.el.classList.add("done"), 250);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// When opened from a versioned unpkg link (e.g. an old bookmark), jump to the newest version.
|
|
1168
|
+
// Returns the URL to move to, or null to stay. Never waits longer than `ms`.
|
|
1169
|
+
async function newerVersionUrl(ms) {
|
|
1170
|
+
const pkg = CFG.npmPackage || "@ywkrlerche/richardwho";
|
|
1171
|
+
const here = location.pathname.match(/@(\d+)\.(\d+)\.(\d+)\//);
|
|
1172
|
+
if (location.hostname !== "unpkg.com" || !here) return null;
|
|
1173
|
+
try {
|
|
1174
|
+
const ctrl = new AbortController();
|
|
1175
|
+
const t = setTimeout(() => ctrl.abort(), ms);
|
|
1176
|
+
const res = await fetch(`https://unpkg.com/${pkg}/package.json?t=${Date.now()}`, { cache: "no-store", signal: ctrl.signal });
|
|
1177
|
+
clearTimeout(t);
|
|
1178
|
+
const latest = (await res.json()).version;
|
|
1179
|
+
const a = here.slice(1).map(Number), b = latest.split(".").map(Number);
|
|
1180
|
+
const newer = b[0] > a[0] || (b[0] === a[0] && (b[1] > a[1] || (b[1] === a[1] && b[2] > a[2])));
|
|
1181
|
+
boot.ver.textContent = newer ? `v${a.join(".")} → v${latest}` : `v${latest}`;
|
|
1182
|
+
return newer ? `https://unpkg.com/${pkg}@${latest}/index.html${location.hash}` : null;
|
|
1183
|
+
} catch {
|
|
1184
|
+
return null; // offline or slow: just open this version
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
535
1188
|
async function init() {
|
|
1189
|
+
loadSettings();
|
|
1190
|
+
if (settings.boot !== false) startBoot();
|
|
1191
|
+
else boot.el.classList.add("done"); // loading screen turned off in Settings
|
|
536
1192
|
initTheme();
|
|
537
1193
|
startClock();
|
|
538
1194
|
startGamepad();
|
|
539
1195
|
setPerRow(store.get(PER_ROW_KEY, 6));
|
|
540
1196
|
bind();
|
|
1197
|
+
bindSettings();
|
|
1198
|
+
bindWheel();
|
|
1199
|
+
$("chat-form").addEventListener("submit", sendChat);
|
|
1200
|
+
bindAdmin();
|
|
1201
|
+
startAnalytics();
|
|
1202
|
+
startPresence();
|
|
1203
|
+
// long enough to read a joke or two (skipped when the loading screen is off)
|
|
1204
|
+
const minShow = new Promise((r) => setTimeout(r, settings.boot !== false ? 1500 : 0));
|
|
1205
|
+
const update = newerVersionUrl(2500);
|
|
541
1206
|
try {
|
|
542
1207
|
const res = await fetch("games.json", { cache: "no-cache" });
|
|
543
1208
|
const data = await res.json();
|
|
@@ -546,9 +1211,19 @@
|
|
|
546
1211
|
} catch (err) {
|
|
547
1212
|
console.error(err);
|
|
548
1213
|
els.homeSub.textContent = "Couldn't load the game list. Try refreshing.";
|
|
1214
|
+
finishBoot("Couldn't load the game list. Try refreshing.");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const target = await update;
|
|
1218
|
+
if (target) {
|
|
1219
|
+
clearInterval(boot.timer);
|
|
1220
|
+
boot.msg.textContent = "Found a newer version, updating…";
|
|
1221
|
+
boot.fill.style.width = "100%";
|
|
1222
|
+
location.replace(target);
|
|
549
1223
|
return;
|
|
550
1224
|
}
|
|
551
|
-
|
|
1225
|
+
await minShow;
|
|
1226
|
+
startTypingPlaceholder();
|
|
552
1227
|
els.homeSub.innerHTML = `${games.length} games · press <kbd>/</kbd> to search`;
|
|
553
1228
|
els.footerCount.textContent = `${games.length} games`;
|
|
554
1229
|
renderChips();
|
|
@@ -556,6 +1231,7 @@
|
|
|
556
1231
|
renderGrid();
|
|
557
1232
|
startMarquee();
|
|
558
1233
|
route();
|
|
1234
|
+
finishBoot("Done");
|
|
559
1235
|
}
|
|
560
1236
|
|
|
561
1237
|
init();
|
package/config.js
CHANGED
|
@@ -6,7 +6,7 @@ window.RUG_CONFIG = {
|
|
|
6
6
|
|
|
7
7
|
// Branch the games live on. jsDelivr caches branches for up to 12h;
|
|
8
8
|
// use a tag like "v1" here if you want instant, permanent URLs.
|
|
9
|
-
branch: "
|
|
9
|
+
branch: "v1", // a fixed tag on every rug-games repo: jsDelivr caches tags for a year (branches only 12h)
|
|
10
10
|
|
|
11
11
|
// Where the panic button sends you
|
|
12
12
|
panicUrl: "https://classroom.google.com/",
|
|
@@ -14,6 +14,25 @@ window.RUG_CONFIG = {
|
|
|
14
14
|
// Keys that trigger panic (KeyboardEvent.key values)
|
|
15
15
|
panicKeys: ["`", "]"],
|
|
16
16
|
|
|
17
|
+
// Google Analytics 4 measurement ID, e.g. "G-ABC123XYZ" (from analytics.google.com).
|
|
18
|
+
// Leave null to turn analytics off. Shows visitors, live users and top games in YOUR GA dashboard.
|
|
19
|
+
googleAnalytics: "G-6VWCQ493PZ", // property "richardsunblockedgames"
|
|
20
|
+
|
|
21
|
+
// Live "people online" count on the home page and the chat room. Paste your Firebase web config here
|
|
22
|
+
// (see SETUP.md). Leave null to hide the count.
|
|
23
|
+
firebase: {
|
|
24
|
+
apiKey: "AIzaSyBxVxBFMwlSLx5FGP-Wna3ozHGZEAGXSKs",
|
|
25
|
+
authDomain: "richardwho-e8f22.firebaseapp.com",
|
|
26
|
+
databaseURL: "https://richardwho-e8f22-default-rtdb.firebaseio.com",
|
|
27
|
+
projectId: "richardwho-e8f22",
|
|
28
|
+
storageBucket: "richardwho-e8f22.firebasestorage.app",
|
|
29
|
+
messagingSenderId: "991096262738",
|
|
30
|
+
appId: "1:991096262738:web:8b6460399396051ee6cf8b",
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// npm package the site is published as (used to find the newest version)
|
|
34
|
+
npmPackage: "@ywkrlerche/richardwho",
|
|
35
|
+
|
|
17
36
|
// What the browser tab and history show instead of the site/game name.
|
|
18
37
|
// Set tabTitle to null to show the real names again.
|
|
19
38
|
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
|
|
|
@@ -31,11 +46,17 @@
|
|
|
31
46
|
<button class="rail-btn" type="button" id="random-btn" title="Random game" aria-label="Random game">
|
|
32
47
|
<svg viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm2.5 3a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM7.5 15a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
|
|
33
48
|
</button>
|
|
49
|
+
<button class="rail-btn" type="button" data-nav="chat" title="Chat" aria-label="Chat">
|
|
50
|
+
<svg viewBox="0 0 24 24"><path d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H9l-5 4v-4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm3 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
|
|
51
|
+
</button>
|
|
34
52
|
<button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
|
|
35
53
|
<svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 1 1-8.95 10h2.02A7 7 0 1 0 13 5a6.98 6.98 0 0 0-4.94 2.06L10 9H4V3l2.64 2.64A8.97 8.97 0 0 1 13 3Zm-1 5h2v4.6l3.2 1.9-1 1.7L12 13.7V8Z"/></svg>
|
|
36
54
|
</button>
|
|
37
55
|
</div>
|
|
38
56
|
<div class="rail-group bottom">
|
|
57
|
+
<button class="rail-btn" type="button" data-nav="settings" title="Settings" aria-label="Settings">
|
|
58
|
+
<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>
|
|
59
|
+
</button>
|
|
39
60
|
<button class="rail-btn" type="button" id="theme-btn" title="Toggle theme" aria-label="Toggle theme">
|
|
40
61
|
<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
62
|
</button>
|
|
@@ -59,6 +80,7 @@
|
|
|
59
80
|
<span class="sweep" aria-hidden="true"></span>
|
|
60
81
|
</form>
|
|
61
82
|
<p class="home-sub" id="home-sub"></p>
|
|
83
|
+
<p class="online" id="online" hidden><span class="online-dot"></span><span id="online-count"></span></p>
|
|
62
84
|
</section>
|
|
63
85
|
|
|
64
86
|
<!-- GAMES -->
|
|
@@ -109,8 +131,110 @@
|
|
|
109
131
|
<p class="empty" id="empty" hidden>No games match that search.</p>
|
|
110
132
|
</section>
|
|
111
133
|
</section>
|
|
134
|
+
|
|
135
|
+
<!-- CHAT -->
|
|
136
|
+
<section id="view-chat" class="view chat" hidden>
|
|
137
|
+
<header class="games-head"><div><h1>Chat</h1><p class="lede">Be nice. Messages are filtered, and links and personal info are blocked.</p></div></header>
|
|
138
|
+
<div class="chat-box">
|
|
139
|
+
<div class="chat-off" id="chat-off" hidden>
|
|
140
|
+
<p><strong>Chat isn't switched on yet.</strong></p>
|
|
141
|
+
<p>It needs the site's free Firebase database (see SETUP.md).</p>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="chat-log" id="chat-log" aria-live="polite"></div>
|
|
144
|
+
<form class="chat-form" id="chat-form" autocomplete="off" hidden>
|
|
145
|
+
<input id="chat-name" class="chat-name" maxlength="16" placeholder="Nickname" spellcheck="false" aria-label="Nickname">
|
|
146
|
+
<input id="chat-text" class="chat-text" maxlength="200" placeholder="Say something…" aria-label="Message">
|
|
147
|
+
<button class="pill-btn" type="submit" id="chat-send">Send</button>
|
|
148
|
+
</form>
|
|
149
|
+
<p class="chat-note" id="chat-note"></p>
|
|
150
|
+
</div>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
153
|
+
<!-- SETTINGS -->
|
|
154
|
+
<section id="view-settings" class="view settings" hidden>
|
|
155
|
+
<header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
|
|
156
|
+
|
|
157
|
+
<div class="set-group">
|
|
158
|
+
<h2>Tab disguise</h2>
|
|
159
|
+
<p class="set-help">What the browser tab and your history show instead of the site's name.</p>
|
|
160
|
+
<div class="disguises" id="disguises"></div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="set-group">
|
|
164
|
+
<h2>Panic button</h2>
|
|
165
|
+
<label class="set-row"><span>Goes to</span>
|
|
166
|
+
<select id="set-panic-url">
|
|
167
|
+
<option value="https://classroom.google.com/">Google Classroom</option>
|
|
168
|
+
<option value="https://docs.google.com/">Google Docs</option>
|
|
169
|
+
<option value="https://drive.google.com/">Google Drive</option>
|
|
170
|
+
<option value="https://www.google.com/">Google</option>
|
|
171
|
+
<option value="custom">Custom website…</option>
|
|
172
|
+
</select>
|
|
173
|
+
</label>
|
|
174
|
+
<label class="set-row" id="set-panic-custom-row" hidden><span>Custom website</span>
|
|
175
|
+
<input id="set-panic-custom" type="url" placeholder="https://…" spellcheck="false">
|
|
176
|
+
</label>
|
|
177
|
+
<div class="set-row"><span>Panic key</span>
|
|
178
|
+
<button class="pill-btn key-btn" type="button" id="set-panic-key">`</button>
|
|
179
|
+
<small class="set-hint" id="set-panic-key-hint">Click, then press any key</small>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="set-group">
|
|
184
|
+
<h2>Look & feel</h2>
|
|
185
|
+
<label class="set-row"><span>Theme</span>
|
|
186
|
+
<select id="set-theme"><option value="dark">Dark</option><option value="light">Light</option></select>
|
|
187
|
+
</label>
|
|
188
|
+
<label class="set-row"><span>Clock</span>
|
|
189
|
+
<select id="set-clock"><option value="12">12-hour</option><option value="24">24-hour</option></select>
|
|
190
|
+
</label>
|
|
191
|
+
<label class="set-row"><span>Games per row</span>
|
|
192
|
+
<select id="set-per-row"><option>5</option><option>6</option><option>7</option><option>8</option></select>
|
|
193
|
+
</label>
|
|
194
|
+
<label class="set-row"><span>Typing search box</span><input type="checkbox" id="set-typing"></label>
|
|
195
|
+
<label class="set-row"><span>Funny loading screen</span><input type="checkbox" id="set-boot"></label>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div class="set-group" id="admin-group" hidden>
|
|
199
|
+
<h2>Chat admin</h2>
|
|
200
|
+
<p class="set-help">Admins can delete chat messages and ban people. To become admin, type the admin code as your chat nickname and press Send. Your messages then show as Richard.</p>
|
|
201
|
+
<div class="set-row"><span id="admin-status">Not an admin on this browser</span>
|
|
202
|
+
<button class="pill-btn" type="button" id="admin-leave" hidden>Stop being admin here</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="set-row" id="admin-tools-row" hidden><span>Chat</span>
|
|
205
|
+
<button class="pill-btn danger" type="button" id="admin-clear">Clear all chat</button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="set-group">
|
|
210
|
+
<h2>Your data</h2>
|
|
211
|
+
<div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div>
|
|
212
|
+
<div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
|
|
213
|
+
</div>
|
|
214
|
+
</section>
|
|
112
215
|
</main>
|
|
113
216
|
|
|
217
|
+
<!-- RANDOM GAME WHEEL -->
|
|
218
|
+
<div class="wheel-wrap" id="wheel-wrap" hidden role="dialog" aria-label="Random game wheel">
|
|
219
|
+
<div class="wheel-card">
|
|
220
|
+
<button class="wheel-x" type="button" id="wheel-close" aria-label="Close">✕</button>
|
|
221
|
+
<h2>Spin for a game</h2>
|
|
222
|
+
<div class="wheel-stage">
|
|
223
|
+
<div class="wheel-pointer" aria-hidden="true"></div>
|
|
224
|
+
<canvas id="wheel" width="720" height="720"></canvas>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="wheel-result" id="wheel-result" hidden>
|
|
227
|
+
<div class="wheel-result-art" id="wheel-result-art"></div>
|
|
228
|
+
<div><p class="wheel-result-label">You got</p><p class="wheel-result-name" id="wheel-result-name"></p></div>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="wheel-actions">
|
|
231
|
+
<button class="pill-btn" type="button" id="wheel-skip">Skip ⏭</button>
|
|
232
|
+
<button class="pill-btn" type="button" id="wheel-again" hidden>Spin again</button>
|
|
233
|
+
<button class="pill-btn play" type="button" id="wheel-play" hidden>▶ Play</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
114
238
|
<!-- PLAYER -->
|
|
115
239
|
<section class="player" id="player" hidden aria-label="Game player">
|
|
116
240
|
<div class="player-bar">
|
package/package.json
CHANGED
package/style.css
CHANGED
|
@@ -45,6 +45,20 @@ button { cursor: pointer; }
|
|
|
45
45
|
kbd { font: 600 11px ui-monospace, Consolas, monospace; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); background: var(--soft); }
|
|
46
46
|
:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
|
47
47
|
|
|
48
|
+
/* ---------- Boot screen ---------- */
|
|
49
|
+
.boot {
|
|
50
|
+
position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
|
|
51
|
+
background: radial-gradient(60vw 30vh at 50% 30%, var(--glow-blue), transparent 70%), var(--bg);
|
|
52
|
+
transition: opacity .35s ease, visibility .35s;
|
|
53
|
+
}
|
|
54
|
+
.boot.done { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
55
|
+
.boot-inner { width: min(420px, 86vw); text-align: center; }
|
|
56
|
+
.boot-word { font-size: clamp(44px, 8vw, 72px); font-weight: 800; letter-spacing: -.055em; margin-bottom: 26px; }
|
|
57
|
+
.boot-bar { height: 3px; background: var(--soft-2); overflow: hidden; border-radius: 3px; }
|
|
58
|
+
.boot-fill { height: 100%; width: 0; background: var(--text); transition: width .25s ease; }
|
|
59
|
+
.boot-msg { margin: 14px 0 0; color: var(--muted); font-size: 13px; min-height: 1.4em; font-variant-numeric: tabular-nums; }
|
|
60
|
+
.boot-ver { margin: 6px 0 0; color: var(--muted); font-size: 11px; opacity: .6; min-height: 1.2em; }
|
|
61
|
+
|
|
48
62
|
/* ---------- Aurora glow ---------- */
|
|
49
63
|
.aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
|
50
64
|
.aurora span { position: absolute; border-radius: 50%; filter: blur(90px); }
|
|
@@ -179,6 +193,80 @@ 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
|
+
/* ---------- Chat ---------- */
|
|
225
|
+
.chat { padding-top: 26px; padding-bottom: 40px; max-width: 820px; }
|
|
226
|
+
.chat-box { border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--panel) 88%, transparent); display: flex; flex-direction: column; height: min(68vh, 640px); }
|
|
227
|
+
.chat-log { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
228
|
+
.chat-msg { font-size: 14px; line-height: 1.4; word-wrap: break-word; }
|
|
229
|
+
.chat-msg .who { font-weight: 700; margin-right: 6px; }
|
|
230
|
+
.chat-msg .when { color: var(--muted); font-size: 11px; margin-left: 6px; }
|
|
231
|
+
.chat-msg.me .who { text-decoration: underline; text-underline-offset: 3px; }
|
|
232
|
+
.chat-empty { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
|
|
233
|
+
.chat-form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
|
|
234
|
+
.chat-form input { height: 40px; padding: 0 12px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--bg); color: var(--text); }
|
|
235
|
+
.chat-name { width: 130px; flex: none; }
|
|
236
|
+
.chat-text { flex: 1; min-width: 0; }
|
|
237
|
+
.chat .pill-btn { color: var(--bg); background: var(--text); border-color: var(--text); height: 40px; padding: 0 18px; }
|
|
238
|
+
.chat-note { margin: 0; padding: 0 12px 10px; min-height: 1.3em; color: var(--muted); font-size: 12px; }
|
|
239
|
+
.chat-note.err { color: var(--danger); }
|
|
240
|
+
.chat-off { margin: auto; text-align: center; color: var(--muted); padding: 30px; }
|
|
241
|
+
.chat-off p { margin: 4px 0; }
|
|
242
|
+
.chat-msg .mod { display: none; margin-left: 8px; border: 1px solid var(--line-2); background: var(--soft); color: var(--text); border-radius: 3px; font-size: 11px; padding: 1px 6px; }
|
|
243
|
+
.chat-msg .mod:hover { background: color-mix(in srgb, var(--danger) 30%, transparent); }
|
|
244
|
+
.chat-log.admin .chat-msg .mod { display: inline-block; }
|
|
245
|
+
.chat-msg .badge-admin { color: #facc15; font-size: 11px; font-weight: 700; margin-right: 6px; }
|
|
246
|
+
.admin-id { font: 12px ui-monospace, Consolas, monospace; padding: 4px 8px; border: 1px solid var(--line-2); border-radius: 4px; user-select: all; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
|
247
|
+
@media (max-width: 720px) { .chat-name { width: 90px; } }
|
|
248
|
+
|
|
249
|
+
/* ---------- Random game wheel ---------- */
|
|
250
|
+
.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); }
|
|
251
|
+
.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); }
|
|
252
|
+
.wheel-card h2 { margin: 0 0 12px; font-size: 18px; }
|
|
253
|
+
.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; }
|
|
254
|
+
.wheel-x:hover { color: var(--text); background: var(--soft); }
|
|
255
|
+
.wheel-stage { position: relative; width: min(360px, 78vw); aspect-ratio: 1; margin: 0 auto; }
|
|
256
|
+
.wheel-stage canvas { width: 100%; height: 100%; display: block; }
|
|
257
|
+
.wheel-pointer {
|
|
258
|
+
position: absolute; left: 50%; top: -6px; z-index: 1; width: 0; height: 0; transform: translateX(-50%);
|
|
259
|
+
border-left: 13px solid transparent; border-right: 13px solid transparent; border-top: 24px solid var(--text);
|
|
260
|
+
filter: drop-shadow(0 2px 4px rgba(0,0,0,.6));
|
|
261
|
+
}
|
|
262
|
+
.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; }
|
|
263
|
+
.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; }
|
|
264
|
+
.wheel-result-label { margin: 0; color: var(--muted); font-size: 12px; }
|
|
265
|
+
.wheel-result-name { margin: 2px 0 0; font-size: 16px; font-weight: 800; }
|
|
266
|
+
.wheel-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
|
267
|
+
.wheel-card .pill-btn { color: var(--text); border-color: var(--line-2); background: var(--soft); height: 38px; padding: 0 18px; }
|
|
268
|
+
.wheel-card .pill-btn.play { background: var(--text); color: var(--bg); border-color: var(--text); }
|
|
269
|
+
|
|
182
270
|
/* ---------- Player ---------- */
|
|
183
271
|
.player { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column; background: #000; }
|
|
184
272
|
.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; }
|