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:
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
 
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ▶️ Cách chạy
|
||||||
|
|
||||||
|
**Cách 1 — mở thẳng file:** nhấp đúp vào `index.html` (Chrome / Edge / Firefox).
|
||||||
|
|
||||||
|
**Cách 2 — qua local server (khuyến nghị cho mobile LAN):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 2dsurvivor
|
||||||
|
python3 -m http.server 8080 # hoặc: npx serve .
|
||||||
|
# mở http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Điều khiển
|
||||||
|
|
||||||
|
| Hành động | Desktop | Mobile |
|
||||||
|
|---|---|---|
|
||||||
|
| Di chuyển | `WASD` / Phím mũi tên | Kéo ngón tay trên màn hình (joystick ảo) |
|
||||||
|
| Tấn công | **Tự động** — cứ việc né! | Tự động |
|
||||||
|
| Tạm dừng | `Esc` / `P` | Nút hệ thống |
|
||||||
|
| Chọn nâng cấp | Chuột / `1`–`4` | Chạm |
|
||||||
|
|
||||||
|
## ✨ Tính năng
|
||||||
|
|
||||||
|
### Gameplay
|
||||||
|
- **11 vũ khí** riêng biệt (đũa phép, roi, phi đao, quả cầu quay, rìu, hào quang, thiên lôi,
|
||||||
|
cầu lửa, băng tinh, thánh giá, 🧨 mìn hẹn giờ) — mỗi vũ khí **8 cấp** với bảng nâng cấp riêng.
|
||||||
|
- **11 dạng tiến hóa**: (mìn → 💣 Vườn Pháo Hoa) vũ khí đầy cấp + sở hữu passive tương ứng → mở **Rương Báu Vật**
|
||||||
|
(rơi từ quái Tinh Anh & Trùm) để tiến hóa (VD: Đũa Phép + Siêu Dẫn ⇒ 🌟 Bão Thần Tín).
|
||||||
|
- **13 vật phẩm bị động** (sát thương, giáp, HP, hồi máu, hồi chiêu, diện tích, tốc đạn,
|
||||||
|
thời gian, di chuyển, nam châm, may mắn, tham lam, tuệ tăng).
|
||||||
|
- **10 nhân vật** mở khóa dần (thêm 🏹 Hana chí-mạng & 🧱 Bram diện-tích), kèm **🎨 skin màu** mua bằng vàng.
|
||||||
|
- **Đạo quái theo phút**: 8 loại quái thường (đuổi, bắn, bay lượn, húc, chia màn…),
|
||||||
|
quái **Tinh Anh** định kỳ, và **4 Trùm lớn** ở phút 5/10/19:
|
||||||
|
🐺 Sói Đầu Lớn → 💀 Pháp Sư Xương → 👹 Quái Vật Địa Ngục → ⚰ **Tử Thần**.
|
||||||
|
Hạ Tử Thần ở phút 19 để **chiến thắng**.
|
||||||
|
- Hiệu ứng trạng thái: cháy, làm chậm, đóng băng (kèm sát thương vỡ băng), choáng, đẩy lùi, hút máu.
|
||||||
|
- Chí mạng, số sát thương nổi, hạt hiệu ứng, rung màn hình, mũi tên chỉ trùm ngoài màn hình.
|
||||||
|
|
||||||
|
### Tiến trình & tùy chọn
|
||||||
|
- **Meta-progression**: vàng tích lũy vĩnh viễn, cửa hàng **12 nâng cấp vĩnh viễn** (có hồi sinh, bốc lại…).
|
||||||
|
- Lưu tự động bằng `localStorage` (khóa `neon_survivors_v1`), kèm nút xóa dữ liệu.
|
||||||
|
- **Tùy chọn đầy đủ**: âm lượng chung/nhạc/SFX, rung màn hình, số sát thương, chất lượng hạt
|
||||||
|
(Thấp/Vừa/Cao), hiện FPS, tự tạm dừng khi mất tiêu điểm, **ngôn ngữ Việt/Anh** chuyển tức thì,
|
||||||
|
**fullscreen**, **remap phím di chuyển**.
|
||||||
|
- Bách khoa toàn thư (Codex): xem toàn bộ vũ khí/passive/quái/trùm/nhân vật + điều kiện mở khóa.
|
||||||
|
- Màn hình tổng kết: thời gian, kills, vàng, cấp độ, kỷ lục cá nhân.
|
||||||
|
- Chơi được trên **desktop lẫn mobile** (joystick ảo), responsive theo DPR; cài được như app (PWA).
|
||||||
|
|
||||||
|
### 🆕 Phase 1 — thương mại hóa
|
||||||
|
- **3 biome**: 🪦 Nghĩa Địa Neon → ❄️ Hang Băng (mở ở mốc 10 phút) → 🌋 Vực Magma (mở sau 1 thắng);
|
||||||
|
mỗi biome có bảng quái, kế hoạch trùm, palette và hệ số HP/vàng riêng.
|
||||||
|
- **Chế độ Vô tận (∞)** + **4 bậc độ khó** (I → 💀): HP/sát thương tăng, vàng thưởng tăng.
|
||||||
|
- **⚡ Thử thách hôm nay**: seed theo ngày UTC — cả thế giới chơi cùng 1 màn (nhân vật/stage cố định),
|
||||||
|
lưu kỷ lục ngày.
|
||||||
|
- **🏆 23 thành tựu** gắn tiến trình thật, popup vàng + tab riêng trong Codex.
|
||||||
|
- **💎 Đèn pha lê phá được** rơi quanh bản đồ → xu + gà.
|
||||||
|
- **📜 Lịch sử 20 ván gần nhất** + tổng hợp (tỉ lệ thắng, nhân vật chủ lực).
|
||||||
|
- **💾 Xuất/Nhập save** dạng mã base64 (chuyển máy).
|
||||||
|
- **Tutorial tương tác** lần đầu chơi + mẹo trùm.
|
||||||
|
- **Gamepad** (stick trái/D-pad/A/Start), perf: viewport culling + object pool particle.
|
||||||
|
- **🥇 Bảng xếp hạng**: scaffold Cloudflare Worker KV (`server/lb-worker.js` + `wrangler.toml`) —
|
||||||
|
điền URL vào Tuỳ chọn là tự submit/top; chữ ký checksum chống sửa tay (client↔worker khớp 100%).
|
||||||
|
|
||||||
|
## 🔓 Gợi ý mở khóa
|
||||||
|
|
||||||
|
| Nhân vật | Điều kiện |
|
||||||
|
|---|---|
|
||||||
|
| Mira | Diệt tổng cộng 500 quái |
|
||||||
|
| Bolt | Sống sót đến phút 10 |
|
||||||
|
| Luna | Kiếm tổng cộng 2.000 vàng |
|
||||||
|
| Rook | Hạ trùm đầu tiên (phút 5) |
|
||||||
|
| Nyx | Sống sót đến phút 15 |
|
||||||
|
| Sera | Thắng 1 ván |
|
||||||
|
| Vane | Diệt tổng cộng 5.000 quái |
|
||||||
|
|
||||||
|
## 🧪 Kiểm thử (dành cho dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test/integrity.js
|
||||||
|
```
|
||||||
|
Bộ test headless kiểm tra tính toàn vẹn dữ liệu (tiến hóa, bảng spawn, song ngữ VN/EN, bảng stat)
|
||||||
|
và **mô phỏng 2.5 phút chiến đấu thật** trong sandbox VM: bắn, chết, rớt đồ, lên cấp, mở rương
|
||||||
|
tiến hóa, não AI của cả 4 trùm, i-frame và dòng chết.
|
||||||
|
|
||||||
|
## 📁 Cấu trúc
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html entry point
|
||||||
|
css/style.css theme neon tối
|
||||||
|
js/
|
||||||
|
util.js toán học, RNG, spatial grid broadphase
|
||||||
|
i18n.js từ điển VI/EN
|
||||||
|
save.js localStorage meta-progression
|
||||||
|
audio.js tổng hợp SFX + nhạc nền generative (WebAudio)
|
||||||
|
data.js TOÀN BỘ nội dung: vũ khí/passive/quái/trùm/cửa hàng
|
||||||
|
entities.js Player / Enemy / Proj / Pickup / Particle…
|
||||||
|
systems.js spawn director, AI, combat, pickups, level-up, chest
|
||||||
|
render.js vẽ canvas (nhân vật vector, hiệu ứng, vignette)
|
||||||
|
ui.js menu / HUD / modal DOM
|
||||||
|
game.js vòng lặp, trạng thái, input, camera
|
||||||
|
test/integrity.js bộ test headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Chúc bạn sống sót qua phút thứ 19! ⚰️🔥
|
||||||
+387
@@ -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}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 📊 GAP ANALYSIS — Thương mại hóa NEON SURVIVORS
|
||||||
|
|
||||||
|
> Trạng thái đánh giá: bản hiện tại (v1.0 — core loop hoàn chỉnh, playable end-to-end).
|
||||||
|
> Mục tiêu: trả lời "thiếu gì để bán được tiền / lên store".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Điểm mạnh sẵn có (không phải làm lại)
|
||||||
|
|
||||||
|
| Hạng mục | Trạng thái |
|
||||||
|
|---|---|
|
||||||
|
| Core loop | ✅ Hoàn chỉnh: bắn tự động, XP, level-up choice, chest/evolution, boss, win/lose |
|
||||||
|
| Nội dung nền | 10 vũ khí + 10 tiến hóa, 13 passive, 8 nhân vật, 8 loại quái, 4 trùm |
|
||||||
|
| Meta progression | Vàng vĩnh viễn + 12 nâng cấp shop + unlock nhân vật theo thành tích |
|
||||||
|
| Kỹ thuật | Không dependency, offline-first, WebAudio tự sinh, test headless, dev tools |
|
||||||
|
| Platform-ready | Touch + desktop, responsive DPR, i18n VI/EN, localStorage |
|
||||||
|
|
||||||
|
→ Đây là mức **"vertical slice tốt"**. Khoảng cách tới sản phẩm thương mại nằm ở các nhóm dưới.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Khoảng trống — xếp theo độ chặn đường kinh doanh
|
||||||
|
|
||||||
|
### 🔴 Nhóm A — CHẶN BÁN (bắt buộc có trước khi thu tiền)
|
||||||
|
|
||||||
|
| # | Thiếu | Vì sao chặn | Effort |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | **Art thật thay programmer-art** | Emoji + hình vector là chuẩn prototype. Store screenshot/video quyết định 80% conversion. Đây là điểm yếu lớn nhất | Lớn (asset pack ~$50–300 hoặc commission $500–2000) |
|
||||||
|
| A2 | **Âm nhạc/nhạc stage** | Loop generative 1 track duy nhất → nghe chán sau 10 phút. Cần 3–5 track (menu/stage/boss) + stingers thắng/thua | Trung ($20 license pack hoặc commission) |
|
||||||
|
| A3 | **Đủ nội dung để chơi lại (replayability)** | 1 stage duy nhất × 20 phút × cùng timeline mỗi lần = người chơi hết việc sau 2–3 giờ. Genre chuẩn cần ≥3 stage + endless + độ khó leo thang | Trung — code sẵn chỗ cắm (SPAWN_TABLE/BOSSES là data) |
|
||||||
|
| A4 | **Tutorial onboarding** | Hiện 1 dòng toast. Người mới không hiểu evolution/chest/meta → churn ngay phút đầu | Nhỏ |
|
||||||
|
| A5 | **Pháp lý**: Privacy Policy, EULA, tuổi (IARC), kiểm tra trùng tên "Neon Survivors" | Store từ chối duyệt nếu thiếu; trademark trùng bị gỡ | Nhỏ (giấy tờ) + phí nền tảng |
|
||||||
|
|
||||||
|
### 🟠 Nhóm B — QUYẾT ĐỊNH DOANH THU (chọn mô hình thì làm)
|
||||||
|
|
||||||
|
Tùy mô hình:
|
||||||
|
|
||||||
|
**Premium (Steam, $4.99 — mô hình của chính Vampire Survivors):**
|
||||||
|
| # | Thiếu | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | Steam wrapper (Tauri/Electron) + Steamworks: achievements, cloud save, overlay | Cần tài khoản Steam partner ($100/app) |
|
||||||
|
| B2 | **Achievements hệ thống trong game** (30–50 thành tựu gắn unlock) | Hiện chưa có khung achievement |
|
||||||
|
| B3 | DLC pipeline: bộ nhân vật/stage/cosmetic bán thêm | Cần tách data content khỏi code (đang hard-code JS) |
|
||||||
|
| B4 | **Mod support** (Workshop) | VS sống dai nhờ mod; yêu cầu data-driven + sandbox loader |
|
||||||
|
|
||||||
|
**F2P (mobile/web portal):**
|
||||||
|
| # | Thiếu | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| B5 | **Rewarded ads** (xem quảng cáo: hồi sinh, x2 vàng, reroll thêm) | Nguồn thu chính F2P; tích AdMob/AppLovin |
|
||||||
|
| B6 | IAP: gói khởi đầu, skin nhân vật/vũ khí, bỏ quảng cáo | Cần cosmetic system (hiện không có skin layer) |
|
||||||
|
| B7 | Daily quests + daily login + mission chain | Retention D1/D7 |
|
||||||
|
| B8 | Battle pass mùa (nếu đủ scale) | Chỉ làm khi có DAU |
|
||||||
|
|
||||||
|
### 🟡 Nhóm C — GIỮ NGƯỜI CHƠI & VẬN HÀNH
|
||||||
|
|
||||||
|
| # | Thiếu | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 | **Leaderboard** (daily-seeded run, global + bạn bè) | Cần backend nhẹ — hợp nhất: Cloudflare Workers + KV/D1 (đang dùng CF tunnel sẵn) |
|
||||||
|
| C2 | Cloud save / account | Cùng backend ở trên; cần chống sửa save (checksum/signed) |
|
||||||
|
| C3 | Analytics funnel (install→phút 5→churn), remote config để tune balance không cần update | Tự host hoặc PostHog/Firebase |
|
||||||
|
| C4 | Crash reporting (Sentry), profiling budget 60fps mobile low-end | |
|
||||||
|
| C5 | Run history / stats cá nhân theo nhân vật-stage, best từng loại | |
|
||||||
|
| C6 | Live-ops cadence: cập nhật nhân vật/vũ khí 4–8 tuần/lần | Quy trình, không phải code |
|
||||||
|
|
||||||
|
### 🟢 Nhóm D — CHẤT LƯỢNG & TIỆN NGHI (nâng cấp dần)
|
||||||
|
|
||||||
|
| # | Thiếu | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | **Gamepad support + remap phím** | Genre này kỳ vọng cao; Gamepad API |
|
||||||
|
| D2 | Viewport culling + object pooling | Hiện render mọi entity kể cả ngoài màn hình; GC pressure do tạo object liên tục — quan trọng cho mobile low-end |
|
||||||
|
| D3 | Seeded RNG | Math.random hiện tại — chặn daily-challenge/replay/anti-cheat |
|
||||||
|
| D4 | Fullscreen toggle, khóa hướng ngang mobile, PWA manifest | |
|
||||||
|
| D5 | Accessibility: colorblind palette, giảm motion, chữ to | |
|
||||||
|
| D6 | Save slot + export/import code | |
|
||||||
|
| D7 | Bản dịch thêm (ES/PT-BR/ZH/JA...) — khung i18n sẵn | |
|
||||||
|
| D8 | Interactive props trong trận (đèn phá rơi vàng, bẫy môi trường) | Genre-standard juice |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Roadmap đề xuất
|
||||||
|
|
||||||
|
```
|
||||||
|
PHASE 1 — "Store-ready core" (2–4 tuần dev)
|
||||||
|
├─ D2 culling + pooling (perf mobile)
|
||||||
|
├─ D1 gamepad + D4 fullscreen/PWA
|
||||||
|
├─ A4 tutorial tương tác run đầu
|
||||||
|
├─ A3 stage #2/#3 (biome + boss riêng) + Endless + Difficulty grade
|
||||||
|
├─ B2 khung achievements (30 cái) — dùng chung cho Steam sau này
|
||||||
|
├─ C5 run history
|
||||||
|
└─ D3 seeded RNG (chuẩn bị daily)
|
||||||
|
|
||||||
|
PHASE 2 — "Thu tiền được" (song song art/audio)
|
||||||
|
├─ A1+A2 art pack + soundtrack
|
||||||
|
├─ A5 pháp lý + chọn model (premium vs F2P)
|
||||||
|
├─ Steam build (B1) HOẶC mobile build (B5–B7)
|
||||||
|
└─ C3 analytics + remote config
|
||||||
|
|
||||||
|
PHASE 3 — "Sống lâu"
|
||||||
|
├─ C1+C2 leaderboard/cloud-save (Cloudflare Workers)
|
||||||
|
├─ B3 DLC / B8 pass
|
||||||
|
├─ B4 mod support
|
||||||
|
└─ C6 live-ops nhịp 4–8 tuần
|
||||||
|
```
|
||||||
|
|
||||||
|
Chi phí nền tảng tham khảo: Steam $100/app · Google Play $25 một lần · Apple $99/năm · Web portal (Poki/CrazyGames): miễn phí, chia doanh thu quảng cáo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tôi có thể code NGAY trong repo này (không cần asset ngoài)
|
||||||
|
|
||||||
|
1. **D2** Viewport culling + object pool cho Particle/Proj (perf)
|
||||||
|
2. **D1** Gamepad API + remap phím
|
||||||
|
3. **A3** Stage system: 2 biome mới + Endless mode + difficulty grade (data-driven sẵn)
|
||||||
|
4. **B2** Khung achievements + popup + gắn unlock (dùng luôn điều kiện cond của nhân vật)
|
||||||
|
5. **D3** Seeded RNG + **Daily Challenge** (seed theo ngày UTC, lưu best local)
|
||||||
|
6. **A4** Tutorial tương tác (mốc: di chuyển → lên cấp → chọn → mở rương → gặp trùm)
|
||||||
|
7. **C5** Run history + trang stats
|
||||||
|
8. **D6** Export/import save code (base64)
|
||||||
|
9. **D8** Props phá được (đèn đá rơi vàng/chicken)
|
||||||
|
10. **D4** Fullscreen + PWA manifest
|
||||||
|
|
||||||
|
> Nói "làm phase 1" (hoặc liệt kê số) là tôi triển khai tiếp.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
+226
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+204
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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); }
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user