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
This commit is contained in:
+33
@@ -81,6 +81,39 @@
|
||||
vi: 'Thái Dương — ánh sáng nguyên thủy' }
|
||||
};
|
||||
|
||||
/* ---------------- Daily challenge modifiers ---------------- */
|
||||
US.DAILY_MOD_POOL = [
|
||||
{ id: 'fastfoes', en: 'Fast Foes ×1.25', vi: 'Quái Nhanh ×1.25' },
|
||||
{ id: 'glasshero', en: 'Glass Hero ×0.6 HP', vi: 'Thủy Tinh Hero ×0.6 HP' },
|
||||
{ id: 'richherds', en: 'Rich Herds ×2 amber', vi: 'Đàn Thú Giàu ×2 hổ phách' },
|
||||
{ id: 'swarm', en: 'Swarm ×1.4 budget', vi: 'Bầy Đàn ×1.4 ngân sách' },
|
||||
{ id: 'venomwind', en: 'Venom +30% dmg', vi: 'Nọc Độc +30% sát thương' }
|
||||
];
|
||||
|
||||
/* ---------------- Pets by rarity ---------------- */
|
||||
US.PETS = {
|
||||
common: [
|
||||
{ name: 'Cơm Nếp', color: '#c98a3d' },
|
||||
{ name: 'Xanh Lá', color: '#5f8f3e' },
|
||||
{ name: 'Bonnie', color: '#b05f8f' },
|
||||
{ name: 'Mập Mạp', color: '#7a5230' }
|
||||
],
|
||||
rare: [
|
||||
{ name: 'Tia Chớp', color: '#58a55c', perk: { rof: 0.06 } },
|
||||
{ name: 'Sấm Rền', color: '#9fd8ff', perk: { dmg: 0.05 } }
|
||||
],
|
||||
legendary: [
|
||||
{ name: 'Rồng Lửa', color: '#ff6b35', perk: { dmg: 0.10, rof: 0.05 } },
|
||||
{ name: 'Băng Giá', color: '#aee3ff', perk: { hp: 15, gold: 0.10 } }
|
||||
]
|
||||
};
|
||||
|
||||
// login calendar rewards (day 1..7)
|
||||
US.LOGIN_REWARDS = [
|
||||
{ coins: 250 }, { chestKey: 1 }, { coins: 400 }, { gems: 15 },
|
||||
{ coins: 600 }, { gems: 25 }, { egg: 'rare', gems: 50 }
|
||||
];
|
||||
|
||||
/* ---------------- Beasts ---------------- */
|
||||
// contact = dps on touch; value feeds heat/score; cost feeds wave budget
|
||||
US.ENEMIES = {
|
||||
|
||||
+217
-17
@@ -23,6 +23,7 @@
|
||||
|
||||
var g = {
|
||||
mode: mode, // 'campaign' | 'arena'
|
||||
runOpts: opts || {},
|
||||
state: 'playing', // playing | cards | paused | revive | over
|
||||
time: 0,
|
||||
over: false,
|
||||
@@ -113,6 +114,7 @@
|
||||
var budget = waveBudget(wave);
|
||||
if (wave > 25) budget = Math.floor(budget * (1 + (wave - 25) * 0.09));
|
||||
if (g.bloodMoon) budget = Math.floor(budget * 1.25);
|
||||
if (g.mods && g.mods.swarm) budget = Math.ceil(budget * 1.4);
|
||||
var pool = availableEnemies(wave);
|
||||
var q = [];
|
||||
var t = 0;
|
||||
@@ -187,12 +189,28 @@
|
||||
function scheduleNextWave(g) {
|
||||
g.wave++;
|
||||
g.waveActive = true;
|
||||
g.waveDamaged = false;
|
||||
// ghost record banner the moment this run passes the old best
|
||||
if (!g.ghostShown && g.ghostWave > 0 && g.wave > g.ghostWave) {
|
||||
g.ghostShown = true;
|
||||
announce(g, L({ en: '👻 GHOST BUSTED — wave ' + g.ghostWave + ' beaten!',
|
||||
vi: '👻 PHÁ KỶ LỤC — vượt wave ' + g.ghostWave + '!' }, metaLang()), '#7ee787');
|
||||
}
|
||||
g.spawnQueue = buildWaveQueue(g, g.wave);
|
||||
|
||||
// act biomes: jungle -> swamp -> ashlands
|
||||
var wantBio = biomeFor(g.wave);
|
||||
if (g.biome !== wantBio) enterBiome(g, wantBio);
|
||||
|
||||
// weekly raid: one colossal bruiser carrying the remaining pool
|
||||
if (g.raid) {
|
||||
var ri = US.save.raidInfo();
|
||||
var remain = Math.max(2000, ri.pool - ri.damage);
|
||||
g.spawnQueue = [{ defId: 'bruiser', t: 0.5, mods: { hp: Math.min(6, remain / 2600), speed: 0.55 } }];
|
||||
g.raidBossPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (g.wave % 5 === 0) {
|
||||
announce(g, US.i18n.t('announce.boss'), '#e04b3a');
|
||||
US.audio.sfx('bossRoar');
|
||||
@@ -498,6 +516,7 @@
|
||||
function damageEnemy(g, e, dmg, crit, silent) {
|
||||
if (!e.alive) return;
|
||||
e.hp -= dmg;
|
||||
if (g.raidBoss && e === g.raidBoss) US.save.addRaidDamage(dmg);
|
||||
e.hitFlash = 0.1;
|
||||
if (!silent) {
|
||||
US.entities.floatText(e.x + util.rand(-8, 8), e.y - e.radius - 6,
|
||||
@@ -633,6 +652,29 @@
|
||||
}
|
||||
|
||||
/* ---------------- the tribe's pet raptor -------------------------------- */
|
||||
function spawnGoldenHerd(g) {
|
||||
if (g.herdDone) return;
|
||||
g.herdDone = true;
|
||||
for (var gh = 0; gh < 6; gh++) {
|
||||
var gp = spawnEdgePoint(g);
|
||||
spawnEnemyAt(g, 'runner', gp.x, gp.y, { hp: 0.5 });
|
||||
var ge2 = g.enemies[g.enemies.length - 1];
|
||||
ge2.golden = true; ge2.color = '#ffd24a';
|
||||
ge2.value = (ge2.value || 1) * 8;
|
||||
ge2.speed = (ge2.speed || 160) * 1.15;
|
||||
}
|
||||
announce(g, L({ en: '✨ GOLDEN HERD STAMPEDE — ×8 amber!',
|
||||
vi: '✨ ĐÀN VÀNG LỘT XUYÊN — hổ phách ×8!' }, metaLang()), '#ffd24a');
|
||||
}
|
||||
|
||||
function applyPetPerk(stats, perk) {
|
||||
if (!perk || !stats) return;
|
||||
if (perk.dmg) stats.dmgMul = (stats.dmgMul || 1) * (1 + perk.dmg);
|
||||
if (perk.rof) stats.rofMul = (stats.rofMul || 1) * (1 + perk.rof);
|
||||
if (perk.gold) stats.coinMul = (stats.coinMul || 1) * (1 + perk.gold);
|
||||
if (perk.hp) stats.maxHp = (stats.maxHp || 100) + perk.hp;
|
||||
}
|
||||
|
||||
function makePet(g) {
|
||||
var pd = US.save.get().pet || { name: 'Rex Jr', color: '#5f8f3e' };
|
||||
return {
|
||||
@@ -701,11 +743,52 @@
|
||||
g.bestCombo = Math.max(g.bestCombo, g.combo);
|
||||
g.sinceKill = 0;
|
||||
|
||||
// unscathed kill streak -> hunter titles
|
||||
g.killStreak = (g.killStreak || 0) + 1;
|
||||
var TITLES = [
|
||||
[15, { en: 'Storm Hunter', vi: 'Thợ Săn Bão Tố' }],
|
||||
[30, { en: 'Apex Predator', vi: 'Kẻ Săn Đỉnh Cao' }],
|
||||
[60, { en: 'Legend of the Valley', vi: 'Huyền Thoại Thung Lũng' }]
|
||||
];
|
||||
for (var tt3 = TITLES.length - 1; tt3 >= 0; tt3--) {
|
||||
if (g.killStreak >= TITLES[tt3][0]) {
|
||||
if (g.curTitle !== TITLES[tt3][1].en) {
|
||||
g.curTitle = TITLES[tt3][1].en;
|
||||
announce(g, L({ en: '🏅 Title earned: ' + TITLES[tt3][1].en,
|
||||
vi: '🏅 Danh hiệu: ' + TITLES[tt3][1].vi }, metaLang()), '#ffd24a');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// weapon mastery XP (kills with current weapon kind)
|
||||
var wk2 = g.player.weapon && g.player.weapon.kind;
|
||||
if (wk2) {
|
||||
var mst = US.save.get().mastery;
|
||||
mst[wk2] = (mst[wk2] || 0) + 1;
|
||||
}
|
||||
|
||||
// boss falls -> chest key drop
|
||||
if (g.raidBoss === e) {
|
||||
announce(g, L({ en: '🏆 RAID COLOSSUS FELLED!', vi: '🏆 KOLOSS SỤP ĐỘ!' }, metaLang()), '#ffd24a');
|
||||
endRun(g);
|
||||
return;
|
||||
}
|
||||
if (e.isBoss) {
|
||||
US.save.get().chest.keys += 1;
|
||||
announce(g, L({ en: '🗝️ Boss dropped a CHEST KEY!', vi: '🗝️ Trùm đánh rơi CHÌA KHÓA RƯƠNG!' }, metaLang()), '#ffd24a');
|
||||
}
|
||||
|
||||
// ---- meta progression on every slain beast ----
|
||||
var spKey = speciesOf(e);
|
||||
if (spKey && US.BESTIARY[spKey]) {
|
||||
var bDef = US.BESTIARY[spKey];
|
||||
US.save.bumpBestiary(spKey);
|
||||
var msGems = US.save.bestiaryMilestoneCheck(spKey);
|
||||
if (msGems > 0) {
|
||||
announce(g, L({ en: '📖 ' + spKey.toUpperCase() + ' milestone — +' + msGems + ' 🔥',
|
||||
vi: '📖 Cột mốc ' + spKey.toUpperCase() + ' — +' + msGems + ' 🔥' }, metaLang()), '#7ee787');
|
||||
}
|
||||
var bEntry = US.save.get().bestiary[spKey];
|
||||
if (bEntry.kills >= bDef.goal && US.save.bestiaryDone(spKey, function () {
|
||||
US.save.addGems(bDef.gem);
|
||||
@@ -805,6 +888,17 @@
|
||||
}
|
||||
|
||||
function hurtPlayer(g, dmg) {
|
||||
if (g.enemyDmgMul) dmg *= g.enemyDmgMul;
|
||||
g.lastHurt = { t: Math.round(g.time * 10) / 10, dmg: Math.round(dmg * 10) / 10,
|
||||
hpAfter: Math.round((g.player ? g.player.hp - dmg : 0)) };
|
||||
g.waveDamaged = true;
|
||||
if (g.perfectChain) g.perfectChain = 0;
|
||||
if (g.frenzyT) g.frenzyT = 0;
|
||||
if ((g.killStreak || 0) >= 10 && !g.titleLostShown) {
|
||||
g.titleLostShown = true;
|
||||
}
|
||||
g.killStreak = 0;
|
||||
g.curTitle = null;
|
||||
var p = g.player;
|
||||
if (!p.alive || p.invuln > 0) return;
|
||||
if (US.save.get().settings.onehit && g.mode === 'campaign') dmg = p.stats.maxHp;
|
||||
@@ -857,6 +951,9 @@
|
||||
var save = US.save.get();
|
||||
save.bombs = g.mode === 'campaign' ? g.player.bombs : save.bombs;
|
||||
var record = US.save.submitRun(g.score, g.wave || 0);
|
||||
if (g.challenge) US.save.submitChallengeScore(g.score, g.wave || 0);
|
||||
var seasonPts = Math.round(g.score / 100) + (g.wave | 0);
|
||||
US.save.addSeasonPoints(seasonPts);
|
||||
US.save.addCoins(g.runCoins);
|
||||
US.save.stat('runs', 1);
|
||||
checkAch(g);
|
||||
@@ -867,7 +964,10 @@
|
||||
kills: g.kills,
|
||||
bestCombo: g.bestCombo,
|
||||
coins: g.runCoins,
|
||||
record: record
|
||||
record: record,
|
||||
biome: g.biome || 'jungle',
|
||||
petName: US.save.get().pet ? US.save.get().pet.name : null,
|
||||
skinColor: g.player.skin.body
|
||||
}, g.mode);
|
||||
}
|
||||
|
||||
@@ -916,8 +1016,47 @@
|
||||
function update(g, dt) {
|
||||
if (!g.metaInit) {
|
||||
g.metaInit = true;
|
||||
if (g.runOpts && g.runOpts.challenge) {
|
||||
g.challenge = true;
|
||||
var dinfo = US.save.dailyInfo();
|
||||
g.mods = {};
|
||||
dinfo.mods.forEach(function (m2) { g.mods[m2] = true; });
|
||||
announce(g, L({ en: '🎯 DAILY CHALLENGE', vi: '🎯 THỬ THÁCH HÔM NAY' }, metaLang()), '#7ee787');
|
||||
}
|
||||
if (g.runOpts && g.runOpts.raid) {
|
||||
g.raid = true;
|
||||
var rinfo = US.save.raidInfo();
|
||||
g.mods = { raid: true };
|
||||
announce(g, L({ en: '⚔️ WEEKLY RAID — strike the colossus!',
|
||||
vi: '⚔️ RAID TUẦN — đánh koloss!' }, metaLang()), '#ff9b3d');
|
||||
}
|
||||
if (g.mods && g.mods.glasshero) {
|
||||
g.player.stats.maxHp = Math.ceil(g.player.stats.maxHp * 0.6);
|
||||
g.player.hp = Math.min(g.player.hp, g.player.stats.maxHp);
|
||||
}
|
||||
if (g.mods && g.mods.venomwind) g.enemyDmgMul = 1.3;
|
||||
// permanent prestige perks
|
||||
var pvk = US.save.get().prestige.perks;
|
||||
var pdefs = { dmg: 0.04, hp: 8, gold: 0.03 };
|
||||
if (pvk.dmg) g.player.stats.dmgMul *= 1 + pdefs.dmg * pvk.dmg;
|
||||
if (pvk.hp) {
|
||||
g.player.stats.maxHp += pdefs.hp * pvk.hp;
|
||||
g.player.hp = Math.min(g.player.hp + pdefs.hp * pvk.hp, g.player.stats.maxHp);
|
||||
}
|
||||
if (pvk.gold) g.player.stats.coinMul *= 1 + pdefs.gold * pvk.gold;
|
||||
// weapon mastery of the equipped weapon
|
||||
var mw2 = US.save.masteryLevel(g.player.weapon.kind);
|
||||
if (mw2.bonus > 0) g.player.stats.dmgMul *= 1 + mw2.bonus;
|
||||
g.waveDamaged = false;
|
||||
g.perfectChain = 0;
|
||||
g.killStreak = 0;
|
||||
g.ghostWave = US.save.get().bestWave || 0;
|
||||
g.player.bombs += g.player.stats.startBombs | 0;
|
||||
if (US.save.get().pet) g.pet = makePet(g);
|
||||
if (US.save.get().pet) {
|
||||
g.pet = makePet(g);
|
||||
applyPetPerk(g.player.stats, US.save.get().pet.perk);
|
||||
g.player.hp = Math.min(g.player.hp, g.player.stats.maxHp);
|
||||
}
|
||||
ensureQuests(US.save.get());
|
||||
if (US.save.isWeekend && US.save.isWeekend()) {
|
||||
g.bloodMoon = true;
|
||||
@@ -930,6 +1069,32 @@
|
||||
updatePet(g, dt);
|
||||
if (g.state === 'over') return;
|
||||
|
||||
// ---- GOLDEN HERD: hourly stampede of gilded compys ----
|
||||
var nowMin = new Date().getMinutes();
|
||||
if (!g.herdDone && nowMin < 10 && g.state === 'playing' && g.enemies.length < 40) {
|
||||
spawnGoldenHerd(g);
|
||||
}
|
||||
|
||||
// ---- GRAZE: skim death without touching it ----
|
||||
if (g.frenzyT > 0) g.frenzyT -= dt;
|
||||
if (g.player && g.player.alive) {
|
||||
for (var gz = 0; gz < g.enemies.length; gz++) {
|
||||
var ge = g.enemies[gz];
|
||||
if (ge.alive === false || ge.spawnAnim > 0 || ge.grazed) continue;
|
||||
var gr_ = ge.radius + g.player.radius;
|
||||
var gd2 = util.dist2(ge.x, ge.y, g.player.x, g.player.y);
|
||||
if (gd2 < (gr_ + 18) * (gr_ + 18) && gd2 > gr_ * gr_) {
|
||||
ge.grazed = true;
|
||||
g.slowmo = Math.max(g.slowmo || 0, 0.16);
|
||||
g.score += 5;
|
||||
g.grazes = (g.grazes || 0) + 1;
|
||||
US.entities.spawnParticles(null, (ge.x + g.player.x) / 2, (ge.y + g.player.y) / 2, '#ffe9c9', 3, 0.4, 2);
|
||||
US.entities.floatText((ge.x + g.player.x) / 2, (ge.y + g.player.y) / 2 - 8,
|
||||
L({ en: 'graze!', vi: 'sượt!' }, metaLang()), '#ffe9c9');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// announcements always tick
|
||||
for (var a = g.announces.length - 1; a >= 0; a--) {
|
||||
g.announces[a].t += dt;
|
||||
@@ -1059,10 +1224,17 @@
|
||||
g.spawnQueue[q].t -= dt;
|
||||
if (g.spawnQueue[q].t <= 0) {
|
||||
var item = g.spawnQueue[q];
|
||||
if (g.mods && g.mods.fastfoes) {
|
||||
item.mods = item.mods || {};
|
||||
item.mods.speed = (item.mods.speed || 1) * 1.25;
|
||||
}
|
||||
var pos = spawnEdgePoint(g);
|
||||
pos.x = util.clamp(pos.x, WALL + 30, W - WALL - 30);
|
||||
pos.y = util.clamp(pos.y, WALL + 30, H - WALL - 30);
|
||||
spawnEnemyAt(g, item.defId, pos.x, pos.y, item.mods);
|
||||
if (g.raid && !g.raidBoss && g.enemies[g.enemies.length - 1].isBoss) {
|
||||
g.raidBoss = g.enemies[g.enemies.length - 1];
|
||||
}
|
||||
g.spawnQueue.splice(q, 1);
|
||||
q--;
|
||||
}
|
||||
@@ -1250,6 +1422,20 @@
|
||||
|
||||
function onWaveClear(g) {
|
||||
var bonus = 15 + g.wave * 3;
|
||||
|
||||
// PERFECT chain: untouched waves stack toward a FRENZY gold rush
|
||||
if (!g.waveDamaged && g.wave > 1) {
|
||||
g.perfectChain = (g.perfectChain || 0) + 1;
|
||||
var pb = 20 * g.perfectChain;
|
||||
g.runCoins += pb;
|
||||
announce(g, L({ en: 'PERFECT ×' + g.perfectChain + ' +' + pb, vi: 'HOÀN HẢO ×' + g.perfectChain + ' +' + pb }, metaLang()), '#7ee787');
|
||||
if (g.perfectChain >= 3 && !(g.frenzyT > 0)) {
|
||||
g.frenzyT = 20;
|
||||
announce(g, L({ en: '🔥 FRENZY — AMBER ×3!', vi: '🔥 CUỒNG NỘ — HỔ PHÁCH ×3!' }, metaLang()), '#ff9b3d');
|
||||
}
|
||||
} else {
|
||||
g.perfectChain = 0;
|
||||
}
|
||||
g.runCoins += bonus;
|
||||
g.score += 50 * g.wave;
|
||||
announce(g, US.i18n.t('announce.waveClear'), '#8ac74a');
|
||||
@@ -1319,10 +1505,13 @@
|
||||
}
|
||||
|
||||
function collectPickup(g, pk) {
|
||||
var frenzyMult = (g.frenzyT > 0) ? 3 : 1;
|
||||
var p = g.player;
|
||||
if (pk.kind === 'coin') {
|
||||
var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul));
|
||||
if (g.bloodMoon) amt *= 2;
|
||||
if (g.mods && g.mods.richherds) amt *= 2;
|
||||
if (frenzyMult > 1) amt *= frenzyMult;
|
||||
g.runCoins += amt;
|
||||
g.score += 2;
|
||||
US.entities.floatText(pk.x, pk.y - 10, '+' + amt, '#ffb938');
|
||||
@@ -1334,16 +1523,20 @@
|
||||
p.bombs++;
|
||||
US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d');
|
||||
} else if (pk.kind === 'egg') {
|
||||
var names = ['Cơm Nếp', 'Xanh Lá', 'Bonnie', 'Mập Mạp', 'Tia Chớp', 'Bông'];
|
||||
var cols = ['#c98a3d', '#5f8f3e', '#b05f8f', '#7a5230', '#58a55c', '#9fd8ff'];
|
||||
var pi = (Math.random() * names.length) | 0;
|
||||
US.save.get().pet = { name: names[pi], color: cols[pi] };
|
||||
US.save.save();
|
||||
var res2 = US.save.hatchEgg();
|
||||
g.pet = makePet(g);
|
||||
applyPetPerk(g.player, res2.pet.perk);
|
||||
var rarCol = res2.rarity === 'legendary' ? '#ff9b3d'
|
||||
: res2.rarity === 'rare' ? '#7ee787' : '#ffd24a';
|
||||
announce(g, L({
|
||||
en: '🥚 A raptor hatched: "' + names[pi] + '"!',
|
||||
vi: '🥚 Raptor con nở rồi: "' + names[pi] + '"!'
|
||||
}, metaLang()), '#ffd24a');
|
||||
en: '🥚 ' + res2.rarity.toUpperCase() + ' pet hatched: "' + res2.pet.name + '"!',
|
||||
vi: '🥚 Thú cưng ' + (res2.rarity === 'legendary' ? 'HUYỀN THOẠI' : res2.rarity === 'rare' ? 'HIẾM' : 'thường') +
|
||||
' nở rồi: "' + res2.pet.name + '"!'
|
||||
}, metaLang()), rarCol);
|
||||
if (res2.rarity !== 'common') {
|
||||
g.flashWhite = 0.5;
|
||||
boomFx(g, p.x, p.y, rarCol, 26, 160);
|
||||
}
|
||||
US.audio.sfx('win');
|
||||
checkAch(g);
|
||||
}
|
||||
@@ -1893,6 +2086,13 @@
|
||||
resume: function () { if (current && current.state === 'paused') { current.state = 'playing'; return true; } return false; },
|
||||
castUlt: function () { if (current) castUlt(current); },
|
||||
ensureQuests: function () { ensureQuests(US.save.get()); },
|
||||
triggerHerd: function () { if (current && current.state === 'playing') spawnGoldenHerd(current); },
|
||||
toggleAutoAim: function () {
|
||||
var st = US.save.get();
|
||||
st.settings.autoAim = !st.settings.autoAim;
|
||||
US.save.save();
|
||||
return st.settings.autoAim;
|
||||
},
|
||||
jumpWave: function (n) {
|
||||
if (!current || current.state === 'over') return false;
|
||||
current.state = 'playing';
|
||||
@@ -1924,7 +2124,13 @@
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (current) {
|
||||
update(current, dt);
|
||||
// graze slow-mo: world breathes while a near-miss plays out
|
||||
var effDt = dt;
|
||||
if (current.slowmo > 0) {
|
||||
current.slowmo -= dt;
|
||||
effDt = dt * 0.35;
|
||||
}
|
||||
update(current, effDt);
|
||||
render(current, ctx, vw, vh);
|
||||
} else {
|
||||
// painted dusk scene behind the menus
|
||||
@@ -1945,12 +2151,6 @@
|
||||
// wire entity callbacks that need game context
|
||||
US.entities._hooks = {
|
||||
nearestEnemy: function (x, y, d, ex) { return current ? nearestEnemy(current, x, y, d, ex) : null; },
|
||||
toggleAutoAim: function () {
|
||||
var st = US.save.get();
|
||||
st.settings.autoAim = !st.settings.autoAim;
|
||||
US.save.save();
|
||||
return st.settings.autoAim;
|
||||
},
|
||||
damageEnemy: function (e, dmg, crit, silent) { if (current) damageEnemy(current, e, dmg, crit, silent); },
|
||||
explode: function (x, y, r, dmg) { if (current) explodeAt(current, x, y, r, dmg); },
|
||||
collect: function (pk) { if (current) collectPickup(current, pk); },
|
||||
|
||||
+44
@@ -95,6 +95,28 @@
|
||||
'set.music': 'Music volume',
|
||||
'set.autofire': 'Auto-fire',
|
||||
'set.autoaim': 'Auto-aim nearest (F)',
|
||||
'menu.daily': 'Daily Hub',
|
||||
'daily.title': '🎁 Daily Hub',
|
||||
'daily.calendar': '📅 Login Calendar',
|
||||
'daily.claim': 'Claim today',
|
||||
'daily.claimed': 'Come back tomorrow!',
|
||||
'daily.wheel': '🎡 Lucky Wheel (1 free spin / day)',
|
||||
'daily.spin': 'SPIN',
|
||||
'daily.spun': 'Next spin tomorrow',
|
||||
'daily.chest': '🗝️ Treasure Chest',
|
||||
'daily.open': 'Open chest',
|
||||
'daily.nokey': 'Slay bosses to earn keys',
|
||||
'daily.day': 'Day',
|
||||
'chal.title': '🎯 Daily Challenge',
|
||||
'chal.play': 'PLAY CHALLENGE',
|
||||
'chal.best': "Today's best",
|
||||
'raid.title': '⚔️ Weekly Raid Colossus',
|
||||
'raid.play': 'ATTACK THE COLOSSUS',
|
||||
'raid.my': 'Your damage',
|
||||
'raid.pool': 'Pool',
|
||||
'over.share': 'Share result',
|
||||
'prest.title': '♻️ Rebirth — Ancient Gems',
|
||||
'prest.go': 'REBIRTH NOW',
|
||||
'set.onehit': 'One-hit mode (campaign)',
|
||||
'set.shake': 'Screen shake',
|
||||
'set.name': 'Player name',
|
||||
@@ -206,6 +228,28 @@
|
||||
'set.music': 'Âm lượng nhạc',
|
||||
'set.autofire': 'Tự động bắn',
|
||||
'set.autoaim': 'Tự ngắm mục tiêu gần nhất (F)',
|
||||
'menu.daily': 'Ưu đãi hôm nay',
|
||||
'daily.title': '🎁 Ưu Đãi Hôm Nay',
|
||||
'daily.calendar': '📅 Lịch Điểm Danh',
|
||||
'daily.claim': 'Nhận thưởng hôm nay',
|
||||
'daily.claimed': 'Mai quay lại nhé!',
|
||||
'daily.wheel': '🎡 Vòng Quay May Mắn (1 lượt/ngày)',
|
||||
'daily.spin': 'QUAY',
|
||||
'daily.spun': 'Ngày mai quay tiếp',
|
||||
'daily.chest': '🗝️ Rương Báu Vật',
|
||||
'daily.open': 'Mở rương',
|
||||
'daily.nokey': 'Diệt trùm để kiếm chìa khóa',
|
||||
'daily.day': 'Ngày',
|
||||
'chal.title': '🎯 Thử Thách Hôm Nay',
|
||||
'chal.play': 'CHƠI THỬ THÁCH',
|
||||
'chal.best': 'Kỷ lục hôm nay',
|
||||
'raid.title': '⚔️ Koloss Raid Tuần',
|
||||
'raid.play': 'TẤN CÔNG KOLOSS',
|
||||
'raid.my': 'Sát thương của bạn',
|
||||
'raid.pool': 'Máu bầy',
|
||||
'over.share': 'Chia sẻ kết quả',
|
||||
'prest.title': '♻️ Tái Sinh — Ngọc Xưa',
|
||||
'prest.go': 'TÁI SINH NGAY',
|
||||
'set.onehit': 'Chế độ một-hit (chiến dịch)',
|
||||
'set.shake': 'Rung màn hình',
|
||||
'set.name': 'Tên người chơi',
|
||||
|
||||
+271
-1
@@ -36,9 +36,49 @@
|
||||
sfx: 0.8, music: 0.5, autofire: true, autoAim: false, onehit: false,
|
||||
shake: true, shakeAmt: 1, quality: 'high'
|
||||
},
|
||||
board: [] // [{name,score,wave,ts}]
|
||||
board: [], // [{name,score,wave,ts}]
|
||||
// --- v3: retention systems ---
|
||||
login: { last: '', streak: 0, claimedToday: false },
|
||||
wheel: { last: '' },
|
||||
chest: { keys: 0, pending: 0 },
|
||||
eggs: { hatched: 0, pity: 0 },
|
||||
prestige: { count: 0, ngoc: 0, perks: {} }, // perks: {dmg:lv,hp:lv,gold:lv}
|
||||
mastery: {}, // weaponKind -> kills
|
||||
dailyChal: { date: '', bestScore: 0, board: [] }, // [{name,score,wave,ts}] top5
|
||||
season: { week: '', points: 0, claimedMilestones: [] },
|
||||
raid: { week: '', damage: 0, claimedTiers: [] }
|
||||
};
|
||||
|
||||
function isoWeekKey(d) {
|
||||
var dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
var day = dt.getUTCDay() || 7;
|
||||
dt.setUTCDate(dt.getUTCDate() + 4 - day);
|
||||
var y0 = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
||||
var wk = Math.ceil(((dt - y0) / 864e5 + 1) / 7);
|
||||
return dt.getUTCFullYear() + '-W' + wk;
|
||||
}
|
||||
|
||||
var PRESTIGE_PERKS = {
|
||||
dmg: { per: 0.04, cost: [1, 2, 4, 8] },
|
||||
hp: { per: 8, cost: [1, 2, 4, 8] },
|
||||
gold: { per: 0.03, cost: [1, 2, 4, 8] }
|
||||
};
|
||||
var SEASON_RANKS = [
|
||||
{ at: 0, en: 'Unranked', vi: 'Chưa Xếp Hạng', icon: '·' },
|
||||
{ at: 1000, en: 'Bronze', vi: 'Đồng', icon: '🥉' },
|
||||
{ at: 3000, en: 'Silver', vi: 'Bạc', icon: '🥈' },
|
||||
{ at: 8000, en: 'Gold', vi: 'Vàng', icon: '🥇' },
|
||||
{ at: 20000, en: 'Diamond', vi: 'Kim Cương', icon: '💎' }
|
||||
];
|
||||
var BESTIARY_MS = [50, 200, 500];
|
||||
var BESTIARY_MS_GEMS = [5, 12, 30];
|
||||
|
||||
var RAID_TIERS = [
|
||||
{ dmg: 1000, gems: 20 },
|
||||
{ dmg: 5000, gems: 50 },
|
||||
{ dmg: 20000, gems: 120 }
|
||||
];
|
||||
|
||||
function clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
|
||||
function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); }
|
||||
@@ -174,6 +214,236 @@
|
||||
},
|
||||
|
||||
/* ---------- live-ops ---------- */
|
||||
isoWeekKey: function (d) { return isoWeekKey(d || new Date()); },
|
||||
|
||||
// ---- eggs with rarity + pity ----
|
||||
hatchEgg: function (forceRoll) {
|
||||
var st = this.get();
|
||||
st.eggs.hatched++;
|
||||
st.eggs.pity++;
|
||||
var rar = 'common';
|
||||
var roll = (forceRoll !== undefined) ? forceRoll : Math.random() * 100;
|
||||
if (roll >= 95 || st.eggs.pity >= 30) rar = 'legendary';
|
||||
else if (roll >= 70) rar = 'rare';
|
||||
if (rar !== 'common') st.eggs.pity = 0;
|
||||
var pool = US.PETS[rar];
|
||||
var pick = pool[(Math.random() * pool.length) | 0];
|
||||
st.pet = { name: pick.name, color: pick.color, rarity: rar, perk: pick.perk || null };
|
||||
this.save();
|
||||
return { pet: clone(st.pet), rarity: rar };
|
||||
},
|
||||
|
||||
// ---- 7-day login calendar ----
|
||||
claimLogin: function () {
|
||||
var st = this.get();
|
||||
var today = this.today();
|
||||
if (st.login.claimedToday && st.login.last === today) return { ok: false, reason: 'claimed' };
|
||||
var yest = new Date(); yest.setDate(yest.getDate() - 1);
|
||||
var yKey = [yest.getFullYear(), String(yest.getMonth() + 1).padStart(2, '0'),
|
||||
String(yest.getDate()).padStart(2, '0')].join('-');
|
||||
st.login.streak = (st.login.last === yKey) ? st.login.streak + 1 : 1;
|
||||
st.login.last = today;
|
||||
st.login.claimedToday = true;
|
||||
var dayIdx = Math.min(6, st.login.streak - 1);
|
||||
var rw = US.LOGIN_REWARDS[dayIdx];
|
||||
if (rw.coins) st.coins += rw.coins;
|
||||
if (rw.gems) this.addGems(rw.gems);
|
||||
if (rw.chestKey) st.chest.keys += rw.chestKey;
|
||||
var hatched = null;
|
||||
if (rw.egg) hatched = this.hatchEgg(rw.egg === 'rare' ? 80 : 96);
|
||||
this.save();
|
||||
return { ok: true, day: dayIdx + 1, streak: st.login.streak, reward: rw, pet: hatched && hatched.pet };
|
||||
},
|
||||
|
||||
// ---- lucky wheel, one free spin per day ----
|
||||
WHEEL: [
|
||||
{ coins: 250 }, { gems: 5 }, { coins: 500 }, { chestKey: 1 },
|
||||
{ coins: 750 }, { gems: 15 }, { coins: 1200 }, { egg: 1 }
|
||||
],
|
||||
canSpin: function () { var st = this.get(); return st.wheel.last !== this.today(); },
|
||||
spinWheel: function (idx) {
|
||||
if (!this.canSpin()) return { ok: false, reason: 'spun' };
|
||||
var st = this.get();
|
||||
st.wheel.last = this.today();
|
||||
var seg = (idx !== undefined) ? idx : (Math.random() * this.WHEEL.length) | 0;
|
||||
var rw = this.WHEEL[seg % this.WHEEL.length];
|
||||
var petRes = null;
|
||||
if (rw.coins) st.coins += rw.coins;
|
||||
if (rw.gems) this.addGems(rw.gems);
|
||||
if (rw.chestKey) st.chest.keys += rw.chestKey;
|
||||
if (rw.egg) petRes = this.hatchEgg();
|
||||
this.save();
|
||||
return { ok: true, seg: seg % this.WHEEL.length, reward: rw, pet: petRes && petRes.pet };
|
||||
},
|
||||
|
||||
// ---- seeded daily challenge ----
|
||||
DAILY_MODS: null, // filled below after US.DAILY_MOD_POOL exists
|
||||
dailyInfo: function () {
|
||||
var date = this.today();
|
||||
var seed = 0;
|
||||
for (var i = 0; i < date.length; i++) seed = (seed * 31 + date.charCodeAt(i)) >>> 0;
|
||||
// mulberry32
|
||||
function rnd() {
|
||||
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
|
||||
var t2 = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t2 = (t2 + Math.imul(t2 ^ (t2 >>> 7), 61 | t2)) ^ t2;
|
||||
return ((t2 ^ (t2 >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
var pool = US.DAILY_MOD_POOL;
|
||||
var a = pool[(rnd() * pool.length) | 0];
|
||||
var b = pool[(rnd() * pool.length) | 0];
|
||||
var mods = [a, b !== a ? b : pool[(pool.indexOf(a) + 1) % pool.length]];
|
||||
return { date: date, mods: mods, seed: seed };
|
||||
},
|
||||
submitChallengeScore: function (score, wave) {
|
||||
var st = this.get();
|
||||
var info = this.dailyInfo();
|
||||
if (st.dailyChal.date !== info.date) {
|
||||
st.dailyChal.date = info.date;
|
||||
st.dailyChal.bestScore = 0;
|
||||
st.dailyChal.board = [];
|
||||
}
|
||||
st.dailyChal.bestScore = Math.max(st.dailyChal.bestScore, score | 0);
|
||||
st.dailyChal.board.push({ name: st.name, score: score | 0, wave: wave | 0, ts: Date.now() });
|
||||
st.dailyChal.board.sort(function (p, q2) { return q2.score - p.score; });
|
||||
st.dailyChal.board = st.dailyChal.board.slice(0, 5);
|
||||
this.save();
|
||||
return { best: st.dailyChal.bestScore, rank: st.dailyChal.board.length };
|
||||
},
|
||||
|
||||
// ---- weekly raid ----
|
||||
raidPoolBase: 50000,
|
||||
raidInfo: function () {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; }
|
||||
var weekNum = parseInt(wk.split('-W')[1], 10) || 1;
|
||||
return {
|
||||
week: wk,
|
||||
pool: this.raidPoolBase * weekNum,
|
||||
damage: st.raid.damage,
|
||||
tiers: RAID_TIERS,
|
||||
claimedTiers: st.raid.claimedTiers.slice()
|
||||
};
|
||||
},
|
||||
addRaidDamage: function (dmg) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; }
|
||||
st.raid.damage += Math.round(dmg);
|
||||
this.save();
|
||||
},
|
||||
claimRaidTier: function (idx) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) return { ok: false, reason: 'week' };
|
||||
if (st.raid.claimedTiers.indexOf(idx) >= 0) return { ok: false, reason: 'claimed' };
|
||||
var tier = RAID_TIERS[idx];
|
||||
if (!tier || st.raid.damage < tier.dmg) return { ok: false, reason: 'locked' };
|
||||
st.raid.claimedTiers.push(idx);
|
||||
this.addGems(tier.gems);
|
||||
this.save();
|
||||
return { ok: true, gems: tier.gems };
|
||||
},
|
||||
|
||||
// ---- prestige / rebirth ----
|
||||
prestigeInfo: function () {
|
||||
var st = this.get();
|
||||
var gain = st.bestWave >= 50 ? Math.max(1, Math.floor((st.bestWave - 40) / 5)) : 0;
|
||||
return {
|
||||
canPrestige: st.bestWave >= 50,
|
||||
bestWave: st.bestWave,
|
||||
ngocGain: gain,
|
||||
ngoc: st.prestige.ngoc,
|
||||
count: st.prestige.count,
|
||||
perks: clone(st.prestige.perks),
|
||||
perkDefs: PRESTIGE_PERKS
|
||||
};
|
||||
},
|
||||
doPrestige: function () {
|
||||
var st = this.get();
|
||||
var info = this.prestigeInfo();
|
||||
if (!info.canPrestige) return { ok: false, reason: 'wave50' };
|
||||
st.prestige.ngoc += info.ngocGain;
|
||||
st.prestige.count++;
|
||||
st.upgrades = {};
|
||||
st.coins = 0;
|
||||
st.bombs = 1;
|
||||
this.save();
|
||||
return { ok: true, gained: info.ngocGain };
|
||||
},
|
||||
buyPerk: function (id) {
|
||||
var def = PRESTIGE_PERKS[id];
|
||||
if (!def) return { ok: false, reason: 'noid' };
|
||||
var st = this.get();
|
||||
var lv = st.prestige.perks[id] || 0;
|
||||
if (lv >= def.cost.length) return { ok: false, reason: 'max' };
|
||||
var cost = def.cost[lv];
|
||||
if (st.prestige.ngoc < cost) return { ok: false, reason: 'poor' };
|
||||
st.prestige.ngoc -= cost;
|
||||
st.prestige.perks[id] = lv + 1;
|
||||
this.save();
|
||||
return { ok: true, level: lv + 1 };
|
||||
},
|
||||
|
||||
// ---- weapon mastery ----
|
||||
masteryLevel: function (kind) {
|
||||
var kills = (this.get().mastery || {})[kind] || 0;
|
||||
var lvl = Math.min(15, Math.floor(Math.sqrt(kills / 5)));
|
||||
return { level: lvl, bonus: lvl * 0.02, kills: kills };
|
||||
},
|
||||
|
||||
// ---- bestiary milestones ----
|
||||
bestiaryMilestoneCheck: function (spKey) {
|
||||
var st = this.get();
|
||||
var entry = st.bestiary[spKey];
|
||||
if (!entry) return 0;
|
||||
entry.ms = entry.ms || [];
|
||||
var granted = 0;
|
||||
BESTIARY_MS.forEach(function (m4, i4) {
|
||||
if (entry.kills >= m4 && entry.ms.indexOf(i4) < 0) {
|
||||
entry.ms.push(i4);
|
||||
granted += BESTIARY_MS_GEMS[i4];
|
||||
}
|
||||
});
|
||||
if (granted > 0) this.addGems(granted);
|
||||
return granted;
|
||||
},
|
||||
|
||||
// ---- season points & rank ----
|
||||
addSeasonPoints: function (n) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.season.week !== wk) { st.season.week = wk; st.season.points = 0; }
|
||||
st.season.week = wk;
|
||||
st.season.points += n | 0;
|
||||
this.save();
|
||||
return st.season.points;
|
||||
},
|
||||
seasonRank: function () {
|
||||
var st = this.get();
|
||||
var pts = st.season.points || 0;
|
||||
var tier = SEASON_RANKS[0];
|
||||
for (var i5 = SEASON_RANKS.length - 1; i5 >= 0; i5--) {
|
||||
if (pts >= SEASON_RANKS[i5].at) { tier = SEASON_RANKS[i5]; break; }
|
||||
}
|
||||
return { points: pts, week: st.season.week, tier: clone(tier) };
|
||||
},
|
||||
|
||||
// ---- boss chest ----
|
||||
openChest: function () {
|
||||
var st = this.get();
|
||||
if (st.chest.keys < 1) return { ok: false, reason: 'nokey' };
|
||||
st.chest.keys--;
|
||||
var r = Math.random();
|
||||
var reward;
|
||||
var petRes = null;
|
||||
if (r < 0.55) { var c2 = 450 + ((Math.random() * 500) | 0); st.coins += c2; reward = { coins: c2 }; }
|
||||
else if (r < 0.85) { var g2 = 10 + ((Math.random() * 12) | 0); this.addGems(g2); reward = { gems: g2 }; }
|
||||
else { petRes = this.hatchEgg(); reward = { egg: true }; }
|
||||
this.save();
|
||||
return { ok: true, reward: reward, pet: petRes && petRes.pet };
|
||||
},
|
||||
isWeekend: function () {
|
||||
var d = new Date().getDay();
|
||||
return d === 0 || d === 6;
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
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];
|
||||
@@ -213,8 +218,10 @@
|
||||
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' : '');
|
||||
(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;
|
||||
@@ -406,7 +413,44 @@
|
||||
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);
|
||||
@@ -510,6 +554,12 @@
|
||||
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() {
|
||||
@@ -596,6 +646,236 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 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();
|
||||
@@ -720,12 +1000,35 @@
|
||||
});
|
||||
});
|
||||
|
||||
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' }));
|
||||
|
||||
Reference in New Issue
Block a user