@ywkrlerche/richardwho 1.0.27 → 1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -222,6 +222,14 @@
222
222
  };
223
223
 
224
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
+ }
225
233
  const pieces = await Promise.all(urls.map(async (url, i) => {
226
234
  // Try the main link, then backups. jsDelivr sometimes fails the first fetch of a file it
227
235
  // hasn't cached yet, and a download can drop mid-way, so a retry usually succeeds.
@@ -257,17 +265,9 @@
257
265
  }));
258
266
  if (currentLoad?.id !== game.id) return;
259
267
  els.loaderStatus.textContent = "Starting…";
260
- let html = await new Blob(pieces.flat()).text();
261
- if (currentLoad?.id !== game.id) return;
262
- if (game.src === "gn") html = stripTracking(html);
263
- html = guardHistory(html);
264
- const doc = freshFrame().contentDocument;
265
- doc.open();
266
- doc.write(html);
267
- doc.close();
268
- els.loader.hidden = true;
269
- hookFrameKeys();
270
- 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());
271
271
  } catch (err) {
272
272
  if (err.name === "AbortError") return;
273
273
  console.error(err);
@@ -276,6 +276,53 @@
276
276
  }
277
277
  }
278
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
+ stats.startPlay(game);
291
+ }
292
+
293
+ // Saved copies of recently played games (browser Cache Storage), so replays start instantly.
294
+ // Keyed by the game's first file URL, which includes the version tag, so new versions don't reuse old copies.
295
+ const gameCache = {
296
+ NAME: "rug-games-v1",
297
+ KEEP: 12,
298
+ async get(key) {
299
+ try {
300
+ if (!window.caches) return null;
301
+ const hit = await (await caches.open(this.NAME)).match(key);
302
+ if (hit) this.touch(key);
303
+ return hit || null;
304
+ } catch { return null; }
305
+ },
306
+ async put(key, blob) {
307
+ try {
308
+ if (!window.caches) return;
309
+ await (await caches.open(this.NAME)).put(key, new Response(blob, { headers: { "Content-Type": "text/html" } }));
310
+ await this.touch(key);
311
+ } catch { /* out of space or not allowed: just skip saving */ }
312
+ },
313
+ async touch(key) {
314
+ // most-recent-first list; drop anything past KEEP
315
+ const list = store.get("rug-cache-list", []).filter((k) => k !== key);
316
+ list.unshift(key);
317
+ const drop = list.splice(this.KEEP);
318
+ store.set("rug-cache-list", list);
319
+ if (drop.length) {
320
+ const c = await caches.open(this.NAME);
321
+ await Promise.all(drop.map((k) => c.delete(k)));
322
+ }
323
+ },
324
+ };
325
+
279
326
  // Backup sources for a jsDelivr GitHub link, in the order to try them:
280
327
  // the same link again, jsDelivr's second network, then GitHub's own file server.
281
328
  // The extra collection's files are also published as gn-math/html at the same commit.
@@ -312,6 +359,7 @@
312
359
  }
313
360
 
314
361
  function stopGame() {
362
+ stats.stopPlay();
315
363
  if (!currentLoad) return;
316
364
  currentLoad.controller.abort();
317
365
  currentLoad = null;
@@ -339,7 +387,10 @@
339
387
  els.player.hidden = false;
340
388
  document.body.classList.add("playing");
341
389
  addRecent(game.id);
342
- if (currentLoad?.id !== game.id) loadGame(game);
390
+ if (currentLoad?.id !== game.id) {
391
+ loadGame(game);
392
+ track(`Play: ${game.name}`, { name: "play_game", params: { game_name: game.name, game_category: game.tag } });
393
+ }
343
394
  return;
344
395
  }
345
396
  stopGame();
@@ -347,11 +398,16 @@
347
398
  document.body.classList.remove("playing");
348
399
  document.title = tabTitle();
349
400
  const h = location.hash;
350
- const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : "home";
401
+ const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : h === "#stats" ? "stats" : "home";
351
402
  els.homeView.hidden = view !== "home";
352
403
  els.gamesView.hidden = view !== "games";
353
404
  $("view-settings").hidden = view !== "settings";
354
- if (view === "settings") renderSettings();
405
+ $("view-chat").hidden = view !== "chat";
406
+ $("view-stats").hidden = view !== "stats";
407
+ if (view === "settings") { renderSettings(); renderAdmin(); }
408
+ if (view === "stats") renderStats();
409
+ if (view === "chat") openChat();
410
+ track(view[0].toUpperCase() + view.slice(1));
355
411
  document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
356
412
  b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
357
413
  if (view === "games") renderRecent();
@@ -410,6 +466,152 @@
410
466
  applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
411
467
  }
412
468
 
469
+ // ---------- Play stats + achievements (saved in this browser) ----------
470
+ // Time counts only while a game is open AND the tab is visible.
471
+ const STATS_KEY = "rug-stats";
472
+ const ACHIEVEMENTS = [
473
+ { id: "first", icon: "🎮", name: "First Steps", desc: "Play your first game", goal: 1, of: (s) => s.unique },
474
+ { id: "ten", icon: "🔟", name: "Warming Up", desc: "Play 10 different games", goal: 10, of: (s) => s.unique },
475
+ { id: "25", icon: "🧭", name: "Explorer", desc: "Play 25 different games", goal: 25, of: (s) => s.unique },
476
+ { id: "50", icon: "🗺️", name: "Adventurer", desc: "Play 50 different games", goal: 50, of: (s) => s.unique },
477
+ { id: "100", icon: "💯", name: "Collector", desc: "Play 100 different games", goal: 100, of: (s) => s.unique },
478
+ { id: "250", icon: "👑", name: "Game Royalty", desc: "Play 250 different games", goal: 250, of: (s) => s.unique },
479
+ { id: "hour", icon: "⏱️", name: "One More Game", desc: "Play for 1 hour in total", goal: 60, of: (s) => s.minutes, unit: "min" },
480
+ { id: "10h", icon: "🛋️", name: "Couch Legend", desc: "Play for 10 hours in total", goal: 600, of: (s) => s.minutes, unit: "min" },
481
+ { id: "hooked", icon: "🎣", name: "Hooked", desc: "Spend 30 minutes in one game", goal: 30, of: (s) => s.topMinutes, unit: "min" },
482
+ { id: "replay", icon: "🔁", name: "Comfort Game", desc: "Open the same game 10 times", goal: 10, of: (s) => s.topPlays },
483
+ { id: "racing", icon: "🏎️", name: "Speed Freak", desc: "Play 10 racing games", goal: 10, of: (s) => s.byTag.Racing || 0 },
484
+ { id: "racing-all", icon: "🏁", name: "Every Racetrack", desc: "Play every racing game", goal: () => tagTotal("Racing"), of: (s) => s.byTag.Racing || 0 },
485
+ { id: "horror", icon: "👻", name: "Brave Soul", desc: "Play 5 horror games", goal: 5, of: (s) => s.byTag.Horror || 0 },
486
+ { id: "rhythm", icon: "🎵", name: "Got Rhythm", desc: "Play 5 rhythm games", goal: 5, of: (s) => s.byTag.Rhythm || 0 },
487
+ { id: "papa", icon: "🍕", name: "Papa's Apprentice", desc: "Play every Papa's game", goal: () => papasTotal(), of: (s) => s.papas },
488
+ { id: "allcats", icon: "🌈", name: "Well-Rounded", desc: "Play a game from every category", goal: () => tagList().length, of: (s) => s.tags },
489
+ { id: "wheel", icon: "🎡", name: "Feeling Lucky", desc: "Play a game the wheel picked", goal: 1, of: (s) => s.wheelPlays || 0 },
490
+ { id: "spins", icon: "🌀", name: "Spin Doctor", desc: "Spin the wheel 25 times", goal: 25, of: (s) => s.wheelSpins || 0 },
491
+ { id: "chat", icon: "💬", name: "Chatterbox", desc: "Send 10 chat messages", goal: 10, of: (s) => s.chatSent || 0 },
492
+ { id: "night", icon: "🦉", name: "Night Owl", desc: "Play a game between midnight and 4 AM", goal: 1, of: (s) => (s.night ? 1 : 0) },
493
+ ];
494
+ const tagList = () => [...new Set(games.filter((g) => g.tag !== "Tools").map((g) => g.tag))];
495
+ const tagTotal = (tag) => games.filter((g) => g.tag === tag).length;
496
+ const isPapas = (g) => /^papa'?s /i.test(g.name);
497
+ const papasTotal = () => games.filter(isPapas).length;
498
+
499
+ const stats = {
500
+ data: null,
501
+ current: null, // { id, since } while a game is open and visible
502
+ load() {
503
+ if (!this.data) this.data = { games: {}, wheelSpins: 0, wheelPlays: 0, chatSent: 0, night: false, unlocked: {}, ...store.get(STATS_KEY, {}) };
504
+ return this.data;
505
+ },
506
+ save() { store.set(STATS_KEY, this.data); },
507
+ bump(key) { this.load()[key] = (this.data[key] || 0) + 1; this.save(); checkAchievements(); },
508
+ startPlay(game) {
509
+ this.stopPlay();
510
+ const d = this.load();
511
+ const rec = d.games[game.id] || (d.games[game.id] = { plays: 0, ms: 0 });
512
+ rec.plays++;
513
+ rec.last = Date.now();
514
+ const h = new Date().getHours();
515
+ if (h < 4) d.night = true;
516
+ this.save();
517
+ this.current = { id: game.id, since: document.hidden ? null : Date.now() };
518
+ checkAchievements();
519
+ },
520
+ // Add the time since the last check to the open game (called on stop, tab hide, and every 30s)
521
+ flush() {
522
+ const c = this.current;
523
+ if (!c || !c.since) return;
524
+ const rec = this.load().games[c.id];
525
+ if (rec) rec.ms += Math.min(Date.now() - c.since, 60 * 60 * 1000); // cap any single gap at an hour
526
+ c.since = document.hidden ? null : Date.now();
527
+ this.save();
528
+ },
529
+ stopPlay() { this.flush(); this.current = null; },
530
+ summary() {
531
+ const d = this.load();
532
+ const entries = Object.entries(d.games).filter(([id]) => byId.has(id));
533
+ const byTag = {};
534
+ let papas = 0, ms = 0, topMs = 0, topPlays = 0;
535
+ for (const [id, r] of entries) {
536
+ const g = byId.get(id);
537
+ byTag[g.tag] = (byTag[g.tag] || 0) + 1;
538
+ if (isPapas(g)) papas++;
539
+ ms += r.ms;
540
+ topMs = Math.max(topMs, r.ms);
541
+ topPlays = Math.max(topPlays, r.plays);
542
+ }
543
+ return {
544
+ ...d, unique: entries.length, plays: entries.reduce((a, [, r]) => a + r.plays, 0),
545
+ minutes: Math.floor(ms / 60000), topMinutes: Math.floor(topMs / 60000), topPlays, byTag, papas,
546
+ tags: tagList().filter((t) => byTag[t]).length,
547
+ top: entries.sort((a, b) => b[1].ms - a[1].ms || b[1].plays - a[1].plays).slice(0, 5),
548
+ };
549
+ },
550
+ };
551
+ document.addEventListener("visibilitychange", () => {
552
+ if (!stats.current) return;
553
+ if (document.hidden) stats.flush();
554
+ else stats.current.since = Date.now();
555
+ });
556
+ addEventListener("pagehide", () => stats.flush());
557
+ setInterval(() => { stats.flush(); checkAchievements(); }, 30000);
558
+
559
+ const goalOf = (a) => (typeof a.goal === "function" ? a.goal() : a.goal);
560
+ function checkAchievements() {
561
+ if (!games.length) return;
562
+ const s = stats.summary(), d = stats.data;
563
+ for (const a of ACHIEVEMENTS) {
564
+ if (d.unlocked[a.id]) continue;
565
+ const goal = goalOf(a);
566
+ if (goal > 0 && a.of(s) >= goal) {
567
+ d.unlocked[a.id] = Date.now();
568
+ stats.save();
569
+ toast(`${a.icon} Achievement unlocked: ${a.name}`);
570
+ }
571
+ }
572
+ if (!$("view-stats").hidden) renderStats();
573
+ }
574
+
575
+ let toastTimer = 0;
576
+ function toast(text) {
577
+ const t = $("toast");
578
+ t.textContent = text;
579
+ t.classList.add("show");
580
+ clearTimeout(toastTimer);
581
+ toastTimer = setTimeout(() => t.classList.remove("show"), 4000);
582
+ }
583
+
584
+ function fmtTime(min) {
585
+ if (min < 1) return "<1 min";
586
+ const h = Math.floor(min / 60), m = min % 60;
587
+ return h ? `${h}h ${m}m` : `${m}m`;
588
+ }
589
+ function renderStats() {
590
+ if (!games.length) return;
591
+ const s = stats.summary(), d = stats.data;
592
+ const favTag = Object.entries(s.byTag).sort((a, b) => b[1] - a[1])[0];
593
+ $("stat-unique").textContent = s.unique;
594
+ $("stat-plays").textContent = s.plays;
595
+ $("stat-time").textContent = s.plays ? fmtTime(s.minutes) : "0 min";
596
+ $("stat-fav").textContent = favTag ? `${TAG_EMOJI[favTag[0]] || ""} ${favTag[0]}` : "–";
597
+ $("stat-top").innerHTML = s.top.length
598
+ ? s.top.map(([id, r], i) => `<li><span>${i + 1}. ${escapeHtml(byId.get(id).name)}</span><span>${fmtTime(Math.floor(r.ms / 60000))} · ${r.plays} play${r.plays === 1 ? "" : "s"}</span></li>`).join("")
599
+ : `<li class="muted">Play some games and your favorites show up here.</li>`;
600
+ const done = ACHIEVEMENTS.filter((a) => d.unlocked[a.id]).length;
601
+ $("ach-count").textContent = `${done} of ${ACHIEVEMENTS.length} unlocked`;
602
+ $("achievements").innerHTML = ACHIEVEMENTS.map((a) => {
603
+ const goal = goalOf(a), have = Math.min(a.of(s), goal), got = !!d.unlocked[a.id];
604
+ const pct = goal ? Math.round((have / goal) * 100) : 0;
605
+ const prog = a.unit === "min" ? `${fmtTime(have)} / ${fmtTime(goal)}` : `${have} / ${goal}`;
606
+ return `<div class="ach${got ? " got" : ""}" title="${escapeHtml(a.desc)}">
607
+ <span class="ach-icon">${got ? a.icon : "🔒"}</span>
608
+ <div class="ach-body"><b>${escapeHtml(a.name)}</b><small>${escapeHtml(a.desc)}</small>
609
+ ${got ? `<small class="ach-done">Unlocked ${new Date(d.unlocked[a.id]).toLocaleDateString()}</small>`
610
+ : `<div class="ach-bar"><div style="width:${pct}%"></div></div><small>${prog}</small>`}
611
+ </div></div>`;
612
+ }).join("");
613
+ }
614
+
413
615
  // ---------- Settings (saved in this browser) ----------
414
616
  const SETTINGS_KEY = "rug-settings";
415
617
  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>");
@@ -504,6 +706,12 @@
504
706
  $("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
505
707
  $("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
506
708
  $("set-boot").addEventListener("change", (e) => { settings.boot = e.target.checked; saveSettings(); });
709
+ $("set-reset-stats").addEventListener("click", () => {
710
+ if (!confirm("Reset your stats and achievements on this device?")) return;
711
+ stats.data = null;
712
+ store.set(STATS_KEY, {});
713
+ renderStats();
714
+ });
507
715
  $("set-clear-recent").addEventListener("click", () => {
508
716
  store.set(RECENT_KEY, []);
509
717
  renderRecent();
@@ -560,6 +768,7 @@
560
768
  ctx.stroke();
561
769
  }
562
770
  function spinWheel(pool) {
771
+ stats.bump("wheelSpins");
563
772
  const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
564
773
  wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
565
774
  const n = wheel.items.length, a = (Math.PI * 2) / n;
@@ -616,23 +825,56 @@
616
825
  function bindWheel() {
617
826
  $("wheel-skip").addEventListener("click", finishWheel);
618
827
  $("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
619
- $("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); play(g.id); });
828
+ $("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); stats.bump("wheelPlays"); play(g.id); });
620
829
  $("wheel-close").addEventListener("click", closeWheel);
621
830
  $("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
622
831
  }
623
832
 
833
+ // ---------- Google Analytics (only when a measurement ID is set in config.js) ----------
834
+ // The tab title is disguised, so pages and games are reported by their real names instead.
835
+ function track(page, extra) {
836
+ if (!window.gtag) return;
837
+ window.gtag("event", "page_view", { page_title: page, page_location: location.href.split("#")[0] + (location.hash || "#home") });
838
+ if (extra) window.gtag("event", extra.name, extra.params);
839
+ }
840
+ function startAnalytics() {
841
+ const id = CFG.googleAnalytics;
842
+ if (!id || !/^G-[A-Z0-9]+$/i.test(id)) return;
843
+ window.dataLayer = window.dataLayer || [];
844
+ window.gtag = function () { window.dataLayer.push(arguments); };
845
+ window.gtag("js", new Date());
846
+ // We send page views ourselves (see track); no ad features
847
+ window.gtag("config", id, { send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false });
848
+ loadScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`).catch(() => {});
849
+ }
850
+
624
851
  // ---------- Live "people online" count ----------
625
852
  // Needs a free Firebase Realtime Database (see SETUP.md). Without one, the count stays hidden.
626
853
  function loadScript(src) {
627
854
  return new Promise((ok, fail) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = fail; document.head.appendChild(s); });
628
855
  }
856
+ // One shared Firebase connection for the online count and the chat (null when not configured)
857
+ let firebaseApp = null;
858
+ function getFirebase() {
859
+ if (!CFG.firebase || !CFG.firebase.databaseURL) return Promise.resolve(null);
860
+ if (!firebaseApp) {
861
+ firebaseApp = (async () => {
862
+ const v = "10.12.2";
863
+ await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
864
+ await Promise.all([
865
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`),
866
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-auth-compat.js`),
867
+ ]);
868
+ return window.firebase.initializeApp(CFG.firebase, "rug");
869
+ })().catch((err) => { console.warn("Firebase unavailable:", err); return null; });
870
+ }
871
+ return firebaseApp;
872
+ }
873
+
629
874
  async function startPresence() {
630
- if (!CFG.firebase || !CFG.firebase.databaseURL) return;
631
875
  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");
876
+ const app = await getFirebase();
877
+ if (!app) return;
636
878
  const db = app.database();
637
879
  const me = db.ref("online").push();
638
880
  db.ref(".info/connected").on("value", (snap) => {
@@ -650,6 +892,217 @@
650
892
  }
651
893
  }
652
894
 
895
+ // ---------- Censored chat room ----------
896
+ // Two layers: this filter (stars out swears, refuses links/personal info) and the database's own
897
+ // rules (see SETUP.md), which reject the same things even if someone skips this page's code.
898
+ const BAD_ANYWHERE = ["fuck", "shit", "bitch", "cunt", "nigg", "faggot", "fagot", "whore", "slut", "pussy",
899
+ "penis", "vagina", "porn", "retard", "asshole", "bastard", "molest", "pedo", "dildo", "boob", "nazi", "hitler",
900
+ "kill yourself", "killyourself"];
901
+ const BAD_WHOLE_WORD = ["ass", "fag", "cum", "tit", "tits", "hoe", "kys", "sex", "nig", "dick", "cock", "rape",
902
+ "raped", "rapist", "raping", "damn", "piss", "crap", "wank", "twat", "prick"];
903
+ const LEET = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t", 8: "b", "@": "a", $: "s", "!": "i", "|": "i" };
904
+ // Real words that happen to contain a filtered one
905
+ const SAFE_WORDS = ["scunthorpe", "shiitake", "cockpit", "peacock", "hancock", "dickens", "assassin", "cocktail", "therapist"];
906
+ const plain = (s) => s.toLowerCase().replace(/[0134578@$!|]/g, (c) => LEET[c] || c).replace(/[^a-z ]/g, "");
907
+ const squash = (s) => plain(s).replace(/(.)\1+/g, "$1"); // "fuuuck" -> "fuck"
908
+ function isBadWord(word) {
909
+ const p = plain(word).replace(/ /g, ""), w = squash(word).replace(/ /g, "");
910
+ if (!w || SAFE_WORDS.includes(p)) return false;
911
+ // whole-word list: check before and after squashing, since squashing turns "ass" into "as"
912
+ return BAD_WHOLE_WORD.includes(p) || BAD_WHOLE_WORD.includes(w) ||
913
+ BAD_ANYWHERE.some((b) => w.includes(squash(b).replace(/ /g, "")));
914
+ }
915
+ // Returns { text } (with swears starred out) or { error } when the message can't be sent at all
916
+ function cleanMessage(raw) {
917
+ const text = raw.replace(/\s+/g, " ").trim();
918
+ if (!text) return { error: "Type a message first." };
919
+ 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." };
920
+ if (/\S+@\S+\.\S+/.test(text)) return { error: "Don't share email addresses." };
921
+ if (/(\d[\s().-]*){7,}/.test(text)) return { error: "Don't share phone numbers." };
922
+ const masked = text.split(" ").map((w) => (isBadWord(w) ? "*".repeat(w.length) : w)).join(" ");
923
+ // spaced-out tricks like "f u c k" survive word-by-word checks; catch them on the whole line
924
+ const joined = squash(masked.split(" ").filter((w) => !SAFE_WORDS.includes(plain(w))).join(" ")).replace(/ /g, "");
925
+ if (BAD_ANYWHERE.some((b) => joined.includes(squash(b).replace(/ /g, "")))) return { error: "That message was blocked." };
926
+ return { text: masked };
927
+ }
928
+ function cleanName(raw) {
929
+ // Refuse (don't silently strip) other characters: a wrong or not-yet-saved admin code like "Code123!"
930
+ // must never be posted as a nickname
931
+ if (/[^\w .-]/.test(raw)) return { error: "Nicknames can only use letters, numbers, spaces, . _ and -." };
932
+ const name = raw.replace(/\s+/g, " ").trim().slice(0, 16);
933
+ if (name.length < 2) return { error: "Pick a nickname (2–16 letters)." };
934
+ if (name.split(/[ ._-]/).some(isBadWord) || isBadWord(name.replace(/[ ._-]/g, ""))) return { error: "Pick a different nickname." };
935
+ return { name };
936
+ }
937
+
938
+ const chat = { started: false, uid: null, lastSent: 0, admin: false, triedName: "" };
939
+ const ADMIN_NAME = "Richard"; // what the admin's messages show as (reserved: the database refuses it from non-admins)
940
+ function setAdminName() {
941
+ $("chat-name").value = ADMIN_NAME;
942
+ $("chat-name").readOnly = !!chat.admin;
943
+ }
944
+ function chatNote(text, err) { const n = $("chat-note"); n.textContent = text || ""; n.classList.toggle("err", !!err); }
945
+
946
+ // Who is this browser? Reuses a saved sign-in (Google for admins), otherwise signs in anonymously:
947
+ // no account, just a random per-browser id used for rate limits and bans.
948
+ async function ensureUser(app) {
949
+ const auth = app.auth();
950
+ const existing = await new Promise((ok) => { const off = auth.onAuthStateChanged((u) => { off(); ok(u); }); });
951
+ const user = existing || (await auth.signInAnonymously()).user;
952
+ chat.uid = user.uid;
953
+ // Admins are listed under chat/admins in the database (only editable from the Firebase console)
954
+ // The entry holds the admin code (the database only accepts the right one), so any entry means admin.
955
+ // If the code is changed later, old entries stop working on the database side.
956
+ try { chat.admin = (await app.database().ref(`chat/admins/${user.uid}`).once("value")).val() !== null; } catch { chat.admin = false; }
957
+ $("chat-log").classList.toggle("admin", chat.admin);
958
+ return user;
959
+ }
960
+
961
+ async function openChat() {
962
+ if (chat.started) return;
963
+ const app = await getFirebase();
964
+ if (!app) { $("chat-off").hidden = false; $("chat-log").hidden = true; $("chat-form").hidden = true; return; }
965
+ chat.started = true;
966
+ $("chat-form").hidden = false;
967
+ $("chat-name").value = store.get("rug-chat-name", "");
968
+ chatNote("Connecting…");
969
+ try {
970
+ await ensureUser(app);
971
+ } catch (err) {
972
+ chatNote("Chat couldn't connect. Try again later.", true);
973
+ return;
974
+ }
975
+ if (chat.admin) setAdminName();
976
+ chatNote(chat.admin ? "You're admin: 🗑 deletes a message, 🚫 bans its sender." : "");
977
+ const log = $("chat-log");
978
+ log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
979
+ if (chat.ref) chat.ref.off(); // reconnecting (signed in/out): drop the old listener so messages don't double
980
+ const ref = chat.ref = app.database().ref("chat/messages").orderByChild("ts").limitToLast(60);
981
+ ref.on("child_added", (snap) => {
982
+ const m = snap.val();
983
+ if (!m || typeof m.text !== "string") return;
984
+ $("chat-empty")?.remove();
985
+ const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
986
+ const t = new Date(m.ts || Date.now());
987
+ const div = document.createElement("div");
988
+ div.className = "chat-msg" + (m.uid === chat.uid ? " me" : "");
989
+ div.dataset.key = snap.key;
990
+ div.dataset.uid = m.uid || "";
991
+ // filter again on display, in case anything slipped past older rules
992
+ const shown = cleanMessage(m.text).text || "[removed]";
993
+ 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>` +
994
+ `<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>`;
995
+ log.appendChild(div);
996
+ while (log.children.length > 80) log.firstChild.remove();
997
+ if (nearBottom || m.uid === chat.uid) log.scrollTop = log.scrollHeight;
998
+ });
999
+ // Deleted by an admin: disappears for everyone
1000
+ ref.on("child_removed", (snap) => {
1001
+ log.querySelector(`[data-key="${CSS.escape(snap.key)}"]`)?.remove();
1002
+ if (!log.querySelector(".chat-msg")) log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
1003
+ });
1004
+ }
1005
+
1006
+ // ---------- Admin tools (chat) ----------
1007
+ async function adminAction(e) {
1008
+ const btn = e.target.closest("[data-mod]");
1009
+ if (!btn || !chat.admin) return;
1010
+ const row = btn.closest(".chat-msg");
1011
+ const db = (await getFirebase()).database();
1012
+ try {
1013
+ if (btn.dataset.mod === "delete") {
1014
+ await db.ref(`chat/messages/${row.dataset.key}`).remove();
1015
+ } else if (btn.dataset.mod === "ban" && row.dataset.uid && row.dataset.uid !== chat.uid) {
1016
+ if (!confirm("Ban this person from chat? They'll still be able to read.")) return;
1017
+ await db.ref(`chat/banned/${row.dataset.uid}`).set(true);
1018
+ chatNote("Banned. Their old messages stay until you delete them.");
1019
+ }
1020
+ } catch {
1021
+ chatNote("That didn't work. Are you still signed in as an admin?", true);
1022
+ }
1023
+ }
1024
+
1025
+ async function renderAdmin() {
1026
+ const app = await getFirebase();
1027
+ $("admin-group").hidden = !app;
1028
+ if (!app) return;
1029
+ await ensureUser(app);
1030
+ $("admin-status").textContent = chat.admin ? "Admin on this browser ✓" : "Not an admin on this browser";
1031
+ $("admin-leave").hidden = !chat.admin;
1032
+ $("admin-tools-row").hidden = !chat.admin;
1033
+ }
1034
+
1035
+ // "#code" as a nickname: the database compares it with the secret code (which visitors can't read).
1036
+ // Returns true if this browser is now admin.
1037
+ async function claimAdmin(code) {
1038
+ const app = await getFirebase();
1039
+ try {
1040
+ await app.database().ref(`chat/admins/${chat.uid}`).set(code);
1041
+ } catch {
1042
+ return false;
1043
+ }
1044
+ chat.admin = true;
1045
+ $("chat-log").classList.add("admin");
1046
+ return true;
1047
+ }
1048
+
1049
+ function bindAdmin() {
1050
+ $("chat-log").addEventListener("click", adminAction);
1051
+ $("admin-leave").addEventListener("click", async () => {
1052
+ try { await (await getFirebase()).database().ref(`chat/admins/${chat.uid}`).remove(); } catch {}
1053
+ chat.admin = false;
1054
+ chat.triedName = "";
1055
+ $("chat-log").classList.remove("admin");
1056
+ $("chat-name").readOnly = false;
1057
+ $("chat-name").value = store.get("rug-chat-name", "");
1058
+ await renderAdmin();
1059
+ });
1060
+ $("admin-clear").addEventListener("click", async () => {
1061
+ if (!chat.admin || !confirm("Delete every chat message for everyone?")) return;
1062
+ try { await (await getFirebase()).database().ref("chat/messages").remove(); }
1063
+ catch { alert("Couldn't clear the chat."); }
1064
+ });
1065
+ }
1066
+ async function sendChat(e) {
1067
+ e.preventDefault();
1068
+ const app = await getFirebase();
1069
+ if (!app || !chat.uid) return;
1070
+ // Typing the admin code as your nickname makes this browser admin (the database checks it;
1071
+ // the code itself is never posted). Each new nickname is tried once.
1072
+ const rawName = $("chat-name").value.trim();
1073
+ if (!chat.admin && rawName && rawName !== chat.triedName) {
1074
+ chat.triedName = rawName;
1075
+ if (await claimAdmin(rawName)) {
1076
+ setAdminName();
1077
+ chatNote("You're admin now ✓ Your messages show as Richard, with 🗑 delete and 🚫 ban on every message.");
1078
+ if (!$("chat-text").value.trim()) return;
1079
+ }
1080
+ }
1081
+ const n = chat.admin ? { name: ADMIN_NAME } : cleanName(rawName);
1082
+ if (n.error) { chatNote(n.error, true); $("chat-name").focus(); return; }
1083
+ if (!chat.admin && n.name.toLowerCase() === ADMIN_NAME.toLowerCase()) { chatNote("That name is taken.", true); return; }
1084
+ const m = cleanMessage($("chat-text").value);
1085
+ if (m.error) { chatNote(m.error, true); return; }
1086
+ const wait = 3000 - (Date.now() - chat.lastSent);
1087
+ if (wait > 0) { chatNote(`Slow down. Wait ${Math.ceil(wait / 1000)}s.`, true); return; }
1088
+ if (!chat.admin) store.set("rug-chat-name", n.name); // never save the admin code as a remembered name
1089
+ const db = app.database();
1090
+ const key = db.ref("chat/messages").push().key;
1091
+ const TS = window.firebase.database.ServerValue.TIMESTAMP;
1092
+ chat.lastSent = Date.now();
1093
+ try {
1094
+ await db.ref().update({
1095
+ [`chat/messages/${key}`]: { name: n.name, text: m.text, ts: TS, uid: chat.uid, ...(chat.admin ? { admin: true } : {}) },
1096
+ [`chat/last/${chat.uid}`]: TS,
1097
+ });
1098
+ $("chat-text").value = "";
1099
+ stats.bump("chatSent");
1100
+ chatNote("");
1101
+ } catch {
1102
+ chatNote("Message blocked (filtered word, or sending too fast).", true);
1103
+ }
1104
+ }
1105
+
653
1106
  // ---------- Arrow keys and controller move focus between tiles and buttons ----------
654
1107
  function focusables() {
655
1108
  const scope = els.player.hidden ? document : els.player;
@@ -781,6 +1234,27 @@
781
1234
  window.addEventListener("popstate", route);
782
1235
  }
783
1236
 
1237
+ // ---------- Logo hover: spotlight follows the mouse, logo tilts toward it ----------
1238
+ function bindWordmark() {
1239
+ const w = document.querySelector(".wordmark");
1240
+ if (!w || matchMedia("(hover: none), (prefers-reduced-motion: reduce)").matches) return;
1241
+ w.addEventListener("mousemove", (e) => {
1242
+ const r = w.getBoundingClientRect();
1243
+ const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
1244
+ w.classList.add("hovering");
1245
+ w.style.setProperty("--mx", `${(x * 100).toFixed(1)}%`);
1246
+ w.style.setProperty("--my", `${(y * 100).toFixed(1)}%`);
1247
+ w.style.setProperty("--spot", `${Math.round(r.height * 1.1)}px`);
1248
+ w.style.setProperty("--ry", `${((x - 0.5) * 14).toFixed(2)}deg`);
1249
+ w.style.setProperty("--rx", `${((0.5 - y) * 12).toFixed(2)}deg`);
1250
+ w.style.setProperty("--sc", "1.03");
1251
+ });
1252
+ w.addEventListener("mouseleave", () => {
1253
+ w.classList.remove("hovering");
1254
+ ["--ry", "--rx", "--sc", "--spot"].forEach((p) => w.style.removeProperty(p));
1255
+ });
1256
+ }
1257
+
784
1258
  // ---------- Home search: placeholder that types itself ----------
785
1259
  let timer = 0; // shared, so restarting (from Settings) replaces the running animation
786
1260
  function startTypingPlaceholder() {
@@ -901,6 +1375,10 @@
901
1375
  bind();
902
1376
  bindSettings();
903
1377
  bindWheel();
1378
+ bindWordmark();
1379
+ $("chat-form").addEventListener("submit", sendChat);
1380
+ bindAdmin();
1381
+ startAnalytics();
904
1382
  startPresence();
905
1383
  // long enough to read a joke or two (skipped when the loading screen is off)
906
1384
  const minShow = new Promise((r) => setTimeout(r, settings.boot !== false ? 1500 : 0));
@@ -933,7 +1411,7 @@
933
1411
  renderGrid();
934
1412
  startMarquee();
935
1413
  route();
936
- finishBoot("Done! (that was not 3 hours)");
1414
+ finishBoot("Done");
937
1415
  }
938
1416
 
939
1417
  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: "main",
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,9 +14,21 @@ 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
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
18
22
  // (see SETUP.md). Leave null to hide the count.
19
- firebase: null,
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
+ },
20
32
 
21
33
  // npm package the site is published as (used to find the newest version)
22
34
  npmPackage: "@ywkrlerche/richardwho",
package/index.html CHANGED
@@ -46,6 +46,12 @@
46
46
  <button class="rail-btn" type="button" id="random-btn" title="Random game" aria-label="Random game">
47
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>
48
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>
52
+ <button class="rail-btn" type="button" data-nav="stats" title="Stats &amp; achievements" aria-label="Stats and achievements">
53
+ <svg viewBox="0 0 24 24"><path d="M7 3h10v2h4v3a5 5 0 0 1-4.6 5A5 5 0 0 1 13 15.9V18h3v3H8v-3h3v-2.1A5 5 0 0 1 7.6 13 5 5 0 0 1 3 8V5h4V3Zm0 4H5v1a3 3 0 0 0 2 2.8V7Zm12 0h-2v3.8A3 3 0 0 0 19 8V7Z"/></svg>
54
+ </button>
49
55
  <button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
50
56
  <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>
51
57
  </button>
@@ -129,6 +135,50 @@
129
135
  </section>
130
136
  </section>
131
137
 
138
+ <!-- CHAT -->
139
+ <section id="view-chat" class="view chat" hidden>
140
+ <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>
141
+ <div class="chat-box">
142
+ <div class="chat-off" id="chat-off" hidden>
143
+ <p><strong>Chat isn't switched on yet.</strong></p>
144
+ <p>It needs the site's free Firebase database (see SETUP.md).</p>
145
+ </div>
146
+ <div class="chat-log" id="chat-log" aria-live="polite"></div>
147
+ <form class="chat-form" id="chat-form" autocomplete="off" hidden>
148
+ <input id="chat-name" class="chat-name" maxlength="16" placeholder="Nickname" spellcheck="false" aria-label="Nickname">
149
+ <input id="chat-text" class="chat-text" maxlength="200" placeholder="Say something…" aria-label="Message">
150
+ <button class="pill-btn" type="submit" id="chat-send">Send</button>
151
+ </form>
152
+ <p class="chat-note" id="chat-note"></p>
153
+ </div>
154
+ </section>
155
+
156
+ <!-- STATS & ACHIEVEMENTS -->
157
+ <section id="view-stats" class="view settings stats-page" hidden>
158
+ <header class="games-head"><div><h1>Stats</h1><p class="lede">Your play time and achievements. Saved on this device only.</p></div></header>
159
+
160
+ <div class="set-group">
161
+ <h2>Your stats</h2>
162
+ <div class="stat-tiles">
163
+ <div class="stat"><b id="stat-unique">0</b><span>games played</span></div>
164
+ <div class="stat"><b id="stat-plays">0</b><span>times played</span></div>
165
+ <div class="stat"><b id="stat-time">–</b><span>time played</span></div>
166
+ <div class="stat"><b id="stat-fav">–</b><span>favorite category</span></div>
167
+ </div>
168
+ <h3 class="set-sub">Most played</h3>
169
+ <ol class="stat-top" id="stat-top"></ol>
170
+ </div>
171
+
172
+ <div class="set-group">
173
+ <h2>Achievements <small class="ach-count" id="ach-count"></small></h2>
174
+ <div class="achievements" id="achievements"></div>
175
+ </div>
176
+
177
+ <div class="set-group">
178
+ <div class="set-row"><span>Start over</span><button class="pill-btn" type="button" id="set-reset-stats">Reset stats &amp; achievements</button></div>
179
+ </div>
180
+ </section>
181
+
132
182
  <!-- SETTINGS -->
133
183
  <section id="view-settings" class="view settings" hidden>
134
184
  <header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
@@ -174,10 +224,20 @@
174
224
  <label class="set-row"><span>Funny loading screen</span><input type="checkbox" id="set-boot"></label>
175
225
  </div>
176
226
 
227
+ <div class="set-group" id="admin-group" hidden>
228
+ <h2>Chat admin</h2>
229
+ <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>
230
+ <div class="set-row"><span id="admin-status">Not an admin on this browser</span>
231
+ <button class="pill-btn" type="button" id="admin-leave" hidden>Stop being admin here</button>
232
+ </div>
233
+ <div class="set-row" id="admin-tools-row" hidden><span>Chat</span>
234
+ <button class="pill-btn danger" type="button" id="admin-clear">Clear all chat</button>
235
+ </div>
236
+ </div>
237
+
177
238
  <div class="set-group">
178
239
  <h2>Your data</h2>
179
- <div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div>
180
- <div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
240
+ <div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div> <div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
181
241
  </div>
182
242
  </section>
183
243
  </main>
@@ -231,6 +291,8 @@
231
291
  </div>
232
292
  </section>
233
293
 
294
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
295
+
234
296
  <footer class="corner-links">
235
297
  <span><kbd>`</kbd> or <kbd>]</kbd> panic</span>
236
298
  <span id="footer-count"></span>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywkrlerche/richardwho",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "Richards Unblocked Games",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
package/style.css CHANGED
@@ -11,8 +11,10 @@
11
11
  --soft-2: rgba(255, 255, 255, .1);
12
12
  --accent: #3b82f6;
13
13
  --danger: #ef4444;
14
- --glow-blue: rgba(30, 64, 175, .55);
15
- --glow-orange: rgba(194, 92, 30, .35);
14
+ --glow-blue: rgba(37, 78, 205, .55);
15
+ --glow-cyan: rgba(14, 165, 233, .30);
16
+ --glow-violet: rgba(99, 76, 230, .32);
17
+ --shine: #93c5fd;
16
18
  --rail-w: 58px;
17
19
  --per-row: 6;
18
20
  --font: "Inter", "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
@@ -29,7 +31,9 @@
29
31
  --soft: rgba(0, 0, 0, .05);
30
32
  --soft-2: rgba(0, 0, 0, .09);
31
33
  --glow-blue: rgba(59, 130, 246, .28);
32
- --glow-orange: rgba(249, 115, 22, .2);
34
+ --glow-cyan: rgba(14, 165, 233, .18);
35
+ --glow-violet: rgba(124, 58, 237, .14);
36
+ --shine: #2563eb;
33
37
  color-scheme: light;
34
38
  }
35
39
 
@@ -63,8 +67,8 @@ kbd { font: 600 11px ui-monospace, Consolas, monospace; padding: 1px 6px; border
63
67
  .aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
64
68
  .aurora span { position: absolute; border-radius: 50%; filter: blur(90px); }
65
69
  .aurora .a1 { width: 70vw; height: 26vh; left: 22vw; top: 8vh; background: var(--glow-blue); transform: rotate(-12deg); animation: drift 18s ease-in-out infinite alternate; }
66
- .aurora .a2 { width: 34vw; height: 20vh; right: -6vw; top: -6vh; background: var(--glow-orange); animation: drift 22s ease-in-out infinite alternate-reverse; }
67
- .aurora .a3 { width: 40vw; height: 18vh; left: 8vw; top: 26vh; background: color-mix(in srgb, var(--glow-blue) 55%, transparent); animation: drift 26s ease-in-out infinite alternate; }
70
+ .aurora .a2 { width: 36vw; height: 22vh; right: -4vw; top: -4vh; background: var(--glow-cyan); animation: drift 22s ease-in-out infinite alternate-reverse; }
71
+ .aurora .a3 { width: 42vw; height: 20vh; left: 6vw; top: 28vh; background: var(--glow-violet); animation: drift 26s ease-in-out infinite alternate; }
68
72
  @keyframes drift { from { translate: 0 0; } to { translate: 4vw 3vh; } }
69
73
  body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
70
74
 
@@ -101,11 +105,36 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
101
105
 
102
106
  /* ---------- Home ---------- */
103
107
  .home { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding-bottom: 12vh; }
108
+ /* Animated logo: fades up on load, a light sweeps across it, and a soft blue glow breathes behind it */
109
+ .wordmark, .boot-word {
110
+ background: linear-gradient(100deg, var(--text) 0%, var(--text) 42%, var(--shine) 50%, var(--text) 58%, var(--text) 100%);
111
+ background-size: 260% 100%;
112
+ -webkit-background-clip: text; background-clip: text; color: transparent;
113
+ animation: shine 5.5s ease-in-out infinite, glow 4s ease-in-out infinite alternate;
114
+ }
104
115
  .wordmark {
105
116
  margin: 0 0 30px; font-size: clamp(64px, 11vw, 150px); font-weight: 800; letter-spacing: -.055em; line-height: .9;
106
- background: linear-gradient(180deg, var(--text) 55%, color-mix(in srgb, var(--text) 70%, transparent));
107
- -webkit-background-clip: text; background-clip: text; color: transparent;
117
+ padding: 0 .04em .08em; /* room for the glow and descenders */
118
+ cursor: default; user-select: none;
119
+ /* layer 1: hover spotlight that follows the mouse (--mx/--my set in app.js); layer 2: the sweeping shine */
120
+ background-image:
121
+ radial-gradient(circle var(--spot, 0px) at var(--mx, 50%) var(--my, 50%), var(--shine), transparent 70%),
122
+ linear-gradient(100deg, var(--text) 0%, var(--text) 42%, var(--shine) 50%, var(--text) 58%, var(--text) 100%);
123
+ background-size: 100% 100%, 260% 100%;
124
+ background-repeat: no-repeat, repeat; /* the shine layer must repeat or the word loses its fill */
125
+ transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1));
126
+ transition: transform .35s cubic-bezier(.2, .8, .2, 1), --spot .3s;
127
+ animation: rise .9s cubic-bezier(.2, .8, .2, 1) both, shine2 5.5s ease-in-out 1s infinite, glow 4s ease-in-out infinite alternate;
128
+ }
129
+ .wordmark.hovering { transition: transform .12s ease-out; }
130
+ @property --spot { syntax: "<length>"; inherits: false; initial-value: 0px; }
131
+ @keyframes shine { 0% { background-position: 100% 0; } 55%, 100% { background-position: -60% 0; } }
132
+ @keyframes shine2 { 0% { background-position: 0 0, 100% 0; } 55%, 100% { background-position: 0 0, -60% 0; } }
133
+ @keyframes glow {
134
+ from { filter: drop-shadow(0 0 18px color-mix(in srgb, var(--shine) 20%, transparent)); }
135
+ to { filter: drop-shadow(0 0 42px color-mix(in srgb, var(--shine) 45%, transparent)); }
108
136
  }
137
+ @keyframes rise { from { opacity: 0; translate: 0 14px; letter-spacing: -.02em; filter: blur(6px); } to { opacity: 1; translate: 0 0; } }
109
138
  .home-search {
110
139
  position: relative; width: min(640px, 100%); height: 64px; display: flex; align-items: center; gap: 14px; padding: 0 22px;
111
140
  background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line-2); border-radius: 4px;
@@ -221,6 +250,61 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
221
250
  .disguise .none-icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; font-size: 13px; }
222
251
  .disguise[aria-pressed="true"] { border-color: var(--text); background: var(--soft-2); box-shadow: inset 0 0 0 1px var(--text); }
223
252
 
253
+ /* ---------- Chat ---------- */
254
+ .chat { padding-top: 26px; padding-bottom: 40px; max-width: 820px; }
255
+ .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); }
256
+ .chat-log { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; }
257
+ .chat-msg { font-size: 14px; line-height: 1.4; word-wrap: break-word; }
258
+ .chat-msg .who { font-weight: 700; margin-right: 6px; }
259
+ .chat-msg .when { color: var(--muted); font-size: 11px; margin-left: 6px; }
260
+ .chat-msg.me .who { text-decoration: underline; text-underline-offset: 3px; }
261
+ .chat-empty { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
262
+ .chat-form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
263
+ .chat-form input { height: 40px; padding: 0 12px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--bg); color: var(--text); }
264
+ .chat-name { width: 130px; flex: none; }
265
+ .chat-text { flex: 1; min-width: 0; }
266
+ .chat .pill-btn { color: var(--bg); background: var(--text); border-color: var(--text); height: 40px; padding: 0 18px; }
267
+ .chat-note { margin: 0; padding: 0 12px 10px; min-height: 1.3em; color: var(--muted); font-size: 12px; }
268
+ .chat-note.err { color: var(--danger); }
269
+ .chat-off { margin: auto; text-align: center; color: var(--muted); padding: 30px; }
270
+ .chat-off p { margin: 4px 0; }
271
+ .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; }
272
+ .chat-msg .mod:hover { background: color-mix(in srgb, var(--danger) 30%, transparent); }
273
+ .chat-log.admin .chat-msg .mod { display: inline-block; }
274
+ .chat-msg .badge-admin { color: #facc15; font-size: 11px; font-weight: 700; margin-right: 6px; }
275
+ .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; }
276
+ @media (max-width: 720px) { .chat-name { width: 90px; } }
277
+
278
+ /* ---------- Stats + achievements ---------- */
279
+ .stat-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0 14px; }
280
+ .stat { border: 1px solid var(--line); border-radius: 6px; padding: 12px; background: var(--soft); }
281
+ .stat b { display: block; font-size: 22px; font-weight: 800; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
282
+ .stat span { color: var(--muted); font-size: 12px; }
283
+ .set-sub { margin: 4px 0 6px; font-size: 13px; color: var(--muted); font-weight: 600; }
284
+ .stat-top { list-style: none; margin: 0; padding: 0; }
285
+ .stat-top li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 14px; }
286
+ .stat-top li span:last-child { color: var(--muted); font-size: 13px; white-space: nowrap; }
287
+ .stat-top .muted { color: var(--muted); }
288
+ .ach-count { color: var(--muted); font-size: 12px; font-weight: 500; margin-left: 8px; }
289
+ .achievements { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; margin-top: 12px; }
290
+ .ach { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); opacity: .7; }
291
+ .ach.got { opacity: 1; border-color: color-mix(in srgb, #facc15 55%, var(--line)); background: color-mix(in srgb, #facc15 8%, var(--soft)); }
292
+ .ach-icon { font-size: 22px; line-height: 1; }
293
+ .ach-body { display: grid; gap: 2px; min-width: 0; }
294
+ .ach-body b { font-size: 13.5px; }
295
+ .ach-body small { color: var(--muted); font-size: 11.5px; }
296
+ .ach-done { color: #ca8a04 !important; }
297
+ .ach-bar { height: 4px; border-radius: 4px; background: var(--line); overflow: hidden; margin-top: 4px; }
298
+ .ach-bar div { height: 100%; background: var(--text); }
299
+ @media (max-width: 720px) { .stat-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
300
+
301
+ .toast {
302
+ position: fixed; left: 50%; bottom: 22px; z-index: 90; transform: translate(-50%, 20px); opacity: 0; pointer-events: none;
303
+ padding: 12px 18px; border-radius: 8px; background: #111; color: #fff; border: 1px solid #facc15;
304
+ font-weight: 700; font-size: 14px; box-shadow: 0 12px 30px rgba(0,0,0,.5); transition: opacity .25s, transform .25s;
305
+ }
306
+ .toast.show { opacity: 1; transform: translate(-50%, 0); }
307
+
224
308
  /* ---------- Random game wheel ---------- */
225
309
  .wheel-wrap { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: rgba(0,0,0,.7); backdrop-filter: blur(6px); }
226
310
  .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); }
@@ -265,7 +349,10 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
265
349
  .corner-links { position: fixed; right: 14px; bottom: 12px; z-index: 5; display: flex; gap: 8px; }
266
350
  .corner-links > span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 4px; background: color-mix(in srgb, var(--panel) 85%, transparent); font-size: 12px; color: var(--muted); }
267
351
  body.playing { overflow: hidden; }
268
- body:has(#view-games:not([hidden])) .corner-links { display: none; }
352
+ body:has(#view-games:not([hidden])) .corner-links,
353
+ body:has(#view-stats:not([hidden])) .corner-links,
354
+ body:has(#view-settings:not([hidden])) .corner-links,
355
+ body:has(#view-chat:not([hidden])) .corner-links { display: none; }
269
356
 
270
357
  /* ---------- Responsive ---------- */
271
358
  @media (max-width: 1100px) { .grid, .recent-row { grid-template-columns: repeat(min(var(--per-row), 4), minmax(0, 1fr)); } }
@@ -284,4 +371,4 @@ body:has(#view-games:not([hidden])) .corner-links { display: none; }
284
371
  .corner-links { display: none; }
285
372
  .clock { top: 8px; right: 8px; }
286
373
  }
287
- @media (prefers-reduced-motion: reduce) { .aurora span, .sweep::after { animation: none; } }
374
+ @media (prefers-reduced-motion: reduce) { .aurora span, .sweep::after, .wordmark, .boot-word { animation: none; } }