PageSourceSearch

https://summerann.github.io/familiar-runner.js

js summerann.github.io collected 2026-10-03 10:00:18 UTC 61,445 bytes, 1,926 lines download raw bytes

1// Familiar Runner - top-down roguelite modal
2(function () {
3  const FR_VERSION = "1.0";
4  const ROOT_ID = "fr-modal-bg";
5  const STORAGE_KEY = "familiar_runner_meta";
6
7  const THEMES = {
8    neon: {
9      name: "Neon Arcade",
10      bg0: "#0b0f1f",
11      bg1: "#101a3a",
12      accent: "#00f5d4",
13      accent2: "#ff4fd8",
14      text: "#e9f0ff",
15      panel: "rgba(12,18,42,0.92)",
16      panelAlt: "rgba(18,26,60,0.85)",
17      grid: "rgba(255,255,255,0.08)",
18      glow: "rgba(0,245,212,0.35)",
19      danger: "#ff5d6c",
20      friendly: "#7df7ff",
21      particle: "#7bfbff"
22    },
23    cozy: {
24      name: "Cozy Pixel",
25      bg0: "#f7efe5",
26      bg1: "#f0dfcf",
27      accent: "#e27d60",
28      accent2: "#7bb08a",
29      text: "#2f2a24",
30      panel: "rgba(255,255,255,0.88)",
31      panelAlt: "rgba(255,255,255,0.72)",
32      grid: "rgba(47,42,36,0.08)",
33      glow: "rgba(226,125,96,0.28)",
34      danger: "#d77a61",
35      friendly: "#7bb08a",
36      particle: "#f2b179"
37    }
38  };
39
40  const QUESTIONS = [
41    {
42      clue: "Guardian that sees in the dark",
43      options: ["Owl", "Hound", "Rabbit"],
44      correct: 0
45    },
46    {
47      clue: "Forest trickster with a quick mind",
48      options: ["Fox", "Bear", "Boar"],
49      correct: 0
50    },
51    {
52      clue: "Tiny helper that keeps watch",
53      options: ["Sprite", "Golem", "Wyrm"],
54      correct: 0
55    },
56    {
57      clue: "Loyal protector of the hearth",
58      options: ["Hound", "Moth", "Eel"],
59      correct: 0
60    },
61    {
62      clue: "Quiet guide of lost paths",
63      options: ["Doe", "Raven", "Crab"],
64      correct: 1
65    },
66    {
67      clue: "Keeper of small fires",
68      options: ["Wisp", "Ox", "Hawk"],
69      correct: 0
70    }
71  ];
72
73  const MODES = [
74    {
75      id: "arcade",
76      title: "Arcade Run",
77      desc: "Waves, upgrades, best score",
78      hint: "90s run"
79    },
80    {
81      id: "daily",
82      title: "Daily Run",
83      desc: "One seed per day",
84      hint: "Local leaderboard"
85    },
86    {
87      id: "boss",
88      title: "Boss Rush",
89      desc: "3 bosses, gates to stun",
90      hint: "Patterns + timing"
91    },
92    {
93      id: "hub",
94      title: "Cozy Hub",
95      desc: "Walk to portals",
96      hint: "Practice + chill"
97    }
98  ];
99
100  const UPS_STRONG = [
101    {
102      id: "dash",
103      name: "Dash",
104      desc: "Unlock dash with brief invulnerability",
105      apply(state) {
106        state.player.dashUnlocked = true;
107      },
108      unique: true
109    },
110    {
111      id: "double_shot",
112      name: "Double Shot",
113      desc: "Fire two shots with slight spread",
114      apply(state) {
115        state.player.doubleShot = true;
116      },
117      unique: true
118    },
119    {
120      id: "shield_pulse",
121      name: "Shield Pulse",
122      desc: "Q releases a radial shock",
123      apply(state) {
124        state.player.shieldPulse = true;
125      },
126      unique: true
127    },
128    {
129      id: "slow_time",
130      name: "Slow-Time Burst",
131      desc: "E slows enemies briefly",
132      apply(state) {
133        state.player.slowTime = true;
134      },
135      unique: true
136    },
137    {
138      id: "homing",
139      name: "Homing Shots",
140      desc: "Bullets gently bend toward foes",
141      apply(state) {
142        state.player.homing = true;
143      },
144      unique: true
145    },
146    {
147      id: "ricochet",
148      name: "Ricochet",
149      desc: "Shots bounce once off walls",
150      apply(state) {
151        state.player.ricochet = true;
152      },
153      unique: true
154    },
155    {
156      id: "drone",
157      name: "Orbiting Drone",
158      desc: "A drone orbits and damages",
159      apply(state) {
160        state.player.drones += 1;
161      }
162    },
163    {
164      id: "fire_rate",
165      name: "Rapid Fire",
166      desc: "Fire rate +20%",
167      apply(state) {
168        state.player.fireRate *= 1.2;
169      }
170    },
171    {
172      id: "damage",
173      name: "Power Shot",
174      desc: "Damage +20%",
175      apply(state) {
176        state.player.damage *= 1.2;
177      }
178    },
179    {
180      id: "magnet",
181      name: "XP Magnet",
182      desc: "XP vacuum range +40%",
183      apply(state) {
184        state.player.magnet *= 1.4;
185      }
186    },
187    {
188      id: "hp",
189      name: "Heart",
190      desc: "Max HP +1",
191      apply(state) {
192        state.player.maxHp += 1;
193        state.player.hp += 1;
194      }
195    }
196  ];
197
198  const UPS_CHAOS = [
199    {
200      id: "glass_cannon",
201      name: "Glass Cannon",
202      desc: "Damage +60%, max HP -1",
203      apply(state) {
204        state.player.damage *= 1.6;
205        state.player.maxHp = Math.max(1, state.player.maxHp - 1);
206        state.player.hp = Math.min(state.player.hp, state.player.maxHp);
207      },
208      unique: true
209    },
210    {
211      id: "wild_spread",
212      name: "Wild Spread",
213      desc: "Shots randomly fan out",
214      apply(state) {
215        state.player.wildSpread = true;
216      },
217      unique: true
218    },
219    {
220      id: "dash_burn",
221      name: "Burning Dash",
222      desc: "Dash leaves a burning trail",
223      apply(state) {
224        state.player.burningDash = true;
225      },
226      unique: true
227    },
228    {
229      id: "volatile",
230      name: "Volatile Orbs",
231      desc: "XP orbs explode on pickup",
232      apply(state) {
233        state.player.orbBoom = true;
234      },
235      unique: true
236    },
237    {
238      id: "time_slip",
239      name: "Time Slip",
240      desc: "Random slow bursts trigger",
241      apply(state) {
242        state.player.timeSlip = true;
243      },
244      unique: true
245    },
246    {
247      id: "overclock",
248      name: "Overclock",
249      desc: "Move speed +25%, dash cooldown -20%",
250      apply(state) {
251        state.player.moveSpeed *= 1.25;
252        state.player.dashCooldown *= 0.8;
253      }
254    },
255    {
256      id: "lucky",
257      name: "Lucky Crits",
258      desc: "Crit chance +10%",
259      apply(state) {
260        state.player.crit += 0.1;
261      }
262    }
263  ];
264
265  const STYLE = `
266    #fr-btn { position:fixed;bottom:24px;right:80px;z-index:9997;font-family:'Raleway',system-ui,sans-serif;font-weight:700;letter-spacing:0.5px;padding:14px 22px;border-radius:18px;border:none;cursor:pointer;transition:transform 0.2s ease, box-shadow 0.2s ease; }
267    #fr-btn:hover { transform:translateY(-2px) scale(1.02); }
268    #fr-modal-bg { position:fixed;top:0;left:0;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;z-index:9996;background:rgba(5,8,14,0.35);backdrop-filter:blur(6px); }
269    #fr-modal { width:min(1040px,94vw);background:var(--fr-bg0);color:var(--fr-text);border-radius:28px;box-shadow:0 24px 60px rgba(0,0,0,0.35);position:relative;overflow:hidden; }
270    #fr-modal::before { content:'';position:absolute;inset:0;background:radial-gradient(circle at 20% 20%, var(--fr-bg1), transparent 60%),radial-gradient(circle at 80% 30%, rgba(255,255,255,0.06), transparent 55%),linear-gradient(135deg, var(--fr-bg0), var(--fr-bg1));opacity:0.95;z-index:0; }
271    #fr-modal::after { content:'';position:absolute;inset:-50%;background:repeating-linear-gradient(0deg,rgba(255,255,255,0.02),rgba(255,255,255,0.02) 1px,transparent 1px,transparent 3px);mix-blend-mode:soft-light;opacity:0.35;pointer-events:none; }
272    #fr-modal[data-theme="neon"] { --fr-bg0:${THEMES.neon.bg0};--fr-bg1:${THEMES.neon.bg1};--fr-accent:${THEMES.neon.accent};--fr-accent2:${THEMES.neon.accent2};--fr-text:${THEMES.neon.text};--fr-panel:${THEMES.neon.panel};--fr-panel-alt:${THEMES.neon.panelAlt};--fr-grid:${THEMES.neon.grid};--fr-glow:${THEMES.neon.glow};--fr-danger:${THEMES.neon.danger};--fr-friendly:${THEMES.neon.friendly}; }
273    #fr-modal[data-theme="cozy"] { --fr-bg0:${THEMES.cozy.bg0};--fr-bg1:${THEMES.cozy.bg1};--fr-accent:${THEMES.cozy.accent};--fr-accent2:${THEMES.cozy.accent2};--fr-text:${THEMES.cozy.text};--fr-panel:${THEMES.cozy.panel};--fr-panel-alt:${THEMES.cozy.panelAlt};--fr-grid:${THEMES.cozy.grid};--fr-glow:${THEMES.cozy.glow};--fr-danger:${THEMES.cozy.danger};--fr-friendly:${THEMES.cozy.friendly}; }
274    #fr-header { position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;padding:18px 24px 8px 24px; }
275    #fr-title { font-family:'Playfair Display',serif;font-size:1.6rem;letter-spacing:1px; }
276    #fr-controls { display:flex;gap:10px;align-items:center; }
277    .fr-pill { background:var(--fr-panel);border:1px solid rgba(255,255,255,0.08);color:var(--fr-text);padding:8px 14px;border-radius:999px;font-family:'Raleway',sans-serif;font-weight:600;font-size:0.9rem;cursor:pointer;transition:transform 0.2s ease, box-shadow 0.2s ease; }
278    .fr-pill:hover { transform:translateY(-1px);box-shadow:0 0 18px var(--fr-glow); }
279    #fr-close { position:absolute;top:16px;right:16px;z-index:5;background:transparent;border:none;color:var(--fr-text);font-size:1.2rem;cursor:pointer;opacity:0.8; }
280    #fr-game-wrap { position:relative;z-index:2;margin:8px 24px 18px;border-radius:22px;background:var(--fr-panel);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.06);overflow:hidden; }
281    #fr-canvas { width:100%;height:100%;display:block;background:transparent; }
282    #fr-overlay { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg, rgba(0,0,0,0.35), rgba(0,0,0,0.15));backdrop-filter:blur(2px);opacity:0;pointer-events:none;transition:opacity 0.2s ease; }
283    #fr-overlay.active { opacity:1;pointer-events:auto; }
284    #fr-hud { position:absolute;top:14px;left:14px;right:14px;display:flex;justify-content:space-between;align-items:flex-start;font-family:'JetBrains Mono',monospace;font-size:0.85rem;z-index:3;color:var(--fr-text);text-shadow:0 2px 8px rgba(0,0,0,0.3); }
285    #fr-hud .fr-hud-block { background:var(--fr-panel-alt);padding:8px 10px;border-radius:12px;min-width:160px;box-shadow:0 8px 18px rgba(0,0,0,0.15); }
286    #fr-hud .fr-hud-row { display:flex;justify-content:space-between;margin-bottom:4px; }
287    #fr-footer { position:relative;z-index:2;padding:0 24px 22px;color:var(--fr-text);opacity:0.8;font-family:'Raleway',sans-serif;font-size:0.85rem;display:flex;justify-content:space-between;align-items:center; }
288    .fr-panel { background:var(--fr-panel);padding:26px 28px;border-radius:22px;min-width:min(680px,85vw);box-shadow:0 12px 28px rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.08);animation:fr-pop 0.25s ease; }
289    .fr-panel h2 { font-family:'Playfair Display',serif;margin:0 0 6px;font-size:2rem; }
290    .fr-panel p { margin:0 0 12px;font-family:'Raleway',sans-serif; }
291    .fr-mode-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:16px; }
292    .fr-mode-card { background:var(--fr-panel-alt);padding:14px 16px;border-radius:16px;border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:transform 0.2s ease, box-shadow 0.2s ease; }
293    .fr-mode-card:hover { transform:translateY(-2px);box-shadow:0 0 20px var(--fr-glow); }
294    .fr-mode-card h3 { margin:0 0 6px;font-family:'Raleway',sans-serif;font-size:1.05rem; }
295    .fr-mode-card span { display:block;opacity:0.7;font-size:0.8rem;margin-top:6px; }
296    .fr-upgrade-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px; }
297    .fr-upgrade { background:var(--fr-panel-alt);padding:14px;border-radius:14px;border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:transform 0.2s ease, box-shadow 0.2s ease; }
298    .fr-upgrade:hover { transform:translateY(-2px);box-shadow:0 0 18px var(--fr-glow); }
299    .fr-upgrade strong { display:block;margin-bottom:4px;font-family:'Raleway',sans-serif; }
300    .fr-gate-options { display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:14px; }
301    .fr-gate-btn { background:var(--fr-panel-alt);padding:16px;border-radius:16px;border:1px solid rgba(255,255,255,0.08);cursor:pointer;font-family:'Raleway',sans-serif;font-weight:700;font-size:1rem;transition:transform 0.2s ease, box-shadow 0.2s ease;display:flex;flex-direction:column;align-items:center;gap:6px; }
302    .fr-gate-btn:hover { transform:translateY(-2px);box-shadow:0 0 18px var(--fr-glow); }
303    .fr-sigil { width:36px;height:36px;border-radius:12px;background:linear-gradient(135deg,var(--fr-accent),var(--fr-accent2));box-shadow:0 0 16px var(--fr-glow); }
304    .fr-sigil-1 { border-radius:50%; }
305    .fr-sigil-2 { clip-path:polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); }
306    .fr-label { display:inline-block;margin-top:6px;font-size:0.75rem;opacity:0.7; }
307    .fr-line { height:1px;background:rgba(255,255,255,0.12);margin:12px 0; }
308    .fr-controls-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:10px;font-size:0.85rem; }
309    .fr-tag { display:inline-block;padding:4px 8px;border-radius:8px;background:var(--fr-panel-alt);margin-right:8px;margin-top:6px;font-size:0.75rem; }
310    @keyframes fr-pop { from { transform:scale(0.96); opacity:0.6; } to { transform:scale(1); opacity:1; } }
311  `;
312
313  function loadMeta() {
314    try {
315      const raw = localStorage.getItem(STORAGE_KEY);
316      if (raw) return JSON.parse(raw);
317    } catch (err) {
318      return {};
319    }
320    return {};
321  }
322
323  function saveMeta(meta) {
324    try {
325      localStorage.setItem(STORAGE_KEY, JSON.stringify(meta));
326    } catch (err) {
327      return;
328    }
329  }
330
331  function ensureStyle() {
332    if (document.getElementById("fr-style")) return;
333    const style = document.createElement("style");
334    style.id = "fr-style";
335    style.innerHTML = STYLE;
336    document.head.appendChild(style);
337  }
338
339  function openModal() {
340    if (document.getElementById(ROOT_ID)) return;
341    ensureStyle();
342
343    const meta = loadMeta();
344    const theme = meta.theme || "neon";
345
346    const wrapper = document.createElement("div");
347    wrapper.id = ROOT_ID;
348    wrapper.innerHTML = `
349      <div id="fr-modal" data-theme="${theme}">
350        <button id="fr-close" aria-label="Close">x</button>
351        <div id="fr-header">
352          <div id="fr-title">Familiar Runner</div>
353          <div id="fr-controls">
354            <button class="fr-pill" id="fr-theme">Theme: ${THEMES[theme].name}</button>
355            <button class="fr-pill" id="fr-restart">Restart</button>
356            <button class="fr-pill" id="fr-pause">Pause</button>
357          </div>
358        </div>
359        <div id="fr-game-wrap">
360          <canvas id="fr-canvas"></canvas>
361          <div id="fr-hud">
362            <div class="fr-hud-block">
363              <div class="fr-hud-row"><span>HP</span><span id="fr-hp">0</span></div>
364              <div class="fr-hud-row"><span>LV</span><span id="fr-lv">1</span></div>
365              <div class="fr-hud-row"><span>XP</span><span id="fr-xp">0/0</span></div>
366              <div class="fr-hud-row"><span>Combo</span><span id="fr-combo">0</span></div>
367            </div>
368            <div class="fr-hud-block">
369              <div class="fr-hud-row"><span>Time</span><span id="fr-time">0.0</span></div>
370              <div class="fr-hud-row"><span>Score</span><span id="fr-score">0</span></div>
371              <div class="fr-hud-row"><span>Best</span><span id="fr-best">0</span></div>
372              <div class="fr-hud-row"><span>Mode</span><span id="fr-mode">
372Arcade</span></div>
373            </div>
374          </div>
375          <div id="fr-overlay"></div>
376        </div>
377        <div id="fr-footer">
378          <div>WASD move, Mouse aim, Click shoot, Space dash, Q shield, E slow time, F interact, Esc pause</div>
379          <div>v${FR_VERSION}</div>
380        </div>
381      </div>
382    `;
383
384    document.body.appendChild(wrapper);
385
386    const modal = document.getElementById("fr-modal");
387    const overlay = document.getElementById("fr-overlay");
388    const canvas = document.getElementById("fr-canvas");
389    const hud = {
390      hp: document.getElementById("fr-hp"),
391      lv: document.getElementById("fr-lv"),
392      xp: document.getElementById("fr-xp"),
393      combo: document.getElementById("fr-combo"),
394      time: document.getElementById("fr-time"),
395      score: document.getElementById("fr-score"),
396      best: document.getElementById("fr-best"),
397      mode: document.getElementById("fr-mode")
398    };
399
400    const ctx = canvas.getContext("2d");
401    let hazardTimer = null;
402    let dpr = Math.max(1, window.devicePixelRatio || 1);
403    let viewW = 960;
404    let viewH = 540;
405
406    function resizeCanvas() {
407      const rect = canvas.getBoundingClientRect();
408      viewW = rect.width;
409      viewH = rect.height;
410      dpr = Math.max(1, window.devicePixelRatio || 1);
411      canvas.width = Math.floor(viewW * dpr);
412      canvas.height = Math.floor(viewH * dpr);
413    }
414
415    resizeCanvas();
416    window.addEventListener("resize", resizeCanvas);
417
418    const state = createGameState(theme);
419
420    function syncTheme(next) {
421      state.theme = next;
422      modal.setAttribute("data-theme", next);
423      document.getElementById("fr-theme").textContent = `Theme: ${THEMES[next].name}`;
424      meta.theme = next;
425      saveMeta(meta);
426      const btn = document.getElementById("fr-btn");
427      if (btn) applyButtonTheme(btn, next);
428    }
429
430    function showOverlay(html) {
431      overlay.innerHTML = html;
432      overlay.classList.add("active");
433      state.paused = true;
434    }
435
436    function hideOverlay() {
437      overlay.classList.remove("active");
438      overlay.innerHTML = "";
439      state.paused = false;
440    }
441
442    function startMenu() {
443      const cards = MODES.map((mode) => `
444        <div class="fr-mode-card" data-action="mode" data-mode="${mode.id}">
445          <h3>${mode.title}</h3>
446          <div>${mode.desc}</div>
447          <span>${mode.hint}</span>
448        </div>
449      `).join("");
450
451      const html = `
452        <div class="fr-panel">
453          <h2>Familiar Runner</h2>
454          <p>Top-down roguelite with question gates, upgrades, and combo memory.</p>
455          <div class="fr-line"></div>
456          <div class="fr-mode-grid">${cards}</div>
457          <div class="fr-controls-grid">
458            <div><span class="fr-tag">Gate</span>Answer to earn strong upgrades.</div>
459            <div><span class="fr-tag">Combo</span>Correct streak boosts magnet + crit.</div>
460            <div><span class="fr-tag">Dash</span>Unlock at level 2.</div>
461          </div>
462        </div>
463      `;
464      showOverlay(html);
465    }
466
467    function pauseMenu() {
468      const html = `
469        <div class="fr-panel">
470          <h2>Paused</h2>
471          <p>Catch your breath.</p>
472          <div class="fr-line"></div>
473          <div class="fr-upgrade-grid">
474            <div class="fr-upgrade" data-action="resume"><strong>Resume</strong>Back to the run.</div>
475            <div class="fr-upgrade" data-action="restart"><strong>Restart</strong>Start fresh.</div>
476            <div class="fr-upgrade" data-action="menu"><strong>Menu</strong>Pick a new mode.</div>
477          </div>
478        </div>
479      `;
480      showOverlay(html);
481    }
482
483    function gateOverlay(question) {
484      const options = question.options.map((opt, idx) => `
485        <button class="fr-gate-btn" data-action="gate" data-idx="${idx}">
486          <span class="fr-sigil fr-sigil-${idx}"></span>
487          <div>${opt}</div>
488          <span class="fr-label">Choose</span>
489        </button>
490      `).join("");
491
492      const html = `
493        <div class="fr-panel">
494          <h2>Question Gate</h2>
495          <p>${question.clue}</p>
496          <div class="fr-gate-options">${options}</div>
497        </div>
498      `;
499      showOverlay(html);
500    }
501
502    function upgradeOverlay(title, choices) {
503      const items = choices.map((up) => `
504        <div class="fr-upgrade" data-action="upgrade" data-upgrade="${up.id}">
505          <strong>${up.name}</strong>
506          <div>${up.desc}</div>
507        </div>
508      `).join("");
509
510      const html = `
511        <div class="fr-panel">
512          <h2>${title}</h2>
513          <p>Pick one upgrade to shape this run.</p>
514          <div class="fr-upgrade-grid">${items}</div>
515        </div>
516      `;
517      showOverlay(html);
518    }
519
520    function endOverlay(summary) {
521      const html = `
522        <div class="fr-panel">
523          <h2>Run Complete</h2>
524          <p>${summary}</p>
525          <div class="fr-line"></div>
526          <div class="fr-upgrade-grid">
527            <div class="fr-upgrade" data-action="restart"><strong>Restart</strong>Jump back in.</div>
528            <div class="fr-upgrade" data-action="menu"><strong>Menu</strong>Pick another mode.</div>
529          </div>
530        </div>
531      `;
532      showOverlay(html);
533    }
534
535    overlay.addEventListener("click", (event) => {
536      const target = event.target.closest("[data-action]");
537      if (!target) return;
538      const action = target.getAttribute("data-action");
539      if (action === "mode") {
540        const mode = target.getAttribute("data-mode");
541        hideOverlay();
542        startRun(mode);
543      }
544      if (action === "resume") {
545        hideOverlay();
546      }
547      if (action === "restart") {
548        hideOverlay();
549        startRun(state.mode || "arcade");
550      }
551      if (action === "menu") {
552        startMenu();
553      }
554      if (action === "gate") {
555        const idx = Number(target.getAttribute("data-idx"));
556        resolveGate(idx);
557      }
558      if (action === "upgrade") {
559        const id = target.getAttribute("data-upgrade");
560        applyUpgrade(id);
561      }
562    });
563
564    document.getElementById("fr-close").addEventListener("click", () => {
565      teardown();
566      wrapper.remove();
567    });
568
569    document.getElementById("fr-theme").addEventListener("click", () => {
570      syncTheme(state.theme === "neon" ? "cozy" : "neon");
571    });
572
573    document.getElementById("fr-restart").addEventListener("click", () => {
574      startRun(state.mode || "arcade");
575    });
576
577    document.getElementById("fr-pause").addEventListener("click", () => {
578      if (state.paused) {
579        hideOverlay();
580      } else {
581        pauseMenu();
582      }
583    });
584
585    const keys = {};
586    const mouse = { x: 0, y: 0, down: false };
587
588    function onKeyDown(e) {
589      keys[e.code] = true;
590      if (e.code === "Escape") {
591        if (state.paused) hideOverlay(); else pauseMenu();
592      }
593    }
594
595    function onKeyUp(e) {
596      keys[e.code] = false;
597    }
598
599    function onMouseMove(e) {
600      const rect = canvas.getBoundingClientRect();
601      mouse.x = ((e.clientX - rect.left) / rect.width) * viewW;
602      mouse.y = ((e.clientY - rect.top) / rect.height) * viewH;
603    }
604
605    function onMouseDown() { mouse.down = true; }
606    function onMouseUp() { mouse.down = false; }
607
608    window.addEventListener("keydown", onKeyDown);
609    window.addEventListener("keyup", onKeyUp);
610    canvas.addEventListener("mousemove", onMouseMove);
611    canvas.addEventListener("mousedown", onMouseDown);
612    canvas.addEventListener("mouseup", onMouseUp);
613    canvas.addEventListener("mouseleave", onMouseUp);
614
615    function teardown() {
616      window.removeEventListener("keydown", onKeyDown);
617      window.removeEventListener("keyup", onKeyUp);
618      window.removeEventListener("resize", resizeCanvas);
619      canvas.removeEventListener("mousemove", onMouseMove);
620      canvas.removeEventListener("mousedown", onMouseDown);
621      canvas.removeEventListener("mouseup", onMouseUp);
622      canvas.removeEventListener("mouseleave", onMouseUp);
623      if (hazardTimer) clearInterval(hazardTimer);
624      cancelAnimationFrame(state.rafId);
625    }
626
627    function createGameState(initialTheme) {
628      return {
629        theme: initialTheme,
630        mode: null,
631        paused: true,
632        time: 0,
633        runLength: 90,
634        nextGateAt: 22,
635        nextWaveAt: 18,
636        waveIndex: 0,
637        score: 0,
638        best: 0,
639        gatePending: null,
640        rng: Math.random,
641        hitStop: 0,
642        shake: 0,
643        bullets: [],
644        enemyBullets: [],
645        enemies: [],
646        particles: [],
647        orbs: [],
648        hazards: [],
649        drones: [],
650        overlays: [],
651        boss: null,
652        player: createPlayer(),
653        stars: createStars(),
654        cam: { x: 0, y: 0 },
655        rafId: 0,
656        lastTime: performance.now(),
657        hudTimer: 0,
658        upgradesTaken: {}
659      };
660    }
661
662    function createPlayer() {
663      return {
664        x: 0,
665        y: 0,
666        vx: 0,
667        vy: 0,
668        radius: 16,
669        accel: 2400,
670        friction: 8,
671        moveSpeed: 520,
672        dashSpeed: 1200,
673        dashTime: 0.18,
674        dashCooldown: 2.4,
675        dashReady: 0,
676        dashActive: 0,
677        dashUnlocked: false,
678        dashDir: { x: 1, y: 0 },
679        invuln: 0,
680        hp: 5,
681        maxHp: 5,
682        level: 1,
683        xp: 0,
684        xpTo: 16,
685        magnet: 120,
686        fireRate: 4,
687        fireCd: 0,
688        damage: 8,
689        crit: 0.05,
690        doubleShot: false,
691        homing: false,
692        ricochet: false,
693        drones: 0,
694        shieldPulse: false,
695        shieldCd: 0,
696        slowTime: false,
697        slowCd: 0,
698        slowActive: 0,
699        wildSpread: false,
700        burningDash: false,
701        orbBoom: false,
702        timeSlip: false,
703        combo: 0
704      };
705    }
706
707    function startRun(mode) {
708      state.mode = mode;
709      state.time = 0;
710      state.score = 0;
711      state.waveIndex = 0;
712      state.nextGateAt = 22;
713      state.nextWaveAt = 18;
714      state.gatePending = null;
715      state.bullets = [];
716      state.enemyBullets = [];
717      state.enemies = [];
718      state.particles = [];
719      state.orbs = [];
720      state.hazards = [];
721      state.drones = [];
722      state.boss = null;
723      state.player = createPlayer();
724      state.stars = createStars();
725      state.upgradesTaken = {};
726
727      const worldCenter = { x: 0, y: 0 };
728      state.player.x = worldCenter.x;
729      state.player.y = worldCenter.y;
730      state.cam.x = state.player.x;
731      state.cam.y = state.player.y;
732
733      state.runLength = mode === "boss" ? 180 : 90;
734      state.rng = mode === "daily" ? seededRng(getDailySeed()) : Math.random;
735
736      state.best = getBestScore(mode);
737      hud.mode.textContent = modeLabel(mode);
738      hud.best.textContent = state.best;
739
740      if (mode === "hub") {
741        setupHub();
742      } else if (mode === "boss") {
743        spawnBoss(0);
744      }
745
746      hideOverlay();
747    }
748
749    function setupHub() {
750      state.enemies = [];
751      state.hazards = [];
752      state.orbs = [];
753    }
754
755    function modeLabel(mode) {
756      if (mode === "daily") return "Daily";
757      if (mode === "boss") return "Boss";
758      if (mode === "hub") return "Hub";
759      return "Arcade";
760    }
761
762    function getDailySeed() {
763      const now = new Date();
764      const yyyy = now.getFullYear();
765      const mm = String(now.getMonth() + 1).padStart(2, "0");
766      const dd = String(now.getDate()).padStart(2, "0");
767      return hashString(`${yyyy}-${mm}-${dd}`);
768    }
769
770    function getBestScore(mode) {
771      const key = mode === "daily" ? `fr_best_daily_${getDateKey()}` : `fr_best_${mode}`;
772      const val = localStorage.getItem(key);
773      return val ? Number(val) : 0;
774    }
775
776    function setBestScore(mode, score) {
777      const key = mode === "daily" ? `fr_best_daily_${getDateKey()}` : `fr_best_${mode}`;
778      localStorage.setItem(key, String(score));
779    }
780
781    function getDateKey() {
782      const now = new Date();
783      const yyyy = now.getFullYear();
784      const mm = String(now.getMonth() + 1).padStart(2, "0");
785      const dd = String(now.getDate()).padStart(2, "0");
786      return `${yyyy}-${mm}-${dd}`;
787    }
788
789    function resolveGate(choice) {
790      const question = state.gatePending;
791      if (!question) return;
792      const correct = choice === question.correct;
793      if (correct) {
794        state.player.combo += 1;
795      } else {
796        state.player.combo = 0;
797      }
798      if (state.mode === "boss" && state.boss) {
799        if (correct) {
800          state.boss.vulnerable = 6;
801          spawnHit(state.boss.x, state.boss.y);
802        } else {
803          spawnExplosion(state.boss.x, state.boss.y, 120, 0.6);
804        }
805      }
806      state.gatePending = null;
807      const pool = correct ? UPS_STRONG : UPS_CHAOS;
808      const label = correct ? "Strong Upgrade" : "Chaos Upgrade";
809      upgradeOverlay(label, pickUpgrades(pool));
810    }
811
812    function applyUpgrade(id) {
813      const upgrade = [...UPS_STRONG, ...UPS_CHAOS].find((u) => u.id === id);
814      if (!upgrade) return;
815      upgrade.apply(state);
816      if (upgrade.unique) state.upgradesTaken[id] = true;
817      hideOverlay();
818    }
819
820    function pickUpgrades(pool) {
821      const choices = [];
822      const available = pool.filter((up) => !up.unique || !state.upgradesTaken[up.id]);
823      const count = Math.min(3, available.length);
824      for (let i = 0; i < count; i++) {
825        const idx = Math.floor(state.rng() * available.length);
826        choices.push(available.splice(idx, 1)[0]);
827      }
828      return choices;
829    }
830
831    function scheduleGate() {
832      const delay = 20 + state.rng() * 20;
833      state.nextGateAt = state.time + delay;
834    }
835
836    function scheduleWave() {
837      state.nextWaveAt = state.time + 18 + state.rng() * 10;
838    }
839
840    function onLevelUp() {
841      state.player.level += 1;
842      state.player.xp -= state.player.xpTo;
843      state.player.xpTo = Math.floor(state.player.xpTo * 1.25 + 8);
844
845      if (state.player.level === 2) state.player.dashUnlocked = true;
846      if (state.player.level === 3) state.player.doubleShot = true;
847      if (state.player.level === 4) state.player.shieldPulse = true;
848      if (state.player.level === 5) state.player.slowTime = true;
849      if (state.player.level === 2) state.upgradesTaken.dash = true;
850      if (state.player.level === 3) state.upgradesTaken.double_shot = true;
851      if (state.player.level === 4) state.upgradesTaken.shield_pulse = true;
852      if (state.player.level === 5) state.upgradesTaken.slow_time = true;
853
854      upgradeOverlay("Level Up", pickUpgrades(UPS_STRONG));
855    }
856
857    function addScore(amount) {
858      state.score += amount;
859      if (state.score > state.best) {
860        state.best = state.score;
861        setBestScore(state.mode || "arcade", state.best);
862      }
863    }
864
865    function updateHud(dt) {
866      state.hudTimer += dt;
867      if (state.hudTimer < 0.1) return;
868      state.hudTimer = 0;
869      hud.hp.textContent = `${state.player.hp}/${state.player.maxHp}`;
870      hud.lv.textContent = state.player.level;
871      hud.xp.textContent = `${Math.floor(state.player.xp)}/${state.player.xpTo}`;
872      hud.combo.textContent = state.player.combo;
873      hud.time.textContent = state.time.toFixed(1);
874      hud.score.textContent = Math.floor(state.score);
875      hud.best.textContent = state.best;
876    }
877
878    function update(dt) {
879      if (state.paused) return;
880
881      if (state.hitStop > 0) {
882        state.hitStop -= dt;
883        if (state.hitStop < 0) state.hitStop = 0;
884        return;
885      }
886
887      state.time += dt;
888
889      if (state.mode !== "hub" && state.time >= state.runLength) {
890        endRun("Time limit reached.");
891        return;
892      }
893
894      if (state.mode !== "hub" && state.mode !== "boss") {
895        if (state.time >= state.nextGateAt) {
896          triggerGate();
897          scheduleGate();
898        }
899        if (state.time >= state.nextWaveAt) {
900          state.waveIndex += 1;
901          scheduleWave();
902          upgradeOverlay("Wave Clear", pickUpgrades(UPS_STRONG));
903        }
904      }
905
906      updatePlayer(dt);
907      updateDrones(dt);
908      updateBullets(dt);
909      updateEnemies(dt);
910      updateEnemyBullets(dt);
911      updateOrbs(dt);
912      updateHazards(dt);
913      updateParticles(dt);
914      updateBoss(dt);
915
916      if (state.player.xp >= state.player.xpTo) {
917        onLevelUp();
918      }
919
920      updateCamera(dt);
921    }
922
923    function updatePlayer(dt) {
924      const player = state.player;
925      const input = {
926        x: (keys["KeyD"] ? 1 : 0) - (keys["KeyA"] ? 1 : 0),
927        y: (keys["KeyS"] ? 1 : 0) - (keys["KeyW"] ? 1 : 0)
928      };
929
930      const len = Math.hypot(input.x, input.y) || 1;
931      input.x /= len;
932      input.y /= len;
933
934      if (input.x || input.y) {
935        player.dashDir.x = input.x;
936        player.dashDir.y = input.y;
937      }
938
939      const accel = player.accel * dt;
940      player.vx += input.x * accel;
941      player.vy += input.y * accel;
942
943      const friction = Math.exp(-player.friction * dt);
944      player.vx *= friction;
945      player.vy *= friction;
946
947      const speed = Math.hypot(player.vx, player.vy);
948      const maxSpeed = player.moveSpeed;
949      if (speed > maxSpeed) {
950        player.vx = (player.vx / speed) * maxSpeed;
951        player.vy = (player.vy / speed) * maxSpeed;
952      }
953
954      if (player.dashActive > 0) {
955        player.dashActive -= dt;
956        if (player.dashActive <= 0) {
957          player.dashActive = 0;
958        }
959        if (state.rng() < 0.6) {
960          spawnParticle(player.x, player.y, THEMES[state.theme].accent, 1.1, 0.25, 8);
961        }
962      }
963
964      if (player.dashReady > 0) player.dashReady -= dt;
965      if (player.invuln > 0) player.invuln -= dt;
966
967      if (player.dashUnlocked && keys["Space"] && player.dashReady <= 0 && player.dashActive <= 0) {
968        startDash();
969      }
970
971      if (player.shieldPulse && keys["KeyQ"] && player.shieldCd <= 0) {
972        pulseShield();
973      }
974
975      if (player.slowTime && keys["KeyE"] && player.slowCd <= 0) {
976        triggerSlowTime();
977      }
978
979      if (player.shieldCd > 0) player.shieldCd -= dt;
980      if (player.slowCd > 0) player.slowCd -= dt;
981      if (player.slowActive > 0) player.slowActive -= dt;
982
983      if (player.timeSlip && state.rng() < 0.004) {
984        player.slowActive = 2.2;
985      }
986
987      player.x += player.vx * dt;
988      player.y += player.vy * dt;
989
990      const bounds = 1000;
991      player.x = clamp(player.x, -bounds, bounds);
992      player.y = clamp(player.y, -bounds, bounds);
993
994      const worldMouse = screenToWorld(mouse.x, mouse.y);
995      const aim = normalize(worldMouse.x - player.x, worldMouse.y - player.y);
996
997      if (mouse.down) {
998        player.fireCd -= dt;
999        if (player.fireCd <= 0) {
1000          shoot(aim);
1001          player.fireCd = 1 / player.fireRate;
1002        }
1003      } else {
1004        player.fireCd = Math.min(player.fireCd, 0);
1005      }
1006    }
1007
1008    function startDash() {
1009      const player = state.player;
1010      player.dashActive = player.dashTime;
1011      const comboBonus = Math.min(0.35, player.combo * 0.05);
1012      player.dashReady = player.dashCooldown * (1 - comboBonus);
1013      player.invuln = Math.max(player.invuln, 0.35);
1014      const dir = normalize(player.dashDir.x, player.dashDir.y);
1015      player.vx = dir.x * player.dashSpeed;
1016      player.vy = dir.y * player.dashSpeed;
1017      state.shake = Math.min(1, state.shake + 0.4);
1018      for (let i = 0; i < 12; i++) {
1019        spawnParticle(player.x, player.y, THEMES[state.theme].particle, 1.2, 0.3, 10);
1020      }
1021      if (player.burningDash) {
1022        spawnFireTrail(player.x, player.y);
1023      }
1024    }
1025
1026    function pulseShield() {
1027      const player = state.player;
1028      player.shieldCd = 8;
1029      for (let i = 0; i < state.enemies.length; i++) {
1030        const enemy = state.enemies[i];
1031        const dist = distance(player.x, player.y, enemy.x, enemy.y);
1032        if (dist < 140) {
1033          enemy.hp -= 16;
1034          spawnHit(enemy.x, enemy.y);
1035        }
1036      }
1037      state.shake = Math.min(1, state.shake + 0.2);
1038      state.particles.push({
1039        x: player.x,
1040        y: player.y,
1041        vx: 0,
1042        vy: 0,
1043        life: 0.35,
1044        size: 160,
1045        color: THEMES[state.theme].accent,
1046        ring: true
1047      });
1048    }
1049
1050    function triggerSlowTime() {
1051      const player = state.player;
1052      player.slowCd = 12;
1053      player.slowActive = 3.2;
1054      state.particles.push({
1055        x: player.x,
1056        y: player.y,
1057        vx: 0,
1058        vy: 0,
1059        life: 0.45,
1060        size: 180,
1061        color: THEMES[state.theme].accent2,
1062        ring: true
1063      });
1064    }
1065
1066    function shoot(aim) {
1067      const player = state.player;
1068      if (!aim.x && !aim.y) return;
1069      const baseSpeed = 760;
1070      const spread = player.wildSpread ? (state.rng() - 0.5) * 0.5 : 0;
1071      const angle = Math.atan2(aim.y, aim.x) + spread;
1072      const shots = player.doubleShot ? 2 : 1;
1073      const critChance = player.crit + player.combo * 0.02;
1074      for (let i = 0; i < shots; i++) {
1075        const offset = shots === 2 ? (i === 0 ? -0.08 : 0.08) : 0;
1076        const dir = { x: Math.cos(angle + offset), y: Math.sin(angle + offset) };
1077        state.bullets.push({
1078          x: player.x + dir.x * 18,
1079          y: player.y + dir.y * 18,
1080          vx: dir.x * baseSpeed,
1081          vy: dir.y * baseSpeed,
1082          life: 1.4,
1083          damage: player.damage,
1084          r: 4,
1085          crit: state.rng() < critChance,
1086          homing: player.homing,
1087          bounce: player.ricochet ? 1 : 0
1088        });
1089      }
1090    }
1091
1092    function updateBullets(dt) {
1093      for (let i = state.bullets.length - 1; i >= 0; i--) {
1094        const b = state.bullets[i];
1095        let removed = false;
1096        b.life -= dt;
1097        if (b.life <= 0) {
1098          state.bullets.splice(i, 1);
1099          continue;
1100        }
1101
1102        if (b.homing && state.enemies.length) {
1103          const target = findNearestEnemy(b.x, b.y);
1104          if (target) {
1105            const dir = normalize(target.x - b.x, target.y - b.y);
1106            b.vx += dir.x * 1200 * dt;
1107            b.vy += dir.y * 1200 * dt;
1108          }
1109        }
1110
1111        b.x += b.vx * dt;
1112        b.y += b.vy * dt;
1113
1114        const bounds = 1040;
1115        if (Math.abs(b.x) > bounds || Math.abs(b.y) > bounds) {
1116          if (b.bounce > 0) {
1117            b.bounce -= 1;
1118            if (Math.abs(b.x) > bounds) b.vx *= -1;
1119            if (Math.abs(b.y) > bounds) b.vy *= -1;
1120            b.x = clamp(b.x, -bounds, bounds);
1121            b.y = clamp(b.y, -bounds, bounds);
1122          } else {
1123            state.bullets.splice(i, 1);
1124            continue;
1125          }
1126        }
1127
1128        for (let j = state.enemies.length - 1; j >= 0; j--) {
1129          const enemy = state.enemies[j];
1130          if (distance(b.x, b.y, enemy.x, enemy.y) < enemy.r + b.r) {
1131            const dmg = b.crit ? b.damage * 1.8 : b.damage;
1132            enemy.hp -= dmg;
1133            spawnHit(enemy.x, enemy.y);
1134            if (!b.bounce) {
1135              state.bullets.splice(i, 1);
1136              removed = true;
1137            }
1138            if (enemy.hp <= 0) {
1139              killEnemy(enemy, j);
1140            }
1141            break;
1142          }
1143        }
1144        if (removed) continue;
1145        if (state.mode === "boss" && state.boss) {
1146          const boss = state.boss;
1147          if (distance(b.x, b.y, boss.x, boss.y) < boss.r + b.r) {
1148            const dmg = b.crit ? b.damage * 1.8 : b.damage;
1149            const scale = boss.vulnerable > 0 ? 1 : 0.25;
1150            boss.hp -= dmg * scale;
1151            spawnHit(boss.x, boss.y);
1152            state.bullets.splice(i, 1);
1153          }
1154        }
1155      }
1156    }
1157
1158    function updateEnemyBullets(dt) {
1159      for (let i = state.enemyBullets.length - 1; i >= 0; i--) {
1160        const b = state.enemyBullets[i];
1161        b.life -= dt;
1162        if (b.life <= 0) {
1163          state.enemyBullets.splice(i, 1);
1164          continue;
1165        }
1166        b.x += b.vx * dt;
1167        b.y += b.vy * dt;
1168
1169        if (distance(b.x, b.y, state.player.x, state.player.y) < b.r + state.player.radius) {
1170          damagePlayer(1);
1171          state.enemyBullets.splice(i, 1);
1172        }
1173      }
1174    }
1175
1176    function updateEnemies(dt) {
1177      if (state.mode === "hub" || state.mode === "boss") return;
1178
1179      const targetCount = Math.min(4 + state.waveIndex * 2, 32);
1180      if (state.enemies.length < targetCount) {
1181        if (state.rng() < 0.08) spawnEnemy(randomEnemyType());
1182      }
1183
1184      for (let i = state.enemies.length - 1; i >= 0; i--) {
1185        const e = state.enemies[i];
1186        const slow = state.player.slowActive > 0 ? 0.55 : 1;
1187        if (e.type === "chaser") {
1188          const dir = normalize(state.player.x - e.x, state.player.y - e.y);
1189          e.vx = dir.x * e.speed * slow;
1190          e.vy = dir.y * e.speed * slow;
1191        } else if (e.type === "shooter") {
1192          const dist = distance(state.player.x, state.player.y, e.x, e.y);
1193          const dir = normalize(state.player.x - e.x, state.player.y - e.y);
1194          if (dist < 220) {
1195            e.vx = -dir.x * e.speed * slow;
1196            e.vy = -dir.y * e.speed * slow;
1197          } else if (dist > 360) {
1198            e.vx = dir.x * e.speed * slow;
1199            e.vy = dir.y * e.speed * slow;
1200          } else {
1201            e.vx *= 0.8;
1202            e.vy *= 0.8;
1203          }
1204          e.shootCd -= dt;
1205          if (e.shootCd <= 0) {
1206            e.shootCd = 2.2;
1207            enemyShoot(e);
1208          }
1209        } else if (e.type === "charger") {
1210          if (!e.state) {
1211            e.state = "telegraph";
1212            e.stateTimer = 0.7;
1213          }
1214          if (e.state === "telegraph") {
1215            e.stateTimer -= dt;
1216            if (e.stateTimer <= 0) {
1217              e.state = "charge";
1218              e.stateTimer = 0.4;
1219              const dir = normalize(state.player.x - e.x, state.player.y - e.y
1219);
1220              e.vx = dir.x * e.speed * 3.2;
1221              e.vy = dir.y * e.speed * 3.2;
1222            }
1223          } else if (e.state === "charge") {
1224            e.stateTimer -= dt;
1225            if (e.stateTimer <= 0) {
1226              e.state = "cool";
1227              e.stateTimer = 0.4;
1228              e.vx *= 0.4;
1229              e.vy *= 0.4;
1230            }
1231          } else if (e.state === "cool") {
1232            e.stateTimer -= dt;
1233            if (e.stateTimer <= 0) {
1234              e.state = null;
1235            }
1236          }
1237        }
1238
1239        e.x += e.vx * dt;
1240        e.y += e.vy * dt;
1241
1242        if (distance(e.x, e.y, state.player.x, state.player.y) < e.r + state.player.radius) {
1243          damagePlayer(1);
1244          e.vx *= -0.6;
1245          e.vy *= -0.6;
1246        }
1247      }
1248    }
1249
1250    function updateBoss(dt) {
1251      if (state.mode !== "boss" || !state.boss) return;
1252      const boss = state.boss;
1253      const slow = state.player.slowActive > 0 ? 0.55 : 1;
1254      if (boss.vulnerable > 0) boss.vulnerable -= dt;
1255      boss.timer -= dt;
1256      boss.shootCd -= dt;
1257      boss.chargeCd -= dt;
1258
1259      if (boss.timer <= 0) {
1260        boss.timer = 6 + state.rng() * 4;
1261        if (!state.gatePending) triggerGate();
1262      }
1263
1264      if (boss.shootCd <= 0) {
1265        boss.shootCd = boss.pattern === 0 ? 1.8 : 1.2;
1266        bossShoot(boss);
1267      }
1268
1269      if (boss.chargeCd <= 0) {
1270        boss.chargeCd = 5.5;
1271        boss.charge = 0.45;
1272        const dir = normalize(state.player.x - boss.x, state.player.y - boss.y);
1273        boss.vx = dir.x * boss.speed * 2.4;
1274        boss.vy = dir.y * boss.speed * 2.4;
1275      }
1276
1277      if (boss.charge > 0) {
1278        boss.charge -= dt;
1279      } else {
1280        const dir = normalize(state.player.x - boss.x, state.player.y - boss.y);
1281        boss.vx = dir.x * boss.speed * slow;
1282        boss.vy = dir.y * boss.speed * slow;
1283      }
1284
1285      boss.x += boss.vx * dt;
1286      boss.y += boss.vy * dt;
1287
1288      if (distance(boss.x, boss.y, state.player.x, state.player.y) < boss.r + state.player.radius) {
1289        damagePlayer(1);
1290      }
1291
1292      if (boss.hp <= 0) {
1293        const next = boss.stage + 1;
1294        if (next < 3) {
1295          spawnBoss(next);
1296        } else {
1297          endRun("Bosses defeated.");
1298        }
1299      }
1300    }
1301
1302    function updateOrbs(dt) {
1303      const player = state.player;
1304      const magnet = player.magnet + player.combo * 20;
1305      for (let i = state.orbs.length - 1; i >= 0; i--) {
1306        const orb = state.orbs[i];
1307        const dist = distance(orb.x, orb.y, player.x, player.y);
1308        if (dist < magnet) {
1309          const dir = normalize(player.x - orb.x, player.y - orb.y);
1310          orb.vx += dir.x * 1200 * dt;
1311          orb.vy += dir.y * 1200 * dt;
1312        }
1313        orb.x += orb.vx * dt;
1314        orb.y += orb.vy * dt;
1315        if (dist < player.radius + 8) {
1316          player.xp += orb.value;
1317          if (player.orbBoom) {
1318            spawnExplosion(orb.x, orb.y, 80, 1);
1319          }
1320          state.orbs.splice(i, 1);
1321        }
1322      }
1323    }
1324
1325    function updateHazards(dt) {
1326      for (let i = state.hazards.length - 1; i >= 0; i--) {
1327        const hz = state.hazards[i];
1328        if (hz.type === "laser") {
1329          hz.angle += dt * 0.8;
1330          const playerDist = distance(state.player.x, state.player.y, hz.x, hz.y);
1331          if (playerDist < hz.length) {
1332            const angleToPlayer = Math.atan2(state.player.y - hz.y, state.player.x - hz.x);
1333            const diff = Math.abs(normalizeAngle(hz.angle - angleToPlayer));
1334            if (diff < 0.15) {
1335              damagePlayer(1);
1336            }
1337          }
1338        } else if (hz.type === "spike") {
1339          if (
1340            Math.abs(state.player.x - hz.x) < hz.w / 2 &&
1341            Math.abs(state.player.y - hz.y) < hz.h / 2
1342          ) {
1343            damagePlayer(1);
1344          }
1345        } else if (hz.type === "barrel") {
1346          hz.timer -= dt;
1347          if (hz.timer <= 0) {
1348            spawnExplosion(hz.x, hz.y, 140, 1);
1349            state.hazards.splice(i, 1);
1350            continue;
1351          }
1352        }
1353      }
1354    }
1355
1356    function updateParticles(dt) {
1357      for (let i = state.particles.length - 1; i >= 0; i--) {
1358        const p = state.particles[i];
1359        p.life -= dt;
1360        if (p.life <= 0) {
1361          state.particles.splice(i, 1);
1362          continue;
1363        }
1364        p.x += p.vx * dt;
1365        p.y += p.vy * dt;
1366      }
1367    }
1368
1369    function updateDrones(dt) {
1370      const player = state.player;
1371      const count = player.drones;
1372      if (count <= 0) return;
1373      if (state.drones.length !== count) {
1374        state.drones = [];
1375        for (let i = 0; i < count; i++) {
1376          state.drones.push({ angle: (Math.PI * 2 * i) / count, radius: 48 });
1377        }
1378      }
1379      for (let i = 0; i < state.drones.length; i++) {
1380        const drone = state.drones[i];
1381        drone.angle += dt * 1.8;
1382        drone.x = player.x + Math.cos(drone.angle) * drone.radius;
1383        drone.y = player.y + Math.sin(drone.angle) * drone.radius;
1384        for (let j = state.enemies.length - 1; j >= 0; j--) {
1385          const enemy = state.enemies[j];
1386          if (distance(drone.x, drone.y, enemy.x, enemy.y) < enemy.r + 8) {
1387            enemy.hp -= 6;
1388            spawnHit(enemy.x, enemy.y);
1389            if (enemy.hp <= 0) killEnemy(enemy, j);
1390          }
1391        }
1392        if (state.mode === "boss" && state.boss) {
1393          const boss = state.boss;
1394          if (distance(drone.x, drone.y, boss.x, boss.y) < boss.r + 10) {
1395            const scale = boss.vulnerable > 0 ? 1 : 0.25;
1396            boss.hp -= 4 * scale;
1397            spawnHit(boss.x, boss.y);
1398          }
1399        }
1400      }
1401    }
1402
1403    function updateCamera(dt) {
1404      const lag = 0.08;
1405      state.cam.x += (state.player.x - state.cam.x) * lag;
1406      state.cam.y += (state.player.y - state.cam.y) * lag;
1407      if (state.shake > 0) {
1408        state.shake = Math.max(0, state.shake - dt * 1.6);
1409      }
1410    }
1411
1412    function endRun(reason) {
1413      const score = Math.floor(state.score);
1414      endOverlay(`${reason} Score: ${score}.`);
1415    }
1416
1417    function damagePlayer(amount) {
1418      const player = state.player;
1419      if (player.invuln > 0) return;
1420      player.hp -= amount;
1421      player.invuln = 0.8;
1422      state.shake = Math.min(1, state.shake + 0.3);
1423      spawnHit(player.x, player.y);
1424      if (player.hp <= 0) {
1425        endRun("You fell in the arena.");
1426      }
1427    }
1428
1429    function triggerGate() {
1430      if (state.paused) return;
1431      state.gatePending = QUESTIONS[Math.floor(state.rng() * QUESTIONS.length)];
1432      gateOverlay(state.gatePending);
1433    }
1434
1435    function spawnEnemy(type) {
1436      const angle = state.rng() * Math.PI * 2;
1437      const dist = 720;
1438      const e = {
1439        type,
1440        x: state.player.x + Math.cos(angle) * dist,
1441        y: state.player.y + Math.sin(angle) * dist,
1442        vx: 0,
1443        vy: 0,
1444        r: type === "charger" ? 18 : 16,
1445        hp: type === "charger" ? 30 : type === "shooter" ? 22 : 18,
1446        speed: type === "charger" ? 180 : type === "shooter" ? 120 : 160,
1447        shootCd: 1.5,
1448        state: null,
1449        stateTimer: 0
1450      };
1451      state.enemies.push(e);
1452    }
1453
1454    function randomEnemyType() {
1455      const roll = state.rng();
1456      if (roll < 0.45) return "chaser";
1457      if (roll < 0.75) return "shooter";
1458      return "charger";
1459    }
1460
1461    function enemyShoot(enemy) {
1462      const dir = normalize(state.player.x - enemy.x, state.player.y - enemy.y);
1463      state.enemyBullets.push({
1464        x: enemy.x,
1465        y: enemy.y,
1466        vx: dir.x * 360,
1467        vy: dir.y * 360,
1468        life: 2.2,
1469        r: 4
1470      });
1471    }
1472
1473    function spawnBoss(stage) {
1474      state.enemyBullets = [];
1475      state.boss = {
1476        stage,
1477        x: state.player.x + 420,
1478        y: state.player.y,
1479        vx: 0,
1480        vy: 0,
1481        r: 44,
1482        hp: 120 + stage * 50,
1483        maxHp: 120 + stage * 50,
1484        speed: 120 + stage * 15,
1485        vulnerable: 0,
1486        timer: 6,
1487        shootCd: 1.4,
1488        chargeCd: 3.8,
1489        charge: 0,
1490        pattern: stage % 2
1491      };
1492    }
1493
1494    function bossShoot(boss) {
1495      const bursts = boss.pattern === 0 ? 6 : 10;
1496      for (let i = 0; i < bursts; i++) {
1497        const angle = (Math.PI * 2 * i) / bursts;
1498        state.enemyBullets.push({
1499          x: boss.x,
1500          y: boss.y,
1501          vx: Math.cos(angle) * 280,
1502          vy: Math.sin(angle) * 280,
1503          life: 2.4,
1504          r: 4
1505        });
1506      }
1507    }
1508
1509    function killEnemy(enemy, index) {
1510      state.enemies.splice(index, 1);
1511      addScore(20 + state.waveIndex * 4);
1512      spawnOrb(enemy.x, enemy.y, 6);
1513      state.hitStop = 0.08;
1514      spawnExplosion(enemy.x, enemy.y, 60, 0.8);
1515    }
1516
1517    function spawnOrb(x, y, value) {
1518      state.orbs.push({ x, y, vx: 0, vy: 0, value });
1519    }
1520
1521    function spawnHit(x, y) {
1522      for (let i = 0; i < 6; i++) {
1523        spawnParticle(x, y, THEMES[state.theme].accent2, 1, 0.2, 14);
1524      }
1525    }
1526
1527    function spawnExplosion(x, y, radius, power) {
1528      const player = state.player;
1529      if (distance(x, y, player.x, player.y) < radius) {
1530        damagePlayer(1);
1531      }
1532      for (let i = 0; i < 18; i++) {
1533        const angle = state.rng() * Math.PI * 2;
1534        const speed = 140 + state.rng() * 120;
1535        state.particles.push({
1536          x,
1537          y,
1538          vx: Math.cos(angle) * speed,
1539          vy: Math.sin(angle) * speed,
1540          life: 0.6,
1541          size: 6 + state.rng() * 4,
1542          color: THEMES[state.theme].danger
1543        });
1544      }
1545      state.shake = Math.min(1, state.shake + power * 0.4);
1546    }
1547
1548    function spawnParticle(x, y, color, speedScale, life, size) {
1549      const angle = state.rng() * Math.PI * 2;
1550      const speed = (40 + state.rng() * 80) * speedScale;
1551      state.particles.push({
1552        x,
1553        y,
1554        vx: Math.cos(angle) * speed,
1555        vy: Math.sin(angle) * speed,
1556        life,
1557        size,
1558        color
1559      });
1560    }
1561
1562    function spawnFireTrail(x, y) {
1563      for (let i = 0; i < 5; i++) {
1564        spawnParticle(x, y, THEMES[state.theme].danger, 1.4, 0.4, 10);
1565      }
1566    }
1567
1568    function screenToWorld(sx, sy) {
1569      const cam = state.cam;
1570      const offsetX = viewW / 2;
1571      const offsetY = viewH / 2;
1572      return {
1573        x: cam.x + (sx - offsetX),
1574        y: cam.y + (sy - offsetY)
1575      };
1576    }
1577
1578    function render() {
1579      const theme = THEMES[state.theme];
1580      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1581      ctx.clearRect(0, 0, viewW, viewH);
1582
1583      const shakeMag = state.shake * 8;
1584      const shakeX = (Math.random() - 0.5) * shakeMag;
1585      const shakeY = (Math.random() - 0.5) * shakeMag;
1586
1587      ctx.translate(viewW / 2 + shakeX, viewH / 2 + shakeY);
1588      ctx.translate(-state.cam.x, -state.cam.y);
1589
1590      drawBackground(theme);
1591      drawHazards(theme);
1592      drawOrbs(theme);
1593      drawEnemies(theme);
1594      drawBoss(theme);
1595      drawBullets(theme);
1596      drawEnemyBullets(theme);
1597      drawDrones(theme);
1598      drawPlayer(theme);
1599      drawParticles(theme);
1600      if (state.mode === "hub") drawHub(theme);
1601    }
1602
1603    function drawBackground(theme) {
1604      ctx.save();
1605      for (const star of state.stars) {
1606        const px = star.x + state.cam.x * (1 - star.layer);
1607        const py = star.y + state.cam.y * (1 - star.layer);
1608        ctx.globalAlpha = 0.35 + star.layer * 0.4;
1609        ctx.fillStyle = theme.friendly;
1610        ctx.beginPath();
1611        ctx.arc(px, py, star.r, 0, Math.PI * 2);
1612        ctx.fill();
1613      }
1614      ctx.restore();
1615      const gridSize = 80;
1616      ctx.strokeStyle = theme.grid;
1617      ctx.lineWidth = 1;
1618      const startX = Math.floor((state.cam.x - viewW / 2) / gridSize) * gridSize - gridSize;
1619      const endX = state.cam.x + viewW / 2 + gridSize;
1620      const startY = Math.floor((state.cam.y - viewH / 2) / gridSize) * gridSize - gridSize;
1621      const endY = state.cam.y + viewH / 2 + gridSize;
1622      for (let x = startX; x < endX; x += gridSize) {
1623        ctx.beginPath();
1624        ctx.moveTo(x, startY);
1625        ctx.lineTo(x, endY);
1626        ctx.stroke();
1627      }
1628      for (let y = startY; y < endY; y += gridSize) {
1629        ctx.beginPath();
1630        ctx.moveTo(startX, y);
1631        ctx.lineTo(endX, y);
1632        ctx.stroke();
1633      }
1634    }
1635
1636    function drawPlayer(theme) {
1637      const p = state.player;
1638      const pulse = p.invuln > 0 ? 0.6 + Math.sin(performance.now() * 0.02) * 0.2 : 1;
1639      ctx.save();
1640      ctx.translate(p.x, p.y);
1641      const stretch = p.dashActive > 0 ? 1.2 : 1;
1642      ctx.scale(stretch, 1 / stretch);
1643      ctx.fillStyle = theme.accent;
1644      ctx.shadowColor = theme.glow;
1645      ctx.shadowBlur = 18;
1646      ctx.beginPath();
1647      ctx.arc(0, 0, p.radius * pulse, 0, Math.PI * 2);
1648      ctx.fill();
1649      ctx.restore();
1650    }
1651
1652    function drawBullets(theme) {
1653      ctx.shadowColor = theme.glow;
1654      ctx.shadowBlur = 10;
1655      for (const b of state.bullets) {
1656        ctx.fillStyle = b.crit ? theme.accent2 : theme.friendly;
1657        ctx.beginPath();
1658        ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
1659        ctx.fill();
1660      }
1661      ctx.shadowBlur = 0;
1662    }
1663
1664    function drawEnemyBullets(theme) {
1665      ctx.fillStyle = theme.danger;
1666      for (const b of state.enemyBullets) {
1667        ctx.beginPath();
1668        ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
1669        ctx.fill();
1670      }
1671    }
1672
1673    function drawEnemies(theme) {
1674      for (const e of state.enemies) {
1675        ctx.fillStyle = e.type === "shooter" ? theme.accent2 : theme.danger;
1676        ctx.beginPath();
1677        ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2);
1678        ctx.fill();
1679      }
1680    }
1681
1682    function drawBoss(theme) {
1683      if (!state.boss) return;
1684      const b = state.boss;
1685      ctx.fillStyle = theme.danger;
1686      ctx.beginPath();
1687      ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
1688      ctx.fill();
1689      ctx.strokeStyle = b.vulnerable > 0 ? theme.accent : theme.accent2;
1690      ctx.lineWidth = b.vulnerable > 0 ? 5 : 3;
1691      ctx.beginPath();
1692      ctx.arc(b.x, b.y, b.r + 8, 0, Math.PI * 2);
1693      ctx.stroke();
1694    }
1695
1696    function drawOrbs(theme) {
1697      ctx.fillStyle = theme.friendly;
1698      for (const orb of state.orbs) {
1699        ctx.beginPath();
1700        ctx.arc(orb.x, orb.y, 5, 0, Math.PI * 2);
1701        ctx.fill();
1702      }
1703    }
1704
1705    function drawHazards(theme) {
1706      for (const hz of state.hazards) {
1707        if (hz.type === "laser") {
1708          ctx.strokeStyle = theme.danger;
1709          ctx.lineWidth = 4;
1710          ctx.beginPath();
1711          ctx.moveTo(hz.x, hz.y);
1712          ctx.lineTo(hz.x + Math.cos(hz.angle) * hz.length, hz.y + Math.sin(hz.angle) * hz.length);
1713          ctx.stroke();
1714        } else if (hz.type === "spike") {
1715          ctx.fillStyle = theme.danger;
1716          ctx.fillRect(hz.x - hz.w / 2, hz.y - hz.h / 2, hz.w, hz.h);
1717        } else if (hz.type === "barrel") {
1718          ctx.fillStyle = theme.accent2;
1719          ctx.beginPath();
1720          ctx.arc(hz.x, hz.y, 12, 0, Math.PI * 2);
1721          ctx.fill();
1722        }
1723      }
1724    }
1725
1726    function drawParticles(theme) {
1727      for (const p of state.particles) {
1728        ctx.globalAlpha = Math.max(0, p.life * 1.8);
1729        ctx.fillStyle = p.color || theme.particle;
1730        if (p.ring) {
1731          ctx.strokeStyle = p.color || theme.accent;
1732          ctx.lineWidth = 3;
1733          ctx.beginPath();
1734          ctx.arc(p.x, p.y, p.size * (1 - p.life), 0, Math.PI * 2);
1735          ctx.stroke();
1736        } else {
1737          ctx.beginPath();
1738          ctx.arc(p.x, p.y, p.size || 4, 0, Math.PI * 2);
1739          ctx.fill();
1740        }
1741        ctx.globalAlpha = 1;
1742      }
1743    }
1744
1745    function drawDrones(theme) {
1746      for (const d of state.drones) {
1747        ctx.fillStyle = theme.accent2;
1748        ctx.beginPath();
1749        ctx.arc(d.x, d.y, 6, 0, Math.PI * 2);
1750        ctx.fill();
1751      }
1752    }
1753
1754    function drawHub(theme) {
1755      const hubs = getHubPortals();
1756      for (const hub of hubs) {
1757        ctx.fillStyle = theme.panelAlt;
1758        ctx.fillRect(hub.x - hub.w / 2, hub.y - hub.h / 2, hub.w, hub.h);
1759        ctx.strokeStyle = theme.accent;
1760        ctx.strokeRect(hub.x - hub.w / 2, hub.y - hub.h / 2, hub.w, hub.h);
1761        ctx.fillStyle = theme.text;
1762        ctx.font = "14px Raleway";
1763        ctx.textAlign = "center";
1764        ctx.fillText(hub.label, hub.x, hub.y + 4);
1765      }
1766    }
1767
1768    function getHubPortals() {
1769      return [
1770        { x: -260, y: -120, w: 120, h: 70, label: "Arcade" },
1771        { x: 260, y: -120, w: 120, h: 70, label: "Daily" },
1772        { x: -260, y: 140, w: 120, h: 70, label: "Boss" },
1773        { x: 260, y: 140, w: 120, h: 70, label: "Gallery" }
1774      ];
1775    }
1776
1777    function checkHubInteractions() {
1778      if (state.mode !== "hub") return;
1779      if (state.paused) return;
1780      if (!keys["KeyF"]) return;
1781      const hubs = getHubPortals();
1782      for (const hub of hubs) {
1783        if (
1784          Math.abs(state.player.x - hub.x) < hub.w / 2 &&
1785          Math.abs(state.player.y - hub.y) < hub.h / 2
1786        ) {
1787          if (hub.label === "Arcade") startRun("arcade");
1788          if (hub.label === "Daily") startRun("daily");
1789          if (hub.label === "Boss") startRun("boss");
1790          if (hub.label === "Gallery") startRun("arcade");
1791        }
1792      }
1793    }
1794
1795    function loop(timestamp) {
1796      const dt = Math.min(0.033, (timestamp - state.lastTime) / 1000);
1797      state.lastTime = timestamp;
1798      update(dt);
1799      checkHubInteractions();
1800      updateHud(dt);
1801      render();
1802      state.rafId = requestAnimationFrame(loop);
1803    }
1804
1805    function findNearestEnemy(x, y) {
1806      let best = null;
1807      let bestDist = Infinity;
1808      for (const e of state.enemies) {
1809        const dist = distance(x, y, e.x, e.y);
1810        if (dist < bestDist) {
1811          bestDist = dist;
1812          best = e;
1813        }
1814      }
1815      return best;
1816    }
1817
1818    function distance(x1, y1, x2, y2) {
1819      return Math.hypot(x2 - x1, y2 - y1);
1820    }
1821
1822    function normalize(x, y) {
1823      const len = Math.hypot(x, y);
1824      if (!len) return { x: 0, y: 0 };
1825      return { x: x / len, y: y / len };
1826    }
1827
1828    function clamp(value, min, max) {
1829      return Math.max(min, Math.min(max, value));
1830    }
1831
1832    function normalizeAngle(a) {
1833      const twoPi = Math.PI * 2;
1834      return ((a + Math.PI) % twoPi) - Math.PI;
1835    }
1836
1837    function createStars() {
1838      const stars = [];
1839      for (let i = 0; i < 70; i++) {
1840        stars.push({
1841          x: (Math.random() - 0.5) * 2600,
1842          y: (Math.random() - 0.5) * 2600,
1843          r: 1 + Math.random() * 2.2,
1844          layer: 0.2 + Math.random() * 0.6
1845        });
1846      }
1847      return stars;
1848    }
1849
1850    function hashString(str) {
1851      let h = 1779033703 ^ str.length;
1852      for (let i = 0; i < str.length; i++) {
1853        h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
1854        h = (h << 13) | (h >>> 19);
1855      }
1856      return (h >>> 0) + 1;
1857    }
1858
1859    function seededRng(seed) {
1860      let t = seed;
1861      return function () {
1862        t += 0x6d2b79f5;
1863        let r = Math.imul(t ^ (t >>> 15), t | 1);
1864        r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
1865        return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
1866      };
1867    }
1868
1869    function seedHazards() {
1870      if (state.mode === "hub" || state.mode === "boss") return;
1871      if (state.paused) return;
1872      if (state.hazards.length >= 2) return;
1873      const pick = state.rng();
1874      if (pick < 0.33) {
1875        state.hazards.push({ type: "laser", x: 0, y: 0, angle: state.rng() * Math.PI * 2, length: 620 });
1876      } else if (pick < 0.66) {
1877        state.hazards.push({ type: "spike", x: state.rng() * 400 - 200, y: state.rng() * 400 - 200, w: 90, h: 60 });
1878      } else {
1879        state.hazards.push({ type: "barrel", x: state.rng() * 400 - 200, y: state.rng() * 400 - 200, timer: 6 + state.rng() * 4 });
1880      }
1881    }
1882
1883    hazardTimer = setInterval(seedHazards, 8000);
1884
1885    startMenu();
1886    state.lastTime = performance.now();
1887    state.rafId = requestAnimationFrame(loop);
1888  }
1889
1890  function initButton() {
1891    if (document.getElementById("fr-btn")) return;
1892    ensureStyle();
1893    const btn = document.createElement("button");
1894    btn.id = "fr-btn";
1895    btn.textContent = "Play Familiar Runner";
1896    const meta = loadMeta();
1897    applyButtonTheme(btn, meta.theme || "neon");
1898    btn.addEventListener("click", openModal);
1899    document.body.appendChild(btn);
1900  }
1901
1902  function applyButtonTheme(btn, theme) {
1903    if (theme === "cozy") {
1904      btn.style.background = "linear-gradient(90deg,#f2b179,#7bb08a)";
1905      btn.style.color = "#2f2a24";
1906      btn.style.boxShadow = "0 0 18px rgba(226,125,96,0.35)";
1907    } else {
1908      btn.style.background = "linear-gradient(90deg,#00f5d4,#ff4fd8)";
1909      btn.style.color = "#ffffff";
1910      btn.style.boxShadow = "0 0 18px rgba(0,245,212,0.5)";
1911    }
1912  }
1913
1914  const SAE = (window.SummerAnnEnhancements = window.SummerAnnEnhancements || {});
1915  SAE.modules = SAE.modules || {};
1916  SAE.modules.familiarRunner = {
1917    init: initButton,
1918    open: openModal
1919  };
1920
1921  if (document.readyState === "loading") {
1922    document.addEventListener("DOMContentLoaded", initButton);
1923  } else {
1924    initButton();
1925  }
1926})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.