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:
@@ -37,6 +37,13 @@ No build step — plain script tags.
|
|||||||
|
|
||||||
## The cast
|
## 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
|
- **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
|
**The Murk Swamp** (creeping fog banks, two extra tar pits), 19+ burn
|
||||||
across **The Ashlands** (pulsing lava veins that sear anyone standing on
|
across **The Ashlands** (pulsing lava veins that sear anyone standing on
|
||||||
|
|||||||
+61
-9
@@ -29,6 +29,7 @@
|
|||||||
<button id="btn-upgrades" class="btn-menu">💪 <span id="lbl-upgrades"></span><span class="badge-dot hidden" id="dot-upgrades"></span><span class="chev">▸</span></button>
|
<button id="btn-upgrades" class="btn-menu">💪 <span id="lbl-upgrades"></span><span class="badge-dot hidden" id="dot-upgrades"></span><span class="chev">▸</span></button>
|
||||||
<button id="btn-quests" class="btn-menu">📜 <span id="lbl-quests"></span><span class="badge-dot hidden" id="dot-quests"></span><span class="chev">▸</span></button>
|
<button id="btn-quests" class="btn-menu">📜 <span id="lbl-quests"></span><span class="badge-dot hidden" id="dot-quests"></span><span class="chev">▸</span></button>
|
||||||
<button id="btn-bestiary" class="btn-menu">📖 <span id="lbl-bestiary"></span><span class="chev">▸</span></button>
|
<button id="btn-bestiary" class="btn-menu">📖 <span id="lbl-bestiary"></span><span class="chev">▸</span></button>
|
||||||
|
<button id="btn-daily" class="btn-menu">🎁 <span id="lbl-daily"></span><span class="badge-dot hidden" id="dot-daily"></span><span class="chev">▸</span></button>
|
||||||
<button id="btn-shop" class="btn-menu" data-i18n="menu.shop"></button>
|
<button id="btn-shop" class="btn-menu" data-i18n="menu.shop"></button>
|
||||||
<div class="menu-sep"></div>
|
<div class="menu-sep"></div>
|
||||||
<button id="btn-rank" class="btn-menu sm" data-i18n="menu.rank"></button>
|
<button id="btn-rank" class="btn-menu sm" data-i18n="menu.rank"></button>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<div class="menu-bottombar">
|
<div class="menu-bottombar">
|
||||||
<span class="ver">PRIMAL RAMPAGE · v0.9 · Stone Age Survival</span>
|
<span class="ver">PRIMAL RAMPAGE · v0.9 · Stone Age Survival</span>
|
||||||
<span class="wallet">
|
<span class="wallet">
|
||||||
|
<span id="menu-rank" class="rank-badge"></span>
|
||||||
<span id="menu-coins" class="coins"></span>
|
<span id="menu-coins" class="coins"></span>
|
||||||
<span id="menu-gems" class="gems"></span>
|
<span id="menu-gems" class="gems"></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -121,6 +123,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- DAILY HUB -->
|
||||||
|
<section id="screen-daily" class="screen narrow">
|
||||||
|
<h2 data-i18n="daily.title"></h2>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="daily.calendar"></h3>
|
||||||
|
<div id="login-strip" class="cal-strip"></div>
|
||||||
|
<button id="btn-claim-login" class="btn primary" data-i18n="daily.claim"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="daily.wheel"></h3>
|
||||||
|
<canvas id="wheel-cv" width="240" height="240"></canvas>
|
||||||
|
<div id="wheel-result" class="wheel-result"></div>
|
||||||
|
<button id="btn-spin" class="btn primary" data-i18n="daily.spin"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="chal.title"></h3>
|
||||||
|
<div id="chal-mods" class="chest-info"></div>
|
||||||
|
<div id="chal-best" class="wheel-result"></div>
|
||||||
|
<button id="btn-play-chal" class="btn primary" data-i18n="chal.play"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="raid.title"></h3>
|
||||||
|
<div id="raid-bar-wrap" class="raid-bar"><div id="raid-bar-fill"></div></div>
|
||||||
|
<div id="raid-info" class="wheel-result"></div>
|
||||||
|
<div id="raid-tiers"></div>
|
||||||
|
<button id="btn-play-raid" class="btn primary danger" data-i18n="raid.play"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="daily.chest"></h3>
|
||||||
|
<div id="chest-info" class="chest-info"></div>
|
||||||
|
<div id="chest-art">🗝️🧰</div>
|
||||||
|
<button id="btn-open-chest" class="btn primary" data-i18n="daily.open"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3 data-i18n="prest.title"></h3>
|
||||||
|
<div id="pr-info" class="chest-info"></div>
|
||||||
|
<div id="pr-perks"></div>
|
||||||
|
<button id="btn-rebirth" class="btn danger" data-i18n="prest.go"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions"><button class="btn" data-back data-i18n="set.done"></button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- LEADERBOARD -->
|
<!-- LEADERBOARD -->
|
||||||
<section id="screen-rank" class="screen narrow">
|
<section id="screen-rank" class="screen narrow">
|
||||||
<h2 data-i18n="rank.title"></h2>
|
<h2 data-i18n="rank.title"></h2>
|
||||||
@@ -225,6 +276,7 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<button id="btn-retry" class="btn big primary" data-i18n="over.retry"></button>
|
<button id="btn-retry" class="btn big primary" data-i18n="over.retry"></button>
|
||||||
<button id="btn-tomenu" class="btn big" data-i18n="over.menu"></button>
|
<button id="btn-tomenu" class="btn big" data-i18n="over.menu"></button>
|
||||||
|
<button id="btn-share" class="btn big">📸 <span data-i18n="over.share"></span></button>
|
||||||
<button id="btn-adx2" class="btn big gem-btn hidden"></button>
|
<button id="btn-adx2" class="btn big gem-btn hidden"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,14 +295,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="./js/i18n.js"></script>
|
<script src="./js/i18n.js"></script>
|
||||||
<script src="./js/save.js?v=13"></script>
|
<script src="./js/save.js?v=17"></script>
|
||||||
<script src="./js/util.js?v=13"></script>
|
<script src="./js/util.js?v=17"></script>
|
||||||
<script src="./js/audio.js?v=13"></script>
|
<script src="./js/audio.js?v=17"></script>
|
||||||
<script src="./js/art.js?v=13"></script>
|
<script src="./js/art.js?v=17"></script>
|
||||||
<script src="./js/data.js?v=13"></script>
|
<script src="./js/data.js?v=17"></script>
|
||||||
<script src="./js/entities.js?v=13"></script>
|
<script src="./js/entities.js?v=17"></script>
|
||||||
<script src="./js/game.js?v=13"></script>
|
<script src="./js/game.js?v=17"></script>
|
||||||
<script src="./js/ui.js?v=13"></script>
|
<script src="./js/ui.js?v=17"></script>
|
||||||
<script src="./js/main.js?v=13"></script>
|
<script src="./js/main.js?v=17"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+33
@@ -81,6 +81,39 @@
|
|||||||
vi: 'Thái Dương — ánh sáng nguyên thủy' }
|
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 ---------------- */
|
/* ---------------- Beasts ---------------- */
|
||||||
// contact = dps on touch; value feeds heat/score; cost feeds wave budget
|
// contact = dps on touch; value feeds heat/score; cost feeds wave budget
|
||||||
US.ENEMIES = {
|
US.ENEMIES = {
|
||||||
|
|||||||
+217
-17
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
var g = {
|
var g = {
|
||||||
mode: mode, // 'campaign' | 'arena'
|
mode: mode, // 'campaign' | 'arena'
|
||||||
|
runOpts: opts || {},
|
||||||
state: 'playing', // playing | cards | paused | revive | over
|
state: 'playing', // playing | cards | paused | revive | over
|
||||||
time: 0,
|
time: 0,
|
||||||
over: false,
|
over: false,
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
var budget = waveBudget(wave);
|
var budget = waveBudget(wave);
|
||||||
if (wave > 25) budget = Math.floor(budget * (1 + (wave - 25) * 0.09));
|
if (wave > 25) budget = Math.floor(budget * (1 + (wave - 25) * 0.09));
|
||||||
if (g.bloodMoon) budget = Math.floor(budget * 1.25);
|
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 pool = availableEnemies(wave);
|
||||||
var q = [];
|
var q = [];
|
||||||
var t = 0;
|
var t = 0;
|
||||||
@@ -187,12 +189,28 @@
|
|||||||
function scheduleNextWave(g) {
|
function scheduleNextWave(g) {
|
||||||
g.wave++;
|
g.wave++;
|
||||||
g.waveActive = true;
|
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);
|
g.spawnQueue = buildWaveQueue(g, g.wave);
|
||||||
|
|
||||||
// act biomes: jungle -> swamp -> ashlands
|
// act biomes: jungle -> swamp -> ashlands
|
||||||
var wantBio = biomeFor(g.wave);
|
var wantBio = biomeFor(g.wave);
|
||||||
if (g.biome !== wantBio) enterBiome(g, wantBio);
|
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) {
|
if (g.wave % 5 === 0) {
|
||||||
announce(g, US.i18n.t('announce.boss'), '#e04b3a');
|
announce(g, US.i18n.t('announce.boss'), '#e04b3a');
|
||||||
US.audio.sfx('bossRoar');
|
US.audio.sfx('bossRoar');
|
||||||
@@ -498,6 +516,7 @@
|
|||||||
function damageEnemy(g, e, dmg, crit, silent) {
|
function damageEnemy(g, e, dmg, crit, silent) {
|
||||||
if (!e.alive) return;
|
if (!e.alive) return;
|
||||||
e.hp -= dmg;
|
e.hp -= dmg;
|
||||||
|
if (g.raidBoss && e === g.raidBoss) US.save.addRaidDamage(dmg);
|
||||||
e.hitFlash = 0.1;
|
e.hitFlash = 0.1;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
US.entities.floatText(e.x + util.rand(-8, 8), e.y - e.radius - 6,
|
US.entities.floatText(e.x + util.rand(-8, 8), e.y - e.radius - 6,
|
||||||
@@ -633,6 +652,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- the tribe's pet raptor -------------------------------- */
|
/* ---------------- 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) {
|
function makePet(g) {
|
||||||
var pd = US.save.get().pet || { name: 'Rex Jr', color: '#5f8f3e' };
|
var pd = US.save.get().pet || { name: 'Rex Jr', color: '#5f8f3e' };
|
||||||
return {
|
return {
|
||||||
@@ -701,11 +743,52 @@
|
|||||||
g.bestCombo = Math.max(g.bestCombo, g.combo);
|
g.bestCombo = Math.max(g.bestCombo, g.combo);
|
||||||
g.sinceKill = 0;
|
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 ----
|
// ---- meta progression on every slain beast ----
|
||||||
var spKey = speciesOf(e);
|
var spKey = speciesOf(e);
|
||||||
if (spKey && US.BESTIARY[spKey]) {
|
if (spKey && US.BESTIARY[spKey]) {
|
||||||
var bDef = US.BESTIARY[spKey];
|
var bDef = US.BESTIARY[spKey];
|
||||||
US.save.bumpBestiary(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];
|
var bEntry = US.save.get().bestiary[spKey];
|
||||||
if (bEntry.kills >= bDef.goal && US.save.bestiaryDone(spKey, function () {
|
if (bEntry.kills >= bDef.goal && US.save.bestiaryDone(spKey, function () {
|
||||||
US.save.addGems(bDef.gem);
|
US.save.addGems(bDef.gem);
|
||||||
@@ -805,6 +888,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hurtPlayer(g, dmg) {
|
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;
|
var p = g.player;
|
||||||
if (!p.alive || p.invuln > 0) return;
|
if (!p.alive || p.invuln > 0) return;
|
||||||
if (US.save.get().settings.onehit && g.mode === 'campaign') dmg = p.stats.maxHp;
|
if (US.save.get().settings.onehit && g.mode === 'campaign') dmg = p.stats.maxHp;
|
||||||
@@ -857,6 +951,9 @@
|
|||||||
var save = US.save.get();
|
var save = US.save.get();
|
||||||
save.bombs = g.mode === 'campaign' ? g.player.bombs : save.bombs;
|
save.bombs = g.mode === 'campaign' ? g.player.bombs : save.bombs;
|
||||||
var record = US.save.submitRun(g.score, g.wave || 0);
|
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.addCoins(g.runCoins);
|
||||||
US.save.stat('runs', 1);
|
US.save.stat('runs', 1);
|
||||||
checkAch(g);
|
checkAch(g);
|
||||||
@@ -867,7 +964,10 @@
|
|||||||
kills: g.kills,
|
kills: g.kills,
|
||||||
bestCombo: g.bestCombo,
|
bestCombo: g.bestCombo,
|
||||||
coins: g.runCoins,
|
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);
|
}, g.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,8 +1016,47 @@
|
|||||||
function update(g, dt) {
|
function update(g, dt) {
|
||||||
if (!g.metaInit) {
|
if (!g.metaInit) {
|
||||||
g.metaInit = true;
|
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;
|
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());
|
ensureQuests(US.save.get());
|
||||||
if (US.save.isWeekend && US.save.isWeekend()) {
|
if (US.save.isWeekend && US.save.isWeekend()) {
|
||||||
g.bloodMoon = true;
|
g.bloodMoon = true;
|
||||||
@@ -930,6 +1069,32 @@
|
|||||||
updatePet(g, dt);
|
updatePet(g, dt);
|
||||||
if (g.state === 'over') return;
|
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
|
// announcements always tick
|
||||||
for (var a = g.announces.length - 1; a >= 0; a--) {
|
for (var a = g.announces.length - 1; a >= 0; a--) {
|
||||||
g.announces[a].t += dt;
|
g.announces[a].t += dt;
|
||||||
@@ -1059,10 +1224,17 @@
|
|||||||
g.spawnQueue[q].t -= dt;
|
g.spawnQueue[q].t -= dt;
|
||||||
if (g.spawnQueue[q].t <= 0) {
|
if (g.spawnQueue[q].t <= 0) {
|
||||||
var item = g.spawnQueue[q];
|
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);
|
var pos = spawnEdgePoint(g);
|
||||||
pos.x = util.clamp(pos.x, WALL + 30, W - WALL - 30);
|
pos.x = util.clamp(pos.x, WALL + 30, W - WALL - 30);
|
||||||
pos.y = util.clamp(pos.y, WALL + 30, H - WALL - 30);
|
pos.y = util.clamp(pos.y, WALL + 30, H - WALL - 30);
|
||||||
spawnEnemyAt(g, item.defId, pos.x, pos.y, item.mods);
|
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);
|
g.spawnQueue.splice(q, 1);
|
||||||
q--;
|
q--;
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1422,20 @@
|
|||||||
|
|
||||||
function onWaveClear(g) {
|
function onWaveClear(g) {
|
||||||
var bonus = 15 + g.wave * 3;
|
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.runCoins += bonus;
|
||||||
g.score += 50 * g.wave;
|
g.score += 50 * g.wave;
|
||||||
announce(g, US.i18n.t('announce.waveClear'), '#8ac74a');
|
announce(g, US.i18n.t('announce.waveClear'), '#8ac74a');
|
||||||
@@ -1319,10 +1505,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectPickup(g, pk) {
|
function collectPickup(g, pk) {
|
||||||
|
var frenzyMult = (g.frenzyT > 0) ? 3 : 1;
|
||||||
var p = g.player;
|
var p = g.player;
|
||||||
if (pk.kind === 'coin') {
|
if (pk.kind === 'coin') {
|
||||||
var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul));
|
var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul));
|
||||||
if (g.bloodMoon) amt *= 2;
|
if (g.bloodMoon) amt *= 2;
|
||||||
|
if (g.mods && g.mods.richherds) amt *= 2;
|
||||||
|
if (frenzyMult > 1) amt *= frenzyMult;
|
||||||
g.runCoins += amt;
|
g.runCoins += amt;
|
||||||
g.score += 2;
|
g.score += 2;
|
||||||
US.entities.floatText(pk.x, pk.y - 10, '+' + amt, '#ffb938');
|
US.entities.floatText(pk.x, pk.y - 10, '+' + amt, '#ffb938');
|
||||||
@@ -1334,16 +1523,20 @@
|
|||||||
p.bombs++;
|
p.bombs++;
|
||||||
US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d');
|
US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d');
|
||||||
} else if (pk.kind === 'egg') {
|
} else if (pk.kind === 'egg') {
|
||||||
var names = ['Cơm Nếp', 'Xanh Lá', 'Bonnie', 'Mập Mạp', 'Tia Chớp', 'Bông'];
|
var res2 = US.save.hatchEgg();
|
||||||
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();
|
|
||||||
g.pet = makePet(g);
|
g.pet = makePet(g);
|
||||||
|
applyPetPerk(g.player, res2.pet.perk);
|
||||||
|
var rarCol = res2.rarity === 'legendary' ? '#ff9b3d'
|
||||||
|
: res2.rarity === 'rare' ? '#7ee787' : '#ffd24a';
|
||||||
announce(g, L({
|
announce(g, L({
|
||||||
en: '🥚 A raptor hatched: "' + names[pi] + '"!',
|
en: '🥚 ' + res2.rarity.toUpperCase() + ' pet hatched: "' + res2.pet.name + '"!',
|
||||||
vi: '🥚 Raptor con nở rồi: "' + names[pi] + '"!'
|
vi: '🥚 Thú cưng ' + (res2.rarity === 'legendary' ? 'HUYỀN THOẠI' : res2.rarity === 'rare' ? 'HIẾM' : 'thường') +
|
||||||
}, metaLang()), '#ffd24a');
|
' 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');
|
US.audio.sfx('win');
|
||||||
checkAch(g);
|
checkAch(g);
|
||||||
}
|
}
|
||||||
@@ -1893,6 +2086,13 @@
|
|||||||
resume: function () { if (current && current.state === 'paused') { current.state = 'playing'; return true; } return false; },
|
resume: function () { if (current && current.state === 'paused') { current.state = 'playing'; return true; } return false; },
|
||||||
castUlt: function () { if (current) castUlt(current); },
|
castUlt: function () { if (current) castUlt(current); },
|
||||||
ensureQuests: function () { ensureQuests(US.save.get()); },
|
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) {
|
jumpWave: function (n) {
|
||||||
if (!current || current.state === 'over') return false;
|
if (!current || current.state === 'over') return false;
|
||||||
current.state = 'playing';
|
current.state = 'playing';
|
||||||
@@ -1924,7 +2124,13 @@
|
|||||||
}
|
}
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
if (current) {
|
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);
|
render(current, ctx, vw, vh);
|
||||||
} else {
|
} else {
|
||||||
// painted dusk scene behind the menus
|
// painted dusk scene behind the menus
|
||||||
@@ -1945,12 +2151,6 @@
|
|||||||
// wire entity callbacks that need game context
|
// wire entity callbacks that need game context
|
||||||
US.entities._hooks = {
|
US.entities._hooks = {
|
||||||
nearestEnemy: function (x, y, d, ex) { return current ? nearestEnemy(current, x, y, d, ex) : null; },
|
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); },
|
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); },
|
explode: function (x, y, r, dmg) { if (current) explodeAt(current, x, y, r, dmg); },
|
||||||
collect: function (pk) { if (current) collectPickup(current, pk); },
|
collect: function (pk) { if (current) collectPickup(current, pk); },
|
||||||
|
|||||||
+44
@@ -95,6 +95,28 @@
|
|||||||
'set.music': 'Music volume',
|
'set.music': 'Music volume',
|
||||||
'set.autofire': 'Auto-fire',
|
'set.autofire': 'Auto-fire',
|
||||||
'set.autoaim': 'Auto-aim nearest (F)',
|
'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.onehit': 'One-hit mode (campaign)',
|
||||||
'set.shake': 'Screen shake',
|
'set.shake': 'Screen shake',
|
||||||
'set.name': 'Player name',
|
'set.name': 'Player name',
|
||||||
@@ -206,6 +228,28 @@
|
|||||||
'set.music': 'Âm lượng nhạc',
|
'set.music': 'Âm lượng nhạc',
|
||||||
'set.autofire': 'Tự động bắn',
|
'set.autofire': 'Tự động bắn',
|
||||||
'set.autoaim': 'Tự ngắm mục tiêu gần nhất (F)',
|
'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.onehit': 'Chế độ một-hit (chiến dịch)',
|
||||||
'set.shake': 'Rung màn hình',
|
'set.shake': 'Rung màn hình',
|
||||||
'set.name': 'Tên người chơi',
|
'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,
|
sfx: 0.8, music: 0.5, autofire: true, autoAim: false, onehit: false,
|
||||||
shake: true, shakeAmt: 1, quality: 'high'
|
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 clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||||
|
|
||||||
function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); }
|
function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); }
|
||||||
@@ -174,6 +214,236 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* ---------- live-ops ---------- */
|
/* ---------- 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 () {
|
isWeekend: function () {
|
||||||
var d = new Date().getDay();
|
var d = new Date().getDay();
|
||||||
return d === 0 || d === 6;
|
return d === 0 || d === 6;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
var s = US.save.get();
|
var s = US.save.get();
|
||||||
$('menu-coins').textContent = '$' + US.util.fmt(s.coins);
|
$('menu-coins').textContent = '$' + US.util.fmt(s.coins);
|
||||||
if ($('menu-gems')) $('menu-gems').textContent = '🔥 ' + (s.gems | 0);
|
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')) {
|
if ($('dot-upgrades')) {
|
||||||
var canUp = Object.keys(US.UPGRADES || {}).some(function (k) {
|
var canUp = Object.keys(US.UPGRADES || {}).some(function (k) {
|
||||||
var d = US.UPGRADES[k];
|
var d = US.UPGRADES[k];
|
||||||
@@ -213,8 +218,10 @@
|
|||||||
var desc = document.createElement('div');
|
var desc = document.createElement('div');
|
||||||
desc.className = 'item-desc';
|
desc.className = 'item-desc';
|
||||||
if (shopTab === 'weapons') {
|
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' +
|
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') {
|
} else if (shopTab === 'heroes') {
|
||||||
desc.textContent = US.i18n.t('hero.statHp') + ' ' + it.hp + ' · ' +
|
desc.textContent = US.i18n.t('hero.statHp') + ' ' + it.hp + ' · ' +
|
||||||
US.i18n.t('hero.statSpeed') + ' ' + it.speed + ' · ' + it.talent;
|
US.i18n.t('hero.statSpeed') + ' ' + it.speed + ' · ' + it.talent;
|
||||||
@@ -406,7 +413,44 @@
|
|||||||
function showPause() { show($('overlay-pause')); }
|
function showPause() { show($('overlay-pause')); }
|
||||||
function hidePause() { hide($('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) {
|
function gameOver(stats, mode) {
|
||||||
|
lastRunStats = stats;
|
||||||
$('over-title').textContent = US.i18n.t('over.title');
|
$('over-title').textContent = US.i18n.t('over.title');
|
||||||
$('over-record').classList.toggle('hidden', !stats.record);
|
$('over-record').classList.toggle('hidden', !stats.record);
|
||||||
$('ov-score').textContent = US.util.fmt(stats.score);
|
$('ov-score').textContent = US.util.fmt(stats.score);
|
||||||
@@ -510,6 +554,12 @@
|
|||||||
return q.done && !q.claimed;
|
return q.done && !q.claimed;
|
||||||
});
|
});
|
||||||
if ($('dot-quests')) $('dot-quests').classList.toggle('hidden', !claimable);
|
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() {
|
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 ---------------- */
|
/* ---------------- bestiary ---------------- */
|
||||||
function renderBestiary() {
|
function renderBestiary() {
|
||||||
var sv = US.save.get();
|
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-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-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'); };
|
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; }
|
function setTxt(id, v) { if ($(id)) $(id).textContent = v; }
|
||||||
setTxt('lbl-quests', L({ en: 'Quests', vi: 'Nhiệm 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-bestiary', L({ en: 'Bestiary', vi: 'Ký sự thú' }));
|
||||||
setTxt('lbl-upgrades', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
setTxt('lbl-upgrades', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
||||||
setTxt('lbl-tabup', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
setTxt('lbl-tabup', L({ en: 'Upgrades', vi: 'Nâng cấp' }));
|
||||||
|
|||||||
@@ -448,6 +448,48 @@ h2 {
|
|||||||
.btn-menu.primary { font-size: 19px; }
|
.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 */
|
/* live-drawn thumbnails in shop & bestiary */
|
||||||
.item-prev {
|
.item-prev {
|
||||||
height: 62px;
|
height: 62px;
|
||||||
|
|||||||
Reference in New Issue
Block a user