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:
+217
-17
@@ -23,6 +23,7 @@
|
||||
|
||||
var g = {
|
||||
mode: mode, // 'campaign' | 'arena'
|
||||
runOpts: opts || {},
|
||||
state: 'playing', // playing | cards | paused | revive | over
|
||||
time: 0,
|
||||
over: false,
|
||||
@@ -113,6 +114,7 @@
|
||||
var budget = waveBudget(wave);
|
||||
if (wave > 25) budget = Math.floor(budget * (1 + (wave - 25) * 0.09));
|
||||
if (g.bloodMoon) budget = Math.floor(budget * 1.25);
|
||||
if (g.mods && g.mods.swarm) budget = Math.ceil(budget * 1.4);
|
||||
var pool = availableEnemies(wave);
|
||||
var q = [];
|
||||
var t = 0;
|
||||
@@ -187,12 +189,28 @@
|
||||
function scheduleNextWave(g) {
|
||||
g.wave++;
|
||||
g.waveActive = true;
|
||||
g.waveDamaged = false;
|
||||
// ghost record banner the moment this run passes the old best
|
||||
if (!g.ghostShown && g.ghostWave > 0 && g.wave > g.ghostWave) {
|
||||
g.ghostShown = true;
|
||||
announce(g, L({ en: '👻 GHOST BUSTED — wave ' + g.ghostWave + ' beaten!',
|
||||
vi: '👻 PHÁ KỶ LỤC — vượt wave ' + g.ghostWave + '!' }, metaLang()), '#7ee787');
|
||||
}
|
||||
g.spawnQueue = buildWaveQueue(g, g.wave);
|
||||
|
||||
// act biomes: jungle -> swamp -> ashlands
|
||||
var wantBio = biomeFor(g.wave);
|
||||
if (g.biome !== wantBio) enterBiome(g, wantBio);
|
||||
|
||||
// weekly raid: one colossal bruiser carrying the remaining pool
|
||||
if (g.raid) {
|
||||
var ri = US.save.raidInfo();
|
||||
var remain = Math.max(2000, ri.pool - ri.damage);
|
||||
g.spawnQueue = [{ defId: 'bruiser', t: 0.5, mods: { hp: Math.min(6, remain / 2600), speed: 0.55 } }];
|
||||
g.raidBossPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (g.wave % 5 === 0) {
|
||||
announce(g, US.i18n.t('announce.boss'), '#e04b3a');
|
||||
US.audio.sfx('bossRoar');
|
||||
@@ -498,6 +516,7 @@
|
||||
function damageEnemy(g, e, dmg, crit, silent) {
|
||||
if (!e.alive) return;
|
||||
e.hp -= dmg;
|
||||
if (g.raidBoss && e === g.raidBoss) US.save.addRaidDamage(dmg);
|
||||
e.hitFlash = 0.1;
|
||||
if (!silent) {
|
||||
US.entities.floatText(e.x + util.rand(-8, 8), e.y - e.radius - 6,
|
||||
@@ -633,6 +652,29 @@
|
||||
}
|
||||
|
||||
/* ---------------- the tribe's pet raptor -------------------------------- */
|
||||
function spawnGoldenHerd(g) {
|
||||
if (g.herdDone) return;
|
||||
g.herdDone = true;
|
||||
for (var gh = 0; gh < 6; gh++) {
|
||||
var gp = spawnEdgePoint(g);
|
||||
spawnEnemyAt(g, 'runner', gp.x, gp.y, { hp: 0.5 });
|
||||
var ge2 = g.enemies[g.enemies.length - 1];
|
||||
ge2.golden = true; ge2.color = '#ffd24a';
|
||||
ge2.value = (ge2.value || 1) * 8;
|
||||
ge2.speed = (ge2.speed || 160) * 1.15;
|
||||
}
|
||||
announce(g, L({ en: '✨ GOLDEN HERD STAMPEDE — ×8 amber!',
|
||||
vi: '✨ ĐÀN VÀNG LỘT XUYÊN — hổ phách ×8!' }, metaLang()), '#ffd24a');
|
||||
}
|
||||
|
||||
function applyPetPerk(stats, perk) {
|
||||
if (!perk || !stats) return;
|
||||
if (perk.dmg) stats.dmgMul = (stats.dmgMul || 1) * (1 + perk.dmg);
|
||||
if (perk.rof) stats.rofMul = (stats.rofMul || 1) * (1 + perk.rof);
|
||||
if (perk.gold) stats.coinMul = (stats.coinMul || 1) * (1 + perk.gold);
|
||||
if (perk.hp) stats.maxHp = (stats.maxHp || 100) + perk.hp;
|
||||
}
|
||||
|
||||
function makePet(g) {
|
||||
var pd = US.save.get().pet || { name: 'Rex Jr', color: '#5f8f3e' };
|
||||
return {
|
||||
@@ -701,11 +743,52 @@
|
||||
g.bestCombo = Math.max(g.bestCombo, g.combo);
|
||||
g.sinceKill = 0;
|
||||
|
||||
// unscathed kill streak -> hunter titles
|
||||
g.killStreak = (g.killStreak || 0) + 1;
|
||||
var TITLES = [
|
||||
[15, { en: 'Storm Hunter', vi: 'Thợ Săn Bão Tố' }],
|
||||
[30, { en: 'Apex Predator', vi: 'Kẻ Săn Đỉnh Cao' }],
|
||||
[60, { en: 'Legend of the Valley', vi: 'Huyền Thoại Thung Lũng' }]
|
||||
];
|
||||
for (var tt3 = TITLES.length - 1; tt3 >= 0; tt3--) {
|
||||
if (g.killStreak >= TITLES[tt3][0]) {
|
||||
if (g.curTitle !== TITLES[tt3][1].en) {
|
||||
g.curTitle = TITLES[tt3][1].en;
|
||||
announce(g, L({ en: '🏅 Title earned: ' + TITLES[tt3][1].en,
|
||||
vi: '🏅 Danh hiệu: ' + TITLES[tt3][1].vi }, metaLang()), '#ffd24a');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// weapon mastery XP (kills with current weapon kind)
|
||||
var wk2 = g.player.weapon && g.player.weapon.kind;
|
||||
if (wk2) {
|
||||
var mst = US.save.get().mastery;
|
||||
mst[wk2] = (mst[wk2] || 0) + 1;
|
||||
}
|
||||
|
||||
// boss falls -> chest key drop
|
||||
if (g.raidBoss === e) {
|
||||
announce(g, L({ en: '🏆 RAID COLOSSUS FELLED!', vi: '🏆 KOLOSS SỤP ĐỘ!' }, metaLang()), '#ffd24a');
|
||||
endRun(g);
|
||||
return;
|
||||
}
|
||||
if (e.isBoss) {
|
||||
US.save.get().chest.keys += 1;
|
||||
announce(g, L({ en: '🗝️ Boss dropped a CHEST KEY!', vi: '🗝️ Trùm đánh rơi CHÌA KHÓA RƯƠNG!' }, metaLang()), '#ffd24a');
|
||||
}
|
||||
|
||||
// ---- meta progression on every slain beast ----
|
||||
var spKey = speciesOf(e);
|
||||
if (spKey && US.BESTIARY[spKey]) {
|
||||
var bDef = US.BESTIARY[spKey];
|
||||
US.save.bumpBestiary(spKey);
|
||||
var msGems = US.save.bestiaryMilestoneCheck(spKey);
|
||||
if (msGems > 0) {
|
||||
announce(g, L({ en: '📖 ' + spKey.toUpperCase() + ' milestone — +' + msGems + ' 🔥',
|
||||
vi: '📖 Cột mốc ' + spKey.toUpperCase() + ' — +' + msGems + ' 🔥' }, metaLang()), '#7ee787');
|
||||
}
|
||||
var bEntry = US.save.get().bestiary[spKey];
|
||||
if (bEntry.kills >= bDef.goal && US.save.bestiaryDone(spKey, function () {
|
||||
US.save.addGems(bDef.gem);
|
||||
@@ -805,6 +888,17 @@
|
||||
}
|
||||
|
||||
function hurtPlayer(g, dmg) {
|
||||
if (g.enemyDmgMul) dmg *= g.enemyDmgMul;
|
||||
g.lastHurt = { t: Math.round(g.time * 10) / 10, dmg: Math.round(dmg * 10) / 10,
|
||||
hpAfter: Math.round((g.player ? g.player.hp - dmg : 0)) };
|
||||
g.waveDamaged = true;
|
||||
if (g.perfectChain) g.perfectChain = 0;
|
||||
if (g.frenzyT) g.frenzyT = 0;
|
||||
if ((g.killStreak || 0) >= 10 && !g.titleLostShown) {
|
||||
g.titleLostShown = true;
|
||||
}
|
||||
g.killStreak = 0;
|
||||
g.curTitle = null;
|
||||
var p = g.player;
|
||||
if (!p.alive || p.invuln > 0) return;
|
||||
if (US.save.get().settings.onehit && g.mode === 'campaign') dmg = p.stats.maxHp;
|
||||
@@ -857,6 +951,9 @@
|
||||
var save = US.save.get();
|
||||
save.bombs = g.mode === 'campaign' ? g.player.bombs : save.bombs;
|
||||
var record = US.save.submitRun(g.score, g.wave || 0);
|
||||
if (g.challenge) US.save.submitChallengeScore(g.score, g.wave || 0);
|
||||
var seasonPts = Math.round(g.score / 100) + (g.wave | 0);
|
||||
US.save.addSeasonPoints(seasonPts);
|
||||
US.save.addCoins(g.runCoins);
|
||||
US.save.stat('runs', 1);
|
||||
checkAch(g);
|
||||
@@ -867,7 +964,10 @@
|
||||
kills: g.kills,
|
||||
bestCombo: g.bestCombo,
|
||||
coins: g.runCoins,
|
||||
record: record
|
||||
record: record,
|
||||
biome: g.biome || 'jungle',
|
||||
petName: US.save.get().pet ? US.save.get().pet.name : null,
|
||||
skinColor: g.player.skin.body
|
||||
}, g.mode);
|
||||
}
|
||||
|
||||
@@ -916,8 +1016,47 @@
|
||||
function update(g, dt) {
|
||||
if (!g.metaInit) {
|
||||
g.metaInit = true;
|
||||
if (g.runOpts && g.runOpts.challenge) {
|
||||
g.challenge = true;
|
||||
var dinfo = US.save.dailyInfo();
|
||||
g.mods = {};
|
||||
dinfo.mods.forEach(function (m2) { g.mods[m2] = true; });
|
||||
announce(g, L({ en: '🎯 DAILY CHALLENGE', vi: '🎯 THỬ THÁCH HÔM NAY' }, metaLang()), '#7ee787');
|
||||
}
|
||||
if (g.runOpts && g.runOpts.raid) {
|
||||
g.raid = true;
|
||||
var rinfo = US.save.raidInfo();
|
||||
g.mods = { raid: true };
|
||||
announce(g, L({ en: '⚔️ WEEKLY RAID — strike the colossus!',
|
||||
vi: '⚔️ RAID TUẦN — đánh koloss!' }, metaLang()), '#ff9b3d');
|
||||
}
|
||||
if (g.mods && g.mods.glasshero) {
|
||||
g.player.stats.maxHp = Math.ceil(g.player.stats.maxHp * 0.6);
|
||||
g.player.hp = Math.min(g.player.hp, g.player.stats.maxHp);
|
||||
}
|
||||
if (g.mods && g.mods.venomwind) g.enemyDmgMul = 1.3;
|
||||
// permanent prestige perks
|
||||
var pvk = US.save.get().prestige.perks;
|
||||
var pdefs = { dmg: 0.04, hp: 8, gold: 0.03 };
|
||||
if (pvk.dmg) g.player.stats.dmgMul *= 1 + pdefs.dmg * pvk.dmg;
|
||||
if (pvk.hp) {
|
||||
g.player.stats.maxHp += pdefs.hp * pvk.hp;
|
||||
g.player.hp = Math.min(g.player.hp + pdefs.hp * pvk.hp, g.player.stats.maxHp);
|
||||
}
|
||||
if (pvk.gold) g.player.stats.coinMul *= 1 + pdefs.gold * pvk.gold;
|
||||
// weapon mastery of the equipped weapon
|
||||
var mw2 = US.save.masteryLevel(g.player.weapon.kind);
|
||||
if (mw2.bonus > 0) g.player.stats.dmgMul *= 1 + mw2.bonus;
|
||||
g.waveDamaged = false;
|
||||
g.perfectChain = 0;
|
||||
g.killStreak = 0;
|
||||
g.ghostWave = US.save.get().bestWave || 0;
|
||||
g.player.bombs += g.player.stats.startBombs | 0;
|
||||
if (US.save.get().pet) g.pet = makePet(g);
|
||||
if (US.save.get().pet) {
|
||||
g.pet = makePet(g);
|
||||
applyPetPerk(g.player.stats, US.save.get().pet.perk);
|
||||
g.player.hp = Math.min(g.player.hp, g.player.stats.maxHp);
|
||||
}
|
||||
ensureQuests(US.save.get());
|
||||
if (US.save.isWeekend && US.save.isWeekend()) {
|
||||
g.bloodMoon = true;
|
||||
@@ -930,6 +1069,32 @@
|
||||
updatePet(g, dt);
|
||||
if (g.state === 'over') return;
|
||||
|
||||
// ---- GOLDEN HERD: hourly stampede of gilded compys ----
|
||||
var nowMin = new Date().getMinutes();
|
||||
if (!g.herdDone && nowMin < 10 && g.state === 'playing' && g.enemies.length < 40) {
|
||||
spawnGoldenHerd(g);
|
||||
}
|
||||
|
||||
// ---- GRAZE: skim death without touching it ----
|
||||
if (g.frenzyT > 0) g.frenzyT -= dt;
|
||||
if (g.player && g.player.alive) {
|
||||
for (var gz = 0; gz < g.enemies.length; gz++) {
|
||||
var ge = g.enemies[gz];
|
||||
if (ge.alive === false || ge.spawnAnim > 0 || ge.grazed) continue;
|
||||
var gr_ = ge.radius + g.player.radius;
|
||||
var gd2 = util.dist2(ge.x, ge.y, g.player.x, g.player.y);
|
||||
if (gd2 < (gr_ + 18) * (gr_ + 18) && gd2 > gr_ * gr_) {
|
||||
ge.grazed = true;
|
||||
g.slowmo = Math.max(g.slowmo || 0, 0.16);
|
||||
g.score += 5;
|
||||
g.grazes = (g.grazes || 0) + 1;
|
||||
US.entities.spawnParticles(null, (ge.x + g.player.x) / 2, (ge.y + g.player.y) / 2, '#ffe9c9', 3, 0.4, 2);
|
||||
US.entities.floatText((ge.x + g.player.x) / 2, (ge.y + g.player.y) / 2 - 8,
|
||||
L({ en: 'graze!', vi: 'sượt!' }, metaLang()), '#ffe9c9');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// announcements always tick
|
||||
for (var a = g.announces.length - 1; a >= 0; a--) {
|
||||
g.announces[a].t += dt;
|
||||
@@ -1059,10 +1224,17 @@
|
||||
g.spawnQueue[q].t -= dt;
|
||||
if (g.spawnQueue[q].t <= 0) {
|
||||
var item = g.spawnQueue[q];
|
||||
if (g.mods && g.mods.fastfoes) {
|
||||
item.mods = item.mods || {};
|
||||
item.mods.speed = (item.mods.speed || 1) * 1.25;
|
||||
}
|
||||
var pos = spawnEdgePoint(g);
|
||||
pos.x = util.clamp(pos.x, WALL + 30, W - WALL - 30);
|
||||
pos.y = util.clamp(pos.y, WALL + 30, H - WALL - 30);
|
||||
spawnEnemyAt(g, item.defId, pos.x, pos.y, item.mods);
|
||||
if (g.raid && !g.raidBoss && g.enemies[g.enemies.length - 1].isBoss) {
|
||||
g.raidBoss = g.enemies[g.enemies.length - 1];
|
||||
}
|
||||
g.spawnQueue.splice(q, 1);
|
||||
q--;
|
||||
}
|
||||
@@ -1250,6 +1422,20 @@
|
||||
|
||||
function onWaveClear(g) {
|
||||
var bonus = 15 + g.wave * 3;
|
||||
|
||||
// PERFECT chain: untouched waves stack toward a FRENZY gold rush
|
||||
if (!g.waveDamaged && g.wave > 1) {
|
||||
g.perfectChain = (g.perfectChain || 0) + 1;
|
||||
var pb = 20 * g.perfectChain;
|
||||
g.runCoins += pb;
|
||||
announce(g, L({ en: 'PERFECT ×' + g.perfectChain + ' +' + pb, vi: 'HOÀN HẢO ×' + g.perfectChain + ' +' + pb }, metaLang()), '#7ee787');
|
||||
if (g.perfectChain >= 3 && !(g.frenzyT > 0)) {
|
||||
g.frenzyT = 20;
|
||||
announce(g, L({ en: '🔥 FRENZY — AMBER ×3!', vi: '🔥 CUỒNG NỘ — HỔ PHÁCH ×3!' }, metaLang()), '#ff9b3d');
|
||||
}
|
||||
} else {
|
||||
g.perfectChain = 0;
|
||||
}
|
||||
g.runCoins += bonus;
|
||||
g.score += 50 * g.wave;
|
||||
announce(g, US.i18n.t('announce.waveClear'), '#8ac74a');
|
||||
@@ -1319,10 +1505,13 @@
|
||||
}
|
||||
|
||||
function collectPickup(g, pk) {
|
||||
var frenzyMult = (g.frenzyT > 0) ? 3 : 1;
|
||||
var p = g.player;
|
||||
if (pk.kind === 'coin') {
|
||||
var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul));
|
||||
if (g.bloodMoon) amt *= 2;
|
||||
if (g.mods && g.mods.richherds) amt *= 2;
|
||||
if (frenzyMult > 1) amt *= frenzyMult;
|
||||
g.runCoins += amt;
|
||||
g.score += 2;
|
||||
US.entities.floatText(pk.x, pk.y - 10, '+' + amt, '#ffb938');
|
||||
@@ -1334,16 +1523,20 @@
|
||||
p.bombs++;
|
||||
US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d');
|
||||
} else if (pk.kind === 'egg') {
|
||||
var names = ['Cơm Nếp', 'Xanh Lá', 'Bonnie', 'Mập Mạp', 'Tia Chớp', 'Bông'];
|
||||
var cols = ['#c98a3d', '#5f8f3e', '#b05f8f', '#7a5230', '#58a55c', '#9fd8ff'];
|
||||
var pi = (Math.random() * names.length) | 0;
|
||||
US.save.get().pet = { name: names[pi], color: cols[pi] };
|
||||
US.save.save();
|
||||
var res2 = US.save.hatchEgg();
|
||||
g.pet = makePet(g);
|
||||
applyPetPerk(g.player, res2.pet.perk);
|
||||
var rarCol = res2.rarity === 'legendary' ? '#ff9b3d'
|
||||
: res2.rarity === 'rare' ? '#7ee787' : '#ffd24a';
|
||||
announce(g, L({
|
||||
en: '🥚 A raptor hatched: "' + names[pi] + '"!',
|
||||
vi: '🥚 Raptor con nở rồi: "' + names[pi] + '"!'
|
||||
}, metaLang()), '#ffd24a');
|
||||
en: '🥚 ' + res2.rarity.toUpperCase() + ' pet hatched: "' + res2.pet.name + '"!',
|
||||
vi: '🥚 Thú cưng ' + (res2.rarity === 'legendary' ? 'HUYỀN THOẠI' : res2.rarity === 'rare' ? 'HIẾM' : 'thường') +
|
||||
' nở rồi: "' + res2.pet.name + '"!'
|
||||
}, metaLang()), rarCol);
|
||||
if (res2.rarity !== 'common') {
|
||||
g.flashWhite = 0.5;
|
||||
boomFx(g, p.x, p.y, rarCol, 26, 160);
|
||||
}
|
||||
US.audio.sfx('win');
|
||||
checkAch(g);
|
||||
}
|
||||
@@ -1893,6 +2086,13 @@
|
||||
resume: function () { if (current && current.state === 'paused') { current.state = 'playing'; return true; } return false; },
|
||||
castUlt: function () { if (current) castUlt(current); },
|
||||
ensureQuests: function () { ensureQuests(US.save.get()); },
|
||||
triggerHerd: function () { if (current && current.state === 'playing') spawnGoldenHerd(current); },
|
||||
toggleAutoAim: function () {
|
||||
var st = US.save.get();
|
||||
st.settings.autoAim = !st.settings.autoAim;
|
||||
US.save.save();
|
||||
return st.settings.autoAim;
|
||||
},
|
||||
jumpWave: function (n) {
|
||||
if (!current || current.state === 'over') return false;
|
||||
current.state = 'playing';
|
||||
@@ -1924,7 +2124,13 @@
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (current) {
|
||||
update(current, dt);
|
||||
// graze slow-mo: world breathes while a near-miss plays out
|
||||
var effDt = dt;
|
||||
if (current.slowmo > 0) {
|
||||
current.slowmo -= dt;
|
||||
effDt = dt * 0.35;
|
||||
}
|
||||
update(current, effDt);
|
||||
render(current, ctx, vw, vh);
|
||||
} else {
|
||||
// painted dusk scene behind the menus
|
||||
@@ -1945,12 +2151,6 @@
|
||||
// wire entity callbacks that need game context
|
||||
US.entities._hooks = {
|
||||
nearestEnemy: function (x, y, d, ex) { return current ? nearestEnemy(current, x, y, d, ex) : null; },
|
||||
toggleAutoAim: function () {
|
||||
var st = US.save.get();
|
||||
st.settings.autoAim = !st.settings.autoAim;
|
||||
US.save.save();
|
||||
return st.settings.autoAim;
|
||||
},
|
||||
damageEnemy: function (e, dmg, crit, silent) { if (current) damageEnemy(current, e, dmg, crit, silent); },
|
||||
explode: function (x, y, r, dmg) { if (current) explodeAt(current, x, y, r, dmg); },
|
||||
collect: function (pk) { if (current) collectPickup(current, pk); },
|
||||
|
||||
Reference in New Issue
Block a user