@ywkrlerche/richardwho 1.0.22 → 1.0.24

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.
Files changed (301) hide show
  1. package/app.js +270 -82
  2. package/games.json +1 -1
  3. package/index.html +100 -48
  4. package/logos/1-on-1-soccer.webp +0 -0
  5. package/logos/1-on-1-tennis.webp +0 -0
  6. package/logos/10-minutes-till-dawn.webp +0 -0
  7. package/logos/1v1.lol.webp +0 -0
  8. package/logos/2048-cupcakes.webp +0 -0
  9. package/logos/2048.webp +0 -0
  10. package/logos/3D-flight-simulator.webp +0 -0
  11. package/logos/3d-free-kick.webp +0 -0
  12. package/logos/8-ball-classic.webp +0 -0
  13. package/logos/9007199254740992.webp +0 -0
  14. package/logos/Alpha_1.2.6.webp +0 -0
  15. package/logos/Indev.webp +0 -0
  16. package/logos/agar-io-lite.webp +0 -0
  17. package/logos/age-of-war-2.webp +0 -0
  18. package/logos/age-of-war.webp +0 -0
  19. package/logos/ages-of-conflict.webp +0 -0
  20. package/logos/among-us.webp +0 -0
  21. package/logos/angry-birds-space.webp +0 -0
  22. package/logos/angry-birds.webp +0 -0
  23. package/logos/awesome-tanks-2.webp +0 -0
  24. package/logos/awesome-tanks.webp +0 -0
  25. package/logos/bacon-may-die.webp +0 -0
  26. package/logos/bad-ice-cream-2.webp +0 -0
  27. package/logos/bad-ice-cream-3.webp +0 -0
  28. package/logos/bad-ice-cream.webp +0 -0
  29. package/logos/bad-parenting.webp +0 -0
  30. package/logos/bad-piggies.webp +0 -0
  31. package/logos/bank-robbery-2.webp +0 -0
  32. package/logos/bank-robbery-3.webp +0 -0
  33. package/logos/baseball-bros.webp +0 -0
  34. package/logos/basket-bros.webp +0 -0
  35. package/logos/basket-random.webp +0 -0
  36. package/logos/basketball-legends.webp +0 -0
  37. package/logos/basketball-stars.webp +0 -0
  38. package/logos/big-ice-tower-tiny-square.webp +0 -0
  39. package/logos/big-neon-tower-tiny-square.webp +0 -0
  40. package/logos/big-tower-tiny-square-2.webp +0 -0
  41. package/logos/big-tower-tiny-square.webp +0 -0
  42. package/logos/blocky-snakes.webp +0 -0
  43. package/logos/bloons-TD-2.webp +0 -0
  44. package/logos/bloons-TD.webp +0 -0
  45. package/logos/bloxorz.webp +0 -0
  46. package/logos/blumgi-racers.webp +0 -0
  47. package/logos/blumgi-rocket.webp +0 -0
  48. package/logos/bob-the-robber-2.webp +0 -0
  49. package/logos/bob-the-robber.webp +0 -0
  50. package/logos/bouncy-motors.webp +0 -0
  51. package/logos/boxing-random.webp +0 -0
  52. package/logos/breaking-the-bank.webp +0 -0
  53. package/logos/bubble-shooter.webp +0 -0
  54. package/logos/candy-crush.webp +0 -0
  55. package/logos/capybara-clicker.webp +0 -0
  56. package/logos/car-drawing.webp +0 -0
  57. package/logos/chaos-faction-2.webp +0 -0
  58. package/logos/chess.webp +0 -0
  59. package/logos/choppy-orc.webp +0 -0
  60. package/logos/circloO-2.webp +0 -0
  61. package/logos/circloO.webp +0 -0
  62. package/logos/clash-of-vikings.webp +0 -0
  63. package/logos/cleanup-io.webp +0 -0
  64. package/logos/cluster-rush.webp +0 -0
  65. package/logos/cookie-clicker.webp +0 -0
  66. package/logos/coreball.webp +0 -0
  67. package/logos/crazy-cars.webp +0 -0
  68. package/logos/crazy-cattle-3D.webp +0 -0
  69. package/logos/crazy-crash-landing.webp +0 -0
  70. package/logos/crazy-motorcycle.webp +0 -0
  71. package/logos/crossy-road.webp +0 -0
  72. package/logos/cut-the-rope-2.webp +0 -0
  73. package/logos/cut-the-rope-holiday-gift.webp +0 -0
  74. package/logos/cut-the-rope-time-travel.webp +0 -0
  75. package/logos/cut-the-rope.webp +0 -0
  76. package/logos/dadish-2.webp +0 -0
  77. package/logos/dadish-3.webp +0 -0
  78. package/logos/dadish.webp +0 -0
  79. package/logos/death-chase.webp +0 -0
  80. package/logos/death-run-3D.webp +0 -0
  81. package/logos/demolition-derby-crash-racing.webp +0 -0
  82. package/logos/doodle-jump.webp +0 -0
  83. package/logos/draw-climber.webp +0 -0
  84. package/logos/dreadhead-parkour.webp +0 -0
  85. package/logos/drift-boss.webp +0 -0
  86. package/logos/drift-hunters-pro.webp +0 -0
  87. package/logos/drive-mad.webp +0 -0
  88. package/logos/duck-life.webp +0 -0
  89. package/logos/ducklings-io.webp +0 -0
  90. package/logos/eagle-ride.webp +0 -0
  91. package/logos/earn-to-die-2.webp +0 -0
  92. package/logos/earn-to-die.webp +0 -0
  93. package/logos/eggy-car.webp +0 -0
  94. package/logos/elastic-face.webp +0 -0
  95. package/logos/escape-road-2.webp +0 -0
  96. package/logos/escape-road-3.webp +0 -0
  97. package/logos/escape-road.webp +0 -0
  98. package/logos/escaping-the-prison.webp +0 -0
  99. package/logos/eugenes-life.webp +0 -0
  100. package/logos/evil-glitch.webp +0 -0
  101. package/logos/fancy-pants-adventure-2.webp +0 -0
  102. package/logos/fancy-pants-adventure.webp +0 -0
  103. package/logos/fireblob.webp +0 -0
  104. package/logos/fireboy-and-watergirl.webp +0 -0
  105. package/logos/flappy-bird.webp +0 -0
  106. package/logos/fleeing-the-complex.webp +0 -0
  107. package/logos/flood-runner-2.webp +0 -0
  108. package/logos/flood-runner-3.webp +0 -0
  109. package/logos/flood-runner-4.webp +0 -0
  110. package/logos/fnaf-2.webp +0 -0
  111. package/logos/fnaf-3.webp +0 -0
  112. package/logos/fnaf.webp +0 -0
  113. package/logos/football-bros.webp +0 -0
  114. package/logos/football-legends.webp +0 -0
  115. package/logos/friday-night-funkin.webp +0 -0
  116. package/logos/fruit-ninja.webp +0 -0
  117. package/logos/funny-battle.webp +0 -0
  118. package/logos/funny-mad-racing.webp +0 -0
  119. package/logos/funny-shooter-2.webp +0 -0
  120. package/logos/geometry-dash-lite.webp +0 -0
  121. package/logos/geometry-dash.webp +0 -0
  122. package/logos/geometry-vibes.webp +0 -0
  123. package/logos/get-on-top.webp +0 -0
  124. package/logos/getaway-shootout.webp +0 -0
  125. package/logos/gladi-hoppers.webp +0 -0
  126. package/logos/google-baseball.webp +0 -0
  127. package/logos/google-dino.webp +0 -0
  128. package/logos/granny.webp +0 -0
  129. package/logos/guess-their-answer.webp +0 -0
  130. package/logos/happy-wheels.webp +0 -0
  131. package/logos/helix-jump.webp +0 -0
  132. package/logos/highway-traffic.webp +0 -0
  133. package/logos/hill-climb-racing-lite.webp +0 -0
  134. package/logos/hole-io.webp +0 -0
  135. package/logos/house-of-hazards.webp +0 -0
  136. package/logos/hover-racer-drive.webp +0 -0
  137. package/logos/ice-dodo.webp +0 -0
  138. package/logos/idle-breakout.webp +0 -0
  139. package/logos/idle-dice.webp +0 -0
  140. package/logos/infiltrating-the-airship.webp +0 -0
  141. package/logos/iron-snout.webp +0 -0
  142. package/logos/jacksmith.webp +0 -0
  143. package/logos/jetpack-joyride.webp +0 -0
  144. package/logos/johnny-trigger.webp +0 -0
  145. package/logos/jumping-shell.webp +0 -0
  146. package/logos/karate-bros.webp +0 -0
  147. package/logos/kart-bros.webp +0 -0
  148. package/logos/kittytoy.webp +0 -0
  149. package/logos/learn-to-fly-2.webp +0 -0
  150. package/logos/learn-to-fly-3.webp +0 -0
  151. package/logos/learn-to-fly.webp +0 -0
  152. package/logos/level-devil.webp +0 -0
  153. package/logos/little-alchemy-2.webp +0 -0
  154. package/logos/madalin-stunt-cars-2.webp +0 -0
  155. package/logos/madness-project-nexus.webp +0 -0
  156. package/logos/melon-playground.webp +0 -0
  157. package/logos/merge-round-racers.webp +0 -0
  158. package/logos/minesweeper.webp +0 -0
  159. package/logos/monkey-mart.webp +0 -0
  160. package/logos/monster-tracks.webp +0 -0
  161. package/logos/motherload.webp +0 -0
  162. package/logos/moto-x3m-2.webp +0 -0
  163. package/logos/moto-x3m-3.webp +0 -0
  164. package/logos/moto-x3m-pool-party.webp +0 -0
  165. package/logos/moto-x3m-spooky-land.webp +0 -0
  166. package/logos/moto-x3m-winter.webp +0 -0
  167. package/logos/moto-x3m.webp +0 -0
  168. package/logos/murder.webp +0 -0
  169. package/logos/noob-miner.webp +0 -0
  170. package/logos/openTTD.webp +0 -0
  171. package/logos/opposite-day.webp +0 -0
  172. package/logos/ovo.webp +0 -0
  173. package/logos/pac-man.webp +0 -0
  174. package/logos/papas-bakeria.webp +0 -0
  175. package/logos/papas-burgeria.webp +0 -0
  176. package/logos/papas-cheeseria.webp +0 -0
  177. package/logos/papas-cupcakeria.webp +0 -0
  178. package/logos/papas-donuteria.webp +0 -0
  179. package/logos/papas-freezeria.webp +0 -0
  180. package/logos/papas-pancakeria.webp +0 -0
  181. package/logos/papas-pastaria.webp +0 -0
  182. package/logos/papas-pizzeria.webp +0 -0
  183. package/logos/papas-scooperia.webp +0 -0
  184. package/logos/papas-sushiria.webp +0 -0
  185. package/logos/papas-taco-mia.webp +0 -0
  186. package/logos/papas-wingeria.webp +0 -0
  187. package/logos/paper-io-2.webp +0 -0
  188. package/logos/parking-fury-2.webp +0 -0
  189. package/logos/picos-school.webp +0 -0
  190. package/logos/ping-pong-chaos.webp +0 -0
  191. package/logos/pixel-speedrun.webp +0 -0
  192. package/logos/plants-vs-zombies.webp +0 -0
  193. package/logos/plonky.webp +0 -0
  194. package/logos/polytrack.webp +0 -0
  195. package/logos/poor-bunny.webp +0 -0
  196. package/logos/pou.webp +0 -0
  197. package/logos/ragdoll-archers.webp +0 -0
  198. package/logos/ragdoll-hit.webp +0 -0
  199. package/logos/ragdoll-soccer.webp +0 -0
  200. package/logos/red-ball-4-vol-1.webp +0 -0
  201. package/logos/red-ball-4-vol-2.webp +0 -0
  202. package/logos/red-ball-4-vol-3.webp +0 -0
  203. package/logos/retro-bowl-college.webp +0 -0
  204. package/logos/retro-bowl.webp +0 -0
  205. package/logos/retro-highway.webp +0 -0
  206. package/logos/retro-ping-pong.webp +0 -0
  207. package/logos/riddle-school-2.webp +0 -0
  208. package/logos/riddle-school-3.webp +0 -0
  209. package/logos/riddle-school-4.webp +0 -0
  210. package/logos/riddle-school-5.webp +0 -0
  211. package/logos/riddle-school.webp +0 -0
  212. package/logos/riddle-transfer.webp +0 -0
  213. package/logos/rocket-soccer-derby.webp +0 -0
  214. package/logos/rooftop-run.webp +0 -0
  215. package/logos/rooftop-snipers-2.webp +0 -0
  216. package/logos/rooftop-snipers.webp +0 -0
  217. package/logos/run-2.webp +0 -0
  218. package/logos/run-3.webp +0 -0
  219. package/logos/run.webp +0 -0
  220. package/logos/sandbox-city.webp +0 -0
  221. package/logos/short-life.webp +0 -0
  222. package/logos/slope-2-player.webp +0 -0
  223. package/logos/slope-3.webp +0 -0
  224. package/logos/slope.webp +0 -0
  225. package/logos/snail-bob-2.webp +0 -0
  226. package/logos/snail-bob-3.webp +0 -0
  227. package/logos/snail-bob-4.webp +0 -0
  228. package/logos/snail-bob-5.webp +0 -0
  229. package/logos/snail-bob-6.webp +0 -0
  230. package/logos/snail-bob-7.webp +0 -0
  231. package/logos/snail-bob-8.webp +0 -0
  232. package/logos/snail-bob.webp +0 -0
  233. package/logos/snow-rider.webp +0 -0
  234. package/logos/snow-road.webp +0 -0
  235. package/logos/snowball-io.webp +0 -0
  236. package/logos/soccer-bros.webp +0 -0
  237. package/logos/soccer-random.webp +0 -0
  238. package/logos/space-is-key-2.webp +0 -0
  239. package/logos/space-is-key.webp +0 -0
  240. package/logos/space-waves.webp +0 -0
  241. package/logos/spacebar-clicker.webp +0 -0
  242. package/logos/speed-stars.webp +0 -0
  243. package/logos/sprunki.webp +0 -0
  244. package/logos/stack.webp +0 -0
  245. package/logos/stacktris.webp +0 -0
  246. package/logos/state-io.webp +0 -0
  247. package/logos/stealing-the-diamond.webp +0 -0
  248. package/logos/stick-archers-battle.webp +0 -0
  249. package/logos/stick-fighter.webp +0 -0
  250. package/logos/stick-merge.webp +0 -0
  251. package/logos/stickman-hook.webp +0 -0
  252. package/logos/subway-surfers-beijing.webp +0 -0
  253. package/logos/subway-surfers-havana.webp +0 -0
  254. package/logos/subway-surfers-san-francisco.webp +0 -0
  255. package/logos/super-bike-the-champion.webp +0 -0
  256. package/logos/super-liquid-soccer.webp +0 -0
  257. package/logos/super-star-car.webp +0 -0
  258. package/logos/table-tennis-world-tour.webp +0 -0
  259. package/logos/tag.webp +0 -0
  260. package/logos/tanuki-sunset.webp +0 -0
  261. package/logos/tap-road.webp +0 -0
  262. package/logos/temple-of-boom.webp +0 -0
  263. package/logos/temple-run-2.webp +0 -0
  264. package/logos/territorial-io.webp +0 -0
  265. package/logos/tetrominoes.webp +0 -0
  266. package/logos/the-impossible-quiz-2.webp +0 -0
  267. package/logos/the-impossible-quiz.webp +0 -0
  268. package/logos/they-are-coming.webp +0 -0
  269. package/logos/this-is-the-only-level-too.webp +0 -0
  270. package/logos/this-is-the-only-level.webp +0 -0
  271. package/logos/time-shooter-2.webp +0 -0
  272. package/logos/time-shooter-3.webp +0 -0
  273. package/logos/tiny-fishing.webp +0 -0
  274. package/logos/trap-the-cat.webp +0 -0
  275. package/logos/trivia-crack.webp +0 -0
  276. package/logos/tube-jumpers.webp +0 -0
  277. package/logos/tunnel-rush.webp +0 -0
  278. package/logos/unicycle-hero.webp +0 -0
  279. package/logos/vex-4.webp +0 -0
  280. package/logos/vex-5.webp +0 -0
  281. package/logos/vex-x3m-2.webp +0 -0
  282. package/logos/vex-x3m.webp +0 -0
  283. package/logos/volley-random.webp +0 -0
  284. package/logos/war-the-knights.webp +0 -0
  285. package/logos/we-become-what-we-behold.webp +0 -0
  286. package/logos/wheelie-bike.webp +0 -0
  287. package/logos/wheely-2.webp +0 -0
  288. package/logos/wheely-3.webp +0 -0
  289. package/logos/wheely-4.webp +0 -0
  290. package/logos/wheely-5.webp +0 -0
  291. package/logos/wheely-6.webp +0 -0
  292. package/logos/wheely-7.webp +0 -0
  293. package/logos/wheely.webp +0 -0
  294. package/logos/wordle-unlimited.webp +0 -0
  295. package/logos/worlds-hardest-game-2.webp +0 -0
  296. package/logos/worlds-hardest-game.webp +0 -0
  297. package/logos/wrestle-bros.webp +0 -0
  298. package/logos/yohoho-io.webp +0 -0
  299. package/logos/zombie-rush.webp +0 -0
  300. package/package.json +4 -3
  301. package/style.css +194 -160
package/app.js CHANGED
@@ -2,23 +2,33 @@
2
2
  "use strict";
3
3
 
4
4
  const CFG = window.RUG_CONFIG || {};
5
+ const SITE_NAME = "Richards Unblocked Games";
5
6
  const RECENT_KEY = "rug-recent";
6
7
  const CLOCK_KEY = "rug-clock24";
7
8
  const THEME_KEY = "rug-theme";
9
+ const PER_ROW_KEY = "rug-per-row";
8
10
  const MAX_RECENT = 6;
11
+ const MARQUEE_SPEED = 38; // pixels per second
9
12
 
10
13
  const TAG_EMOJI = {
11
14
  Minecraft: "⛏️", Racing: "🏎️", Sports: "⚽", Shooter: "🎯", Horror: "👻", Clicker: "👆",
12
15
  Cooking: "🍔", Puzzle: "🧩", Strategy: "🏰", IO: "🌐", Platformer: "🏃", Arcade: "👾",
16
+ Rhythm: "🎵", Adventure: "🗺️", Tools: "🧰",
13
17
  };
18
+ // Shown in the "Popular games" strip; any id missing from the catalog is skipped
19
+ const POPULAR = ["1v1.lol", "slope", "retro-bowl", "subway-surfers-san-francisco", "fnaf", "moto-x3m",
20
+ "papas-freezeria", "basket-random", "drift-boss", "monkey-mart", "temple-run-2", "happy-wheels",
21
+ "jetpack-joyride", "cut-the-rope", "friday-night-funkin", "plants-vs-zombies", "crossy-road", "run-3"];
14
22
 
15
23
  const $ = (id) => document.getElementById(id);
16
24
  const els = {
17
- grid: $("grid"), recentGrid: $("recent-grid"), recentSection: $("recent-section"),
18
- search: $("search"), chips: $("chips"), count: $("count"), empty: $("empty"),
25
+ homeView: $("view-home"), gamesView: $("view-games"), homeInput: $("home-input"), homeSub: $("home-sub"),
26
+ grid: $("grid"), search: $("search"), sort: $("sort"), chips: $("chips"), count: $("count"), empty: $("empty"),
19
27
  allTitle: $("all-title"), footerCount: $("footer-count"),
20
- home: $("home"), player: $("player"), frame: $("game-frame"), playerTitle: $("player-title"),
21
- loader: $("loader"), loaderName: $("loader-name"), loaderThumb: $("loader-thumb"),
28
+ recentBlock: $("recent-block"), recentRow: $("recent-row"),
29
+ popularBlock: $("popular-block"), marquee: $("marquee"), marqueeTrack: $("marquee-track"),
30
+ player: $("player"), frame: $("game-frame"), playerTitle: $("player-title"),
31
+ loader: $("loader"), loaderName: $("loader-name"), loaderDesc: $("loader-desc"), loaderArt: $("loader-art"),
22
32
  barFill: $("bar-fill"), loaderStatus: $("loader-status"), retryBtn: $("retry-btn"),
23
33
  };
24
34
 
@@ -39,21 +49,28 @@
39
49
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
40
50
  return h >>> 0;
41
51
  }
42
- function thumbStyle(game) {
52
+ function gradient(game) {
43
53
  const h = hash(game.id);
44
54
  const a = h % 360, b = (a + 40 + (h >> 9) % 80) % 360;
45
- return `background: linear-gradient(135deg, hsl(${a} 75% 52%), hsl(${b} 80% 38%))`;
55
+ return `linear-gradient(135deg, hsl(${a} 65% 42%), hsl(${b} 70% 24%))`;
46
56
  }
47
57
  function initials(name) {
48
58
  const words = name.replace(/\(.*?\)/g, "").split(/\s+/).filter(Boolean);
49
59
  if (words.length === 1) return words[0].slice(0, 3).toUpperCase();
50
60
  return words.slice(0, 3).map((w) => w[0]).join("").toUpperCase();
51
61
  }
62
+ // Our logos ship inside this package under logos/<id>.webp (relative, so it works on unpkg and locally);
63
+ // games from the extra collection carry their own pinned cover URL
64
+ function logoUrl(game) {
65
+ if (game.cover) return game.cover;
66
+ return game.logo ? `logos/${encodeURIComponent(game.id)}.webp` : null;
67
+ }
52
68
  function fmtMB(bytes) {
53
69
  const mb = bytes / 1048576;
54
70
  return mb < 1 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
55
71
  }
56
72
  function gameUrls(game) {
73
+ if (game.url) return [game.url]; // extra collection: one launch file at a pinned URL
57
74
  const base = CFG.localBase
58
75
  ? `${CFG.localBase}/${game.repo}`
59
76
  : `https://cdn.jsdelivr.net/gh/${CFG.githubUser}/${game.repo}@${CFG.branch || "main"}`;
@@ -62,54 +79,106 @@
62
79
  return Array.from({ length: game.parts }, (_, i) => `${url}.part${i}`);
63
80
  }
64
81
  function escapeHtml(s) {
65
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
82
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
66
83
  }
67
84
 
68
85
  // ---------- Rendering ----------
69
- function cardHtml(game) {
70
- return `<button class="card" type="button" data-id="${escapeHtml(game.id)}" title="${escapeHtml(game.name)}">
71
- <div class="thumb" style="${thumbStyle(game)}">${escapeHtml(initials(game.name))}<span class="emoji">${TAG_EMOJI[game.tag] || "👾"}</span><span class="play">▶</span></div>
72
- <div class="card-body">
73
- <span class="card-name">${escapeHtml(game.name)}</span>
74
- <span class="card-meta"><span>${escapeHtml(game.tag)}</span><span>${fmtMB(game.size)}</span></span>
75
- </div>
86
+ function tileHtml(game, { popular = false, eager = false } = {}) {
87
+ const url = logoUrl(game);
88
+ return `<button class="tile" type="button" data-id="${escapeHtml(game.id)}" title="${escapeHtml(game.desc || game.name)}">
89
+ <span class="tile-art" style="background:${gradient(game)}">${escapeHtml(initials(game.name))}</span>
90
+ ${url ? `<span class="tile-art"><img src="${url}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async" onerror="this.parentNode.remove()"></span>` : ""}
91
+ ${popular ? `<span class="badge">Popular</span>` : ""}
92
+ <span class="tile-label">${escapeHtml(game.name)}</span>
76
93
  </button>`;
77
94
  }
78
95
 
79
- function renderChips() {
80
- const counts = { All: games.length };
96
+ function tagCounts() {
97
+ const counts = {};
81
98
  games.forEach((g) => { counts[g.tag] = (counts[g.tag] || 0) + 1; });
82
- const tags = ["All", ...Object.keys(counts).filter((t) => t !== "All").sort((a, b) => counts[b] - counts[a])];
99
+ return counts;
100
+ }
101
+
102
+ function renderChips() {
103
+ const counts = tagCounts();
104
+ const tags = ["All", ...Object.keys(counts).sort((a, b) => counts[b] - counts[a])];
83
105
  els.chips.innerHTML = tags.map((t) =>
84
- `<button class="chip" role="tab" type="button" data-tag="${escapeHtml(t)}" aria-selected="${t === activeTag}">${t === "All" ? "All" : (TAG_EMOJI[t] || "") + " " + escapeHtml(t)}<small>${counts[t]}</small></button>`
106
+ `<button class="tab" role="tab" type="button" data-tag="${escapeHtml(t)}" aria-selected="${t === activeTag}">${t === "All" ? "All games" : (TAG_EMOJI[t] || "") + " " + escapeHtml(t)}<small>${t === "All" ? games.length : counts[t]}</small></button>`
85
107
  ).join("");
86
108
  }
87
109
 
110
+ function sorted(list) {
111
+ const by = els.sort.value;
112
+ const out = list.slice();
113
+ if (by === "za") out.reverse();
114
+ else if (by === "small") out.sort((a, b) => a.size - b.size);
115
+ else if (by === "big") out.sort((a, b) => b.size - a.size);
116
+ return out;
117
+ }
118
+
88
119
  function renderGrid() {
89
- const q = els.search.value.trim().toLowerCase();
90
- const terms = q.split(/\s+/).filter(Boolean);
91
- const list = games.filter((g) => {
120
+ const raw = els.search.value.trim();
121
+ const terms = raw.toLowerCase().split(/\s+/).filter(Boolean);
122
+ const list = sorted(games.filter((g) => {
92
123
  if (activeTag !== "All" && g.tag !== activeTag) return false;
93
- const hay = (g.name + " " + g.id + " " + g.tag).toLowerCase();
124
+ const hay = `${g.name} ${g.id} ${g.tag} ${g.desc || ""}`.toLowerCase();
94
125
  return terms.every((t) => hay.includes(t));
95
- });
96
- els.grid.innerHTML = list.map(cardHtml).join("");
126
+ }));
127
+ els.grid.innerHTML = list.map((g) => tileHtml(g)).join("");
97
128
  els.empty.hidden = list.length > 0;
98
129
  els.count.textContent = `${list.length} game${list.length === 1 ? "" : "s"}`;
99
- els.allTitle.textContent = q ? `Results for “${els.search.value.trim()}”` : activeTag === "All" ? "All Games" : `${activeTag} Games`;
130
+ els.allTitle.textContent = raw ? `Results for “${raw}”` : activeTag === "All" ? "All games" : `${activeTag} games`;
131
+ // Strips are for browsing; hide them while searching or filtering
132
+ const browsing = !raw && activeTag === "All";
133
+ els.popularBlock.hidden = !browsing;
134
+ els.recentBlock.hidden = !browsing || !els.recentRow.children.length;
100
135
  }
101
136
 
102
137
  function renderRecent() {
103
138
  const recent = store.get(RECENT_KEY, []).map((id) => byId.get(id)).filter(Boolean);
104
- els.recentSection.hidden = recent.length === 0;
105
- els.recentGrid.innerHTML = recent.map(cardHtml).join("");
139
+ els.recentRow.innerHTML = recent.map((g) => tileHtml(g, { eager: true })).join("");
140
+ els.recentBlock.hidden = recent.length === 0 || !!els.search.value.trim() || activeTag !== "All";
106
141
  }
107
142
 
108
143
  function addRecent(id) {
109
144
  const list = store.get(RECENT_KEY, []).filter((x) => x !== id);
110
145
  list.unshift(id);
111
146
  store.set(RECENT_KEY, list.slice(0, MAX_RECENT));
112
- renderRecent();
147
+ }
148
+
149
+ function setTag(tag) {
150
+ activeTag = tag;
151
+ els.chips.querySelectorAll(".tab").forEach((c) => c.setAttribute("aria-selected", c.dataset.tag === tag));
152
+ renderGrid();
153
+ }
154
+
155
+ function setPerRow(n) {
156
+ document.documentElement.style.setProperty("--per-row", n);
157
+ document.querySelectorAll(".per-row button").forEach((b) => b.classList.toggle("on", Number(b.dataset.per) === n));
158
+ store.set(PER_ROW_KEY, n);
159
+ }
160
+
161
+ // ---------- Popular games: a smooth, endless sideways glide ----------
162
+ function startMarquee() {
163
+ const list = POPULAR.map((id) => byId.get(id)).filter(Boolean);
164
+ if (!list.length) { els.popularBlock.hidden = true; return; }
165
+ const set = list.map((g) => tileHtml(g, { popular: true, eager: true })).join("");
166
+ els.marqueeTrack.innerHTML = set + set; // two copies so the loop never shows a gap
167
+ const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
168
+ let offset = 0, last = 0;
169
+ function step(t) {
170
+ const dt = last ? Math.min(64, t - last) : 0;
171
+ last = t;
172
+ // Pause while pointed at or keyboard-focused (checked live, so it can never get stuck paused)
173
+ const paused = els.marquee.matches(":hover") || els.marquee.contains(document.activeElement);
174
+ const half = els.marqueeTrack.scrollWidth / 2; // width of one copy (gap included); 0 while hidden
175
+ if (!paused && !reduce && half > 0 && els.player.hidden) {
176
+ offset = (offset + (MARQUEE_SPEED * dt) / 1000) % half;
177
+ els.marqueeTrack.style.transform = `translate3d(${-offset}px,0,0)`;
178
+ }
179
+ requestAnimationFrame(step);
180
+ }
181
+ requestAnimationFrame(step);
113
182
  }
114
183
 
115
184
  // ---------- Game loading ----------
@@ -124,21 +193,29 @@
124
193
 
125
194
  els.playerTitle.textContent = game.name;
126
195
  els.loaderName.textContent = game.name;
127
- els.loaderThumb.setAttribute("style", thumbStyle(game));
128
- els.loaderThumb.textContent = initials(game.name);
196
+ els.loaderDesc.textContent = game.desc || "";
197
+ const url = logoUrl(game);
198
+ els.loaderArt.style.backgroundImage = url ? `url("${url}"), ${gradient(game)}` : gradient(game);
199
+ els.loaderArt.textContent = url ? "" : initials(game.name);
129
200
  els.loader.hidden = false;
130
201
  els.retryBtn.hidden = true;
131
202
  els.barFill.style.width = "0%";
132
203
  els.loaderStatus.textContent = "Connecting…";
133
- document.title = `${game.name} · Richards Unblocked Games`;
204
+ document.title = `${game.name} · ${SITE_NAME}`;
134
205
 
135
206
  const urls = gameUrls(game);
136
207
  const loaded = new Array(urls.length).fill(0);
137
208
  const total = game.size || 1;
138
209
  const update = () => {
139
210
  const got = loaded.reduce((a, b) => a + b, 0);
140
- els.barFill.style.width = `${Math.min(100, (got / total) * 100).toFixed(1)}%`;
141
- els.loaderStatus.textContent = `${fmtMB(got)} / ${fmtMB(total)}${urls.length > 1 ? ` · ${urls.length} parts` : ""}`;
211
+ if (game.size) {
212
+ els.barFill.style.width = `${Math.min(100, (got / total) * 100).toFixed(1)}%`;
213
+ els.loaderStatus.textContent = `${fmtMB(got)} / ${fmtMB(total)}${urls.length > 1 ? ` · ${urls.length} parts` : ""}`;
214
+ } else {
215
+ // Size unknown (extra collection): show what has arrived so far
216
+ els.barFill.style.width = "35%";
217
+ els.loaderStatus.textContent = `${fmtMB(got)} loaded`;
218
+ }
142
219
  };
143
220
 
144
221
  try {
@@ -159,8 +236,10 @@
159
236
  }));
160
237
  if (currentLoad?.id !== game.id) return;
161
238
  els.loaderStatus.textContent = "Starting…";
162
- const html = await new Blob(pieces.flat()).text();
239
+ let html = await new Blob(pieces.flat()).text();
163
240
  if (currentLoad?.id !== game.id) return;
241
+ if (game.src === "gn") html = stripTracking(html);
242
+ html = guardHistory(html);
164
243
  const doc = freshFrame().contentDocument;
165
244
  doc.open();
166
245
  doc.write(html);
@@ -176,6 +255,24 @@
176
255
  }
177
256
  }
178
257
 
258
+ // The extra collection's launch files embed Google Analytics; remove it so players aren't tracked
259
+ function stripTracking(html) {
260
+ return html
261
+ .replace(/<script\b[^>]*googletagmanager\.com[^>]*>\s*<\/script>/gi, "")
262
+ .replace(/<script\b[^>]*>(?:(?!<\/script>)[\s\S])*?\bgtag\s*\((?:(?!<\/script>)[\s\S])*?<\/script>/gi, "");
263
+ }
264
+
265
+ // A written-in game shares this page's address and session history, so a game calling history.back()
266
+ // or pushState() would move the whole tab (and kick the player out). Give games no-op versions first.
267
+ const HISTORY_GUARD = "<script>(function(){try{var h=window.history,n=function(){};" +
268
+ "h.back=h.forward=h.go=h.pushState=h.replaceState=n;}catch(e){}})();<\/script>";
269
+ function guardHistory(html) {
270
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + HISTORY_GUARD);
271
+ if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + HISTORY_GUARD);
272
+ if (/^\s*<!doctype[^>]*>/i.test(html)) return html.replace(/^\s*<!doctype[^>]*>/i, (m) => m + HISTORY_GUARD);
273
+ return HISTORY_GUARD + html;
274
+ }
275
+
179
276
  function stopGame() {
180
277
  if (!currentLoad) return;
181
278
  currentLoad.controller.abort();
@@ -191,15 +288,12 @@
191
288
  return fresh;
192
289
  }
193
290
 
194
- // The blob: page is same-origin, so we can listen for the panic keys inside the game too
291
+ // The game frame is same-origin, so we can listen for the panic keys inside the game too
195
292
  function hookFrameKeys() {
196
- try {
197
- const win = els.frame.contentWindow;
198
- win.addEventListener("keydown", onKey, true);
199
- } catch {}
293
+ try { els.frame.contentWindow.addEventListener("keydown", onKey, true); } catch {}
200
294
  }
201
295
 
202
- // ---------- Routing (#play/<id>) so the back button works ----------
296
+ // ---------- Routing: "" home, #games, #recent, #play/<id> ----------
203
297
  function route() {
204
298
  const m = location.hash.match(/^#play\/(.+)$/);
205
299
  const game = m && byId.get(decodeURIComponent(m[1]));
@@ -208,36 +302,41 @@
208
302
  document.body.classList.add("playing");
209
303
  addRecent(game.id);
210
304
  if (currentLoad?.id !== game.id) loadGame(game);
211
- } else {
212
- stopGame();
213
- els.player.hidden = true;
214
- document.body.classList.remove("playing");
215
- document.title = "Richards Unblocked Games";
305
+ return;
216
306
  }
307
+ stopGame();
308
+ els.player.hidden = true;
309
+ document.body.classList.remove("playing");
310
+ document.title = SITE_NAME;
311
+ const view = location.hash === "#games" || location.hash === "#recent" ? "games" : "home";
312
+ els.homeView.hidden = view !== "home";
313
+ els.gamesView.hidden = view !== "games";
314
+ document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
315
+ b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
316
+ if (view === "games") renderRecent();
317
+ if (location.hash === "#recent") requestAnimationFrame(() => els.recentBlock.scrollIntoView({ block: "start" }));
217
318
  }
218
319
  function play(id) { location.hash = `#play/${encodeURIComponent(id)}`; }
219
- function goHome() {
220
- if (location.hash.startsWith("#play/")) history.length > 1 ? history.back() : (location.hash = "");
320
+ function go(view) {
321
+ const target = view === "home" ? "" : `#${view}`;
322
+ if (location.hash !== target) history.pushState(null, "", target || location.pathname + location.search);
323
+ route();
324
+ if (view !== "recent") window.scrollTo(0, 0);
325
+ }
326
+ function goBack() {
327
+ if (location.hash.startsWith("#play/")) history.length > 1 ? history.back() : (location.hash = "#games");
328
+ else if (location.hash) go("home");
221
329
  }
222
330
 
223
331
  // ---------- Utilities ----------
224
- // When opened through index.svg the site runs inside a frame, so redirect the whole tab
332
+ // If this page is ever shown inside a frame, redirect the whole tab
225
333
  function panic() {
226
334
  const url = CFG.panicUrl || "https://classroom.google.com/";
227
335
  try { window.top.location.replace(url); } catch { location.replace(url); }
228
336
  }
229
337
 
230
- function onKey(e) {
231
- const keys = CFG.panicKeys || ["`", "]"];
232
- if (keys.includes(e.key)) { e.preventDefault(); panic(); return; }
233
- if (e.key === "/" && document.activeElement !== els.search && els.player.hidden) {
234
- e.preventDefault(); els.search.focus();
235
- }
236
- if (e.key === "Escape" && document.activeElement === els.search) { els.search.value = ""; renderGrid(); els.search.blur(); }
237
- }
238
-
239
338
  function randomGame() {
240
- const pool = activeTag === "All" ? games : games.filter((g) => g.tag === activeTag);
339
+ const pool = !els.gamesView.hidden && activeTag !== "All" ? games.filter((g) => g.tag === activeTag) : games;
241
340
  if (pool.length) play(pool[Math.floor(Math.random() * pool.length)].id);
242
341
  }
243
342
 
@@ -248,9 +347,8 @@
248
347
  const d = new Date();
249
348
  let h = d.getHours();
250
349
  const m = String(d.getMinutes()).padStart(2, "0");
251
- const s = String(d.getSeconds()).padStart(2, "0");
252
- if (is24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}:${s}`; metaEl.textContent = "24H"; }
253
- else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}:${s}`; metaEl.textContent = ap; }
350
+ if (is24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
351
+ else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}`; metaEl.textContent = ap; }
254
352
  };
255
353
  $("clock").addEventListener("click", () => { is24 = !is24; store.set(CLOCK_KEY, is24); tick(); });
256
354
  tick();
@@ -261,7 +359,6 @@
261
359
  const btn = $("theme-btn");
262
360
  const apply = (t) => {
263
361
  document.documentElement.dataset.theme = t;
264
- btn.textContent = t === "dark" ? "🌙" : "☀️";
265
362
  btn.title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
266
363
  };
267
364
  apply(document.documentElement.dataset.theme === "light" ? "light" : "dark");
@@ -272,32 +369,118 @@
272
369
  });
273
370
  }
274
371
 
372
+ // ---------- Arrow keys and controller move focus between tiles and buttons ----------
373
+ function focusables() {
374
+ const scope = els.player.hidden ? document : els.player;
375
+ return [...scope.querySelectorAll(".tile, .rail-btn, .tab, .per-row button, #search, #sort, #home-input, .text-btn, .pill-btn")]
376
+ .filter((el) => el.offsetParent !== null && !el.closest("[hidden]"));
377
+ }
378
+ function moveFocus(dir) {
379
+ const cur = document.activeElement;
380
+ const vw = innerWidth;
381
+ const list = focusables().filter((el) => { const r = el.getBoundingClientRect(); return r.right > 0 && r.left < vw; });
382
+ if (!list.includes(cur)) { (list.find((el) => el.classList.contains("tile")) || list[0])?.focus(); return; }
383
+ const a = cur.getBoundingClientRect();
384
+ const ax = a.left + a.width / 2, ay = a.top + a.height / 2;
385
+ let best = null, bestScore = Infinity;
386
+ for (const el of list) {
387
+ if (el === cur) continue;
388
+ const b = el.getBoundingClientRect();
389
+ const dx = b.left + b.width / 2 - ax, dy = b.top + b.height / 2 - ay;
390
+ const main = { left: -dx, right: dx, up: -dy, down: dy }[dir];
391
+ if (main <= 4) continue;
392
+ const cross = dir === "left" || dir === "right" ? Math.abs(dy) : Math.abs(dx);
393
+ const score = main + cross * 2.5;
394
+ if (score < bestScore) { bestScore = score; best = el; }
395
+ }
396
+ if (best) { best.focus({ preventScroll: true }); best.scrollIntoView({ block: "nearest", behavior: "smooth" }); }
397
+ }
398
+
399
+ function onKey(e) {
400
+ const keys = CFG.panicKeys || ["`", "]"];
401
+ if (keys.includes(e.key)) { e.preventDefault(); panic(); return; }
402
+ if (!els.player.hidden) {
403
+ if (e.key === "Escape" && document.activeElement !== els.frame) goBack();
404
+ return;
405
+ }
406
+ const typing = document.activeElement === els.search || document.activeElement === els.homeInput;
407
+ if (e.key === "/" && !typing) {
408
+ e.preventDefault();
409
+ (els.homeView.hidden ? els.search : els.homeInput).focus();
410
+ return;
411
+ }
412
+ if (e.key === "Escape") {
413
+ if (typing) { document.activeElement.value = ""; renderGrid(); document.activeElement.blur(); } else goBack();
414
+ return;
415
+ }
416
+ const dir = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" }[e.key];
417
+ if (dir && !(typing && (dir === "left" || dir === "right"))) { e.preventDefault(); moveFocus(dir); }
418
+ }
419
+
420
+ // Gamepad: d-pad/left stick move, A select, B back, Y random. In a game only View (⧉) leaves,
421
+ // since games use A/B themselves.
422
+ function startGamepad() {
423
+ const prev = {};
424
+ let lastMove = 0;
425
+ const press = (i) => {
426
+ if (!els.player.hidden) { if (i === 8) goBack(); return; }
427
+ if (i === 0) document.activeElement?.click?.();
428
+ else if (i === 1) goBack();
429
+ else if (i === 3) randomGame();
430
+ else if (i === 12) moveFocus("up");
431
+ else if (i === 13) moveFocus("down");
432
+ else if (i === 14) moveFocus("left");
433
+ else if (i === 15) moveFocus("right");
434
+ };
435
+ function poll(t) {
436
+ const pads = navigator.getGamepads ? [...navigator.getGamepads()].filter(Boolean) : [];
437
+ for (const pad of pads) {
438
+ pad.buttons.forEach((b, i) => {
439
+ const key = `${pad.index}:${i}`;
440
+ if (b.pressed && !prev[key]) press(i);
441
+ prev[key] = b.pressed;
442
+ });
443
+ const [x, y] = pad.axes;
444
+ if (els.player.hidden && t - lastMove > 180 && (Math.abs(x) > 0.6 || Math.abs(y) > 0.6)) {
445
+ moveFocus(Math.abs(x) > Math.abs(y) ? (x > 0 ? "right" : "left") : (y > 0 ? "down" : "up"));
446
+ lastMove = t;
447
+ }
448
+ }
449
+ if (pads.length) requestAnimationFrame(poll);
450
+ }
451
+ window.addEventListener("gamepadconnected", () => requestAnimationFrame(poll));
452
+ }
453
+
275
454
  // ---------- Wire up ----------
276
455
  function bind() {
277
456
  document.addEventListener("keydown", onKey, true);
278
- const onCard = (e) => { const c = e.target.closest(".card"); if (c) play(c.dataset.id); };
279
- els.grid.addEventListener("click", onCard);
280
- els.recentGrid.addEventListener("click", onCard);
281
- els.search.addEventListener("input", renderGrid);
282
- els.chips.addEventListener("click", (e) => {
283
- const chip = e.target.closest(".chip"); if (!chip) return;
284
- activeTag = chip.dataset.tag;
285
- els.chips.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-selected", c === chip));
286
- renderGrid();
457
+ document.addEventListener("click", (e) => {
458
+ const tile = e.target.closest(".tile");
459
+ if (tile) { play(tile.dataset.id); return; }
460
+ const nav = e.target.closest("[data-nav]");
461
+ if (nav) { e.preventDefault(); go(nav.dataset.nav); }
287
462
  });
288
- // index.svg adds a <base> pointing at jsDelivr, so a plain href="#" would leave the page
289
- document.querySelector(".brand").addEventListener("click", (e) => {
290
- e.preventDefault();
291
- els.search.value = "";
463
+ // Typing on the home search jumps straight into the games list
464
+ const fromHome = () => {
465
+ els.search.value = els.homeInput.value;
466
+ els.homeInput.value = "";
467
+ setTag("All");
468
+ go("games");
469
+ els.search.focus();
292
470
  renderGrid();
293
- goHome();
294
- window.scrollTo(0, 0);
295
- });
471
+ };
472
+ els.homeInput.addEventListener("input", () => { if (els.homeInput.value.trim()) fromHome(); });
473
+ $("home-search").addEventListener("submit", (e) => { e.preventDefault(); fromHome(); });
474
+
475
+ els.search.addEventListener("input", renderGrid);
476
+ els.sort.addEventListener("change", renderGrid);
477
+ els.chips.addEventListener("click", (e) => { const tab = e.target.closest(".tab"); if (tab) setTag(tab.dataset.tag); });
478
+ document.querySelectorAll(".per-row button").forEach((b) => b.addEventListener("click", () => setPerRow(Number(b.dataset.per))));
296
479
  $("clear-recent").addEventListener("click", () => { store.set(RECENT_KEY, []); renderRecent(); });
297
480
  $("random-btn").addEventListener("click", randomGame);
298
481
  $("panic-btn").addEventListener("click", panic);
299
482
  $("panic-btn-2").addEventListener("click", panic);
300
- $("back-btn").addEventListener("click", goHome);
483
+ $("back-btn").addEventListener("click", goBack);
301
484
  $("reload-btn").addEventListener("click", () => {
302
485
  const g = currentLoad && byId.get(currentLoad.id);
303
486
  if (g) loadGame(g);
@@ -308,11 +491,14 @@
308
491
  (el.requestFullscreen || el.webkitRequestFullscreen)?.call(el);
309
492
  });
310
493
  window.addEventListener("hashchange", route);
494
+ window.addEventListener("popstate", route);
311
495
  }
312
496
 
313
497
  async function init() {
314
498
  initTheme();
315
499
  startClock();
500
+ startGamepad();
501
+ setPerRow(store.get(PER_ROW_KEY, 6));
316
502
  bind();
317
503
  try {
318
504
  const res = await fetch("games.json", { cache: "no-cache" });
@@ -321,14 +507,16 @@
321
507
  byId = new Map(games.map((g) => [g.id, g]));
322
508
  } catch (err) {
323
509
  console.error(err);
324
- els.empty.hidden = false;
325
- els.empty.textContent = "Couldn't load the game list (games.json).";
510
+ els.homeSub.textContent = "Couldn't load the game list. Try refreshing.";
326
511
  return;
327
512
  }
513
+ els.homeInput.placeholder = `Search ${games.length} games…`;
514
+ els.homeSub.innerHTML = `${games.length} games · press <kbd>/</kbd> to search`;
328
515
  els.footerCount.textContent = `${games.length} games`;
329
516
  renderChips();
330
- renderGrid();
331
517
  renderRecent();
518
+ renderGrid();
519
+ startMarquee();
332
520
  route();
333
521
  }
334
522