Files
Primal Rampage Bot 7164cc798c Add full retention systems: daily hub, raids, prestige, seasons
- Graze slow-mo, perfect-wave FRENZY (x3 amber), ghost records,
  dynamic hunter titles
- Daily Hub: 7-day login calendar, animated lucky wheel, boss chest
  keys, pet eggs with rarity tiers + pity counter and real stat perks
- Seeded daily challenge (server-consistent modifiers, local top-5),
  hourly Golden Herd stampede, weekly Raid Colossus with damage tiers
- Rebirth prestige with Ancient Gem permanent perk shop
- Weapon mastery stars (+2%/level), bestiary milestone gems
- Weekly season points with Bronze..Diamond rank badges
- Shareable PNG result card on game over
2026-08-23 06:55:51 +00:00

1131 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 ($('menu-rank') && US.save.seasonRank) {
var sr2 = US.save.seasonRank();
$('menu-rank').textContent = sr2.tier.icon + ' ' +
L({ en: sr2.tier.en, vi: sr2.tier.vi });
}
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') {
var mw3 = US.save.masteryLevel ? US.save.masteryLevel(it.kind) : { level: 0, bonus: 0 };
desc.textContent = 'DMG ' + it.dmg + ' · ROF ' + it.rof + '/s' +
(it.pellets > 1 ? ' · ×' + it.pellets : '') + (it.pierce ? ' · PIERCE' : '') + (it.aoe ? ' · AoE' : '') +
(mw3.level > 0 ? ' · ⭐Lv' + mw3.level + ' (+' + Math.round(mw3.bonus * 100) + '%)' : '');
} 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')); }
var lastRunStats = null;
function buildShareCard(st) {
var cv = document.createElement('canvas');
cv.width = 520; cv.height = 280;
var c7 = cv.getContext('2d');
// dusk backdrop
var g7 = c7.createLinearGradient(0, 0, 0, 280);
g7.addColorStop(0, '#221033'); g7.addColorStop(0.55, '#b4581c'); g7.addColorStop(1, '#241206');
c7.fillStyle = g7; c7.fillRect(0, 0, 520, 280);
c7.fillStyle = 'rgba(255,217,143,0.9)';
c7.beginPath(); c7.arc(400, 90, 34, 0, Math.PI * 2); c7.fill();
// hero silhouette dot
c7.fillStyle = st.skinColor || '#e04b3a';
c7.beginPath(); c7.arc(96, 190, 26, 0, Math.PI * 2); c7.fill();
c7.strokeStyle = 'rgba(20,10,4,0.9)'; c7.lineWidth = 4; c7.stroke();
// text block
c7.fillStyle = '#fff3dd';
c7.font = 'bold 30px system-ui'; c7.textAlign = 'left';
c7.fillText('PRIMAL RAMPAGE', 150, 60);
c7.font = '16px system-ui';
c7.fillStyle = '#ffe4bd';
c7.fillText((st.name || US.save.get().name || 'Hunter') + ' · 🌍 ' + (st.biome || 'jungle'), 150, 92);
c7.font = 'bold 44px system-ui';
c7.fillStyle = '#ffd24a';
c7.fillText('Wave ' + (st.wave | 0), 150, 150);
c7.font = '18px system-ui';
c7.fillStyle = '#fff3dd';
c7.fillText('💀 ' + (st.kills | 0) + ' kills ×' + st.bestCombo + ' combo 💰' + (st.coins | 0), 150, 186);
if (st.petName) { c7.fillText('🐾 ' + st.petName, 150, 216); }
c7.font = '12px system-ui';
c7.fillStyle = 'rgba(255,233,201,0.75)';
c7.fillText('play: trycloudflare — hunt or be hunted', 150, 252);
return cv;
}
function gameOver(stats, mode) {
lastRunStats = stats;
$('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);
// daily hub glows when anything is waiting
var dailyReady =
!(sv.login.claimedToday && sv.login.last === US.save.today()) ||
US.save.canSpin() || sv.chest.keys > 0;
if ($('dot-daily')) $('dot-daily').classList.toggle('hidden', !dailyReady);
}
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 || [];
}
}
}
/* ---------------- daily hub ---------------- */
function fmtReward(rw) {
var parts = [];
if (rw.coins) parts.push('💰' + rw.coins);
if (rw.gems) parts.push('🔥' + rw.gems);
if (rw.chestKey) parts.push('🗝️×' + rw.chestKey);
if (rw.egg) parts.push('🥚');
return parts.join(' ') || '—';
}
function renderDaily() {
var sv = US.save.get();
var L2 = function (k) { return US.i18n.t(k); };
// calendar strip
var strip = document.getElementById('login-strip');
if (strip) {
strip.innerHTML = '';
var dayIdx = Math.min(6, sv.login.streak - (sv.login.claimedToday ? 0 : 1));
for (var d = 0; d < 7; d++) {
var chip = document.createElement('div');
chip.className = 'cal-day' + (d < dayIdx ? ' past' : d === dayIdx ? ' today' : '');
chip.innerHTML = '<span>' + L2('daily.day') + ' ' + (d + 1) + '</span><b>' +
fmtReward(US.LOGIN_REWARDS[d]) + '</b>';
strip.appendChild(chip);
}
}
var btnClaim = document.getElementById('btn-claim-login');
if (btnClaim) {
var canClaim = !(sv.login.claimedToday && sv.login.last === US.save.today());
btnClaim.disabled = !canClaim;
btnClaim.textContent = canClaim ? L2('daily.claim') : L2('daily.claimed');
btnClaim.onclick = function () {
var res = US.save.claimLogin();
if (!res.ok) return;
US.audio.sfx('win');
document.getElementById('wheel-result').textContent =
'✅ ' + fmtReward(res.reward) + (res.pet ? ' 🥚 ' + res.pet.name : '');
renderDaily();
};
}
// wheel
drawWheel(document.getElementById('wheel-cv'), -Math.PI / 2);
var btnSpin = document.getElementById('btn-spin');
if (btnSpin) {
btnSpin.disabled = !US.save.canSpin();
btnSpin.textContent = US.save.canSpin() ? L2('daily.spin') : L2('daily.spun');
btnSpin.onclick = function () {
if (!US.save.canSpin()) return;
var res = US.save.spinWheel();
if (!res.ok) return;
US.audio.sfx('ui');
animateWheel(res.seg, function () {
US.audio.sfx('win');
document.getElementById('wheel-result').textContent =
'🎉 ' + fmtReward(res.reward) + (res.pet ? ' 🥚 ' + res.pet.name : '');
});
btnSpin.disabled = true;
btnSpin.textContent = L2('daily.spun');
};
}
// ---- daily challenge panel ----
var dinfo = US.save.dailyInfo();
var cm = document.getElementById('chal-mods');
if (cm) {
cm.innerHTML = dinfo.mods.map(function (mid) {
var md = US.DAILY_MOD_POOL.filter(function (m3) { return m3.id === mid; })[0] || {};
return '🎲 ' + L({ en: md.en, vi: md.vi });
}).join('<br>');
}
var cb = document.getElementById('chal-best');
var stCh = US.save.get().dailyChal;
if (cb) cb.textContent = (stCh.date === dinfo.date && stCh.bestScore > 0)
? L({ en: "Today's best", vi: 'Kỷ lục hôm nay' }) + ': ' + stCh.bestScore +
' · Top: ' + (stCh.board[0] ? stCh.board[0].score : '—')
: '';
var bp2 = document.getElementById('btn-play-chal');
if (bp2) bp2.onclick = function () {
US.audio.resume(); US.audio.sfx('ui');
US.game.start('campaign', { challenge: true });
showScreen(null);
};
// ---- weekly raid panel ----
var ri = US.save.raidInfo();
var pct = Math.min(100, Math.round(ri.damage / ri.pool * 100));
var fill = document.getElementById('raid-bar-fill');
if (fill) fill.style.width = pct + '%';
var rinfo = document.getElementById('raid-info');
if (rinfo) rinfo.innerHTML =
L({ en: 'Your damage', vi: 'Sát thương của bạn' }) + ': <b>' + ri.damage.toLocaleString() +
'</b> / ' + ri.pool.toLocaleString();
var tiersEl = document.getElementById('raid-tiers');
if (tiersEl) {
tiersEl.innerHTML = '';
ri.tiers.forEach(function (t4, ti4) {
var el = document.createElement('div');
var claimed = ri.claimedTiers.indexOf(ti4) >= 0;
var can = !claimed && ri.damage >= t4.dmg;
el.className = 'raid-tier' + (claimed ? ' claimed' : can ? ' claimable' : '');
el.innerHTML = '🔥' + t4.gems + '<br>' + t4.dmg.toLocaleString();
el.onclick = function () {
if (!can) return;
var r3 = US.save.claimRaidTier(ti4);
if (r3.ok) { US.audio.sfx('win'); renderDaily(); }
};
tiersEl.appendChild(el);
});
}
var br2 = document.getElementById('btn-play-raid');
if (br2) br2.onclick = function () {
US.audio.resume(); US.audio.sfx('ui');
US.game.start('campaign', { raid: true });
showScreen(null);
};
// ---- prestige / rebirth panel ----
var pi = US.save.prestigeInfo();
var prInfo = document.getElementById('pr-info');
if (prInfo) prInfo.innerHTML =
L({ en: 'Ancient Gems', vi: 'Ngọc Xưa' }) + ': <b>🟣 ' + pi.ngoc + '</b>' +
' · ' + L({ en: 'Rebirths', vi: 'Tái sinh' }) + ': <b>' + pi.count + '</b><br>' +
(pi.canPrestige
? L({ en: 'Rebirth now for', vi: 'Tái sinh nhận' }) + ' <b>+' + pi.ngocGain + '</b>'
: L({ en: 'Reach wave 50 to unlock Rebirth', vi: 'Đến wave 50 để mở Tái Sinh' }));
var perksEl = document.getElementById('pr-perks');
if (perksEl) {
perksEl.innerHTML = '';
Object.keys(pi.perkDefs).forEach(function (pk3) {
var def = pi.perkDefs[pk3];
var lv = pi.perks[pk3] || 0;
var cost = lv < def.cost.length ? def.cost[lv] : null;
var row = document.createElement('div');
row.className = 'pr-perk';
var label = pk3 === 'dmg' ? ('⚔️ +' + Math.round(def.per * 100) + '% DMG /lv')
: pk3 === 'hp' ? ('❤️ +' + def.per + ' HP /lv')
: ('💰 +' + Math.round(def.per * 100) + '% gold /lv');
row.innerHTML = '<span>' + label + '</span><b>Lv ' + lv + '/' + def.cost.length + '</b>';
var btn = document.createElement('button');
btn.className = 'btn sm';
btn.textContent = cost !== null ? '🟣' + cost : 'MAX';
btn.disabled = cost === null || pi.ngoc < cost;
btn.onclick = function () {
var r4 = US.save.buyPerk(pk3);
if (r4.ok) { US.audio.sfx('win'); renderDaily(); }
};
row.appendChild(btn);
perksEl.appendChild(row);
});
}
var btnReb = document.getElementById('btn-rebirth');
if (btnReb) {
btnReb.disabled = !pi.canPrestige;
btnReb.onclick = function () {
if (!pi.canPrestige) return;
var r5 = US.save.doPrestige();
if (r5.ok) {
US.audio.sfx('win');
toast('♻️ +' + r5.gained + ' ' + L({ en: 'Ancient Gems', vi: 'Ngọc Xưa' }));
renderDaily();
refreshMenuStats();
}
};
}
// chest
var info = document.getElementById('chest-info');
if (info) info.innerHTML = '🗝️ ×<b>' + sv.chest.keys + '</b>';
var btnChest = document.getElementById('btn-open-chest');
if (btnChest) {
var hasKey = sv.chest.keys > 0;
btnChest.disabled = !hasKey;
btnChest.textContent = hasKey ? L2('daily.open') : L2('daily.nokey');
btnChest.onclick = function () {
var res = US.save.openChest();
if (!res.ok) return;
US.audio.sfx('win');
document.getElementById('wheel-result').textContent =
'🧰 ' + fmtReward(res.reward) + (res.pet ? ' 🥚 ' + res.pet.name : '');
renderDaily();
};
}
}
function drawWheel(cv, rot) {
if (!cv || !cv.getContext) return;
var c2 = cv.getContext('2d');
var W2 = cv.width, H2 = cv.height, r = W2 / 2 - 6;
var cols = ['#c44a14', '#7a5230', '#b4581c', '#58a55c', '#9fd8ff', '#ffb938', '#b05f8f', '#ffd24a'];
c2.clearRect(0, 0, W2, H2);
c2.save();
c2.translate(W2 / 2, H2 / 2);
c2.rotate(rot || 0);
for (var i = 0; i < 8; i++) {
var a0 = i * Math.PI / 4, a1 = a0 + Math.PI / 4;
c2.beginPath(); c2.moveTo(0, 0); c2.arc(0, 0, r, a0, a1); c2.closePath();
c2.fillStyle = cols[i % cols.length]; c2.fill();
c2.strokeStyle = 'rgba(20,10,4,0.8)'; c2.lineWidth = 2; c2.stroke();
c2.save();
c2.rotate(a0 + Math.PI / 8);
c2.fillStyle = '#fff3dd';
c2.font = 'bold 11px system-ui';
c2.textAlign = 'right';
var lbl = fmtReward(US.save.WHEEL[i]);
c2.fillText(lbl, r - 10, 4);
c2.restore();
}
c2.restore();
c2.beginPath(); c2.arc(W2 / 2, H2 / 2, 16, 0, Math.PI * 2);
c2.fillStyle = '#241206'; c2.fill();
c2.strokeStyle = '#ffb938'; c2.lineWidth = 3; c2.stroke();
}
function animateWheel(targetSeg, done) {
var cv = document.getElementById('wheel-cv');
if (!cv) { if (done) done(); return; }
var start = performance.now(), dur = 2600;
var turns = 5;
var targetRot = turns * Math.PI * 2 + (Math.PI * 2 - targetSeg * Math.PI / 4 - Math.PI / 8);
(function step() {
var t = (performance.now() - start) / dur;
if (t >= 1) { drawWheel(cv, targetRot % (Math.PI * 2)); if (done) done(); return; }
var e2 = 1 - Math.pow(1 - t, 3);
drawWheel(cv, e2 * targetRot);
requestAnimationFrame(step);
})();
}
/* ---------------- 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: 'Lets 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-daily')) $('btn-daily').onclick = function () { US.audio.sfx('ui'); renderDaily(); showScreen('daily'); };
if ($('btn-share')) $('btn-share').onclick = function () {
if (!lastRunStats) return;
var card2 = buildShareCard({
name: US.save.get().name,
wave: lastRunStats.wave, kills: lastRunStats.kills,
bestCombo: lastRunStats.bestCombo, coins: lastRunStats.coins,
biome: lastRunStats.biome, petName: lastRunStats.petName,
skinColor: lastRunStats.skinColor
});
try {
var a4 = document.createElement('a');
a4.download = 'primal-rampage-wave' + (lastRunStats.wave | 0) + '.png';
a4.href = card2.toDataURL('image/png');
document.body.appendChild(a4); a4.click(); a4.remove();
toast(L({ en: '📸 Share card saved!', vi: '📸 Đã lưu thẻ chia sẻ!' }));
} catch (e9) {
toast(L({ en: '📸 Card ready!', vi: '📸 Thẻ đã sẵn sàng!' }));
}
};
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-daily', L({ en: 'Daily Hub', vi: 'Ưu đãi hôm nay' }));
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
};
})();