NEON SURVIVORS v1.1 — full-featured bullet-heaven survivor game

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)
This commit is contained in:
neon-survivors-dev
2026-08-23 07:01:22 +00:00
commit 796880375e
26 changed files with 7447 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
Thumbs.db
*.log
node_modules/
dist/
+114
View File
@@ -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)
**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! ⚰️🔥
+387
View File
@@ -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}
}
+122
View File
@@ -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 ~$50300 hoặc commission $5002000) |
| A2 | **Âm nhạc/nhạc stage** | Loop generative 1 track duy nhất → nghe chán sau 10 phút. Cần 35 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 23 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** (3050 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í 48 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" (24 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 (B5B7)
└─ 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 48 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+226
View File
@@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#7c5cff">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<title>NEON SURVIVORS — Bullet Heaven</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<canvas id="game"></canvas>
<!-- FPS counter -->
<div id="fpsMeter" class="hidden">60</div>
<!-- Touch joystick -->
<div id="touchLayer" class="hidden">
<div id="stickBase"><div id="stickKnob"></div></div>
</div>
<!-- ===== UI OVERLAY ===== -->
<div id="ui">
<!-- Loading splash -->
<div class="screen" id="scr-loading">
<div class="logo-pop">NEON<span>SURVIVORS</span></div>
<div class="loading-bar"><i></i></div>
</div>
<!-- Main menu -->
<div class="screen hidden" id="scr-menu">
<div class="menu-wrap">
<header class="menu-head">
<h1 class="logo"><span class="l1">NEON</span><span class="l2">SURVIVORS</span></h1>
<p class="tagline" data-i18n="tagline"></p>
<div class="ver-chip">BULLET HEAVEN · v1.1</div>
</header>
<nav class="menu-btns">
<button class="mbtn primary" id="bt-play"><i></i><span data-i18n="btn_play"></span></button>
<button class="mbtn" id="bt-daily"><i></i><span data-i18n="btn_daily"></span></button>
<button class="mbtn" id="bt-shop"><i>🛒</i><span data-i18n="ms_title"></span></button>
<button class="mbtn" id="bt-codex"><i>📖</i><span data-i18n="btn_codex"></span></button>
<button class="mbtn" id="bt-history"><i>📜</i><span data-i18n="btn_history"></span></button>
<button class="mbtn" id="bt-board"><i>🥇</i><span data-i18n="btn_board"></span></button>
<button class="mbtn" id="bt-options"><i>⚙️</i><span data-i18n="btn_options"></span></button>
</nav>
<footer class="menu-foot">
<div class="menu-stats">
<span class="chip">🪙 <b id="menuGold">0</b></span>
<span class="chip">💀 <b id="menuKills">0</b></span>
<span class="chip">🏆 <b id="menuBest">00:00</b></span>
</div>
<p class="hint" data-i18n="tip_move"></p>
</footer>
</div>
</div>
<!-- Character select -->
<div class="screen hidden" id="scr-chars">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="scr_chars"></h2><span class="head-gold">🪙 <b>0</b></span></header>
<div class="card-grid" id="charGrid"></div>
</div>
<!-- Stage select -->
<div class="screen hidden" id="scr-stage">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="st_select"></h2><span class="head-gold">🪙 <b>0</b></span></header>
<div class="stage-grid" id="stageGrid"></div>
<div class="stage-opts" id="stageOpts"></div>
<div class="start-bar" style="position:absolute;bottom:1rem;left:0;right:0;display:flex;justify-content:center;z-index:5">
<button class="btn btn-primary btn-big" id="bt-stage-go"></button>
</div>
</div>
<!-- Run history -->
<div class="screen hidden" id="scr-history">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="btn_history"></h2><span></span></header>
<div class="hist-summary" id="histSummary"></div>
<div class="hist-list" id="histList"></div>
</div>
<!-- Meta shop -->
<div class="screen hidden" id="scr-shop">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="ms_title"></h2><span class="head-gold">🪙 <b>0</b></span></header>
<div class="shop-grid" id="shopGrid"></div>
</div>
<!-- Codex -->
<div class="screen hidden" id="scr-codex">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="btn_codex"></h2><span></span></header>
<nav class="tabs" id="codexTabs">
<button class="tab active" data-tab="weapons" data-i18n="cx_weapons"></button>
<button class="tab" data-tab="passives" data-i18n="cx_passives"></button>
<button class="tab" data-tab="enemies" data-i18n="cx_enemies"></button>
<button class="tab" data-tab="chars" data-i18n="cx_chars"></button>
<button class="tab" data-tab="ach">🏆 <span data-i18n="cx_ach"></span></button>
</nav>
<div class="codex-list" id="codexList"></div>
</div>
<!-- Options -->
<div class="screen hidden" id="scr-options">
<header class="scr-head"><button class="btn btn-icon back-btn"></button><h2 data-i18n="btn_options"></h2><span></span></header>
<div class="opt-panel" id="optPanel"></div>
</div>
<!-- HUD -->
<div class="hidden" id="hud">
<div id="xpBar"><i id="xpFill"></i><span id="lvlTag">LV 1</span></div>
<div id="hudTop">
<div id="hudPortrait"><span id="portraitSym">🧙</span><em id="hudLevel">1</em></div>
<div id="hudTimer">00:00</div>
<div id="hudRight"><span id="hudGold">🪙 0</span><span id="hudKills">💀 0</span></div>
</div>
<div id="bossBar" class="hidden"><label id="bossName"></label><div class="bb-track"><i id="bossFill"></i></div></div>
<div id="slotWeapons"></div>
<div id="slotPassives"></div>
</div>
<!-- Pause -->
<div class="modal hidden" id="md-pause">
<div class="panel">
<h2 data-i18n="scr_pause"></h2>
<div id="pauseStats" class="pause-stats"></div>
<div class="col-btns">
<button class="btn btn-primary" id="bt-resume" data-i18n="btn_resume"></button>
<button class="btn" id="bt-poptions" data-i18n="btn_options"></button>
<button class="btn btn-danger" id="bt-quit" data-i18n="btn_quitmenu"></button>
</div>
</div>
</div>
<!-- Level up -->
<div class="modal hidden" id="md-level">
<div class="panel panel-up">
<h2 data-i18n="lv_title"></h2>
<p class="sub" data-i18n="lv_choose"></p>
<div id="levelChoices" class="choice-list"></div>
<div class="row-btns">
<button class="btn btn-small" id="bt-reroll"></button>
<button class="btn btn-small" id="bt-skipLv"></button>
</div>
</div>
</div>
<!-- Chest -->
<div class="modal hidden" id="md-chest">
<div class="panel panel-chest">
<div class="chest-art" id="chestArt">🎁</div>
<h2 id="chestTitle"></h2>
<div id="chestBody"></div>
<button class="btn btn-primary" id="bt-chestOk" data-i18n="btn_continue"></button>
</div>
</div>
<!-- Game over / victory -->
<div class="modal hidden" id="md-over">
<div class="panel panel-over">
<h2 id="overTitle"></h2>
<p id="overRecord" class="record hidden" data-i18n="es_newrec"></p>
<div class="over-grid" id="overStats"></div>
<div class="col-btns">
<button class="btn btn-primary" id="bt-retry" data-i18n="btn_restart"></button>
<button class="btn" id="bt-tomenu" data-i18n="btn_quitmenu"></button>
</div>
</div>
</div>
<!-- Developer tools -->
<button id="devBtn" class="hidden" title="Developer Tools (`)">🔧</button>
<div class="modal hidden" id="md-dev">
<div class="panel panel-dev" id="devPanel"></div>
</div>
<!-- Save export / import -->
<div class="modal hidden" id="md-save">
<div class="panel panel-save">
<h3 data-i18n="save_title"></h3>
<textarea id="saveArea" rows="7" spellcheck="false"></textarea>
<div class="save-btns">
<button class="btn btn-small" id="bt-save-copy" data-i18n="save_copy"></button>
<button class="btn btn-small" id="bt-save-import" data-i18n="save_import"></button>
<button class="btn btn-small" id="bt-save-close" data-i18n="btn_close"></button>
</div>
</div>
</div>
<!-- Leaderboard -->
<div class="modal hidden" id="md-lb">
<div class="panel panel-lb">
<h3 data-i18n="lb_title"></h3>
<div class="seg lb-scopes" id="lbScopes"></div>
<div class="lb-list" id="lbList"></div>
<div class="save-btns"><button class="btn btn-small" id="bt-lb-close" data-i18n="btn_close"></button></div>
</div>
</div>
<!-- Tutorial bubble -->
<div id="tutTip" class="hidden">
<span id="tutText"></span>
<button id="bt-tut-skip"></button>
</div>
<!-- Toasts -->
<div id="toasts"></div>
</div>
<script src="js/util.js"></script>
<script src="js/i18n.js"></script>
<script src="js/save.js"></script>
<script src="js/audio.js"></script>
<script src="js/data.js"></script>
<script src="js/achievements.js"></script>
<script src="js/entities.js"></script>
<script src="js/systems.js"></script>
<script src="js/render.js"></script>
<script src="js/ui.js"></script>
<script src="js/game.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+90
View File
@@ -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 };
}
+195
View File
@@ -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); }
};
+388
View File
@@ -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)); }
+280
View File
@@ -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;
}
}
+739
View File
@@ -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);
}
}
+486
View File
@@ -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';
}
+33
View File
@@ -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());
})();
+983
View File
@@ -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();
}
};
+100
View File
@@ -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); }
};
+1262
View File
File diff suppressed because it is too large Load Diff
+1109
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -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;
};
}
+15
View File
@@ -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" }
]
}
+99
View File
@@ -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);
}
};
+51
View File
@@ -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)
)
);
});
+405
View File
@@ -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); }
+97
View File
@@ -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');
}
+49
View File
@@ -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');
+8
View File
@@ -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"