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)
1110 lines
44 KiB
JavaScript
1110 lines
44 KiB
JavaScript
'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);
|
||
}
|
||
};
|