Files
neon-survivors-dev 796880375e 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)
2026-08-23 07:01:22 +00:00

1110 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ============================================================
NEON SURVIVORS — ui.js : DOM overlay management
============================================================ */
const UI = {
G: null,
els: {},
_slotSig: '',
_charSel: 'kaito',
_codexTab: 'weapons',
_resetArm: false,
$(id) { return document.getElementById(id); },
init(game) {
this.G = game;
const ids = ['fpsMeter','scr-loading','scr-menu','scr-chars','scr-stage','scr-history','scr-shop','scr-codex','scr-options',
'hud','md-pause','md-level','md-chest','md-over','md-dev','devPanel','devBtn','toasts',
'xpFill','lvlTag','hudPortrait','portraitSym','hudLevel','hudTimer','hudGold','hudKills',
'bossBar','bossName','bossFill','slotWeapons','slotPassives',
'charGrid','stageGrid','stageOpts','bt-stage-go','shopGrid','codexList','codexTabs','optPanel',
'levelChoices','bt-reroll','bt-skipLv','chestArt','chestTitle','chestBody','bt-chestOk',
'overTitle','overRecord','overStats','pauseStats',
'menuGold','menuKills','menuBest',
'histSummary','histList','md-save','saveArea','tutTip','tutText',
'md-lb','lbScopes','lbList'];
for (const id of ids) this.els[id] = this.$(id);
this._bindButtons();
this._buildStartBar();
this.syncLang();
},
/* ================= language ================= */
syncLang() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = tr(el.getAttribute('data-i18n'));
});
document.documentElement.lang = currentLang();
// dynamic screens re-render
this.buildShop();
this.buildChars();
this.buildCodex(this._codexTab);
this.buildOptions();
this.refreshMenuStats();
this._slotSig = ''; // force slot rebuild (names are icons though)
},
/* ================= screens ================= */
showScreen(name) {
for (const id of ['scr-loading','scr-menu','scr-chars','scr-stage','scr-history','scr-shop','scr-codex','scr-options'])
this.els[id].classList.add('hidden');
for (const id of ['md-pause','md-level','md-chest','md-over'])
this.els[id].classList.add('hidden');
if (name && this.els[name]) this.els[name].classList.remove('hidden');
if (name === 'scr-menu') {
this.els.hud.classList.add('hidden');
this.hideBossBar();
this.refreshMenuStats();
// restart entrance animations
const scr = this.els['scr-menu'];
scr.classList.remove('enter');
void scr.offsetWidth;
scr.classList.add('enter');
}
},
showModal(id) { this.els[id] && this.els[id].classList.remove('hidden'); },
hideModal(id) { this.els[id] && this.els[id].classList.add('hidden'); },
refreshMenuStats() {
if (!this.els.menuGold) return;
this.els.menuGold.textContent = fmtNum(Store.data.gold);
this.els.menuKills.textContent = fmtNum(Store.data.totals.kills);
this.els.menuBest.textContent = fmtTime(Store.data.totals.best);
// daily button shows today's best when one exists
const bd = this.$('bt-daily');
if (bd) {
const d = Store.data.daily;
let html = '<i>⚡</i><span>' + tr('btn_daily') + '</span>';
if (d.date === dailyKey() && d.best > 0)
html += '<em class="daily-best">🏆 ' + fmtTime(d.best) + '</em>';
bd.innerHTML = html;
}
this.checkAndToastAch();
},
/** Evaluate achievements; pop a gold toast + jingle per new unlock (+100🪙 each). */
checkAndToastAch() {
if (typeof checkAchievements !== 'function') return;
const newly = checkAchievements();
if (!newly.length) return;
Store.addGold(100 * newly.length); // unlock reward
const en = currentLang() === 'en';
const a = newly[0];
let msg = '🏆 ' + (en ? a.en : a.vi) + ' — ' + (en ? a.den : a.dvi) + ' (+100🪙)';
if (newly.length > 1) msg += ` +${newly.length - 1} more`;
this.toast(msg, 'gold');
Snd.play('chest');
},
/* ================= run history ================= */
buildHistory() {
const sum = this.els.histSummary, list = this.els.histList;
if (!list) return;
const T = Store.data.totals, H = Store.data.history || [];
const wins = H.filter(r => r.w).length;
const avg = H.length ? H.reduce((a, r) => a + (r.t || 0), 0) / H.length : 0;
// favorite character
const freq = {};
let fav = '—';
for (const r of H) { freq[r.ch] = (freq[r.ch] || 0) + 1; if (freq[r.ch] > (freq[fav] || 0)) fav = r.ch; }
const en = currentLang() === 'en';
sum.innerHTML =
'<span>' + tr('hist_run') + ': <b>' + H.length + '</b></span>' +
'<span>👑 <b>' + wins + '</b></span>' +
'<span>⏱ ~<b>' + fmtTime(avg) + '</b></span>' +
'<span>' + (fav !== '—' && CHARS[fav] ? CHARS[fav].sym + ' <b>' + tr('c_' + fav) + '</b>' : '—') + '</span>';
list.innerHTML = '';
if (!H.length) {
const p = document.createElement('p');
p.className = 'hist-empty';
p.textContent = tr('hist_empty');
list.appendChild(p);
return;
}
for (const r of H) {
const row = document.createElement('div');
row.className = 'hist-row' + (r.w ? ' win' : '');
const ch = CHARS[r.ch], st = STAGES[r.st] || STAGES.graveyard;
const date = new Date(r.d).toLocaleDateString();
row.innerHTML =
'<span class="h-sym">' + (ch ? ch.sym : '❔') + '</span>' +
'<span class="h-stage">' + st.icon + '</span>' +
'<span class="h-date">' + date + '</span>' +
'<span class="h-time">' + fmtTime(r.t) + '</span>' +
'<span class="h-meta">💀' + fmtNum(r.k) + ' · 🪙' + fmtNum(r.gold) + ' · Lv' + r.lv + '</span>' +
(r.g ? '<span class="h-grade">' + 'I'.repeat(r.g + 1) + '</span>' : '') +
'<span class="h-badge">' + (r.w ? '👑 ' + tr('hist_win') : '') + '</span>';
list.appendChild(row);
}
},
/* ================= leaderboard ================= */
openBoard(scope) {
const url = (Store.s().lbUrl || '').trim();
const listEl = this.els.lbList;
if (!listEl) return;
// scope chips: today + one per unlocked stage
const scopes = this.els.lbScopes || { innerHTML: '' };
scopes.innerHTML = '';
const mkChip = (label, sc) => {
const b = document.createElement('button');
b.className = 'btn btn-mini' + (sc === scope ? ' active' : '');
b.textContent = label;
b.addEventListener('click', () => { Snd.play('click'); this.openBoard(sc); });
scopes.appendChild(b);
};
mkChip(tr('lb_scope_daily'), 'daily:' + dailyKey());
for (const id in STAGES) {
mkChip(STAGES[id].icon + ' ' + tr('st_' + id), 'stage:' + id + ':0');
}
this.showModal('md-lb');
if (!url) {
listEl.innerHTML = '<p class="hist-empty">' + tr('lb_off') + '</p>';
return;
}
listEl.innerHTML = '<p class="hist-empty">…</p>';
fetch(url.replace(/\/$/, '') + '/top?scope=' + encodeURIComponent(scope) + '&n=10')
.then(r => r.json())
.then(j => {
const rows = (j && j.rows) || [];
if (!rows.length) { listEl.innerHTML = '<p class="hist-empty">' + tr('lb_empty') + '</p>'; return; }
listEl.innerHTML = rows.map((r, i) =>
'<div class="hist-row"><span class="h-rank">#' + (i + 1) + '</span>' +
'<span class="h-name">' + String(r.n).replace(/[<>&]/g, '') + '</span>' +
'<span class="h-time">' + fmtTime(r.t) + '</span>' +
'<span class="h-meta">💀' + fmtNum(r.k) + '</span></div>'
).join('');
})
.catch(() => { listEl.innerHTML = '<p class="hist-empty">⚠️</p>'; });
},
/* ================= save transfer ================= */
openSaveModal() {
const payload = 'NSV1.' + btoa(unescape(encodeURIComponent(JSON.stringify(Store.data))));
this.els.saveArea.value = payload;
this.showModal('md-save');
},
importSave() {
const raw = (this.els.saveArea.value || '').trim();
try {
const b64 = raw.startsWith('NSV1.') ? raw.slice(5) : raw;
const json = decodeURIComponent(escape(atob(b64)));
const obj = JSON.parse(json);
if (!obj || !obj.settings || !obj.totals) throw new Error('bad');
localStorage.setItem(SAVE_KEY, JSON.stringify(obj));
this.toast(tr('save_ok'), 'gold');
setTimeout(() => location.reload(), 900);
} catch (e) {
this.toast(tr('save_bad'), 'warn');
}
},
/* ================= tutorial bubble ================= */
showTut(key) {
if (!this.els.tutTip) return;
this.els.tutText.textContent = tr(key);
this.els.tutTip.classList.remove('hidden');
},
hideTut() {
if (this.els.tutTip) this.els.tutTip.classList.add('hidden');
},
/* ================= toasts ================= */
toast(msg, cls) {
const t = document.createElement('div');
t.className = 'toast' + (cls ? ' ' + cls : '');
t.textContent = msg;
this.els.toasts.appendChild(t);
setTimeout(() => t.remove(), 2600);
},
/* ================= HUD ================= */
showHud(v) { this.els.hud.classList.toggle('hidden', !v); },
showBossBar(b) {
this.els.bossBar.classList.remove('hidden');
this.els.bossName.textContent = tr(b.nk);
},
hideBossBar() { this.els.bossBar.classList.add('hidden'); },
hudUpdate(G, dt) {
const P = G.player;
const s = Store.s();
this.els.xpFill.style.width = clamp(P.xp / P.xpNext * 100, 0, 100) + '%';
this.els.lvlTag.textContent = 'LV ' + P.level + (G.grade ? ' · ' + 'I'.repeat(G.grade + 1) : '');
// endless mode marks the overtime clock
if (G.endless && G.stage && G.time > G.stage.length) {
this.els.hudTimer.innerHTML = '∞ <small>' + fmtTime(G.time) + '</small>';
this.els.hudTimer.classList.add('endless');
} else {
this.els.hudTimer.textContent = fmtTime(G.time);
this.els.hudTimer.classList.remove('endless');
}
this.els.hudGold.textContent = '🪙 ' + fmtNum(P.gold);
this.els.hudKills.textContent = '💀 ' + fmtNum(P.kills);
if (G.boss && !G.boss.dead) {
this.els.bossFill.style.width = clamp(G.boss.hp / G.boss.maxhp * 100, 0, 100) + '%';
}
// weapon/passive slots (rebuild only on change)
let sig = P.weapons.map(w => w.id + w.lvl).join(',') + '|' +
Object.entries(P.passives).map(([k, v]) => k + v).join(',');
if (sig !== this._slotSig) {
this._slotSig = sig;
const we = this.els.slotWeapons; we.innerHTML = '';
for (const w of P.weapons) {
const d = document.createElement('div');
d.className = 'slot' + (EVOLVED[w.id] ? ' evolved' : '');
d.title = tr(EVOLVED[w.id] ? w.id : 'w_' + w.id);
d.innerHTML = wdef(w.id).sym + '<span class="lv">' + (EVOLVED[w.id] ? '★' : w.lvl) + '</span>';
we.appendChild(d);
}
const pe = this.els.slotPassives; pe.innerHTML = '';
for (const pid in P.passives) {
const d = document.createElement('div');
d.className = 'slot passive';
d.title = tr(pid);
d.innerHTML = PASSIVES[pid].sym + '<span class="lv">' + P.passives[pid] + '</span>';
pe.appendChild(d);
}
}
// cooldown sweep on first slot row
const slots = this.els.slotWeapons.children;
for (let i = 0; i < slots.length && i < P.weapons.length; i++) {
const w = P.weapons[i];
if (EVOLVED[w.id]) continue;
const st = wstats(w.id, w.lvl);
const frac = clamp(1 - w.timer / Math.max(0.01, st.cd * P.cdrMul), 0, 1);
slots[i].style.setProperty('--p', ((1 - frac) * 100).toFixed(1) + '%');
slots[i].classList.toggle('cd', frac < 1);
}
},
/* ================= level up modal ================= */
openLevelUp(choices, rerolls) {
const wrap = this.els.levelChoices;
wrap.innerHTML = '';
choices.forEach((c, idx) => {
const d = Sys.describeChoice(c);
let lvLabel = '';
if (c.type === 'w') lvLabel = 'Lv.' + (this.G.player.weaponById(c.id).lvl + 1);
else if (c.type === 'nw') lvLabel = tr('new_weapon');
else if (c.type === 'p') {
const cur = this.G.player.passives[c.id] || 0;
lvLabel = cur === 0 ? tr('new_passive') : 'Lv.' + (cur + 1);
}
const el = document.createElement('div');
el.className = 'choice' + (d.isNew ? ' neww' : '') + (c.type === 'w' && this._isEvolveReady(c.id) ? ' evolve' : '');
el.innerHTML =
'<div class="ch-sym">' + d.sym + '</div>' +
'<div class="ch-info"><h4></h4><p></p></div>' +
'<span class="ch-lv"></span>';
el.querySelector('h4').textContent = d.title;
el.querySelector('p').textContent = d.desc;
el.querySelector('.ch-lv').textContent = lvLabel;
el.addEventListener('click', () => { Snd.play('click'); this.G.chooseUpgrade(idx); });
wrap.appendChild(el);
});
this.els['bt-reroll'].textContent = tr('lv_reroll', { n: rerolls });
this.els['bt-reroll'].disabled = rerolls <= 0;
this.els['bt-skipLv'].textContent = tr('lv_skip', { n: 15 });
this.showModal('md-level');
},
_isEvolveReady(wid) {
const P = this.G.player;
const def = WEAPONS[wid];
return def && P.passives[def.evo.need] && wid !== undefined;
},
/* ================= chest modal ================= */
openChest(data) {
if (data.evolve) {
this.els.chestTitle.textContent = tr('ch_evolved_title');
this.els.chestArt.textContent = wdef(data.into).sym;
const body = this.els.chestBody;
body.innerHTML = '';
const line = document.createElement('div');
line.className = 'chest-line';
line.innerHTML = '<b>' + tr('w_' + data.from) + '</b> ➜ <b style="color:' + wdef(data.into).col + '">' + tr(data.into) + '</b>';
body.appendChild(line);
const p = document.createElement('p');
p.style.cssText = 'color:var(--dim);font-size:.85rem;margin-top:.4rem';
p.textContent = tr(data.into + '_d');
body.appendChild(p);
} else {
this.els.chestTitle.textContent = tr('ch_loot_title');
this.els.chestArt.textContent = '🎁';
const body = this.els.chestBody;
body.innerHTML = '';
for (const ln of data.lines) {
const el = document.createElement('div');
el.className = 'chest-line';
el.textContent = '▲ ' + ln;
body.appendChild(el);
}
}
const g = document.createElement('div');
g.className = 'chest-line';
g.innerHTML = '🪙 <b>' + tr('ch_gold', { n: data.gold }) + '</b>';
this.els.chestBody.appendChild(g);
this.showModal('md-chest');
},
/* ================= pause ================= */
openPause(G) {
const P = G.player;
const rows = [
[tr('es_time'), fmtTime(G.time)],
[tr('es_level'), P.level],
[tr('es_kills'), P.kills],
[tr('es_gold'), P.gold]
];
this.els.pauseStats.innerHTML = rows.map(r => `<span>${r[0]}</span><b>${r[1]}</b>`).join('');
this.showModal('md-pause');
},
/* ================= game over ================= */
openOver(win, stats) {
this.els.overTitle.textContent = win ? tr('scr_win') : tr('scr_over');
this.els.overTitle.style.color = win ? '#7dff9e' : '#ff5f7a';
this.els.overRecord.classList.toggle('hidden', !stats.newRecord);
const rows = [
[tr('es_time'), fmtTime(stats.time)],
[tr('es_kills'), fmtNum(stats.kills)],
[tr('es_gold'), '+' + fmtNum(stats.goldEarned) + ' 🪙'],
[tr('es_level'), stats.level],
[tr('es_best'), fmtTime(Math.max(stats.best, stats.time))]
];
this.els.overStats.innerHTML = rows.map(r => `<span>${r[0]}</span><b>${r[1]}</b>`).join('');
this.els.hud.classList.add('hidden');
this.hideBossBar();
this.showModal('md-over');
},
/* ================= character select ================= */
_buildStartBar() {
const bar = document.createElement('div');
bar.className = 'start-bar';
bar.style.cssText = 'position:absolute;bottom:1rem;left:0;right:0;display:flex;justify-content:center;z-index:5';
const b = document.createElement('button');
b.className = 'btn btn-primary btn-big';
b.id = 'bt-start';
b.addEventListener('click', () => {
Snd.play('click');
this.buildStageSelect();
this.showScreen('scr-stage');
});
bar.appendChild(b);
this.els['scr-chars'].appendChild(bar);
this._startBtn = b;
},
/* ================= stage select ================= */
buildStageSelect() {
const grid = this.els.stageGrid;
if (!grid) return;
const save = Store.data;
if (!this._stageSel) { this._stageSel = 'graveyard'; this._endlessSel = false; this._gradeSel = 0; }
grid.innerHTML = '';
for (const id in STAGES) {
const st = STAGES[id];
const unlocked = st.unlock(save);
const card = document.createElement('div');
card.className = 'stage-card' + (unlocked ? '' : ' locked') + (this._stageSel === id ? ' selected' : '');
const best = save.progress.bestPerStage[id] || 0;
card.innerHTML =
'<div class="char-sym">' + st.icon + '</div>' +
'<div class="char-name">' + tr('st_' + id) + '</div>' +
'<div class="char-traits">' + tr('st_' + id + '_d') + '</div>' +
'<div class="stage-best">' + (best ? '🏆 ' + tr('st_best') + ': ' + fmtTime(best) : '') + '</div>' +
(!unlocked ? '<div class="lock-tag">🔒 ' + tr(st.lockKey || 'st_lock_frost') + '</div>' : '');
card.addEventListener('click', () => {
if (!unlocked) { Snd.play('hit'); return; }
Snd.play('click');
this._stageSel = id;
this.buildStageSelect();
});
grid.appendChild(card);
}
// options row: endless + grade
const opts = this.els.stageOpts;
opts.innerHTML = '';
const mkSeg = (labelKey, items, getVal, setVal) => {
const wrap = document.createElement('div');
wrap.className = 'opt-row';
const l = document.createElement('label'); l.textContent = tr(labelKey);
const seg = document.createElement('div'); seg.className = 'seg';
items.forEach(it => {
const sb = document.createElement('button');
sb.className = 'btn btn-small' + (getVal() === it.val ? ' active' : '');
sb.textContent = it.label;
sb.addEventListener('click', () => {
Snd.play('click'); setVal(it.val);
this.buildStageSelect();
});
seg.appendChild(sb);
});
wrap.appendChild(l); wrap.appendChild(seg);
opts.appendChild(wrap);
};
mkSeg('st_endless',
[{ label: 'OFF', val: false }, { label: '∞ ON', val: true }],
() => this._endlessSel, v => { this._endlessSel = v; });
mkSeg('st_grade',
[{ label: 'I', val: 0 }, { label: 'II', val: 1 }, { label: 'III', val: 2 }, { label: '💀', val: 3 }],
() => this._gradeSel, v => { this._gradeSel = v; });
const go = this.els['bt-stage-go'];
go.textContent = tr('btn_start') + ' ▶';
},
buildChars() {
const grid = this.els.charGrid;
if (!grid) return;
grid.innerHTML = '';
for (const id in CHARS) {
const c = CHARS[id];
const unlocked = Store.charUnlocked(id);
const card = document.createElement('div');
card.className = 'char-card' + (unlocked ? '' : ' locked') + (this._charSel === id ? ' selected' : '');
const wpnName = tr('w_' + c.weapon);
card.innerHTML =
'<div class="char-sym">' + c.sym + '</div>' +
'<div class="char-name">' + tr('c_' + id) + '</div>' +
'<div class="char-weapon">⚔ ' + wpnName + '</div>' +
'<div class="char-traits">' + tr('c_' + id + '_t') + '</div>';
if (!unlocked) {
const lock = document.createElement('div');
lock.className = 'char-lock';
lock.textContent = tr(c.cond.key);
card.appendChild(lock);
} else {
card.addEventListener('click', () => {
Snd.play('click');
this._charSel = id;
this.buildChars();
});
// ---- skin controls: cycle owned / buy next ----
const skins = skinsFor(id);
const owned = skins.filter(s => Store.data.skinOwned[id + ':' + s.key]);
const sel = Store.data.skinSel[id];
const row = document.createElement('div');
row.className = 'skin-row';
const cur = sel && !sel.startsWith('base')
? (skins.find(s => s.key === sel) || null)
: null;
const nameLbl = document.createElement('span');
nameLbl.className = 'skin-name';
const en = currentLang() === 'en';
nameLbl.innerHTML = cur
? '<i style="background:' + cur.col + '"></i>' + (en ? cur.en : cur.vi)
: '<i style="background:' + c.col + '"></i>' + tr('skin_base');
const cyc = document.createElement('button');
cyc.className = 'btn btn-mini';
cyc.textContent = '🎨';
cyc.title = tr('skin_cycle');
cyc.addEventListener('click', (ev) => {
ev.stopPropagation();
Snd.play('click');
const list = [{ key: 'base' }].concat(owned);
const i = list.findIndex(s => s.key === (Store.data.skinSel[id] || 'base'));
const next = list[(i + 1) % list.length];
if (next.key === 'base') delete Store.data.skinSel[id];
else Store.data.skinSel[id] = next.key;
Store.save();
this.buildChars();
});
row.appendChild(nameLbl);
row.appendChild(cyc);
const unowned = skins.find(s => !Store.data.skinOwned[id + ':' + s.key]);
if (unowned) {
const buy = document.createElement('button');
buy.className = 'btn btn-mini buy';
buy.textContent = '🛒' + unowned.price;
buy.title = (en ? unowned.en : unowned.vi);
buy.addEventListener('click', (ev) => {
ev.stopPropagation();
if (Store.data.gold < unowned.price) { Snd.play('hit'); this.toast(tr('ms_need_gold'), 'warn'); return; }
Snd.play('coin');
Store.spendGold(unowned.price);
Store.data.skinOwned[id + ':' + unowned.key] = 1;
Store.data.skinSel[id] = unowned.key;
Store.save();
this.toast('🎨 ' + (en ? unowned.en : unowned.vi) + ' ✔');
this.buildChars();
this.refreshMenuStats();
});
row.appendChild(buy);
}
card.appendChild(row);
}
grid.appendChild(card);
}
if (this._startBtn) this._startBtn.textContent = tr('btn_start') + ' · ' + tr('c_' + this._charSel);
},
/* ================= meta shop ================= */
buildShop() {
const grid = this.els.shopGrid;
if (!grid) return;
grid.innerHTML = '';
for (const def of META_SHOP) {
const rank = Store.rank(def.id);
const maxed = rank >= def.max;
const cost = metaCost(def, rank);
const canBuy = !maxed && Store.data.gold >= cost;
const item = document.createElement('div');
item.className = 'shop-item';
item.innerHTML =
'<h4>' + def.sym + ' ' + tr('ms_' + def.id) + '</h4>' +
'<p>' + tr('ms_' + def.id + '_d') + '</p>';
const pips = document.createElement('div');
pips.className = 'rank-pips';
for (let i = 0; i < def.max; i++) {
const pip = document.createElement('i');
if (i < rank) pip.className = 'on';
pips.appendChild(pip);
}
item.appendChild(pips);
const btn = document.createElement('button');
btn.className = 'btn btn-small shop-buy';
btn.textContent = maxed ? tr('ms_max') : tr('ms_cost', { c: cost });
btn.disabled = maxed || !canBuy;
btn.addEventListener('click', () => { Snd.play('coin'); this.G.buyMeta(def.id); });
item.appendChild(btn);
grid.appendChild(item);
}
this.refreshHeadGold();
},
refreshHeadGold() {
document.querySelectorAll('.head-gold b').forEach(el => { el.textContent = fmtNum(Store.data.gold); });
},
/* ================= codex ================= */
buildCodex(tab) {
this._codexTab = tab || this._codexTab;
const list = this.els.codexList;
if (!list) return;
list.innerHTML = '';
document.querySelectorAll('#codexTabs .tab').forEach(t =>
t.classList.toggle('active', t.getAttribute('data-tab') === this._codexTab));
const card = (sym, col, title, desc, tagHtml, locked) => {
const el = document.createElement('div');
el.className = 'cx-card' + (locked ? ' cx-lock' : '');
el.innerHTML =
'<div class="cx-sym" style="filter:drop-shadow(0 0 6px ' + col + ')">' + sym + '</div>' +
'<div class="cx-body"><h4>' + title + '</h4><p>' + desc + '</p>' + (tagHtml || '') + '</div>';
list.appendChild(el);
};
if (this._codexTab === 'ach') {
const prog = achProgress();
const head = document.createElement('div');
head.className = 'ach-head';
head.textContent = '🏆 ' + prog.n + ' / ' + prog.total;
list.appendChild(head);
for (const a of ACHS) {
const done = !!Store.data.ach[a.id];
const name = currentLang() === 'en' ? a.en : a.vi;
const desc = currentLang() === 'en' ? a.den : a.dvi;
card(done ? a.icon : '🔒', done ? '#ffd24a' : 'rgba(120,120,140,.6)',
name, desc, done ? '<span class="cx-tag" style="color:#7dff9e">✔</span>' : '', !done);
}
return;
}
if (this._codexTab === 'weapons') {
for (const id in WEAPONS) {
const d = WEAPONS[id];
const evoTag = '<span class="cx-tag">⭐ ' + tr(d.evo.into) + ' ' + tr(d.evo.need) + '</span>';
card(d.sym, d.col, tr(id) + ' <small style="color:var(--dim)">max ' + d.max + '</small>',
tr(id + '_d'), evoTag, false);
}
for (const id in EVOLVED) {
const d = EVOLVED[id];
card(d.sym, d.col, '⭐ ' + tr(id), tr(id + '_d'), '', false);
}
} else if (this._codexTab === 'passives') {
for (const id in PASSIVES) {
const d = PASSIVES[id];
card(d.sym, d.col, tr(id) + ' <small style="color:var(--dim)">max ' + d.max + '</small>', tr(id + '_d'), '', false);
}
} else if (this._codexTab === 'enemies') {
for (const id in ENEMIES) {
const d = ENEMIES[id];
card('👹', d.col, tr(d.nk),
tr('cx_stats', { hp: d.hp, dm: d.dmg, sp: d.spd }), '', false);
}
for (const id in BOSSES) {
const d = BOSSES[id];
card(d.shape === 'death' ? '⚰' : '☠', d.col, tr(d.nk),
tr('cx_stats', { hp: fmtNum(d.hp), dm: d.dmg, sp: d.spd }),
'<span class="cx-tag" style="color:#ff9db0">BOSS</span>', false);
}
} else {
for (const id in CHARS) {
const c = CHARS[id];
const unlocked = Store.charUnlocked(id);
card(c.sym, c.col, tr('c_' + id), unlocked ? tr('c_' + id + '_t') : tr(c.cond.key),
'<span class="cx-tag">⚔ ' + tr('w_' + c.weapon) + '</span>', !unlocked);
}
}
},
/* ================= options ================= */
buildOptions() {
const panel = this.els.optPanel;
if (!panel || panel.dataset.built === '1') { this._syncOptionControls(); return; }
panel.dataset.built = '1';
const s = () => Store.s();
const group = (titleKey) => {
const g = document.createElement('div');
g.className = 'opt-group';
const h = document.createElement('h3');
h.textContent = tr(titleKey);
g.appendChild(h);
panel.appendChild(g);
return g;
};
const rowSlider = (g, labelKey, key, onchange) => {
const r = document.createElement('div'); r.className = 'opt-row';
const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey);
const sl = document.createElement('input');
sl.type = 'range'; sl.min = 0; sl.max = 100; sl.value = s()[key] * 100;
sl.addEventListener('input', () => {
s()[key] = sl.value / 100;
Store.save();
Snd.applyVolumes();
if (onchange) onchange();
});
r.appendChild(l); r.appendChild(sl);
g.appendChild(r);
};
const rowToggle = (g, labelKey, key, onchange) => {
const r = document.createElement('div'); r.className = 'opt-row';
const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey);
const t = document.createElement('div');
t.className = 'toggle' + (s()[key] ? ' on' : '');
t.addEventListener('click', () => {
s()[key] = !s()[key];
t.classList.toggle('on', s()[key]);
Store.save();
Snd.play('click');
if (onchange) onchange();
});
r.appendChild(l); r.appendChild(t);
g.appendChild(r);
};
const rowSeg = (g, labelKey, opts, getVal, setVal) => {
const r = document.createElement('div'); r.className = 'opt-row';
const l = document.createElement('label'); l.setAttribute('data-i18n', labelKey); l.textContent = tr(labelKey);
const seg = document.createElement('div'); seg.className = 'seg';
opts.forEach(o => {
const b = document.createElement('button');
b.textContent = o.label;
if (getVal() === o.val) b.classList.add('on');
b.addEventListener('click', () => {
setVal(o.val);
seg.querySelectorAll('button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
Snd.play('click');
Store.save();
});
seg.appendChild(b);
});
r.appendChild(l); r.appendChild(seg);
g.appendChild(r);
};
const ga = group('opt_audio');
rowSlider(ga, 'master', 'volMaster');
rowSlider(ga, 'music', 'volMusic');
rowSlider(ga, 'sfx', 'volSfx');
const gv = group('opt_video');
rowToggle(gv, 'shake', 'shake');
rowToggle(gv, 'dmgnum', 'dmgNum');
rowSeg(gv, 'particles',
[{ label: tr('q_low'), val: 'low' }, { label: tr('q_med'), val: 'med' }, { label: tr('q_high'), val: 'high' }],
() => s().particles, v => { s().particles = v; this.G.applyQuality(); });
rowToggle(gv, 'fps', 'fps', () => this.G.applySettings());
const gg = group('opt_gameplay');
rowToggle(gg, 'autopause', 'autoPause');
// ---- fullscreen ----
const fsRow = document.createElement('div'); fsRow.className = 'opt-row';
const fsL = document.createElement('label'); fsL.textContent = tr('fullscreen');
const fsB = document.createElement('button');
fsB.className = 'btn btn-small';
const fsSync = () => { fsB.textContent = '⛶ ' + (document.fullscreenElement ? tr('btn_quitmenu') : tr('fullscreen')); };
fsSync();
fsB.addEventListener('click', () => {
Snd.play('click');
if (document.fullscreenElement) { if (document.exitFullscreen) document.exitFullscreen(); }
else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
setTimeout(fsSync, 350);
});
document.addEventListener('fullscreenchange', fsSync);
fsRow.appendChild(fsL); fsRow.appendChild(fsB);
gg.appendChild(fsRow);
// ---- keybinds ----
const gc = group('opt_controls');
const prettyCode = c => c.replace(/^(Key|Digit|Arrow)/, '');
const mkBind = (labelKey, act) => {
const r = document.createElement('div'); r.className = 'opt-row';
const l = document.createElement('label'); l.textContent = tr(labelKey);
const b = document.createElement('button');
b.className = 'btn btn-small';
b.style.minWidth = '84px';
const paint = () => {
b.textContent = this._listenBind === act ? tr('bind_press') : prettyCode(Store.s().keybinds[act]);
};
paint();
b.addEventListener('click', () => {
Snd.play('click');
this._listenBind = act; paint();
const h = (e) => {
e.preventDefault(); e.stopPropagation();
if (e.code !== 'Escape') {
Store.s().keybinds[act] = e.code;
Store.save();
}
this._listenBind = null;
window.removeEventListener('keydown', h, true);
paint();
};
window.addEventListener('keydown', h, true);
});
r.appendChild(l); r.appendChild(b);
gc.appendChild(r);
};
mkBind('bind_up', 'up');
mkBind('bind_down', 'down');
mkBind('bind_left', 'left');
mkBind('bind_right', 'right');
// ---- save transfer ----
const gsRow = document.createElement('div'); gsRow.className = 'opt-row';
const gsL = document.createElement('label'); gsL.textContent = tr('save_title');
const gsB = document.createElement('button');
gsB.className = 'btn btn-small';
gsB.textContent = '💾 ↔️';
gsB.addEventListener('click', () => { Snd.play('click'); this.openSaveModal(); });
gsRow.appendChild(gsL); gsRow.appendChild(gsB);
gg.appendChild(gsRow);
// ---- leaderboard identity / server ----
const mkText = (labelKey, key, ph) => {
const r = document.createElement('div'); r.className = 'opt-row';
const l = document.createElement('label'); l.textContent = tr(labelKey);
const inp = document.createElement('input');
inp.type = 'text'; inp.className = 'opt-input'; inp.maxLength = 120;
inp.value = Store.data[key] || '';
if (ph) inp.placeholder = ph;
inp.addEventListener('change', () => {
Store.data[key] = inp.value.trim().slice(0, 120);
Store.save();
});
r.appendChild(l); r.appendChild(inp);
gc.appendChild(r);
};
mkText('lb_name', 'lbName');
mkText('lb_server', 'lbUrl', 'https://your-worker.workers.dev');
const gl = group('opt_language');
rowSeg(gl, 'lang',
[{ label: '🇻🇳 Tiếng Việt', val: 'vi' }, { label: '🇬🇧 English', val: 'en' }],
() => s().lang,
v => { s().lang = v; Store.save(); this.syncLang(); });
// ---- developer mode toggle ----
const gd = group('opt_dev');
rowToggle(gd, 'opt_dev', 'dev', () => this.G.applySettings());
const devHint = document.createElement('p');
devHint.style.cssText = 'font-size:.72rem;color:var(--dim);margin-top:.3rem';
devHint.textContent = tr('dev_hint');
gd.appendChild(devHint);
const gz = group('opt_video'); // reset lives under its own group header reuse
gz.querySelector('h3').textContent = '⚠️ Danger';
const rr = document.createElement('div'); rr.className = 'opt-row';
const resetBtn = document.createElement('button');
resetBtn.className = 'btn btn-danger btn-small';
resetBtn.setAttribute('data-i18n', 'reset');
resetBtn.textContent = tr('reset');
resetBtn.addEventListener('click', () => {
if (!this._resetArm) {
this._resetArm = true;
resetBtn.textContent = tr('reset_confirm');
setTimeout(() => { this._resetArm = false; resetBtn.textContent = tr('reset'); }, 4000);
} else {
Store.reset();
this._resetArm = false;
resetBtn.textContent = tr('reset');
this.toast('🗑️ OK');
this.syncLang();
this.refreshMenuStats();
}
});
rr.appendChild(resetBtn);
gz.appendChild(rr);
},
_syncOptionControls() {
// controls read from Store on rebuild; sliders/toggles keep live state already
},
/* ================= bindings ================= */
_bindButtons() {
const on = (id, fn) => { const el = this.$(id); if (el) el.addEventListener('click', () => { Snd.play('click'); fn(); }); };
on('bt-play', () => { this.buildChars(); this.showScreen('scr-chars'); });
on('bt-shop', () => { this.buildShop(); this.showScreen('scr-shop'); });
on('bt-codex', () => { this.buildCodex(); this.showScreen('scr-codex'); });
on('bt-options', () => { this.buildOptions(); this.showScreen('scr-options'); });
on('bt-resume', () => this.G.togglePause());
on('bt-poptions', () => { this.buildOptions(); this.showScreen('scr-options'); });
on('bt-quit', () => this.G.quitToMenu());
on('bt-retry', () => this.G.restart());
on('bt-tomenu', () => this.G.quitToMenu());
on('bt-chestOk', () => this.G.closeChest());
on('bt-reroll', () => this.G.rerollChoices());
on('bt-skipLv', () => this.G.skipLevelUp());
on('bt-stage-go', () => {
this.G.startRun(this._charSel, {
stage: this._stageSel || 'graveyard',
endless: !!this._endlessSel,
grade: this._gradeSel | 0
});
});
on('bt-daily', () => {
const ds = dailySetup();
this.G.startRun(ds.char, ds);
});
on('bt-history', () => { this.buildHistory(); this.showScreen('scr-history'); });
on('bt-save-close', () => this.hideModal('md-save'));
on('bt-save-copy', () => {
const txt = this.els.saveArea.value || '';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => {}, () => {});
}
this.els.saveArea.select();
try { document.execCommand('copy'); } catch (e) { /* older browsers */ }
this.toast('📋 ✔');
});
on('bt-save-import', () => this.importSave());
on('bt-lb-close', () => this.hideModal('md-lb'));
on('bt-board', () => { this.openBoard('daily:' + dailyKey()); });
if (this.els.tutTip) {
this.$('bt-tut-skip').addEventListener('click', () => {
Snd.play('click');
Store.data.flags.tutDone = true;
Store.save();
this.hideTut();
if (this.G) { this.G._tutStep = -1; }
});
}
document.querySelectorAll('.back-btn').forEach(b =>
b.addEventListener('click', () => { Snd.play('click'); this.G.onBackFromScreen(); }));
document.querySelectorAll('#codexTabs .tab').forEach(t =>
t.addEventListener('click', () => { Snd.play('click'); this.buildCodex(t.getAttribute('data-tab')); }));
if (this.els.devBtn) {
this.els.devBtn.addEventListener('click', () => { Snd.play('click'); this.toggleDevPanel(); });
}
},
/* ================= developer panel ================= */
toggleDevPanel() {
const md = this.els['md-dev'];
if (!Store.s().dev || !this.G.player) return;
if (md.classList.contains('hidden')) {
this.buildDevPanel();
md.classList.remove('hidden');
} else {
md.classList.add('hidden');
}
},
buildDevPanel() {
const G = this.G, P = G.player;
const el = this.els.devPanel;
if (!el || !P) return;
el.innerHTML = '';
// header
const head = document.createElement('h2');
head.innerHTML = '<span>🛠️ ' + tr('dev_title') + '</span>';
const closeB = document.createElement('button');
closeB.className = 'dev-close'; closeB.textContent = '✕';
closeB.addEventListener('click', () => { Snd.play('click'); this.hideModal('md-dev'); });
head.appendChild(closeB);
el.appendChild(head);
const sec = (titleKey) => {
const s = document.createElement('div'); s.className = 'dev-sec';
const h = document.createElement('h3'); h.textContent = tr(titleKey);
s.appendChild(h); el.appendChild(s); return s;
};
const dbtn = (parent, label, fn) => {
const b = document.createElement('button');
b.className = 'dev-btn'; b.textContent = label;
b.addEventListener('click', () => { Snd.play('click'); fn(); this.buildDevPanel(); });
parent.appendChild(b);
};
/* ---- gold ---- */
const sGold = sec('dev_gold');
const goldRow = document.createElement('div'); goldRow.className = 'dev-row';
const gVal = document.createElement('label');
gVal.textContent = '🪙 ' + fmtNum(P.gold);
goldRow.appendChild(gVal);
[100, 1000, 10000].forEach(n =>
dbtn(goldRow, '+' + fmtNum(n), () => G.devAddGold(n)));
const setInp = document.createElement('input');
setInp.type = 'number'; setInp.min = 0; setInp.value = Math.floor(P.gold);
setInp.style.cssText = 'width:90px;background:#181838;border:1px solid var(--line);color:#fff;border-radius:8px;padding:.25em .5em;font-size:.8rem';
goldRow.appendChild(setInp);
dbtn(goldRow, tr('dev_set'), () => G.devSetGold(parseInt(setInp.value, 10) || 0));
sGold.appendChild(goldRow);
/* ---- level ---- */
const sLv = sec('dev_level');
const lvRow = document.createElement('div'); lvRow.className = 'dev-row';
const lvVal = document.createElement('label');
lvVal.textContent = 'LV ' + P.level + (G.queueLevelUps ? ' (+' + G.queueLevelUps + ')' : '');
lvRow.appendChild(lvVal);
dbtn(lvRow, '+1', () => G.devAddLevels(1));
dbtn(lvRow, '+5', () => G.devAddLevels(5));
dbtn(lvRow, tr('dev_maxb') + ' 50', () => G.devAddLevels(50));
sLv.appendChild(lvRow);
/* ---- cheats ---- */
const sCh = sec('dev_cheats');
const chRow = document.createElement('div'); chRow.className = 'dev-row';
const mkCheatBtn = (labelKey, flagProp) => {
const b = document.createElement('button');
b.className = 'dev-btn';
b.textContent = tr(labelKey) + ': ' + (G[flagProp] ? 'ON ✅' : 'OFF ❌');
if (G[flagProp]) b.style.borderColor = 'var(--green)';
b.addEventListener('click', () => {
Snd.play('click');
G[flagProp] = !G[flagProp];
this.buildDevPanel();
});
chRow.appendChild(b);
};
mkCheatBtn('dev_god', 'devGod');
mkCheatBtn('dev_onehit', 'devOneHit');
const spdLabel = document.createElement('label');
spdLabel.textContent = tr('dev_speed') + ':';
chRow.appendChild(spdLabel);
[0.5, 1, 2, 3].forEach(v => {
const b = document.createElement('button');
b.className = 'dev-btn'; b.textContent = v + '×';
if ((G.timeScale || 1) === v) { b.style.borderColor = 'var(--neon2)'; b.style.color = 'var(--neon2)'; }
b.addEventListener('click', () => { Snd.play('click'); G.timeScale = v; this.buildDevPanel(); });
chRow.appendChild(b);
});
sCh.appendChild(chRow);
/* ---- world ---- */
const sW = sec('dev_world');
const wRow = document.createElement('div'); wRow.className = 'dev-row';
dbtn(wRow, tr('dev_nuke'), () => G.devNuke(900));
dbtn(wRow, tr('dev_killall'), () => G.devNuke(320));
dbtn(wRow, tr('dev_magnet'), () => G.devMagnetAll());
dbtn(wRow, tr('dev_heal'), () => G.devHeal());
dbtn(wRow, tr('dev_spawn_elite'), () => G.devSpawnElite());
dbtn(wRow, tr('dev_skip1'), () => G.devSkipTime(60));
dbtn(wRow, tr('dev_skip5'), () => G.devSkipTime(300));
sW.appendChild(wRow);
const bRow = document.createElement('div'); bRow.className = 'dev-row';
const bl = document.createElement('label'); bl.textContent = tr('dev_spawn_boss');
bRow.appendChild(bl);
for (const bid in BOSSES) {
const b = document.createElement('button');
b.className = 'dev-btn';
b.textContent = tr(BOSSES[bid].nk).replace(/^[^\w]+/, '');
b.addEventListener('click', () => { Snd.play('roar'); G.devSpawnBoss(bid); this.buildDevPanel(); });
bRow.appendChild(b);
}
sW.appendChild(bRow);
/* ---- weapons ---- */
const sWea = sec('dev_weapons');
const wg = document.createElement('div'); wg.className = 'dev-grid';
const addItem = (grid, sym, nameKey, curLvTxt, onAdd, onMax, onGet) => {
const it = document.createElement('div'); it.className = 'dev-item';
it.innerHTML = '<span>' + sym + '</span><span class="nm">' + tr(nameKey) + '</span><span class="lv">' + curLvTxt + '</span>';
if (onAdd) {
const b1 = document.createElement('button'); b1.textContent = tr('dev_add');
b1.addEventListener('click', () => { Snd.play('click'); onAdd(); this.buildDevPanel(); });
it.appendChild(b1);
}
if (onMax) {
const b2 = document.createElement('button'); b2.textContent = tr('dev_maxb');
b2.addEventListener('click', () => { Snd.play('click'); onMax(); this.buildDevPanel(); });
it.appendChild(b2);
}
if (onGet) {
const b3 = document.createElement('button'); b3.textContent = tr('dev_get');
b3.addEventListener('click', () => { Snd.play('click'); onGet(); this.buildDevPanel(); });
it.appendChild(b3);
}
grid.appendChild(it);
};
for (const id in WEAPONS) {
const w = P.weaponById(id);
addItem(wg, WEAPONS[id].sym, 'w_' + id,
w ? 'Lv.' + w.lvl : '—',
() => G.devGiveWeapon(id),
() => G.devGiveWeapon(id, true), null);
}
for (const id in EVOLVED) {
addItem(wg, EVOLVED[id].sym, id,
P.weaponById(id) ? '★' : '—', null, null,
() => G.devGiveWeapon(id));
}
sWea.appendChild(wg);
/* ---- passives ---- */
const sPa = sec('dev_passives');
const pg = document.createElement('div'); pg.className = 'dev-grid';
for (const id in PASSIVES) {
const cur = P.passives[id] || 0;
addItem(pg, PASSIVES[id].sym, id,
cur ? 'Lv.' + cur : '—',
() => G.devGivePassive(id),
() => G.devGivePassive(id, true), null);
}
sPa.appendChild(pg);
/* ---- meta ---- */
const sM = sec('dev_meta');
const mRow = document.createElement('div'); mRow.className = 'dev-row';
dbtn(mRow, tr('dev_unlockall'), () => G.devUnlockAllChars());
sM.appendChild(mRow);
const note = document.createElement('p');
note.className = 'dev-note';
note.textContent = '` = đóng/mở · Esc = đóng · Cheat chỉ áp dụng cho phiên hiện tại';
el.appendChild(note);
}
};