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