From 7164cc798c84b740f10cb5b691dc9a70bc62e71b Mon Sep 17 00:00:00 2001 From: Primal Rampage Bot Date: Sun, 23 Aug 2026 06:55:51 +0000 Subject: [PATCH] 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 --- README.md | 7 ++ index.html | 70 ++++++++++-- js/data.js | 33 ++++++ js/game.js | 234 +++++++++++++++++++++++++++++++++++++--- js/i18n.js | 44 ++++++++ js/save.js | 272 ++++++++++++++++++++++++++++++++++++++++++++++- js/ui.js | 305 ++++++++++++++++++++++++++++++++++++++++++++++++++++- style.css | 42 ++++++++ 8 files changed, 979 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 853edcd..ced2266 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ No build step — plain script tags. ## The cast +- **Retention systems**: graze slow-mo, perfect-wave FRENZY (×3 amber), + ghost-record banner, hunter titles, 7-day login calendar, daily lucky + wheel, boss chest keys, pet eggs with rarity + pity, seeded Daily + Challenge with local top-5 board, hourly Golden Herd stampede, weekly + Raid Colossus with damage tiers, Rebirth prestige (Ancient Gem perks), + weapon mastery stars, bestiary milestone gems, weekly season ranks and + a shareable result card. - **Act biomes**: waves 1–9 in **The Green Jungle**, 10–18 descend into **The Murk Swamp** (creeping fog banks, two extra tar pits), 19+ burn across **The Ashlands** (pulsing lava veins that sear anyone standing on diff --git a/index.html b/index.html index cba52b0..19a0857 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ + @@ -40,6 +41,7 @@ + +
+

+ +
+

+
+ +
+ +
+

+ +
+ +
+ +
+

+
+
+ +
+ +
+

+
+
+
+ +
+ +
+

+
+
🗝️🧰
+ +
+ +
+

+
+
+ +
+ +
+
+

@@ -225,6 +276,7 @@
+
@@ -243,14 +295,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/js/data.js b/js/data.js index 84f6eba..768344c 100644 --- a/js/data.js +++ b/js/data.js @@ -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 = { diff --git a/js/game.js b/js/game.js index 3c26838..795b5ac 100644 --- a/js/game.js +++ b/js/game.js @@ -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); }, diff --git a/js/i18n.js b/js/i18n.js index a5f3b46..e2a4544 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -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', diff --git a/js/save.js b/js/save.js index 48ad3df..9f12806 100644 --- a/js/save.js +++ b/js/save.js @@ -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; diff --git a/js/ui.js b/js/ui.js index 3988e2e..be8d546 100644 --- a/js/ui.js +++ b/js/ui.js @@ -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 = '' + L2('daily.day') + ' ' + (d + 1) + '' + + fmtReward(US.LOGIN_REWARDS[d]) + ''; + 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('
'); + } + 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' }) + ': ' + ri.damage.toLocaleString() + + ' / ' + 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 + '
' + 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' }) + ': 🟣 ' + pi.ngoc + '' + + ' · ' + L({ en: 'Rebirths', vi: 'Tái sinh' }) + ': ' + pi.count + '
' + + (pi.canPrestige + ? L({ en: 'Rebirth now for', vi: 'Tái sinh nhận' }) + ' +' + pi.ngocGain + '' + : 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 = '' + label + 'Lv ' + lv + '/' + def.cost.length + ''; + 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 = '🗝️ ×' + sv.chest.keys + ''; + 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' })); diff --git a/style.css b/style.css index d5624a1..412a53e 100644 --- a/style.css +++ b/style.css @@ -448,6 +448,48 @@ h2 { .btn-menu.primary { font-size: 19px; } } +/* ---------- daily hub ---------- */ +#screen-daily .panel { + width: min(560px, 92vw); + background: rgba(14,9,4,0.72); + border: 1px solid rgba(255,170,90,0.22); + border-radius: 14px; + padding: 16px 18px; + margin-bottom: 16px; +} +#screen-daily h3 { margin: 0 0 10px; color: #ffb938; letter-spacing: 1px; } +.cal-strip { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } +.cal-day { + flex: 1; min-width: 56px; text-align: center; + padding: 8px 4px; border-radius: 10px; + background: rgba(255,255,255,0.05); + font-size: 11px; color: rgba(243,231,207,0.75); + border: 1px solid transparent; +} +.cal-day.past { opacity: 0.45; } +.cal-day.today { border-color: #ffb938; background: rgba(255,185,56,0.14); color: #ffe9c9; } +.cal-day b { display: block; font-size: 13px; margin-top: 2px; } +#wheel-cv { display: block; margin: 0 auto 10px; } +.wheel-result, .chest-info { text-align: center; min-height: 22px; color: #ffe9c9; margin-bottom: 8px; } +.chest-info { font-size: 14px; } +.raid-bar { height: 14px; border-radius: 7px; background: rgba(255,255,255,0.08); + overflow: hidden; margin-bottom: 8px; } +#raid-bar-fill { height: 100%; width: 0%; + background: linear-gradient(90deg, #ff6b35, #ffd24a); transition: width .5s ease; } +#raid-tiers { display: flex; gap: 8px; margin-bottom: 10px; } +.raid-tier { flex: 1; text-align: center; padding: 7px 4px; border-radius: 9px; + background: rgba(255,255,255,0.05); font-size: 11px; color: rgba(243,231,207,0.8); + border: 1px solid transparent; cursor: pointer; } +.raid-tier.claimable { border-color: #ffb938; color: #ffe9c9; } +.raid-tier.claimed { opacity: 0.45; text-decoration: line-through; } +.pr-perk { display:flex; align-items:center; gap:10px; padding:7px 10px; margin-bottom:6px; + border-radius:9px; background:rgba(255,255,255,0.05); font-size:13px; } +.pr-perk b { color:#ffd24a; } +.pr-perk button { margin-left:auto; } +.rank-badge { color:#ffd24a; letter-spacing:0.5px; } + +#chest-art { text-align: center; font-size: 44px; margin-bottom: 8px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.6)); } + /* live-drawn thumbnails in shop & bestiary */ .item-prev { height: 62px;