Vanilla JS + Canvas 2D, zero dependencies. Gameplay: campaign waves vs 12 dino species + 3 bosses, arena mode, 7 weapons, hero talents, pets, ult/bombs, card roguelite picks. World: day/night cycle, weather fronts (rain+lightning), Blood Moon weekends, 3 act biomes (jungle -> swamp -> ashlands with lava veins). Meta: gems economy, upgrade tree, quests, bestiary, achievements, shop (weapons/heroes/skins incl. premium), revive, ad-cap system, endless scaling past wave 25, live-painted shop/bestiary icons, pro main menu, auto-aim toggle (F). Headless smoke suite included.
828 lines
31 KiB
JavaScript
828 lines
31 KiB
JavaScript
/* Neon Rampage — DOM UI: menus, shop, settings, leaderboard, overlays. */
|
||
(function () {
|
||
'use strict';
|
||
var US = (window.US = window.US || {});
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
|
||
var reviveTimer = null;
|
||
|
||
function show(el) { el.classList.remove('hidden'); }
|
||
function hide(el) { el.classList.add('hidden'); }
|
||
|
||
function toast(msg) {
|
||
var t = $('toast');
|
||
t.textContent = msg;
|
||
show(t);
|
||
clearTimeout(toast._h);
|
||
toast._h = setTimeout(function () { hide(t); }, 2200);
|
||
}
|
||
|
||
/* ---------------- screens ---------------- */
|
||
var SCREENS = ['menu', 'shop', 'settings', 'rank', 'howto', 'arena-setup', 'quests', 'bestiary'];
|
||
|
||
function L(o) { var sv = US.save.get(); return (o && (o[vi0()] || o.en)) || ''; }
|
||
function vi0() { return US.save.get().lang === 'vi' ? 'vi' : 'en'; }
|
||
|
||
function showScreen(name) {
|
||
SCREENS.forEach(function (s) {
|
||
var el = $('screen-' + s);
|
||
if (el) (s === name ? show : hide)(el);
|
||
});
|
||
$('hud-root').classList.toggle('in-game', !name);
|
||
refreshMenuStats();
|
||
}
|
||
US.uiShowScreen = showScreen;
|
||
|
||
function refreshMenuStats() {
|
||
var s = US.save.get();
|
||
$('menu-coins').textContent = '$' + US.util.fmt(s.coins);
|
||
if ($('menu-gems')) $('menu-gems').textContent = '🔥 ' + (s.gems | 0);
|
||
if ($('dot-upgrades')) {
|
||
var canUp = Object.keys(US.UPGRADES || {}).some(function (k) {
|
||
var d = US.UPGRADES[k];
|
||
return (s.upgrades[k] | 0) < d.max && s.coins >= upCost(d, s.upgrades[k] | 0);
|
||
});
|
||
$('dot-upgrades').classList.toggle('hidden', !canUp);
|
||
}
|
||
$('menu-best').textContent =
|
||
US.i18n.t('menu.bestScore') + ': ' + US.util.fmt(s.bestScore) +
|
||
' · ' + US.i18n.t('menu.bestWave') + ': ' + s.bestWave;
|
||
}
|
||
|
||
/* ---------------- shop ---------------- */
|
||
var shopTab = 'weapons';
|
||
|
||
/* ---- live-drawn icons: reuse the game's own painters as thumbnails --- */
|
||
function makePreview(w, h, drawFn) {
|
||
var wrap = document.createElement('div');
|
||
wrap.className = 'item-prev';
|
||
var cv = document.createElement('canvas');
|
||
cv.width = w; cv.height = h;
|
||
cv.style.width = w + 'px'; cv.style.height = h + 'px';
|
||
if (drawFn) {
|
||
var c2 = cv.getContext('2d');
|
||
try {
|
||
drawFn(c2, w, h);
|
||
} catch (e) {
|
||
// never leave a silent empty plate: show fallback + log the truth
|
||
console.warn('[icon] painter failed:', e && e.message, e);
|
||
c2.fillStyle = '#ffb938';
|
||
c2.font = '30px system-ui';
|
||
c2.textAlign = 'center'; c2.textBaseline = 'middle';
|
||
c2.fillText('🎨', w / 2, h / 2);
|
||
}
|
||
}
|
||
wrap.appendChild(cv);
|
||
return wrap;
|
||
}
|
||
|
||
var UP_ICON = { hp: '❤️', dmg: '🦷', speed: '💨', ult: '☀️', coin: '🧿', bombs: '💣' };
|
||
|
||
// hero bust: top-down but angled up so it reads like a portrait
|
||
function drawHeroIcon(c2, w, h, bodyCol, trimCol, kind) {
|
||
c2.save();
|
||
c2.translate(w / 2 - 46 * 1.25, h / 2 - 30 * 1.25);
|
||
c2.scale(1.25, 1.25);
|
||
US.art.drawHuman(c2, {
|
||
x: 46, y: 30, radius: 14, aim: -Math.PI / 2,
|
||
walkPhase: 0, moving: false,
|
||
color: bodyCol || '#e04b3a',
|
||
body: bodyCol || '#e04b3a', trim: trimCol || '#e8dcc4',
|
||
weaponKind: kind || 'slingshot',
|
||
hurtT: 0, fireT: 0, torch: false, scale: 1.3, seedF: 3
|
||
}, 1);
|
||
c2.restore();
|
||
}
|
||
|
||
function drawWeaponIcon(c2, w, h, kind) {
|
||
c2.save();
|
||
c2.translate(12, h / 2);
|
||
c2.scale(1.9, 1.9);
|
||
US.art.drawHeldWeapon(c2, kind, 0);
|
||
c2.restore();
|
||
}
|
||
|
||
var BEST_DRAW = {
|
||
grunt: { fn: 'drawRaptor', r: 15 },
|
||
runner: { fn: 'drawRaptor', r: 11 },
|
||
spitter: { fn: 'drawDilo', r: 14 },
|
||
tank: { fn: 'drawTriceratops', r: 21 },
|
||
stego: { fn: 'drawStego', r: 19 },
|
||
pachy: { fn: 'drawPachy', r: 14 },
|
||
splitter: { fn: 'drawPtero', r: 17, opt: false },
|
||
mini: { fn: 'drawPtero', r: 11, opt: true },
|
||
eliteGrunt: { fn: 'drawRaptor', r: 18 },
|
||
bruiser: { fn: 'drawRex', r: 25 },
|
||
artillery: { fn: 'drawSpino', r: 25 },
|
||
hydra: { fn: 'drawGiga', r: 25 }
|
||
};
|
||
|
||
function drawBeastIcon(c2, w, h, speciesId, locked) {
|
||
var spec = BEST_DRAW[speciesId];
|
||
if (!spec) return;
|
||
var def = (US.ENEMIES && (US.ENEMIES[speciesId])) ||
|
||
(speciesId === 'bruiser' ? (US.BOSSES && US.BOSSES.bruiser) : null) || {};
|
||
var col = def.color || '#5f8f3e';
|
||
var ent = {
|
||
x: w / 2, y: h / 2 + 4, radius: spec.r,
|
||
face: -Math.PI / 2, color: col, hitFlash: 0,
|
||
seed: 3, walkPhase: 0, dashing: 0, dashCd: 9,
|
||
shootCd: 1, charging: false, spawnAnim: 0
|
||
};
|
||
c2.save();
|
||
if (locked) c2.globalAlpha = 0.22;
|
||
var fn2 = US.art[spec.fn];
|
||
if (spec.opt === undefined) fn2(c2, ent, 1.23);
|
||
else fn2(c2, ent, 1.23, spec.opt);
|
||
c2.restore();
|
||
if (locked) {
|
||
c2.fillStyle = 'rgba(243,231,207,0.75)';
|
||
c2.font = 'bold 26px system-ui';
|
||
c2.textAlign = 'center'; c2.textBaseline = 'middle';
|
||
c2.fillText('?', w / 2, h / 2);
|
||
}
|
||
}
|
||
|
||
function renderShop() {
|
||
var s = US.save.get();
|
||
$('shop-coins').textContent = '$' + US.util.fmt(s.coins);
|
||
if ($('shop-gems')) $('shop-gems').textContent = '🔥 ' + (s.gems | 0);
|
||
['weapons', 'heroes', 'skins', 'upgrades'].forEach(function (t) {
|
||
$('tab-' + t).classList.toggle('active', t === shopTab);
|
||
});
|
||
var grid = $('shop-grid');
|
||
grid.innerHTML = '';
|
||
var list, ownArr, equipKey;
|
||
|
||
if (shopTab === 'upgrades') { renderUpgrades(grid); return; }
|
||
if (shopTab === 'weapons') { list = US.WEAPONS; ownArr = s.weapons; equipKey = 'weapon'; }
|
||
else if (shopTab === 'heroes') { list = US.HEROES; ownArr = s.heroes; equipKey = 'hero'; }
|
||
else { list = US.SKINS; ownArr = s.skins; equipKey = 'skin'; }
|
||
|
||
Object.keys(list).forEach(function (id) {
|
||
var it = list[id];
|
||
var owned = ownArr.indexOf(id) >= 0;
|
||
var equipped = s[equipKey] === id;
|
||
var card = document.createElement('div');
|
||
card.className = 'item' + (equipped ? ' equipped' : '');
|
||
|
||
// live-drawn thumbnail
|
||
if (shopTab === 'weapons') {
|
||
card.appendChild(makePreview(96, 50, function (c2, w2, h2) {
|
||
drawWeaponIcon(c2, w2, h2, it.kind);
|
||
}));
|
||
} else if (shopTab === 'heroes') {
|
||
card.appendChild(makePreview(96, 58, function (c2, w2, h2) {
|
||
drawHeroIcon(c2, w2, h2, it.tint || it.body || '#e04b3a', '#e8dcc4', 'slingshot');
|
||
}));
|
||
} else if (shopTab === 'skins') {
|
||
card.appendChild(makePreview(96, 58, function (c2, w2, h2) {
|
||
// bold colour disc so every paint job is unmistakable
|
||
c2.fillStyle = it.body || '#e04b3a';
|
||
c2.beginPath();
|
||
c2.arc(w2 / 2, h2 / 2 + 2, 21, 0, Math.PI * 2);
|
||
c2.fill();
|
||
c2.strokeStyle = 'rgba(0,0,0,0.4)';
|
||
c2.lineWidth = 2;
|
||
c2.stroke();
|
||
drawHeroIcon(c2, w2, h2, it.body, it.trim, (US.WEAPONS[sv.weapon] || {}).kind);
|
||
if (it.premium) {
|
||
c2.strokeStyle = 'rgba(255,210,74,0.8)';
|
||
c2.lineWidth = 2;
|
||
c2.strokeRect(1.5, 1.5, w2 - 3, h2 - 3);
|
||
}
|
||
// ground truth: sample what actually landed on the canvas
|
||
try {
|
||
var px3 = c2.getImageData(Math.round(w2 / 2), Math.round(h2 / 2) + 16, 1, 1).data;
|
||
console.log('[icon]', it.name, 'want', it.body,
|
||
'got rgb(' + px3[0] + ',' + px3[1] + ',' + px3[2] + ')');
|
||
} catch (e3) { /* tainted ctx: ignore */ }
|
||
}));
|
||
} else if (shopTab === 'upgrades') {
|
||
var up = document.createElement('div');
|
||
up.className = 'item-prev up-icon';
|
||
up.textContent = UP_ICON[it.id] || '💪';
|
||
card.appendChild(up);
|
||
}
|
||
|
||
var h = document.createElement('div');
|
||
h.className = 'item-name';
|
||
h.textContent = it.premium ? (L(it) || it.name) : it.name;
|
||
card.appendChild(h);
|
||
|
||
var desc = document.createElement('div');
|
||
desc.className = 'item-desc';
|
||
if (shopTab === 'weapons') {
|
||
desc.textContent = 'DMG ' + it.dmg + ' · ROF ' + it.rof + '/s' +
|
||
(it.pellets > 1 ? ' · ×' + it.pellets : '') + (it.pierce ? ' · PIERCE' : '') + (it.aoe ? ' · AoE' : '');
|
||
} else if (shopTab === 'heroes') {
|
||
desc.textContent = US.i18n.t('hero.statHp') + ' ' + it.hp + ' · ' +
|
||
US.i18n.t('hero.statSpeed') + ' ' + it.speed + ' · ' + it.talent;
|
||
} else if (it.premium) {
|
||
desc.textContent = L(it) || 'Legendary paint';
|
||
card.classList.add('premium');
|
||
} else {
|
||
desc.textContent = it.name === 'Default' ? 'Standard issue' : 'Chromatic skin';
|
||
}
|
||
card.appendChild(desc);
|
||
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn';
|
||
if (equipped) {
|
||
btn.textContent = US.i18n.t('shop.equipped');
|
||
btn.disabled = true;
|
||
} else if (owned) {
|
||
btn.textContent = US.i18n.t('shop.equip');
|
||
btn.onclick = function () {
|
||
US.audio.sfx('ui');
|
||
US.save.equip(equipKey, id);
|
||
renderShop();
|
||
};
|
||
} else {
|
||
if (it.premium) {
|
||
btn.innerHTML = '🔥 ' + it.gems;
|
||
btn.onclick = function () {
|
||
if (!US.save.spendGems(it.gems)) { toast(L({ en: 'Not enough flames!', vi: 'Không đủ ngọn lửa!' })); return; }
|
||
US.save.ownSkin(id);
|
||
US.save.equip(equipKey, id);
|
||
US.audio.sfx('buy');
|
||
toast(US.i18n.t('shop.bought'));
|
||
renderShop();
|
||
};
|
||
} else {
|
||
btn.textContent = US.i18n.t('shop.buy') + ' — $' + it.price;
|
||
btn.onclick = function () {
|
||
var cur = US.save.get();
|
||
if (cur.coins < it.price) { toast(US.i18n.t('toast.notEnough')); return; }
|
||
US.save.addCoins(-it.price);
|
||
if (shopTab === 'weapons') US.save.ownWeapon(id);
|
||
else if (shopTab === 'heroes') US.save.ownHero(id);
|
||
else US.save.ownSkin(id);
|
||
US.save.equip(equipKey, id);
|
||
US.audio.sfx('buy');
|
||
toast(US.i18n.t('shop.bought'));
|
||
renderShop();
|
||
};
|
||
}
|
||
}
|
||
card.appendChild(btn);
|
||
grid.appendChild(card);
|
||
});
|
||
|
||
// spare bombs row
|
||
var bombRow = $('shop-bombs');
|
||
bombRow.innerHTML = '';
|
||
var bl = document.createElement('span');
|
||
bl.className = 'bomb-label';
|
||
bl.textContent = '💣 ' + US.i18n.t('shop.bombs') + ': ' + (s.bombs | 0);
|
||
var bb = document.createElement('button');
|
||
bb.className = 'btn';
|
||
bb.textContent = US.i18n.t('shop.buy') + ' — $50';
|
||
bb.onclick = function () {
|
||
var cur = US.save.get();
|
||
if (cur.coins < 50) { toast(US.i18n.t('toast.notEnough')); return; }
|
||
US.save.addCoins(-50);
|
||
cur.bombs = (cur.bombs | 0) + 1;
|
||
US.save.save();
|
||
US.audio.sfx('buy');
|
||
renderShop();
|
||
};
|
||
bombRow.appendChild(bl);
|
||
bombRow.appendChild(bb);
|
||
}
|
||
|
||
/* ---------------- settings ---------------- */
|
||
function bindSettings() {
|
||
var s = US.save.get();
|
||
var langSel = $('set-lang');
|
||
langSel.value = s.lang;
|
||
langSel.onchange = function () {
|
||
US.save.get().lang = langSel.value;
|
||
US.save.save();
|
||
US.i18n.setLang(langSel.value);
|
||
US.i18n.apply();
|
||
};
|
||
$('set-sfx').value = s.settings.sfx * 100;
|
||
$('set-music').value = s.settings.music * 100;
|
||
$('set-autoaim').checked = !!s.settings.autoAim;
|
||
$('set-autofire').checked = !!s.settings.autofire;
|
||
$('set-onehit').checked = !!s.settings.onehit;
|
||
$('set-shake').checked = !!s.settings.shake;
|
||
$('set-name').value = s.name;
|
||
|
||
$('set-sfx').oninput = function () { US.save.get().settings.sfx = this.value / 100; US.audio.setVolumes(); US.save.save(); };
|
||
$('set-music').oninput = function () { US.save.get().settings.music = this.value / 100; US.audio.setVolumes(); US.save.save(); };
|
||
if ($('set-autoaim')) $('set-autoaim').onchange = function () { US.save.get().settings.autoAim = this.checked; US.save.save(); };
|
||
$('set-autofire').onchange = function () { US.save.get().settings.autofire = this.checked; US.save.save(); };
|
||
$('set-onehit').onchange = function () { US.save.get().settings.onehit = this.checked; US.save.save(); };
|
||
$('set-shake').onchange = function () { US.save.get().settings.shake = this.checked; US.save.save(); };
|
||
if ($('set-shakeamt')) {
|
||
var sa = $('set-shakeamt');
|
||
sa.value = Math.round((US.save.get().settings.shakeAmt == null ? 1 : US.save.get().settings.shakeAmt) * 100);
|
||
if ($('val-shakeamt')) $('val-shakeamt').textContent = sa.value + '%';
|
||
sa.oninput = function () {
|
||
US.save.get().settings.shakeAmt = this.value / 100;
|
||
US.save.save();
|
||
if ($('val-shakeamt')) $('val-shakeamt').textContent = this.value + '%';
|
||
};
|
||
}
|
||
if ($('set-quality')) {
|
||
$('set-quality').value = US.save.get().settings.quality || 'high';
|
||
$('set-quality').onchange = function () {
|
||
US.save.get().settings.quality = this.value;
|
||
US.save.save();
|
||
};
|
||
}
|
||
$('set-name').onchange = function () {
|
||
US.save.get().name = (this.value || 'Rookie').slice(0, 16);
|
||
US.save.save();
|
||
toast(US.i18n.t('toast.saved'));
|
||
};
|
||
$('set-reset').onclick = function () {
|
||
if (confirm(US.i18n.t('set.resetConfirm'))) {
|
||
US.save.reset();
|
||
location.reload();
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ---------------- leaderboard ---------------- */
|
||
function renderRank() {
|
||
var s = US.save.get();
|
||
var box = $('rank-body');
|
||
box.innerHTML = '';
|
||
if (!s.board.length) {
|
||
var d = document.createElement('div');
|
||
d.className = 'empty';
|
||
d.textContent = US.i18n.t('rank.empty');
|
||
box.appendChild(d);
|
||
return;
|
||
}
|
||
s.board.forEach(function (row, i) {
|
||
var r = document.createElement('div');
|
||
r.className = 'rank-row' + (i === 0 ? ' gold' : '');
|
||
[('#' + (i + 1)), row.name, US.util.fmt(row.score), String(row.wave)].forEach(function (cell) {
|
||
var c = document.createElement('span');
|
||
c.textContent = cell;
|
||
r.appendChild(c);
|
||
});
|
||
box.appendChild(r);
|
||
});
|
||
}
|
||
|
||
/* ---------------- card pick ---------------- */
|
||
function showCardPick(cards, onPick) {
|
||
var ov = $('overlay-cards');
|
||
var wrap = $('card-row');
|
||
wrap.innerHTML = '';
|
||
$('card-hint').textContent = US.i18n.t('card.pickHint');
|
||
cards.forEach(function (c) {
|
||
var el = document.createElement('div');
|
||
el.className = 'card rarity-' + c.rarity;
|
||
var rc = document.createElement('span');
|
||
rc.className = 'card-rarity';
|
||
rc.textContent = US.i18n.t('card.rarity' + c.rarity.charAt(0).toUpperCase() + c.rarity.slice(1));
|
||
var ic = document.createElement('div');
|
||
ic.className = 'card-icon';
|
||
ic.textContent = c.icon;
|
||
var tt = document.createElement('div');
|
||
tt.className = 'card-title';
|
||
tt.textContent = c.title;
|
||
var dd = document.createElement('div');
|
||
dd.className = 'card-desc';
|
||
dd.textContent = c.desc;
|
||
el.appendChild(rc); el.appendChild(ic); el.appendChild(tt); el.appendChild(dd);
|
||
el.onclick = function () {
|
||
US.audio.sfx('card');
|
||
hide(ov);
|
||
onPick(c);
|
||
};
|
||
wrap.appendChild(el);
|
||
});
|
||
show(ov);
|
||
}
|
||
|
||
/* ---------------- pause / gameover / revive ---------------- */
|
||
function showPause() { show($('overlay-pause')); }
|
||
function hidePause() { hide($('overlay-pause')); }
|
||
|
||
function gameOver(stats, mode) {
|
||
$('over-title').textContent = US.i18n.t('over.title');
|
||
$('over-record').classList.toggle('hidden', !stats.record);
|
||
$('ov-score').textContent = US.util.fmt(stats.score);
|
||
$('ov-wave').textContent = mode === 'campaign' ? stats.wave : '—';
|
||
$('ov-kills').textContent = stats.kills;
|
||
$('ov-combo').textContent = '×' + stats.bestCombo;
|
||
$('ov-coins').textContent = '+$' + stats.coins;
|
||
setupAdButton(stats.coins | 0);
|
||
show($('overlay-over'));
|
||
}
|
||
|
||
function arenaOver(winner, g) {
|
||
$('over-title').textContent = winner.player ? US.i18n.t('over.win') : 'DEFEAT';
|
||
$('over-record').classList.add('hidden');
|
||
$('ov-score').textContent = g.kills + ' / ' + g.targetKills;
|
||
$('ov-wave').textContent = '—';
|
||
$('ov-kills').textContent = g.kills;
|
||
$('ov-combo').textContent = '×' + (g.bestCombo || 0);
|
||
$('ov-coins').textContent = '+$' + g.runCoins;
|
||
show($('overlay-over'));
|
||
}
|
||
|
||
function showRevive(cost, seconds) {
|
||
$('revive-cost').textContent = '-$' + cost;
|
||
var left = seconds;
|
||
$('revive-count').textContent = left;
|
||
show($('overlay-revive'));
|
||
clearInterval(reviveTimer);
|
||
reviveTimer = setInterval(function () {
|
||
left--;
|
||
$('revive-count').textContent = Math.max(0, left);
|
||
if (left <= 0) US.game.declineRevive();
|
||
}, 1000);
|
||
}
|
||
function hideRevive() {
|
||
clearInterval(reviveTimer);
|
||
hide($('overlay-revive'));
|
||
}
|
||
|
||
|
||
/* ---------------- permanent upgrades tab ---------------- */
|
||
function upCost(def, lv) { return def.base * (lv + 1); }
|
||
|
||
function renderUpgrades(grid) {
|
||
var sv = US.save.get();
|
||
var bombRow = $('shop-bombs');
|
||
if (bombRow) bombRow.innerHTML = '';
|
||
Object.keys(US.UPGRADES).forEach(function (id) {
|
||
var d = US.UPGRADES[id];
|
||
var lv = sv.upgrades[id] | 0;
|
||
var maxed = lv >= d.max;
|
||
var cost = upCost(d, lv);
|
||
|
||
var card = document.createElement('div');
|
||
card.className = 'item upgrade' + (maxed ? ' equipped' : '');
|
||
|
||
var h = document.createElement('div');
|
||
h.className = 'item-name';
|
||
h.textContent = L(d);
|
||
card.appendChild(h);
|
||
|
||
var pips = document.createElement('div');
|
||
pips.className = 'pips';
|
||
for (var i = 0; i < d.max; i++) {
|
||
var pip = document.createElement('span');
|
||
pip.className = 'pip' + (i < lv ? ' on' : '');
|
||
pips.appendChild(pip);
|
||
}
|
||
card.appendChild(pips);
|
||
|
||
var desc = document.createElement('div');
|
||
desc.className = 'item-desc';
|
||
desc.textContent = L({ en: d.en2 || d.per, vi: d.per });
|
||
card.appendChild(desc);
|
||
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn';
|
||
if (maxed) {
|
||
btn.textContent = US.i18n.t('shop.equipped');
|
||
btn.disabled = true;
|
||
} else {
|
||
btn.textContent = US.i18n.t('shop.buy') + ' — $' + US.util.fmt(cost);
|
||
btn.onclick = function () {
|
||
if (US.save.get().coins < cost) { toast(US.i18n.t('toast.notEnough')); return; }
|
||
US.save.buyUpgrade(id, cost);
|
||
US.audio.sfx('buy');
|
||
toast('💪 ' + L(d) + ' Lv.' + ((sv.upgrades[id] | 0)));
|
||
renderShop();
|
||
refreshMenuStats();
|
||
};
|
||
}
|
||
card.appendChild(btn);
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
/* ---------------- daily quests + achievements ---------------- */
|
||
function questBadge() {
|
||
var sv = US.save.get();
|
||
var claimable = (sv.quests.items || []).some(function (q) {
|
||
return q.done && !q.claimed;
|
||
});
|
||
if ($('dot-quests')) $('dot-quests').classList.toggle('hidden', !claimable);
|
||
}
|
||
|
||
function renderQuests() {
|
||
var sv = US.save.get();
|
||
ensureUiQuests(sv);
|
||
$('quest-streak').textContent = '🔥 ' + L({
|
||
en: 'Login streak: day ' + (sv.quests.streak | 0),
|
||
vi: 'Điểm danh ngày thứ ' + (sv.quests.streak | 0)
|
||
}) + (sv.gems ? ' · 🔥 ×' + sv.gems : '');
|
||
$('h3-ach').textContent = '🏆 ' + L({ en: 'Achievements', vi: 'Thành tựu' });
|
||
|
||
var list = $('quest-list');
|
||
list.innerHTML = '';
|
||
sv.quests.items.forEach(function (q) {
|
||
var def = null;
|
||
US.QUEST_POOL.forEach(function (d0) { if (d0.id === q.defId) def = d0; });
|
||
if (!def) return;
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'item quest-row' + (q.claimed ? ' equipped' : '');
|
||
var t = document.createElement('div');
|
||
t.className = 'item-name';
|
||
t.textContent = L(def.text);
|
||
row.appendChild(t);
|
||
|
||
var bar = document.createElement('div');
|
||
bar.className = 'qbar';
|
||
var fill = document.createElement('div');
|
||
fill.className = 'qfill';
|
||
fill.style.width = Math.min(100, (q.prog / q.goal) * 100) + '%';
|
||
bar.appendChild(fill);
|
||
row.appendChild(bar);
|
||
|
||
var meta = document.createElement('div');
|
||
meta.className = 'item-desc';
|
||
meta.textContent = Math.min(q.prog, q.goal) + ' / ' + q.goal +
|
||
' · 🔥 +' + def.gem + ' $+' + def.coins;
|
||
row.appendChild(meta);
|
||
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn';
|
||
if (q.claimed) { btn.textContent = '✓'; btn.disabled = true; }
|
||
else if (q.done) {
|
||
btn.textContent = L({ en: 'Claim', vi: 'Nhận thưởng' });
|
||
btn.onclick = function () {
|
||
q.claimed = true;
|
||
US.save.addGems(def.gem);
|
||
US.save.addCoins(def.coins);
|
||
US.audio.sfx('buy');
|
||
toast('📜 +🔥' + def.gem + ' +$' + def.coins);
|
||
US.save.save();
|
||
renderQuests();
|
||
refreshMenuStats();
|
||
};
|
||
} else {
|
||
btn.textContent = '…';
|
||
btn.disabled = true;
|
||
}
|
||
row.appendChild(btn);
|
||
list.appendChild(row);
|
||
});
|
||
|
||
// achievements
|
||
var al = $('ach-list');
|
||
al.innerHTML = '';
|
||
Object.keys(US.ACHIEVEMENTS).forEach(function (id) {
|
||
var d = US.ACHIEVEMENTS[id];
|
||
var got = US.save.hasAch(id);
|
||
var el = document.createElement('div');
|
||
el.className = 'ach-row' + (got ? ' done' : '');
|
||
el.textContent = (got ? '🏆 ' : '🔒 ') + L(d.text) + ' (🔥' + d.gem + ')';
|
||
al.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function ensureUiQuests(sv) {
|
||
// game.js owns the generator; nudge it via a run-less call
|
||
if (!sv.quests.items || sv.quests.items.length !== 3 || sv.quests.date !== US.save.today()) {
|
||
// replicate light version: full logic lives in game.js ensureQuests
|
||
if (US.game && US.game.ensureQuests) US.game.ensureQuests();
|
||
else {
|
||
sv.quests.items = sv.quests.items || [];
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------------- bestiary ---------------- */
|
||
function renderBestiary() {
|
||
var sv = US.save.get();
|
||
var grid = $('bestiary-grid');
|
||
grid.innerHTML = '';
|
||
var keys = Object.keys(US.BESTIARY);
|
||
var doneN = 0;
|
||
keys.forEach(function (id) {
|
||
var d = US.BESTIARY[id];
|
||
var e = sv.bestiary[id] || { kills: 0, done: false };
|
||
if (e.done) doneN++;
|
||
|
||
var card = document.createElement('div');
|
||
card.className = 'item best-card' + (e.done ? ' equipped' : (e.kills > 0 ? '' : ' locked'));
|
||
|
||
var h = document.createElement('div');
|
||
h.className = 'item-name';
|
||
h.textContent = e.kills > 0 ? L(d.name) : '? ? ?';
|
||
card.appendChild(h);
|
||
|
||
card.appendChild(makePreview(104, 60, function (c2, w2, h2) {
|
||
drawBeastIcon(c2, w2, h2, id, e.kills <= 0);
|
||
if (e.done) {
|
||
c2.strokeStyle = 'rgba(255,210,74,0.85)';
|
||
c2.lineWidth = 2;
|
||
c2.strokeRect(1.5, 1.5, w2 - 3, h2 - 3);
|
||
}
|
||
}));
|
||
|
||
var lore = document.createElement('div');
|
||
lore.className = 'item-desc';
|
||
lore.textContent = e.done ? L(d.lore)
|
||
: L({ en: 'Slay ' + d.goal + ' to reveal', vi: 'Hạ ' + d.goal + ' con để mở ký sự' }) +
|
||
' (' + Math.min(e.kills, d.goal) + '/' + d.goal + ')';
|
||
card.appendChild(lore);
|
||
|
||
var rw = document.createElement('div');
|
||
rw.className = 'item-desc';
|
||
rw.style.color = '#ffb938';
|
||
rw.textContent = e.done ? '✓ 🔥' + d.gem : '🔥' + d.gem;
|
||
card.appendChild(rw);
|
||
|
||
grid.appendChild(card);
|
||
});
|
||
$('best-progress').textContent = '📖 ' + doneN + ' / ' + keys.length;
|
||
}
|
||
|
||
/* ---------------- rewarded ad stub (plug real SDK here) ---------------- */
|
||
function setupAdButton(runCoins) {
|
||
var btn = $('btn-adx2');
|
||
if (!btn) return;
|
||
if (!US.save.canWatchAd()) { btn.classList.add('hidden'); return; }
|
||
btn.classList.remove('hidden');
|
||
btn.textContent = L({
|
||
en: '▶ Watch ad — bonus +50% amber (' + runCoins + ' → ' + Math.floor(runCoins * 1.5) + ')',
|
||
vi: '▶ Xem quảng cáo — +50% hổ phách (' + runCoins + ' → ' + Math.floor(runCoins * 1.5) + ')'
|
||
});
|
||
btn.onclick = function () {
|
||
btn.disabled = true;
|
||
var left = 5;
|
||
btn.textContent = L({ en: 'Ad playing… ', vi: 'Đang phát quảng cáo… ' }) + left + 's';
|
||
var iv = setInterval(function () {
|
||
left--;
|
||
if (left > 0) { btn.textContent = L({ en: 'Ad playing… ', vi: 'Đang phát quảng cáo… ' }) + left + 's'; return; }
|
||
clearInterval(iv);
|
||
US.save.consumeAd();
|
||
var bonus = Math.floor(runCoins * 0.5);
|
||
US.save.addCoins(bonus);
|
||
btn.classList.add('hidden');
|
||
toast('💰 +' + bonus);
|
||
US.audio.sfx('buy');
|
||
}, 1000);
|
||
};
|
||
}
|
||
|
||
/* ---------------- first-run tutorial ---------------- */
|
||
var TUT_STEPS = [
|
||
{ en: 'Move with WASD / arrows (or the touch sticks).', vi: 'Di chuyển bằng WASD / mũi tên (hoặc joystick cảm ứng).' },
|
||
{ en: 'You auto-aim and auto-fire at the nearest beast.', vi: 'Bạn tự ngắm và tự bắn vào con thú gần nhất.' },
|
||
{ en: 'Kill fast to build COMBO and go ON FIRE — double damage!', vi: 'Giết nhanh để lên COMBO và BÙNG CHÁY — sát thương x2!' },
|
||
{ en: 'Grab the amber ($) they drop — spend it on weapons & permanent power.', vi: 'Nhặt hổ phách ($) rơi ra — dùng mua vũ khí & nâng cấp vĩnh viễn.' },
|
||
{ en: 'Every 5th wave a BOSS stalks in. Feel the footsteps. Good luck, chief.', vi: 'Mỗi wave thứ 5 trùm sẽ xuất hiện. Nghe tiếng bước chân đi. Chúc may mắn, thủ lĩnh.' }
|
||
];
|
||
var tutStep = -1;
|
||
|
||
function startTutorial() {
|
||
tutStep = 0;
|
||
showTutStep();
|
||
show($('overlay-tutorial'));
|
||
}
|
||
function showTutStep() {
|
||
$('tut-title').textContent = L({ en: 'Welcome to the Valley', vi: 'Chào mừng đến Thung Lũng' });
|
||
$('tut-step').textContent = L(TUT_STEPS[tutStep]);
|
||
$('btn-tut-next').textContent = tutStep >= TUT_STEPS.length - 1
|
||
? L({ en: 'Let’s hunt!', vi: 'Đi săn thôi!' })
|
||
: L({ en: 'Next', vi: 'Tiếp' });
|
||
}
|
||
function endTutorial() {
|
||
hide($('overlay-tutorial'));
|
||
US.save.get().tut = true;
|
||
US.save.save();
|
||
tutStep = -1;
|
||
}
|
||
|
||
/* ---------------- wiring ---------------- */
|
||
function init() {
|
||
US.i18n.setLang(US.save.get().lang);
|
||
US.i18n.apply();
|
||
|
||
$('btn-campaign').onclick = function () { US.audio.resume(); US.audio.sfx('ui'); US.game.start('campaign'); showScreen(null); };
|
||
$('btn-arena').onclick = function () { US.audio.sfx('ui'); showScreen('arena-setup'); };
|
||
$('btn-shop').onclick = function () { US.audio.sfx('ui'); renderShop(); showScreen('shop'); };
|
||
$('btn-settings').onclick = function () { US.audio.sfx('ui'); bindSettings(); showScreen('settings'); };
|
||
$('btn-rank').onclick = function () { US.audio.sfx('ui'); renderRank(); showScreen('rank'); };
|
||
$('btn-howto').onclick = function () { US.audio.sfx('ui'); showScreen('howto'); };
|
||
// commercialization screens — guarded so a stale HTML shell can never
|
||
// crash menu init (feature buttons simply stay hidden)
|
||
// subtle hover tick on the pro menu list
|
||
document.querySelectorAll('.btn-menu').forEach(function (b) {
|
||
b.addEventListener('mouseenter', function () {
|
||
if (US.audio && US.audio.hoverTick) US.audio.hoverTick();
|
||
});
|
||
});
|
||
|
||
if ($('btn-quests')) $('btn-quests').onclick = function () { US.audio.sfx('ui'); renderQuests(); showScreen('quests'); };
|
||
if ($('btn-bestiary')) $('btn-bestiary').onclick = function () { US.audio.sfx('ui'); renderBestiary(); showScreen('bestiary'); };
|
||
if ($('btn-upgrades')) $('btn-upgrades').onclick = function () { US.audio.sfx('ui'); shopTab = 'upgrades'; renderShop(); showScreen('shop'); };
|
||
|
||
function setTxt(id, v) { if ($(id)) $(id).textContent = v; }
|
||
setTxt('lbl-quests', L({ en: 'Quests', vi: 'Nhiệm vụ' }));
|
||
setTxt('lbl-bestiary', L({ en: 'Bestiary', vi: 'Ký sự thú' }));
|
||
setTxt('lbl-upgrades', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
||
setTxt('lbl-tabup', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
||
setTxt('h2-quests', '📜 ' + L({ en: 'Daily Quests', vi: 'Nhiệm vụ mỗi ngày' }));
|
||
setTxt('h2-best', '📖 ' + L({ en: 'Bestiary of the Valley', vi: 'Ký sự thú thung lũng' }));
|
||
setTxt('lbl-shakeamt', L({ en: 'Screen shake', vi: 'Rung màn hình' }));
|
||
setTxt('lbl-quality', L({ en: 'Effects quality', vi: 'Chất lượng hiệu ứng' }));
|
||
|
||
document.querySelectorAll('[data-back]').forEach(function (b) {
|
||
b.onclick = function () { US.audio.sfx('ui'); showScreen('menu'); };
|
||
});
|
||
|
||
[['tab-weapons', 'weapons'], ['tab-heroes', 'heroes'], ['tab-skins', 'skins'], ['tab-upgrades', 'upgrades']].forEach(function (p) {
|
||
$(p[0]).onclick = function () { US.audio.sfx('ui'); shopTab = p[1]; renderShop(); };
|
||
});
|
||
|
||
// arena setup
|
||
$('arena-bots').onchange = function () {};
|
||
$('arena-target').onchange = function () {};
|
||
$('arena-start').onclick = function () {
|
||
US.audio.resume();
|
||
US.audio.sfx('ui');
|
||
US.game.start('arena', {
|
||
arena: {
|
||
bots: parseInt($('arena-bots').value, 10),
|
||
target: parseInt($('arena-target').value, 10)
|
||
}
|
||
});
|
||
showScreen(null);
|
||
};
|
||
|
||
// first-run tutorial (guarded for stale shells)
|
||
if ($('overlay-tutorial') && !$('overlay-tutorial').classList.contains('hidden')) hide($('overlay-tutorial'));
|
||
if (!$('btn-tut-next')) return;
|
||
$('btn-tut-next').onclick = function () {
|
||
US.audio.sfx('ui');
|
||
if (tutStep >= TUT_STEPS.length - 1) endTutorial();
|
||
else { tutStep++; showTutStep(); }
|
||
};
|
||
$('btn-tut-skip').onclick = function () { US.audio.sfx('ui'); endTutorial(); };
|
||
$('btn-tut-skip').textContent = L({ en: 'Skip', vi: 'Bỏ qua' });
|
||
if (!US.save.get().tut && !$('btn-retry').onclick) setTimeout(startTutorial, 400);
|
||
|
||
// pause
|
||
$('btn-pause').onclick = function () {
|
||
if (US.game.pause()) { US.audio.sfx('ui'); showPause(); }
|
||
};
|
||
$('btn-resume').onclick = function () {
|
||
if (US.game.resume()) { US.audio.sfx('ui'); hidePause(); }
|
||
};
|
||
$('btn-quit').onclick = function () {
|
||
// bank the run politely before leaving
|
||
var g = US.game.get();
|
||
if (g && !g.over && g.mode === 'campaign' && (g.wave > 0)) {
|
||
US.save.addCoins(g.runCoins);
|
||
US.save.submitRun(g.score, g.wave || 0);
|
||
}
|
||
US.game.stop();
|
||
US.ui.hideRevive();
|
||
hidePause();
|
||
hide($('overlay-over'));
|
||
hide($('overlay-cards'));
|
||
showScreen('menu');
|
||
};
|
||
|
||
// game over
|
||
$('btn-retry').onclick = function () {
|
||
var g = US.game.get();
|
||
var mode = g ? g.mode : 'campaign';
|
||
US.game.stop();
|
||
hide($('overlay-over'));
|
||
US.audio.sfx('ui');
|
||
if (mode === 'arena') showScreen('arena-setup');
|
||
else { US.game.start('campaign'); showScreen(null); }
|
||
};
|
||
$('btn-tomenu').onclick = function () {
|
||
US.game.stop();
|
||
hide($('overlay-over'));
|
||
showScreen('menu');
|
||
};
|
||
|
||
// revive
|
||
$('btn-revive').onclick = function () { US.audio.sfx('buy'); US.game.acceptRevive(); };
|
||
$('btn-nodecline').onclick = function () { US.game.declineRevive(); };
|
||
|
||
showScreen('menu');
|
||
}
|
||
|
||
US.ui = {
|
||
init: init,
|
||
showCardPick: showCardPick,
|
||
gameOver: gameOver,
|
||
arenaOver: arenaOver,
|
||
showRevive: showRevive,
|
||
hideRevive: hideRevive,
|
||
toast: toast,
|
||
questBadge: questBadge
|
||
};
|
||
})();
|