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:
+271
-1
@@ -36,9 +36,49 @@
|
||||
sfx: 0.8, music: 0.5, autofire: true, autoAim: false, onehit: false,
|
||||
shake: true, shakeAmt: 1, quality: 'high'
|
||||
},
|
||||
board: [] // [{name,score,wave,ts}]
|
||||
board: [], // [{name,score,wave,ts}]
|
||||
// --- v3: retention systems ---
|
||||
login: { last: '', streak: 0, claimedToday: false },
|
||||
wheel: { last: '' },
|
||||
chest: { keys: 0, pending: 0 },
|
||||
eggs: { hatched: 0, pity: 0 },
|
||||
prestige: { count: 0, ngoc: 0, perks: {} }, // perks: {dmg:lv,hp:lv,gold:lv}
|
||||
mastery: {}, // weaponKind -> kills
|
||||
dailyChal: { date: '', bestScore: 0, board: [] }, // [{name,score,wave,ts}] top5
|
||||
season: { week: '', points: 0, claimedMilestones: [] },
|
||||
raid: { week: '', damage: 0, claimedTiers: [] }
|
||||
};
|
||||
|
||||
function isoWeekKey(d) {
|
||||
var dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
var day = dt.getUTCDay() || 7;
|
||||
dt.setUTCDate(dt.getUTCDate() + 4 - day);
|
||||
var y0 = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
||||
var wk = Math.ceil(((dt - y0) / 864e5 + 1) / 7);
|
||||
return dt.getUTCFullYear() + '-W' + wk;
|
||||
}
|
||||
|
||||
var PRESTIGE_PERKS = {
|
||||
dmg: { per: 0.04, cost: [1, 2, 4, 8] },
|
||||
hp: { per: 8, cost: [1, 2, 4, 8] },
|
||||
gold: { per: 0.03, cost: [1, 2, 4, 8] }
|
||||
};
|
||||
var SEASON_RANKS = [
|
||||
{ at: 0, en: 'Unranked', vi: 'Chưa Xếp Hạng', icon: '·' },
|
||||
{ at: 1000, en: 'Bronze', vi: 'Đồng', icon: '🥉' },
|
||||
{ at: 3000, en: 'Silver', vi: 'Bạc', icon: '🥈' },
|
||||
{ at: 8000, en: 'Gold', vi: 'Vàng', icon: '🥇' },
|
||||
{ at: 20000, en: 'Diamond', vi: 'Kim Cương', icon: '💎' }
|
||||
];
|
||||
var BESTIARY_MS = [50, 200, 500];
|
||||
var BESTIARY_MS_GEMS = [5, 12, 30];
|
||||
|
||||
var RAID_TIERS = [
|
||||
{ dmg: 1000, gems: 20 },
|
||||
{ dmg: 5000, gems: 50 },
|
||||
{ dmg: 20000, gems: 120 }
|
||||
];
|
||||
|
||||
function clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
|
||||
function isPlain(o) { return o && typeof o === 'object' && !(o instanceof Array); }
|
||||
@@ -174,6 +214,236 @@
|
||||
},
|
||||
|
||||
/* ---------- live-ops ---------- */
|
||||
isoWeekKey: function (d) { return isoWeekKey(d || new Date()); },
|
||||
|
||||
// ---- eggs with rarity + pity ----
|
||||
hatchEgg: function (forceRoll) {
|
||||
var st = this.get();
|
||||
st.eggs.hatched++;
|
||||
st.eggs.pity++;
|
||||
var rar = 'common';
|
||||
var roll = (forceRoll !== undefined) ? forceRoll : Math.random() * 100;
|
||||
if (roll >= 95 || st.eggs.pity >= 30) rar = 'legendary';
|
||||
else if (roll >= 70) rar = 'rare';
|
||||
if (rar !== 'common') st.eggs.pity = 0;
|
||||
var pool = US.PETS[rar];
|
||||
var pick = pool[(Math.random() * pool.length) | 0];
|
||||
st.pet = { name: pick.name, color: pick.color, rarity: rar, perk: pick.perk || null };
|
||||
this.save();
|
||||
return { pet: clone(st.pet), rarity: rar };
|
||||
},
|
||||
|
||||
// ---- 7-day login calendar ----
|
||||
claimLogin: function () {
|
||||
var st = this.get();
|
||||
var today = this.today();
|
||||
if (st.login.claimedToday && st.login.last === today) return { ok: false, reason: 'claimed' };
|
||||
var yest = new Date(); yest.setDate(yest.getDate() - 1);
|
||||
var yKey = [yest.getFullYear(), String(yest.getMonth() + 1).padStart(2, '0'),
|
||||
String(yest.getDate()).padStart(2, '0')].join('-');
|
||||
st.login.streak = (st.login.last === yKey) ? st.login.streak + 1 : 1;
|
||||
st.login.last = today;
|
||||
st.login.claimedToday = true;
|
||||
var dayIdx = Math.min(6, st.login.streak - 1);
|
||||
var rw = US.LOGIN_REWARDS[dayIdx];
|
||||
if (rw.coins) st.coins += rw.coins;
|
||||
if (rw.gems) this.addGems(rw.gems);
|
||||
if (rw.chestKey) st.chest.keys += rw.chestKey;
|
||||
var hatched = null;
|
||||
if (rw.egg) hatched = this.hatchEgg(rw.egg === 'rare' ? 80 : 96);
|
||||
this.save();
|
||||
return { ok: true, day: dayIdx + 1, streak: st.login.streak, reward: rw, pet: hatched && hatched.pet };
|
||||
},
|
||||
|
||||
// ---- lucky wheel, one free spin per day ----
|
||||
WHEEL: [
|
||||
{ coins: 250 }, { gems: 5 }, { coins: 500 }, { chestKey: 1 },
|
||||
{ coins: 750 }, { gems: 15 }, { coins: 1200 }, { egg: 1 }
|
||||
],
|
||||
canSpin: function () { var st = this.get(); return st.wheel.last !== this.today(); },
|
||||
spinWheel: function (idx) {
|
||||
if (!this.canSpin()) return { ok: false, reason: 'spun' };
|
||||
var st = this.get();
|
||||
st.wheel.last = this.today();
|
||||
var seg = (idx !== undefined) ? idx : (Math.random() * this.WHEEL.length) | 0;
|
||||
var rw = this.WHEEL[seg % this.WHEEL.length];
|
||||
var petRes = null;
|
||||
if (rw.coins) st.coins += rw.coins;
|
||||
if (rw.gems) this.addGems(rw.gems);
|
||||
if (rw.chestKey) st.chest.keys += rw.chestKey;
|
||||
if (rw.egg) petRes = this.hatchEgg();
|
||||
this.save();
|
||||
return { ok: true, seg: seg % this.WHEEL.length, reward: rw, pet: petRes && petRes.pet };
|
||||
},
|
||||
|
||||
// ---- seeded daily challenge ----
|
||||
DAILY_MODS: null, // filled below after US.DAILY_MOD_POOL exists
|
||||
dailyInfo: function () {
|
||||
var date = this.today();
|
||||
var seed = 0;
|
||||
for (var i = 0; i < date.length; i++) seed = (seed * 31 + date.charCodeAt(i)) >>> 0;
|
||||
// mulberry32
|
||||
function rnd() {
|
||||
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
|
||||
var t2 = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t2 = (t2 + Math.imul(t2 ^ (t2 >>> 7), 61 | t2)) ^ t2;
|
||||
return ((t2 ^ (t2 >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
var pool = US.DAILY_MOD_POOL;
|
||||
var a = pool[(rnd() * pool.length) | 0];
|
||||
var b = pool[(rnd() * pool.length) | 0];
|
||||
var mods = [a, b !== a ? b : pool[(pool.indexOf(a) + 1) % pool.length]];
|
||||
return { date: date, mods: mods, seed: seed };
|
||||
},
|
||||
submitChallengeScore: function (score, wave) {
|
||||
var st = this.get();
|
||||
var info = this.dailyInfo();
|
||||
if (st.dailyChal.date !== info.date) {
|
||||
st.dailyChal.date = info.date;
|
||||
st.dailyChal.bestScore = 0;
|
||||
st.dailyChal.board = [];
|
||||
}
|
||||
st.dailyChal.bestScore = Math.max(st.dailyChal.bestScore, score | 0);
|
||||
st.dailyChal.board.push({ name: st.name, score: score | 0, wave: wave | 0, ts: Date.now() });
|
||||
st.dailyChal.board.sort(function (p, q2) { return q2.score - p.score; });
|
||||
st.dailyChal.board = st.dailyChal.board.slice(0, 5);
|
||||
this.save();
|
||||
return { best: st.dailyChal.bestScore, rank: st.dailyChal.board.length };
|
||||
},
|
||||
|
||||
// ---- weekly raid ----
|
||||
raidPoolBase: 50000,
|
||||
raidInfo: function () {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; }
|
||||
var weekNum = parseInt(wk.split('-W')[1], 10) || 1;
|
||||
return {
|
||||
week: wk,
|
||||
pool: this.raidPoolBase * weekNum,
|
||||
damage: st.raid.damage,
|
||||
tiers: RAID_TIERS,
|
||||
claimedTiers: st.raid.claimedTiers.slice()
|
||||
};
|
||||
},
|
||||
addRaidDamage: function (dmg) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) { st.raid.week = wk; st.raid.damage = 0; st.raid.claimedTiers = []; }
|
||||
st.raid.damage += Math.round(dmg);
|
||||
this.save();
|
||||
},
|
||||
claimRaidTier: function (idx) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.raid.week !== wk) return { ok: false, reason: 'week' };
|
||||
if (st.raid.claimedTiers.indexOf(idx) >= 0) return { ok: false, reason: 'claimed' };
|
||||
var tier = RAID_TIERS[idx];
|
||||
if (!tier || st.raid.damage < tier.dmg) return { ok: false, reason: 'locked' };
|
||||
st.raid.claimedTiers.push(idx);
|
||||
this.addGems(tier.gems);
|
||||
this.save();
|
||||
return { ok: true, gems: tier.gems };
|
||||
},
|
||||
|
||||
// ---- prestige / rebirth ----
|
||||
prestigeInfo: function () {
|
||||
var st = this.get();
|
||||
var gain = st.bestWave >= 50 ? Math.max(1, Math.floor((st.bestWave - 40) / 5)) : 0;
|
||||
return {
|
||||
canPrestige: st.bestWave >= 50,
|
||||
bestWave: st.bestWave,
|
||||
ngocGain: gain,
|
||||
ngoc: st.prestige.ngoc,
|
||||
count: st.prestige.count,
|
||||
perks: clone(st.prestige.perks),
|
||||
perkDefs: PRESTIGE_PERKS
|
||||
};
|
||||
},
|
||||
doPrestige: function () {
|
||||
var st = this.get();
|
||||
var info = this.prestigeInfo();
|
||||
if (!info.canPrestige) return { ok: false, reason: 'wave50' };
|
||||
st.prestige.ngoc += info.ngocGain;
|
||||
st.prestige.count++;
|
||||
st.upgrades = {};
|
||||
st.coins = 0;
|
||||
st.bombs = 1;
|
||||
this.save();
|
||||
return { ok: true, gained: info.ngocGain };
|
||||
},
|
||||
buyPerk: function (id) {
|
||||
var def = PRESTIGE_PERKS[id];
|
||||
if (!def) return { ok: false, reason: 'noid' };
|
||||
var st = this.get();
|
||||
var lv = st.prestige.perks[id] || 0;
|
||||
if (lv >= def.cost.length) return { ok: false, reason: 'max' };
|
||||
var cost = def.cost[lv];
|
||||
if (st.prestige.ngoc < cost) return { ok: false, reason: 'poor' };
|
||||
st.prestige.ngoc -= cost;
|
||||
st.prestige.perks[id] = lv + 1;
|
||||
this.save();
|
||||
return { ok: true, level: lv + 1 };
|
||||
},
|
||||
|
||||
// ---- weapon mastery ----
|
||||
masteryLevel: function (kind) {
|
||||
var kills = (this.get().mastery || {})[kind] || 0;
|
||||
var lvl = Math.min(15, Math.floor(Math.sqrt(kills / 5)));
|
||||
return { level: lvl, bonus: lvl * 0.02, kills: kills };
|
||||
},
|
||||
|
||||
// ---- bestiary milestones ----
|
||||
bestiaryMilestoneCheck: function (spKey) {
|
||||
var st = this.get();
|
||||
var entry = st.bestiary[spKey];
|
||||
if (!entry) return 0;
|
||||
entry.ms = entry.ms || [];
|
||||
var granted = 0;
|
||||
BESTIARY_MS.forEach(function (m4, i4) {
|
||||
if (entry.kills >= m4 && entry.ms.indexOf(i4) < 0) {
|
||||
entry.ms.push(i4);
|
||||
granted += BESTIARY_MS_GEMS[i4];
|
||||
}
|
||||
});
|
||||
if (granted > 0) this.addGems(granted);
|
||||
return granted;
|
||||
},
|
||||
|
||||
// ---- season points & rank ----
|
||||
addSeasonPoints: function (n) {
|
||||
var st = this.get();
|
||||
var wk = isoWeekKey(new Date());
|
||||
if (st.season.week !== wk) { st.season.week = wk; st.season.points = 0; }
|
||||
st.season.week = wk;
|
||||
st.season.points += n | 0;
|
||||
this.save();
|
||||
return st.season.points;
|
||||
},
|
||||
seasonRank: function () {
|
||||
var st = this.get();
|
||||
var pts = st.season.points || 0;
|
||||
var tier = SEASON_RANKS[0];
|
||||
for (var i5 = SEASON_RANKS.length - 1; i5 >= 0; i5--) {
|
||||
if (pts >= SEASON_RANKS[i5].at) { tier = SEASON_RANKS[i5]; break; }
|
||||
}
|
||||
return { points: pts, week: st.season.week, tier: clone(tier) };
|
||||
},
|
||||
|
||||
// ---- boss chest ----
|
||||
openChest: function () {
|
||||
var st = this.get();
|
||||
if (st.chest.keys < 1) return { ok: false, reason: 'nokey' };
|
||||
st.chest.keys--;
|
||||
var r = Math.random();
|
||||
var reward;
|
||||
var petRes = null;
|
||||
if (r < 0.55) { var c2 = 450 + ((Math.random() * 500) | 0); st.coins += c2; reward = { coins: c2 }; }
|
||||
else if (r < 0.85) { var g2 = 10 + ((Math.random() * 12) | 0); this.addGems(g2); reward = { gems: g2 }; }
|
||||
else { petRes = this.hatchEgg(); reward = { egg: true }; }
|
||||
this.save();
|
||||
return { ok: true, reward: reward, pet: petRes && petRes.pet };
|
||||
},
|
||||
isWeekend: function () {
|
||||
var d = new Date().getDay();
|
||||
return d === 0 || d === 6;
|
||||
|
||||
Reference in New Issue
Block a user