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:
Primal Rampage Bot
2026-08-23 06:55:51 +00:00
parent 2024ea48b5
commit 7164cc798c
8 changed files with 979 additions and 28 deletions
+7
View File
@@ -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 19 in **The Green Jungle**, 1018 descend into - **Act biomes**: waves 19 in **The Green Jungle**, 1018 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+304 -1
View File
@@ -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' }));
+42
View File
@@ -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;