From 796880375e7d345af945f7f00ab8f460a6ca4e9e Mon Sep 17 00:00:00 2001 From: neon-survivors-dev Date: Sun, 23 Aug 2026 07:01:22 +0000 Subject: [PATCH] =?UTF-8?q?NEON=20SURVIVORS=20v1.1=20=E2=80=94=20full-feat?= =?UTF-8?q?ured=20bullet-heaven=20survivor=20game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js) --- .gitignore | 5 + README.md | 114 ++++ css/style.css | 387 ++++++++++++ docs/GAP-COMMERCIAL.md | 122 ++++ icons/icon-192.png | Bin 0 -> 2228 bytes icons/icon-512.png | Bin 0 -> 7076 bytes index.html | 226 +++++++ js/achievements.js | 90 +++ js/audio.js | 195 +++++++ js/data.js | 388 ++++++++++++ js/entities.js | 280 +++++++++ js/game.js | 739 +++++++++++++++++++++++ js/i18n.js | 486 ++++++++++++++++ js/main.js | 33 ++ js/render.js | 983 +++++++++++++++++++++++++++++++ js/save.js | 100 ++++ js/systems.js | 1262 ++++++++++++++++++++++++++++++++++++++++ js/ui.js | 1109 +++++++++++++++++++++++++++++++++++ js/util.js | 204 +++++++ manifest.webmanifest | 15 + server/lb-worker.js | 99 ++++ sw.js | 51 ++ test/integrity.js | 405 +++++++++++++ tools/gen-icons.js | 97 +++ tools/scan-calls.js | 49 ++ wrangler.toml | 8 + 26 files changed, 7447 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 css/style.css create mode 100644 docs/GAP-COMMERCIAL.md create mode 100644 icons/icon-192.png create mode 100644 icons/icon-512.png create mode 100644 index.html create mode 100644 js/achievements.js create mode 100644 js/audio.js create mode 100644 js/data.js create mode 100644 js/entities.js create mode 100644 js/game.js create mode 100644 js/i18n.js create mode 100644 js/main.js create mode 100644 js/render.js create mode 100644 js/save.js create mode 100644 js/systems.js create mode 100644 js/ui.js create mode 100644 js/util.js create mode 100644 manifest.webmanifest create mode 100644 server/lb-worker.js create mode 100644 sw.js create mode 100644 test/integrity.js create mode 100644 tools/gen-icons.js create mode 100644 tools/scan-calls.js create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1cee06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +Thumbs.db +*.log +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f308d6 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# 🌃 NEON SURVIVORS + +Một game **survivor / bullet-heaven** hoàn chỉnh (lấy cảm hứng từ Vampire Survivors), viết thuần bằng +HTML5 Canvas + JavaScript — **không cần cài đặt, không asset ngoài, không internet**, mọi thứ từ đồ họa +đến âm thanh đều được sinh tự động bằng code. + +![genre](https://img.shields.io/badge/genre-bullet%20heaven-7c5cff) ![offline](https://img.shields.io/badge/runs-offline-00e5ff) + +--- + +## ▶️ Cách chạy + +**Cách 1 — mở thẳng file:** nhấp đúp vào `index.html` (Chrome / Edge / Firefox). + +**Cách 2 — qua local server (khuyến nghị cho mobile LAN):** + +```bash +cd 2dsurvivor +python3 -m http.server 8080 # hoặc: npx serve . +# mở http://localhost:8080 +``` + +## 🎮 Điều khiển + +| Hành động | Desktop | Mobile | +|---|---|---| +| Di chuyển | `WASD` / Phím mũi tên | Kéo ngón tay trên màn hình (joystick ảo) | +| Tấn công | **Tự động** — cứ việc né! | Tự động | +| Tạm dừng | `Esc` / `P` | Nút hệ thống | +| Chọn nâng cấp | Chuột / `1`–`4` | Chạm | + +## ✨ Tính năng + +### Gameplay +- **11 vũ khí** riêng biệt (đũa phép, roi, phi đao, quả cầu quay, rìu, hào quang, thiên lôi, + cầu lửa, băng tinh, thánh giá, 🧨 mìn hẹn giờ) — mỗi vũ khí **8 cấp** với bảng nâng cấp riêng. +- **11 dạng tiến hóa**: (mìn → 💣 Vườn Pháo Hoa) vũ khí đầy cấp + sở hữu passive tương ứng → mở **Rương Báu Vật** + (rơi từ quái Tinh Anh & Trùm) để tiến hóa (VD: Đũa Phép + Siêu Dẫn ⇒ 🌟 Bão Thần Tín). +- **13 vật phẩm bị động** (sát thương, giáp, HP, hồi máu, hồi chiêu, diện tích, tốc đạn, + thời gian, di chuyển, nam châm, may mắn, tham lam, tuệ tăng). +- **10 nhân vật** mở khóa dần (thêm 🏹 Hana chí-mạng & 🧱 Bram diện-tích), kèm **🎨 skin màu** mua bằng vàng. +- **Đạo quái theo phút**: 8 loại quái thường (đuổi, bắn, bay lượn, húc, chia màn…), + quái **Tinh Anh** định kỳ, và **4 Trùm lớn** ở phút 5/10/19: + 🐺 Sói Đầu Lớn → 💀 Pháp Sư Xương → 👹 Quái Vật Địa Ngục → ⚰ **Tử Thần**. + Hạ Tử Thần ở phút 19 để **chiến thắng**. +- Hiệu ứng trạng thái: cháy, làm chậm, đóng băng (kèm sát thương vỡ băng), choáng, đẩy lùi, hút máu. +- Chí mạng, số sát thương nổi, hạt hiệu ứng, rung màn hình, mũi tên chỉ trùm ngoài màn hình. + +### Tiến trình & tùy chọn +- **Meta-progression**: vàng tích lũy vĩnh viễn, cửa hàng **12 nâng cấp vĩnh viễn** (có hồi sinh, bốc lại…). +- Lưu tự động bằng `localStorage` (khóa `neon_survivors_v1`), kèm nút xóa dữ liệu. +- **Tùy chọn đầy đủ**: âm lượng chung/nhạc/SFX, rung màn hình, số sát thương, chất lượng hạt + (Thấp/Vừa/Cao), hiện FPS, tự tạm dừng khi mất tiêu điểm, **ngôn ngữ Việt/Anh** chuyển tức thì, + **fullscreen**, **remap phím di chuyển**. +- Bách khoa toàn thư (Codex): xem toàn bộ vũ khí/passive/quái/trùm/nhân vật + điều kiện mở khóa. +- Màn hình tổng kết: thời gian, kills, vàng, cấp độ, kỷ lục cá nhân. +- Chơi được trên **desktop lẫn mobile** (joystick ảo), responsive theo DPR; cài được như app (PWA). + +### 🆕 Phase 1 — thương mại hóa +- **3 biome**: 🪦 Nghĩa Địa Neon → ❄️ Hang Băng (mở ở mốc 10 phút) → 🌋 Vực Magma (mở sau 1 thắng); + mỗi biome có bảng quái, kế hoạch trùm, palette và hệ số HP/vàng riêng. +- **Chế độ Vô tận (∞)** + **4 bậc độ khó** (I → 💀): HP/sát thương tăng, vàng thưởng tăng. +- **⚡ Thử thách hôm nay**: seed theo ngày UTC — cả thế giới chơi cùng 1 màn (nhân vật/stage cố định), + lưu kỷ lục ngày. +- **🏆 23 thành tựu** gắn tiến trình thật, popup vàng + tab riêng trong Codex. +- **💎 Đèn pha lê phá được** rơi quanh bản đồ → xu + gà. +- **📜 Lịch sử 20 ván gần nhất** + tổng hợp (tỉ lệ thắng, nhân vật chủ lực). +- **💾 Xuất/Nhập save** dạng mã base64 (chuyển máy). +- **Tutorial tương tác** lần đầu chơi + mẹo trùm. +- **Gamepad** (stick trái/D-pad/A/Start), perf: viewport culling + object pool particle. +- **🥇 Bảng xếp hạng**: scaffold Cloudflare Worker KV (`server/lb-worker.js` + `wrangler.toml`) — + điền URL vào Tuỳ chọn là tự submit/top; chữ ký checksum chống sửa tay (client↔worker khớp 100%). + +## 🔓 Gợi ý mở khóa + +| Nhân vật | Điều kiện | +|---|---| +| Mira | Diệt tổng cộng 500 quái | +| Bolt | Sống sót đến phút 10 | +| Luna | Kiếm tổng cộng 2.000 vàng | +| Rook | Hạ trùm đầu tiên (phút 5) | +| Nyx | Sống sót đến phút 15 | +| Sera | Thắng 1 ván | +| Vane | Diệt tổng cộng 5.000 quái | + +## 🧪 Kiểm thử (dành cho dev) + +```bash +node test/integrity.js +``` +Bộ test headless kiểm tra tính toàn vẹn dữ liệu (tiến hóa, bảng spawn, song ngữ VN/EN, bảng stat) +và **mô phỏng 2.5 phút chiến đấu thật** trong sandbox VM: bắn, chết, rớt đồ, lên cấp, mở rương +tiến hóa, não AI của cả 4 trùm, i-frame và dòng chết. + +## 📁 Cấu trúc + +``` +index.html entry point +css/style.css theme neon tối +js/ + util.js toán học, RNG, spatial grid broadphase + i18n.js từ điển VI/EN + save.js localStorage meta-progression + audio.js tổng hợp SFX + nhạc nền generative (WebAudio) + data.js TOÀN BỘ nội dung: vũ khí/passive/quái/trùm/cửa hàng + entities.js Player / Enemy / Proj / Pickup / Particle… + systems.js spawn director, AI, combat, pickups, level-up, chest + render.js vẽ canvas (nhân vật vector, hiệu ứng, vignette) + ui.js menu / HUD / modal DOM + game.js vòng lặp, trạng thái, input, camera +test/integrity.js bộ test headless +``` + +Chúc bạn sống sót qua phút thứ 19! ⚰️🔥 diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..be87e6a --- /dev/null +++ b/css/style.css @@ -0,0 +1,387 @@ +/* ============================================================ + NEON SURVIVORS — stylesheet + Dark neon arcade theme. No external assets. + ============================================================ */ +:root{ + --bg:#07070f; + --panel:#10101f; + --panel2:#161630; + --line:#262650; + --txt:#e8e8ff; + --dim:#8a8ab8; + --neon:#7c5cff; + --neon2:#00e5ff; + --gold:#ffd24a; + --red:#ff4d6d; + --green:#3ddc84; + font-size:16px; +} +*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent} +html,body{height:100%;overflow:hidden;background:var(--bg)} +body{ + font-family:"Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif; + color:var(--txt);user-select:none;-webkit-user-select:none;touch-action:none; +} +.hidden{display:none !important} + +#game{position:fixed;inset:0;width:100%;height:100%;display:block;background:#05050c} + +/* ---------- generic ---------- */ +.btn{ + display:inline-flex;align-items:center;justify-content:center;gap:.5em; + background:var(--panel2);color:var(--txt); + border:1px solid var(--line);border-radius:12px; + padding:.65em 1.4em;font-size:1rem;font-weight:600;letter-spacing:.03em; + cursor:pointer;transition:transform .08s,border-color .15s,box-shadow .15s,background .15s; +} +.btn:hover{border-color:var(--neon);box-shadow:0 0 14px rgba(124,92,255,.45)} +.btn:active{transform:scale(.96)} +.btn-primary{background:linear-gradient(135deg,#6d4bff,#9b3dff);border-color:#8f6bff;box-shadow:0 0 18px rgba(124,92,255,.5)} +.btn-danger{background:#3a1020;border-color:#7a2038;color:#ffb3c2} +.btn-danger:hover{border-color:var(--red);box-shadow:0 0 14px rgba(255,77,109,.4)} +.btn-big{font-size:1.25rem;padding:.8em 2em} +.btn-small{font-size:.85rem;padding:.45em .9em} +.btn-icon{padding:.35em .7em;font-size:1rem;border-radius:10px} +.btn:disabled{opacity:.4;cursor:not-allowed;box-shadow:none} + +.screen,.modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;flex-direction:column} +.screen{background:radial-gradient(1200px 700px at 50% -10%,rgba(124,92,255,.16),transparent 60%),var(--bg)} +#scr-chars,#scr-stage,#scr-shop,#scr-codex,#scr-history{ + background:radial-gradient(1200px 700px at 50% -10%,rgba(124,92,255,.14),transparent 60%),rgba(5,5,12,.86)} +.modal{background:rgba(4,4,12,.72);backdrop-filter:blur(3px)} + +.panel{ + background:linear-gradient(180deg,var(--panel2),var(--panel)); + border:1px solid var(--line);border-radius:18px; + padding:1.4rem 1.6rem;min-width:min(92vw,430px);max-width:min(94vw,560px); + max-height:88vh;overflow-y:auto;text-align:center; + box-shadow:0 10px 60px rgba(0,0,0,.6),0 0 40px rgba(124,92,255,.12) inset; + animation:pop .22s ease-out; +} +@keyframes pop{from{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}} +.panel h2{font-size:1.5rem;margin-bottom:.6rem;letter-spacing:.06em} +.panel .sub{color:var(--dim);margin-bottom:1rem;font-size:.95rem} + +.col-btns{display:flex;flex-direction:column;gap:.6rem;margin-top:1rem} +.row-btns{display:flex;gap:.6rem;margin-top:1rem;justify-content:center} + +/* ---------- loading / logo ---------- */ +#scr-loading{background:var(--bg);z-index:99} +.logo-pop{font-size:clamp(2rem,7vw,3.6rem);font-weight:900;letter-spacing:.08em;color:var(--neon2);text-shadow:0 0 18px rgba(0,229,255,.8);animation:logoIn .8s ease-out} +.logo-pop span{display:block;color:var(--neon);text-shadow:0 0 18px rgba(124,92,255,.9)} +@keyframes logoIn{from{transform:scale(.6);opacity:0}to{transform:scale(1);opacity:1}} +.loading-bar{width:min(60vw,300px);height:6px;border-radius:4px;background:#1c1c38;margin-top:1.6rem;overflow:hidden} +.loading-bar i{display:block;height:100%;width:40%;background:linear-gradient(90deg,var(--neon),var(--neon2));animation:load 1s infinite ease-in-out;border-radius:4px} +@keyframes load{0%{transform:translateX(-120%)}100%{transform:translateX(320%)}} + +/* ---------- main menu (cinematic) ---------- */ +#scr-menu{background:transparent;overflow:hidden} +.menu-wrap{width:min(94vw,560px);height:100%;display:flex;flex-direction:column; + align-items:center;justify-content:space-between;padding:max(4vh,1.2rem) 1rem 1.4rem;text-align:center} + +.menu-head{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-top:2vh} +.logo{display:flex;flex-direction:column;line-height:.92;font-weight:900;letter-spacing:.04em; + animation:logoFloat 5s ease-in-out infinite} +.logo .l1{font-size:clamp(2.6rem,10vw,5rem);color:#fff; + text-shadow:0 0 14px rgba(0,229,255,.9),0 0 48px rgba(0,229,255,.45),0 2px 0 rgba(0,80,120,.6)} +.logo .l2{font-size:clamp(1.7rem,6.6vw,3.3rem);color:var(--neon); + text-shadow:0 0 12px rgba(124,92,255,.95),0 0 52px rgba(124,92,255,.5)} +@keyframes logoFloat{0%,100%{transform:translateY(0) rotate(-.4deg)}50%{transform:translateY(-7px) rotate(.4deg)}} +.tagline{color:var(--dim);font-size:.95rem;max-width:38ch;text-shadow:0 1px 4px rgba(0,0,0,.8)} +.ver-chip{font-size:.62rem;letter-spacing:.28em;color:var(--neon2);border:1px solid rgba(0,229,255,.35); + border-radius:999px;padding:.28em .9em;background:rgba(0,229,255,.06)} + +.menu-btns{display:flex;flex-direction:column;gap:.62rem;width:min(84vw,320px)} +.mbtn{position:relative;display:flex;align-items:center;gap:.85em;width:100%; + padding:.72em 1.15em;border-radius:14px;font-weight:700;font-size:1rem;letter-spacing:.04em; + color:var(--txt);text-align:left;cursor:pointer; + background:linear-gradient(180deg,rgba(30,26,66,.78),rgba(16,13,40,.82)); + border:1px solid var(--line);backdrop-filter:blur(6px); + transition:transform .14s,border-color .14s,box-shadow .14s;overflow:hidden} +.mbtn i{font-style:normal;font-size:1.15em;width:1.5em;text-align:center;filter:drop-shadow(0 0 6px rgba(124,92,255,.8))} +.mbtn span{flex:1} +.mbtn:hover,.mbtn:focus-visible{transform:translateX(6px);border-color:var(--neon); + box-shadow:0 0 20px rgba(124,92,255,.35),inset 0 0 18px rgba(124,92,255,.08)} +.mbtn:hover::after{content:"";position:absolute;top:0;bottom:0;left:-70%;width:45%; + background:linear-gradient(100deg,transparent,rgba(255,255,255,.09),transparent); + animation:shine .7s ease-out} +@keyframes shine{to{left:130%}} +.mbtn.primary{background:linear-gradient(180deg,rgba(124,92,255,.32),rgba(60,40,160,.42)); + border-color:rgba(124,92,255,.65);box-shadow:0 0 24px rgba(124,92,255,.25)} +.mbtn.primary:hover{box-shadow:0 0 30px rgba(124,92,255,.5)} +.mbtn.primary i{color:#fff} +.daily-best{font-size:.68rem;color:#ffd24a;font-style:normal} + +.menu-foot{width:min(88vw,520px);display:flex;flex-direction:column;align-items:center;gap:.5rem} +.menu-stats{display:flex;gap:.6rem;flex-wrap:wrap;justify-content:center} +.chip{background:rgba(12,10,30,.72);border:1px solid var(--line);border-radius:999px; + padding:.34em .95em;color:var(--dim);font-size:.85rem;backdrop-filter:blur(4px)} +.chip b{color:var(--gold)} +.hint{color:var(--dim);font-size:.74rem;opacity:.75;max-width:52ch;text-align:center} + +/* staggered entrance — replayed by re-adding .enter on #scr-menu */ +#scr-menu.enter .menu-head,#scr-menu.enter .menu-btns,#scr-menu.enter .menu-foot{ + animation:fadeUp .55s cubic-bezier(.2,.7,.2,1) both} +#scr-menu.enter .menu-btns{animation-delay:.08s} +#scr-menu.enter .menu-foot{animation-delay:.16s} +@keyframes fadeUp{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){ + .logo,.mbtn::after{animation:none!important} + #scr-menu.enter *{animation:none!important} +} + +/* ---------- screen headers ---------- */ +.scr-head{position:absolute;top:0;left:0;right:0;display:flex;align-items:center;gap:.8rem; + padding:.8rem 1rem;background:linear-gradient(180deg,rgba(7,7,15,.95),rgba(7,7,15,.6) 70%,transparent)} +.scr-head h2{flex:1;text-align:center;font-size:1.25rem;letter-spacing:.08em} +.head-gold{color:var(--gold);font-weight:700;min-width:64px;text-align:right} +.back-btn{background:var(--panel2)} +.card-grid,.shop-grid,.codex-list,.opt-panel{ + margin-top:4.2rem;width:min(94vw,900px);max-height:calc(100vh - 5.4rem); + overflow-y:auto;padding:.4rem .6rem 1.4rem;scrollbar-width:thin;scrollbar-color:var(--line) transparent; +} +.card-grid::-webkit-scrollbar,.shop-grid::-webkit-scrollbar,.codex-list::-webkit-scrollbar,.opt-panel::-webkit-scrollbar{width:8px} +.card-grid::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px} + +/* ---------- character cards ---------- */ +.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:.9rem} +.char-card{ + background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);border-radius:16px; + padding:1rem .8rem;text-align:center;cursor:pointer;position:relative;overflow:hidden; + transition:border-color .15s,box-shadow .15s,transform .1s; +} +.char-card:hover{border-color:var(--neon);box-shadow:0 0 18px rgba(124,92,255,.35);transform:translateY(-2px)} +.char-card.selected{border-color:var(--neon2);box-shadow:0 0 22px rgba(0,229,255,.5)} +.char-card.locked{filter:grayscale(.9) brightness(.65);cursor:default} +.char-card.locked:hover{border-color:var(--line);box-shadow:none;transform:none} +.char-sym{font-size:2.6rem;line-height:1;filter:drop-shadow(0 0 8px rgba(124,92,255,.7))} +.char-name{font-weight:800;letter-spacing:.05em;margin-top:.35rem} +.char-weapon{color:var(--dim);font-size:.78rem;margin-top:.15rem} +.char-traits{font-size:.78rem;color:var(--neon2);margin-top:.5rem;min-height:2.1em} +.char-lock{position:absolute;inset:auto 0 0 0;background:rgba(5,5,14,.85);padding:.5rem;font-size:.75rem;color:#ffb3c2} +.char-lock::before{content:"🔒 "} + +/* ---------- stage select ---------- */ +.stage-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.9rem;padding:4.2rem 1.2rem .5rem;overflow-y:auto;max-height:52vh} +.stage-card{position:relative;background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);border-radius:16px; + padding:1rem .8rem;text-align:center;cursor:pointer;transition:border-color .15s,box-shadow .15s,transform .1s} +.stage-card:hover{border-color:var(--neon);box-shadow:0 0 18px rgba(124,92,255,.35);transform:translateY(-2px)} +.stage-card.selected{border-color:var(--neon2);box-shadow:0 0 22px rgba(0,229,255,.5)} +.stage-card.locked{filter:grayscale(.9) brightness(.6)} +.lock-tag{position:absolute;inset:auto 0 0 0;background:rgba(5,5,14,.88);padding:.45rem;font-size:.72rem;color:#ffb3c2} +.stage-best{font-size:.75rem;color:#ffd24a;margin-top:.4rem;min-height:1.1em} +.stage-opts{padding:.6rem 1.4rem;display:flex;flex-direction:column;gap:.15rem;max-width:520px;margin:0 auto;width:100%} +.seg{display:flex;gap:.3rem;flex-wrap:wrap} +.seg .btn.active{border-color:var(--neon2);color:var(--neon2);box-shadow:0 0 10px rgba(0,229,255,.35)} + +/* ---------- run history ---------- */ +.hist-summary{display:flex;gap:1.2rem;flex-wrap:wrap;padding:4rem 1.4rem .6rem;color:var(--dim);font-size:.85rem} +.hist-summary b{color:#fff} +.hist-list{overflow-y:auto;max-height:60vh;padding:.2rem 1.2rem 1rem;display:flex;flex-direction:column;gap:.4rem} +.hist-row{display:flex;align-items:center;gap:.7rem;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:.45rem .8rem;font-size:.85rem} +.hist-row.win{border-color:rgba(255,210,74,.5)} +.h-sym{font-size:1.2rem} +.h-stage{font-size:1rem} +.h-date{color:var(--dim);min-width:5.2em} +.h-time{font-weight:800;color:var(--neon2);min-width:3.4em} +.h-meta{color:var(--dim);font-size:.78rem} +.h-grade{color:#ff9db0;font-weight:700;font-size:.75rem} +.h-badge{margin-left:auto;color:#ffd24a;font-weight:800;font-size:.75rem} +.hist-empty{text-align:center;color:var(--dim);padding:2rem} + +/* ---------- save transfer modal ---------- */ +.panel-save{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:1.1rem;width:min(92vw,560px)} +.panel-save h3{margin-bottom:.6rem} +.panel-save textarea{width:100%;background:#0a0a18;color:#bfeaff;border:1px solid var(--line);border-radius:8px;padding:.6rem;font-size:.72rem;resize:vertical} +.save-btns{display:flex;gap:.5rem;margin-top:.6rem;justify-content:flex-end} + +/* ---------- tutorial bubble ---------- */ +#tutTip{position:fixed;left:50%;bottom:5.4rem;transform:translateX(-50%);z-index:40; + background:rgba(8,8,22,.94);border:1px solid var(--neon);border-radius:12px; + padding:.65rem 2.4rem .65rem 1rem;max-width:min(88vw,480px); + font-size:.88rem;box-shadow:0 0 22px rgba(124,92,255,.35);pointer-events:auto} +#bt-tut-skip{position:absolute;top:.25rem;right:.35rem;background:none;border:none;color:var(--dim);cursor:pointer;font-size:.95rem} + +/* endless overtime clock */ +#hudTimer.endless{color:var(--neon2);text-shadow:0 0 10px rgba(0,229,255,.7)} +#hudTimer.endless small{font-size:.62em;opacity:.8} + +/* ---------- skins ---------- */ +.skin-row{display:flex;align-items:center;justify-content:center;gap:.35rem;margin-top:.5rem} +.skin-name{font-size:.75rem;color:var(--dim);display:inline-flex;align-items:center;gap:.3em} +.skin-name i{width:.85em;height:.85em;border-radius:50%;display:inline-block;box-shadow:0 0 6px currentColor} +.btn-mini{font-size:.72rem;padding:.25em .55em;border-radius:8px} +.btn-mini.buy{color:#ffd24a} + +/* ---------- options text inputs ---------- */ +.opt-input{flex:1;background:#0a0a18;border:1px solid var(--line);border-radius:8px; + color:#e8e8ff;padding:.45em .6em;font-size:.8rem;max-width:60%} +.opt-input:focus{outline:none;border-color:var(--neon)} + +/* ---------- leaderboard modal ---------- */ +.panel-lb{background:var(--panel2);border:1px solid var(--line);border-radius:16px; + padding:1.1rem;width:min(94vw,520px)} +.lb-scopes{margin:.7rem 0} +.lb-list{max-height:46vh;overflow-y:auto;display:flex;flex-direction:column;gap:.35rem;margin-top:.4rem} +.h-rank{font-weight:800;color:var(--neon2);min-width:2.4em} +.h-name{font-weight:700} + +/* ---------- meta shop ---------- */ +.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:.8rem} +.shop-item{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:.85rem;text-align:left} +.shop-item h4{font-size:1rem;display:flex;align-items:center;gap:.45em} +.shop-item p{color:var(--dim);font-size:.78rem;margin:.3rem 0 .55rem;min-height:2.2em} +.rank-pips{display:flex;gap:4px;margin-bottom:.55rem} +.rank-pips i{width:16px;height:6px;border-radius:3px;background:#232345} +.rank-pips i.on{background:linear-gradient(90deg,var(--neon),var(--neon2));box-shadow:0 0 6px rgba(124,92,255,.7)} +.shop-buy{width:100%;font-size:.85rem;padding:.5em} + +/* ---------- codex ---------- */ +.tabs{position:absolute;top:3.2rem;left:0;right:0;display:flex;justify-content:center;gap:.4rem;flex-wrap:wrap;padding:.4rem} +.tab{background:var(--panel);border:1px solid var(--line);color:var(--dim);padding:.4em 1em;border-radius:999px;cursor:pointer;font-size:.85rem;font-weight:600} +.tab.active{color:var(--txt);border-color:var(--neon);box-shadow:0 0 10px rgba(124,92,255,.4)} +.codex-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:.7rem;align-content:start} +.cx-card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:.75rem .85rem;display:flex;gap:.7rem;align-items:flex-start;text-align:left} +.cx-sym{font-size:1.7rem;filter:drop-shadow(0 0 6px rgba(124,92,255,.6))} +.cx-body h4{font-size:.98rem} +.cx-body p{color:var(--dim);font-size:.76rem;margin-top:.2rem} +.cx-tag{display:inline-block;font-size:.66rem;background:#1e1e40;border:1px solid var(--line);border-radius:6px;padding:.1em .5em;margin-top:.35em;color:var(--neon2)} +.cx-lock{opacity:.45} + +/* ---------- options ---------- */ +.opt-panel{max-width:min(94vw,520px);margin-left:auto;margin-right:auto} +.opt-group{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:.9rem 1rem;margin-bottom:.8rem;text-align:left} +.opt-group h3{font-size:.85rem;text-transform:uppercase;letter-spacing:.12em;color:var(--neon2);margin-bottom:.6rem} +.opt-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.4rem 0;font-size:.92rem} +.opt-row label{color:var(--txt)} +input[type=range]{-webkit-appearance:none;appearance:none;width:150px;height:5px;border-radius:3px;background:#232345;outline:none} +input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:17px;height:17px;border-radius:50%; + background:var(--neon2);box-shadow:0 0 8px rgba(0,229,255,.8);cursor:pointer} +input[type=range]::-moz-range-thumb{width:17px;height:17px;border-radius:50%;background:var(--neon2);border:none;cursor:pointer} +.toggle{position:relative;width:48px;height:25px;border-radius:99px;background:#232345;cursor:pointer;transition:background .2s;flex-shrink:0} +.toggle::after{content:"";position:absolute;top:3px;left:3px;width:19px;height:19px;border-radius:50%;background:#8888bb;transition:left .18s,background .18s} +.toggle.on{background:linear-gradient(90deg,var(--neon),var(--neon2))} +.toggle.on::after{left:26px;background:#fff} +.seg{display:flex;gap:.3rem} +.seg button{background:#181832;border:1px solid var(--line);color:var(--dim);border-radius:8px;padding:.3em .7em;font-size:.78rem;cursor:pointer} +.seg button.on{color:var(--txt);border-color:var(--neon2);background:#12303a} + +/* ---------- HUD ---------- */ +#hud{position:fixed;inset:0;z-index:10;pointer-events:none} +#xpBar{position:absolute;top:0;left:0;right:0;height:16px;background:rgba(10,10,25,.85);border-bottom:1px solid var(--line)} +#xpFill{display:block;height:100%;width:0%;background:linear-gradient(90deg,#28d7fe,#7c5cff);transition:width .15s;box-shadow:0 0 10px rgba(40,215,254,.7)} +#lvlTag{position:absolute;right:8px;top:-1px;font-size:.68rem;font-weight:800;line-height:16px;color:#fff;text-shadow:0 1px 2px #000;letter-spacing:.08em} +#hudTop{position:absolute;top:24px;left:0;right:0;display:flex;align-items:center;justify-content:space-between;padding:0 .8rem} +#hudPortrait{position:relative;width:52px;height:52px;border-radius:50%;background:rgba(12,12,30,.85); + border:2px solid var(--neon);display:flex;align-items:center;justify-content:center;font-size:1.6rem;box-shadow:0 0 14px rgba(124,92,255,.5)} +#hudPortrait em{position:absolute;bottom:-6px;right:-6px;background:var(--neon2);color:#00232b;font-style:normal; + font-size:.68rem;font-weight:900;border-radius:8px;padding:.05em .45em;border:1px solid #063a44} +#hudTimer{font-size:1.7rem;font-weight:900;letter-spacing:.1em;text-shadow:0 0 12px rgba(0,229,255,.6);font-variant-numeric:tabular-nums} +#hudRight{display:flex;flex-direction:column;align-items:flex-end;gap:.2rem;font-weight:700;font-size:1rem;text-shadow:0 1px 3px #000} +#hudGold{color:var(--gold)}#hudKills{color:#ff9db0} +#bossBar{position:absolute;top:86px;left:50%;transform:translateX(-50%);width:min(70vw,480px);text-align:center} +#bossBar label{font-size:.85rem;font-weight:800;letter-spacing:.14em;color:#ffb3c2;text-shadow:0 0 8px rgba(255,77,109,.8)} +.bb-track{height:10px;background:rgba(20,8,16,.85);border:1px solid #5a1830;border-radius:6px;margin-top:3px;overflow:hidden} +#bossFill{display:block;height:100%;width:100%;background:linear-gradient(90deg,#ff2740,#ff7a3d);box-shadow:0 0 10px rgba(255,60,80,.8)} +#slotWeapons,#slotPassives{position:absolute;left:.7rem;display:flex;flex-wrap:wrap;gap:5px;max-width:200px} +#slotWeapons{bottom:.7rem} +#slotPassives{bottom:4.1rem} +.slot{width:36px;height:36px;border-radius:9px;background:rgba(12,12,30,.85);border:1px solid var(--line); + display:flex;align-items:center;justify-content:center;font-size:1.15rem;position:relative} +.slot.evolved{border-color:var(--gold);box-shadow:0 0 10px rgba(255,210,74,.55)} +.slot .lv{position:absolute;bottom:-3px;right:-3px;font-size:.58rem;font-weight:900;background:#000c;border-radius:5px;padding:0 .25em;color:var(--neon2)} +.slot.passive{width:28px;height:28px;font-size:.9rem} +.slot.cd::before{content:"";position:absolute;inset:0;background:conic-gradient(rgba(0,0,0,.62) var(--p,0%),transparent 0);border-radius:9px} + +/* ---------- level up choices ---------- */ +.choice-list{display:flex;flex-direction:column;gap:.55rem;margin:.4rem 0 .2rem} +.choice{ + display:flex;align-items:center;gap:.8rem;text-align:left; + background:linear-gradient(180deg,#1b1b3a,#12122a);border:1px solid var(--line);border-radius:13px; + padding:.6rem .8rem;cursor:pointer;transition:border-color .12s,transform .1s,box-shadow .12s; + animation:slideUp .25s ease-out backwards; +} +.choice:nth-child(2){animation-delay:.05s}.choice:nth-child(3){animation-delay:.1s}.choice:nth-child(4){animation-delay:.15s} +@keyframes slideUp{from{transform:translateY(14px);opacity:0}to{transform:none;opacity:1}} +.choice:hover{border-color:var(--neon2);box-shadow:0 0 16px rgba(0,229,255,.35);transform:translateX(4px)} +.choice.neww{border-color:#2affd0} +.ch-sym{width:46px;height:46px;border-radius:11px;background:#0d0d20;border:1px solid var(--line); + display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0} +.choice.evolve .ch-sym{border-color:var(--gold);box-shadow:0 0 12px rgba(255,210,74,.5)} +.ch-info{flex:1;min-width:0} +.ch-info h4{font-size:1rem} +.ch-info p{color:var(--dim);font-size:.78rem;margin-top:.15rem} +.ch-lv{font-size:.72rem;font-weight:800;color:var(--neon2);white-space:nowrap} +.choice.evolve .ch-lv{color:var(--gold)} + +/* ---------- chest modal ---------- */ +.panel-chest{min-width:min(90vw,380px)} +.chest-art{font-size:4rem;animation:chestBounce .7s infinite alternate ease-in-out;filter:drop-shadow(0 0 18px rgba(255,210,74,.8))} +@keyframes chestBounce{from{transform:translateY(0) rotate(-4deg)}to{transform:translateY(-10px) rotate(4deg)}} +.chest-line{display:flex;align-items:center;gap:.6rem;background:#14142c;border:1px solid var(--line); + border-radius:11px;padding:.5rem .7rem;margin:.4rem 0;text-align:left;font-size:.92rem} +.chest-line b{color:var(--gold)} + +/* ---------- game over ---------- */ +.panel-over{min-width:min(92vw,420px)} +.record{color:var(--gold);font-weight:900;letter-spacing:.14em;margin-bottom:.5rem;animation:pulseR 1s infinite alternate} +@keyframes pulseR{from{text-shadow:0 0 6px rgba(255,210,74,.6)}to{text-shadow:0 0 20px rgba(255,210,74,1)}} +.over-grid{display:grid;grid-template-columns:1fr auto;gap:.45rem 1.2rem;text-align:left;background:#12122a; + border:1px solid var(--line);border-radius:12px;padding:.9rem 1.1rem;margin:.8rem 0;font-size:.95rem} +.over-grid span{color:var(--dim)}.over-grid b{text-align:right;color:var(--txt)} + +.pause-stats{display:grid;grid-template-columns:auto auto;gap:.3rem 1.4rem;justify-content:center;margin:.6rem 0 .2rem;color:var(--dim);font-size:.9rem} +.pause-stats b{color:var(--txt)} + +/* ---------- toasts ---------- */ +#toasts{position:fixed;top:22vh;left:0;right:0;z-index:40;display:flex;flex-direction:column;align-items:center;gap:.4rem;pointer-events:none} +.toast{background:rgba(10,10,26,.92);border:1px solid var(--neon);border-radius:99px;padding:.45em 1.3em; + font-weight:800;letter-spacing:.1em;animation:toastIn 2.6s forwards;box-shadow:0 0 18px rgba(124,92,255,.5);font-size:.95rem} +.toast.warn{border-color:var(--red);box-shadow:0 0 18px rgba(255,77,109,.6);color:#ffc9d4} +.toast.gold{border-color:var(--gold);box-shadow:0 0 18px rgba(255,210,74,.55);color:#ffe9ad} +@keyframes toastIn{0%{transform:translateY(-18px);opacity:0}10%{transform:none;opacity:1}80%{opacity:1}100%{opacity:0;transform:translateY(-8px)}} + +/* ---------- fps meter ---------- */ +#fpsMeter{position:fixed;top:44px;left:10px;z-index:30;font-size:.72rem;font-family:monospace;color:#7dffce; + background:rgba(0,0,0,.5);padding:2px 7px;border-radius:6px;pointer-events:none} + +/* ---------- touch controls ---------- */ +#touchLayer{position:fixed;inset:0;z-index:15;pointer-events:none} +#stickBase{position:absolute;width:110px;height:110px;border-radius:50%;border:2px solid rgba(124,92,255,.5); + background:rgba(20,20,50,.35);display:none} +#stickKnob{position:absolute;left:50%;top:50%;width:48px;height:48px;border-radius:50%; + background:rgba(0,229,255,.5);border:2px solid rgba(0,229,255,.9);transform:translate(-50%,-50%)} + +/* ---------- developer mode ---------- */ +#devBtn{ + position:fixed;top:86px;right:10px;z-index:35; + width:44px;height:44px;border-radius:12px;font-size:1.25rem; + background:rgba(15,15,35,.9);border:1px dashed var(--neon2);color:#fff;cursor:pointer; + box-shadow:0 0 12px rgba(0,229,255,.4); +} +#devBtn:hover{transform:scale(1.06)} +.panel-dev{min-width:min(94vw,560px);text-align:left} +.panel-dev h2{color:var(--neon2);letter-spacing:.12em;font-size:1.05rem;display:flex;justify-content:space-between;align-items:center} +.panel-dev .dev-close{background:none;border:none;color:var(--dim);font-size:1.3rem;cursor:pointer;line-height:1} +.panel-dev .dev-close:hover{color:var(--red)} +.dev-sec{border-top:1px solid var(--line);margin-top:.7rem;padding-top:.55rem} +.dev-sec h3{font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;color:var(--gold);margin-bottom:.45rem} +.dev-row{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-bottom:.5rem} +.dev-row label{font-size:.82rem;color:var(--txt);min-width:110px} +.dev-btn{font-size:.75rem;padding:.32em .7em;border-radius:8px;background:#181838;border:1px solid var(--line);color:var(--txt);cursor:pointer} +.dev-btn:hover{border-color:var(--neon2)} +.dev-grid{display:grid;grid-template-columns:1fr 1fr;gap:.25rem .9rem;max-height:200px;overflow-y:auto;padding-right:.2rem} +.dev-item{display:flex;align-items:center;gap:.35rem;font-size:.78rem;background:#12122a;border-radius:8px;padding:.22rem .4rem} +.dev-item .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dev-item .lv{color:var(--neon2);font-weight:800;font-size:.68rem;min-width:26px;text-align:right} +.dev-item button{font-size:.62rem;padding:.18em .5em;border-radius:6px;background:#20204a;border:1px solid var(--line);color:var(--txt);cursor:pointer} +.dev-item button:hover{border-color:var(--gold)} +.dev-note{font-size:.7rem;color:var(--dim);margin-top:.5rem} + +@media (max-height:520px){ + .menu-btns{gap:.4rem;width:min(84vw,300px)} + .mbtn{padding:.5em 1em;font-size:.92rem} + .logo .l1{font-size:clamp(2rem,9vw,3.4rem)} + .logo .l2{font-size:clamp(1.3rem,6vw,2.3rem)} + .menu-head{margin-top:0} +} diff --git a/docs/GAP-COMMERCIAL.md b/docs/GAP-COMMERCIAL.md new file mode 100644 index 0000000..d61d340 --- /dev/null +++ b/docs/GAP-COMMERCIAL.md @@ -0,0 +1,122 @@ +# 📊 GAP ANALYSIS — Thương mại hóa NEON SURVIVORS + +> Trạng thái đánh giá: bản hiện tại (v1.0 — core loop hoàn chỉnh, playable end-to-end). +> Mục tiêu: trả lời "thiếu gì để bán được tiền / lên store". + +--- + +## 1. Điểm mạnh sẵn có (không phải làm lại) + +| Hạng mục | Trạng thái | +|---|---| +| Core loop | ✅ Hoàn chỉnh: bắn tự động, XP, level-up choice, chest/evolution, boss, win/lose | +| Nội dung nền | 10 vũ khí + 10 tiến hóa, 13 passive, 8 nhân vật, 8 loại quái, 4 trùm | +| Meta progression | Vàng vĩnh viễn + 12 nâng cấp shop + unlock nhân vật theo thành tích | +| Kỹ thuật | Không dependency, offline-first, WebAudio tự sinh, test headless, dev tools | +| Platform-ready | Touch + desktop, responsive DPR, i18n VI/EN, localStorage | + +→ Đây là mức **"vertical slice tốt"**. Khoảng cách tới sản phẩm thương mại nằm ở các nhóm dưới. + +--- + +## 2. Khoảng trống — xếp theo độ chặn đường kinh doanh + +### 🔴 Nhóm A — CHẶN BÁN (bắt buộc có trước khi thu tiền) + +| # | Thiếu | Vì sao chặn | Effort | +|---|---|---|---| +| A1 | **Art thật thay programmer-art** | Emoji + hình vector là chuẩn prototype. Store screenshot/video quyết định 80% conversion. Đây là điểm yếu lớn nhất | Lớn (asset pack ~$50–300 hoặc commission $500–2000) | +| A2 | **Âm nhạc/nhạc stage** | Loop generative 1 track duy nhất → nghe chán sau 10 phút. Cần 3–5 track (menu/stage/boss) + stingers thắng/thua | Trung ($20 license pack hoặc commission) | +| A3 | **Đủ nội dung để chơi lại (replayability)** | 1 stage duy nhất × 20 phút × cùng timeline mỗi lần = người chơi hết việc sau 2–3 giờ. Genre chuẩn cần ≥3 stage + endless + độ khó leo thang | Trung — code sẵn chỗ cắm (SPAWN_TABLE/BOSSES là data) | +| A4 | **Tutorial onboarding** | Hiện 1 dòng toast. Người mới không hiểu evolution/chest/meta → churn ngay phút đầu | Nhỏ | +| A5 | **Pháp lý**: Privacy Policy, EULA, tuổi (IARC), kiểm tra trùng tên "Neon Survivors" | Store từ chối duyệt nếu thiếu; trademark trùng bị gỡ | Nhỏ (giấy tờ) + phí nền tảng | + +### 🟠 Nhóm B — QUYẾT ĐỊNH DOANH THU (chọn mô hình thì làm) + +Tùy mô hình: + +**Premium (Steam, $4.99 — mô hình của chính Vampire Survivors):** +| # | Thiếu | Ghi chú | +|---|---|---| +| B1 | Steam wrapper (Tauri/Electron) + Steamworks: achievements, cloud save, overlay | Cần tài khoản Steam partner ($100/app) | +| B2 | **Achievements hệ thống trong game** (30–50 thành tựu gắn unlock) | Hiện chưa có khung achievement | +| B3 | DLC pipeline: bộ nhân vật/stage/cosmetic bán thêm | Cần tách data content khỏi code (đang hard-code JS) | +| B4 | **Mod support** (Workshop) | VS sống dai nhờ mod; yêu cầu data-driven + sandbox loader | + +**F2P (mobile/web portal):** +| # | Thiếu | Ghi chú | +|---|---|---| +| B5 | **Rewarded ads** (xem quảng cáo: hồi sinh, x2 vàng, reroll thêm) | Nguồn thu chính F2P; tích AdMob/AppLovin | +| B6 | IAP: gói khởi đầu, skin nhân vật/vũ khí, bỏ quảng cáo | Cần cosmetic system (hiện không có skin layer) | +| B7 | Daily quests + daily login + mission chain | Retention D1/D7 | +| B8 | Battle pass mùa (nếu đủ scale) | Chỉ làm khi có DAU | + +### 🟡 Nhóm C — GIỮ NGƯỜI CHƠI & VẬN HÀNH + +| # | Thiếu | Ghi chú | +|---|---|---| +| C1 | **Leaderboard** (daily-seeded run, global + bạn bè) | Cần backend nhẹ — hợp nhất: Cloudflare Workers + KV/D1 (đang dùng CF tunnel sẵn) | +| C2 | Cloud save / account | Cùng backend ở trên; cần chống sửa save (checksum/signed) | +| C3 | Analytics funnel (install→phút 5→churn), remote config để tune balance không cần update | Tự host hoặc PostHog/Firebase | +| C4 | Crash reporting (Sentry), profiling budget 60fps mobile low-end | | +| C5 | Run history / stats cá nhân theo nhân vật-stage, best từng loại | | +| C6 | Live-ops cadence: cập nhật nhân vật/vũ khí 4–8 tuần/lần | Quy trình, không phải code | + +### 🟢 Nhóm D — CHẤT LƯỢNG & TIỆN NGHI (nâng cấp dần) + +| # | Thiếu | Ghi chú | +|---|---|---| +| D1 | **Gamepad support + remap phím** | Genre này kỳ vọng cao; Gamepad API | +| D2 | Viewport culling + object pooling | Hiện render mọi entity kể cả ngoài màn hình; GC pressure do tạo object liên tục — quan trọng cho mobile low-end | +| D3 | Seeded RNG | Math.random hiện tại — chặn daily-challenge/replay/anti-cheat | +| D4 | Fullscreen toggle, khóa hướng ngang mobile, PWA manifest | | +| D5 | Accessibility: colorblind palette, giảm motion, chữ to | | +| D6 | Save slot + export/import code | | +| D7 | Bản dịch thêm (ES/PT-BR/ZH/JA...) — khung i18n sẵn | | +| D8 | Interactive props trong trận (đèn phá rơi vàng, bẫy môi trường) | Genre-standard juice | + +--- + +## 3. Roadmap đề xuất + +``` +PHASE 1 — "Store-ready core" (2–4 tuần dev) +├─ D2 culling + pooling (perf mobile) +├─ D1 gamepad + D4 fullscreen/PWA +├─ A4 tutorial tương tác run đầu +├─ A3 stage #2/#3 (biome + boss riêng) + Endless + Difficulty grade +├─ B2 khung achievements (30 cái) — dùng chung cho Steam sau này +├─ C5 run history +└─ D3 seeded RNG (chuẩn bị daily) + +PHASE 2 — "Thu tiền được" (song song art/audio) +├─ A1+A2 art pack + soundtrack +├─ A5 pháp lý + chọn model (premium vs F2P) +├─ Steam build (B1) HOẶC mobile build (B5–B7) +└─ C3 analytics + remote config + +PHASE 3 — "Sống lâu" +├─ C1+C2 leaderboard/cloud-save (Cloudflare Workers) +├─ B3 DLC / B8 pass +├─ B4 mod support +└─ C6 live-ops nhịp 4–8 tuần +``` + +Chi phí nền tảng tham khảo: Steam $100/app · Google Play $25 một lần · Apple $99/năm · Web portal (Poki/CrazyGames): miễn phí, chia doanh thu quảng cáo. + +--- + +## 4. Tôi có thể code NGAY trong repo này (không cần asset ngoài) + +1. **D2** Viewport culling + object pool cho Particle/Proj (perf) +2. **D1** Gamepad API + remap phím +3. **A3** Stage system: 2 biome mới + Endless mode + difficulty grade (data-driven sẵn) +4. **B2** Khung achievements + popup + gắn unlock (dùng luôn điều kiện cond của nhân vật) +5. **D3** Seeded RNG + **Daily Challenge** (seed theo ngày UTC, lưu best local) +6. **A4** Tutorial tương tác (mốc: di chuyển → lên cấp → chọn → mở rương → gặp trùm) +7. **C5** Run history + trang stats +8. **D6** Export/import save code (base64) +9. **D8** Props phá được (đèn đá rơi vàng/chicken) +10. **D4** Fullscreen + PWA manifest + +> Nói "làm phase 1" (hoặc liệt kê số) là tôi triển khai tiếp. diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f54a2914b8a3f93f37ffe5e02f0b1eaf6b1156ac GIT binary patch literal 2228 zcmZveX;f257RM_u5A%W|fq>H3n#dMMP?1p@lqLxVB4CHjMo@vEsI&u$L{yq3&k#ji zNDvT|NTT+PW21t0k1QetHxfYOhB(qlga87{62TC7Bn|V?o)7h_|M}Owx6Zj$=Tzkc zbJ%94wx$38GyjdO(3x32JCHdu4E-a4005Ha&th&q^7hlkyUE?|R*a4ayS4uAcd)JH z8fge(_>}-f&}cc2uHPB~tJ55v7E+C9p)lLTH*DoEE$i5SZqtrRT!6exHx*@d3INeshUvZv1HVm!B z#UtF0AMCdn8W6W9#K$5ZnCgxyTlt5rocT0?3r|oSg#sb0K0KD^V$>nYPl-;sJ{>b2 z3wFIXIVd(xWgU3x$$Ykw0g`j+_H4=zT64dOw-WB&h3Z|wqh-c@J*ts^ovSA|yyXdA zhbVeia$V7(f$~VhN1mWAN*)eG9Pd)x!Ej?1QU9BY_^!NXVs-q7CCWpA{y=mEcThzC zqEUI%;y*zdjnoY7qIv4yC$|;QrzG4l8y0Y8yQX8eT=+wtM~$mLP|%`t2fTZ)HKTSfbeH0cQla!9Gj(S9Q7jk zIS|TvHeWrC}qRdLa$fbqI!FiG75)hDI zzY4m+gP>ynYg#E=mcd~F1;x?m9Nr7VC>EmKc7Z%|LD6p`7h}AyJp4 zyGh8_KwI>88Wm_nvmOe>^nlDjo}dfQ$n1q5%|WpiVlrEnMkv0Ocy+sHo^&yH8nKi_ zhBakjUij;an3tt?KX}IaW=5MdQJ4>(1kUPSptmbt{Vr%$FV@x*lKOtJux1K$cYcvn z*crvx?d{}m7@Ogk7qyj+Jwj{!uI5$i&^U{cRG?|I1w1HlPbL)c9fapW8X7u*6sU+| zDcFiDSVOBA52fK0IgADg&=a|9&<}`J3-O~_HNxP)_xoUlbOm}Zzc~pmyk!2YWFiB7 zP3V{!D%sR1Bvep3Om?%WaU5=-A#JVJnaHKr%w@5u2^?9ZVYHPW1p4jchoK+GxjQ(r z;XQdvoWl9~=%_^ZG}`%V$OY?V1J18Zo)X6tMvq&Q(xH;L`iF#Pxm?Y%&5kD+3JK#F zR@nKRU9)XDWRoi+Qkn4mUf5Ps?v(3oTl<16e{5V<@NwZTHx)6iX*hN4za&N}@+%>% z{%=kkLsKp`02QB<4X{Sy_Hx8QhO0-fi1z6_@l`47W^7}sEcXoKjf7f+jmt3I5^b|C zAOw6OaV$2`%~`(Gc#b$GQ^c!x8?`1Rt=BgNw|@2#y9f&VU^|V@V=}%V<^ZU)P~nMo z5vd%Rui-|hzK~Fn3_Jg?t+kPk0_#4wM$`5wHwWDI6CdHA7%Za+9?o_jd~e}UN~Y3r zE>;b7bV2ol+d>}Xx#6a_Ubnl4Z?1mazuQ6ZBmyoXcc)8f3VZ(H)^b0m+HUROcq0Qm zSen4jWwpxlmbFQ!eT&*T+D!%ks&D>|VUSy(^oNx(|{2AnngP)GHHVpB@U<38Od9@Y_im3-B&Gw180@T8q6q>K`&k;rrU`wP>A zeiGjSm0rxiHoW;+SkWutmSRz(l?`3(hpvc$&yK2j@9*f+0@{kn50(7U%F^le*rQdA z*${r@%=<&hTOyJTbMe0(Xo!(LrOV%)Mw`W}(JL8|6Q;wO~20#h8o0TTEMXe-D(2>$8wf1Y(1aMz-g jZyDD}e@&aIQmo;7yWM}=J&+8~{6>JkFNby0hb#CKTix2u literal 0 HcmV?d00001 diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4e463f8fcebadaf562768cb3f4b0edb70e8e64b9 GIT binary patch literal 7076 zcmcIpdstIfw%_M)0s%ylK@>zu1Y7)k0cynuny4r~Zh^0z4sC5stXl2W8d@KqAm{jm zQC_XjudRaBsdc6m+ZhWga!!j4ty)919ef~(OD2!$+KnDP`tH2g~=r9b?6nDb3PLQ!iMrOf$b&YjMC zm);zay4kgSMoOk@@$+xYduaT~&+*>M_OUA;bZrdSTefccr0;JWxEZv3`iN_(>5Zu` z|L|pUsWa!L%L9YI=*JhfW<4sn+1410%C3gi+sWU3V1q(PiI844ic|=3@E?Pa7XJM2 zOMvtD#I?_(yz>a12c^HatOvdSX6LU=ZFBDf_w_fN!zkEz^8armcbzuAC2gh7%uF-$SCx+Li8V`*(4;(4#12y@M6xQB zphpj$Lt^#jHV{G0Hy$ znD-xn7t~qrx?Pdfxw!&6Pl*zwaeMeiCw^Rv_y5yiO3fiIy1?o-jl{F1%|gCf%YcvT zMa_*8)~a@BX+1KEt|;kiGmpKN6QoTA>^IKtmNfn6Dix(f%4F%Q*}ZJv%ilXod#e0u z#WO$LMMUo$Gke#uIuhro9e=s+o!V1p{co|{zxBX6@kENy_8gP<%5Z8fMB&9^+<#xU zEza&vJs;-ql+W8Q>t2&6m1mE@jdkVQ#1mpCcbI+r!93%uOx{p1T&R;)?XkM?&>8gR zP}!4@%y^}E@qAcTr)4q20rRHle7j~KO~_@}U!A9Cw5Y=Fj878HeCbT8N*eUH0!~>` zj@(xjR}!S#kh&e=)Ul*>aYz|S4;16h`$>|gIdDuAt&S0T6qF#S&o;WtUoOU(c5ClZ zu5$9ciUOtMg*hl83w2DEdH_x7>v6URAE=JtsTI#KIcKyeVhZ}Cn7y6uIkn%ZX-^Z{ z*50k6=En#dKp#grwu-u>2+W7@PR+wKp_~1rr`i8N31?AwC5%)?u6Q`FJxSVW4SZHb zo%+rYxULvk!Wk4BAavRB6w$2v@+j9W)m*(4^6W4QQd)lpJ&P~#&hFR5`NAP<00C$e zO@naVyWr5GZl=baZQHCYL=0LtnCrC;9QJ%;68D4t5tJclzHkS&>szPR~+qEH1o5$o_*P?}L6h)$NFK`T?y`v@q z5v;vDN34@N<+RvDW%PebbX4%lF`j3*mJm?O_6+3PyVLZymF}@L6i6IeT!Jgj)}vgA z+VSA}FKY5cyXaMOn>u^fU0%e`q(WkC-^`$sp}d(OuBsmY-I*jEwFcgrOwYCyA&Z7V ze);`LlGK-zO)p-+RYBqI!4G+h8-MA(7&n6j|m|94h>&&Fb!+A>n?@VN+A!t#S0bmlTc{hM+=ZFW)Ry zF5qTTJ0VQR;l#Q&i2JZoDrTFUY&d9Q_Hj!M3TRm9bH9pfr?9IY?{kJGNNfu1*&S>A zGG5WuH&p1!fU|AKHlU%?jN1+Bjd8}M%&xp(+mg*VrK{U6OI@^7UwQDv&*1WHxmN(Nm(LvB$zfH$x-UXSWUBeLU=V)W+Df}4mOGA>S zO?hPC8R4xeXmphRA4K>8$yQ$J7>X#tSPpXCMDD&kq1R9uKSJVl=aS@%q!hJ51o#z3 zxRH*pF7rfT8Thss+oMcssg=%|_%jCfGRamWGbK0JcAhYCP)DTe!Of@C4eb-SmUn^j zbxLk2n8de;$>YjrJwx@t<}G(WJ7AJ{>7hy7K(h6*7~}P&++WyOp%JVHvt|%e`fyk~RAkf@k-!%NXntbGfPdlU3;0%}XlOHrIB^iLdT3NVx;P zO&w|~xS6i$UnM+G+0Ktg%4n*0wkL`uu3c5G>3f1}`3PxWC6(QsCN#_C8B?K0iOOtW?d3vlm5G9oYo8;Yctu}}skqmOj@8lJdOzb=4_Lbi;?p{w z{_D07+w}3MRZCx~k&+~mR7apR@o=Fh6P1ldMWlyl`;T%}aGYxqBXl(4Nd{{>JT4XU$LKa*G2!ku`1*%X@5Lb6!nmhi(pXDxvHB3dx))i`0w@dgKRX_50 zEfwX*8N0fv15vDH10~!*(>i<+D{D;?N+{cN6Vdygp~fds@+`*d+3lm;+XlY_fynY2 zrSGjm4|wcl;bb;F)f3I7W18!tZ3B&1j(nMzCbU;lkPF&{%=l0w)0h=!Gr?T|C0|sa zL?6Vaz??`5h)KvH;lr&u{MAwDM#l&>0`U#I?Dz{Z)m(xoEn1^N1)cKzQaje+*ZRz4 zZCR>t1x-_OX|yeG)ZVS~daj+FKsN<`ksDXB9_2KrYSGrc+(CKi9VZ(JeW*I@>|?+& zs>onI2q6#;7vb1!*i$`{T1yK(FQT*4=zlUCWbRrscH-+Yj8g6|kcAV`v*FYd;sl=X ziy`AulyfW?eWDPSFC*?4UJ?zY4|Xxvg1DVfFhh#D^8^9=C1i_mlzX5~NJ7c=3iPe4 zX*lFBh?`P5klm#ukk*o_>tbqT=WjhmHwTkhR|KS0Q{a(s$^tl-qKpY7SFEIE0m&~z zf1>mrxLuj~&!G%$X~IP)3I?sZG>Gh8>ecx|F{h)24X`_z>@H1!`m7Ffcy}MIq{!S9 z@7!3rMjD0=$Z?GIAsP(|6_L;=w|K2?Vsh386GiEX@+K|RKIB5&NExaM?gC(Xzjt#X zq6n*tplLz$Qpn{f3ZL|PAESr+K{0znW(Wapa6{K7yTQSMM~?4}vPC_(NzBdoU3152 zn|=3GShq~``Y?m;m=jEsiu|n|QntcTpBd7#Y-v6#r0}!id`3v`st)tHL~6Pz(B}~; zzAgqFB6TSDI9ZOkCs%@3AN>0pyWoRK^z`^tdPQ||=)rk;k(dTp45zFfPL1?@_Vq%( z(-T3N?-DifC`FLe4fjn^r4R2mW6Z9okxkQjrXz|PiJv*~M*iJ_=NVa3F^^Q#K&n0< zw4BVY`q$Z1?`$FsL;+v}D&#z|6?Fxcg`xN`J5y%MBq})N8doMWGsj)@kPph9@T)XL zuPG1$MBIa0MH7KdAeMqr;+{-ODk}U{E*6nfzRL?kjbgydY_GnWD8#?8=5sQBy_qP* zeeSzEhcbFZG?lfb5u(!V-F@+hki!k}?W4Kq-)Nz^m1^L8CgsJXzhM}(kx)q(|EwJ8 zK@}`}oh?iJ6|q<8&sb<~F{SEf7vJ)sE8leA((?jJSoSa5$NzTw`xnUH>WFjS5*E6(`E zFAkpdvT;Y=!mq%MMi1t zlohm)1VT&+_jeMt@fS!)@lAI*OjoohkHn+`_~SBoyk?RVN!z;Sj^5Nv?Gv3(TGJ1kN7 zJ-aTFrG!FtR>?>};%+HsIj0WNPS2|ox+m!02U-N3NqoB$zHjicyEJD6(P2K(GOs9B zC*TRfl=E^9q$f!?&)+!3T3(Sk6>{Xn)&7a82}d_K%Jpbw-A>*BhaBYj+o0*!C=+jtlmQ@wA&U9Q={`9ey#6Yf9?KnQ4X{sl5K#f@wB-cPG zzq%S#z&A@Emqs!MQgdG^{$tO#{OT%XtgK|(YZS`fecNSL`PW64=VAAj38-~6{oXO3 zk<4QFs!?#7xdn!Ryjy>uCi6i@W{@fa8|6vT(_d!dMnhk2Cqy%W{;@_OcN05&dmkoO zJO%ocO>`Xo$fqo-2*>B&Pe)~A>G!Vt0A-TT4^m5Kr3nx)fP%lU^VYwymgQcuWY}sn zXc%?#oYMWhTygwuH~Uu+zBz)Ce<}B>i7I(RHlb^EYpf&in{XlJk+F=Ab}y+V(SoM` zak=E#q@(mD$|FPb!&PH}-vnu1DHb!~V-Fe!H-%I)DcADpl%r>N^FIo?o)47M7L*ca z*?K$s&Mc{A0d$3?Fe)Ky&Ta`Wzk~RP(rV2Oz)EIH?J6iHpXc!K#yUqScz{~|JZw2N=}EmHrnGA7s3)G zvb05vgC0pL)7K#rlIX$3F}8?VG62eqZ>3>fm8(^5?P_!l z-b}po>>;A0kwDucB7gS9R*g>X<95nD&>fm21^mWp%Q57DX9yXBj)o&pHVv-me}v}_ z67e_8)(s()@Rrh1<@Xe6$tT25_?AeJ^&0hd4!}~1IITJ>KbG#g7kG;_>i9&-Wh%nA zWh_geiXWqA@Zjs!BFI}cDtbQ+ib?04bB(~I2hG-=KuX`EJTi>F*=`N4hEE(D5XEm2 zyEC_0b>aPM^UdW4DaXy->$Vng8=91@BABU6lv>j2tv!ihl-B=3F?YH^+3FtVUyN&< z+amHK%`@Wb_vUS;sXx#EhbfHeOX+R-_Cok z<_FZ3BJESk5j*l8BaT*1mp(M~X$I+NE>E4+Id1y~o!Te{497iPm0~f>HjK*3iKGj9 z4T1j^)$C6QUGm((Y4*-fSFj_`9l8f7HH>O_^ePDt-{E%sblTbzMClJHr!ghSu4{H{ ziXZ@^RJL*3@0$Eb1;9I?bV~Z9Qk->Ig&wM?i?t!spoiEsd7=g0x1v~hAlM_^n{DzN zyggQMyCzEomuB}`Bl8)wqCgNy3ke|wX3*e~x?a=PAG){fM8*TkTYWMJe9vj^dzyy= z(=vkxb`xCi9o-n6U*4~VnmcFm>zbM(JWvr8mXXrwTMzO{;wWp>RBla3PBh)cjE=@H z)$tuq<4+xsZ*<~ZIJU0YO>Q-5lud)Tzp_9H$Ih#wt~mmS)R`dYsg9kp@ zQpPN1a0uD>j@__A<_S4#hvvjHaBZI~y$Zwb6N`wOq+jTBW6M)exlY=Sv>Keh;g#xQ*r~5YrwyqZU71iEfEh2Y)@PWq1 znZXC=fnBwk^*A(d4dz>dRt2INgLh0F1)3_MQ95$hLw3`<=h<2mqp}(^p&EWT!B)@i z2N>wZ%fc1avyp!AtTGZ`{HpwsOOtQ$W6(<#`&HHZ!l|};e5UTc%Q}ljn?tw1nON4B zrXO%xS3C@XFxP~)PM|+7<iujKLD72_fYjF?s z_){o(@+0_1L7|g-5qm~85lMO5n^N@6PFJ^XOCVB?2ryf}*a^?{%Z*~lEG1ev+}~_% zkSEIEewXX_jGwJugY&5M1CA8~`iQmSIL~;`#h||{QEQCq*xXT)J5!2uXNNFoV|akV zZ`6$pU`i6eqVUll1dxvqpA>|zo^9_-A7@$H(e+;<`{zE_h=n+C+ p`PsVT|Fgf)T>KCHzBIQ_*E6Aoo<8L9%isuFG(Ron%v|G+{{jAiyafON literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..a60c773 --- /dev/null +++ b/index.html @@ -0,0 +1,226 @@ + + + + + + + + + +NEON SURVIVORS — Bullet Heaven + + + + + + + + + + + + + +
+ + +
+
NEONSURVIVORS
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js new file mode 100644 index 0000000..2f1ed24 --- /dev/null +++ b/js/achievements.js @@ -0,0 +1,90 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — achievements.js + Declarative achievement list + evaluator. Names are embedded + bilingual strings (kept out of i18n dicts on purpose). + cond(save, G) -> boolean against persisted progress only. + ============================================================ */ + +const ACHS = [ + // ---- combat milestones ---- + { id: 'kill100', icon: '⚔️', vi: 'Thợ Săn Mới', en: 'Rookie Hunter', + dvi: 'Diệt 100 quái', den: 'Kill 100 enemies', cond: s => s.totals.kills >= 100 }, + { id: 'kill500', icon: '🗡️', vi: 'Kẻ Trừng Phạt', en: 'Executioner', + dvi: 'Diệt 500 quái', den: 'Kill 500 enemies', cond: s => s.totals.kills >= 500 }, + { id: 'kill2500', icon: '💀', vi: 'Tử Thần Nhỏ', en: 'Petite Reaper', + dvi: 'Diệt 2.500 quái', den: 'Kill 2,500 enemies', cond: s => s.totals.kills >= 2500 }, + { id: 'kill10k', icon: '☠️', vi: 'Tai Họa Đi Bộ', en: 'Walking Calamity', + dvi: 'Diệt 10.000 quái', den: 'Kill 10,000 enemies', cond: s => s.totals.kills >= 10000 }, + + // ---- wealth ---- + { id: 'gold1k', icon: '🪙', vi: 'Giàu Có', en: 'Coin Purse', + dvi: 'Kiếm 1.000 vàng lifetime', den: 'Earn 1,000 gold lifetime', cond: s => s.totals.gold >= 1000 }, + { id: 'gold10k', icon: '💰', vi: 'Đại Gia Vàng', en: 'Gold Baron', + dvi: 'Kiếm 10.000 vàng lifetime', den: 'Earn 10,000 gold lifetime', cond: s => s.totals.gold >= 10000 }, + + // ---- survival time ---- + { id: 'time5', icon: '⏱️', vi: 'Làm quen', en: 'Warmed Up', + dvi: 'Sống sót 5 phút', den: 'Survive 5 minutes', cond: s => s.totals.best >= 300 }, + { id: 'time10', icon: '⏳', vi: 'Bền Bỉ', en: 'Tenacious', + dvi: 'Sống sót 10 phút', den: 'Survive 10 minutes', cond: s => s.totals.best >= 600 }, + { id: 'time15', icon: '🌙', vi: 'Dòng Chảy', en: 'In The Zone', + dvi: 'Sống sót 15 phút', den: 'Survive 15 minutes', cond: s => s.totals.best >= 900 }, + { id: 'time20', icon: '🌅', vi: 'Bình Minh', en: 'Daybreak', + dvi: 'Sống sót đủ 20 phút (thắng)', den: 'Survive the full 20 minutes (win)', cond: s => s.totals.best >= 1200 || s.totals.wins >= 1 }, + + // ---- victories & difficulty ---- + { id: 'win1', icon: '👑', vi: 'Kết Thúc Cuộc Ngủ', en: 'Wake The Dead', + dvi: 'Hạ Tử Thần — thắng 1 ván', den: 'Slay Death — win a run', cond: s => s.totals.wins >= 1 }, + { id: 'win5', icon: '🏆', vi: 'Vua Sống Sót', en: 'Survivor King', + dvi: 'Thắng 5 ván', den: 'Win 5 runs', cond: s => s.totals.wins >= 5 }, + { id: 'grade3', icon: '💀', vi: 'Vô Cầu Vị', en: 'No Mercy', + dvi: 'Thắng ở Độ khó 💀 (III+)', den: 'Win on Grade 💀 (III+)', cond: s => (s.progress.gradeWins[2] || 0) + (s.progress.gradeWins[3] || 0) >= 1 }, + + // ---- stages & endless ---- + { id: 'st_frost', icon: '❄️', vi: 'Phá Băng', en: 'Ice Breaker', + dvi: 'Thắng Hang Băng', den: 'Win Frozen Hollow', cond: s => !!s.progress.stageWins.frostcave }, + { id: 'st_magma', icon: '🌋', vi: 'Chúa Tể Núi Lửa', en: 'Volcano Lord', + dvi: 'Thắng Vực Magma', den: 'Win Magma Rift', cond: s => !!s.progress.stageWins.magmalands }, + { id: 'endless25', icon: '∞', vi: 'Không Điểm Dừng', en: 'No Stop Sign', + dvi: 'Endless đạt 25 phút', den: 'Reach minute 25 in Endless', cond: s => (s.progress.endlessBest || 0) >= 1500 }, + + // ---- collection ---- + { id: 'evo1', icon: '🌟', vi: 'Tiến Hóa!', en: 'Evolve!', + dvi: 'Tiến hóa 1 vũ khí', den: 'Evolve a weapon', cond: s => (s.totals.evos || 0) >= 1 }, + { id: 'evo6', icon: '✨', vi: 'Nhà Sưu Tầm', en: 'Collector', + dvi: 'Tiến hóa 6 lần (tổng mọi ván)', den: 'Evolve 6 times (across runs)', cond: s => (s.totals.evos || 0) >= 6 }, + { id: 'chars_all', icon: '🎭', vi: 'Full Dàn Sao', en: 'Full Roster', + dvi: 'Mở khóa tất cả nhân vật', den: 'Unlock every character', cond: s => (s.chars || []).length >= Object.keys(CHARS).length }, + { id: 'meta10', icon: '🔧', vi: 'Nâng Cấp Giỏi', en: 'Well Oiled', + dvi: 'Mua tổng cộng 10 cấp cửa hàng', den: 'Buy 10 total meta-shop ranks', cond: s => Object.values(s.meta || {}).reduce((a, b) => a + b, 0) >= 10 }, + + // ---- misc ---- + { id: 'props50', icon: '💎', vi: 'Phá Phách', en: 'Smasher', + dvi: 'Phá 50 đèn pha lê', den: 'Break 50 crystal lamps', cond: s => (s.totals.props || 0) >= 50 }, + { id: 'daily1', icon: '⚡', vi: 'Người Của Hôm Nay', en: 'Daily Daredevil', + dvi: 'Hoàn thành 1 Thử thách hôm nay', den: 'Finish a Daily Challenge', cond: s => (s.daily.best || 0) > 0 } +]; + +/** Evaluate all; unlock new ones into save.ach and return their defs. */ +function checkAchievements() { + const save = Store.data; + const newly = []; + for (const a of ACHS) { + if (!save.ach[a.id] && _safe(a.cond, save)) { + save.ach[a.id] = Date.now(); + newly.push(a); + } + } + if (newly.length) Store.save(); + return newly; +} + +function _safe(fn, save) { try { return !!fn(save); } catch (e) { return false; } } + +/** Achievement count helper for menu/codex badges. */ +function achProgress() { + let n = 0; + for (const a of ACHS) if (Store.data.ach[a.id]) n++; + return { n, total: ACHS.length }; +} diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..30e27d3 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,195 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — audio.js : WebAudio synth SFX + generative music + No audio files needed; everything is synthesized. + ============================================================ */ + +const Snd = { + ctx: null, + master: null, + musicG: null, + sfxG: null, + + // music sequencer state + _step: 0, + _nextTime: 0, + _timer: null, + intensity: 0.2, // 0..1 — drives music layers + _xpCombo: 0, + _xpComboT: 0, + + init() { + if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; } + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.musicG = this.ctx.createGain(); + this.sfxG = this.ctx.createGain(); + this.master.connect(this.ctx.destination); + this.musicG.connect(this.master); + this.sfxG.connect(this.master); + this.applyVolumes(); + this._startMusic(); + }, + + applyVolumes() { + if (!this.ctx) return; + const s = Store.s(); + this.master.gain.value = s.volMaster; + this.musicG.gain.value = s.volMusic * 0.9; + this.sfxG.gain.value = s.volSfx; + }, + + midi(n) { return 440 * Math.pow(2, (n - 69) / 12); }, + + /** Generic oscillator blip. */ + tone(o) { + if (!this.ctx) return; + const t0 = (o.delay || 0) + this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = o.type || 'square'; + osc.frequency.setValueAtTime(o.f || 440, t0); + if (o.f2) osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f2), t0 + (o.dur || .1)); + const vol = o.vol !== undefined ? o.vol : .2; + const dur = o.dur || .1; + g.gain.setValueAtTime(0.0001, t0); + g.gain.linearRampToValueAtTime(vol, t0 + (o.attack || .004)); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + osc.connect(g); g.connect(o.dest || this.sfxG); + osc.start(t0); osc.stop(t0 + dur + .05); + }, + + /** Filtered noise burst. */ + noise(o) { + if (!this.ctx) return; + const dur = o.dur || .15; + const t0 = (o.delay || 0) + this.ctx.currentTime; + const len = Math.max(1, Math.floor(this.ctx.sampleRate * dur)); + const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buf; + const flt = this.ctx.createBiquadFilter(); + flt.type = o.hp ? 'highpass' : 'lowpass'; + flt.frequency.setValueAtTime(o.f || 1200, t0); + if (o.f2) flt.frequency.exponentialRampToValueAtTime(Math.max(20, o.f2), t0 + dur); + const g = this.ctx.createGain(); + g.gain.setValueAtTime(o.vol !== undefined ? o.vol : .2, t0); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + src.connect(flt); flt.connect(g); g.connect(this.sfxG); + src.start(t0); src.stop(t0 + dur + .02); + }, + + /** Play named SFX (throttled per name). */ + play(name, force) { + if (!this.ctx) return; + if (!force && !throttled('sfx_' + name, 45)) return; + switch (name) { + case 'shoot': this.tone({ f: rand(640, 760), f2: 260, type: 'triangle', dur: .06, vol: .05 }); break; + case 'hit': this.noise({ dur: .05, vol: .09, f: 1900 }); break; + case 'crit': this.tone({ f: 1568, type: 'sine', dur: .06, vol: .12 }); + this.tone({ f: 2093, type: 'sine', dur: .09, vol: .1, delay: .045 }); break; + case 'kill': this.noise({ dur: .08, vol: .12, f: 520, f2: 160 }); + this.tone({ f: 300, f2: 90, type: 'sine', dur: .08, vol: .11 }); break; + case 'hurt': this.tone({ f: 170, f2: 70, type: 'sawtooth', dur: .22, vol: .3 }); + this.noise({ dur: .12, vol: .18, f: 700 }); break; + case 'xp': { + const now = performance.now(); + if (now - this._xpComboT > 900) this._xpCombo = 0; + this._xpComboT = now; + this._xpCombo = (this._xpCombo + 1) % 8; + this.tone({ f: 660 * Math.pow(2, this._xpCombo / 12), type: 'sine', dur: .07, vol: .09 }); break; + } + case 'coin': this.tone({ f: 1318, type: 'square', dur: .05, vol: .08 }); + this.tone({ f: 1760, type: 'square', dur: .09, vol: .08, delay: .055 }); break; + case 'heal': [523, 659, 784].forEach((f, i) => this.tone({ f, type: 'sine', dur: .16, vol: .12, delay: i * .07 })); break; + case 'levelup':[392, 494, 587, 784].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .22, vol: .18, delay: i * .09 })); break; + case 'chest': [659, 830, 988, 1318].forEach((f, i) => this.tone({ f, type: 'sine', dur: .2, vol: .14, delay: i * .08 })); + this.noise({ dur: .4, vol: .06, f: 6000, hp: true }); break; + case 'evolve': this.tone({ f: 300, f2: 1400, type: 'sawtooth', dur: .5, vol: .16 }); + [523, 659, 784, 1046].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .3, vol: .14, delay: .25 + i * .07 })); break; + case 'boom': this.noise({ dur: .38, vol: .4, f: 900, f2: 90 }); + this.tone({ f: 130, f2: 36, type: 'sine', dur: .32, vol: .35 }); break; + case 'zap': this.tone({ f: 1500, f2: 90, type: 'sawtooth', dur: .13, vol: .16 }); break; + case 'freeze': this.noise({ dur: .14, vol: .12, f: 4200, hp: true }); + this.tone({ f: 1800, f2: 2400, type: 'sine', dur: .1, vol: .06 }); break; + case 'roar': this.tone({ f: 58, type: 'sawtooth', dur: .7, vol: .34 }); + this.tone({ f: 61, type: 'sawtooth', dur: .7, vol: .3 }); + this.noise({ dur: .5, vol: .15, f: 300 }); break; + case 'click': this.tone({ f: 900, type: 'square', dur: .03, vol: .1 }); break; + case 'death': this.tone({ f: 220, f2: 40, type: 'sawtooth', dur: .9, vol: .35 }); break; + case 'win': [523, 659, 784, 1046, 1318].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .35, vol: .18, delay: i * .12 })); break; + } + }, + + /* ---------------- generative dark-synthwave loop ---------------- */ + _startMusic() { + if (this._timer) clearInterval(this._timer); + this._step = 0; + this._nextTime = this.ctx.currentTime + .1; + this._timer = setInterval(() => this._schedule(), 30); + }, + + _schedule() { + if (!this.ctx || document.hidden) return; + const fl = this.stageFlavor || {}; + const spb = 60 / (118 * (fl.tempo || 1)) / 4; // 16th-note @118bpm × stage tempo + while (this._nextTime < this.ctx.currentTime + .14) { + this._playStep(this._step, this._nextTime); + this._step = (this._step + 1) % 64; + this._nextTime += spb; + } + }, + + /** Per-stage musical flavor: semitone shift + tempo multiplier. */ + setFlavor(f) { this.stageFlavor = f || null; }, + + _mnote(freq, when, dur, vol, type, dest) { + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = type; osc.frequency.value = freq; + g.gain.setValueAtTime(0.0001, when); + g.gain.linearRampToValueAtTime(vol, when + .01); + g.gain.exponentialRampToValueAtTime(0.0001, when + dur); + osc.connect(g); g.connect(dest); + osc.start(when); osc.stop(when + dur + .03); + }, + + _playStep(step, when) { + const I = this.intensity; + const fl = this.stageFlavor || {}; + const chords = [45, 41, 48, 43].map(c => c + (fl.shift || 0)); // Am F C G, stage-shifted + const root = chords[(step >> 4) & 3]; + + // Bass on syncopated steps + if (step % 8 === 0 || step % 8 === 3 || step % 8 === 6) { + this._mnote(this.midi(root - 12), when, .17, .16, 'square', this.musicG); + } + // Kick + if (I > .25 && step % 8 === 0) this._mnote(110, when, .12, .22 * I, 'sine', this.musicG); + // Hats + if (I > .15 && step % 2 === 1) { + // cheap hat via short high sine tick + this._mnote(8000, when, .02, .04 * Math.min(1, I * 1.6), 'square', this.musicG); + } + // Lead pentatonic random walk + const pent = [0, 3, 5, 7, 10]; + const leadDensity = .18 + I * .5; + if (Math.random() < leadDensity && step % 2 === 0) { + const oct = chance(.25) ? 24 : 12; + const note = root + oct + pick(pent); + this._mnote(this.midi(note), when, .14, .07 + I * .05, 'triangle', this.musicG); + } + // Pad chord swell each bar + if (step % 16 === 0) { + [0, 3, 7].forEach(iv => this._mnote(this.midi(root + iv), when, 1.6, .028, 'sawtooth', this.musicG)); + } + // Boss tension drone + if (I > .75 && step % 16 === 8) this._mnote(this.midi(root - 24), when, .3, .12, 'sawtooth', this.musicG); + }, + + setIntensity(v) { this.intensity = clamp(v, 0, 1); } +}; diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..306d12e --- /dev/null +++ b/js/data.js @@ -0,0 +1,388 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — data.js : all game content definitions + Weapons / Evolutions / Passives / Characters / Enemies / + Bosses / Spawn timeline / Meta shop. + ============================================================ */ + +/* ---------------- WEAPONS ---------------- + Stat model: effective stat = base + sum(per[0..level-2]) + dmg damage per hit cd seconds between activations + n projectile/count spd projectile speed (px/s) + pierce extra enemies pierced dur lifetime (s) + area size multiplier kb knockback impulse +*/ +const WEAPONS = { + wand: { + sym: '🪄', col: '#b39dff', max: 8, + base: { dmg: 12, cd: 1.1, n: 1, spd: 430, pierce: 0, dur: 1.6, area: 1, kb: 40 }, + per: [ + { dmg: 5 }, { n: 1 }, { cd: -0.15 }, { dmg: 6 }, + { n: 1, spd: 60 }, { pierce: 1, dmg: 4 }, { n: 1 }, { cd: -0.15, dmg: 8 } + ], + evo: { into: 'e_wand', need: 'p_cd' } + }, + whip: { + sym: '🩹', col: '#ff7a9c', max: 8, + base: { dmg: 14, cd: 1.35, n: 1, area: 1.1, kb: 140 }, + per: [ + { dmg: 6 }, { area: 0.15 }, { dmg: 8 }, { n: 1 }, + { dmg: 10 }, { area: 0.2 }, { cd: -0.15 }, { dmg: 12 } + ], + evo: { into: 'e_whip', need: 'p_hp' } + }, + knife: { + sym: '🔪', col: '#cfe8ff', max: 8, + base: { dmg: 8, cd: 0.9, n: 2, spd: 520, pierce: 0, dur: 1.1, kb: 20 }, + per: [ + { n: 1 }, { dmg: 4 }, { n: 2 }, { dmg: 4 }, + { pierce: 1 }, { n: 2 }, { dmg: 6 }, { n: 2, spd: 80 } + ], + evo: { into: 'e_knife', need: 'p_spd' } + }, + orb: { + sym: '🔮', col: '#7dfaff', max: 8, + base: { dmg: 10, cd: 1.0, n: 2, spd: 2.4, area: 1, hitCd: 0.5 }, + per: [ + { n: 1 }, { spd: 0.4 }, { dmg: 5 }, { n: 1 }, + { area: 0.15 }, { dmg: 6 }, { n: 1, spd: 0.4 }, { dmg: 8, area: 0.15 } + ], + evo: { into: 'e_orb', need: 'p_area' } + }, + axe: { + sym: '🪓', col: '#ffb347', max: 8, + base: { dmg: 26, cd: 1.7, n: 1, spd: 240, pierce: 99, area: 1, kb: 200, grav: 900 }, + per: [ + { dmg: 12 }, { n: 1 }, { dmg: 12 }, { n: 1 }, + { area: 0.25 }, { dmg: 16 }, { n: 1, cd: -0.2 }, { dmg: 20 } + ], + evo: { into: 'e_axe', need: 'p_might' } + }, + garlic: { + sym: '💡', col: '#fff3b0', max: 8, + base: { dmg: 5, cd: 0.45, area: 1, kb: 90, radius: 95 }, + per: [ + { area: 0.18 }, { dmg: 3 }, { area: 0.18 }, { kb: 40 }, + { dmg: 4 }, { area: 0.22 }, { dmg: 5, cd: -0.05 }, { area: 0.25, dmg: 6 } + ], + evo: { into: 'e_garlic', need: 'p_regen' } + }, + light: { + sym: '⚡', col: '#9bf6ff', max: 8, + base: { dmg: 30, cd: 2.2, n: 2, area: 1, blastR: 70 }, + per: [ + { n: 1 }, { dmg: 12 }, { n: 1 }, { area: 0.2 }, + { dmg: 16 }, { n: 1 }, { cd: -0.3 }, { dmg: 20, n: 1 } + ], + evo: { into: 'e_light', need: 'p_dur' } + }, + fire: { + sym: '🔥', col: '#ff8c42', max: 8, + base: { dmg: 20, cd: 2.0, n: 1, spd: 300, area: 1, blastR: 85, burnDps: 8, burnDur: 2, kb: 60 }, + per: [ + { dmg: 8 }, { n: 1 }, { dmg: 10 }, { area: 0.25 }, + { n: 1 }, { dmg: 12 }, { cd: -0.3 }, { area: 0.3, dmg: 14 } + ], + evo: { into: 'e_fire', need: 'p_luck' } + }, + frost: { + sym: '❄️', col: '#a0e7ff', max: 8, + base: { dmg: 10, cd: 1.2, n: 1, spd: 480, pierce: 2, dur: 1.4, slow: 0.35, slowDur: 2, kb: 30 }, + per: [ + { n: 1 }, { dmg: 5 }, { slow: 0.1 }, { pierce: 2 }, + { dmg: 6 }, { n: 1 }, { slow: 0.1, dmg: 6 }, { n: 2, dmg: 8 } + ], + evo: { into: 'e_frost', need: 'p_mag' } + }, + cross: { + sym: '✝️', col: '#ffffff', max: 8, + base: { dmg: 16, cd: 1.9, n: 1, spd: 420, pierce: 99, dur: 2.2, area: 1, rehit: 0.45, kb: 80 }, + per: [ + { dmg: 8 }, { n: 1 }, { dmg: 8 }, { spd: 60 }, + { n: 1 }, { dmg: 10 }, { dur: 0.4, area: 0.15 }, { n: 1, dmg: 12 } + ], + evo: { into: 'e_cross', need: 'p_move' } + }, + mine: { + sym: '🧨', col: '#ff6b9d', max: 8, + base: { dmg: 26, cd: 2.6, n: 1, dur: 10, blastR: 85, kb: 140, pierce: 0 }, + per: [ + { dmg: 10 }, { dur: 2 }, { blastR: 15 }, { n: 1 }, + { dmg: 14 }, { cd: -0.4 }, { blastR: 20, dmg: 12 }, { n: 1, dmg: 18 } + ], + evo: { into: 'e_mine', need: 'p_area' } + } +}; + +/* ---------------- EVOLVED WEAPONS (max level 1, from chests) ---------------- */ +const EVOLVED = { + e_mine: { sym: '💣', col: '#ff4d88', base: { dmg: 55, cd: 1.5, n: 3, dur: 14, blastR: 150, kb: 220 } }, + e_wand: { sym: '🌟', col: '#ffe066', base: { dmg: 30, cd: 0.55, n: 6, spd: 560, pierce: 2, dur: 1.8, kb: 60 } }, + e_whip: { sym: '🩸', col: '#ff4d6d', base: { dmg: 44, cd: 1.0, n: 2, area: 1.5, kb: 180, lifesteal: 0.08 } }, + e_knife: { sym: '🗡️', col: '#e8fbff', base: { dmg: 18, cd: 0.45, n: 8, spd: 640, pierce: 3, dur: 1.2, kb: 20 } }, + e_orb: { sym: '💫', col: '#7dfaff', base: { dmg: 28, cd: 0.35, n: 6, spd: 3.4, area: 1.5, hitCd: 0.4 } }, + e_axe: { sym: '🌀', col: '#ffd166', base: { dmg: 70, cd: 1.2, n: 4, spd: 320, pierce: 999, area: 1.6, kb: 260, grav: 700 } }, + e_garlic:{ sym: '😇', col: '#fffbe0', base: { dmg: 16, cd: 0.3, area: 1.9, kb: 160, radius: 175, healOnKill: 1 } }, + e_light: { sym: '🌩️', col: '#d0fbff', base: { dmg: 65, cd: 1.3, n: 5, area: 1.4, blastR: 100, chain: 2, stun: 1.0 } }, + e_fire: { sym: '☄️', col: '#ff5c33', base: { dmg: 50, cd: 1.4, n: 2, spd: 340, area: 1.7, blastR: 130, burnDps: 30, burnDur: 4, groundDur: 3.5, groundDps: 35, kb: 90 } }, + e_frost: { sym: '💠', col: '#cdf5ff', base: { dmg: 34, cd: 0.9, n: 3, spd: 560, pierce: 99, dur: 1.6, slow: 0.6, slowDur: 2.5, freeze: 1.4, kb: 40 } }, + e_cross: { sym: '⚜️', col: '#ffffff', base: { dmg: 60, cd: 1.5, n: 2, spd: 500, pierce: 999, dur: 3.2, area: 1.8, rehit: 0.4, kb: 120 } } +}; + +/** Unified weapon definition accessor. */ +function wdef(id) { return EVOLVED[id] || WEAPONS[id]; } + +/** Compute effective stats for a weapon at a given level. */ +function wstats(id, lvl) { + const ev = EVOLVED[id]; + if (ev) return Object.assign({}, ev.base); + const d = WEAPONS[id]; + const s = Object.assign({}, d.base); + for (let i = 0; i < Math.min(lvl - 1, d.per.length); i++) { + const add = d.per[i]; + for (const k in add) s[k] = (s[k] || 0) + add[k]; + } + s.cd = Math.max(0.12, s.cd); + return s; +} + +/* ---------------- PASSIVES ---------------- */ +const PASSIVES = { + p_might: { sym: '💪', col: '#ff7a7a', max: 5, val: 0.10 }, + p_armor: { sym: '🛡️', col: '#9db4ff', max: 5, val: 1 }, + p_hp: { sym: '❤️', col: '#ff5f7a', max: 5, val: 0.20 }, + p_regen: { sym: '🌿', col: '#7dff9e', max: 5, val: 0.6 }, + p_cd: { sym: '⏱️', col: '#7dd0ff', max: 5, val: 0.08 }, + p_area: { sym: '🌐', col: '#c07dff', max: 5, val: 0.12 }, + p_spd: { sym: '🏹', col: '#ffd57d', max: 5, val: 0.15 }, + p_dur: { sym: '⌛', col: '#ffc7ec', max: 5, val: 0.15 }, + p_move: { sym: '👟', col: '#9effd0', max: 5, val: 0.10 }, + p_mag: { sym: '🧲', col: '#ffa8f6', max: 5, val: 0.30 }, + p_luck: { sym: '🍀', col: '#8aff6b', max: 5, val: 0.10 }, + p_greed: { sym: '💰', col: '#ffd24a', max: 5, val: 0.20 }, + p_growth: { sym: '📘', col: '#7db8ff', max: 5, val: 0.10 } +}; + +/** Passive name/desc keys derive from id: p_might -> 'p_might'/'p_might_d'. */ + +/* ---------------- CHARACTERS ---------------- */ +const CHARS = { + kaito: { sym: '🧙', col: '#9b8cff', weapon: 'wand', + mods: {}, + cond: { key: 'cond_free', fn: () => true } }, + mira: { sym: '🛡️', col: '#ff9db0', weapon: 'whip', + mods: { armorFlat: 1, hpMul: 0.10 }, + cond: { key: 'cond_kills500', fn: () => Store.data.totals.kills >= 500 } }, + bolt: { sym: '⚡', col: '#ffe066', weapon: 'knife', + mods: { moveMul: 0.12, pspdMul: 0.15 }, + cond: { key: 'cond_time10', fn: () => Store.data.totals.best >= 600 } }, + luna: { sym: '🌙', col: '#b8a9ff', weapon: 'orb', + mods: { areaMul: 0.18, cdrMul: 0.10 }, + cond: { key: 'cond_gold2000', fn: () => Store.data.totals.gold >= 2000 } }, + rook: { sym: '🪓', col: '#ffb347', weapon: 'axe', + mods: { dmgMul: 0.20, moveMul: -0.05 }, + cond: { key: 'cond_boss1', fn: () => !!Store.data.flags.boss1 } }, + nyx: { sym: '🧛', col: '#ff6b81', weapon: 'garlic', + mods: { regenFlat: 0.4, magMul: 0.40 }, + cond: { key: 'cond_time15', fn: () => Store.data.totals.best >= 900 } }, + sera: { sym: '🌩️', col: '#7dfaff', weapon: 'light', + mods: { cdrMul: 0.12, luckFlat: 0.05 }, + cond: { key: 'cond_win', fn: () => Store.data.totals.wins >= 1 } }, + vane: { sym: '✝️', col: '#e8e8ff', weapon: 'cross', + mods: { luckFlat: 0.15, greedMul: 0.10 }, + cond: { key: 'cond_kills5000', fn: () => Store.data.totals.kills >= 5000 } }, + hana: { sym: '🏹', col: '#7dff9e', weapon: 'mine', + mods: { critC: 0.15, critM: 0.3 }, + cond: { key: 'cond_time15', fn: () => Store.data.totals.best >= 900 } }, + bram: { sym: '🧱', col: '#c96f4a', weapon: 'fire', + mods: { areaMul: 0.20, moveMul: -0.05, hpMul: 0.10 }, + cond: { key: 'cond_gold2000', fn: () => Store.data.totals.gold >= 2000 } } +}; + +/* ---------------- COSMETIC SKINS ---------------- + * Deterministic per-character palettes; purchasable with gold. + */ +const SKIN_POOL = [ + { key: 'ember', col: '#ff8c42', vi: 'Lửa', en: 'Ember', price: 300 }, + { key: 'frost', col: '#7fdcff', vi: 'Băng', en: 'Frost', price: 300 }, + { key: 'toxic', col: '#a4f94d', vi: 'Độc', en: 'Toxic', price: 450 }, + { key: 'rose', col: '#ff9db0', vi: 'Hồng', en: 'Rose', price: 450 }, + { key: 'gold', col: '#ffd24a', vi: 'Vàng', en: 'Gold', price: 800 } +]; + +function _strHash(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 16777619) >>> 0; } + return h; +} + +/** Two extra skins per character, chosen deterministically from the pool. */ +function skinsFor(charId) { + const h = _strHash(charId); + const a = SKIN_POOL[h % SKIN_POOL.length]; + let b = SKIN_POOL[(h + 2) % SKIN_POOL.length]; + if (b.key === a.key) b = SKIN_POOL[(h + 4) % SKIN_POOL.length]; + return [a, b]; +} + +/* ---------------- ENEMIES ---------------- */ +const ENEMIES = { + bat: { nk: 'en_bat', hp: 8, spd: 100, dmg: 6, xp: 1, r: 10, col: '#b06bff', shape: 'bat', ai: 'chase', wiggle: 1.6 }, + zombie: { nk: 'en_zombie', hp: 18, spd: 68, dmg: 10, xp: 1, r: 13, col: '#6bd66b', shape: 'zombie', ai: 'chase' }, + imp: { nk: 'en_imp', hp: 11, spd: 118, dmg: 8, xp: 1, r: 10, col: '#ff8c42', shape: 'imp', ai: 'chase', wiggle: 2.6 }, + arch: { nk: 'en_arch', hp: 24, spd: 76, dmg: 10, xp: 2, r: 13, col: '#e8e8e8', shape: 'archer', ai: 'shooter', range: 270, bspd: 235, bcd: 2.3 }, + brute: { nk: 'en_brute', hp: 135, spd: 46, dmg: 22, xp: 5, r: 24, col: '#c96f4a', shape: 'brute', ai: 'chase', kbRes: 0.75 }, + ghost: { nk: 'en_ghost', hp: 32, spd: 82, dmg: 12, xp: 2, r: 13, col: '#9ad5ff', shape: 'ghost', ai: 'ghost' }, + spit: { nk: 'en_spit', hp: 38, spd: 62, dmg: 12, xp: 3, r: 15, col: '#a4f94d', shape: 'spit', ai: 'shooter', range: 250, bspd: 210, bcd: 2.8, spread: 3 }, + garg: { nk: 'en_garg', hp: 95, spd: 60, dmg: 18, xp: 4, r: 17, col: '#8fa3b8', shape: 'garg', ai: 'charger', chargeSpd: 340 }, + /* breakable prop — no contact damage, drops coins when smashed */ + lamp: { nk: 'en_lamp', hp: 26, spd: 0, dmg: 0, xp: 0, r: 15, col: '#8fd0ff', shape: 'prop', ai: 'prop', prop: true } +}; + +/* Difficulty multipliers by elapsed minute. */ +function diffScale(minute) { + return { + hp: 1 + minute * 0.5 + minute * minute * 0.035, + dmg: 1 + minute * 0.08, + spd: Math.min(1 + minute * 0.012, 1.3) + }; +} + +/* Which enemy types can appear at a given minute (weighted evenly). */ +const SPAWN_TABLE = [ + /*00*/ ['bat'], + /*01*/ ['bat', 'zombie'], + /*02*/ ['zombie', 'bat', 'imp'], + /*03*/ ['zombie', 'imp', 'bat'], + /*04*/ ['zombie', 'imp', 'arch'], + /*05*/ ['imp', 'arch', 'zombie', 'bat'], + /*06*/ ['arch', 'imp', 'ghost'], + /*07*/ ['ghost', 'arch', 'brute'], + /*08*/ ['brute', 'ghost', 'imp', 'arch'], + /*09*/ ['brute', 'spit', 'ghost'], + /*10*/ ['spit', 'brute', 'garg', 'imp'], + /*11*/ ['garg', 'spit', 'ghost', 'brute'], + /*12*/ ['garg', 'brute', 'spit', 'bat'], + /*13*/ ['garg', 'spit', 'brute', 'ghost'], + /*14*/ ['garg', 'brute', 'spit', 'garg'], + /*15*/ ['garg', 'brute', 'spit', 'ghost'], + /*16*/ ['garg', 'brute', 'spit', 'imp'], + /*17*/ ['garg', 'brute', 'garg', 'spit'], + /*18*/ ['garg', 'brute', 'spit', 'garg'], + /*19*/ ['garg', 'brute', 'spit', 'garg'] +]; + +/* Elite spawn schedule (seconds). Elites drop treasure chests. */ +const ELITE_TIMES = [70, 145, 220, 295, 370, 445, 520, 595, 670, 745, 820, 895, 970, 1045, 1120]; + +/* Random elite modifiers — one may roll on spawn (65%). Bilingual inline. */ +const ELITE_AFFIXES = { + haste: { icon: '⚡', vi: 'Tốc Độ', en: 'Swift', col: '#ffe066', + apply(e) { e.spd *= 1.45; } }, + tank: { icon: '🛡️', vi: 'Cứng Cáp', en: 'Sturdy', col: '#9db4ff', + apply(e) { e.hp *= 1.6; e.maxhp = e.hp; e.dmg *= 1.15; + e.kbRes = Math.min(0.92, (e.def.kbRes || 0) + 0.55); } }, + blast: { icon: '💥', vi: 'Nổ Tung', en: 'Volatile', col: '#ff8c42', + apply(e) { e.blastOnDeath = true; } } +}; + +/* Boss plan — killing the final boss wins the run. */ +const RUN_LENGTH = 1200; +const BOSS_PLAN = [ + { t: 300, id: 'b_wolf' }, + { t: 600, id: 'b_lich' }, + { t: 900, id: 'b_behemoth' }, + { t: 1140, id: 'b_death' } +]; + +const BOSSES = { + b_wolf: { nk: 'bn_wolf', hp: 850, spd: 165, dmg: 25, r: 30, col: '#8a5cff', shape: 'wolf', ai: 'boss_wolf', xp: 60 }, + b_lich: { nk: 'bn_lich', hp: 2400, spd: 58, dmg: 28, r: 30, col: '#c9f0ff', shape: 'lich', ai: 'boss_lich', xp: 100 }, + b_behemoth: { nk: 'bn_behemoth', hp: 6500, spd: 42, dmg: 34, r: 44, col: '#ff5c5c', shape: 'behemoth', ai: 'boss_beho', xp: 160 }, + b_death: { nk: 'bn_death', hp: 15000, spd: 195, dmg: 45, r: 34, col: '#ddddff', shape: 'death', ai: 'boss_death', xp: 400 } +}; + +/* ---------------- STAGES ---------------- + * Each biome swaps the spawn table / boss plan / palette and applies + * hp/gold multipliers. Killing the plan's final boss wins (unless Endless). + */ +const STAGES = { + graveyard: { + id: 'graveyard', icon: '🪦', unlock: () => true, + music: { shift: 0, tempo: 1 }, + bg: { grid: 'rgba(90,90,160,0.10)', mote: ['#5a4d9c', '#3f7fb0'] }, + table: SPAWN_TABLE, + plan: [ + { t: 300, id: 'b_wolf' }, + { t: 600, id: 'b_lich' }, + { t: 900, id: 'b_behemoth' }, + { t: 1140, id: 'b_death', final: true } + ], + length: RUN_LENGTH, + hpMul: 1, goldMul: 1 + }, + frostcave: { + id: 'frostcave', icon: '❄️', unlock: s => s.flags.time10, + music: { shift: -3, tempo: 0.92 }, // darker, slower + lockKey: 'st_lock_frost', + bg: { grid: 'rgba(120,190,255,0.10)', mote: ['#7fc8ff', '#b0e8ff'] }, + table: (() => { + // colder mix: ghosts early, brutes mid, gargs sooner + const t = SPAWN_TABLE.map(r => r.slice()); + t[2] = ['ghost', 'zombie', 'bat']; + t[6] = ['ghost', 'arch', 'imp']; + t[9] = ['brute', 'garg', 'spit']; + t[12] = ['garg', 'garg', 'spit', 'brute']; + return t; + })(), + plan: [ + { t: 240, id: 'b_lich' }, + { t: 540, id: 'b_wolf' }, + { t: 870, id: 'b_behemoth' }, + { t: 1140, id: 'b_death', final: true } + ], + length: RUN_LENGTH, + hpMul: 1.12, goldMul: 1.25 + }, + magmalands: { + id: 'magmalands', icon: '🌋', unlock: s => s.totals.wins >= 1, + music: { shift: 2, tempo: 1.08 }, // hotter, faster + lockKey: 'st_lock_magma', + bg: { grid: 'rgba(255,120,60,0.09)', mote: ['#ff8c42', '#ffd166'] }, + table: (() => { + // violent mix: imps early, spitters sooner, heavy late + const t = SPAWN_TABLE.map(r => r.slice()); + t[1] = ['bat', 'imp']; + t[4] = ['imp', 'spit', 'zombie']; + t[7] = ['spit', 'brute', 'garg']; + t[10] = ['garg', 'brute', 'brute', 'spit']; + t[16] = ['garg', 'garg', 'brute', 'brute']; + return t; + })(), + plan: [ + { t: 210, id: 'b_behemoth' }, + { t: 480, id: 'b_lich' }, + { t: 780, id: 'b_wolf' }, + { t: 1080, id: 'b_death', final: true } + ], + length: RUN_LENGTH, + hpMul: 1.28, goldMul: 1.5 + } +}; + +/* ---------------- META SHOP ---------------- */ +const META_SHOP = [ + { id: 'might', sym: '💪', base: 100, max: 5 }, + { id: 'hp', sym: '❤️', base: 100, max: 5 }, + { id: 'armor', sym: '🛡️', base: 120, max: 5 }, + { id: 'regen', sym: '🌿', base: 150, max: 5 }, + { id: 'spd', sym: '👟', base: 120, max: 5 }, + { id: 'cd', sym: '⏱️', base: 180, max: 5 }, + { id: 'growth', sym: '📘', base: 130, max: 5 }, + { id: 'greed', sym: '💰', base: 130, max: 5 }, + { id: 'luck', sym: '🍀', base: 140, max: 5 }, + { id: 'magnet', sym: '🧲', base: 110, max: 5 }, + { id: 'reroll', sym: '🎲', base: 150, max: 3 }, + { id: 'revive', sym: '🕊️', base: 350, max: 2 } +]; +function metaCost(def, rank) { return Math.round(def.base * Math.pow(1.7, rank)); } diff --git a/js/entities.js b/js/entities.js new file mode 100644 index 0000000..41a576b --- /dev/null +++ b/js/entities.js @@ -0,0 +1,280 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — entities.js : entity classes & factories + ============================================================ */ + +let _eid = 1; + +/* ================= PLAYER ================= */ +class Player { + constructor(charId) { + const c = CHARS[charId]; + this.charId = charId; + this.sym = c.sym; + this.col = c.col; + // equipped cosmetic skin (owned only) + try { + const sel = Store.data.skinSel[charId]; + if (sel && Store.data.skinOwned[charId + ':' + sel]) { + const def = skinsFor(charId).find(s => s.key === sel); + if (def) this.col = def.col; + } + } catch (e) { /* headless tests / legacy saves */ } + this.x = 0; this.y = 0; + this.r = 13; + this.faceX = 1; this.faceY = 0; + + this.level = 1; + this.xp = 0; + this.xpNext = this._xpFor(1); + this.gold = 0; + this.kills = 0; + + this.weapons = [{ id: c.weapon, lvl: 1, timer: 0.35 }]; + this.passives = {}; + this.rerollMax = 0; + + this.regenAcc = 0; + this.invuln = 0; + this.revives = 0; + this.recompute(); + this.hp = this.maxhp; + } + + _xpFor(level) { + return Math.floor(10 + (level - 1) * 9 + Math.pow(level - 1, 1.9)); + } + + /** Recompute derived stats from meta shop + passives + character mods. */ + recompute() { + const m = Store.data.meta; + const rank = id => m[id] || 0; + + let dmgPct = rank('might') * 0.05; + let hpPct = rank('hp') * 0.10; + let armor = rank('armor'); + let regen = rank('regen') * 0.3; + let movePct = rank('spd') * 0.04; + let cdr = rank('cd') * 0.04; + let growth = rank('growth') * 0.06; + let greed = rank('greed') * 0.15; + let luck = rank('luck') * 0.10; + let magMul = 1 + rank('magnet') * 0.20; + this.rerollMax = rank('reroll'); + + // passives + const pl = this.passives; + dmgPct += (pl.p_might || 0) * PASSIVES.p_might.val; + armor += (pl.p_armor || 0) * PASSIVES.p_armor.val; + hpPct += (pl.p_hp || 0) * PASSIVES.p_hp.val; + regen += (pl.p_regen || 0) * PASSIVES.p_regen.val; + cdr += (pl.p_cd || 0) * PASSIVES.p_cd.val; + let areaPct = (pl.p_area || 0) * PASSIVES.p_area.val; + let pspdPct = (pl.p_spd || 0) * PASSIVES.p_spd.val; + let durPct = (pl.p_dur || 0) * PASSIVES.p_dur.val; + movePct += (pl.p_move || 0) * PASSIVES.p_move.val; + let magPct = (pl.p_mag || 0) * PASSIVES.p_mag.val; + luck += (pl.p_luck || 0) * PASSIVES.p_luck.val; + greed += (pl.p_greed || 0) * PASSIVES.p_greed.val; + growth += (pl.p_growth || 0) * PASSIVES.p_growth.val; + + // character modifiers + const cm = CHARS[this.charId].mods; + dmgPct += cm.dmgMul || 0; + hpPct += cm.hpMul || 0; + armor += cm.armorFlat || 0; + regen += cm.regenFlat || 0; + movePct += cm.moveMul || 0; + cdr += cm.cdrMul || 0; + areaPct += cm.areaMul || 0; + pspdPct += cm.pspdMul || 0; + magPct += cm.magMul || 0; + luck += cm.luckFlat || 0; + greed += cm.greedMul || 0; + + this.might = Math.max(0.1, 1 + dmgPct); + this.maxhp = Math.round(100 * (1 + hpPct)); + this.armor = armor; + this.regen = regen; + this.spd = 175 * (1 + movePct); + this.cdrMul = Math.max(0.40, 1 - cdr); + this.areaMul = Math.max(0.2, 1 + areaPct); + this.pspdMul = Math.max(0.2, 1 + pspdPct); + this.durMul = Math.max(0.2, 1 + durPct); + this.magR = 60 * magMul * (1 + magPct); + this.luck = Math.max(0, luck); + this.greed = Math.max(0, greed); + this.growth = Math.max(0, growth); + this.critC = 0.05 + (pl.p_luck ? pl.p_luck * 0.02 : 0) + (cm.critC || 0); + this.critM = 2.0 + (cm.critM || 0); + + if (this.hp > this.maxhp) this.hp = this.maxhp; + } + + weaponById(id) { return this.weapons.find(w => w.id === id); } + + canOfferWeapon(id) { + // not owned yet and there is a free slot + return !this.weaponById(id) && this.weapons.length < 6 && !this.hasEvolvedBase(id); + } + hasEvolvedBase(baseId) { + const into = WEAPONS[baseId] && WEAPONS[baseId].evo.into; + return !!this.weaponById(into); + } + canLevelWeapon(id) { + const w = this.weaponById(id); + return w && w.lvl < WEAPONS[id].max; + } + canOfferPassive(id) { + const cur = this.passives[id] || 0; + if (cur >= PASSIVES[id].max) return false; + if (cur > 0) return true; // already owned + return Object.keys(this.passives).length < 6; // free slot + } + + addXp(amount) { + this.xp += amount; + let ups = 0; + while (this.xp >= this.xpNext) { + this.xp -= this.xpNext; + this.level++; + this.xpNext = this._xpFor(this.level); + ups++; + } + return ups; + } +} + +/* ================= ENEMY ================= */ +class Enemy { + constructor(tid, x, y) { + const d = BOSSES[tid] || ENEMIES[tid]; + this.id = _eid++; + this.tid = tid; + this.boss = !!BOSSES[tid]; + this.def = d; + this.nk = d.nk; + this.x = x; this.y = y; + this.r = d.r; + this.spd = d.spd; + this.dmg = d.dmg; + this.xpVal = d.xp; + this.col = d.col; + + this.hp = d.hp; this.maxhp = d.hp; + this.elite = false; + this.slowT = 0; this.slowF = 1; + this.freezeT = 0; + this.burnT = 0; this.burnDps = 0; + this.stunT = 0; + this.kbx = 0; this.kby = 0; + this.flash = 0; + this.lastTouch = -99; + this.shootT = rand(0.5, 1.8); + this.aiT = rand(0, 2); + this.state = 0; // generic AI state + this.wob = rand(TAU); // wiggle phase + this.rot = 0; + this.dead = false; + } + + get speedFactor() { + if (this.freezeT > 0) return 0; + return this.slowT > 0 ? 1 - this.slowF : 1; + } + + applyElite(minute) { + this.elite = true; + this.hp *= 9; this.maxhp = this.hp; + this.dmg *= 1.6; + this.spd *= 0.92; + this.r *= 1.55; + this.xpVal *= 8; + } + + scaleTo(minute, mult) { + const s = diffScale(minute); + const m = mult || 1; + this.hp *= s.hp * m; this.maxhp = this.hp; + this.dmg *= s.dmg * Math.sqrt(m); + this.spd *= s.spd; + } +} + +/* ================= PROJECTILE ================= */ +class Proj { + constructor(o) { Object.assign(this, o); this.hits = new Set(); this.hitTimes = new Map(); this.t = 0; this.maxLife = this.life || 1; } +} + +/* ================= ENEMY BULLET ================= */ +class EBullet { + constructor(x, y, vx, vy, dmg, col) { + this.x = x; this.y = y; this.vx = vx; this.vy = vy; + this.dmg = dmg; this.r = 6; this.life = 5; this.col = col || '#ffd24a'; + } +} + +/* ================= PICKUP ================= */ +class Pickup { + constructor(type, x, y, val) { + this.type = type; // gem | coin | chicken | magnet | bomb | chest + this.x = x; this.y = y; + this.v = val || 1; + this.r = type === 'chest' ? 16 : type === 'chicken' ? 12 : 7; + this.vx = 0; this.vy = 0; + this.t = rand(TAU); + this.pull = false; + this.dead = false; + switch (type) { + case 'gem': this.col = val >= 25 ? '#ff5fd0' : val >= 5 ? '#57e8ff' : '#69ff8c'; break; + case 'coin': this.col = '#ffd24a'; break; + case 'chicken': this.col = '#ff9d5c'; break; + default: this.col = '#ffffff'; + } + } +} + +/* ================= PARTICLE / FLOATING TEXT / EFFECT ================= */ +/** Resettable particle — pooled via part(G,...) to avoid GC churn. */ +class Particle { + constructor(x, y, col, opts) { this.set(x, y, col, opts || {}); } + set(x, y, col, o) { + this.x = x; this.y = y; + const a = o.ang !== undefined ? o.ang : rand(TAU); + const sp = o.sp !== undefined ? o.sp : rand(30, 160); + this.vx = Math.cos(a) * sp; this.vy = Math.sin(a) * sp; + this.life = this.maxLife = o.life || rand(0.25, 0.6); + this.size = o.size || rand(2, 4.5); + this.col = col; + this.grav = o.grav || 0; + this.drag = o.drag !== undefined ? o.drag : 0.94; + return this; + } +} + +class FText { + /** + * Floating combat text. + * extra: { life, vy, pop } — pop = scale-punch on spawn (crits, heals). + */ + constructor(x, y, txt, col, big, extra) { + this.x = x + rand(-6, 6); this.y = y; + this.txt = txt; this.col = col || '#fff'; + this.big = big || false; + extra = extra || {}; + this.life = extra.life || 0.75; + this.maxLife = this.life; + this.vy = extra.vy !== undefined ? extra.vy : 34; + this.pop = extra.pop || (big ? 1.7 : 1); + this.t = 0; + } +} + +class Effect { + constructor(type, x, y, dur, data) { + this.type = type; this.x = x; this.y = y; + this.t = 0; this.dur = dur; + this.data = data || {}; + this.dead = false; + } +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..711fa30 --- /dev/null +++ b/js/game.js @@ -0,0 +1,739 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — game.js : Game class, loop, states, input + ============================================================ */ + +const ST = { MENU: 'menu', RUN: 'run', PAUSE: 'pause', LEVEL: 'level', CHEST: 'chest', OVER: 'over' }; + +class Game { + constructor(canvas) { + this.cv = canvas; + this.ctx = canvas.getContext('2d'); + this.dpr = Math.min(window.devicePixelRatio || 1, 2); + this.state = ST.MENU; + + // world + this.player = null; + this.enemies = []; + this.projs = []; + this.ebullets = []; + this.pickups = []; + this.parts = []; + this.partPool = []; + this.texts = []; + this.effects = []; + this.grid = new SpatialGrid(96); + + this.cam = { x: 0, y: 0 }; + this.time = 0; + this.lastDt = 0.016; + this.shake = 0; + this.hurtFlash = 0; + this.partCap = 600; + this.over = false; + this.playerHidden = false; + + // flow + this.queueLevelUps = 0; + this.pendingChests = 0; + this.currentChoices = []; + this.rerollsLeft = 0; + this.goldEarned = 0; + this.charId = 'kaito'; + this._screenFrom = 'scr-menu'; + + // developer mode (session-scoped) + this.timeScale = 1; + this.devGod = false; + this.devOneHit = false; + this.freezeT = 0; // hit-stop / slow-mo on big kills + + // director state + this.spawnAcc = 0; + this.lastBurstMinute = -1; + this.eliteIdx = 0; + this.bossIdx = 0; + this.boss = null; + + // input + this.keys = new Set(); + this.touchVec = { x: 0, y: 0 }; + this._bindInput(); + this._resize(); + window.addEventListener('resize', () => this._resize()); + document.addEventListener('visibilitychange', () => { + if (document.hidden && this.state === ST.RUN && Store.s().autoPause) this.togglePause(true); + }); + + this.applyQuality(); + + this._last = performance.now(); + const loop = (t) => { + const dt = Math.min(0.05, (t - this._last) / 1000); + this._last = t; + this.lastDt = dt; + this.frame(dt); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } + + /* ================= setup ================= */ + _resize() { + this.dpr = Math.min(window.devicePixelRatio || 1, 2); + this.cv.width = innerWidth * this.dpr; + this.cv.height = innerHeight * this.dpr; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + R._vig = null; + } + + applyQuality() { + const q = Store.s().particles; + this.partCap = q === 'low' ? 120 : q === 'med' ? 320 : 650; + } + + applySettings() { + Snd.applyVolumes(); + UI.els.fpsMeter.classList.toggle('hidden', !Store.s().fps); + this.applyQuality(); + } + + /* ================= run lifecycle ================= */ + startRun(charId, setup) { + setup = setup || {}; + this.setup = setup; + // deterministic seed for Daily Challenge; null restores Math.random + seedRng(setup.seed !== undefined ? setup.seed : null); + this.charId = charId || this._charSelDefault(); + const stageId = STAGES[setup.stage] ? setup.stage : 'graveyard'; + this.stage = STAGES[stageId]; + this.endless = !!setup.endless; + this.grade = setup.grade | 0; + this.daily = !!setup.daily; + this.diffMul = (this.stage.hpMul || 1) * (1 + this.grade * 0.32); + this.goldMul = (this.stage.goldMul || 1) * (1 + this.grade * 0.15); + this.finalBossId = this.stage.plan[this.stage.plan.length - 1].id; + this.propAcc = 0; + this.lastSurge = -1; + // tutorial state (skipped once completed) + this._tutStep = Store.data.flags.tutDone ? -1 : (setup.daily ? -1 : 0); + this._tutMoved = 0; + this.player = new Player(this.charId); + this.enemies.length = 0; + this.projs.length = 0; + this.ebullets.length = 0; + this.pickups.length = 0; + this.effects.length = 0; + this.texts.length = 0; + this.time = 0; + this.over = false; + this.playerHidden = false; + this.spawnAcc = 0; + this.lastBurstMinute = -1; + this.eliteIdx = 0; + this.bossIdx = 0; + this.boss = null; + this.queueLevelUps = 0; + this.pendingChests = 0; + this.goldEarned = 0; + this.rerollsLeft = this.player.rerollMax; + this.cam.x = 0; this.cam.y = 0; + this.shake = 0; + this.hurtFlash = 0; + + this.state = ST.RUN; + UI.showScreen(null); + UI.showHud(true); + UI.hideBossBar(); + Snd.setFlavor(this.stage.music || null); + UI.toast(tr('tip_move')); + Snd.init(); + } + + _charSelDefault() { return UI._charSel || 'kaito'; } + + frame(dt) { + // developer tools visibility (cheap toggle) + UI.els.devBtn.classList.toggle('hidden', + !(Store.s().dev && (this.state === ST.RUN || this.state === ST.PAUSE || + this.state === ST.LEVEL || this.state === ST.CHEST))); + + switch (this.state) { + case ST.RUN: + this.update(dt * (this.timeScale || 1)); + R.draw(this); + UI.hudUpdate(this, dt); + break; + case ST.PAUSE: + case ST.LEVEL: + case ST.CHEST: + R.draw(this); // frozen world behind modal + break; + case ST.OVER: + R.draw(this); + break; + default: + this._renderMenuBg(dt); + } + } + + update(dt) { + // hit-stop: brief slow-mo window after elite/boss kills + if (this.freezeT > 0) { + this.freezeT -= dt; + dt *= 0.12; + } + const P = this.player; + this.time += dt; + + // ---- movement input ---- + let mx = 0, my = 0; + const kb = Store.s().keybinds; + if (this.keys.has(kb.up) || this.keys.has('ArrowUp')) my -= 1; + if (this.keys.has(kb.down) || this.keys.has('ArrowDown')) my += 1; + if (this.keys.has(kb.left) || this.keys.has('ArrowLeft')) mx -= 1; + if (this.keys.has(kb.right) || this.keys.has('ArrowRight')) mx += 1; + + // gamepad: left stick + d-pad + edge-triggered A/Start + if (typeof navigator !== 'undefined' && navigator.getGamepads) { + let gp = null; + try { + for (const p of navigator.getGamepads()) if (p && p.connected) { gp = p; break; } + } catch (e) { /* some browsers throw when no permission */ } + if (gp) { + const dz = v => (Math.abs(v) > 0.22 ? v : 0); + mx += dz(gp.axes[0] || 0); + my += dz(gp.axes[1] || 0); + const btn = i => !!(gp.buttons[i] && gp.buttons[i].pressed); + if (btn(12)) my -= 1; + if (btn(13)) my += 1; + if (btn(14)) mx -= 1; + if (btn(15)) mx += 1; + const prev = this._gpPrev || (this._gpPrev = {}); + const now0 = btn(0), nowStart = btn(9) || btn(8); + if (now0 && !prev.b0) { + if (this.state === ST.LEVEL) this.chooseUpgrade(0); + else if (this.state === ST.CHEST) this.closeChest(); + } + if (nowStart && !prev.start) { + if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause(); + } + prev.b0 = now0; prev.start = nowStart; + } + } + + mx += this.touchVec.x; my += this.touchVec.y; + const ml = len2(mx, my); + if (ml > 1) { mx /= ml; my /= ml; } + P.x += mx * P.spd * dt; + P.y += my * P.spd * dt; + if (Math.abs(mx) + Math.abs(my) > 0.01) { + const fl = Math.max(0.001, len2(mx, my)); + P.faceX = mx / fl; P.faceY = my / fl; + } + + // tutorial: step 0 — learn to move; step 1 — make a level-up choice + if (this._tutStep === 0) { + UI.showTut('tut_move'); + this._tutMoved += len2(mx, my) * P.spd * dt; + if (this._tutMoved > 260) { this._tutStep = 1; UI.hideTut(); } + } else if (this._tutStep === 1 && this.player.level >= 2) { + UI.showTut('tut_choose'); + } + + // regen / timers + if (P.regen > 0) P.hp = Math.min(P.maxhp, P.hp + P.regen * dt); + if (P.invuln > 0) P.invuln -= dt; + if (this.hurtFlash > 0) this.hurtFlash -= dt; + if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 26); + + // systems + Sys.fireWeapons(this, dt); + Sys.spawnTick(this, dt); + Sys.updEnemies(this, dt); + Sys.updProjectiles(this, dt); + Sys.updEffects(this, dt); + Sys.updEBullets(this, dt); + Sys.updPickups(this, dt); + + // particles (pooled) + for (let i = this.parts.length - 1; i >= 0; i--) { + const p = this.parts[i]; + p.life -= dt; + if (p.life <= 0) { + removeItem(this.parts, i); + if (this.partPool.length < 500) this.partPool.push(p); + continue; + } + p.x += p.vx * dt; p.y += p.vy * dt; + p.vy += p.grav * dt; + const dr = Math.pow(p.drag, dt * 60); + p.vx *= dr; p.vy *= dr; + } + + // camera + const k = 1 - Math.exp(-8 * dt); + this.cam.x += (P.x - this.cam.x) * k; + this.cam.y += (P.y - this.cam.y) * k; + + // music intensity + Snd.setIntensity(Sys.musicIntensity(this)); + + // ---- flow: chests first, then level ups ---- + if (this.state === ST.RUN) { + if (this.pendingChests > 0) { + this.pendingChests--; + this._openChestFlow(); + return; + } + if (this.queueLevelUps > 0) { + this.queueLevelUps--; + this.currentChoices = Sys.makeChoices(this); + this.state = ST.LEVEL; + UI.hideModal('md-chest'); + UI.openLevelUp(this.currentChoices, this.rerollsLeft); + return; + } + } + } + + /* ================= flow helpers ================= */ + chooseUpgrade(idx) { + if (this.state !== ST.LEVEL) return; + const c = this.currentChoices[idx]; + if (!c) return; + Sys.applyChoice(this, c); + this._tutComplete(); + this._afterModal(); + } + + /** Finish the tutorial once the player makes their first upgrade choice. */ + _tutComplete() { + if (this._tutStep === 1 || !Store.data.flags.tutDone) { + Store.data.flags.tutDone = true; + Store.save(); + UI.hideTut(); + } + this._tutStep = -1; + } + + skipLevelUp() { + if (this.state !== ST.LEVEL) return; + this.player.gold += 15; + this.goldEarned += 15; + this._afterModal(); + } + + rerollChoices() { + if (this.state !== ST.LEVEL || this.rerollsLeft <= 0) return; + this.rerollsLeft--; + Snd.play('click'); + this.currentChoices = Sys.makeChoices(this); + UI.hideModal('md-level'); + UI.openLevelUp(this.currentChoices, this.rerollsLeft); + } + + _afterModal() { + UI.hideModal('md-level'); + if (this.pendingChests > 0) { + this.pendingChests--; + this._openChestFlow(); + return; + } + if (this.queueLevelUps > 0) { + this.queueLevelUps--; + this.currentChoices = Sys.makeChoices(this); + this.state = ST.LEVEL; + UI.openLevelUp(this.currentChoices, this.rerollsLeft); + return; + } + this.state = ST.RUN; + } + + _openChestFlow() { + const data = Sys.chestReward(this); + this.state = ST.CHEST; + UI.hideModal('md-level'); + UI.openChest(data); + } + + closeChest() { + UI.hideModal('md-chest'); + if (this.queueLevelUps > 0) { + this.queueLevelUps--; + this.currentChoices = Sys.makeChoices(this); + this.state = ST.LEVEL; + UI.openLevelUp(this.currentChoices, this.rerollsLeft); + return; + } + if (this.pendingChests > 0) { + this.pendingChests--; + this._openChestFlow(); + return; + } + this.state = ST.RUN; + } + + /* ================= events ================= */ + onBossDead(e) { + if (e.tid === 'b_wolf' && !Store.data.flags.boss1) { + Store.data.flags.boss1 = true; + Store.save(); + UI.toast(tr('toast_unlock', { c: tr('c_rook') }), 'gold'); + } + if (this.boss === e) { + this.boss = null; + UI.hideBossBar(); + } + if (e.tid === this.finalBossId && !this.endless) { + Snd.play('win', true); + this.gameOver(true); + } else { + if (e.tid === this.finalBossId && this.endless) { + UI.toast(tr('toast_endless_on'), 'gold'); + } + this.shakeIt(10); + Snd.play('boom', true); + } + } + + onPlayerDown() { + const P = this.player; + if (P.revives > 0) { + P.revives--; + P.hp = Math.round(P.maxhp * 0.6); + P.invuln = 2.2; + this.effects.push(new Effect('nova', P.x, P.y, 0.6, { r: 300, col: '#ffe066' })); + for (const e of this.enemies) { + if (!e.boss && dist2(P.x, P.y, e.x, e.y) < 280 * 280) { + Sys.damageEnemy(this, e, 9999, {}); + } + } + UI.toast(tr('toast_revive'), 'gold'); + Snd.play('evolve', true); + return; + } + Snd.play('death', true); + this.gameOver(false); + } + + gameOver(win) { + if (this.over) return; + this.over = true; + this.state = ST.OVER; + const P = this.player; + + // persist progression + Store.data.totals.kills += P.kills; + Store.data.totals.runs++; + Store.addGold(P.gold); + let newRecord = false; + if (this.time > Store.data.totals.best && this.time >= 30) { + Store.data.totals.best = Math.floor(this.time); + newRecord = true; + } + if (this.time >= 600) Store.data.flags.time10 = true; + if (this.time >= 900) Store.data.flags.time15 = true; + if (win) Store.data.totals.wins++; + + // stage / mode records + run history + const sec = Math.floor(this.time); + const prog = Store.data.progress; + if (win) { + prog.stageWins[this.stage.id] = (prog.stageWins[this.stage.id] || 0) + 1; + prog.gradeWins[this.grade] = (prog.gradeWins[this.grade] || 0) + 1; + } + if (win || sec > (prog.bestPerStage[this.stage.id] || 0)) { + prog.bestPerStage[this.stage.id] = Math.max(prog.bestPerStage[this.stage.id] || 0, win ? this.stage.length : sec); + } + if (this.endless && sec > (prog.endlessBest || 0)) { prog.endlessBest = sec; newRecord = true; } + Store.data.history.unshift({ + d: Date.now(), ch: this.charId, st: this.stage.id, + en: this.endless ? 1 : 0, g: this.grade, + t: sec, k: P.kills, gold: P.gold, lv: P.level, w: win ? 1 : 0 + }); + if (Store.data.history.length > 20) Store.data.history.length = 20; + + // daily challenge record + if (this.daily) { + const today = dailyKey(); + const d = Store.data.daily; + if (d.date !== today) { d.date = today; d.best = 0; } + if (sec > (d.best || 0)) { + d.best = sec; + UI.toast(tr('toast_daily_record'), 'gold'); + } + } + Store.save(); + UI.checkAndToastAch(); + this._lbSubmit(win, sec); + + setTimeout(() => { + UI.openOver(win, { + time: this.time, + kills: P.kills, + goldEarned: P.gold, + level: P.level, + best: Store.data.totals.best, + newRecord + }); + }, win ? 400 : 700); + if (win) this.shakeIt(12); + } + + /** Best-effort leaderboard submit (silent when no server configured). */ + _lbSubmit(win, sec) { + const s = Store.s(); + if (!s.lbUrl || sec < 30) return; + try { + const payload = { + name: (Store.data.lbName || 'Anon').slice(0, 16), + time: sec, kills: this.player.kills, gold: this.player.gold, + lv: this.player.level, stageId: this.stage.id, grade: this.grade, + endless: !!this.endless, daily: !!this.daily, win: !!win, + day: new Date().toISOString().slice(0, 10).replace(/-/g, '') + }; + payload.sig = lbSig(payload); + fetch(s.lbUrl.replace(/\/$/, '') + '/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true + }).then(r => { if (r.ok) UI.toast(tr('lb_sent'), 'gold'); }) + .catch(() => {}); + } catch (e) { /* offline — ignore */ } + } + + togglePause(forcePause) { + if (this.state === ST.RUN) { + this.state = ST.PAUSE; + UI.openPause(this); + } else if (this.state === ST.PAUSE && !forcePause) { + this.state = ST.RUN; + UI.hideModal('md-pause'); + } + } + + quitToMenu() { + Snd.setFlavor(null); + // abandoning a live run still banks gold/kills/time records + if (this.player && !this.over && this.time > 5) { + const P = this.player; + Store.data.totals.kills += P.kills; + Store.data.totals.runs++; + Store.addGold(P.gold); + if (this.time > Store.data.totals.best && this.time >= 30) + Store.data.totals.best = Math.floor(this.time); + if (this.time >= 600) Store.data.flags.time10 = true; + if (this.time >= 900) Store.data.flags.time15 = true; + this.over = true; + } + Store.save(); + this.state = ST.MENU; + UI.hideModal('md-pause'); + UI.hideModal('md-over'); + UI.showHud(false); + UI.showScreen('scr-menu'); + Snd.setIntensity(0.2); + } + + restart() { + UI.hideModal('md-over'); + this.startRun(this.charId); + } + + onBackFromScreen() { + // returning from options/codex/shop/chars/stage screens + const visible = ['scr-menu', 'scr-chars', 'scr-stage', 'scr-shop', 'scr-codex', 'scr-options'] + .find(id => !UI.els[id].classList.contains('hidden')); + if (this.state === ST.MENU || visible === 'scr-menu') { + UI.showScreen('scr-menu'); + } else if (visible === 'scr-options' && this.state === ST.PAUSE) { + UI.showScreen(null); + UI.openPause(this); + } else { + // browsing menus pre-run + if (visible === 'scr-stage') { UI.buildChars(); UI.showScreen('scr-chars'); } + else UI.showScreen('scr-menu'); + } + } + + buyMeta(id) { + const def = META_SHOP.find(m => m.id === id); + if (!def) return; + const rank = Store.rank(id); + if (rank >= def.max) return; + const cost = metaCost(def, rank); + if (!Store.spendGold(cost)) return; + Store.data.meta[id] = rank + 1; + Store.save(); + if (this.player) this.player.recompute(); + UI.buildShop(); + UI.refreshHeadGold(); + UI.checkAndToastAch(); + } + + /* ================= developer mode ================= */ + devAddGold(n) { + const P = this.player; + P.gold += n; this.goldEarned += Math.max(0, n); + Snd.play('coin'); + } + + devSetGold(v) { + v = Math.max(0, Math.floor(v) || 0); + this.goldEarned += Math.max(0, v - this.player.gold); + this.player.gold = v; + } + + devAddLevels(n) { + const P = this.player; + for (let i = 0; i < n; i++) { + this.queueLevelUps += P.addXp(P.xpNext); + } + } + + devGiveWeapon(id, maxIt) { + const P = this.player; + let w = P.weaponById(id); + if (EVOLVED[id]) { + if (!w) P.weapons.push({ id, lvl: 1, timer: 0.2 }); + } else if (w) { + w.lvl = maxIt ? WEAPONS[id].max : Math.min(WEAPONS[id].max, w.lvl + 1); + } else { + P.weapons.push({ id, lvl: maxIt ? WEAPONS[id].max : 1, timer: 0.2 }); + } + Snd.play('chest'); + } + + devGivePassive(id, maxIt) { + const P = this.player; + P.passives[id] = maxIt ? PASSIVES[id].max + : Math.min(PASSIVES[id].max, (P.passives[id] || 0) + 1); + P.recompute(); + Snd.play('levelup'); + } + + devHeal() { this.player.hp = this.player.maxhp; Snd.play('heal'); } + + devMagnetAll() { + for (const pk of this.pickups) pk.pull = true; + Snd.play('heal'); + } + + devNuke(radius) { + const P = this.player; + const r = radius || 900; + this.effects.push(new Effect('nova', P.x, P.y, 0.5, { r, col: '#ffd24a' })); + for (const e of this.enemies) { + if (dist2(P.x, P.y, e.x, e.y) < r * r) Sys.damageEnemy(this, e, 9999999, {}); + } + Snd.play('boom'); + this.shakeIt(10); + } + + devSkipTime(sec) { this.time += sec; } + + devSpawnElite() { Sys.spawnElite(this); } + + devSpawnBoss(id) { + if (!BOSSES[id]) return; + const pos = Sys.ringPos(this, 520); + const b = new Enemy(id, pos.x, pos.y); + b.hp *= 1 + Math.floor(this.time / 60) * 0.06; + b.maxhp = b.hp; + this.enemies.push(b); + this.boss = b; + UI.showBossBar(b); + UI.toast(tr('toast_boss'), 'warn'); + Snd.play('roar', true); + this.shakeIt(8); + } + + devUnlockAllChars() { + for (const id in CHARS) Store.unlockChar(id); + Store.save(); + UI.toast('🔓 ' + tr('dev_unlockall'), 'gold'); + } + + shakeIt(a) { + if (Store.s().shake) this.shake = Math.min(14, this.shake + a); + } + + /* ================= input ================= */ + _bindInput() { + window.addEventListener('keydown', (e) => { + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault(); + this.keys.add(e.code); + Snd.init(); + // developer mode shortcuts + if (Store.s().dev && e.code === 'Backquote') { + UI.toggleDevPanel(); + return; + } + if (e.code === 'Escape' && !UI.els['md-dev'].classList.contains('hidden')) { + UI.hideModal('md-dev'); + return; + } + if ((e.code === 'Escape' || e.code === 'KeyP')) { + if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause(); + else if (this.state === ST.LEVEL) this.skipLevelUp(); + else if (this.state === ST.CHEST) this.closeChest(); + } + if (e.code === 'Digit1') this.chooseUpgrade(0); + if (e.code === 'Digit2') this.chooseUpgrade(1); + if (e.code === 'Digit3') this.chooseUpgrade(2); + if (e.code === 'Digit4') this.chooseUpgrade(3); + }); + window.addEventListener('keyup', (e) => this.keys.delete(e.code)); + + // ---- touch joystick ---- + const layer = document.getElementById('touchLayer'); + const base = document.getElementById('stickBase'); + const knob = document.getElementById('stickKnob'); + let tid = null, bx = 0, by = 0; + + const isTouch = ('ontouchstart' in window); + window.addEventListener('touchstart', (e) => { + Snd.init(); + if (!isTouch) return; + layer.classList.remove('hidden'); + const t = e.changedTouches[0]; + tid = t.identifier; + bx = t.clientX; by = t.clientY; + base.style.display = 'block'; + base.style.left = (bx - 55) + 'px'; + base.style.top = (by - 55) + 'px'; + knob.style.transform = 'translate(-50%,-50%)'; + }, { passive: true }); + + window.addEventListener('touchmove', (e) => { + for (const t of e.changedTouches) { + if (t.identifier !== tid) continue; + let dx = t.clientX - bx, dy = t.clientY - by; + const d = len2(dx, dy); + const max = 46; + if (d > max) { dx = dx / d * max; dy = dy / d * max; } + knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; + this.touchVec.x = dx / max; + this.touchVec.y = dy / max; + } + }, { passive: true }); + + const endTouch = (e) => { + for (const t of e.changedTouches) { + if (t.identifier !== tid) continue; + tid = null; + this.touchVec.x = 0; this.touchVec.y = 0; + base.style.display = 'none'; + } + }; + window.addEventListener('touchend', endTouch); + window.addEventListener('touchcancel', endTouch); + } + + /* ================= menu backdrop ================= */ + _renderMenuBg(dt) { + R.drawMenuScene(this, dt); + } +} diff --git a/js/i18n.js b/js/i18n.js new file mode 100644 index 0000000..7eae2b7 --- /dev/null +++ b/js/i18n.js @@ -0,0 +1,486 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — i18n.js : Vietnamese / English dictionaries + ============================================================ */ + +const I18N = { +/* ============================ TIẾNG VIỆT ============================ */ +vi: { + tagline: 'Sống sót giữa biển quái vật. Nâng cấp vũ khí. Tiến hóa. Hạ Tử Thần.', + btn_play: 'CHƠI NGAY', + btn_codex: 'Bách Khoa Toàn Thư', + btn_options: 'Tùy Chọn', + btn_resume: 'Tiếp Tục', + btn_restart: 'Chơi Lại', + btn_quitmenu: 'Về Menu Chính', + btn_continue: 'Tiếp Tục', + btn_start: 'Vào Trận', + + scr_chars: 'Chọn Nhân Vật', + st_select: 'Chọn Màn Chơi', + st_endless: 'Chế độ Vô tận', + st_grade: 'Độ khó', + st_best: 'Kỷ lục', + st_graveyard: 'Nghĩa Địa Neon', + st_graveyard_d: 'Nơi bắt đầu. Nhịp quái chuẩn.', + st_frostcave: 'Hang Băng Vĩnh Cửu', + st_frostcave_d: 'Bóng ma sớm hơn, trùm dày hơn. HP +12%, vàng +25%.', + st_magmalands: 'Vực Magma', + st_magmalands_d: 'Địa ngục thật sự. HP +28%, vàng +50%.', + st_lock_frost: 'Mở khi đạt mốc 10 phút', + st_lock_magma: 'Mở khi thắng 1 ván', + toast_surge: '⚡ CƠN THỊNH NỘ ĐANG ĐẾN!', + toast_endless_on: '∞ Vô tận tiếp tục…', + prop_break: '+Vàng!', + en_lamp: 'Đèn Pha Lê', + cx_ach: 'Thành Tựu', + btn_daily: 'Thử Thách Hôm Nay', + btn_history: 'Lịch Sử', + btn_close: 'Đóng', + daily_best: 'Kỷ lục hôm nay', + toast_daily_record: '⚡ Kỷ lục daily mới!', + hist_empty: 'Chưa có ván nào — ra trận thôi!', + hist_run: 'Ván', + hist_win: 'THẮNG', + save_title: 'Chuyển Save', + save_copy: '📋 Sao chép', + save_import: '📥 Nhập', + save_bad: 'Mã không hợp lệ!', + save_ok: 'Đã nạp save! Đang tải lại…', + tut_move: 'Di chuyển để né — vũ khí tự bắn. Diệt quái để hút XP!', + tut_choose: 'Lên cấp! Chọn một nâng cấp bên dưới 👇', + tut_boss: '⚠️ TRÙM! Rất trâu — giữ khoảng cách và né đòn!', + scr_pause: 'TẠM DỪNG', + scr_over: 'BẠN ĐÃ GỤC NGÃ', + scr_win: 'CHIẾN THẮNG!', + + tip_move: 'WASD / Phím mũi tên để di chuyển — vũ khí tấn công tự động.', + + // Level up + lv_title: 'LÊN CẤP!', + lv_choose: 'Chọn một nâng cấp', + lv_reroll: '🎲 Bốc Lại ({n})', + lv_skip: 'Bỏ Qua (+{n} 🪙)', + + // Chest + ch_evolved_title: '⚡ TIẾN HÓA! ⚡', + ch_loot_title: '🎁 RƯƠNG BÁU VẬT', + ch_gold: '+{n} vàng', + + // End screen rows + es_time: 'Thời gian sống sót', + es_kills: 'Quái đã diệt', + es_gold: 'Vàng kiếm được', + es_level: 'Cấp độ đạt được', + es_best: 'Kỷ lục của bạn', + es_newrec: '✦ KỶ LỤC MỚI ✦', + + // Options + opt_audio: 'Âm thanh', + opt_video: 'Hình ảnh', + opt_gameplay: 'Lối chơi', + opt_language: 'Ngôn ngữ / Language', + opt_controls: 'Điều khiển', + bind_up: 'Di chuyển lên', bind_down: 'Di chuyển xuống', + bind_left: 'Sang trái', bind_right: 'Sang phải', + bind_press: 'Nhấn phím…', + fullscreen: 'Toàn màn hình', + master: 'Âm lượng chung', music: 'Nhạc nền', sfx: 'Hiệu ứng âm thanh', + shake: 'Rung màn hình', dmgnum: 'Số sát thương nổi', + particles: 'Hiệu ứng hạt', fps: 'Hiện FPS', + autopause: 'Tự động tạm dừng khi mất tiêu điểm', + lang: 'Ngôn ngữ', + reset: '🗑️ Xóa toàn bộ dữ liệu lưu', + reset_confirm: 'Xóa TOÀN BỘ tiến trình, vàng và mở khóa?', + yes: 'Xóa hết!', no: 'Thôi', + q_low: 'Thấp', q_med: 'Vừa', q_high: 'Cao', + + // Codex + cx_weapons: '⚔ Vũ Khí', cx_passives: '🔮 Vật Phẩm', cx_enemies: '👹 Quái Vật', cx_chars: '🧙 Nhân Vật', + cx_locked: 'Chưa mở khóa', + cx_maxed: 'Đã tối đa', + cx_evo: 'Tiến hóa khi đầy cấp + có "{p}", mở rương để kích hoạt', + cx_stats: 'HP {hp} · Sát thương {dm} · Tốc {sp}', + + // Char stats + cstat_hp: 'HP', cstat_spd: 'Tốc độ', cstat_dmg: 'ST', + cond_free: 'Luôn sẵn sàng', + cond_kills500: 'Diệt tổng cộng 500 quái', + cond_time10: 'Sống sót đến phút 10', + cond_gold2000: 'Kiếm tổng cộng 2.000 vàng', + cond_boss1: 'Hạ trùm đầu tiên', + cond_time15: 'Sống sót đến phút 15', + cond_win: 'Thắng 1 ván bất kỳ', + cond_kills5000: 'Diệt tổng cộng 5.000 quái', + + // Meta shop + ms_title: 'Nâng Cường Vĩnh Viễn', + ms_cost: 'Mua · {c} 🪙', + ms_max: 'ĐÃ TỐI ĐA', + ms_might: 'Cuồng Nộ', ms_might_d: '+5% sát thương mỗi cấp.', + ms_armor: 'Giáp Thân', ms_armor_d: '+1 giáp mỗi cấp (giảm sát thương nhận vào).', + ms_hp: 'Sinh Lực', ms_hp_d: '+10% HP tối đa mỗi cấp.', + ms_regen: 'Tái Tạo', ms_regen_d: '+0.3 HP/giây mỗi cấp.', + ms_spd: 'Thần Tốc', ms_spd_d: '+4% tốc chạy mỗi cấp.', + ms_luck: 'May Mắn', ms_luck_d: '+10% may mắn mỗi cấp (quái rơi đồ tốt hơn).', + ms_growth: 'Tuệ Tăng', ms_growth_d: '+6% kinh nghiệm mỗi cấp.', + ms_greed: 'Tham Lam', ms_greed_d: '+15% vàng nhặt được mỗi cấp.', + ms_cd: 'Định Độ', ms_cd_d: '-4% thời gian hồi chiêu mỗi cấp.', + ms_revive: 'Hồi Sinh', ms_revive_d: 'Hồi sinh 1 lần khi gục ngã, mỗi cấp.', + ms_reroll: 'Bốc Lại', ms_reroll_d: '+1 lượt bốc lại nâng cấp mỗi trận, mỗi cấp.', + ms_magnet: 'Nam Châm', ms_magnet_d: '+20% bán kính hút vật phẩm mỗi cấp.', + + // Toasts + toast_boss: '☠ TRÙM XUẤT HIỆN ☠', + toast_elite: '⚠ Quái Tinh Anh xuất hiện!', + toast_revive: '✨ HỒI SINH! Đứng dậy và chiến đấu!', + toast_unlock: '🏆 Nhân vật mới: {c}', + + // Developer mode + opt_dev: 'Chế độ nhà phát triển', + dev_hint: 'Bật để thấy nút 🔧 trong trận đấu.', + dev_title: 'CÔNG CỤ NHÀ PHÁT TRIỂN', + dev_gold: '💰 Vàng', + dev_level: '⬆️ Cấp độ', + dev_weapons: '⚔️ Vũ khí', + dev_passives: '🔮 Vật phẩm', + dev_cheats: '⚡ Gian lận', + dev_world: '🌍 Thế giới', + dev_meta: '👤 Meta', + dev_god: 'Bất tử', + dev_onehit: 'Một hit nổ', + dev_speed: 'Tốc độ game', + dev_nuke: '💥 Quét màn hình', + dev_magnet: '🧲 Hút hết đồ', + dev_heal: '❤️ Hồi đầy máu', + dev_killall: '💀 Diệt quái gần', + dev_skip1: '+1 phút', + dev_skip5: '+5 phút', + dev_spawn_elite: '👹 Spawn Tinh Anh', + dev_spawn_boss: '☠ Spawn Trùm:', + dev_unlockall: 'Mở khóa mọi nhân vật', + dev_add: '+1', + dev_maxb: 'MAX', + dev_get: 'NHẬN', + dev_set: 'Đặt', + + // Weapon upgrade lines + up_dmg: '+{n} sát thương', up_n: '+{n} viên/diện tác động', up_cd: '{n}s hồi chiêu', + up_pierce: '+{n} lực xuyên', up_spd: 'Tăng tốc độ bay', up_area: 'Tăng diện tích', + up_dur: 'Tăng thời gian tồn tại', up_kb: 'Tăng đẩy lùi', up_misc: 'Nâng cấp sức mạnh', + new_weapon: 'Vũ khí mới!', new_passive: 'Vật phẩm mới!', + + // ---- Weapons ---- + w_wand: 'Đũa Phép', w_wand_d: 'Tự động bắn phép vào kẻ địch gần nhất.', + e_wand: 'Bão Thần Tín', e_wand_d: 'Trận mưa phép liên hồi, xuyên phá mọi thứ.', + w_whip: 'Roi Da', w_whip_d: 'Tự động quất roi về phía kẻ địch gần nhất.', + e_whip: 'Roi Máu', e_whip_d: 'Quất đồng thời cả hai phía, hút máu kẻ địch.', + w_knife: 'Phi Đao', w_knife_d: 'Phóng dao nhanh theo hướng di chuyển.', + e_knife: 'Ngàn Lưỡi Dao', e_knife_d: 'Vũ bão lưỡi dao xuyên phá hàng loạt.', + w_orb: 'Quả Cầu Bảo Vệ', w_orb_d: 'Các quả cầu quay quanh người bạn.', + e_orb: 'Vành Tân Tinh', e_orb_d: 'Vành đai cầu lớn, va chạm nổ tán xạ.', + w_axe: 'Rìu Chiến', w_axe_d: 'Ném rìu vòng cung cao, xuyên xuyên suốt, sát thương khủng.', + e_axe: 'Phán Quyết', e_axe_d: 'Mưa rìu khổng lồ xoáy tròn như lốc xoáy chết chóc.', + w_garlic: 'Hào Quang Thánh', w_garlic_d: 'Vùng thánh ánh sáng thiêu đốt quái vật xung quanh.', + e_garlic: 'Nuốt Linh Hồn', e_garlic_d: 'Hào quang khổng lồ, mỗi mạng quái hồi máu bạn.', + w_light: 'Thiên Lôi', w_light_d: 'Sét đánh ngẫu nhiên xuống kẻ địch.', + e_light: 'Cuồng Lôi Chiến', e_light_d: 'Sét dây chuyền, làm choáng cả vùng trời.', + w_fire: 'Cầu Lửa', w_fire_d: 'Quả cầu lửa nổ lan, để lại vệt cháy.', + e_fire: 'Địa Ngục Hỏa', e_fire_d: 'Đại nổ địa ngục, mặt đất cháy âm ỉ lâu dài.', + w_frost: 'Băng Tinh', w_frost_d: 'Mảnh băng xuyên qua, làm chậm kẻ địch.', + e_frost: 'Tuyệt Đối Không', e_frost_d: 'Đóng băng kẻ địch thành băng, vỡ tan thành nghìn mảnh.', + w_cross: 'Thánh Giá', w_cross_d: 'Chiếu bay ra rồi quay về, cứa mọi thứ trên đường.', + e_cross: 'Thiên Kiếm', e_cross_d: 'Thánh kiếm khổng lồ xoay tít, vô hạn xuyên phá.', + w_mine: 'Mìn Hẹn Giờ', w_mine_d: 'Rơi mìn phía sau; chạm vào hoặc hết giờ là NỔ TO.', + e_mine: 'Vườn Pháo Hoa', e_mine_d: 'Rải cả cụm mìn siêu lớn — mỗi quả một màn pháo.', + c_hana: 'Hana', c_hana_t: '+15% tỉ lệ chí mạng, +30% sát thương chí mạng', + c_bram: 'Bram', c_bram_t: '+20% diện tích, +10% HP, đi chậm hơn chút', + skin_base: 'Gốc', + skin_cycle: 'Đổi màu đã sở hữu', + ms_need_gold: 'Không đủ vàng!', + btn_board: 'Bảng Xếp Hạng', + lb_title: '🥇 Bảng Xếp Hạng', + lb_name: 'Tên hiển thị', + lb_server: 'Máy chủ (URL Worker)', + lb_empty: 'Chưa có điểm nào.', + lb_off: 'Chưa cấu hình máy chủ trong Tuỳ chọn.', + lb_sent: '📤 Đã gửi điểm!', + lb_fail: '⚠️ Gửi thất bại.', + lb_you: 'Bạn', + lb_scope_daily: '⚡ Hôm nay', + lb_time: 'Thời gian', + lb_score: 'Điểm', + + // ---- Passives ---- + p_might: 'Sức Mạnh', p_might_d: '+10% sát thương mỗi cấp.', + p_armor: 'Giáp Trận', p_armor_d: '-1 sát thương nhận vào mỗi cấp.', + p_hp: 'Tim Khổng Lồ', p_hp_d: '+20% HP tối đa mỗi cấp.', + p_regen: 'Hồi Phục', p_regen_d: '+0.6 HP/giây mỗi cấp.', + p_cd: 'Siêu Dẫn', p_cd_d: '-8% thời gian hồi chiêu mỗi cấp.', + p_area: 'Mở Rộng', p_area_d: '+12% tầm và diện tích mỗi cấp.', + p_spd: 'Viên Đạn Nhanh', p_spd_d: '+15% tốc độ đạn mỗi cấp.', + p_dur: 'Bền Bỉ', p_dur_d: '+15% thời gian hiệu ứng mỗi cấp.', + p_move: 'Giày Gió', p_move_d: '+10% tốc chạy mỗi cấp.', + p_mag: 'Nam Châm', p_mag_d: '+30% bán kính hút mỗi cấp.', + p_luck: 'May Mắn', p_luck_d: '+10% may mắn, +2% chí mạng mỗi cấp.', + p_greed: 'Tham Lam', p_greed_d: '+20% vàng nhặt được mỗi cấp.', + p_growth: 'Tuệ Tăng', p_growth_d: '+10% kinh nghiệm mỗi cấp.', + + // ---- Characters ---- + c_kaito: 'Kaito', c_kaito_t: 'Nhà mạo hiểm cân bằng: không buff, không điểm yếu.', + c_mira: 'Mira', c_mira_t: '+1 Giáp · +10% HP', + c_bolt: 'Bolt', c_bolt_t: '+12% tốc chạy · +15% tốc đạn', + c_luna: 'Luna', c_luna_t: '+18% diện tích · +10% hồi chiêu', + c_rook: 'Rook', c_rook_t: '+20% sát thương · -5% tốc chạy', + c_nyx: 'Nyx', c_nyx_t: '+0.4 HP/s · +40% nam châm', + c_sera: 'Sera', c_sera_t: '-12% hồi chiêu · +5% may mắn', + c_vane: 'Vane', c_vane_t: '+15% may mắn · +10% vàng', + + // ---- Enemies ---- + en_bat: 'Dơi Máu', en_zombie: 'Thây Ma', en_imp: 'Tiểu Quỷ', + en_arch: 'Cung Thủ Xương', en_brute: 'Cao Bồi Da Gai', en_ghost: 'Bóng Ma', + en_spit: 'Xạ Hỏa Quỷ', en_garg: 'Gar-gô-yl', + bn_wolf: '🐺 Sói Đầu Lớn', bn_lich: '💀 Pháp Sư Xương', + bn_behemoth: '👹 Quái Vật Địa Ngục', bn_death: '⚰ TỬ THẦN', +}, + +/* ============================ ENGLISH ============================ */ +en: { + tagline: 'Survive the horde. Upgrade weapons. Evolve. Slay Death itself.', + btn_play: 'PLAY', + btn_codex: 'Codex', + btn_options: 'Options', + btn_resume: 'Resume', + btn_restart: 'Play Again', + btn_quitmenu: 'Main Menu', + btn_continue: 'Continue', + btn_start: 'START', + + scr_chars: 'Choose Character', + st_select: 'Select Stage', + st_endless: 'Endless Mode', + st_grade: 'Difficulty Grade', + st_best: 'Best', + st_graveyard: 'Neon Graveyard', + st_graveyard_d: 'Where it all begins. Standard pace.', + st_frostcave: 'Frozen Hollow', + st_frostcave_d: 'Early ghosts, denser bosses. +12% HP, +25% gold.', + st_magmalands: 'Magma Rift', + st_magmalands_d: 'True hell. +28% HP, +50% gold.', + st_lock_frost: 'Unlocks at the 10-minute mark', + st_lock_magma: 'Unlocks after your first win', + toast_surge: '⚡ A SURGE IS COMING!', + toast_endless_on: '∞ Endless continues…', + prop_break: '+Gold!', + en_lamp: 'Crystal Lamp', + cx_ach: 'Achievements', + btn_daily: 'Daily Challenge', + btn_history: 'History', + btn_close: 'Close', + daily_best: "Today's best", + toast_daily_record: "⚡ New daily record!", + hist_empty: 'No runs yet — get out there!', + hist_run: 'Run', + hist_win: 'WIN', + save_title: 'Save Transfer', + save_copy: '📋 Copy', + save_import: '📥 Import', + save_bad: 'Invalid code!', + save_ok: 'Save loaded! Reloading…', + tut_move: 'Move to dodge — weapons fire automatically. Kill enemies to collect XP!', + tut_choose: 'Level up! Pick one upgrade below 👇', + tut_boss: '⚠️ BOSS! Very tanky — dodge and keep your distance!', + scr_pause: 'PAUSED', + scr_over: 'YOU HAVE FALLEN', + scr_win: 'VICTORY!', + + tip_move: 'Move with WASD / Arrow keys — weapons fire automatically.', + + lv_title: 'LEVEL UP!', + lv_choose: 'Pick one upgrade', + lv_reroll: '🎲 Reroll ({n})', + lv_skip: 'Skip (+{n} 🪙)', + + ch_evolved_title: '⚡ EVOLUTION! ⚡', + ch_loot_title: '🎁 TREASURE CHEST', + ch_gold: '+{n} gold', + + es_time: 'Time survived', + es_kills: 'Enemies slain', + es_gold: 'Gold earned', + es_level: 'Level reached', + es_best: 'Your best', + es_newrec: '✦ NEW RECORD ✦', + + opt_audio: 'Audio', + opt_video: 'Video', + opt_gameplay: 'Gameplay', + opt_language: 'Language / Ngôn ngữ', + opt_controls: 'Controls', + bind_up: 'Move up', bind_down: 'Move down', + bind_left: 'Move left', bind_right: 'Move right', + bind_press: 'Press a key…', + fullscreen: 'Fullscreen', + master: 'Master volume', music: 'Music volume', sfx: 'SFX volume', + shake: 'Screen shake', dmgnum: 'Floating damage numbers', + particles: 'Particle effects', fps: 'Show FPS', + autopause: 'Auto-pause on focus loss', + lang: 'Language', + reset: '🗑️ Erase all saved data', + reset_confirm: 'Erase ALL progress, gold and unlocks?', + yes: 'Erase it!', no: 'Cancel', + q_low: 'Low', q_med: 'Med', q_high: 'High', + + cx_weapons: '⚔ Weapons', cx_passives: '🔮 Items', cx_enemies: '👹 Monsters', cx_chars: '🧙 Heroes', + cx_locked: 'Locked', + cx_maxed: 'Maxed out', + cx_evo: 'Evolves at max level + owning "{p}". Open a chest to trigger', + cx_stats: 'HP {hp} · DMG {dm} · SPD {sp}', + + cstat_hp: 'HP', cstat_spd: 'Speed', cstat_dmg: 'DMG', + cond_free: 'Always available', + cond_kills500: 'Slay 500 total enemies', + cond_time10: 'Survive to minute 10', + cond_gold2000: 'Earn 2,000 total gold', + cond_boss1: 'Defeat the first boss', + cond_time15: 'Survive to minute 15', + cond_win: 'Win any run', + cond_kills5000: 'Slay 5,000 total enemies', + + ms_title: 'Permanent Upgrades', + ms_cost: 'Buy · {c} 🪙', + ms_max: 'MAXED OUT', + ms_might: 'Might', ms_might_d: '+5% damage per rank.', + ms_armor: 'Plating', ms_armor_d: '+1 armor per rank (reduces damage taken).', + ms_hp: 'Vitality', ms_hp_d: '+10% max HP per rank.', + ms_regen: 'Renewal', ms_regen_d: '+0.3 HP/sec per rank.', + ms_spd: 'Fleet Foot', ms_spd_d: '+4% move speed per rank.', + ms_luck: 'Fortune', ms_luck_d: '+10% luck per rank (better drops).', + ms_growth: 'Insight', ms_growth_d: '+6% experience per rank.', + ms_greed: 'Greed', ms_greed_d: '+15% gold picked up per rank.', + ms_cd: 'Overclock', ms_cd_d: '-4% weapon cooldown per rank.', + ms_revive: 'Second Wind', ms_revive_d: 'Revive once on death, per rank.', + ms_reroll: 'Rerolls', ms_reroll_d: '+1 upgrade reroll per run, per rank.', + ms_magnet: 'Attractor', ms_magnet_d: '+20% pickup radius per rank.', + + toast_boss: '☠ BOSS INCOMING ☠', + toast_elite: '⚠ Elite monster appeared!', + toast_revive: '✨ REVIVED! Back to the fight!', + toast_unlock: '🏆 New character: {c}', + + // Developer mode + opt_dev: 'Developer mode', + dev_hint: 'Enable to reveal the 🔧 tool in runs.', + dev_title: 'DEVELOPER TOOLS', + dev_gold: '💰 Gold', + dev_level: '⬆️ Level', + dev_weapons: '⚔️ Weapons', + dev_passives: '🔮 Items', + dev_cheats: '⚡ Cheats', + dev_world: '🌍 World', + dev_meta: '👤 Meta', + dev_god: 'God mode', + dev_onehit: 'One-hit kill', + dev_speed: 'Game speed', + dev_nuke: '💥 Screen nuke', + dev_magnet: '🧲 Vacuum pickups', + dev_heal: '❤️ Full heal', + dev_killall: '💀 Kill nearby', + dev_skip1: '+1 minute', + dev_skip5: '+5 minutes', + dev_spawn_elite: '👹 Spawn Elite', + dev_spawn_boss: '☠ Spawn Boss:', + dev_unlockall: 'Unlock all characters', + dev_add: '+1', + dev_maxb: 'MAX', + dev_get: 'GET', + dev_set: 'Set', + + up_dmg: '+{n} damage', up_n: '+{n} projectile/instance', up_cd: '{n}s cooldown', + up_pierce: '+{n} pierce', up_spd: 'Faster projectiles', up_area: 'Bigger area', + up_dur: 'Longer duration', up_kb: 'More knockback', up_misc: 'Power improved', + new_weapon: 'New weapon!', new_passive: 'New item!', + + w_wand: 'Magic Wand', w_wand_d: 'Fires bolts at the nearest enemy.', + e_wand: 'Holy Storm', e_wand_d: 'Relentless piercing bolt barrage.', + w_whip: 'Leather Whip', w_whip_d: 'Auto-swings toward the nearest enemy.', + e_whip: 'Bloodthirster', e_whip_d: 'Strikes both sides at once and lifesteals.', + w_knife: 'Thrown Knife', w_knife_d: 'Flings fast knives where you move.', + e_knife: 'Thousand Blades', e_knife_d: 'A storm of piercing blades.', + w_orb: 'Guard Orbs', w_orb_d: 'Orbs orbit around you.', + e_orb: 'Nova Ring', e_orb_d: 'Ring of huge orbs that burst into shrapnel.', + w_axe: 'Battle Axe', w_axe_d: 'High-arcing throw, pierces everything, huge damage.', + e_axe: 'Executioner', e_axe_d: 'A whirlwind of giant spinning axes.', + w_garlic: 'Holy Aura', w_garlic_d: 'Sacred light burns nearby monsters.', + e_garlic: 'Soul Eater', e_garlic_d: 'Massive aura; each kill heals you.', + w_light: 'Thunderbolt', w_light_d: 'Lightning strikes random enemies.', + e_light: 'Warstorm', e_light_d: 'Chain lightning that stuns the battlefield.', + w_fire: 'Fireball', w_fire_d: 'Exploding fireball leaving burning ground.', + e_fire: 'Hellfire', e_fire_d: 'Hellish blast; ground scorched far longer.', + w_frost: 'Frost Shard', w_frost_d: 'Piercing shard that slows enemies.', + e_frost: 'Absolute Zero', e_frost_d: 'Freezes enemies solid, shattering for bonus damage.', + w_cross: 'Holy Cross', w_cross_d: 'Flies out then returns, cutting everything en route.', + e_cross: 'Heaven\'s Edge', e_cross_d: 'A colossal spinning blade with infinite pierce.', + w_mine: 'Fuse Mines', w_mine_d: 'Drops mines behind you; contact or timer = BIG boom.', + e_mine: 'Firework Garden', e_mine_d: 'Scatters clusters of huge mines — each one a show.', + c_hana: 'Hana', c_hana_t: '+15% crit chance, +30% crit damage', + c_bram: 'Bram', c_bram_t: '+20% area, +10% HP, slightly slower', + skin_base: 'Default', + skin_cycle: 'Cycle owned colors', + ms_need_gold: 'Not enough gold!', + btn_board: 'Leaderboard', + lb_title: '🥇 Leaderboard', + lb_name: 'Display name', + lb_server: 'Server (Worker URL)', + lb_empty: 'No scores yet.', + lb_off: 'Server not configured in Options.', + lb_sent: '📤 Score submitted!', + lb_fail: '⚠️ Submit failed.', + lb_you: 'You', + lb_scope_daily: '⚡ Today', + lb_time: 'Time', + lb_score: 'Score', + + p_might: 'Strength', p_might_d: '+10% damage per level.', + p_armor: 'Armor', p_armor_d: '-1 damage taken per level.', + p_hp: 'Giant Heart', p_hp_d: '+20% max HP per level.', + p_regen: 'Recovery', p_regen_d: '+0.6 HP/sec per level.', + p_cd: 'Superconductor', p_cd_d: '-8% weapon cooldown per level.', + p_area: 'Expansion', p_area_d: '+12% range and area per level.', + p_spd: 'Hypersonic', p_spd_d: '+15% projectile speed per level.', + p_dur: 'Persistence', p_dur_d: '+15% effect duration per level.', + p_move: 'Wind Shoes', p_move_d: '+10% move speed per level.', + p_mag: 'Magnet', p_mag_d: '+30% pickup radius per level.', + p_luck: 'Luck', p_luck_d: '+10% luck, +2% crit chance per level.', + p_greed: 'Greed', p_greed_d: '+20% gold gained per level.', + p_growth: 'Growth', p_growth_d: '+10% experience per level.', + + c_kaito: 'Kaito', c_kaito_t: 'The adventurer — no buffs, no weakness.', + c_mira: 'Mira', c_mira_t: '+1 Armor · +10% HP', + c_bolt: 'Bolt', c_bolt_t: '+12% move speed · +15% proj speed', + c_luna: 'Luna', c_luna_t: '+18% area · +10% cooldown', + c_rook: 'Rook', c_rook_t: '+20% damage · -5% move speed', + c_nyx: 'Nyx', c_nyx_t: '+0.4 HP/s · +40% magnet', + c_sera: 'Sera', c_sera_t: '-12% cooldown · +5% luck', + c_vane: 'Vane', c_vane_t: '+15% luck · +10% gold', + + en_bat: 'Blood Bat', en_zombie: 'Zombie', en_imp: 'Imp', + en_arch: 'Bone Archer', en_brute: 'Thug Brute', en_ghost: 'Wraith', + en_spit: 'Spitter', en_garg: 'Gargoyle', + bn_wolf: '🐺 Alpha Werewolf', bn_lich: '💀 Bone Lich', + bn_behemoth: '👹 Hell Behemoth', bn_death: '⚰ DEATH', +}}; + +/** Translate key with {var} substitution. */ +function tr(key, vars) { + let lang = 'vi'; + if (typeof Store !== 'undefined' && Store.data) lang = Store.data.settings.lang || 'vi'; + const dict = I18N[lang] || I18N.vi; + let s = dict[key] || I18N.vi[key] || I18N.en[key] || key; + if (vars) for (const k in vars) s = s.split('{' + k + '}').join(String(vars[k])); + return s; +} + +function currentLang() { + return (typeof Store !== 'undefined' && Store.data) ? (Store.data.settings.lang || 'vi') : 'vi'; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..d1632f2 --- /dev/null +++ b/js/main.js @@ -0,0 +1,33 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — main.js : bootstrap + ============================================================ */ + +(function boot() { + Store.load(); + + const canvas = document.getElementById('game'); + window.GAME = new Game(canvas); + UI.init(GAME); + GAME.applySettings(); + + // leave the splash + setTimeout(() => { + UI.showScreen('scr-menu'); + }, 450); + + // unlock WebAudio on first user gesture + const kick = () => { Snd.init(); }; + window.addEventListener('pointerdown', kick); + window.addEventListener('keydown', kick); + + // PWA service worker (https only; plain http still runs without SW) + if ('serviceWorker' in navigator && /^https:/.test(location.protocol)) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('sw.js').catch(() => {}); + }); + } + + // block context menu / double-tap zoom on mobile + window.addEventListener('contextmenu', e => e.preventDefault()); +})(); diff --git a/js/render.js b/js/render.js new file mode 100644 index 0000000..1e1cc4e --- /dev/null +++ b/js/render.js @@ -0,0 +1,983 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — render.js : all canvas drawing + ============================================================ */ + +const R = { + _vig: null, + + draw(G) { + const cv = G.cv, ctx = G.ctx; + const W = cv.width / G.dpr, H = cv.height / G.dpr; + const s = Store.s(); + const q = s.particles; + + // ---- background ---- + ctx.fillStyle = '#05050c'; + ctx.fillRect(0, 0, W, H); + + // camera + shake + let sx = 0, sy = 0; + if (G.shake > 0.2 && s.shake) { + sx = rand(-G.shake, G.shake); + sy = rand(-G.shake, G.shake); + } + const camX = G.cam.x - W / 2 + sx; + const camY = G.cam.y - H / 2 + sy; + + // viewport bounds for culling (margin covers glow/auras) + this._vx0 = camX - 70; this._vy0 = camY - 70; + this._vx1 = camX + W + 70; this._vy1 = camY + H + 70; + + this._bg(ctx, camX, camY, W, H); + + ctx.save(); + ctx.translate(-camX, -camY); + + this._pickups(G, ctx); + this._enemies(G, ctx, q); + this._projectiles(G, ctx, q); + this._ebullets(G, ctx); + if (!G.playerHidden) this._player(G, ctx); + this._effects(G, ctx); + this._particles(G, ctx, q); + + ctx.restore(); + + // ---- screen-space overlays ---- + this._texts(G, ctx, camX, camY); + this._bossArrow(G, ctx, W, H); + if (G.hurtFlash > 0) { + ctx.fillStyle = `rgba(255,40,70,${G.hurtFlash * 0.55})`; + ctx.fillRect(0, 0, W, H); + } + this._lowHp(G, ctx, W, H); + this._vignette(ctx, W, H); + }, + + /** Pulsing red edge when the player drops below 30% HP. */ + _lowHp(G, ctx, W, H) { + const P = G.player; + if (!P || P.maxhp <= 0) return; + const ratio = clamp(P.hp / P.maxhp, 0, 1); + if (ratio >= 0.3) return; + if (!this._low || this._lowW !== W || this._lowH !== H) { + const c = document.createElement('canvas'); + c.width = W; c.height = H; + const cc = c.getContext('2d'); + const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .32, W / 2, H / 2, Math.max(W, H) * .7); + g.addColorStop(0, 'rgba(255,30,60,0)'); + g.addColorStop(1, 'rgba(255,20,50,0.55)'); + cc.fillStyle = g; + cc.fillRect(0, 0, W, H); + this._low = c; this._lowW = W; this._lowH = H; + } + const pulse = 0.45 + 0.45 * Math.sin(G.time * 7); + ctx.globalAlpha = clamp((0.3 - ratio) / 0.3, 0, 1) * (0.35 + pulse * 0.5); + ctx.drawImage(this._low, 0, 0); + ctx.globalAlpha = 1; + }, + + /* ---------- background ---------- */ + _bg(ctx, camX, camY, W, H) { + const bg = (window.GAME && GAME.stage && GAME.stage.bg) || {}; + // grid + const step = 140; + ctx.strokeStyle = bg.grid || 'rgba(90,90,160,0.10)'; + ctx.lineWidth = 1; + const x0 = Math.floor(camX / step) * step, y0 = Math.floor(camY / step) * step; + ctx.beginPath(); + for (let x = x0; x < camX + W + step; x += step) { + ctx.moveTo(x - camX, 0); ctx.lineTo(x - camX, H); + } + for (let y = y0; y < camY + H + step; y += step) { + ctx.moveTo(0, y - camY); ctx.lineTo(W, y - camY); + } + ctx.stroke(); + + // deterministic glowing motes (parallax feel via world-anchored cells) + const rgbOf = h => { + if (typeof h !== 'string' || h[0] !== '#') return h || '124,92,255'; + const n = parseInt(h.slice(1), 16); + return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255); + }; + const m1 = rgbOf(bg.mote && bg.mote[0]); + const m2 = rgbOf(bg.mote && bg.mote[1]); + const cstep = 320; + const cx0 = Math.floor(camX / cstep), cy0 = Math.floor(camY / cstep); + for (let cy = cy0; cy < cy0 + H / cstep + 2; cy++) { + for (let cx = cx0; cx < cx0 + W / cstep + 2; cx++) { + const hsh = ((cx * 374761393 + cy * 668265263) ^ 1274126177) >>> 0; + if (hsh % 3 !== 0) continue; + const px = cx * cstep + (hsh % cstep) - camX; + const py = cy * cstep + ((hsh >> 8) % cstep) - camY; + const r = 1.5 + (hsh % 3); + ctx.fillStyle = hsh % 7 === 0 ? `rgba(${m2},0.20)` : `rgba(${m1},0.16)`; + ctx.beginPath(); + ctx.arc(px, py, r, 0, TAU); + ctx.fill(); + } + } + }, + + /* ---------- pickups ---------- */ + _pickups(G, ctx) { + for (const pk of G.pickups) { + if (pk.x < this._vx0 || pk.x > this._vx1 || pk.y < this._vy0 || pk.y > this._vy1) continue; + const bob = Math.sin(pk.t * 4) * 2; + if (pk.type === 'gem') { + ctx.save(); + ctx.translate(pk.x, pk.y + bob); + ctx.rotate(pk.t * 1.5); + ctx.shadowColor = pk.col; ctx.shadowBlur = 8; + ctx.fillStyle = pk.col; + const s = pk.v >= 25 ? 9 : pk.v >= 5 ? 7.5 : 6; + ctx.beginPath(); + ctx.moveTo(0, -s); ctx.lineTo(s * .7, 0); ctx.lineTo(0, s); ctx.lineTo(-s * .7, 0); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } else if (pk.type === 'coin') { + ctx.save(); + ctx.translate(pk.x, pk.y + bob); + ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 7; + ctx.fillStyle = '#ffd24a'; + ctx.beginPath(); ctx.arc(0, 0, 6, 0, TAU); ctx.fill(); + ctx.fillStyle = '#a67400'; + ctx.beginPath(); ctx.arc(0, 0, 3, 0, TAU); ctx.fill(); + ctx.restore(); + } else { + ctx.save(); + ctx.translate(pk.x, pk.y + bob * 1.5); + ctx.font = '20px serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const icon = { chicken: '🍗', magnet: '🧲', bomb: '💣', chest: '🎁' }[pk.type] || '❓'; + if (pk.type === 'chest') { + const sc = 1 + Math.sin(pk.t * 6) * 0.08; + ctx.scale(sc, sc); + ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 14; + } + ctx.fillText(icon, 0, 0); + ctx.restore(); + } + } + }, + + /* ---------- enemies ---------- */ + _enemies(G, ctx, q) { + for (const e of G.enemies) { + if (e.x < this._vx0 - e.r || e.x > this._vx1 + e.r || e.y < this._vy0 - e.r || e.y > this._vy1 + e.r) continue; + ctx.save(); + ctx.translate(e.x, e.y); + const flash = e.flash > 0; + const col = flash ? '#ffffff' : e.col; + + if (q !== 'low' && !flash) { + ctx.shadowColor = e.boss ? col : 'transparent'; + ctx.shadowBlur = e.boss ? 22 : 0; + } + if (e.elite || e.boss) { + ctx.strokeStyle = e.boss + ? 'rgba(255,80,110,.9)' + : (e.affixCol ? colA(e.affixCol, .95) : 'rgba(255,210,74,.95)'); + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, e.r + 5 + Math.sin(G.time * 5) * 1.5, 0, TAU); + ctx.stroke(); + } + + const shape = e.def.shape; + ctx.fillStyle = col; + switch (shape) { + case 'bat': { + const flap = Math.sin(G.time * 14 + e.wob) * 0.5; + ctx.beginPath(); ctx.arc(0, 0, e.r * .62, 0, TAU); ctx.fill(); + for (const d of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(d * e.r * .4, 0); + ctx.lineTo(d * e.r * 1.5, -e.r * (.5 + flap)); + ctx.lineTo(d * e.r * 1.2, e.r * .45); + ctx.closePath(); ctx.fill(); + } + break; + } + case 'zombie': { + this._roundRect(ctx, -e.r * .75, -e.r * .9, e.r * 1.5, e.r * 1.8, 4); ctx.fill(); + ctx.beginPath(); ctx.arc(0, -e.r * 1.05, e.r * .52, 0, TAU); ctx.fill(); + ctx.fillStyle = 'rgba(0,0,0,.35)'; + ctx.fillRect(-e.r * .55, -e.r * .15, e.r * 1.1, 3); + break; + } + case 'imp': { + ctx.beginPath(); + ctx.moveTo(0, -e.r); ctx.lineTo(e.r * .85, e.r * .75); ctx.lineTo(-e.r * .85, e.r * .75); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#ffe066'; + ctx.fillRect(-e.r * .5, -e.r * .85, 2.5, 5); + ctx.fillRect(e.r * .3, -e.r * .85, 2.5, 5); + break; + } + case 'archer': { + ctx.beginPath(); ctx.arc(0, 0, e.r * .72, 0, TAU); ctx.fill(); + ctx.strokeStyle = '#caa96b'; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.arc(e.r * .9, 0, e.r * .85, -Math.PI / 2.4, Math.PI / 2.4); ctx.stroke(); + break; + } + case 'brute': { + this._roundRect(ctx, -e.r, -e.r * .95, e.r * 2, e.r * 1.9, 7); ctx.fill(); + ctx.fillStyle = 'rgba(0,0,0,.3)'; + this._roundRect(ctx, -e.r * .6, -e.r * .5, e.r * 1.2, e.r * .28, 3); ctx.fill(); + break; + } + case 'ghost': { + ctx.globalAlpha = 0.65 + Math.sin(G.time * 3 + e.wob) * 0.25; + ctx.beginPath(); + ctx.arc(0, -e.r * .25, e.r * .8, Math.PI, 0); + const bot = e.r * .75; + ctx.lineTo(e.r * .8, bot); + for (let k = 3; k >= -3; k--) { + ctx.lineTo(k / 3 * e.r * .8, bot + (k % 2 ? 5 : -3)); + } + ctx.closePath(); ctx.fill(); + ctx.globalAlpha = 1; + break; + } + case 'spit': { + ctx.beginPath(); ctx.arc(0, 0, e.r * .8, 0, TAU); ctx.fill(); + ctx.fillStyle = '#3f7a00'; + ctx.beginPath(); ctx.arc(0, -e.r * .2, e.r * .32, 0, TAU); ctx.fill(); + break; + } + case 'garg': { + ctx.rotate(Math.sin(G.time * 2 + e.wob) * .06); + this._roundRect(ctx, -e.r * .7, -e.r * .8, e.r * 1.4, e.r * 1.6, 5); ctx.fill(); + for (const d of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(d * e.r * .5, -e.r * .6); + ctx.lineTo(d * e.r * 1.35, -e.r * 1.05); + ctx.lineTo(d * e.r * .75, e.r * .1); + ctx.closePath(); ctx.fill(); + } + if (e.state === 1) { + ctx.fillStyle = '#ff4d4d'; + ctx.fillRect(-e.r * .38, -e.r * .45, 4, 4); + ctx.fillRect(e.r * .18, -e.r * .45, 4, 4); + } + break; + } + + /* ---- bosses ---- */ + case 'wolf': { + ctx.rotate(e.state === 2 ? 0 : Math.sin(G.time * 6) * .05); + if (e.state === 1 && flash === false) ctx.fillStyle = Math.floor(G.time * 12) % 2 ? '#ff8899' : col; + ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill(); + for (const d of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(d * e.r * .45, -e.r * .8); + ctx.lineTo(d * e.r * .75, -e.r * 1.5); + ctx.lineTo(d * e.r * .95, -e.r * .6); + ctx.closePath(); ctx.fill(); + } + ctx.fillStyle = '#ffef5c'; + ctx.fillRect(-e.r * .5, -e.r * .3, 6, 6); + ctx.fillRect(e.r * .22, -e.r * .3, 6, 6); + break; + } + case 'lich': { + ctx.beginPath(); + ctx.moveTo(0, -e.r * 1.15); + ctx.lineTo(e.r * .95, e.r); ctx.lineTo(-e.r * .95, e.r); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#22223a'; + ctx.beginPath(); ctx.arc(0, -e.r * .45, e.r * .42, 0, TAU); ctx.fill(); + ctx.fillStyle = '#b388ff'; + ctx.fillRect(-e.r * .3, -e.r * .55, 5, 7); + ctx.fillRect(e.r * .12, -e.r * .55, 5, 7); + ctx.fillStyle = '#8a5cff'; + ctx.beginPath(); ctx.arc(Math.sin(G.time * 3) * e.r, -e.r * 1.3, 5, 0, TAU); ctx.fill(); + break; + } + case 'behemoth': { + ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill(); + ctx.strokeStyle = col; ctx.lineWidth = 7; ctx.lineCap = 'round'; + for (const d of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(d * e.r * .55, -e.r * .75); + ctx.quadraticCurveTo(d * e.r * 1.25, -e.r * 1.35, d * e.r * .8, -e.r * 1.7); + ctx.stroke(); + } + ctx.fillStyle = '#ffe066'; + ctx.beginPath(); ctx.arc(-e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.arc(e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill(); + break; + } + case 'death': { + ctx.beginPath(); + ctx.moveTo(0, -e.r * 1.25); + ctx.lineTo(e.r, e.r * 1.05); ctx.lineTo(-e.r, e.r * 1.05); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#cfcfff'; ctx.lineWidth = 3.5; + ctx.beginPath(); ctx.arc(e.r * 1.15, -e.r * .2, e.r * .8, -2.4, -0.4); ctx.stroke(); + ctx.fillStyle = '#ff2740'; + ctx.beginPath(); ctx.arc(0, -e.r * .62, e.r * .3, 0, TAU); ctx.fill(); + break; + } + case 'prop': { + // floating crystal lamp on a stone base + const pulse = 0.78 + 0.22 * Math.sin(G.time * 3 + e.wob); + ctx.fillStyle = 'rgba(16,24,44,0.95)'; + ctx.beginPath(); + ctx.ellipse(0, e.r * 0.9, e.r * 0.85, e.r * 0.34, 0, 0, TAU); + ctx.fill(); + if (q !== 'low') { ctx.shadowColor = '#8fdcff'; ctx.shadowBlur = 14; } + const s = e.r * pulse; + const bob = Math.sin(G.time * 2.2 + e.wob) * 2.5; + ctx.translate(0, bob); + ctx.fillStyle = '#bfeaff'; + ctx.beginPath(); + ctx.moveTo(0, -s); ctx.lineTo(s * 0.62, 0); + ctx.lineTo(0, s); ctx.lineTo(-s * 0.62, 0); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.3; + ctx.stroke(); + break; + } + default: { + ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill(); + } + } + + // ---- status feedback ---- + if (e.freezeT > 0) { + ctx.globalAlpha = .5; + ctx.fillStyle = '#9fdcff'; + ctx.beginPath(); ctx.arc(0, 0, e.r * .95, 0, TAU); ctx.fill(); + ctx.globalAlpha = .9; + ctx.strokeStyle = '#e8fbff'; ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(-e.r * .5, -e.r * .5); ctx.lineTo(e.r * .5, e.r * .5); + ctx.moveTo(e.r * .5, -e.r * .5); ctx.lineTo(-e.r * .5, e.r * .5); + ctx.stroke(); + ctx.globalAlpha = 1; + } else if (e.slowT > 0) { + ctx.globalAlpha = .25; + ctx.fillStyle = '#8fd0ff'; + ctx.beginPath(); ctx.arc(0, 0, e.r * 1.05, 0, TAU); ctx.fill(); + ctx.globalAlpha = 1; + } + if (e.burnT > 0) { + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = .15 + Math.sin(G.time * 20 + e.wob) * .07; + ctx.fillStyle = '#ff7b39'; + ctx.beginPath(); ctx.arc(0, 0, e.r * 1.15, 0, TAU); ctx.fill(); + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + } + if (e.stunT > 0) { + const sa = G.time * 8 + e.wob; + ctx.fillStyle = '#ffe066'; + for (let k = 0; k < 3; k++) { + const aa = sa + k * TAU / 3; + ctx.beginPath(); + ctx.arc(Math.cos(aa) * e.r * .55, -e.r - 7 + Math.sin(aa) * 2.5, 1.8, 0, TAU); + ctx.fill(); + } + } + + // hp bar for elites/bosses + if ((e.elite || e.boss) && e.hp < e.maxhp) { + const bw = e.r * 2.4; + ctx.fillStyle = 'rgba(0,0,0,.6)'; + ctx.fillRect(-bw / 2, -e.r - 16, bw, 5); + ctx.fillStyle = e.boss ? '#ff2740' : '#ffd24a'; + ctx.fillRect(-bw / 2, -e.r - 16, bw * clamp(e.hp / e.maxhp, 0, 1), 5); + } + ctx.restore(); + } + }, + + /* ---------- projectiles ---------- */ + _projectiles(G, ctx, q) { + for (const p of G.projs) { + if (p.x < this._vx0 || p.x > this._vx1 || p.y < this._vy0 || p.y > this._vy1) continue; + ctx.save(); + ctx.translate(p.x, p.y); + + // motion trail for fast straight shots + const spd = len2(p.vx || 0, p.vy || 0); + if (p.mode === 'straight' && spd > 60) { + const tx = -p.vx / spd, ty = -p.vy / spd; + const tl = Math.min(30, spd * 0.05); + const tg = ctx.createLinearGradient(0, 0, tx * tl, ty * tl); + tg.addColorStop(0, colA(p.col, 0.75)); + tg.addColorStop(1, colA(p.col, 0)); + ctx.strokeStyle = tg; + ctx.lineWidth = p.r * 1.3; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(tx * tl, ty * tl); + ctx.stroke(); + } + + if (q !== 'low') { ctx.shadowColor = p.col; ctx.shadowBlur = 10; } + ctx.fillStyle = p.col; + switch (p.kind) { + case 'mine': case 'e_mine': { + // dark puck with a blinking fuse light + ctx.fillStyle = '#2a2033'; + ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill(); + ctx.strokeStyle = colA(p.col, .9); + ctx.lineWidth = 1.6; + ctx.stroke(); + const blink = 0.5 + 0.5 * Math.sin(p.t * (p.life < 2 ? 18 : 6)); + if (q !== 'low') { ctx.shadowColor = '#ff4d6d'; ctx.shadowBlur = 8 * blink + 2; } + ctx.fillStyle = blink > .5 ? '#ff4d6d' : '#7a2740'; + ctx.beginPath(); ctx.arc(0, -p.r * .35, p.r * .34, 0, TAU); ctx.fill(); + break; + } + case 'wand': case 'e_wand': + ctx.rotate(Math.atan2(p.vy, p.vx)); + this._roundRect(ctx, -8, -2.5, 16, 5, 2.5); ctx.fill(); + break; + case 'knife': case 'e_knife': + ctx.rotate(Math.atan2(p.vy, p.vx)); + ctx.beginPath(); + ctx.moveTo(9, 0); ctx.lineTo(-6, -3.5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 3.5); + ctx.closePath(); ctx.fill(); + break; + case 'orb': case 'e_orb': { + ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,.7)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(0, 0, p.r * .55, 0, TAU); ctx.stroke(); + break; + } + case 'axe': case 'e_axe': + ctx.rotate(p.rot); + ctx.fillRect(-2.5, -p.r * 1.1, 5, p.r * 2.2); + ctx.beginPath(); + ctx.moveTo(0, -p.r * 1.05); + ctx.quadraticCurveTo(p.r * 1.15, -p.r * .55, 0, p.r * .1); + ctx.closePath(); ctx.fill(); + ctx.beginPath(); + ctx.moveTo(0, -p.r * 1.05); + ctx.quadraticCurveTo(-p.r * 1.15, -p.r * .55, 0, p.r * .1); + ctx.closePath(); ctx.fill(); + break; + case 'fire': case 'e_fire': { + const g = ctx.createRadialGradient(0, 0, 1, 0, 0, p.r * 1.6); + g.addColorStop(0, '#fff3b0'); g.addColorStop(.5, p.col); g.addColorStop(1, 'rgba(255,60,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(0, 0, p.r * 1.6, 0, TAU); ctx.fill(); + break; + } + case 'frost': case 'e_frost': + ctx.rotate(p.t * 9); + for (let k = 0; k < 3; k++) { + ctx.rotate(TAU / 3); + ctx.beginPath(); + ctx.moveTo(0, -p.r * 1.4); ctx.lineTo(2.5, 0); ctx.lineTo(-2.5, 0); + ctx.closePath(); ctx.fill(); + } + break; + case 'cross': case 'e_cross': + ctx.rotate(p.rot); + const arm = p.r * 1.5, th = p.r * .48; + this._roundRect(ctx, -th / 2, -arm, th, arm * 2, 2); ctx.fill(); + this._roundRect(ctx, -arm * .72, -arm * .38, arm * 1.44, th, 2); ctx.fill(); + break; + default: + ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill(); + } + ctx.restore(); + } + }, + + _ebullets(G, ctx) { + for (const b of G.ebullets) { + if (b.x < this._vx0 || b.x > this._vx1 || b.y < this._vy0 || b.y > this._vy1) continue; + ctx.save(); + ctx.translate(b.x, b.y); + ctx.shadowColor = b.col; ctx.shadowBlur = 8; + ctx.fillStyle = b.col; + ctx.beginPath(); ctx.arc(0, 0, b.r, 0, TAU); ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(0, 0, b.r * .4, 0, TAU); ctx.fill(); + ctx.restore(); + } + }, + + /* ---------- player ---------- */ + _player(G, ctx) { + const P = G.player; + ctx.save(); + ctx.translate(P.x, P.y); + + // magnet radius hint (subtle) + ctx.strokeStyle = 'rgba(124,92,255,0.10)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(0, 0, P.magR, 0, TAU); ctx.stroke(); + + // invulnerability flicker + if (P.invuln > 0 && Math.floor(G.time * 20) % 2 === 0) ctx.globalAlpha = 0.45; + + if (Store.s().particles !== 'low') { + ctx.shadowColor = P.col; ctx.shadowBlur = 18; + } + // body + const g = ctx.createRadialGradient(-3, -4, 2, 0, 0, P.r + 2); + g.addColorStop(0, '#ffffff'); + g.addColorStop(.35, P.col); + g.addColorStop(1, '#20203a'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(0, 0, P.r, 0, TAU); ctx.fill(); + ctx.shadowBlur = 0; + + // face direction + ctx.fillStyle = '#0b0b18'; + const fx = P.faceX, fy = P.faceY; + ctx.beginPath(); ctx.arc(fx * 5 - fy * 3.2, fy * 5 + fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.arc(fx * 5 + fy * 3.2, fy * 5 - fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill(); + + ctx.restore(); + + // regen sparkle + if (P.regen > 0 && chance(0.08)) { + part(G, P.x + rand(-10, 10), P.y + rand(-10, 4), '#7dff9e', { sp: 24, life: .5, size: 2 }); + } + }, + + /* ---------- effects ---------- */ + _effects(G, ctx) { + for (const ef of G.effects) { + const prog = clamp(ef.t / ef.dur, 0, 1); + ctx.save(); + ctx.translate(ef.x, ef.y); + switch (ef.type) { + case 'slash': { + ctx.globalAlpha = 1 - prog; + ctx.rotate(ef.data.ang || 0); + const grad = ctx.createLinearGradient(-ef.data.w / 2, 0, ef.data.w / 2, 0); + grad.addColorStop(0, 'rgba(255,255,255,0)'); + grad.addColorStop(.5, ef.data.col); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = grad; + const h = ef.data.h * (0.6 + prog * 0.6); + this._roundRect(ctx, -ef.data.w / 2, -h / 2, ef.data.w, h, h / 2); + ctx.fill(); + break; + } + case 'bolt': { + ctx.globalAlpha = 1 - prog; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 4 * (1 - prog) + 1; + ctx.shadowColor = ef.data.col; ctx.shadowBlur = 12; + ctx.beginPath(); + let yy = 0; + ctx.moveTo(rand(-4, 4), yy); + while (yy < ef.data.ty - ef.y - 20) { + yy += rand(24, 46); + ctx.lineTo(rand(-16, 16), Math.min(yy, ef.data.ty - ef.y)); + } + ctx.lineTo(0, ef.data.ty - ef.y); + ctx.stroke(); + break; + } + case 'arc': { + ctx.globalAlpha = 1 - prog; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(0, 0); + const mx = (ef.data.tx - ef.x) / 2 + rand(-30, 30); + const my = (ef.data.ty - ef.y) / 2 + rand(-30, 30); + ctx.quadraticCurveTo(mx, my, ef.data.tx - ef.x, ef.data.ty - ef.y); + ctx.stroke(); + break; + } + case 'ring': { + ctx.globalAlpha = 1 - prog; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 3 * (1 - prog) + 1; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r * (0.4 + prog * 0.6), 0, TAU); ctx.stroke(); + break; + } + case 'nova': { + ctx.globalAlpha = 1 - prog; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 5 * (1 - prog) + 1; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r * prog, 0, TAU); ctx.stroke(); + break; + } + case 'explosion': { + ctx.globalAlpha = (1 - prog) * 0.95; + const rr = ef.data.r * (0.35 + prog * 0.65); + const g = ctx.createRadialGradient(0, 0, rr * .15, 0, 0, rr); + g.addColorStop(0, '#fff8d6'); + g.addColorStop(.55, ef.data.col); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(0, 0, rr, 0, TAU); ctx.fill(); + break; + } + case 'aura': { + ctx.globalAlpha = 0.13 + Math.sin(G.time * 6) * 0.03; + ctx.fillStyle = ef.data.col; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.5; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke(); + break; + } + case 'zone': { + ctx.globalAlpha = 0.28 * (1 - prog * 0.5); + ctx.fillStyle = '#ff6b35'; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.7 * (1 - prog); + ctx.fillStyle = '#ffd166'; + for (let k = 0; k < 4; k++) { + const a = G.time * 2 + k * 1.7; + ctx.beginPath(); + ctx.arc(Math.cos(a) * ef.data.r * .5, Math.sin(a * 1.3) * ef.data.r * .5, 3.5, 0, TAU); + ctx.fill(); + } + break; + } + case 'shock': { + ctx.globalAlpha = 1 - prog; + ctx.strokeStyle = ef.data.col; + ctx.lineWidth = 10 * (1 - prog) + 2; + ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke(); + break; + } + case 'impact': { + // crit starburst: spikes + white core + ctx.rotate(ef.data.ang || 0); + ctx.globalAlpha = 1 - prog; + const iR = (ef.data.r || 12) * (0.5 + prog * 0.8); + ctx.strokeStyle = ef.data.col || '#ffd24a'; + ctx.lineWidth = 2.2 * (1 - prog) + .5; + ctx.lineCap = 'round'; + ctx.beginPath(); + for (let k = 0; k < 4; k++) { + const a = k * Math.PI / 2 + .4; + ctx.moveTo(Math.cos(a) * iR * .3, Math.sin(a) * iR * .3); + ctx.lineTo(Math.cos(a) * iR, Math.sin(a) * iR); + } + ctx.stroke(); + ctx.globalAlpha = (1 - prog) * .9; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.arc(0, 0, iR * .42, 0, TAU); ctx.stroke(); + break; + } + case 'muzzle': { + // weapon fire flash at the barrel + ctx.rotate(ef.data.ang || 0); + ctx.globalAlpha = 1 - prog; + ctx.fillStyle = '#fff8d6'; + if (Store.s().particles !== 'low') { ctx.shadowColor = '#ffe066'; ctx.shadowBlur = 8; } + const mR = 13 * (1 - prog * 0.55); + ctx.beginPath(); + for (let k = 0; k < 4; k++) { + const a = k * Math.PI / 2; + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(a - .25) * mR * .45, Math.sin(a - .25) * mR * .45); + ctx.lineTo(Math.cos(a) * mR, Math.sin(a) * mR); + ctx.lineTo(Math.cos(a + .25) * mR * .45, Math.sin(a + .25) * mR * .45); + ctx.closePath(); + } + ctx.fill(); + break; + } + } + ctx.restore(); + } + }, + + /* ---------- particles / floating text ---------- */ + _particles(G, ctx, q) { + const maxDraw = q === 'low' ? 150 : q === 'med' ? 300 : 600; + const n = Math.min(G.parts.length, maxDraw); + for (let i = 0; i < n; i++) { + const p = G.parts[i]; + ctx.globalAlpha = clamp(p.life / p.maxLife, 0, 1); + ctx.fillStyle = p.col; + ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size); + } + ctx.globalAlpha = 1; + }, + + _texts(G, ctx, camX, camY) { + ctx.textAlign = 'center'; + for (let i = G.texts.length - 1; i >= 0; i--) { + const ft = G.texts[i]; + ft.life -= G.lastDt; + ft.y -= (ft.vy || 34) * G.lastDt; + if (ft.life <= 0) { removeItem(G.texts, i); continue; } + // scale punch on spawn (crits / heals / big events) + const age = ft.maxLife - ft.life; + let scale = 1; + if (ft.pop > 1 && age < 0.09) scale = ft.pop - (ft.pop - 1) * (age / 0.09); + else if (ft.pop > 1 && ft.life < 0.12) scale = 1 - (0.12 - ft.life); // shrink out + ctx.globalAlpha = clamp(ft.life / 0.35, 0, 1); + ctx.save(); + ctx.translate(ft.x - camX, ft.y - camY); + ctx.scale(scale, scale); + ctx.font = (ft.big ? '800 17px' : '700 13px') + ' "Segoe UI",sans-serif'; + ctx.strokeStyle = 'rgba(0,0,0,.85)'; + ctx.lineWidth = 3; + ctx.strokeText(ft.txt, 0, 0); + ctx.fillStyle = ft.col; + ctx.fillText(ft.txt, 0, 0); + ctx.restore(); + } + ctx.globalAlpha = 1; + }, + + /* ---------- offscreen boss indicator ---------- */ + _bossArrow(G, ctx, W, H) { + const b = G.boss; + if (!b || b.dead) return; + const dx = b.x - G.cam.x, dy = b.y - G.cam.y; + if (Math.abs(dx) < W / 2 - 60 && Math.abs(dy) < H / 2 - 60) return; + const a = Math.atan2(dy, dx); + const ex = clamp(W / 2 + Math.cos(a) * W, 26, W - 26); + const ey = clamp(H / 2 + Math.sin(a) * H, 70, H - 26); + ctx.save(); + ctx.translate(ex, ey); + ctx.rotate(a); + ctx.fillStyle = 'rgba(255,60,90,.9)'; + ctx.beginPath(); + ctx.moveTo(14, 0); ctx.lineTo(-8, -9); ctx.lineTo(-8, 9); + ctx.closePath(); ctx.fill(); + ctx.restore(); + }, + + + /* ================= cinematic main-menu scene ================= */ + drawMenuScene(G, dt) { + const ctx = G.ctx; + const W = G.cv.width / G.dpr, H = G.cv.height / G.dpr; + const M = G._menu || (G._menu = { t: 0, walkers: [], spawnAcc: 1.2, motes: null, sweep: 0 }); + M.t += dt; + const t = M.t; + const horY = H * 0.66; + + // ---- sky ---- + let sky = this._menuSky || (this._menuSky = { h: 0 }); + if (sky.h !== H || !this._menuSkyC) { + const c = document.createElement('canvas'); c.width = 2; c.height = 256; + const cc = c.getContext('2d'); + const g = cc.createLinearGradient(0, 0, 0, 256); + g.addColorStop(0, '#04040b'); + g.addColorStop(.55, '#0c0a24'); + g.addColorStop(1, '#241a4e'); + cc.fillStyle = g; cc.fillRect(0, 0, 2, 256); + this._menuSkyC = c; + } + ctx.drawImage(this._menuSkyC, 0, 0, 2, 256, 0, 0, W, H); + + // nebula blobs + for (let i = 0; i < 2; i++) { + const nx = W * (0.22 + i * 0.55) + Math.sin(t * 0.05 + i * 3) * W * 0.05; + const ny = H * (0.18 + i * 0.16); + const nr = Math.min(W, H) * (0.42 - i * 0.1); + const ng = ctx.createRadialGradient(nx, ny, 0, nx, ny, nr); + ng.addColorStop(0, i ? 'rgba(0,229,255,0.07)' : 'rgba(124,92,255,0.10)'); + ng.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = ng; + ctx.fillRect(nx - nr, ny - nr, nr * 2, nr * 2); + } + + // ---- stars (deterministic twinkle) ---- + ctx.fillStyle = '#fff'; + for (let i = 0; i < 70; i++) { + const hsh = _strHash('star' + i); + const sx = (hsh % 1000) / 1000 * W; + const sy = ((hsh >> 10) % 1000) / 1000 * horY * 0.96; + const tw = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.6 + (hsh % 5) * 0.25) + i)); + ctx.globalAlpha = tw * 0.85; + const r = 0.5 + (hsh % 13) / 10; + ctx.fillRect(sx, sy, r, r); + } + ctx.globalAlpha = 1; + + // ---- moon ---- + const mr = Math.min(W, H) * 0.14; + const mx = W * 0.78, my = H * 0.20 + Math.sin(t * 0.3) * 6; + if (Store.s().particles !== 'low') { + ctx.shadowColor = 'rgba(199,180,255,.9)'; + ctx.shadowBlur = 46; + } + const mg = ctx.createRadialGradient(mx - mr * .3, my - mr * .35, mr * .15, mx, my, mr); + mg.addColorStop(0, '#f3edff'); + mg.addColorStop(.7, '#b9a8ff'); + mg.addColorStop(1, '#6e57c9'); + ctx.fillStyle = mg; + ctx.beginPath(); ctx.arc(mx, my, mr, 0, TAU); ctx.fill(); + ctx.shadowBlur = 0; + // craters + ctx.fillStyle = 'rgba(80,60,150,.28)'; + [[-.3,-.1,.18],[.22,.25,.12],[-.05,.38,.09],[.34,-.28,.08]].forEach(c => { + ctx.beginPath(); ctx.arc(mx + c[0] * mr, my + c[1] * mr, c[2] * mr, 0, TAU); ctx.fill(); + }); + + // ---- skyline silhouettes ---- + const bld = (layer, colr, seedOff, maxH) => { + ctx.fillStyle = colr; + const bw = W / 14; + for (let i = -1; i < 15; i++) { + const hs = _strHash('bld' + layer + '_' + i); + const bh = maxH * (0.35 + (hs % 100) / 100 * 0.65); + const x = i * bw - bw * 0.5 + ((hs >> 7) % 20) - 10; + ctx.fillRect(x, horY - bh, bw * (0.62 + (hs % 30) / 100), bh + 4); + if (layer === 1 && (hs % 3 === 0)) { + // a few lit windows + ctx.fillStyle = 'rgba(255,209,102,' + (0.25 + 0.2 * Math.sin(t * 2 + i)) + ')'; + for (let wy = 0; wy < 3; wy++) + ctx.fillRect(x + bw * 0.2, horY - bh + 8 + wy * 9, 2.5, 3.5); + ctx.fillStyle = colr; + } + } + }; + bld(0, '#191238', 0, H * 0.11); + bld(1, '#0d0a20', 50, H * 0.075); + + // horizon glow band + const hg = ctx.createLinearGradient(0, horY - 26, 0, horY + 26); + hg.addColorStop(0, 'rgba(124,92,255,0)'); + hg.addColorStop(.5, 'rgba(160,120,255,0.55)'); + hg.addColorStop(1, 'rgba(0,229,255,0)'); + ctx.fillStyle = hg; + ctx.fillRect(0, horY - 26, W, 52); + + // ---- perspective floor grid ---- + ctx.strokeStyle = 'rgba(124,92,255,0.30)'; + ctx.lineWidth = 1; + const vpx = W / 2; + ctx.beginPath(); + for (let k = -14; k <= 14; k++) { + const xb = vpx + k * (W * 0.09); + ctx.moveTo(vpx + k * 8, horY); + ctx.lineTo(xb * 1.02 - vpx * 0.02 + (xb - vpx) * 0.0, H + 40); + } + ctx.stroke(); + ctx.strokeStyle = 'rgba(0,229,255,0.20)'; + ctx.beginPath(); + const ROWS = 15; + for (let i = 0; i < ROWS; i++) { + const z = ((i + t * 2.0) % ROWS) / ROWS; + const y = horY + (H - horY) * Math.pow(z, 3); + ctx.moveTo(0, y); ctx.lineTo(W, y); + } + ctx.stroke(); + + // ---- marching silhouette walkers on the ground line ---- + M.spawnAcc -= dt; + if (M.spawnAcc <= 0 && M.walkers.length < 7) { + M.spawnAcc = rand(1.3, 2.6); + const dir = chance(.5) ? 1 : -1; + const cols = ['#ff6b81', '#7dfaff', '#c07dff']; + M.walkers.push({ + x: dir > 0 ? -40 : W + 40, dir, + y: horY + rand(16, 44), + r: rand(7, 11), + spd: rand(24, 46), + wob: rand(TAU), + col: pick(cols) + }); + } + for (let i = M.walkers.length - 1; i >= 0; i--) { + const wk = M.walkers[i]; + wk.x += wk.dir * wk.spd * dt; + if (wk.x < -60 || wk.x > W + 60) { M.walkers.splice(i, 1); continue; } + const bob = Math.abs(Math.sin(t * 6 + wk.wob)) * 3; + ctx.save(); + ctx.translate(wk.x, wk.y - bob); + ctx.globalAlpha = 0.92; + // body blob + ctx.fillStyle = '#0b0817'; + ctx.beginPath(); + ctx.arc(0, 0, wk.r, Math.PI, TAU); + ctx.lineTo(wk.r * 0.9, wk.r * 0.9); + ctx.quadraticCurveTo(0, wk.r * 1.25, -wk.r * 0.9, wk.r * 0.9); + ctx.closePath(); ctx.fill(); + // neon rim toward viewer + ctx.strokeStyle = colA(wk.col, .75); + ctx.lineWidth = 1.4; + ctx.stroke(); + // eyes + ctx.fillStyle = wk.col; + const eo = wk.dir * wk.r * 0.28; + ctx.beginPath(); ctx.arc(-wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.arc(wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill(); + ctx.restore(); + } + ctx.globalAlpha = 1; + + // ---- rising dust motes ---- + if (!M.motes) { + M.motes = []; + for (let i = 0; i < 26; i++) + M.motes.push({ x: rand(0, 1), y: rand(0, 1), s: rand(1, 2.6), v: rand(8, 26), ph: rand(TAU) }); + } + for (const m of M.motes) { + m.y -= m.v * dt / H; + if (m.y < -0.03) { m.y = 1.03; m.x = Math.random(); } + const px = (m.x + Math.sin(t * 0.6 + m.ph) * 0.01) * W; + ctx.globalAlpha = 0.16 + 0.14 * Math.sin(t * 2 + m.ph); + ctx.fillStyle = '#9ad5ff'; + ctx.beginPath(); ctx.arc(px, m.y * H, m.s, 0, TAU); ctx.fill(); + } + ctx.globalAlpha = 1; + + // ---- periodic diagonal light sweep ---- + M.sweep += dt; + const period = 8; + const sp = (M.sweep % period) / period; + if (sp < 0.22) { + const k = sp / 0.22; + const cx = -W * 0.3 + k * W * 1.6; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.translate(cx, H * 0.35); + ctx.rotate(-0.35); + const lg = ctx.createLinearGradient(-90, 0, 90, 0); + lg.addColorStop(0, 'rgba(160,140,255,0)'); + lg.addColorStop(.5, 'rgba(190,170,255,0.06)'); + lg.addColorStop(1, 'rgba(160,140,255,0)'); + ctx.fillStyle = lg; + ctx.fillRect(-90, -H, 180, H * 2); + ctx.restore(); + } + + this._vignette(ctx, W, H); + }, + + _vignette(ctx, W, H) { if (!this._vig || this._vigW !== W || this._vigH !== H) { + const c = document.createElement('canvas'); + c.width = W; c.height = H; + const cc = c.getContext('2d'); + const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .45, W / 2, H / 2, Math.max(W, H) * .75); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(0,0,10,0.42)'); + cc.fillStyle = g; + cc.fillRect(0, 0, W, H); + this._vig = c; this._vigW = W; this._vigH = H; + } + ctx.drawImage(this._vig, 0, 0); + }, + + _roundRect(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } +}; diff --git a/js/save.js b/js/save.js new file mode 100644 index 0000000..a901b81 --- /dev/null +++ b/js/save.js @@ -0,0 +1,100 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — save.js : localStorage persistence + Meta progression (gold, permanent upgrades, unlocks) + settings. + ============================================================ */ + +const SAVE_KEY = 'neon_survivors_v1'; + +const DEFAULT_SAVE = { + gold: 0, + meta: {}, // metaId -> rank purchased + chars: ['kaito'], // unlocked character ids + flags: { boss1: false, time10: false, time15: false }, + totals: { kills: 0, gold: 0, best: 0, wins: 0, runs: 0, evos: 0, props: 0 }, + progress: { stages: ['graveyard'], bestPerStage: {}, endlessBest: 0, stageWins: {}, gradeWins: {} }, + skinOwned: {}, // 'charId:skinKey' -> 1 + skinSel: {}, // charId -> skinKey + lbName: '', // leaderboard display name + daily: { date: '', best: 0 }, + ach: {}, + history: [], + settings: { + lang: 'vi', + volMaster: 0.9, + volMusic: 0.6, + volSfx: 0.85, + shake: true, + dmgNum: true, + particles: 'high', // low | med | high + fps: false, + autoPause: true, + dev: false, // developer mode tools + keybinds: { up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD' } + } +}; + +const Store = { + data: null, + + load() { + this.data = JSON.parse(JSON.stringify(DEFAULT_SAVE)); + try { + const raw = localStorage.getItem(SAVE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + // Deep-merge known sections so old saves survive schema additions. + for (const sec of ['flags', 'totals', 'settings', 'progress', 'daily', 'ach']) { + if (parsed[sec]) Object.assign(this.data[sec], parsed[sec]); + } + if (parsed.skinOwned) Object.assign(this.data.skinOwned, parsed.skinOwned); + if (parsed.skinSel) Object.assign(this.data.skinSel, parsed.skinSel); + if (!this.data.lbName && typeof parsed.lbName === 'string') this.data.lbName = parsed.lbName; + if (Array.isArray(parsed.history)) this.data.history = parsed.history.slice(0, 20); + if (typeof parsed.gold === 'number') this.data.gold = parsed.gold; + if (parsed.meta && typeof parsed.meta === 'object') this.data.meta = parsed.meta; + if (Array.isArray(parsed.chars)) this.data.chars = parsed.chars; + } + } catch (e) { console.warn('Save load failed', e); } + return this.data; + }, + + save() { + try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.data)); } + catch (e) { console.warn('Save write failed', e); } + }, + + reset() { + this.data = JSON.parse(JSON.stringify(DEFAULT_SAVE)); + this.save(); + }, + + s() { return this.data.settings; }, + + addGold(n) { + this.data.gold += n; + this.data.totals.gold += Math.max(0, n); + // lazy-save; frequent small writes are cheap enough here + this.save(); + }, + + spendGold(n) { + if (this.data.gold < n) return false; + this.data.gold -= n; + this.save(); + return true; + }, + + rank(metaId) { return this.data.meta[metaId] || 0; }, + + unlockChar(id) { + if (!this.data.chars.includes(id)) { + this.data.chars.push(id); + this.save(); + return true; + } + return false; + }, + + charUnlocked(id) { return this.data.chars.includes(id); } +}; diff --git a/js/systems.js b/js/systems.js new file mode 100644 index 0000000..dd3ff5c --- /dev/null +++ b/js/systems.js @@ -0,0 +1,1262 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — systems.js : spawning, AI, combat, pickups, + leveling, chests, boss AI. All operate on a game object `G`. + ============================================================ */ + +const Sys = { + + /* ================= SPAWNING ================= */ + + ringPos(G, dist) { + const a = rand(TAU); + return { + x: G.player.x + Math.cos(a) * dist, + y: G.player.y + Math.sin(a) * dist + }; + }, + + spawnTick(G, dt) { + const P = G.player; + const stage = G.stage || STAGES.graveyard; + const rm = Math.floor(G.time / 60); // real minute (unbounded, endless) + const m = Math.min(19, rm); // table row + const dm = G.diffMul || 1; + const maxE = { low: 130, med: 180, high: 250 }[Store.s().particles] || 200; + + // trickle spawns + const rate = 0.9 + m * 0.55; + G.spawnAcc += dt * rate; + while (G.spawnAcc >= 1) { + G.spawnAcc -= 1; + if (G.enemies.length < maxE) { + const tid = pick(stage.table[m]); + const pos = this.ringPos(G, rand(560, 720)); + const e = new Enemy(tid, pos.x + rand(-40, 40), pos.y + rand(-40, 40)); + e.scaleTo(rm, dm); + G.enemies.push(e); + } + } + + // breakable props — a few lamps scattered around the field + G.propAcc += dt; + if (G.propAcc > 9) { + G.propAcc = 0; + let props = 0; + for (const e of G.enemies) if (e.def.prop) props++; + if (props < 4 && G.enemies.length < maxE) { + const pos = this.ringPos(G, rand(380, 700)); + const e = new Enemy('lamp', pos.x, pos.y); + e.scaleTo(Math.max(1, rm * 0.5), 1); + G.enemies.push(e); + } + } + + // minute bursts + const minBoundary = Math.floor(G.time / 60); + if (minBoundary > G.lastBurstMinute && G.time > 3) { + G.lastBurstMinute = minBoundary; + const n = 10 + minBoundary * 4; + const tid = pick(stage.table[m]); + const center = this.ringPos(G, 640); + for (let i = 0; i < n; i++) { + if (G.enemies.length >= maxE + 30) break; + const ang = rand(TAU); + const rad = rand(0, 140); + const e = new Enemy(tid, center.x + Math.cos(ang) * rad, center.y + Math.sin(ang) * rad); + e.scaleTo(rm, dm); + G.enemies.push(e); + } + } + + // endless surges — every 2 minutes past the finish line + if (G.endless && G.time > stage.length) { + const sm = Math.floor((G.time - stage.length) / 120); + if (sm > (G.lastSurge !== undefined ? G.lastSurge : -1)) { + G.lastSurge = sm; + UI.toast(tr('toast_surge'), 'warn'); + Snd.play('roar', true); + this.spawnElite(G); + this.spawnElite(G); + const center = this.ringPos(G, 620); + for (let i = 0; i < 14; i++) { + if (G.enemies.length >= maxE + 30) break; + const ang = (i / 14) * TAU; + const e = new Enemy(pick(stage.table[m]), center.x + Math.cos(ang) * 150, center.y + Math.sin(ang) * 150); + e.scaleTo(rm, dm); + G.enemies.push(e); + } + } + } + + // elites -> chests + while (G.eliteIdx < ELITE_TIMES.length && G.time >= ELITE_TIMES[G.eliteIdx]) { + G.eliteIdx++; + this.spawnElite(G); + } + + // bosses + while (G.bossIdx < stage.plan.length && G.time >= stage.plan[G.bossIdx].t) { + const plan = stage.plan[G.bossIdx++]; + const pos = this.ringPos(G, 520); + const b = new Enemy(plan.id, pos.x, pos.y); + const mm = Math.floor(G.time / 60); + b.hp *= (1 + mm * 0.06) * (1 + ((dm || 1) - 1) * 0.5); b.maxhp = b.hp; + G.enemies.push(b); + G.boss = b; + UI.toast(tr('toast_boss'), 'warn'); + UI.showBossBar(b); + Snd.play('roar', true); + G.shakeIt(8); + if (!Store.data.flags.tutBoss) { + Store.data.flags.tutBoss = true; + Store.save(); + UI.showTut('tut_boss'); + setTimeout(() => UI.hideTut(), 5000); + } + } + }, + + spawnElite(G) { + const stage = G.stage || STAGES.graveyard; + const m = Math.min(19, Math.floor(G.time / 60)); + const tid = pick(stage.table[m]); + const pos = this.ringPos(G, 600); + const e = new Enemy(tid, pos.x, pos.y); + e.scaleTo(Math.max(1, Math.floor(G.time / 120)), G.diffMul || 1); + e.applyElite(); + // random modifier + if (chance(0.65)) { + const key = pick(Object.keys(ELITE_AFFIXES)); + const af = ELITE_AFFIXES[key]; + e.affix = key; + e.affixCol = af.col; + af.apply(e); + if (Store.s().dmgNum) { + G.texts.push(new FText(e.x, e.y - e.r - 22, + af.icon + ' ' + (currentLang() === 'en' ? af.en : af.vi), + af.col, true, { life: 1.2, vy: 20, pop: 1.7 })); + } + } + G.enemies.push(e); + UI.toast(tr('toast_elite'), 'warn'); + Snd.play('roar'); + return e; + }, + + /* ================= ENEMY UPDATE ================= */ + + updEnemies(G, dt) { + const P = G.player; + const grid = G.grid; + grid.clear(); + for (const e of G.enemies) grid.insert(e); + const near = []; + + for (let i = G.enemies.length - 1; i >= 0; i--) { + const e = G.enemies[i]; + if (e.dead) { removeItem(G.enemies, i); continue; } + + // status timers + if (e.slowT > 0) e.slowT -= dt; + if (e.freezeT > 0) e.freezeT -= dt; + if (e.stunT > 0) e.stunT -= dt; + if (e.flash > 0) e.flash -= dt; + if (e.burnT > 0) { + e.burnT -= dt; + this.damageEnemy(G, e, e.burnDps * dt, { silent: true }); + if (e.dead) { removeItem(G.enemies, i); continue; } + } + if (chance(dt * 8) && (e.burnT > 0)) { + part(G, e.x + rand(-6, 6), e.y + rand(-6, 6), '#ff8c42', { sp: 20, life: .4, size: 3 }); + } + + // far recycle (keeps pressure, skips elites/bosses/props) + const dpx = e.x - P.x, dpy = e.y - P.y; + const dp2 = dpx * dpx + dpy * dpy; + if (!e.elite && !e.boss && !e.def.prop && dp2 > 1500 * 1500) { + const np = this.ringPos(G, 800); + e.x = np.x; e.y = np.y; + } + + if (e.stunT <= 0 && e.freezeT <= 0) { + if (e.boss) this._bossAI(G, e, dt); + else this._enemyAI(G, e, dt, Math.sqrt(dp2)); + } else { + e.state = e.state === 2 ? 0 : e.state; // cancel dashes + } + + // knockback decay + e.kbx *= Math.exp(-8 * dt); e.kby *= Math.exp(-8 * dt); + + // separation (skip ghosts & props) + if (e.def.ai !== 'ghost' && !e.def.prop) { + grid.query(e.x, e.y, e.r + 26, near); + let pushX = 0, pushY = 0; + for (const o of near) { + if (o === e || o.def.ai === 'ghost' || o.def.prop) continue; + const dx = e.x - o.x, dy = e.y - o.y; + const rr = e.r + o.r; + const d2 = dx * dx + dy * dy; + if (d2 < rr * rr && d2 > 0.01) { + const d = Math.sqrt(d2); + pushX += (dx / d) * (rr - d); + pushY += (dy / d) * (rr - d); + } + } + const sepF = e.boss ? 0 : 14 * dt; + e.x += pushX * sepF; e.y += pushY * sepF; + } + + e.x += e.kbx * dt; e.y += e.kby * dt; + + // contact damage (props never hurt) + const pr = P.r + e.r; + if (!e.def.prop && P.invuln <= 0 && dp2 < pr * pr && G.time - e.lastTouch > 0.65) { + e.lastTouch = G.time; + this.hurtPlayer(G, e.dmg); + } + } + }, + + _enemyAI(G, e, dt, dist) { + const P = G.player; + const sf = e.speedFactor; + const dx = P.x - e.x, dy = P.y - e.y; + const d = Math.max(1, dist); + const ux = dx / d, uy = dy / d; + let mvx = 0, mvy = 0; + + switch (e.def.ai) { + case 'prop': // breakable lamp: never moves, never attacks + return; + case 'chase': + mvx = ux; mvy = uy; + if (e.def.wiggle) { + const w = Math.sin(G.time * e.def.wiggle + e.wob) * 0.45; + mvx += -uy * w; mvy += ux * w; + } + break; + + case 'ghost': { + mvx = ux; mvy = uy; + const w = Math.sin(G.time * 1.8 + e.wob) * 0.6; + mvx += -uy * w; mvy += ux * w; + break; + } + + case 'shooter': { + const range = e.def.range; + if (dist > range) { mvx = ux; mvy = uy; } + else if (dist < range * 0.55) { mvx = -ux; mvy = -uy; } + else { const s = Math.sin(G.time * 0.8 + e.wob) > 0 ? 1 : -1; mvx = -uy * s * 0.6; mvy = ux * s * 0.6; } + e.shootT -= dt * sf; + if (e.shootT <= 0 && dist < range * 1.35) { + e.shootT = e.def.bcd * rand(0.85, 1.2); + const cnt = e.def.spread || 1; + const baseA = Math.atan2(dy, dx); + for (let k = 0; k < cnt; k++) { + const a = baseA + (k - (cnt - 1) / 2) * 0.22; + G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * e.def.bspd, Math.sin(a) * e.def.bspd, e.dmg, e.col)); + } + } + break; + } + + case 'charger': { + // 0 approach, 1 windup, 2 dash, 3 recover + if (e.state === 0) { + mvx = ux; mvy = uy; + if (dist < 340) { e.state = 1; e.aiT = 0.55; } + } else if (e.state === 1) { + e.aiT -= dt; + e.rot += dt * 10; + if (e.aiT <= 0) { + e.state = 2; e.aiT = 0.5; + e.cx = ux; e.cy = uy; + } + } else if (e.state === 2) { + e.aiT -= dt; + mvx = e.cx * 3.4; mvy = e.cy * 3.4; + if (e.aiT <= 0) { e.state = 3; e.aiT = 0.8; } + } else { + e.aiT -= dt; + if (e.aiT <= 0) e.state = 0; + } + break; + } + } + + e.x += mvx * e.spd * sf * dt; + e.y += mvy * e.spd * sf * dt; + }, + + _bossAI(G, e, dt) { + const P = G.player; + const dx = P.x - e.x, dy = P.y - e.y; + const d = Math.max(1, len2(dx, dy)); + const ux = dx / d, uy = dy / d; + + switch (e.def.ai) { + case 'boss_wolf': + e.aiT -= dt; + if (e.state === 0) { // stalk + e.x += ux * e.spd * dt; e.y += uy * e.spd * dt; + if (e.aiT <= 0 && d < 520) { e.state = 1; e.aiT = 0.6; } + } else if (e.state === 1) { // windup + e.rot += dt * 12; + if (e.aiT <= 0) { e.state = 2; e.aiT = 0.62; e.cx = ux; e.cy = uy; Snd.play('roar'); } + } else if (e.state === 2) { // dash + e.x += e.cx * e.spd * 3.6 * dt; e.y += e.cy * e.spd * 3.6 * dt; + if (e.aiT <= 0) { e.state = 0; e.aiT = rand(1.6, 2.4); } + } + break; + + case 'boss_lich': { + const want = 300; + if (d > want + 40) { e.x += ux * e.spd * dt; e.y += uy * e.spd * dt; } + else if (d < want - 60) { e.x -= ux * e.spd * dt; e.y -= uy * e.spd * dt; } + else { e.x += -uy * e.spd * 0.5 * dt; e.y += ux * e.spd * 0.5 * dt; } + e.shootT -= dt; + if (e.shootT <= 0) { + e.shootT = 3.1; + const n = 14, off = rand(TAU); + for (let k = 0; k < n; k++) { + const a = off + k * TAU / n; + G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * 190, Math.sin(a) * 190, e.dmg * 0.6, '#b388ff')); + } + Snd.play('zap'); + } + e.aiT -= dt; + if (e.aiT <= 0) { + e.aiT = 6.5; + for (let k = 0; k < 3; k++) { + const a = rand(TAU); + const s = new Enemy('bat', e.x + Math.cos(a) * 60, e.y + Math.sin(a) * 60); + s.scaleTo(Math.floor(G.time / 60)); + G.enemies.push(s); + } + } + break; + } + + case 'boss_beho': + e.x += ux * e.spd * dt; e.y += uy * e.spd * dt; + e.aiT -= dt; + if (e.aiT <= 0) { + e.aiT = 4.6; + G.effects.push(new Effect('shock', e.x, e.y, 1.1, { maxR: 300, dmg: e.dmg * 0.9, hit: new Set(), col: '#ff5c5c' })); + Snd.play('boom'); + G.shakeIt(6); + } + break; + + case 'boss_death': + e.aiT -= dt; + if (e.state === 0) { + e.x += ux * e.spd * dt; e.y += uy * e.spd * dt; + if (e.aiT <= 0) { + e.state = 1; e.aiT = 1.4; + // blink next to player + G.effects.push(new Effect('nova', e.x, e.y, 0.35, { r: 46, col: '#cfcfff' })); + const a = rand(TAU); + e.x = P.x + Math.cos(a) * 190; e.y = P.y + Math.sin(a) * 190; + G.effects.push(new Effect('nova', e.x, e.y, 0.35, { r: 46, col: '#cfcfff' })); + Snd.play('zap'); + } + } else { // scythe spiral + e.shootT -= dt; + e.rot += dt * 18; + if (e.shootT <= 0) { + e.shootT = 0.07; + const a = G.time * 9; + G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * 230, Math.sin(a) * 230, e.dmg * 0.45, '#e6e6ff')); + G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a + Math.PI) * 230, Math.sin(a + Math.PI) * 230, e.dmg * 0.45, '#e6e6ff')); + } + if (e.aiT <= 0) { e.state = 0; e.aiT = rand(2.0, 3.0); } + } + break; + } + }, + + /* ================= WEAPONS ================= */ + + fireWeapons(G, dt) { + const P = G.player; + for (const w of P.weapons) { + const st = wstats(w.id, w.lvl); + const cd = st.cd * P.cdrMul; + + // orbit orbs are persistent — sync them outside the timer + if (w.id === 'orb' || w.id === 'e_orb') { this._syncOrbs(G, w, st); continue; } + // aura ticks continuously + if (w.id === 'garlic' || w.id === 'e_garlic') { + w.timer -= dt; + if (w.timer <= 0) { w.timer = cd; this._garlicTick(G, w, st); } + continue; + } + + w.timer -= dt; + if (w.timer > 0) continue; + w.timer = cd; + + switch (w.id) { + case 'wand': case 'e_wand': this._fireWand(G, w, st); break; + case 'whip': case 'e_whip': this._fireWhip(G, w, st); break; + case 'knife': case 'e_knife': this._fireKnife(G, w, st); break; + case 'axe': case 'e_axe': this._fireAxe(G, w, st); break; + case 'light': case 'e_light': this._fireLight(G, w, st); break; + case 'fire': case 'e_fire': this._fireFire(G, w, st); break; + case 'frost': case 'e_frost': this._fireFrost(G, w, st); break; + case 'cross': case 'e_cross': this._fireCross(G, w, st); break; + case 'mine': case 'e_mine': this._fireMine(G, w, st); break; + } + } + }, + + _nearestEnemies(G, x, y, count, maxR) { + const arr = []; + for (const e of G.enemies) { + const d2 = dist2(x, y, e.x, e.y); + if (d2 < maxR * maxR) arr.push({ e, d2 }); + } + arr.sort((a, b) => a.d2 - b.d2); + return arr.slice(0, count).map(o => o.e); + }, + + /** Small flash at the barrel when a shot goes out. */ + _muzzle(G, x, y, ang) { + if (G.effects.length < 70) { + G.effects.push(new Effect('muzzle', x, y, 0.12, { ang })); + } + }, + + _aimDirRandomEnemy(G) { + const vis = G.enemies.filter(e => dist2(G.player.x, G.player.y, e.x, e.y) < 700 * 700); + if (vis.length) { + const t = pick(vis); + const d = Math.max(1, len2(t.x - G.player.x, t.y - G.player.y)); + return { x: (t.x - G.player.x) / d, y: (t.y - G.player.y) / d }; + } + return { x: G.player.faceX, y: G.player.faceY }; + }, + + _fireWand(G, w, st) { + const P = G.player; + const targets = this._nearestEnemies(G, P.x, P.y, st.n, 760); + if (targets.length) { + const d = Math.max(1, len2(targets[0].x - P.x, targets[0].y - P.y)); + this._muzzle(G, P.x + (targets[0].x - P.x) / d * 15, + P.y + (targets[0].y - P.y) / d * 15, + Math.atan2(targets[0].y - P.y, targets[0].x - P.x)); + } else { + this._muzzle(G, P.x + P.faceX * 15, P.y + P.faceY * 15, Math.atan2(P.faceY, P.faceX)); + } + for (let i = 0; i < st.n; i++) { + let dx, dy; + if (targets[i % Math.max(1, targets.length)] && targets.length) { + const t = targets[i % targets.length]; + const d = Math.max(1, len2(t.x - P.x, t.y - P.y)); + dx = (t.x - P.x) / d; dy = (t.y - P.y) / d; + } else { dx = P.faceX; dy = P.faceY; } + G.projs.push(new Proj({ + kind: w.id, mode: 'straight', + x: P.x, y: P.y, vx: dx * st.spd * P.pspdMul, vy: dy * st.spd * P.pspdMul, + dmg: st.dmg * P.might, pierce: st.pierce, r: 6 * st.area * P.areaMul, + life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col + })); + } + Snd.play('shoot'); + }, + + _fireWhip(G, w, st) { + const P = G.player; + + // --- auto-aim: swing toward the nearest enemy in range --- + let ax = P.faceX, ay = P.faceY; + let best = null, bd = 480 * 480; + for (const e of G.enemies) { + const d2 = dist2(P.x, P.y, e.x, e.y); + if (d2 < bd) { bd = d2; best = e; } + } + if (best) { + const d = Math.max(1, len2(best.x - P.x, best.y - P.y)); + ax = (best.x - P.x) / d; + ay = (best.y - P.y) / d; + } + + const both = st.n >= 2; // evolved / high level hits front+back + const sides = both ? [1, -1] : [w.side || 1]; + w.side = -(w.side || 1); + + const L = 165 * st.area * P.areaMul, W = 70 * st.area * P.areaMul; + for (const s of sides) { + const dirX = ax * s, dirY = ay * s; + const ang = Math.atan2(dirY, dirX); + const cx = P.x + dirX * (L / 2 + 22), cy = P.y + dirY * (L / 2 + 22); + G.effects.push(new Effect('slash', cx, cy, 0.22, { w: L, h: W, ang, col: wdef(w.id).col })); + + // oriented box test: project onto swing axis + for (const e of G.enemies) { + const dx = e.x - P.x, dy = e.y - P.y; + const along = dx * dirX + dy * dirY; + const perp = dx * -dirY + dy * dirX; + if (along > -e.r && along < L + e.r && Math.abs(perp) < W / 2 + e.r) { + this.hitEnemy(G, e, st.dmg * P.might, { + kb: { x: dirX, y: dirY, f: st.kb }, lifesteal: st.lifesteal || 0 + }); + } + } + } + Snd.play('hit'); + }, + + _fireKnife(G, w, st) { + const P = G.player; + const baseA = Math.atan2(P.faceY, P.faceX); + this._muzzle(G, P.x + Math.cos(baseA) * 15, P.y + Math.sin(baseA) * 15, baseA); + const spread = 0.11; + for (let i = 0; i < st.n; i++) { + const a = baseA + (i - (st.n - 1) / 2) * spread; + G.projs.push(new Proj({ + kind: w.id, mode: 'straight', + x: P.x, y: P.y, vx: Math.cos(a) * st.spd * P.pspdMul, vy: Math.sin(a) * st.spd * P.pspdMul, + dmg: st.dmg * P.might, pierce: st.pierce, r: 5 * st.area * P.areaMul, + life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col + })); + } + Snd.play('shoot'); + }, + + _syncOrbs(G, w, st) { + const P = G.player; + let count = 0; + for (const p of G.projs) if (p.mode === 'orbit' && p.wid === w.id) count++; + while (count < st.n) { + G.projs.push(new Proj({ + kind: w.id, mode: 'orbit', wid: w.id, + x: P.x, y: P.y, ang: rand(TAU), + orbR: 78 * st.area * P.areaMul, av: st.spd, + dmg: st.dmg * P.might, r: 11 * st.area * P.areaMul, + hitCd: st.hitCd, life: Infinity, kb: 60, col: wdef(w.id).col + })); + count++; + } + // remove extras (after downgrade never happens, but safe) + for (let i = G.projs.length - 1; i >= 0 && count > st.n; i--) { + const p = G.projs[i]; + if (p.mode === 'orbit' && p.wid === w.id) { removeItem(G.projs, i); count--; } + } + }, + + _garlicTick(G, w, st) { + const P = G.player; + const R = (st.radius || 95) * st.area * P.areaMul; + for (const e of G.enemies) { + if (dist2(P.x, P.y, e.x, e.y) < (R + e.r) * (R + e.r)) { + this.hitEnemy(G, e, st.dmg * P.might, { + kb: { x: e.x - P.x, y: e.y - P.y, f: st.kb }, silent: true + }); + } + } + G.effects.push(new Effect('aura', P.x, P.y, st.cd * P.cdrMul, { r: R, col: wdef(w.id).col })); + }, + + _fireAxe(G, w, st) { + const P = G.player; + for (let i = 0; i < st.n; i++) { + const dir = chance(0.5) ? 1 : -1; + const spin = w.id === 'e_axe'; + G.projs.push(new Proj({ + kind: w.id, mode: spin ? 'spiral' : 'lob', + x: P.x, y: P.y, + vx: dir * rand(st.spd * 0.5, st.spd) * P.pspdMul, + vy: spin ? rand(-80, 80) : -rand(430, 560), + grav: st.grav || 900, + dmg: st.dmg * P.might, pierce: st.pierce, r: 13 * st.area * P.areaMul, + life: spin ? 2.6 : 3, rot: rand(TAU), spin: rand(8, 12) * (spin ? 2 : 1), + kb: st.kb, col: wdef(w.id).col + })); + } + Snd.play('shoot'); + }, + + _fireLight(G, w, st) { + const P = G.player; + const cands = G.enemies.filter(e => dist2(P.x, P.y, e.x, e.y) < 680 * 680); + if (!cands.length) { w.timer = 0.25; return; } // retry soon, don't waste cd + const n = Math.min(st.n, cands.length); + for (let i = 0; i < n; i++) { + const t = pick(cands); + this._strikeLightning(G, t, st); + } + }, + + _strikeLightning(G, target, st, dmgMul) { + const P = G.player; + const mul = dmgMul || 1; + const R = st.blastR * st.area * P.areaMul; + G.effects.push(new Effect('bolt', target.x, target.y - 320, 0.28, { ty: target.y, col: '#dffcff' })); + for (const e of G.enemies) { + if (dist2(target.x, target.y, e.x, e.y) < (R + e.r) * (R + e.r)) { + this.hitEnemy(G, e, st.dmg * P.might * mul, { kb: { x: 0, y: -1, f: 60 } }); + if (st.stun && !e.dead) e.stunT = Math.max(e.stunT, st.stun); + } + } + G.effects.push(new Effect('ring', target.x, target.y, 0.3, { r: R, col: '#9bf6ff' })); + Snd.play('zap'); + + // evolution chain + if (st.chain) { + let from = target, hops = st.chain; + const chained = new Set([target.id]); + while (hops-- > 0) { + let best = null, bd = 220 * 220; + for (const e of G.enemies) { + if (chained.has(e.id)) continue; + const d2 = dist2(from.x, from.y, e.x, e.y); + if (d2 < bd) { bd = d2; best = e; } + } + if (!best) break; + chained.add(best.id); + G.effects.push(new Effect('arc', from.x, from.y, 0.2, { tx: best.x, ty: best.y, col: '#9bf6ff' })); + this.hitEnemy(G, best, st.dmg * P.might * 0.65, {}); + if (st.stun && !best.dead) best.stunT = Math.max(best.stunT, st.stun * 0.8); + from = best; + } + } + }, + + _fireFire(G, w, st) { + const P = G.player; + let firstDir = null; + for (let i = 0; i < st.n; i++) { + const dir = this._aimDirRandomEnemy(G); + if (!firstDir) { + firstDir = dir; + this._muzzle(G, P.x + dir.x * 15, P.y + dir.y * 15, Math.atan2(dir.y, dir.x)); + } + G.projs.push(new Proj({ + kind: w.id, mode: 'straight', + x: P.x, y: P.y, + vx: dir.x * st.spd * P.pspdMul, vy: dir.y * st.spd * P.pspdMul, + dmg: st.dmg * P.might, pierce: 0, r: 9 * st.area * P.areaMul, + life: st.dur ? st.dur * P.durMul : 1.6, kb: st.kb, col: wdef(w.id).col, + boom: { + r: st.blastR * st.area * P.areaMul, dmg: st.dmg * P.might, + burnDps: (st.burnDps || 0) * P.might, burnDur: st.burnDur || 0, + groundDps: st.groundDps || 0, groundDur: st.groundDur || 0 + } + })); + } + Snd.play('shoot'); + }, + + _fireFrost(G, w, st) { + const P = G.player; + let firstDir = null; + for (let i = 0; i < st.n; i++) { + const dir = this._aimDirRandomEnemy(G); + if (!firstDir) { + firstDir = dir; + this._muzzle(G, P.x + dir.x * 15, P.y + dir.y * 15, Math.atan2(dir.y, dir.x)); + } + const jitter = (i - (st.n - 1) / 2) * 0.15; + const ca = Math.cos(jitter), sa = Math.sin(jitter); + const dx = dir.x * ca - dir.y * sa, dy = dir.x * sa + dir.y * ca; + G.projs.push(new Proj({ + kind: w.id, mode: 'straight', + x: P.x, y: P.y, vx: dx * st.spd * P.pspdMul, vy: dy * st.spd * P.pspdMul, + dmg: st.dmg * P.might, pierce: st.pierce, r: 6 * st.area * P.areaMul, + life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col, + slow: st.slow, slowDur: st.slowDur * P.durMul, freeze: st.freeze || 0 + })); + } + Snd.play('freeze'); + }, + + _fireCross(G, w, st) { + const P = G.player; + for (let i = 0; i < st.n; i++) { + const dir = this._aimDirRandomEnemy(G); + G.projs.push(new Proj({ + kind: w.id, mode: 'boomerang', + x: P.x, y: P.y, ox: P.x, oy: P.y, + dx: dir.x, dy: dir.y, + spd: st.spd * P.pspdMul, + dmg: st.dmg * P.might, pierce: st.pierce, r: 14 * st.area * P.areaMul, + life: st.dur * P.durMul, rot: 0, spin: 10, kb: st.kb, + rehit: st.rehit || 0.4, col: wdef(w.id).col + })); + } + Snd.play('shoot'); + }, + + /** Timed mines: dropped at the player's feet, detonate on contact or expiry. */ + _fireMine(G, w, st) { + const P = G.player; + for (let i = 0; i < st.n; i++) { + G.projs.push(new Proj({ + kind: 'mine', mode: 'straight', + x: P.x + rand(-30, 30), y: P.y + rand(-20, 20), + vx: 0, vy: 0, + r: 8 * st.area * P.areaMul, + dmg: st.dmg * P.might, pierce: 0, + life: st.dur * P.durMul, rot: 0, spin: 1.2, kb: st.kb, + boom: { r: st.blastR * st.area * P.areaMul, dmg: st.dmg * P.might }, + col: wdef(w.id).col + })); + } + }, + + /* ================= PROJECTILES ================= */ + + updProjectiles(G, dt) { + const P = G.player; + const near = []; + for (let i = G.projs.length - 1; i >= 0; i--) { + const p = G.projs[i]; + p.t += dt; + + if (p.mode === 'orbit') { + p.ang += p.av * dt; + p.x = P.x + Math.cos(p.ang) * p.orbR; + p.y = P.y + Math.sin(p.ang) * p.orbR; + G.grid.query(p.x, p.y, p.r + 30, near); + for (const e of near) { + const rr = p.r + e.r; + if (dist2(p.x, p.y, e.x, e.y) < rr * rr) { + const lt = p.hitTimes.get(e.id) || -99; + if (G.time - lt >= p.hitCd) { + p.hitTimes.set(e.id, G.time); + this.hitEnemy(G, e, p.dmg, { kb: { x: e.x - P.x, y: e.y - P.y, f: p.kb } }); + } + } + } + continue; + } + + if (p.mode === 'boomerang') { + const phase = p.t / (p.maxLife || 1); + const v = Math.cos(Math.PI * clamp(phase, 0, 1)) * p.spd; + p.vx = p.dx * v; p.vy = p.dy * v; + p.x += p.vx * dt; p.y += p.vy * dt; + p.rot += p.spin * dt; + } else { + if (p.grav) p.vy += p.grav * dt; + if (p.mode === 'spiral') { + // evolved axe: sweeping horizontal arcs while falling + if (p.v0x === undefined) p.v0x = Math.abs(p.vx) + 60; + p.vx = Math.cos(p.t * 5) * p.v0x; + p.vy += p.grav * dt * 0.35; + } + p.x += p.vx * dt; p.y += p.vy * dt; + if (p.spin) p.rot += p.spin * dt; + } + + p.life -= dt; + + // collision + G.grid.query(p.x, p.y, p.r + 34, near); + for (const e of near) { + const key = p.rehit !== undefined ? p.rehit : -1; + const rr = p.r + e.r; + if (dist2(p.x, p.y, e.x, e.y) >= rr * rr) continue; + if (p.mode === 'boomerang') { + const lt = p.hitTimes.get(e.id) || -99; + if (G.time - lt < p.rehit) continue; + p.hitTimes.set(e.id, G.time); + } else { + if (p.hits.has(e.id)) continue; + p.hits.add(e.id); + } + this.hitEnemy(G, e, p.dmg, { kb: { x: p.vx, y: p.vy, f: p.kb } }); + if (p.slow) { e.slowT = Math.max(e.slowT, p.slowDur); e.slowF = p.slow; } + if (p.freeze) { e.freezeT = Math.max(e.freezeT, p.freeze); Snd.play('freeze'); G.effects.push(new Effect('nova', e.x, e.y, .3, { r: e.r + 12, col: '#cdf5ff' })); } + if (p.boom) { this._detonate(G, p); break; } + if (p.pierce-- <= 0) { p.life = -1; break; } + } + + if (p.life <= 0 || p.y > P.y + 700) { + if (p.boom && p.life <= 0) this._detonate(G, p); + removeItem(G.projs, i); + } + } + }, + + _detonate(G, p) { + if (p.boomed) return; + p.boomed = true; + this.explode(G, p.x, p.y, p.boom.r, p.boom.dmg, '#ff8c42', p.boom); + p.life = -1; + }, + + /** AoE explosion with optional burn + burning ground. */ + explode(G, x, y, r, dmg, col, boomData) { + G.effects.push(new Effect('explosion', x, y, 0.4, { r, col: col || '#ff8c42' })); + for (const e of G.enemies) { + if (dist2(x, y, e.x, e.y) < (r + e.r) * (r + e.r)) { + this.hitEnemy(G, e, dmg, { kb: { x: e.x - x, y: e.y - y, f: 160 } }); + if (boomData && boomData.burnDps && !e.dead) { e.burnT = boomData.burnDur; e.burnDps = boomData.burnDps; } + } + } + if (boomData && boomData.groundDps > 0) { + G.effects.push(new Effect('zone', x, y, (boomData.groundDur || 3) , { + r: r * 0.85, dps: boomData.groundDps, tickT: 0, col: '#ff6b35' + })); + } + Snd.play('boom'); + G.shakeIt(3); + }, + + /* ================= EFFECTS (incl. damaging zones/shockwaves) ================= */ + + updEffects(G, dt) { + const P = G.player; + for (let i = G.effects.length - 1; i >= 0; i--) { + const ef = G.effects[i]; + ef.t += dt; + + if (ef.type === 'zone') { + ef.data.tickT -= dt; + if (ef.data.tickT <= 0) { + ef.data.tickT = 0.3; + for (const e of G.enemies) { + if (dist2(ef.x, ef.y, e.x, e.y) < (ef.data.r + e.r) * (ef.data.r + e.r)) { + this.hitEnemy(G, e, ef.data.dps * 0.3, { silent: true }); + if (!e.dead && chance(0.3)) { e.burnT = 1; e.burnDps = ef.data.dps * 0.5; } + } + } + } + } + + if (ef.type === 'shock') { + const prog = ef.t / ef.dur; + ef.data.r = ef.data.maxR * prog; + const d = Math.sqrt(dist2(ef.x, ef.y, P.x, P.y)); + if (Math.abs(d - ef.data.r) < 18 && !ef.data.hit.has('P')) { + ef.data.hit.add('P'); + this.hurtPlayer(G, ef.data.dmg); + } + } + + if (ef.t >= ef.dur) removeItem(G.effects, i); + } + }, + + /* ================= ENEMY BULLETS ================= */ + + updEBullets(G, dt) { + const P = G.player; + for (let i = G.ebullets.length - 1; i >= 0; i--) { + const b = G.ebullets[i]; + b.x += b.vx * dt; b.y += b.vy * dt; + b.life -= dt; + const rr = b.r + P.r; + if (dist2(b.x, b.y, P.x, P.y) < rr * rr) { + this.hurtPlayer(G, b.dmg); + removeItem(G.ebullets, i); + continue; + } + if (b.life <= 0) removeItem(G.ebullets, i); + } + }, + + /* ================= DAMAGE ================= */ + + hitEnemy(G, e, rawDmg, opts) { + opts = opts || {}; + const P = G.player; + let dmg = rawDmg; + if (G.devOneHit) dmg = Math.max(dmg, 1e7); + let crit = false; + if (!opts.silent && chance(P.critC)) { dmg *= P.critM; crit = true; } + + // frost evolution shatter bonus + if (e.freezeT > 0) dmg *= 1.3; + + e.hp -= dmg; + e.flash = crit ? 0.14 : 0.09; + if (opts.lifesteal) { + P.hp = Math.min(P.maxhp, P.hp + dmg * opts.lifesteal); + } + if (opts.kb && opts.kb.f && !(e.def.kbRes) && !e.boss) { + const d = Math.max(1, len2(opts.kb.x, opts.kb.y)); + const res = e.def.kbRes ? 1 - e.def.kbRes : 1; + e.kbx += (opts.kb.x / d) * opts.kb.f * 10 * res; + e.kby += (opts.kb.y / d) * opts.kb.f * 10 * res; + } + if (!opts.silent) { + Snd.play('hit'); + if (crit) { + Snd.play('crit'); + this.shakeFeedback(G, 1.2); + // impact ring at contact point + if (G.effects.length < 80) { + const kx = opts.kb && opts.kb.f ? -(opts.kb.x / Math.max(1, len2(opts.kb.x, opts.kb.y))) : rand(-1, 1); + const ky = opts.kb && opts.kb.f ? -(opts.kb.y / Math.max(1, len2(opts.kb.x, opts.kb.y))) : rand(-1, 1); + G.effects.push(new Effect('impact', e.x - kx * e.r * .4, e.y - ky * e.r * .4, 0.18, + { r: 8 + Math.min(14, dmg / 40), ang: Math.atan2(ky, kx), col: '#ffd24a' })); + } + } + if (Store.s().dmgNum) { + G.texts.push(new FText(e.x, e.y - e.r - 4, String(Math.round(dmg)), + crit ? '#ffd24a' : '#ffffff', crit, + { life: crit ? 0.9 : 0.7, pop: crit ? 2.0 : 1.15 })); + } + // directional sparks along the impact axis + if (G.parts.length < G.partCap) { + let sx = rand(-1, 1), sy = rand(-1, 1); + if (opts.kb && opts.kb.f) { + const dl = Math.max(1, len2(opts.kb.x, opts.kb.y)); + sx = -opts.kb.x / dl; sy = -opts.kb.y / dl; // sparks fly back toward the shooter + } + const baseA = Math.atan2(sy, sx); + const cnt = crit ? 6 : 3; + for (let k = 0; k < cnt; k++) { + const a = baseA + rand(-.7, .7); + const sp = rand(90, 260); + part(G, e.x - sx * e.r * .5, e.y - sy * e.r * .5, + crit ? '#ffe066' : (chance(.5) ? '#ffffff' : e.col), + { ang: a, sp, life: rand(.16, .32), size: crit ? 3.4 : 2.4, drag: .88 }); + } + } + } + if (e.hp <= 0) this.killEnemy(G, e); + }, + + shakeFeedback(G, amt) { if (G.shakeIt) G.shakeIt(amt); }, + + damageEnemy(G, e, dmg, opts) { this.hitEnemy(G, e, dmg, Object.assign({ silent: true }, opts)); }, + + killEnemy(G, e) { + if (e.dead) return; + e.dead = true; + const P = G.player; + P.kills++; + + // garlic evolution heals on kill + if (P.weaponById('e_garlic')) P.hp = Math.min(P.maxhp, P.hp + 1); + + // ---- breakable props: coins + maybe chicken, no XP ---- + if (e.def.prop) { + Store.data.totals.props = (Store.data.totals.props || 0) + 1; // achievement counter + const gm = G.goldMul || 1; + G.effects.push(new Effect('nova', e.x, e.y, 0.22, { r: e.r * 2.2, col: '#8fdcff' })); + for (let k = 0; k < 8; k++) + part(G, e.x, e.y, chance(.5) ? '#bfeaff' : '#ffffff', { sp: rand(60, 220), life: rand(.25, .5), size: 2.6 }); + const n = randi(3, 6); + for (let i = 0; i < n; i++) + G.pickups.push(new Pickup('coin', e.x + rand(-26, 26), e.y + rand(-26, 26), Math.max(1, Math.round(randi(2, 5) * gm)))); + if (chance(0.18)) G.pickups.push(new Pickup('chicken', e.x, e.y)); + Snd.play('boom'); + this.shakeFeedback(G, 1); + return; + } + + // ---- death juice ---- + Snd.play('kill'); + const big = e.elite || e.boss; + G.effects.push(new Effect('nova', e.x, e.y, big ? 0.34 : 0.2, + { r: e.r * (big ? 3.2 : 1.9), col: big ? '#ffd24a' : '#ffffff' })); + const n = big ? 22 : Math.min(10, 4 + ((e.r / 4) | 0)); + for (let k = 0; k < n; k++) + part(G, e.x, e.y, chance(.35) ? '#ffffff' : e.col, + { sp: rand(big ? 90 : 50, big ? 320 : 210), life: rand(.3, .6), size: big ? 3.5 : 2.6, big }); + if (big) { + G.freezeT = Math.max(G.freezeT || 0, e.boss ? 0.26 : 0.12); // hit-stop + this.shakeFeedback(G, e.boss ? 6 : 2.5); + if (Store.s().dmgNum) + G.texts.push(new FText(e.x, e.y - e.r - 14, e.boss ? '☠☠☠' : '★ ★ ★', '#ffd24a', true, + { life: 1, vy: 20, pop: 2.2 })); + } else { + this.shakeFeedback(G, 0.35); + } + + // volatile elites detonate on death — keep your distance + if (e.blastOnDeath) { + G.effects.push(new Effect('nova', e.x, e.y, 0.3, { r: 130, col: '#ff8c42' })); + Snd.play('boom'); + this.shakeFeedback(G, 3); + if (!G.over && dist2(P.x, P.y, e.x, e.y) < 115 * 115) { + this.hurtPlayer(G, Math.round(10 + (G.time / 60) * 1.5)); + } + } + + // drops + const gm = G.goldMul || 1; + G.pickups.push(new Pickup('gem', e.x, e.y, e.xpVal)); + const luck = P.luck; + if (e.boss) { + G.pickups.push(new Pickup('chest', e.x + 14, e.y)); + for (let i = 0; i < 8; i++) G.pickups.push(new Pickup('coin', e.x + rand(-40, 40), e.y + rand(-40, 40), Math.max(1, Math.round(randi(4, 9) * gm)))); + G.pickups.push(new Pickup('chicken', e.x - 30, e.y)); + G.onBossDead(e); + } else if (e.elite) { + G.pickups.push(new Pickup('chest', e.x, e.y)); + for (let i = 0; i < 3; i++) G.pickups.push(new Pickup('coin', e.x + rand(-24, 24), e.y + rand(-24, 24), Math.max(1, Math.round(randi(2, 5) * gm)))); + } else { + const roll = Math.random(); + const coinP = 0.07 + luck * 0.12; + const chickP = coinP + 0.008 + luck * 0.01; + const magP = chickP + 0.002 + luck * 0.004; + const bombP = magP + 0.0015 + luck * 0.003; + if (roll < coinP) G.pickups.push(new Pickup('coin', e.x, e.y, Math.max(1, Math.round(randi(1, 3) * gm)))); + else if (roll < chickP) G.pickups.push(new Pickup('chicken', e.x, e.y)); + else if (roll < magP) G.pickups.push(new Pickup('magnet', e.x, e.y)); + else if (roll < bombP) G.pickups.push(new Pickup('bomb', e.x, e.y)); + } + + // merge gems when too many + if (G.pickups.length > 320) { + let oldest = null; + for (const pk of G.pickups) if (pk.type === 'gem' && (!oldest || pk.t < oldest.t)) oldest = pk; + if (oldest) { + const gem = G.pickups[G.pickups.length - 1]; + if (gem !== oldest && gem.type === 'gem') { gem.v += oldest.v; oldest.dead = true; } + } + G.pickups = G.pickups.filter(pk => !pk.dead); + } + + Snd.play('hit'); + }, + + hurtPlayer(G, amount) { + const P = G.player; + if (G.devGod || P.invuln > 0 || G.over) return; + const dmg = Math.max(1, amount - P.armor); + P.hp -= dmg; + P.invuln = 0.4; + G.hurtFlash = 0.25; + G.shakeIt(4); + Snd.play('hurt', true); + G.effects.push(new Effect('nova', P.x, P.y, 0.28, { r: 34, col: '#ff5f7a' })); + for (let k = 0; k < 6; k++) + part(G, P.x, P.y, '#ff5f7a', { sp: rand(60, 190), life: .3 }); + if (Store.s().dmgNum) + G.texts.push(new FText(P.x, P.y - 18, '-' + Math.round(dmg), '#ff5f7a', true, + { life: .8, pop: 1.6 })); + if (P.hp <= 0) G.onPlayerDown(); + }, + + /* ================= PICKUPS ================= */ + + updPickups(G, dt) { + const P = G.player; + const mag2 = P.magR * P.magR; + for (let i = G.pickups.length - 1; i >= 0; i--) { + const pk = G.pickups[i]; + pk.t += dt; + const d2 = dist2(pk.x, pk.y, P.x, P.y); + + if (pk.pull || d2 < mag2) { + const d = Math.sqrt(Math.max(1, d2)); + const pullSpd = pk.pull ? 620 : clamp(360 - d * 0.4, 90, 360); + pk.x += (P.x - pk.x) / d * pullSpd * dt; + pk.y += (P.y - pk.y) / d * pullSpd * dt; + } + + const touchR = pk.r + P.r + 4; + if (d2 < touchR * touchR) { + this.collect(G, pk); + removeItem(G.pickups, i); + } + } + }, + + collect(G, pk) { + const P = G.player; + switch (pk.type) { + case 'gem': { + const amt = Math.max(1, Math.round(pk.v * (1 + P.growth))); + const ups = P.addXp(amt); + if (ups > 0) { + G.queueLevelUps += ups; + // level-up burst around the player + G.effects.push(new Effect('nova', P.x, P.y, 0.5, { r: 90, col: '#ffe066' })); + G.effects.push(new Effect('ring', P.x, P.y, 0.45, { r: 120, col: '#fff3b0' })); + for (let k = 0; k < 14; k++) + part(G, P.x, P.y, chance(.5) ? '#ffe066' : '#7dfaff', + { sp: rand(70, 240), life: rand(.35, .6), size: 3 }); + Snd.play('levelup'); + } + Snd.play('xp'); + break; + } + case 'coin': { + const g = Math.max(1, Math.round(pk.v * (1 + P.greed))); + P.gold += g; G.goldEarned += g; + Snd.play('coin'); + if (Store.s().dmgNum && chance(.5)) + G.texts.push(new FText(pk.x, pk.y - 10, '+' + g, '#ffd24a', false, { life: .55, vy: 42 })); + break; + } + case 'chicken': + P.hp = Math.min(P.maxhp, P.hp + P.maxhp * 0.3); + Snd.play('heal'); + if (Store.s().dmgNum) G.texts.push(new FText(P.x, P.y - 18, '+HP', '#7dff9e')); + break; + case 'magnet': + for (const q of G.pickups) if (q.type === 'gem') q.pull = true; + Snd.play('heal'); + break; + case 'bomb': { + G.effects.push(new Effect('nova', P.x, P.y, 0.5, { r: 640, col: '#ffd24a' })); + for (const e of G.enemies) { + if (dist2(P.x, P.y, e.x, e.y) < 640 * 640) { + this.damageEnemy(G, e, e.boss ? 350 : 9999, {}); + } + } + Snd.play('boom'); + G.shakeIt(8); + break; + } + case 'chest': + G.pendingChests++; + break; + } + }, + + /* ================= LEVEL UP CHOICES ================= */ + + makeChoices(G) { + const P = G.player; + const pool = []; + + for (const w of P.weapons) { + if (EVOLVED[w.id]) continue; + if (w.lvl < WEAPONS[w.id].max) pool.push({ type: 'w', id: w.id, wt: 2 }); + } + if (P.weapons.length < 6) { + for (const id in WEAPONS) if (P.canOfferWeapon(id)) pool.push({ type: 'nw', id, wt: 1.2 }); + } + for (const id in PASSIVES) if (P.canOfferPassive(id)) pool.push({ type: 'p', id, wt: 1.4 }); + + if (!pool.length) { + return [ + { type: 'heal', label: '🍗', col: '#7dff9e' }, + { type: 'gold', label: '🪙', col: '#ffd24a' } + ]; + } + + const out = []; + const used = new Set(); + const want = P.level % 7 === 0 ? 4 : 3; + while (out.length < want && used.size < pool.length) { + const c = weightedPick(pool.filter(x => !used.has(x)), x => x.wt); + used.add(c); + out.push(c); + } + return out; + }, + + describeChoice(c) { + const P = GAME ? GAME.player : null; + if (c.type === 'w' || c.type === 'nw') { + const isEvolvedBase = c.type === 'nw'; + const curLvl = c.type === 'w' ? GAME.player.weaponById(c.id).lvl : 0; + const nextLvl = curLvl + 1; + const nameKey = EVOLVED[c.id] ? c.id : 'w_' + c.id; // base weapons use w_ prefix in i18n + const sym = wdef(c.id).sym; + const col = wdef(c.id).col; + let title = tr(nameKey); + let desc; + if (isEvolvedBase) desc = tr(nameKey + '_d'); + else { + const cur = wstats(c.id, curLvl), nxt = wstats(c.id, nextLvl); + const parts = []; + if (nxt.dmg > cur.dmg) parts.push(tr('up_dmg', { n: Math.round(nxt.dmg - cur.dmg) })); + if ((nxt.n || 0) > (cur.n || 0)) parts.push(tr('up_n', { n: nxt.n - cur.n })); + if (nxt.cd < cur.cd) parts.push(tr('up_cd', { n: (cur.cd - nxt.cd).toFixed(2) })); + if ((nxt.pierce || 0) > (cur.pierce || 0)) parts.push(tr('up_pierce', { n: nxt.pierce - cur.pierce })); + if ((nxt.spd || 0) > (cur.spd || 0) && c.id !== 'orb' && c.id !== 'e_orb') parts.push(tr('up_spd')); + if ((nxt.area || 0) > (cur.area || 0)) parts.push(tr('up_area')); + if ((nxt.slow || 0) > (cur.slow || 0)) parts.push('+slow'); + if (!parts.length) parts.push(tr('up_misc')); + title += ' Lv.' + nextLvl; + desc = parts.join(' · '); + } + return { title, desc, sym, col, isNew: isEvolvedBase }; + } + if (c.type === 'p') { + const cur = (GAME.player.passives[c.id] || 0) + 1; + return { + title: tr(c.id) + (cur > 1 ? ' Lv.' + cur : ''), + desc: tr(c.id + '_d'), + sym: PASSIVES[c.id].sym, col: PASSIVES[c.id].col, + isNew: cur === 1 + }; + } + if (c.type === 'heal') return { title: '🍗 ' + tr('p_regen'), desc: '+40 HP', sym: '🍗', col: '#7dff9e' }; + return { title: '🪙 ' + tr('ms_greed'), desc: '+25 🪙', sym: '🪙', col: '#ffd24a' }; + }, + + applyChoice(G, c) { + const P = G.player; + switch (c.type) { + case 'w': P.weaponById(c.id).lvl++; break; + case 'nw': P.weapons.push({ id: c.id, lvl: 1, timer: 0.3 }); break; + case 'p': + P.passives[c.id] = (P.passives[c.id] || 0) + 1; + P.recompute(); + break; + case 'heal': P.hp = Math.min(P.maxhp, P.hp + 40); break; + case 'gold': P.gold += 25; G.goldEarned += 25; break; + } + Snd.play('levelup'); + }, + + /** Resolve a chest: prefer evolution. Returns modal data. */ + chestReward(G) { + const P = G.player; + // evolution candidates + const evoReady = []; + for (const w of P.weapons) { + if (EVOLVED[w.id]) continue; + const def = WEAPONS[w.id]; + if (w.lvl >= def.max && P.passives[def.evo.need] && !P.weaponById(def.evo.into)) { + evoReady.push(w); + } + } + if (evoReady.length) { + const w = pick(evoReady); + const fromId = w.id, intoId = WEAPONS[fromId].evo.into; + w.id = intoId; w.lvl = 1; w.timer = 0.2; + G.projs = G.projs.filter(p => p.wid !== fromId); + const goldBonus = 50; + P.gold += goldBonus; G.goldEarned += goldBonus; + Store.data.totals.evos = (Store.data.totals.evos || 0) + 1; // achievement counter + Snd.play('evolve', true); + G.shakeIt(6); + return { evolve: true, from: fromId, into: intoId, gold: goldBonus, lines: [] }; + } + // fallback: 1-3 upgrade levels + gold + const pool = []; + for (const w of P.weapons) if (!EVOLVED[w.id] && w.lvl < WEAPONS[w.id].max) pool.push({ type: 'w', id: w.id }); + for (const id in PASSIVES) if (P.canOfferPassive(id)) pool.push({ type: 'p', id }); + const lines = []; + const nGain = 1 + ((chance(0.3 + P.luck * 0.2)) ? 1 : 0) + ((chance(0.12 + P.luck * 0.2)) ? 1 : 0); + if (pool.length) { + for (const c of sampleN(pool, nGain)) { + this.applyChoice(G, c); + const d = this.describeChoice(c); + lines.push(d.title); + } + } + const goldBonus = 25 + Math.round(rand(0, 20) * (1 + P.luck)); + P.gold += goldBonus; G.goldEarned += goldBonus; + Snd.play('chest', true); + return { evolve: false, gold: goldBonus, lines }; + }, + + /** Music intensity from threat level. */ + musicIntensity(G) { + let near = 0; + for (const e of G.enemies) { + if (dist2(G.player.x, G.player.y, e.x, e.y) < 420 * 420) near++; + } + let v = clamp(near / 40, 0, 0.7) + clamp(G.time / ((G.stage && G.stage.length) || RUN_LENGTH), 0, 0.2); + if (G.boss && !G.boss.dead) v = Math.max(v, 0.85); + return clamp(v, 0.15, 1); + } +}; diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..b171687 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,1109 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — ui.js : DOM overlay management + ============================================================ */ + +const UI = { + G: null, + els: {}, + _slotSig: '', + _charSel: 'kaito', + _codexTab: 'weapons', + _resetArm: false, + + $(id) { return document.getElementById(id); }, + + init(game) { + this.G = game; + const ids = ['fpsMeter','scr-loading','scr-menu','scr-chars','scr-stage','scr-history','scr-shop','scr-codex','scr-options', + 'hud','md-pause','md-level','md-chest','md-over','md-dev','devPanel','devBtn','toasts', + 'xpFill','lvlTag','hudPortrait','portraitSym','hudLevel','hudTimer','hudGold','hudKills', + 'bossBar','bossName','bossFill','slotWeapons','slotPassives', + 'charGrid','stageGrid','stageOpts','bt-stage-go','shopGrid','codexList','codexTabs','optPanel', + 'levelChoices','bt-reroll','bt-skipLv','chestArt','chestTitle','chestBody','bt-chestOk', + 'overTitle','overRecord','overStats','pauseStats', + 'menuGold','menuKills','menuBest', + 'histSummary','histList','md-save','saveArea','tutTip','tutText', + 'md-lb','lbScopes','lbList']; + for (const id of ids) this.els[id] = this.$(id); + + this._bindButtons(); + this._buildStartBar(); + this.syncLang(); + }, + + /* ================= language ================= */ + syncLang() { + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = tr(el.getAttribute('data-i18n')); + }); + document.documentElement.lang = currentLang(); + // dynamic screens re-render + this.buildShop(); + this.buildChars(); + this.buildCodex(this._codexTab); + this.buildOptions(); + this.refreshMenuStats(); + this._slotSig = ''; // force slot rebuild (names are icons though) + }, + + /* ================= screens ================= */ + showScreen(name) { + for (const id of ['scr-loading','scr-menu','scr-chars','scr-stage','scr-history','scr-shop','scr-codex','scr-options']) + this.els[id].classList.add('hidden'); + for (const id of ['md-pause','md-level','md-chest','md-over']) + this.els[id].classList.add('hidden'); + if (name && this.els[name]) this.els[name].classList.remove('hidden'); + if (name === 'scr-menu') { + this.els.hud.classList.add('hidden'); + this.hideBossBar(); + this.refreshMenuStats(); + // restart entrance animations + const scr = this.els['scr-menu']; + scr.classList.remove('enter'); + void scr.offsetWidth; + scr.classList.add('enter'); + } + }, + + showModal(id) { this.els[id] && this.els[id].classList.remove('hidden'); }, + hideModal(id) { this.els[id] && this.els[id].classList.add('hidden'); }, + + refreshMenuStats() { + if (!this.els.menuGold) return; + this.els.menuGold.textContent = fmtNum(Store.data.gold); + this.els.menuKills.textContent = fmtNum(Store.data.totals.kills); + this.els.menuBest.textContent = fmtTime(Store.data.totals.best); + // daily button shows today's best when one exists + const bd = this.$('bt-daily'); + if (bd) { + const d = Store.data.daily; + let html = '' + tr('btn_daily') + ''; + if (d.date === dailyKey() && d.best > 0) + html += '🏆 ' + fmtTime(d.best) + ''; + bd.innerHTML = html; + } + this.checkAndToastAch(); + }, + + /** Evaluate achievements; pop a gold toast + jingle per new unlock (+100🪙 each). */ + checkAndToastAch() { + if (typeof checkAchievements !== 'function') return; + const newly = checkAchievements(); + if (!newly.length) return; + Store.addGold(100 * newly.length); // unlock reward + const en = currentLang() === 'en'; + const a = newly[0]; + let msg = '🏆 ' + (en ? a.en : a.vi) + ' — ' + (en ? a.den : a.dvi) + ' (+100🪙)'; + if (newly.length > 1) msg += ` +${newly.length - 1} more`; + this.toast(msg, 'gold'); + Snd.play('chest'); + }, + + /* ================= run history ================= */ + buildHistory() { + const sum = this.els.histSummary, list = this.els.histList; + if (!list) return; + const T = Store.data.totals, H = Store.data.history || []; + const wins = H.filter(r => r.w).length; + const avg = H.length ? H.reduce((a, r) => a + (r.t || 0), 0) / H.length : 0; + // favorite character + const freq = {}; + let fav = '—'; + for (const r of H) { freq[r.ch] = (freq[r.ch] || 0) + 1; if (freq[r.ch] > (freq[fav] || 0)) fav = r.ch; } + const en = currentLang() === 'en'; + sum.innerHTML = + '' + tr('hist_run') + ': ' + H.length + '' + + '👑 ' + wins + '' + + '⏱ ~' + fmtTime(avg) + '' + + '' + (fav !== '—' && CHARS[fav] ? CHARS[fav].sym + ' ' + tr('c_' + fav) + '' : '—') + ''; + list.innerHTML = ''; + if (!H.length) { + const p = document.createElement('p'); + p.className = 'hist-empty'; + p.textContent = tr('hist_empty'); + list.appendChild(p); + return; + } + for (const r of H) { + const row = document.createElement('div'); + row.className = 'hist-row' + (r.w ? ' win' : ''); + const ch = CHARS[r.ch], st = STAGES[r.st] || STAGES.graveyard; + const date = new Date(r.d).toLocaleDateString(); + row.innerHTML = + '' + (ch ? ch.sym : '❔') + '' + + '' + st.icon + '' + + '' + date + '' + + '' + fmtTime(r.t) + '' + + '💀' + fmtNum(r.k) + ' · 🪙' + fmtNum(r.gold) + ' · Lv' + r.lv + '' + + (r.g ? '' + 'I'.repeat(r.g + 1) + '' : '') + + '' + (r.w ? '👑 ' + tr('hist_win') : '') + ''; + list.appendChild(row); + } + }, + + /* ================= leaderboard ================= */ + openBoard(scope) { + const url = (Store.s().lbUrl || '').trim(); + const listEl = this.els.lbList; + if (!listEl) return; + // scope chips: today + one per unlocked stage + const scopes = this.els.lbScopes || { innerHTML: '' }; + scopes.innerHTML = ''; + const mkChip = (label, sc) => { + const b = document.createElement('button'); + b.className = 'btn btn-mini' + (sc === scope ? ' active' : ''); + b.textContent = label; + b.addEventListener('click', () => { Snd.play('click'); this.openBoard(sc); }); + scopes.appendChild(b); + }; + mkChip(tr('lb_scope_daily'), 'daily:' + dailyKey()); + for (const id in STAGES) { + mkChip(STAGES[id].icon + ' ' + tr('st_' + id), 'stage:' + id + ':0'); + } + this.showModal('md-lb'); + + if (!url) { + listEl.innerHTML = '

' + tr('lb_off') + '

'; + return; + } + listEl.innerHTML = '

'; + fetch(url.replace(/\/$/, '') + '/top?scope=' + encodeURIComponent(scope) + '&n=10') + .then(r => r.json()) + .then(j => { + const rows = (j && j.rows) || []; + if (!rows.length) { listEl.innerHTML = '

' + tr('lb_empty') + '

'; return; } + listEl.innerHTML = rows.map((r, i) => + '
#' + (i + 1) + '' + + '' + String(r.n).replace(/[<>&]/g, '') + '' + + '' + fmtTime(r.t) + '' + + '💀' + fmtNum(r.k) + '
' + ).join(''); + }) + .catch(() => { listEl.innerHTML = '

⚠️

'; }); + }, + + /* ================= save transfer ================= */ + openSaveModal() { + const payload = 'NSV1.' + btoa(unescape(encodeURIComponent(JSON.stringify(Store.data)))); + this.els.saveArea.value = payload; + this.showModal('md-save'); + }, + + importSave() { + const raw = (this.els.saveArea.value || '').trim(); + try { + const b64 = raw.startsWith('NSV1.') ? raw.slice(5) : raw; + const json = decodeURIComponent(escape(atob(b64))); + const obj = JSON.parse(json); + if (!obj || !obj.settings || !obj.totals) throw new Error('bad'); + localStorage.setItem(SAVE_KEY, JSON.stringify(obj)); + this.toast(tr('save_ok'), 'gold'); + setTimeout(() => location.reload(), 900); + } catch (e) { + this.toast(tr('save_bad'), 'warn'); + } + }, + + /* ================= tutorial bubble ================= */ + showTut(key) { + if (!this.els.tutTip) return; + this.els.tutText.textContent = tr(key); + this.els.tutTip.classList.remove('hidden'); + }, + hideTut() { + if (this.els.tutTip) this.els.tutTip.classList.add('hidden'); + }, + + /* ================= toasts ================= */ + toast(msg, cls) { + const t = document.createElement('div'); + t.className = 'toast' + (cls ? ' ' + cls : ''); + t.textContent = msg; + this.els.toasts.appendChild(t); + setTimeout(() => t.remove(), 2600); + }, + + /* ================= HUD ================= */ + showHud(v) { this.els.hud.classList.toggle('hidden', !v); }, + + showBossBar(b) { + this.els.bossBar.classList.remove('hidden'); + this.els.bossName.textContent = tr(b.nk); + }, + hideBossBar() { this.els.bossBar.classList.add('hidden'); }, + + hudUpdate(G, dt) { + const P = G.player; + const s = Store.s(); + + this.els.xpFill.style.width = clamp(P.xp / P.xpNext * 100, 0, 100) + '%'; + this.els.lvlTag.textContent = 'LV ' + P.level + (G.grade ? ' · ' + 'I'.repeat(G.grade + 1) : ''); + // endless mode marks the overtime clock + if (G.endless && G.stage && G.time > G.stage.length) { + this.els.hudTimer.innerHTML = '∞ ' + fmtTime(G.time) + ''; + this.els.hudTimer.classList.add('endless'); + } else { + this.els.hudTimer.textContent = fmtTime(G.time); + this.els.hudTimer.classList.remove('endless'); + } + this.els.hudGold.textContent = '🪙 ' + fmtNum(P.gold); + this.els.hudKills.textContent = '💀 ' + fmtNum(P.kills); + + if (G.boss && !G.boss.dead) { + this.els.bossFill.style.width = clamp(G.boss.hp / G.boss.maxhp * 100, 0, 100) + '%'; + } + + // weapon/passive slots (rebuild only on change) + let sig = P.weapons.map(w => w.id + w.lvl).join(',') + '|' + + Object.entries(P.passives).map(([k, v]) => k + v).join(','); + if (sig !== this._slotSig) { + this._slotSig = sig; + const we = this.els.slotWeapons; we.innerHTML = ''; + for (const w of P.weapons) { + const d = document.createElement('div'); + d.className = 'slot' + (EVOLVED[w.id] ? ' evolved' : ''); + d.title = tr(EVOLVED[w.id] ? w.id : 'w_' + w.id); + d.innerHTML = wdef(w.id).sym + '' + (EVOLVED[w.id] ? '★' : w.lvl) + ''; + we.appendChild(d); + } + const pe = this.els.slotPassives; pe.innerHTML = ''; + for (const pid in P.passives) { + const d = document.createElement('div'); + d.className = 'slot passive'; + d.title = tr(pid); + d.innerHTML = PASSIVES[pid].sym + '' + P.passives[pid] + ''; + pe.appendChild(d); + } + } + // cooldown sweep on first slot row + const slots = this.els.slotWeapons.children; + for (let i = 0; i < slots.length && i < P.weapons.length; i++) { + const w = P.weapons[i]; + if (EVOLVED[w.id]) continue; + const st = wstats(w.id, w.lvl); + const frac = clamp(1 - w.timer / Math.max(0.01, st.cd * P.cdrMul), 0, 1); + slots[i].style.setProperty('--p', ((1 - frac) * 100).toFixed(1) + '%'); + slots[i].classList.toggle('cd', frac < 1); + } + }, + + /* ================= level up modal ================= */ + openLevelUp(choices, rerolls) { + const wrap = this.els.levelChoices; + wrap.innerHTML = ''; + choices.forEach((c, idx) => { + const d = Sys.describeChoice(c); + let lvLabel = ''; + if (c.type === 'w') lvLabel = 'Lv.' + (this.G.player.weaponById(c.id).lvl + 1); + else if (c.type === 'nw') lvLabel = tr('new_weapon'); + else if (c.type === 'p') { + const cur = this.G.player.passives[c.id] || 0; + lvLabel = cur === 0 ? tr('new_passive') : 'Lv.' + (cur + 1); + } + const el = document.createElement('div'); + el.className = 'choice' + (d.isNew ? ' neww' : '') + (c.type === 'w' && this._isEvolveReady(c.id) ? ' evolve' : ''); + el.innerHTML = + '
' + d.sym + '
' + + '

' + + ''; + el.querySelector('h4').textContent = d.title; + el.querySelector('p').textContent = d.desc; + el.querySelector('.ch-lv').textContent = lvLabel; + el.addEventListener('click', () => { Snd.play('click'); this.G.chooseUpgrade(idx); }); + wrap.appendChild(el); + }); + this.els['bt-reroll'].textContent = tr('lv_reroll', { n: rerolls }); + this.els['bt-reroll'].disabled = rerolls <= 0; + this.els['bt-skipLv'].textContent = tr('lv_skip', { n: 15 }); + this.showModal('md-level'); + }, + + _isEvolveReady(wid) { + const P = this.G.player; + const def = WEAPONS[wid]; + return def && P.passives[def.evo.need] && wid !== undefined; + }, + + /* ================= chest modal ================= */ + openChest(data) { + if (data.evolve) { + this.els.chestTitle.textContent = tr('ch_evolved_title'); + this.els.chestArt.textContent = wdef(data.into).sym; + const body = this.els.chestBody; + body.innerHTML = ''; + const line = document.createElement('div'); + line.className = 'chest-line'; + line.innerHTML = '' + tr('w_' + data.from) + '' + tr(data.into) + ''; + body.appendChild(line); + const p = document.createElement('p'); + p.style.cssText = 'color:var(--dim);font-size:.85rem;margin-top:.4rem'; + p.textContent = tr(data.into + '_d'); + body.appendChild(p); + } else { + this.els.chestTitle.textContent = tr('ch_loot_title'); + this.els.chestArt.textContent = '🎁'; + const body = this.els.chestBody; + body.innerHTML = ''; + for (const ln of data.lines) { + const el = document.createElement('div'); + el.className = 'chest-line'; + el.textContent = '▲ ' + ln; + body.appendChild(el); + } + } + const g = document.createElement('div'); + g.className = 'chest-line'; + g.innerHTML = '🪙 ' + tr('ch_gold', { n: data.gold }) + ''; + this.els.chestBody.appendChild(g); + this.showModal('md-chest'); + }, + + /* ================= pause ================= */ + openPause(G) { + const P = G.player; + const rows = [ + [tr('es_time'), fmtTime(G.time)], + [tr('es_level'), P.level], + [tr('es_kills'), P.kills], + [tr('es_gold'), P.gold] + ]; + this.els.pauseStats.innerHTML = rows.map(r => `${r[0]}${r[1]}`).join(''); + this.showModal('md-pause'); + }, + + /* ================= game over ================= */ + openOver(win, stats) { + this.els.overTitle.textContent = win ? tr('scr_win') : tr('scr_over'); + this.els.overTitle.style.color = win ? '#7dff9e' : '#ff5f7a'; + this.els.overRecord.classList.toggle('hidden', !stats.newRecord); + const rows = [ + [tr('es_time'), fmtTime(stats.time)], + [tr('es_kills'), fmtNum(stats.kills)], + [tr('es_gold'), '+' + fmtNum(stats.goldEarned) + ' 🪙'], + [tr('es_level'), stats.level], + [tr('es_best'), fmtTime(Math.max(stats.best, stats.time))] + ]; + this.els.overStats.innerHTML = rows.map(r => `${r[0]}${r[1]}`).join(''); + this.els.hud.classList.add('hidden'); + this.hideBossBar(); + this.showModal('md-over'); + }, + + /* ================= character select ================= */ + _buildStartBar() { + const bar = document.createElement('div'); + bar.className = 'start-bar'; + bar.style.cssText = 'position:absolute;bottom:1rem;left:0;right:0;display:flex;justify-content:center;z-index:5'; + const b = document.createElement('button'); + b.className = 'btn btn-primary btn-big'; + b.id = 'bt-start'; + b.addEventListener('click', () => { + Snd.play('click'); + this.buildStageSelect(); + this.showScreen('scr-stage'); + }); + bar.appendChild(b); + this.els['scr-chars'].appendChild(bar); + this._startBtn = b; + }, + + /* ================= stage select ================= */ + buildStageSelect() { + const grid = this.els.stageGrid; + if (!grid) return; + const save = Store.data; + if (!this._stageSel) { this._stageSel = 'graveyard'; this._endlessSel = false; this._gradeSel = 0; } + grid.innerHTML = ''; + for (const id in STAGES) { + const st = STAGES[id]; + const unlocked = st.unlock(save); + const card = document.createElement('div'); + card.className = 'stage-card' + (unlocked ? '' : ' locked') + (this._stageSel === id ? ' selected' : ''); + const best = save.progress.bestPerStage[id] || 0; + card.innerHTML = + '
' + st.icon + '
' + + '
' + tr('st_' + id) + '
' + + '
' + tr('st_' + id + '_d') + '
' + + '
' + (best ? '🏆 ' + tr('st_best') + ': ' + fmtTime(best) : '') + '
' + + (!unlocked ? '
🔒 ' + tr(st.lockKey || 'st_lock_frost') + '
' : ''); + card.addEventListener('click', () => { + if (!unlocked) { Snd.play('hit'); return; } + Snd.play('click'); + this._stageSel = id; + this.buildStageSelect(); + }); + grid.appendChild(card); + } + + // options row: endless + grade + const opts = this.els.stageOpts; + opts.innerHTML = ''; + const mkSeg = (labelKey, items, getVal, setVal) => { + const wrap = document.createElement('div'); + wrap.className = 'opt-row'; + const l = document.createElement('label'); l.textContent = tr(labelKey); + const seg = document.createElement('div'); seg.className = 'seg'; + items.forEach(it => { + const sb = document.createElement('button'); + sb.className = 'btn btn-small' + (getVal() === it.val ? ' active' : ''); + sb.textContent = it.label; + sb.addEventListener('click', () => { + Snd.play('click'); setVal(it.val); + this.buildStageSelect(); + }); + seg.appendChild(sb); + }); + wrap.appendChild(l); wrap.appendChild(seg); + opts.appendChild(wrap); + }; + mkSeg('st_endless', + [{ label: 'OFF', val: false }, { label: '∞ ON', val: true }], + () => this._endlessSel, v => { this._endlessSel = v; }); + mkSeg('st_grade', + [{ label: 'I', val: 0 }, { label: 'II', val: 1 }, { label: 'III', val: 2 }, { label: '💀', val: 3 }], + () => this._gradeSel, v => { this._gradeSel = v; }); + + const go = this.els['bt-stage-go']; + go.textContent = tr('btn_start') + ' ▶'; + }, + + buildChars() { + const grid = this.els.charGrid; + if (!grid) return; + grid.innerHTML = ''; + for (const id in CHARS) { + const c = CHARS[id]; + const unlocked = Store.charUnlocked(id); + const card = document.createElement('div'); + card.className = 'char-card' + (unlocked ? '' : ' locked') + (this._charSel === id ? ' selected' : ''); + const wpnName = tr('w_' + c.weapon); + card.innerHTML = + '
' + c.sym + '
' + + '
' + tr('c_' + id) + '
' + + '
⚔ ' + wpnName + '
' + + '
' + tr('c_' + id + '_t') + '
'; + if (!unlocked) { + const lock = document.createElement('div'); + lock.className = 'char-lock'; + lock.textContent = tr(c.cond.key); + card.appendChild(lock); + } else { + card.addEventListener('click', () => { + Snd.play('click'); + this._charSel = id; + this.buildChars(); + }); + // ---- skin controls: cycle owned / buy next ---- + const skins = skinsFor(id); + const owned = skins.filter(s => Store.data.skinOwned[id + ':' + s.key]); + const sel = Store.data.skinSel[id]; + const row = document.createElement('div'); + row.className = 'skin-row'; + const cur = sel && !sel.startsWith('base') + ? (skins.find(s => s.key === sel) || null) + : null; + const nameLbl = document.createElement('span'); + nameLbl.className = 'skin-name'; + const en = currentLang() === 'en'; + nameLbl.innerHTML = cur + ? '' + (en ? cur.en : cur.vi) + : '' + tr('skin_base'); + const cyc = document.createElement('button'); + cyc.className = 'btn btn-mini'; + cyc.textContent = '🎨'; + cyc.title = tr('skin_cycle'); + cyc.addEventListener('click', (ev) => { + ev.stopPropagation(); + Snd.play('click'); + const list = [{ key: 'base' }].concat(owned); + const i = list.findIndex(s => s.key === (Store.data.skinSel[id] || 'base')); + const next = list[(i + 1) % list.length]; + if (next.key === 'base') delete Store.data.skinSel[id]; + else Store.data.skinSel[id] = next.key; + Store.save(); + this.buildChars(); + }); + row.appendChild(nameLbl); + row.appendChild(cyc); + const unowned = skins.find(s => !Store.data.skinOwned[id + ':' + s.key]); + if (unowned) { + const buy = document.createElement('button'); + buy.className = 'btn btn-mini buy'; + buy.textContent = '🛒' + unowned.price; + buy.title = (en ? unowned.en : unowned.vi); + buy.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (Store.data.gold < unowned.price) { Snd.play('hit'); this.toast(tr('ms_need_gold'), 'warn'); return; } + Snd.play('coin'); + Store.spendGold(unowned.price); + Store.data.skinOwned[id + ':' + unowned.key] = 1; + Store.data.skinSel[id] = unowned.key; + Store.save(); + this.toast('🎨 ' + (en ? unowned.en : unowned.vi) + ' ✔'); + this.buildChars(); + this.refreshMenuStats(); + }); + row.appendChild(buy); + } + card.appendChild(row); + } + grid.appendChild(card); + } + if (this._startBtn) this._startBtn.textContent = tr('btn_start') + ' · ' + tr('c_' + this._charSel); + }, + + /* ================= meta shop ================= */ + buildShop() { + const grid = this.els.shopGrid; + if (!grid) return; + grid.innerHTML = ''; + for (const def of META_SHOP) { + const rank = Store.rank(def.id); + const maxed = rank >= def.max; + const cost = metaCost(def, rank); + const canBuy = !maxed && Store.data.gold >= cost; + const item = document.createElement('div'); + item.className = 'shop-item'; + item.innerHTML = + '

' + def.sym + ' ' + tr('ms_' + def.id) + '

' + + '

' + tr('ms_' + def.id + '_d') + '

'; + const pips = document.createElement('div'); + pips.className = 'rank-pips'; + for (let i = 0; i < def.max; i++) { + const pip = document.createElement('i'); + if (i < rank) pip.className = 'on'; + pips.appendChild(pip); + } + item.appendChild(pips); + const btn = document.createElement('button'); + btn.className = 'btn btn-small shop-buy'; + btn.textContent = maxed ? tr('ms_max') : tr('ms_cost', { c: cost }); + btn.disabled = maxed || !canBuy; + btn.addEventListener('click', () => { Snd.play('coin'); this.G.buyMeta(def.id); }); + item.appendChild(btn); + grid.appendChild(item); + } + this.refreshHeadGold(); + }, + + refreshHeadGold() { + document.querySelectorAll('.head-gold b').forEach(el => { el.textContent = fmtNum(Store.data.gold); }); + }, + + /* ================= codex ================= */ + buildCodex(tab) { + this._codexTab = tab || this._codexTab; + const list = this.els.codexList; + if (!list) return; + list.innerHTML = ''; + document.querySelectorAll('#codexTabs .tab').forEach(t => + t.classList.toggle('active', t.getAttribute('data-tab') === this._codexTab)); + + const card = (sym, col, title, desc, tagHtml, locked) => { + const el = document.createElement('div'); + el.className = 'cx-card' + (locked ? ' cx-lock' : ''); + el.innerHTML = + '
' + sym + '
' + + '

' + title + '

' + desc + '

' + (tagHtml || '') + '
'; + list.appendChild(el); + }; + + if (this._codexTab === 'ach') { + const prog = achProgress(); + const head = document.createElement('div'); + head.className = 'ach-head'; + head.textContent = '🏆 ' + prog.n + ' / ' + prog.total; + list.appendChild(head); + for (const a of ACHS) { + const done = !!Store.data.ach[a.id]; + const name = currentLang() === 'en' ? a.en : a.vi; + const desc = currentLang() === 'en' ? a.den : a.dvi; + card(done ? a.icon : '🔒', done ? '#ffd24a' : 'rgba(120,120,140,.6)', + name, desc, done ? '' : '', !done); + } + return; + } + + if (this._codexTab === 'weapons') { + for (const id in WEAPONS) { + const d = WEAPONS[id]; + const evoTag = '⭐ ' + tr(d.evo.into) + ' ➕ ' + tr(d.evo.need) + ''; + card(d.sym, d.col, tr(id) + ' max ' + d.max + '', + tr(id + '_d'), evoTag, false); + } + for (const id in EVOLVED) { + const d = EVOLVED[id]; + card(d.sym, d.col, '⭐ ' + tr(id), tr(id + '_d'), '', false); + } + } else if (this._codexTab === 'passives') { + for (const id in PASSIVES) { + const d = PASSIVES[id]; + card(d.sym, d.col, tr(id) + ' max ' + d.max + '', tr(id + '_d'), '', false); + } + } else if (this._codexTab === 'enemies') { + for (const id in ENEMIES) { + const d = ENEMIES[id]; + card('👹', d.col, tr(d.nk), + tr('cx_stats', { hp: d.hp, dm: d.dmg, sp: d.spd }), '', false); + } + for (const id in BOSSES) { + const d = BOSSES[id]; + card(d.shape === 'death' ? '⚰' : '☠', d.col, tr(d.nk), + tr('cx_stats', { hp: fmtNum(d.hp), dm: d.dmg, sp: d.spd }), + 'BOSS', false); + } + } else { + for (const id in CHARS) { + const c = CHARS[id]; + const unlocked = Store.charUnlocked(id); + card(c.sym, c.col, tr('c_' + id), unlocked ? tr('c_' + id + '_t') : tr(c.cond.key), + '⚔ ' + tr('w_' + c.weapon) + '', !unlocked); + } + } + }, + + /* ================= options ================= */ + buildOptions() { + const panel = this.els.optPanel; + if (!panel || panel.dataset.built === '1') { this._syncOptionControls(); return; } + panel.dataset.built = '1'; + const s = () => Store.s(); + const group = (titleKey) => { + const g = document.createElement('div'); + g.className = 'opt-group'; + const h = document.createElement('h3'); + h.textContent = tr(titleKey); + g.appendChild(h); + panel.appendChild(g); + return g; + }; + const rowSlider = (g, labelKey, key, onchange) => { + const r = document.createElement('div'); r.className = 'opt-row'; + const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey); + const sl = document.createElement('input'); + sl.type = 'range'; sl.min = 0; sl.max = 100; sl.value = s()[key] * 100; + sl.addEventListener('input', () => { + s()[key] = sl.value / 100; + Store.save(); + Snd.applyVolumes(); + if (onchange) onchange(); + }); + r.appendChild(l); r.appendChild(sl); + g.appendChild(r); + }; + const rowToggle = (g, labelKey, key, onchange) => { + const r = document.createElement('div'); r.className = 'opt-row'; + const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey); + const t = document.createElement('div'); + t.className = 'toggle' + (s()[key] ? ' on' : ''); + t.addEventListener('click', () => { + s()[key] = !s()[key]; + t.classList.toggle('on', s()[key]); + Store.save(); + Snd.play('click'); + if (onchange) onchange(); + }); + r.appendChild(l); r.appendChild(t); + g.appendChild(r); + }; + const rowSeg = (g, labelKey, opts, getVal, setVal) => { + const r = document.createElement('div'); r.className = 'opt-row'; + const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey); + const seg = document.createElement('div'); seg.className = 'seg'; + opts.forEach(o => { + const b = document.createElement('button'); + b.textContent = o.label; + if (getVal() === o.val) b.classList.add('on'); + b.addEventListener('click', () => { + setVal(o.val); + seg.querySelectorAll('button').forEach(x => x.classList.remove('on')); + b.classList.add('on'); + Snd.play('click'); + Store.save(); + }); + seg.appendChild(b); + }); + r.appendChild(l); r.appendChild(seg); + g.appendChild(r); + }; + + const ga = group('opt_audio'); + rowSlider(ga, 'master', 'volMaster'); + rowSlider(ga, 'music', 'volMusic'); + rowSlider(ga, 'sfx', 'volSfx'); + + const gv = group('opt_video'); + rowToggle(gv, 'shake', 'shake'); + rowToggle(gv, 'dmgnum', 'dmgNum'); + rowSeg(gv, 'particles', + [{ label: tr('q_low'), val: 'low' }, { label: tr('q_med'), val: 'med' }, { label: tr('q_high'), val: 'high' }], + () => s().particles, v => { s().particles = v; this.G.applyQuality(); }); + rowToggle(gv, 'fps', 'fps', () => this.G.applySettings()); + + const gg = group('opt_gameplay'); + rowToggle(gg, 'autopause', 'autoPause'); + + // ---- fullscreen ---- + const fsRow = document.createElement('div'); fsRow.className = 'opt-row'; + const fsL = document.createElement('label'); fsL.textContent = tr('fullscreen'); + const fsB = document.createElement('button'); + fsB.className = 'btn btn-small'; + const fsSync = () => { fsB.textContent = '⛶ ' + (document.fullscreenElement ? tr('btn_quitmenu') : tr('fullscreen')); }; + fsSync(); + fsB.addEventListener('click', () => { + Snd.play('click'); + if (document.fullscreenElement) { if (document.exitFullscreen) document.exitFullscreen(); } + else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen(); + setTimeout(fsSync, 350); + }); + document.addEventListener('fullscreenchange', fsSync); + fsRow.appendChild(fsL); fsRow.appendChild(fsB); + gg.appendChild(fsRow); + + // ---- keybinds ---- + const gc = group('opt_controls'); + const prettyCode = c => c.replace(/^(Key|Digit|Arrow)/, ''); + const mkBind = (labelKey, act) => { + const r = document.createElement('div'); r.className = 'opt-row'; + const l = document.createElement('label'); l.textContent = tr(labelKey); + const b = document.createElement('button'); + b.className = 'btn btn-small'; + b.style.minWidth = '84px'; + const paint = () => { + b.textContent = this._listenBind === act ? tr('bind_press') : prettyCode(Store.s().keybinds[act]); + }; + paint(); + b.addEventListener('click', () => { + Snd.play('click'); + this._listenBind = act; paint(); + const h = (e) => { + e.preventDefault(); e.stopPropagation(); + if (e.code !== 'Escape') { + Store.s().keybinds[act] = e.code; + Store.save(); + } + this._listenBind = null; + window.removeEventListener('keydown', h, true); + paint(); + }; + window.addEventListener('keydown', h, true); + }); + r.appendChild(l); r.appendChild(b); + gc.appendChild(r); + }; + mkBind('bind_up', 'up'); + mkBind('bind_down', 'down'); + mkBind('bind_left', 'left'); + mkBind('bind_right', 'right'); + + // ---- save transfer ---- + const gsRow = document.createElement('div'); gsRow.className = 'opt-row'; + const gsL = document.createElement('label'); gsL.textContent = tr('save_title'); + const gsB = document.createElement('button'); + gsB.className = 'btn btn-small'; + gsB.textContent = '💾 ↔️'; + gsB.addEventListener('click', () => { Snd.play('click'); this.openSaveModal(); }); + gsRow.appendChild(gsL); gsRow.appendChild(gsB); + gg.appendChild(gsRow); + + // ---- leaderboard identity / server ---- + const mkText = (labelKey, key, ph) => { + const r = document.createElement('div'); r.className = 'opt-row'; + const l = document.createElement('label'); l.textContent = tr(labelKey); + const inp = document.createElement('input'); + inp.type = 'text'; inp.className = 'opt-input'; inp.maxLength = 120; + inp.value = Store.data[key] || ''; + if (ph) inp.placeholder = ph; + inp.addEventListener('change', () => { + Store.data[key] = inp.value.trim().slice(0, 120); + Store.save(); + }); + r.appendChild(l); r.appendChild(inp); + gc.appendChild(r); + }; + mkText('lb_name', 'lbName'); + mkText('lb_server', 'lbUrl', 'https://your-worker.workers.dev'); + + const gl = group('opt_language'); + rowSeg(gl, 'lang', + [{ label: '🇻🇳 Tiếng Việt', val: 'vi' }, { label: '🇬🇧 English', val: 'en' }], + () => s().lang, + v => { s().lang = v; Store.save(); this.syncLang(); }); + + // ---- developer mode toggle ---- + const gd = group('opt_dev'); + rowToggle(gd, 'opt_dev', 'dev', () => this.G.applySettings()); + const devHint = document.createElement('p'); + devHint.style.cssText = 'font-size:.72rem;color:var(--dim);margin-top:.3rem'; + devHint.textContent = tr('dev_hint'); + gd.appendChild(devHint); + + const gz = group('opt_video'); // reset lives under its own group header reuse + gz.querySelector('h3').textContent = '⚠️ Danger'; + const rr = document.createElement('div'); rr.className = 'opt-row'; + const resetBtn = document.createElement('button'); + resetBtn.className = 'btn btn-danger btn-small'; + resetBtn.setAttribute('data-i18n', 'reset'); + resetBtn.textContent = tr('reset'); + resetBtn.addEventListener('click', () => { + if (!this._resetArm) { + this._resetArm = true; + resetBtn.textContent = tr('reset_confirm'); + setTimeout(() => { this._resetArm = false; resetBtn.textContent = tr('reset'); }, 4000); + } else { + Store.reset(); + this._resetArm = false; + resetBtn.textContent = tr('reset'); + this.toast('🗑️ OK'); + this.syncLang(); + this.refreshMenuStats(); + } + }); + rr.appendChild(resetBtn); + gz.appendChild(rr); + }, + + _syncOptionControls() { + // controls read from Store on rebuild; sliders/toggles keep live state already + }, + + /* ================= bindings ================= */ + _bindButtons() { + const on = (id, fn) => { const el = this.$(id); if (el) el.addEventListener('click', () => { Snd.play('click'); fn(); }); }; + on('bt-play', () => { this.buildChars(); this.showScreen('scr-chars'); }); + on('bt-shop', () => { this.buildShop(); this.showScreen('scr-shop'); }); + on('bt-codex', () => { this.buildCodex(); this.showScreen('scr-codex'); }); + on('bt-options', () => { this.buildOptions(); this.showScreen('scr-options'); }); + on('bt-resume', () => this.G.togglePause()); + on('bt-poptions', () => { this.buildOptions(); this.showScreen('scr-options'); }); + on('bt-quit', () => this.G.quitToMenu()); + on('bt-retry', () => this.G.restart()); + on('bt-tomenu', () => this.G.quitToMenu()); + on('bt-chestOk', () => this.G.closeChest()); + on('bt-reroll', () => this.G.rerollChoices()); + on('bt-skipLv', () => this.G.skipLevelUp()); + on('bt-stage-go', () => { + this.G.startRun(this._charSel, { + stage: this._stageSel || 'graveyard', + endless: !!this._endlessSel, + grade: this._gradeSel | 0 + }); + }); + on('bt-daily', () => { + const ds = dailySetup(); + this.G.startRun(ds.char, ds); + }); + on('bt-history', () => { this.buildHistory(); this.showScreen('scr-history'); }); + on('bt-save-close', () => this.hideModal('md-save')); + on('bt-save-copy', () => { + const txt = this.els.saveArea.value || ''; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(txt).then(() => {}, () => {}); + } + this.els.saveArea.select(); + try { document.execCommand('copy'); } catch (e) { /* older browsers */ } + this.toast('📋 ✔'); + }); + on('bt-save-import', () => this.importSave()); + on('bt-lb-close', () => this.hideModal('md-lb')); + on('bt-board', () => { this.openBoard('daily:' + dailyKey()); }); + if (this.els.tutTip) { + this.$('bt-tut-skip').addEventListener('click', () => { + Snd.play('click'); + Store.data.flags.tutDone = true; + Store.save(); + this.hideTut(); + if (this.G) { this.G._tutStep = -1; } + }); + } + + document.querySelectorAll('.back-btn').forEach(b => + b.addEventListener('click', () => { Snd.play('click'); this.G.onBackFromScreen(); })); + + document.querySelectorAll('#codexTabs .tab').forEach(t => + t.addEventListener('click', () => { Snd.play('click'); this.buildCodex(t.getAttribute('data-tab')); })); + + if (this.els.devBtn) { + this.els.devBtn.addEventListener('click', () => { Snd.play('click'); this.toggleDevPanel(); }); + } + }, + + /* ================= developer panel ================= */ + toggleDevPanel() { + const md = this.els['md-dev']; + if (!Store.s().dev || !this.G.player) return; + if (md.classList.contains('hidden')) { + this.buildDevPanel(); + md.classList.remove('hidden'); + } else { + md.classList.add('hidden'); + } + }, + + buildDevPanel() { + const G = this.G, P = G.player; + const el = this.els.devPanel; + if (!el || !P) return; + + el.innerHTML = ''; + // header + const head = document.createElement('h2'); + head.innerHTML = '🛠️ ' + tr('dev_title') + ''; + const closeB = document.createElement('button'); + closeB.className = 'dev-close'; closeB.textContent = '✕'; + closeB.addEventListener('click', () => { Snd.play('click'); this.hideModal('md-dev'); }); + head.appendChild(closeB); + el.appendChild(head); + + const sec = (titleKey) => { + const s = document.createElement('div'); s.className = 'dev-sec'; + const h = document.createElement('h3'); h.textContent = tr(titleKey); + s.appendChild(h); el.appendChild(s); return s; + }; + const dbtn = (parent, label, fn) => { + const b = document.createElement('button'); + b.className = 'dev-btn'; b.textContent = label; + b.addEventListener('click', () => { Snd.play('click'); fn(); this.buildDevPanel(); }); + parent.appendChild(b); + }; + + /* ---- gold ---- */ + const sGold = sec('dev_gold'); + const goldRow = document.createElement('div'); goldRow.className = 'dev-row'; + const gVal = document.createElement('label'); + gVal.textContent = '🪙 ' + fmtNum(P.gold); + goldRow.appendChild(gVal); + [100, 1000, 10000].forEach(n => + dbtn(goldRow, '+' + fmtNum(n), () => G.devAddGold(n))); + const setInp = document.createElement('input'); + setInp.type = 'number'; setInp.min = 0; setInp.value = Math.floor(P.gold); + setInp.style.cssText = 'width:90px;background:#181838;border:1px solid var(--line);color:#fff;border-radius:8px;padding:.25em .5em;font-size:.8rem'; + goldRow.appendChild(setInp); + dbtn(goldRow, tr('dev_set'), () => G.devSetGold(parseInt(setInp.value, 10) || 0)); + sGold.appendChild(goldRow); + + /* ---- level ---- */ + const sLv = sec('dev_level'); + const lvRow = document.createElement('div'); lvRow.className = 'dev-row'; + const lvVal = document.createElement('label'); + lvVal.textContent = 'LV ' + P.level + (G.queueLevelUps ? ' (+' + G.queueLevelUps + ')' : ''); + lvRow.appendChild(lvVal); + dbtn(lvRow, '+1', () => G.devAddLevels(1)); + dbtn(lvRow, '+5', () => G.devAddLevels(5)); + dbtn(lvRow, tr('dev_maxb') + ' 50', () => G.devAddLevels(50)); + sLv.appendChild(lvRow); + + /* ---- cheats ---- */ + const sCh = sec('dev_cheats'); + const chRow = document.createElement('div'); chRow.className = 'dev-row'; + const mkCheatBtn = (labelKey, flagProp) => { + const b = document.createElement('button'); + b.className = 'dev-btn'; + b.textContent = tr(labelKey) + ': ' + (G[flagProp] ? 'ON ✅' : 'OFF ❌'); + if (G[flagProp]) b.style.borderColor = 'var(--green)'; + b.addEventListener('click', () => { + Snd.play('click'); + G[flagProp] = !G[flagProp]; + this.buildDevPanel(); + }); + chRow.appendChild(b); + }; + mkCheatBtn('dev_god', 'devGod'); + mkCheatBtn('dev_onehit', 'devOneHit'); + const spdLabel = document.createElement('label'); + spdLabel.textContent = tr('dev_speed') + ':'; + chRow.appendChild(spdLabel); + [0.5, 1, 2, 3].forEach(v => { + const b = document.createElement('button'); + b.className = 'dev-btn'; b.textContent = v + '×'; + if ((G.timeScale || 1) === v) { b.style.borderColor = 'var(--neon2)'; b.style.color = 'var(--neon2)'; } + b.addEventListener('click', () => { Snd.play('click'); G.timeScale = v; this.buildDevPanel(); }); + chRow.appendChild(b); + }); + sCh.appendChild(chRow); + + /* ---- world ---- */ + const sW = sec('dev_world'); + const wRow = document.createElement('div'); wRow.className = 'dev-row'; + dbtn(wRow, tr('dev_nuke'), () => G.devNuke(900)); + dbtn(wRow, tr('dev_killall'), () => G.devNuke(320)); + dbtn(wRow, tr('dev_magnet'), () => G.devMagnetAll()); + dbtn(wRow, tr('dev_heal'), () => G.devHeal()); + dbtn(wRow, tr('dev_spawn_elite'), () => G.devSpawnElite()); + dbtn(wRow, tr('dev_skip1'), () => G.devSkipTime(60)); + dbtn(wRow, tr('dev_skip5'), () => G.devSkipTime(300)); + sW.appendChild(wRow); + const bRow = document.createElement('div'); bRow.className = 'dev-row'; + const bl = document.createElement('label'); bl.textContent = tr('dev_spawn_boss'); + bRow.appendChild(bl); + for (const bid in BOSSES) { + const b = document.createElement('button'); + b.className = 'dev-btn'; + b.textContent = tr(BOSSES[bid].nk).replace(/^[^\w]+/, ''); + b.addEventListener('click', () => { Snd.play('roar'); G.devSpawnBoss(bid); this.buildDevPanel(); }); + bRow.appendChild(b); + } + sW.appendChild(bRow); + + /* ---- weapons ---- */ + const sWea = sec('dev_weapons'); + const wg = document.createElement('div'); wg.className = 'dev-grid'; + const addItem = (grid, sym, nameKey, curLvTxt, onAdd, onMax, onGet) => { + const it = document.createElement('div'); it.className = 'dev-item'; + it.innerHTML = '' + sym + '' + tr(nameKey) + '' + curLvTxt + ''; + if (onAdd) { + const b1 = document.createElement('button'); b1.textContent = tr('dev_add'); + b1.addEventListener('click', () => { Snd.play('click'); onAdd(); this.buildDevPanel(); }); + it.appendChild(b1); + } + if (onMax) { + const b2 = document.createElement('button'); b2.textContent = tr('dev_maxb'); + b2.addEventListener('click', () => { Snd.play('click'); onMax(); this.buildDevPanel(); }); + it.appendChild(b2); + } + if (onGet) { + const b3 = document.createElement('button'); b3.textContent = tr('dev_get'); + b3.addEventListener('click', () => { Snd.play('click'); onGet(); this.buildDevPanel(); }); + it.appendChild(b3); + } + grid.appendChild(it); + }; + for (const id in WEAPONS) { + const w = P.weaponById(id); + addItem(wg, WEAPONS[id].sym, 'w_' + id, + w ? 'Lv.' + w.lvl : '—', + () => G.devGiveWeapon(id), + () => G.devGiveWeapon(id, true), null); + } + for (const id in EVOLVED) { + addItem(wg, EVOLVED[id].sym, id, + P.weaponById(id) ? '★' : '—', null, null, + () => G.devGiveWeapon(id)); + } + sWea.appendChild(wg); + + /* ---- passives ---- */ + const sPa = sec('dev_passives'); + const pg = document.createElement('div'); pg.className = 'dev-grid'; + for (const id in PASSIVES) { + const cur = P.passives[id] || 0; + addItem(pg, PASSIVES[id].sym, id, + cur ? 'Lv.' + cur : '—', + () => G.devGivePassive(id), + () => G.devGivePassive(id, true), null); + } + sPa.appendChild(pg); + + /* ---- meta ---- */ + const sM = sec('dev_meta'); + const mRow = document.createElement('div'); mRow.className = 'dev-row'; + dbtn(mRow, tr('dev_unlockall'), () => G.devUnlockAllChars()); + sM.appendChild(mRow); + + const note = document.createElement('p'); + note.className = 'dev-note'; + note.textContent = '` = đóng/mở · Esc = đóng · Cheat chỉ áp dụng cho phiên hiện tại'; + el.appendChild(note); + } +}; diff --git a/js/util.js b/js/util.js new file mode 100644 index 0000000..5e2f220 --- /dev/null +++ b/js/util.js @@ -0,0 +1,204 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — util.js : math helpers, RNG, spatial grid + ============================================================ */ +const TAU = Math.PI * 2; + +/* ---- seeded RNG layer ---- + * All game randomness flows through rng(). Normal runs use Math.random; + * Daily Challenges call seedRng(n) for a deterministic day seed. + */ +let __rngState = null; +function seedRng(seed) { __rngState = (seed === null || seed === undefined) ? null : mulberry32(seed >>> 0); } +function rng() { return __rngState ? __rngState() : Math.random(); } + +const clamp = (v, a, b) => v < a ? a : v > b ? b : v; +const lerp = (a, b, t) => a + (b - a) * t; +const rand = (a = 1, b) => b === undefined ? rng() * a : a + rng() * (b - a); +const randi = (a, b) => Math.floor(rand(a, b + 1)); +const pick = (arr) => arr[(rng() * arr.length) | 0]; +const chance = (p) => rng() < p; +const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }; +const len2 = (x, y) => Math.sqrt(x * x + y * y); + +function shuffle(a) { + for (let i = a.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + const tmp = a[i]; a[i] = a[j]; a[j] = tmp; + } + return a; +} + +/** Pick one item from [{...}] weighted by wf(item). */ +function weightedPick(items, wf) { + let sum = 0; + for (const it of items) sum += wf(it); + let r = rng() * sum; + for (const it of items) { r -= wf(it); if (r <= 0) return it; } + return items[items.length - 1]; +} + +/** Pick up to k distinct items (shuffled copy). */ +function sampleN(arr, k) { + const c = arr.slice(); + shuffle(c); + return c.slice(0, Math.max(0, Math.min(k, c.length))); +} + +function fmtTime(sec) { + sec = Math.max(0, Math.floor(sec)); + const m = (sec / 60) | 0, s = sec % 60; + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; +} + +function fmtNum(n) { + n = Math.floor(n); + if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K'; + return String(n); +} + +/** Swap-remove: order not preserved, O(1). */ +function removeItem(arr, i) { + arr[i] = arr[arr.length - 1]; + arr.pop(); +} + +/** 'YYYYMMDD' in UTC — the Daily Challenge key. */ +function dailyKey() { + const d = new Date(); + return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(); +} + +/** Deterministic per-day setup for the Daily Challenge. */ +function dailySetup() { + const k = dailyKey(); + let h = (k * 2654435761) >>> 0; + h ^= h >>> 13; h = (h * 1274126177) >>> 0; h ^= h >>> 16; + const charIds = Object.keys(CHARS); + const stageIds = Object.keys(STAGES); + return { + seed: h, + char: charIds[h % charIds.length], + stage: stageIds[(h >> 3) % stageIds.length], + grade: 1, + endless: false, + daily: true + }; +} + +/** + * Leaderboard checksum — MUST mirror server/lb-worker.js lbSig(). + * Two-pass FNV-1a over UTF-8 bytes with a shared salt. + */ +function lbSig(obj) { + const SALT = 'NSv1-lb-salt-v1'; + const bytes = new TextEncoder().encode(SALT + JSON.stringify(obj)); + let h = 2166136261 >>> 0; + for (let i = 0; i < bytes.length; i++) { + h ^= bytes[i]; + h = Math.imul(h, 16777619) >>> 0; + } + h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16; + return ('0000000' + h.toString(36)).slice(-8); +} + +/** + * Uniform-grid broadphase for circle collisions. + * Rebuild each frame: clear() -> insert(e) for each enemy -> query(x,y,r,out). + */ +class SpatialGrid { + constructor(cellSize = 96) { this.cs = cellSize; this.map = new Map(); } + clear() { this.map.clear(); } + _key(cx, cy) { return cx * 73856093 ^ cy * 19349663; } + insert(e) { + const cs = this.cs; + const x0 = Math.floor((e.x - e.r) / cs), x1 = Math.floor((e.x + e.r) / cs); + const y0 = Math.floor((e.y - e.r) / cs), y1 = Math.floor((e.y + e.r) / cs); + for (let cy = y0; cy <= y1; cy++) + for (let cx = x0; cx <= x1; cx++) { + const k = this._key(cx, cy); + let bucket = this.map.get(k); + if (!bucket) { bucket = []; this.map.set(k, bucket); } + bucket.push(e); + } + } + /** + * Fills `out` with entities whose cells overlap the query circle. + * Stamp dedupe is bumped PER CALL so back-to-back queries in the same + * frame never swallow results from each other. + */ + query(x, y, r, out) { + out.length = 0; + const cs = this.cs; + const stamp = ++SpatialGrid._stamp; + const x0 = Math.floor((x - r) / cs), x1 = Math.floor((x + r) / cs); + const y0 = Math.floor((y - r) / cs), y1 = Math.floor((y + r) / cs); + for (let cy = y0; cy <= y1; cy++) + for (let cx = x0; cx <= x1; cx++) { + const bucket = this.map.get(this._key(cx, cy)); + if (!bucket) continue; + for (let i = 0; i < bucket.length; i++) { + const e = bucket[i]; + if (e._stamp !== stamp) { + e._stamp = stamp; + out.push(e); + } + } + } + return out; + } +} +SpatialGrid._stamp = 0; + +/** Simple throttle helper: returns true at most once per `ms` for a given slot. */ +const throttleMap = new Map(); +function throttled(slot, ms) { + const now = performance.now(); + const last = throttleMap.get(slot) || -1e9; + if (now - last >= ms) { throttleMap.set(slot, now); return true; } + return false; +} + +/** '#rrggbb' / '#rgb' -> 'rgba(r,g,b,a)' (returns input unchanged if not hex). */ +function colA(hex, a) { + if (typeof hex !== 'string' || hex[0] !== '#') return hex; + if (hex.length === 4) { + const r = parseInt(hex[1] + hex[1], 16); + const g = parseInt(hex[2] + hex[2], 16); + const b = parseInt(hex[3] + hex[3], 16); + return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; + } + if (hex.length !== 7) return hex; + const n = parseInt(hex.slice(1), 16); + return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')'; +} + +/** + * Pooled particle spawn. Uses G.partPool when available, respects G.partCap. + * opts.big grants overflow headroom for important bursts. + */ +function part(G, x, y, col, opts) { + opts = opts || {}; + const cap = G.partCap !== undefined ? G.partCap : 600; + if (G.parts.length >= cap + (opts.big ? 60 : 0)) return null; + let p; + if (G.partPool && G.partPool.length) { + p = G.partPool.pop().set(x, y, col, opts); + } else { + p = new Particle(x, y, col, opts); + } + G.parts.push(p); + return p; +} + +// Tiny seeded RNG (mulberry32) — used for deterministic tests. +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..1eb9674 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Neon Survivors — Bullet Heaven", + "short_name": "NeonSurv", + "description": "Sống sót giữa biển quái vật. Nâng cấp vũ khí. Tiến hóa. Hạ Tử Thần.", + "start_url": "./index.html", + "scope": "./", + "display": "fullscreen", + "orientation": "any", + "background_color": "#05050c", + "theme_color": "#7c5cff", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/server/lb-worker.js b/server/lb-worker.js new file mode 100644 index 0000000..79eadea --- /dev/null +++ b/server/lb-worker.js @@ -0,0 +1,99 @@ +/** + * NEON SURVIVORS — Leaderboard Worker (Cloudflare Workers + KV) + * + * Deploy: + * 1. wrangler kv:namespace create LB + * 2. paste the returned id into wrangler.toml + * 3. wrangler deploy + * + * Endpoints: + * GET /top?scope=daily:20260822|stage:frostcave:2&n=10 + * POST /submit { name, time, kills, gold, lv, stageId, grade, endless, daily, win, day, sig } + * + * `sig` is a lightweight checksum (same algorithm as the client) that deters + * casual tampering. True server authority would require session validation. + */ + +const SALT = 'NSv1-lb-salt-v1'; + +async function lbSig(obj) { + const data = new TextEncoder().encode(SALT + JSON.stringify(obj)); + let h = 2166136261 >>> 0; + const view = data; + for (let i = 0; i < view.length; i++) { + h ^= view[i]; + h = Math.imul(h, 16777619) >>> 0; + } + // second avalanche pass so short payloads spread + h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16; + return ('0000000' + h.toString(36)).slice(-8); +} + +function jsonRes(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'content-type', + 'access-control-allow-methods': 'GET,POST,OPTIONS' + } + }); +} + +export default { + async fetch(req, env) { + const url = new URL(req.url); + if (req.method === 'OPTIONS') return new Response(null, { status: 204 }); + + if (url.pathname === '/top' && req.method === 'GET') { + const scope = (url.searchParams.get('scope') || '').slice(0, 48); + const n = Math.min(50, Math.max(1, parseInt(url.searchParams.get('n') || '10', 10))); + if (!scope) return jsonRes({ ok: false, error: 'scope' }, 400); + const raw = await env.LB.get('b:' + scope); + const rows = raw ? JSON.parse(raw) : []; + return jsonRes({ ok: true, scope, rows: rows.slice(0, n) }); + } + + if (url.pathname === '/submit' && req.method === 'POST') { + let b; + try { b = await req.json(); } catch (e) { return jsonRes({ ok: false, error: 'json' }, 400); } + const time = Math.floor(+b.time || 0); + const entry = { + n: String(b.name || 'Anon').slice(0, 16), + t: time, + k: Math.max(0, Math.min(999999, Math.floor(+b.kills || 0))), + g: Math.max(0, Math.min(9999999, Math.floor(+b.gold || 0))), + lv: Math.max(1, Math.min(200, Math.floor(+b.lv || 1))), + w: b.win ? 1 : 0, + st: String(b.stageId || '').slice(0, 24), + gr: Math.max(0, Math.min(3, +b.grade | 0)) + }; + // scope: daily board uses UTC day; stage boards keyed by stage+grade + const day = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + let scope; + if (b.daily) scope = 'daily:' + day; + else if (time >= 60) scope = 'stage:' + (entry.st || 'graveyard') + ':' + entry.gr; + else return jsonRes({ ok: false, error: 'too_short' }, 400); + + // verify checksum over the signed fields only + const payload = { + name: b.name, time, kills: entry.k, gold: entry.g, lv: entry.lv, + stageId: b.stageId, grade: entry.gr, endless: !!b.endless, daily: !!b.daily, + win: !!b.win, day + }; + if ((await lbSig(payload)) !== b.sig) return jsonRes({ ok: false, error: 'sig' }, 403); + if (time < 30 || time > 86400) return jsonRes({ ok: false, error: 'range' }, 400); + + const key = 'b:' + scope; + const raw = await env.LB.get(key); + const rows = raw ? JSON.parse(raw) : []; + rows.push(entry); + rows.sort((a, z) => z.t - a.t || z.k - a.k); + await env.LB.put(key, JSON.stringify(rows.slice(0, 100))); + return jsonRes({ ok: true, rank: rows.findIndex(r => r === entry) + 1 }); + } + + return jsonRes({ ok: false, error: 'route' }, 404); + } +}; diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..ddcd83e --- /dev/null +++ b/sw.js @@ -0,0 +1,51 @@ +/* Neon Survivors — service worker (cache-first, versioned) */ +'use strict'; +const CACHE = 'neon-survivors-v1'; +const ASSETS = [ + './', + './index.html', + './manifest.webmanifest', + './css/style.css', + './icons/icon-192.png', + './icons/icon-512.png', + './js/util.js', + './js/i18n.js', + './js/save.js', + './js/audio.js', + './js/data.js', + './js/entities.js', + './js/systems.js', + './js/render.js', + './js/ui.js', + './js/game.js', + './js/main.js' +]; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE).then(c => c.addAll(ASSETS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + if (e.request.method !== 'GET') return; + const url = new URL(e.request.url); + if (url.origin !== location.origin) return; + e.respondWith( + caches.match(e.request, { ignoreSearch: true }).then(hit => + hit || fetch(e.request).then(res => { + const copy = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, copy)); + return res; + }).catch(() => hit) + ) + ); +}); diff --git a/test/integrity.js b/test/integrity.js new file mode 100644 index 0000000..b9908bc --- /dev/null +++ b/test/integrity.js @@ -0,0 +1,405 @@ +'use strict'; +/* ============================================================ + NEON SURVIVORS — test/integrity.js + Headless integrity checks + gameplay smoke simulation. + Run: node test/integrity.js + ============================================================ */ +const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); + +let failures = 0; +function check(cond, msg) { + if (cond) { console.log(' ✔ ' + msg); } + else { failures++; console.error(' ✘ FAIL: ' + msg); } +} + +/* ---------- sandbox ---------- */ +const sandbox = { + console, + Math, + JSON, + Date, + performance: { now: () => Date.now() }, + localStorage: { + _d: {}, + getItem(k) { return this._d[k] !== undefined ? this._d[k] : null; }, + setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; } + }, + document: { hidden: false }, + window: {}, + setTimeout, clearTimeout, setInterval, clearInterval +}; +sandbox.globalThis = sandbox; +vm.createContext(sandbox); + +function load(f) { + const code = fs.readFileSync(path.join(__dirname, '..', 'js', f), 'utf8'); + vm.runInContext(code, sandbox, { filename: f }); +} +for (const f of ['util.js', 'i18n.js', 'save.js', 'audio.js', 'data.js', 'achievements.js', 'entities.js', 'systems.js']) + load(f); + +// Top-level const/class bindings live in the context's lexical env, NOT on the +// sandbox object — copy every game symbol into one exposed namespace. +vm.runInContext(` +globalThis.__G = { + SpatialGrid, + I18N, tr, currentLang, Store, Snd, + WEAPONS, EVOLVED, wdef, wstats, PASSIVES, CHARS, ENEMIES, + diffScale, SPAWN_TABLE, ELITE_TIMES, RUN_LENGTH, BOSS_PLAN, BOSSES, + META_SHOP, metaCost, + STAGES, ACHS, ELITE_AFFIXES, checkAchievements, achProgress, dailySetup, seedRng, rng, + colA, part, rand, + Player, Enemy, Proj, EBullet, Pickup, Particle, FText, Effect, + Sys +};`, sandbox); +const X = sandbox.__G; + +/* ================= data integrity ================= */ +console.log('\n== DATA INTEGRITY =='); +{ + const { WEAPONS, EVOLVED, PASSIVES, CHARS, ENEMIES, BOSSES, META_SHOP, + I18N, SPAWN_TABLE, BOSS_PLAN, RUN_LENGTH, wstats } = X; + + let ok = true; + for (const id in WEAPONS) { + const w = WEAPONS[id]; + if (!EVOLVED[w.evo.into]) { ok = false; console.error(` ${id}: evo target ${w.evo.into} missing`); } + if (!PASSIVES[w.evo.need]) { ok = false; console.error(` ${id}: evo need ${w.evo.need} missing`); } + if (!w.base || !Array.isArray(w.per)) { ok = false; console.error(` ${id}: bad stat tables`); } + } + check(ok, 'all weapon evolutions resolve (target + required passive)'); + check(Object.keys(WEAPONS).length === 11, '11 base weapons'); + check(Object.keys(EVOLVED).length === 11, '11 evolutions'); + check(new Set([...Object.keys(WEAPONS), ...Object.keys(EVOLVED)]).size === 22, 'no id collisions'); + + ok = true; + for (const cid in CHARS) { + const c = CHARS[cid]; + if (!WEAPONS[c.weapon]) { ok = false; console.error(` ${cid}: weapon ${c.weapon} missing`); } + if (typeof c.cond.fn !== 'function') { ok = false; console.error(` ${cid}: cond.fn missing`); } + } + check(ok, 'all characters reference valid weapons + unlock functions'); + + ok = true; + for (const row of SPAWN_TABLE) + for (const tid of row) + if (!ENEMIES[tid]) { ok = false; console.error(' missing enemy: ' + tid); } + check(ok, 'spawn table references valid enemies'); + check(SPAWN_TABLE.length === 20, 'spawn table covers all 20 minutes'); + for (const b of BOSS_PLAN) check(!!BOSSES[b.id], `boss plan ${b.id} exists`); + check(BOSS_PLAN[BOSS_PLAN.length - 1].t <= RUN_LENGTH, 'final boss scheduled within run length'); + + const mids = META_SHOP.map(m => m.id); + check(new Set(mids).size === mids.length, 'meta shop ids unique'); + + /* i18n parity */ + const viKeys = Object.keys(I18N.vi), enKeys = Object.keys(I18N.en); + const missEn = viKeys.filter(k => !(k in I18N.en)); + const missVi = enKeys.filter(k => !(k in I18N.vi)); + check(missEn.length === 0, `en covers all vi keys${missEn.length ? ' (' + missEn.join(',') + ')' : ''}`); + check(missVi.length === 0, `vi covers all en keys${missVi.length ? ' (' + missVi.join(',') + ')' : ''}`); + + /* content i18n keys */ + ok = true; + const need = []; + for (const id in WEAPONS) + need.push('w_' + id, 'w_' + id + '_d', + WEAPONS[id].evo.into, WEAPONS[id].evo.into + '_d', + WEAPONS[id].evo.need, WEAPONS[id].evo.need + '_d'); + for (const cid in CHARS) need.push('c_' + cid, 'c_' + cid + '_t', CHARS[cid].cond.key); + for (const eid in ENEMIES) need.push(ENEMIES[eid].nk); + for (const bid in BOSSES) need.push(BOSSES[bid].nk); + for (const m of META_SHOP) need.push('ms_' + m.id, 'ms_' + m.id + '_d'); + for (const pid in PASSIVES) need.push(pid, pid + '_d'); + for (const k of [...new Set(need)]) { + if (!I18N.vi[k]) { ok = false; console.error(' vi missing: ' + k); } + if (!I18N.en[k]) { ok = false; console.error(' en missing: ' + k); } + } + check(ok, 'weapons/passives/chars/enemies/shop have vi+en strings'); + + /* stat tables valid across every level */ + ok = true; + for (const id in WEAPONS) { + for (let lvl = 1; lvl <= WEAPONS[id].max; lvl++) { + const s = wstats(id, lvl); + for (const k in s) + if (typeof s[k] !== 'number' || !isFinite(s[k])) { ok = false; console.error(` ${id} lvl${lvl}.${k} invalid`); } + if (!(s.cd >= 0.12)) { ok = false; console.error(` ${id} lvl${lvl} cd too low: ${s.cd}`); } + } + } + check(ok, 'wstats() returns finite stats with sane cooldowns at every level'); +} + +/* ================= phase-1 systems (stages/ach/daily/props) ================= */ +console.log('\n== PHASE-1 SYSTEMS =='); +{ + const { STAGES, ENEMIES, Store } = X; + + check(Object.keys(STAGES).length === 3, '3 stages defined'); + let tablesOk = true, plansOk = true; + for (const id in STAGES) { + const st = STAGES[id]; + if (!Array.isArray(st.table) || st.table.length !== 20 || + st.table.some(row => !Array.isArray(row) || !row.length || + row.some(tid => !ENEMIES[tid] || tid === 'lamp'))) tablesOk = false; + if (!Array.isArray(st.plan) || st.plan.length < 2 || + st.plan.some((p, i) => i > 0 && p.t <= st.plan[i - 1].t) || + !st.plan[st.plan.length - 1].final) plansOk = false; + if (typeof st.unlock !== 'function' || typeof st.hpMul !== 'number') { tablesOk = false; plansOk = false; } + } + check(tablesOk, 'every stage has a valid 20-row spawn table'); + check(plansOk, 'boss plans ascend and end with a final boss'); + + // achievements unlock from persisted counters + if (!Store.data) Store.load(); + Store.data.totals.kills = 100; + const got = X.checkAchievements(); + check(got.some(a => a.id === 'kill100') && !!Store.data.ach.kill100, + 'checkAchievements unlocks kill100 at exactly 100 kills'); + + // daily setup is deterministic per UTC day + const ds1 = X.dailySetup(), ds2 = X.dailySetup(); + check(ds1.seed === ds2.seed && ds1.char === ds2.char && ds1.stage === ds2.stage, + 'dailySetup deterministic per day'); + X.seedRng(ds1.seed); + const seqA = [X.rand(), X.rand(), X.rand()]; + X.seedRng(ds1.seed); + const seqB = [X.rand(), X.rand(), X.rand()]; + X.seedRng(null); + check(JSON.stringify(seqA) === JSON.stringify(seqB), 'seeded RNG reproduces its sequence'); + + // breakable props: die to damage, drop coins only + { + const { Player, Enemy, Sys: SysRef, SpatialGrid } = X; + const P = new Player('kaito'); + const g = { + player: P, enemies: [], pickups: [], parts: [], texts: [], effects: [], + grid: new SpatialGrid(96), cam: { x: 0, y: 0 }, time: .5, lastDt: 1 / 30, + shakeIt() {}, hurtFlash: 0, over: false, partCap: 200, + goldMul: 1.25, goldEarned: 0, boss: null, onPlayerDown() {} + }; + const lamp = new Enemy('lamp', P.x + 40, P.y); + g.enemies.push(lamp); + const gemsBefore = g.pickups.filter(p => p.type === 'gem').length; + SysRef.hitEnemy(g, lamp, 99999, { silent: true }); + check(lamp.dead, 'breakable lamp dies to damage'); + check(g.pickups.some(p => p.type === 'coin'), 'lamp drops coins'); + check(g.pickups.filter(p => p.type === 'gem').length === gemsBefore, 'lamp drops no gem'); + check(Store.data.totals.props >= 1, 'prop breaks counted for achievements'); + } + + // elite affixes behave as declared + { + const { Player, Enemy, Sys: SysRef, SpatialGrid, ELITE_AFFIXES } = X; + const P = new Player('kaito'); + const e = new Enemy('brute', P.x + 60, P.y); + const hp0 = e.hp; + ELITE_AFFIXES.tank.apply(e); + check(e.hp > hp0 && e.kbRes >= 0.55, 'tank affix hardens the elite'); + + const g = { + player: P, enemies: [e], pickups: [], parts: [], texts: [], effects: [], + grid: new SpatialGrid(96), cam: { x: 0, y: 0 }, time: 120, lastDt: 1 / 30, + shakeIt() {}, hurtFlash: 0, over: false, partCap: 200, + goldMul: 1, goldEarned: 0, boss: null, onPlayerDown() {} + }; + ELITE_AFFIXES.blast.apply(e); + P.x = e.x; P.y = e.y; P.invuln = 0; + const php = P.hp; + SysRef.hitEnemy(g, e, 99999, { silent: true }); + check(e.dead && P.hp < php, 'volatile elite detonates and hurts a nearby player'); + } +} + +/* ================= headless smoke simulation ================= */ +console.log('\n== HEADLESS SMOKE SIM =='); +{ + X.Store.load(); + sandbox.UI = { toast() {}, showBossBar() {}, hideBossBar() {} }; + + const { Player, SpatialGrid, Enemy, Proj, Sys, WEAPONS, BOSSES, EVOLVED } = X; + + sandbox.GAME = null; + const G = { + player: new Player('kaito'), + enemies: [], projs: [], ebullets: [], pickups: [], parts: [], texts: [], effects: [], + grid: new SpatialGrid(96), + cam: { x: 0, y: 0 }, time: 0, lastDt: 1 / 30, + shakeIt() {}, hurtFlash: 0, over: false, partCap: 200, + spawnAcc: 0, lastBurstMinute: -1, + eliteIdx: 999, bossIdx: 999, + queueLevelUps: 0, pendingChests: 0, goldEarned: 0, boss: null, + onPlayerDown() {} + }; + sandbox.GAME = G; + + const dt = 1 / 30; + let errCount = 0; + + function describeSafe(c) { + try { Sys.describeChoice(c); return true; } + catch (e) { errCount++; console.error(' describeChoice threw:', e.message); return false; } + } + + try { + // --- 2.5 simulated minutes of combat --- + for (let step = 0; step < 30 * 150 && G.time < 150; step++) { + G.time += dt; + const P = G.player; + + // wander toward nearest pickup + let mx = Math.sin(step * 0.01), my = Math.cos(step * 0.013); + if (G.pickups.length) { + const pk = G.pickups[0]; + const d = Math.max(1, Math.hypot(pk.x - P.x, pk.y - P.y)); + mx = (pk.x - P.x) / d; my = (pk.y - P.y) / d; + } + P.x += mx * P.spd * dt; P.y += my * P.spd * dt; + P.faceX = mx; P.faceY = my; + if (P.invuln > 0) P.invuln -= dt; + + Sys.fireWeapons(G, dt); + Sys.spawnTick(G, dt); + Sys.updEnemies(G, dt); + Sys.updProjectiles(G, dt); + Sys.updEffects(G, dt); + Sys.updEBullets(G, dt); + Sys.updPickups(G, dt); + + // guarantee kills -> drops -> gems -> level-ups + if (step % 6 === 0 && G.enemies.length) { + const e = G.enemies[0]; + if (!e.boss) Sys.hitEnemy(G, e, 99999, {}); + } + + // particle decay + for (let i = G.parts.length - 1; i >= 0; i--) { + const p = G.parts[i]; p.life -= dt; + if (p.life <= 0) { G.parts[i] = G.parts[G.parts.length - 1]; G.parts.pop(); continue; } + p.x += p.vx * dt; p.y += p.vy * dt; + } + + // process level-ups headlessly + while (G.queueLevelUps > 0) { + G.queueLevelUps--; + const ch = Sys.makeChoices(G); + if (!(ch.length > 0)) { failures++; console.error(' ✘ no choices offered'); } + for (const c of ch) describeSafe(c); + Sys.applyChoice(G, ch[0]); + } + } + check(true, `simulated ${G.time.toFixed(1)}s of combat without exceptions`); + check(errCount === 0, 'describeChoice ran clean on every offered choice'); + check(G.player.level > 3, `player leveled up (lvl ${G.player.level})`); + check(G.player.kills > 0, `kills registered (${G.player.kills})`); + console.log(` [info] enemies alive: ${G.enemies.length}, projs: ${G.projs.length}, pickups: ${G.pickups.length}`); + + // --- evolution path --- + const wand = G.player.weapons.find(w => !EVOLVED[w.id]); + if (wand) { + wand.lvl = WEAPONS[wand.id].max; + G.player.passives[WEAPONS[wand.id].evo.need] = 1; + const reward = Sys.chestReward(G); + check(reward.evolve === true, 'chest triggers evolution when maxed + passive owned'); + check(G.player.weapons.some(w => w.id === reward.into), + 'weapon replaced by evolved version (' + reward.into + ')'); + } else { + check(true, '(all weapons already evolved via levelups; skip)'); + } + + // --- REGRESSION: projectile hits via spatial grid (per-call stamp dedupe) --- + // Two enemies on separate lanes; two bolts fired back-to-back. + // With the old frame-level stamp, query #2 returned [] and bolt #2 missed. + { + const P = G.player; + const e1 = new Enemy('zombie', P.x + 120, P.y); + const e2 = new Enemy('zombie', P.x + 260, P.y + 40); + G.enemies.push(e1, e2); + G.grid.clear(); + G.grid.insert(e1); G.grid.insert(e2); + const scratch = []; + G.grid.query(e1.x, e1.y, 80, scratch); // simulate separation pass pre-stamping + G.grid.query(P.x, P.y, 220, scratch); + + const hp1 = e1.hp, hp2 = e2.hp; + G.projs.length = 0; + G.projs.push(new Proj({ kind: 'wand', mode: 'straight', x: P.x, y: P.y, vx: 420, vy: 0, dmg: 50, pierce: 0, r: 6, life: 1, kb: 40, col: '#fff' })); + G.projs.push(new Proj({ kind: 'wand', mode: 'straight', x: P.x, y: P.y + 40, vx: 420, vy: 0, dmg: 50, pierce: 0, r: 6, life: 1, kb: 40, col: '#fff' })); + for (let i = 0; i < 30; i++) { G.time += dt; Sys.updProjectiles(G, dt); } + check(e1.hp < hp1 || e1.dead, 'regression: bolt #1 connects with enemy #1'); + check(e2.hp < hp2 || e2.dead, 'regression: bolt #2 still hits enemy #2 after earlier queries'); + G.enemies.length = 0; G.projs.length = 0; + } + + // --- REGRESSION: whip auto-aims at the nearest enemy --- + // Target sits ABOVE the player; the old horizontal-only box could never reach it. + { + const P = G.player; + const target = new Enemy('zombie', P.x + 10, P.y - 160); + G.enemies.push(target); + G.grid.clear(); G.grid.insert(target); + const hpT = target.hp; + Sys._fireWhip(G, { id: 'whip', lvl: 1, timer: 0 }, X.wstats('whip', 1)); + check(target.hp < hpT || target.dead, 'regression: whip strikes toward the nearest enemy'); + G.enemies.length = 0; + } + + // --- boss AI brains run 6 simulated seconds each --- + for (const bid in BOSSES) { + const b = new Enemy(bid, G.player.x + 300, G.player.y); + b.scaleTo(5); + const before = G.ebullets.length + G.effects.length + G.enemies.length; + try { + for (let i = 0; i < 180; i++) Sys._bossAI(G, b, dt); + check(true, `${bid} AI ran 6s cleanly`); + } catch (e) { failures++; console.error(` ✘ ${bid} AI threw:`, e.message); } + } + + // --- death / i-frame flow --- + const P3 = G.player; + let downs = 0; + G.onPlayerDown = () => { downs++; P3.hp = 50; }; // simulate a revive + P3.invuln = 0; + P3.hp = 10; + Sys.hurtPlayer(G, 999); + check(downs === 1 && P3.hp > 0, 'lethal hit triggers onPlayerDown'); + const hpBefore = P3.hp; + Sys.hurtPlayer(G, 100); + check(P3.hp === hpBefore, 'i-frames block the immediate follow-up hit'); + + // --- developer hooks: one-hit / god / spawnElite --- + { + G.devOneHit = true; + const brute = new Enemy('brute', G.player.x + 50, G.player.y); + brute.scaleTo(0); + G.enemies.push(brute); + Sys.hitEnemy(G, brute, 1, { silent: true }); + check(brute.dead || brute.hp <= 0, 'dev one-hit kills through full HP'); + G.devOneHit = false; + G.enemies.length = 0; + + G.devGod = true; + const hpG = G.player.hp; + Sys.hurtPlayer(G, 100); + check(G.player.hp === hpG && downs === 1, 'dev god mode blocks all damage'); + G.devGod = false; + + const nBefore = G.enemies.length; + const elite = Sys.spawnElite(G); + check(G.enemies.length === nBefore + 1 && elite.elite === true, + 'spawnElite produces a real elite enemy'); + G.enemies.length = 0; + } + + } catch (e) { + failures++; + console.error(' ✘ SIM CRASHED:', e.stack); + } +} + +console.log('\n=============================='); +if (failures === 0) { console.log('ALL TESTS PASSED ✅'); process.exit(0); } +else { console.log(`${failures} FAILURES ❌`); process.exit(1); } diff --git a/tools/gen-icons.js b/tools/gen-icons.js new file mode 100644 index 0000000..c130c30 --- /dev/null +++ b/tools/gen-icons.js @@ -0,0 +1,97 @@ +// Generates PWA icons without external deps: neon diamond on dark bg. +'use strict'; +const zlib = require('zlib'); +const fs = require('fs'); +const path = require('path'); + +function crc32(buf) { + let table = crc32.table; + if (!table) { + table = crc32.table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + } + let c = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const t = Buffer.from(type, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([t, data]))); + return Buffer.concat([len, t, data, crc]); +} + +function png(w, h, rgba) { + const stride = w * 4; + const raw = Buffer.alloc((stride + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; + rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; ihdr[9] = 6; + const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + return Buffer.concat([ + sig, + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)) + ]); +} + +function lerp(a, b, t) { return a + (b - a) * t; } + +function makeIcon(size) { + const px = Buffer.alloc(size * size * 4); + const cx = size / 2, cy = size / 2; + const maxD = Math.hypot(cx, cy); + const diaR = size * 0.34; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + const dx = x - cx, dy = y - cy; + const d = Math.hypot(dx, dy) / maxD; + + // background: vertical dark gradient + let r = lerp(10, 5, y / size); + let g = lerp(10, 5, y / size); + let b = lerp(31, 12, y / size); + + // soft purple glow ring + const glow = Math.max(0, 1 - d * 2.2) ** 2; + r += 124 * glow * 0.35; g += 92 * glow * 0.35; b += 255 * glow * 0.4; + + // neon diamond + const ad = Math.abs(dx) + Math.abs(dy); + if (ad < diaR) { + const t = ad / diaR; + // gradient edge cyan -> center violet -> white core + if (t > 0.82) { r = 224; g = 250; b = 255; } // rim highlight + else if (t > 0.55) { r = 0; g = 229; b = 255; } // cyan edge + else if (t > 0.25) { r = 124; g = 92; b = 255; } // violet body + else { r = 233; g = 213; b = 255; } // white core + // subtle scanline shading + if (((x + y) >> 3) % 2 === 0 && t <= 0.82) { r *= 0.92; g *= 0.92; b *= 0.95; } + } + px[i] = Math.min(255, r | 0); px[i + 1] = Math.min(255, g | 0); + px[i + 2] = Math.min(255, b | 0); px[i + 3] = 255; + } + } + return png(size, size, px); +} + +const outDir = path.join(__dirname, '..', 'icons'); +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir); +for (const s of [192, 512]) { + const f = path.join(outDir, `icon-${s}.png`); + fs.writeFileSync(f, makeIcon(s)); + console.log('wrote', f, fs.statSync(f).size, 'bytes'); +} diff --git a/tools/scan-calls.js b/tools/scan-calls.js new file mode 100644 index 0000000..0243162 --- /dev/null +++ b/tools/scan-calls.js @@ -0,0 +1,49 @@ +// dev tool: scan for calls to members/functions that are never defined +'use strict'; +const fs = require('fs'); +const files = fs.readdirSync('js').filter(f => f.endsWith('.js')); +let all = ''; +for (const f of files) all += fs.readFileSync('js/' + f, 'utf8') + '\n'; + +const defs = new Set(); +for (const m of all.matchAll(/(?:^|\n)[ \t]*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^()]*\)\s*\{/g)) defs.add(m[1]); +for (const m of all.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)/g)) defs.add(m[1]); +for (const m of all.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defs.add(m[1]); + +const allow = new Set([ + 'push','pop','shift','unshift','splice','slice','concat','join','map','filter','forEach', + 'reduce','find','findIndex','some','every','includes','indexOf','sort','fill','keys', + 'values','entries','from','isArray','assign','create','freeze','getOwnPropertyNames', + 'setItem','getItem','removeItem','clear','log','warn','error','now','floor','ceil','round', + 'abs','min','max','sqrt','pow','sin','cos','tan','atan','atan2','random','hypot','sign','exp', + 'add','has','delete','get','set', + 'translate','rotate','scale','save','restore','beginPath','closePath','moveTo','lineTo','arc', + 'arcTo','quadraticCurveTo','bezierCurveTo','rect','fill','stroke','clip','fillText','strokeText', + 'measureText','drawImage','createLinearGradient','createRadialGradient','createPattern','fillRect', + 'strokeRect','clearRect','setTransform','resetTransform','ellipse', + 'play','pause','resume','connect','start','stop','setValueAtTime','linearRampToValueAtTime', + 'exponentialRampToValueAtTime','createGain','createOscillator','createBuffer','createBufferSource', + 'createBiquadFilter', + 'addEventListener','removeEventListener','preventDefault','stopPropagation','appendChild', + 'removeChild','remove','querySelector','querySelectorAll','getElementById','createElement', + 'toggle','contains','setAttribute','getAttribute','insertBefore','closest','matches', + 'test','exec','replace','split','trim','toLowerCase','toUpperCase','charAt','charCodeAt', + 'padStart','padEnd','repeat','match','matchAll','toString','toFixed','stringify','parse', + 'requestAnimationFrame','setTimeout','setInterval','clearTimeout','clearInterval','fetch', + 'then','catch','finally','bind','call','apply' +]); + +let bad = 0; +for (const m of all.matchAll(/\b(UI|Sys|R|Snd|Store|GAME)\.([A-Za-z_$][\w$]*)\s*\(/g)) { + const meth = m[2]; + if (!allow.has(meth) && !defs.has(meth)) { console.log('MISSING:', m[1] + '.' + meth); bad++; } +} +console.log(bad === 0 ? 'No missing member calls OK' : bad + ' issues'); + +for (const f of ['render.js', 'systems.js', 'ui.js', 'audio.js', 'save.js']) { + const src = fs.readFileSync('js/' + f, 'utf8'); + for (const m of src.matchAll(/this\.(_?[A-Za-z_$][\w$]*)\s*\(/g)) { + if (!defs.has(m[1])) console.log('MISSING this.' + m[1] + ' in ' + f); + } +} +console.log('this-method scan done'); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..948f1aa --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,8 @@ +name = "neon-survivors-lb" +main = "server/lb-worker.js" +compatibility_date = "2025-01-01" + +# After `wrangler kv:namespace create LB`, paste the real id below: +[[kv_namespaces]] +binding = "LB" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID"