Primal Rampage — prehistoric survival wave shooter
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.
This commit is contained in:
@@ -0,0 +1,827 @@
|
||||
/* 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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user