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.