@ywkrlerche/richardwho 1.0.28 → 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 +182 -2
- package/index.html +32 -2
- package/logos/60-seconds-reatomized.jpg +0 -0
- package/package.json +1 -1
- package/style.css +71 -9
package/app.js
CHANGED
|
@@ -287,6 +287,7 @@
|
|
|
287
287
|
els.loader.hidden = true;
|
|
288
288
|
hookFrameKeys();
|
|
289
289
|
try { els.frame.contentWindow.focus(); } catch {}
|
|
290
|
+
stats.startPlay(game);
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
// Saved copies of recently played games (browser Cache Storage), so replays start instantly.
|
|
@@ -358,6 +359,7 @@
|
|
|
358
359
|
}
|
|
359
360
|
|
|
360
361
|
function stopGame() {
|
|
362
|
+
stats.stopPlay();
|
|
361
363
|
if (!currentLoad) return;
|
|
362
364
|
currentLoad.controller.abort();
|
|
363
365
|
currentLoad = null;
|
|
@@ -396,12 +398,14 @@
|
|
|
396
398
|
document.body.classList.remove("playing");
|
|
397
399
|
document.title = tabTitle();
|
|
398
400
|
const h = location.hash;
|
|
399
|
-
const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : "home";
|
|
401
|
+
const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : h === "#stats" ? "stats" : "home";
|
|
400
402
|
els.homeView.hidden = view !== "home";
|
|
401
403
|
els.gamesView.hidden = view !== "games";
|
|
402
404
|
$("view-settings").hidden = view !== "settings";
|
|
403
405
|
$("view-chat").hidden = view !== "chat";
|
|
406
|
+
$("view-stats").hidden = view !== "stats";
|
|
404
407
|
if (view === "settings") { renderSettings(); renderAdmin(); }
|
|
408
|
+
if (view === "stats") renderStats();
|
|
405
409
|
if (view === "chat") openChat();
|
|
406
410
|
track(view[0].toUpperCase() + view.slice(1));
|
|
407
411
|
document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
|
|
@@ -462,6 +466,152 @@
|
|
|
462
466
|
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
|
|
463
467
|
}
|
|
464
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
|
+
|
|
465
615
|
// ---------- Settings (saved in this browser) ----------
|
|
466
616
|
const SETTINGS_KEY = "rug-settings";
|
|
467
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>");
|
|
@@ -556,6 +706,12 @@
|
|
|
556
706
|
$("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
|
|
557
707
|
$("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
|
|
558
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
|
+
});
|
|
559
715
|
$("set-clear-recent").addEventListener("click", () => {
|
|
560
716
|
store.set(RECENT_KEY, []);
|
|
561
717
|
renderRecent();
|
|
@@ -612,6 +768,7 @@
|
|
|
612
768
|
ctx.stroke();
|
|
613
769
|
}
|
|
614
770
|
function spinWheel(pool) {
|
|
771
|
+
stats.bump("wheelSpins");
|
|
615
772
|
const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
|
|
616
773
|
wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
|
|
617
774
|
const n = wheel.items.length, a = (Math.PI * 2) / n;
|
|
@@ -668,7 +825,7 @@
|
|
|
668
825
|
function bindWheel() {
|
|
669
826
|
$("wheel-skip").addEventListener("click", finishWheel);
|
|
670
827
|
$("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
|
|
671
|
-
$("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); });
|
|
672
829
|
$("wheel-close").addEventListener("click", closeWheel);
|
|
673
830
|
$("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
|
|
674
831
|
}
|
|
@@ -939,6 +1096,7 @@
|
|
|
939
1096
|
[`chat/last/${chat.uid}`]: TS,
|
|
940
1097
|
});
|
|
941
1098
|
$("chat-text").value = "";
|
|
1099
|
+
stats.bump("chatSent");
|
|
942
1100
|
chatNote("");
|
|
943
1101
|
} catch {
|
|
944
1102
|
chatNote("Message blocked (filtered word, or sending too fast).", true);
|
|
@@ -1076,6 +1234,27 @@
|
|
|
1076
1234
|
window.addEventListener("popstate", route);
|
|
1077
1235
|
}
|
|
1078
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
|
+
|
|
1079
1258
|
// ---------- Home search: placeholder that types itself ----------
|
|
1080
1259
|
let timer = 0; // shared, so restarting (from Settings) replaces the running animation
|
|
1081
1260
|
function startTypingPlaceholder() {
|
|
@@ -1196,6 +1375,7 @@
|
|
|
1196
1375
|
bind();
|
|
1197
1376
|
bindSettings();
|
|
1198
1377
|
bindWheel();
|
|
1378
|
+
bindWordmark();
|
|
1199
1379
|
$("chat-form").addEventListener("submit", sendChat);
|
|
1200
1380
|
bindAdmin();
|
|
1201
1381
|
startAnalytics();
|
package/index.html
CHANGED
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
<button class="rail-btn" type="button" data-nav="chat" title="Chat" aria-label="Chat">
|
|
50
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
51
|
</button>
|
|
52
|
+
<button class="rail-btn" type="button" data-nav="stats" title="Stats & 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>
|
|
52
55
|
<button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
|
|
53
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>
|
|
54
57
|
</button>
|
|
@@ -150,6 +153,32 @@
|
|
|
150
153
|
</div>
|
|
151
154
|
</section>
|
|
152
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 & achievements</button></div>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
|
|
153
182
|
<!-- SETTINGS -->
|
|
154
183
|
<section id="view-settings" class="view settings" hidden>
|
|
155
184
|
<header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
|
|
@@ -208,8 +237,7 @@
|
|
|
208
237
|
|
|
209
238
|
<div class="set-group">
|
|
210
239
|
<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>
|
|
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>
|
|
213
241
|
</div>
|
|
214
242
|
</section>
|
|
215
243
|
</main>
|
|
@@ -263,6 +291,8 @@
|
|
|
263
291
|
</div>
|
|
264
292
|
</section>
|
|
265
293
|
|
|
294
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
295
|
+
|
|
266
296
|
<footer class="corner-links">
|
|
267
297
|
<span><kbd>`</kbd> or <kbd>]</kbd> panic</span>
|
|
268
298
|
<span id="footer-count"></span>
|
|
Binary file
|
package/package.json
CHANGED
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(
|
|
15
|
-
--glow-
|
|
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-
|
|
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:
|
|
67
|
-
.aurora .a3 { width:
|
|
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
|
-
|
|
107
|
-
|
|
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;
|
|
@@ -246,6 +275,36 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
|
|
|
246
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; }
|
|
247
276
|
@media (max-width: 720px) { .chat-name { width: 90px; } }
|
|
248
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
|
+
|
|
249
308
|
/* ---------- Random game wheel ---------- */
|
|
250
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); }
|
|
251
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); }
|
|
@@ -290,7 +349,10 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
|
|
|
290
349
|
.corner-links { position: fixed; right: 14px; bottom: 12px; z-index: 5; display: flex; gap: 8px; }
|
|
291
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); }
|
|
292
351
|
body.playing { overflow: hidden; }
|
|
293
|
-
body:has(#view-games:not([hidden])) .corner-links
|
|
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; }
|
|
294
356
|
|
|
295
357
|
/* ---------- Responsive ---------- */
|
|
296
358
|
@media (max-width: 1100px) { .grid, .recent-row { grid-template-columns: repeat(min(var(--per-row), 4), minmax(0, 1fr)); } }
|
|
@@ -309,4 +371,4 @@ body:has(#view-games:not([hidden])) .corner-links { display: none; }
|
|
|
309
371
|
.corner-links { display: none; }
|
|
310
372
|
.clock { top: 8px; right: 8px; }
|
|
311
373
|
}
|
|
312
|
-
@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; } }
|