'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); } };