Files
Primal Rampage Bot 7164cc798c 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
2026-08-23 06:55:51 +00:00

2165 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Neon Rampage — game engine: campaign (waves, bosses, On Fire, cards) & arena vs bots. */
(function () {
'use strict';
var US = (window.US = window.US || {});
var util = US.util;
var E;
var W = 1280, H = 720; // logical world size
var WALL = 26; // arena inset
var REVIVE_COST = 150;
/* ================================================================
Run construction
================================================================ */
function newRun(mode, opts) {
opts = opts || {};
E = US.entities;
var save = US.save.get();
var hero = US.HEROES[save.hero] || US.HEROES.recruit;
var skin = US.SKINS[save.skin] || US.SKINS.default;
var weaponId = opts.weaponId || save.weapon;
var g = {
mode: mode, // 'campaign' | 'arena'
runOpts: opts || {},
state: 'playing', // playing | cards | paused | revive | over
time: 0,
over: false,
W: W, H: H,
player: new E.Player({
x: W / 2, y: H / 2,
hero: hero,
skin: skin,
weaponId: weaponId,
bombs: mode === 'campaign' ? (save.bombs | 0) : 2
}),
enemies: [],
bullets: [],
pickups: [],
rings: [], // shockwaves/novas
// campaign state
wave: 0,
spawnQueue: [], // [{defId, mods, t}]
waveActive: false,
// scoring
score: 0,
kills: 0,
runCoins: 0,
combo: 0,
comboTimer: 0,
bestCombo: 0,
// heat / on fire
heat: 0,
onFire: false,
onFireT: 0,
hangover: 0,
sinceKill: 0,
reviveUsed: false,
reviving: false,
// fx
shake: 0,
flashRed: 0,
announces: [],
decor: null,
roarT: 9,
decals: [],
prints: [],
stompCount: 0,
weather: 'clear',
weatherT: util.rand(20, 40),
flashWhite: 0,
thunderT: -1,
bloodMoon: false,
// arena
arenaCfg: opts.arena || null,
arenaTime: 0,
eid: 1
};
g.decor = US.art.makeDecor(W, H, WALL);
g.player.hurt = function (dmg) { hurtPlayer(g, dmg); };
g.player.invulnHit = function () { return this.invuln > 0; };
if (mode === 'arena') setupArena(g);
else scheduleNextWave(g);
return g;
}
/* ================================================================
Campaign waves
================================================================ */
function waveBudget(wave) { return Math.floor(55 + wave * 24 + Math.pow(wave, 1.35) * 4); }
function availableEnemies(wave) {
return Object.keys(US.ENEMIES).filter(function (k) {
var d = US.ENEMIES[k];
return !d.noSpawn && d.fromWave <= wave;
});
}
function buildWaveQueue(g, wave) {
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;
var guard = 0;
while (budget > 0 && guard++ < 400) {
var id = util.pick(pool);
var def = US.ENEMIES[id];
if (def.cost > budget && q.length) break;
var mods = { hp: 1 + wave * 0.06, dmg: 1 + wave * 0.02 };
if (def.elite) mods.elite = true;
budget -= def.cost;
t += util.rand(0.35, 1.05);
q.push({ defId: id, mods: mods, t: t });
}
return q;
}
function biomeFor(wave) {
return wave < 10 ? 'jungle' : (wave < 19 ? 'swamp' : 'ash');
}
function enterBiome(g, bio) {
g.biome = bio;
var names = {
jungle: { en: '🌿 The Green Jungle', vi: '🌿 Rừng Xanh Lục' },
swamp: { en: '💀 The Murk Swamp', vi: '💀 Đầm Lầy U Ám' },
ash: { en: '🌋 The Ashlands', vi: '🌋 Tro Núi Lửa' }
};
announce(g, L(names[bio], metaLang()), '#ffd24a');
if (bio === 'swamp' && !g.swampPitsAdded && g.decor) {
g.swampPitsAdded = true; // the muck spreads: two extra tar pits
for (var i2 = 0; i2 < 2; i2++) {
var ang = util.rand(0, Math.PI * 2);
g.decor.tarPits.push({
x: W / 2 + Math.cos(ang) * util.rand(180, 380),
y: H / 2 + Math.sin(ang) * util.rand(140, 260),
r: util.rand(52, 70), seed: Math.random() * 9
});
}
}
if (bio === 'ash' && !g.ashCracksMade && g.decor) {
g.ashCracksMade = true; // lava veins sear anyone standing on them
g.decor.cracks = [];
for (var c3 = 0; c3 < 3; c3++) {
var ca = c3 * Math.PI * 2 / 3 + util.rand(-0.4, 0.4);
var cr2 = {
x: W / 2 + Math.cos(ca) * util.rand(200, 400),
y: H / 2 + Math.sin(ca) * util.rand(150, 260),
pts: []
};
var px = 0, py = 0;
for (var si = 0; si < 5; si++) {
px += util.rand(18, 34); py += util.rand(-14, 14);
cr2.pts.push({ x: px, y: py });
}
cr2.r = 40;
g.decor.cracks.push(cr2);
}
}
}
function inCrack(g, x, y) {
if (!g.decor || !g.decor.cracks) return false;
for (var i = 0; i < g.decor.cracks.length; i++) {
var c = g.decor.cracks[i];
if (util.dist2(x, y, c.x + 40, c.y) < c.r * c.r) return true;
}
return false;
}
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');
spawnBoss(g);
roarFx(g);
} else {
announce(g, US.i18n.t('announce.wave') + ' ' + g.wave, '#f3e7cf');
}
if (g.wave > 25 && !g.endlessAnnounced) {
g.endlessAnnounced = true;
announce(g, L({ en: 'ENDLESS MODE', vi: 'CHẾ ĐỘ VÔ TẬN' }, metaLang()), '#ff8a3d');
}
questProgress(g, 'wave', g.wave, true);
checkAch(g);
}
function spawnEdgePoint(g) {
var m = 40;
var side = util.randInt(0, 3);
if (side === 0) return { x: util.rand(WALL + m, W - WALL - m), y: WALL - m };
if (side === 1) return { x: W - WALL + m, y: util.rand(WALL + m, H - WALL - m) };
if (side === 2) return { x: util.rand(WALL + m, W - WALL - m), y: H - WALL + m };
return { x: WALL - m, y: util.rand(WALL + m, H - WALL - m) };
}
function spawnBoss(g) {
var idx = Math.floor((g.wave / 5 - 1) % US.BOSSES.length);
var tierHp = 1 + Math.floor((g.wave - 5) / 15) * 0.9;
var def = US.BOSSES[idx];
var b = new E.Enemy(def, W / 2, -80, {
hp: (1 + g.wave * 0.09) * tierHp,
isBoss: true
});
b.bossDef = def;
b.eid = g.eid++;
g.enemies.push(b);
}
// sonic roar: expanding visual ring + heavy shake as the alpha arrives
function roarFx(g) {
var b = null;
for (var i = 0; i < g.enemies.length; i++) if (g.enemies[i].isBoss) b = g.enemies[i];
if (!b) return;
g.rings.push({ x: b.x, y: b.y, r: 8, maxR: 300, life: 0.6, maxLife: 0.6, color: '#e04b3a', dmg: 0, hit: [] });
g.shake = Math.max(g.shake, 14);
US.entities.spawnParticles(null, b.x, b.y, '#c9bda6', 20, 3, 3);
}
function splitBoss(g, boss) {
var n = 2;
// parent's body counts for partial credit before it splits
var def = boss.bossDef || {};
g.score += Math.round((def.score || 400) * 0.4);
g.runCoins += Math.round(util.randInt(def.coin ? def.coin[0] : 20, def.coin ? def.coin[1] : 30) * 0.5);
announce(g, 'IT SPLITS!', '#58a55c');
for (var i = 0; i < n; i++) {
var child = new E.Enemy(boss.def, boss.x + util.rand(-50, 50), boss.y + util.rand(-50, 50), {
hp: boss.maxHp * 0.32,
size: 0.62,
isBoss: true,
splitStage: 1
});
child.bossDef = boss.def;
child.patterns = boss.patterns.filter(function (p) { return p !== 'split'; });
child.patCd = 1.5;
child.eid = g.eid++;
g.enemies.push(child);
}
boomFx(g, boss.x, boss.y, boss.color, 40, 260);
}
/* ================================================================
Spawning helpers
================================================================ */
function spawnEnemyAt(g, defId, x, y, mods) {
var def = typeof defId === 'string' ? US.ENEMIES[defId] : defId;
if (!def) return null;
var e = new E.Enemy(def, x, y, mods || {});
e.eid = g.eid++;
g.enemies.push(e);
return e;
}
/* ================================================================
Arena (local PvP vs bots)
================================================================ */
function makeBot(g, name, tint) {
var wid = util.pick(['pistol', 'smg', 'shotgun']);
var f = {
isFighter: true,
isBot: true,
eid: g.eid++,
name: name,
color: tint,
x: util.rand(120, W - 120),
y: util.rand(120, H - 120),
vx: 0, vy: 0,
radius: 15,
maxHp: 85,
hp: 85,
alive: true,
spawnAnim: 0.5,
weapon: US.WEAPONS[wid],
shootCd: util.rand(0.4, 1.2),
burst: 0,
strafe: Math.random() < 0.5 ? 1 : -1,
strafeT: util.rand(0.6, 1.6),
targetEid: null,
kills: 0,
deaths: 0,
respawnT: 0,
hitFlash: 0
};
return f;
}
function setupArena(g) {
var cfg = g.arenaCfg;
g.targetKills = cfg.target;
g.arenaTimeLeft = 0; // untimed unless configured
var names = ['VEX', 'JOLT', 'RUIN', 'HALE'];
var tints = ['#ff8a3d', '#b06bff', '#57d98a', '#ff6ea9'];
g.bots = [];
for (var i = 0; i < cfg.bots; i++) g.bots.push(makeBot(g, names[i], tints[i]));
// fighters live in g.enemies so bullets hit them
g.enemies = g.bots.slice();
announce(g, US.i18n.t('arena.leader', { n: g.targetKills }), '#ffd24a');
}
function respawnFighter(g, f) {
f.alive = true;
f.hp = f.maxHp;
f.spawnAnim = 0.5;
// spawn far from others
var best = null, bestD = -1;
for (var tries = 0; tries < 12; tries++) {
var px = util.rand(90, W - 90), py = util.rand(90, H - 90);
var d = 1e9;
for (var j = 0; j < g.enemies.length; j++) {
var o = g.enemies[j];
if (!o.alive || o === f) continue;
d = Math.min(d, util.dist2(px, py, o.x, o.y));
}
d = Math.min(d, util.dist2(px, py, g.player.x, g.player.y));
if (d > bestD) { bestD = d; best = { x: px, y: py }; }
}
f.x = best.x; f.y = best.y;
boomFx(g, f.x, f.y, f.color, 12, 140);
}
function updateBots(g, dt) {
var p = g.player;
for (var i = 0; i < g.bots.length; i++) {
var b = g.bots[i];
if (!b.alive) {
b.respawnT -= dt;
if (b.respawnT <= 0) respawnFighter(g, b);
continue;
}
if (b.spawnAnim > 0) { b.spawnAnim -= dt; continue; }
if (b.hitFlash > 0) b.hitFlash -= dt;
// choose nearest living target (player counts as fighter id 0)
var tx, ty, td = 1e9;
var cands = [];
if (p.alive) cands.push(p);
for (var k = 0; k < g.bots.length; k++) {
var o = g.bots[k];
if (o !== b && o.alive) cands.push(o);
}
for (var c = 0; c < cands.length; c++) {
var d2 = util.dist2(b.x, b.y, cands[c].x, cands[c].y);
if (d2 < td) { td = d2; tx = cands[c].x; ty = cands[c].y; }
}
if (tx === undefined) continue;
var dist = Math.sqrt(td) || 1;
var nx = (tx - b.x) / dist, ny = (ty - b.y) / dist;
// approach until preferred range, then strafe
b.strafeT -= dt;
if (b.strafeT <= 0) { b.strafe *= -1; b.strafeT = util.rand(0.6, 1.8); }
var want = b.weapon.id === 'shotgun' ? 170 : b.weapon.id === 'pistol' ? 260 : 220;
var move = dist > want ? 1 : dist < want * 0.6 ? -0.7 : 0;
var sp = 165;
b.x += (nx * move + -ny * b.strafe * 0.8) * sp * dt;
b.y += (ny * move + nx * b.strafe * 0.8) * sp * dt;
clampToWorld(b);
b.face = Math.atan2(ny, nx);
b.moving = true;
b.walkPhase = (b.walkPhase || 0) + dt * 12;
if (Math.random() < 0.08) {
US.entities.spawnParticles(null, b.x - nx * 12, b.y - ny * 12, '#9a7d52', 1, 0.6, 2.5);
}
// fire
b.shootCd -= dt;
if (dist < 520 && b.shootCd <= 0) {
var ang = Math.atan2(ty - b.y, tx - b.x) + util.rand(-0.07, 0.07);
fireWeaponBullets(g, b, ang, 0.75);
b.shootCd = 1 / (b.weapon.rof * 0.55);
}
}
}
function fighterDied(g, f, killerLabel) {
f.alive = false;
f.deaths++;
f.respawnT = 2.2;
boomFx(g, f.x, f.y, f.color, 30, 240);
US.audio.sfx('explode');
var byPlayer = killerLabel === 'player' || killerLabel === 0;
if (byPlayer) {
g.kills++;
g.score += 100;
g.runCoins += 6;
g.player.ult = Math.min(100, g.player.ult + 6 * g.player.stats.ultRate);
addHeat(g, 14);
US.entities.floatText(f.x, f.y - 20, '+100', '#ffb938');
checkArenaWin(g);
} else if (f.isBot && typeof killerLabel === 'number') {
// bot killed bot — credit if we tracked shooter eid
var killer = findFighterByEid(g, f.lastHitBy);
if (killer && killer.isBot) {
killer.kills++;
checkArenaWin(g);
}
}
}
function findFighterByEid(g, eid) {
if (eid === 0) return g.player;
for (var i = 0; i < g.bots.length; i++) if (g.bots[i].eid === eid) return g.bots[i];
return null;
}
function checkArenaWin(g) {
if (g.state === 'over') return;
var winner = null;
if (g.kills >= g.targetKills) winner = { name: US.i18n.t('arena.you'), player: true };
else {
for (var i = 0; i < g.bots.length; i++) {
if (g.bots[i].kills >= g.targetKills) winner = { name: g.bots[i].name, player: false };
}
}
if (winner) finishArena(g, winner);
}
function finishArena(g, winner) {
g.state = 'over';
g.over = true;
US.save.addCoins(g.runCoins);
if (winner.player) US.audio.sfx('win'); else US.audio.sfx('lose');
if (US.ui) US.ui.arenaOver(winner, g);
}
/* ================================================================
Combat plumbing
================================================================ */
function fireWeaponBullets(g, shooter, ang, dmgScale) {
var isPlayer = shooter === g.player;
var w = shooter.weapon;
var s = isPlayer ? shooter.stats : { pellets: 0, pierce: 0, crit: 0.03, critMul: 2, bulletSpeedMul: 1, bulletSizeMul: 1, explosive: 0, ricochet: 0 };
var n = w.pellets + (s.pellets || 0);
var baseDmg = (w.dmg * (isPlayer ? s.dmgMul : 0.5)) * (isPlayer && g.onFire ? 2 : 1) * (dmgScale || 1);
for (var i = 0; i < n; i++) {
var off = (i - (n - 1) / 2) * (w.spread || 0.08);
var a = ang + off + (w.spread ? util.rand(-w.spread, w.spread) * 0.5 : 0);
var crit = Math.random() < (s.crit || 0);
var b = new E.Bullet(
shooter.x + Math.cos(ang) * (shooter.radius + 6),
shooter.y + Math.sin(ang) * (shooter.radius + 6),
a,
w.speed * (s.bulletSpeedMul || 1),
baseDmg * (crit ? (s.critMul || 2) : 1),
isPlayer && g.onFire ? '#ffd24a' : w.color,
w.size * (s.bulletSizeMul || 1),
isPlayer ? 'player' : 'e' + shooter.eid,
{
pierce: (w.pierce || 0) + (s.pierce || 0),
aoe: w.aoe || 0,
ricochet: isPlayer ? (s.ricochet || 0) : 0,
crit: crit,
kind: w.kind
}
);
b.ownerEid = isPlayer ? 0 : shooter.eid;
b.life = 1.9;
g.bullets.push(b);
}
shooter.fireT = 0.09;
if (isPlayer) {
US.audio.sfx(w.sfx);
g.shake = Math.min(g.shake + (w.id === 'rail' ? 3 : 1), 8);
}
}
function enemyBullet(g, x, y, nx, ny, speed, dmg, color) {
var b = new E.Bullet(x, y, Math.atan2(ny, nx), speed, dmg, color || '#ff5e7a', 6, 'enemy', {});
b.enemyShot = true;
g.bullets.push(b);
}
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,
String(Math.round(dmg)), crit ? '#ffd24a' : '#ffffff');
if (!crit) US.audio.sfx('hit');
}
// lifesteal
if (g.player.alive && g.player.stats.lifesteal > 0 && e.hp > 0) {
g.player.hp = Math.min(g.player.stats.maxHp, g.player.hp + dmg * g.player.stats.lifesteal * 0.4);
}
if (e.hp <= 0) killEnemy(g, e);
}
/* persistent blood stains that slowly fade */
function bloodDecal(g, e) {
if (e.isFighter) return;
var n = e.isBoss ? 5 : 2;
for (var i = 0; i < n; i++) {
if (g.decals.length > 70) g.decals.shift();
g.decals.push({
x: e.x + util.rand(-e.radius, e.radius),
y: e.y + util.rand(-e.radius, e.radius),
rx: e.radius * util.rand(0.7, 1.5),
ry: e.radius * util.rand(0.45, 1.0),
rot: util.rand(0, Math.PI),
life: 16,
dark: Math.random() < 0.4
});
}
}
function drawPrints(ctx, g) {
for (var i = 0; i < g.prints.length; i++) {
var pt = g.prints[i];
ctx.globalAlpha = 0.26 * Math.min(1, pt.life / 3);
ctx.fillStyle = '#4a3826';
ctx.beginPath();
ctx.ellipse(pt.x, pt.y, 4.2, 2.6, pt.rot, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawDecals(ctx, g) {
for (var i = 0; i < g.decals.length; i++) {
var dc = g.decals[i];
ctx.globalAlpha = 0.4 * Math.min(1, dc.life / 12);
ctx.fillStyle = dc.dark ? '#571810' : '#7e2418';
ctx.beginPath();
ctx.ellipse(dc.x, dc.y, dc.rx, dc.ry, dc.rot, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateDecals(g, dt) {
for (var i = g.decals.length - 1; i >= 0; i--) {
g.decals[i].life -= dt;
if (g.decals[i].life <= 0) g.decals.splice(i, 1);
}
}
/* ------------- meta progression: bestiary / quests / achievements ------- */
function L(o, lang) { return (o && (o[lang] || o.en)) || ''; }
function metaLang() { return US.save.get().lang === 'vi' ? 'vi' : 'en'; }
function speciesOf(e) {
if (e.isBoss) return (e.bossDef && (e.bossDef.id || e.bossDef.art)) || null;
var d = e.def;
if (!d) return null;
if (d._spKey) return d._spKey;
for (var k in US.ENEMIES) {
if (US.ENEMIES[k] === d) { d._spKey = k; return k; }
}
return null;
}
function ensureQuests(sv) {
var today = US.save.today();
if (sv.quests.date === today && sv.quests.items.length === 3) return;
var y = new Date(Date.now() - 864e5);
var yest = y.getFullYear() + '-' + ('0' + (y.getMonth() + 1)).slice(-2) + '-' + ('0' + y.getDate()).slice(-2);
if (sv.quests.lastDay !== today) {
sv.quests.streak = (sv.quests.lastDay === yest ? (sv.quests.streak | 0) : 0) + 1;
sv.quests.lastDay = today;
}
var seed = 7;
today.split('').forEach(function (ch) { seed = (seed * 31 + ch.charCodeAt(0)) % 1e9; });
var pool = US.QUEST_POOL.slice();
var picks = [];
while (picks.length < 3 && pool.length) {
seed = (seed * 1103515245 + 12345) % 2147483647;
picks.push(pool.splice(seed % pool.length, 1)[0]);
}
sv.quests.date = today;
sv.quests.items = picks.map(function (d) {
return { defId: d.id, type: d.type, goal: d.goal, prog: 0, done: false };
});
US.save.save();
if (US.ui) US.ui.questBadge();
}
function questProgress(g, type, n, isMax) {
var sv = US.save.get();
ensureQuests(sv);
var changed = false;
sv.quests.items.forEach(function (q) {
if (q.type !== type || q.done) return;
q.prog = isMax ? Math.max(q.prog | 0, n | 0) : (q.prog | 0) + n;
if (q.prog >= q.goal) { q.prog = q.goal; q.done = true; changed = true; }
});
if (changed) {
US.save.save();
if (US.ui) US.ui.questBadge();
announce(g, '📜 ' + L({ en: 'Quest complete!', vi: 'Nhiệm vụ xong!' }, metaLang()), '#ffd24a');
}
}
function checkAch(g) {
var sv = US.save.get();
var lang = metaLang();
Object.keys(US.ACHIEVEMENTS).forEach(function (id) {
if (US.save.hasAch(id)) return;
var def = US.ACHIEVEMENTS[id];
var hit = false;
try { hit = def.test(sv, g); } catch (e) { /* g-dependent ones need a run */ }
if (hit && US.save.unlockAch(id)) {
US.save.addGems(def.gem);
if (g) announce(g, '🏆 ' + L(def.text, lang) + ' +' + def.gem + L({ en: ' flames', vi: ' lửa' }, lang), '#ffd24a');
US.audio.sfx('buy');
}
});
}
/* ---------------- 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 {
x: g.player.x - 34, y: g.player.y + 22,
face: 0, walkPhase: 0, seed: Math.random(),
shootT: 1.4, name: pd.name, color: pd.color, radius: 9
};
}
function updatePet(g, dt) {
var pt = g.pet, p = g.player;
if (!pt || !p.alive || g.state !== 'playing') return;
var tx = p.x - Math.cos(p.aim) * 36;
var ty = p.y - Math.sin(p.aim) * 36 + 20;
var dx = tx - pt.x, dy = ty - pt.y;
var d = Math.hypot(dx, dy) || 1;
var moving = false;
if (d > 24) {
var sp2 = Math.min(d * 4.2, 320);
pt.x += dx / d * sp2 * dt;
pt.y += dy / d * sp2 * dt;
moving = true;
pt.face = Math.atan2(dy, dx);
}
pt.walkPhase = (pt.walkPhase || 0) + dt * (moving ? 13 : 3);
pt.shootT -= dt;
if (pt.shootT <= 0) {
var tgt = null, bd = 280 * 280;
for (var i = 0; i < g.enemies.length; i++) {
var e = g.enemies[i];
if (!e.alive || e.isFighter || e.spawnAnim > 0) continue;
var dd = (e.x - pt.x) * (e.x - pt.x) + (e.y - pt.y) * (e.y - pt.y);
if (dd < bd) { bd = dd; tgt = e; }
}
if (tgt) {
pt.face = Math.atan2(tgt.y - pt.y, tgt.x - pt.x);
var ang = new E.Bullet(pt.x + Math.cos(pt.face) * 10, pt.y + Math.sin(pt.face) * 10,
pt.face, 340, 5, '#d8ccb0', 3.4, 'player', { kind: 'stone', life: 1 });
ang.eid = 0;
g.bullets.push(ang);
pt.shootT = 1.25;
if (Math.random() < 0.35) US.audio.sfx('shoot');
} else {
pt.shootT = 0.4;
}
}
}
function killEnemy(g, e) {
e.alive = false;
var def = e.isFighter ? null : e.def;
if (e.isFighter) {
fighterDied(g, e, e.lastHitBy);
return;
}
boomFx(g, e.x, e.y, e.color, e.isBoss ? 60 : 14, e.isBoss ? 320 : e.radius * 8);
bloodDecal(g, e);
US.audio.sfx(e.isBoss ? 'explode' : 'kill');
g.kills++;
g.combo++;
g.comboTimer = 3;
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);
})) {
announce(g, '📖 ' + bDef.name[metaLang()] + ' ✓ +' + bDef.gem +
L({ en: ' flames', vi: ' lửa' }, metaLang()), '#ffd24a');
US.audio.sfx('buy');
}
}
questProgress(g, 'kill', 1);
if (e.isBoss) questProgress(g, 'boss', 1);
US.save.stat('totalKills', 1);
checkAch(g);
var mult = (1 + Math.min(g.combo, 50) * 0.02) * (g.onFire ? 2 : 1);
g.score += Math.round(def.score * mult);
// ultimate + heat
g.player.ult = Math.min(100, g.player.ult + 4 * g.player.stats.ultRate);
addHeat(g, e.isBoss ? 40 : def.value * 7);
// coins
var cn = util.randInt(def.coin[0], def.coin[1]);
var per = Math.max(1, Math.round(cn / Math.min(cn, 3)));
var left = cn;
while (left > 0) {
var amt = Math.min(per, left);
left -= amt;
g.pickups.push(new E.Pickup(e.x + util.rand(-14, 14), e.y + util.rand(-14, 14), 'coin', amt));
}
if (Math.random() < 0.04) g.pickups.push(new E.Pickup(e.x, e.y, 'heal', 20));
if (Math.random() < 0.016) g.pickups.push(new E.Pickup(e.x, e.y, 'bomb', 1));
// splitters
if (def.splits) {
for (var i = 0; i < def.splits.count; i++) {
var a = Math.PI * 2 * i / def.splits.count + Math.random();
spawnEnemyAt(g, def.splits.into, e.x + Math.cos(a) * 24, e.y + Math.sin(a) * 24, { hp: 1 });
}
}
if (e.isBoss) {
announce(g, 'BOSS DOWN', '#ffb938');
g.shake = 16;
US.save.addGems(2);
if (!g.petDropped && !US.save.get().pet && Math.random() < 0.35) {
g.petDropped = true;
g.pickups.push(new E.Pickup(e.x, e.y, 'egg', 1));
}
g.pickups.push(new E.Pickup(e.x, e.y, 'heal', 40));
g.pickups.push(new E.Pickup(e.x + 30, e.y, 'bomb', 1));
}
}
function explodeAt(g, x, y, radius, dmg) {
boomFx(g, x, y, '#ffb35e', 18, radius * 1.4);
g.rings.push({ x: x, y: y, r: 6, maxR: radius, life: 0.28, maxLife: 0.28, color: '#ffb35e', dmg: dmg, hit: [] });
}
/* ================================================================
Heat / On Fire
================================================================ */
function addHeat(g, v) {
if (g.onFire) return;
if (g.hangover > 0) v *= 0.5;
g.heat = Math.min(100, g.heat + v);
if (g.heat >= 100) igniteOnFire(g);
}
function igniteOnFire(g) {
g.onFire = true;
g.onFireT = 7;
g.heat = 100;
announce(g, US.i18n.t('announce.onFire'), '#ffb938', true);
US.audio.sfx('onfire');
}
function endOnFire(g) {
g.onFire = false;
g.heat = 0;
g.hangover = 5;
announce(g, US.i18n.t('announce.hangover'), '#9c8a72');
}
/* ================================================================
Player damage / death / revive
================================================================ */
function spdFactor(p) { return Math.min(8, p.speedBase * p.stats.speedMul / 40); }
function inTar(g, x, y) {
var tp = g.decor && g.decor.tarPits;
if (!tp) return false;
for (var i = 0; i < tp.length; i++) {
if (util.dist2(x, y, tp[i].x, tp[i].y) < tp[i].r * tp[i].r * 0.72) return true;
}
return false;
}
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;
p.hp -= dmg;
p.hurtT = 0.4;
g.flashRed = 0.25;
g.shake = Math.min(g.shake + 6, 14);
g.combo = 0;
US.audio.sfx('hurt');
US.entities.floatText(p.x, p.y - 24, '-' + Math.round(dmg), '#ff5e7a');
if (p.hp <= 0) {
p.hp = 0;
p.alive = false;
boomFx(g, p.x, p.y, p.skin.body, 40, 280);
if (g.mode === 'arena') {
g.arenaDeaths = (g.arenaDeaths || 0) + 1;
g.playerRespawnT = 2.2;
} else {
offerReviveOrEnd(g);
}
}
}
function offerReviveOrEnd(g) {
var save = US.save.get();
if (!g.reviveUsed && save.coins >= REVIVE_COST) {
g.state = 'revive';
g.reviveCountdown = 6;
if (US.ui) US.ui.showRevive(REVIVE_COST, g.reviveCountdown);
} else {
endRun(g);
}
}
function acceptRevive(g) {
g.reviveUsed = true;
US.save.addCoins(-REVIVE_COST);
var p = g.player;
p.alive = true;
p.hp = p.stats.maxHp;
p.x = W / 2; p.y = H / 2;
p.invuln = 2.5;
g.state = 'playing';
boomFx(g, p.x, p.y, '#7df9ff', 30, 240);
}
function endRun(g) {
g.state = 'over';
g.over = true;
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);
US.audio.sfx('lose');
if (US.ui) US.ui.gameOver({
score: g.score,
wave: g.wave,
kills: g.kills,
bestCombo: g.bestCombo,
coins: g.runCoins,
record: record,
biome: g.biome || 'jungle',
petName: US.save.get().pet ? US.save.get().pet.name : null,
skinColor: g.player.skin.body
}, g.mode);
}
/* ================================================================
Player actions (called from input layer)
================================================================ */
function castUlt(g) {
var p = g.player;
if (!p.alive || g.state !== 'playing' || p.ult < 100) return;
p.ult = 0;
p.invuln = Math.max(p.invuln, 1);
g.rings.push({ x: p.x, y: p.y, r: 10, maxR: 430, life: 0.65, maxLife: 0.65, color: p.skin.body, dmg: 130, nova: true, hit: [] });
g.shake = 12;
US.audio.sfx('ult');
questProgress(g, 'ult', 1);
}
function useBomb(g) {
var p = g.player;
if (!p.alive || g.state !== 'playing' || p.bombs <= 0) return;
p.bombs--;
g.rings.push({ x: p.x, y: p.y, r: 10, maxR: 520, life: 0.5, maxLife: 0.5, color: '#ff8a3d', dmg: 220, bomb: true, hit: [] });
// wipe enemy bullets
for (var i = 0; i < g.bullets.length; i++) {
if (g.bullets[i].owner !== 'player') g.bullets[i].alive = false;
}
g.shake = 16;
US.audio.sfx('bomb');
}
/* ================================================================
FX helpers
================================================================ */
function boomFx(g, x, y, color, n, power) {
US.entities.spawnParticles(null, x, y, color, n, power ? power / 60 : 4, 4);
g.rings.push({ x: x, y: y, r: 4, maxR: power ? power : 60, life: 0.22, maxLife: 0.22, color: color, dmg: 0, hit: [] });
}
function announce(g, text, color, big) {
g.announces.push({ text: text, color: color || '#fff', t: 0, dur: big ? 2.2 : 1.6, big: !!big });
}
/* ================================================================
Main update
================================================================ */
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);
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;
announce(g, L({
en: '🌑 BLOOD MOON WEEKEND — amber ×2, herds swell!',
vi: '🌑 HUYẾT NGŨ CUỐI TUẦN — hổ phách ×2, thú về đông!'
}, metaLang()), '#ff6b6b');
}
}
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;
if (g.announces[a].t > g.announces[a].dur) g.announces.splice(a, 1);
}
if (g.state !== 'playing') return; // cards/paused/revive freeze the world
g.time += dt;
var p = g.player;
// ---- player ----
if (p.alive) {
var inp = US.input;
var mx = (inp.right ? 1 : 0) - (inp.left ? 1 : 0);
var my = (inp.down ? 1 : 0) - (inp.up ? 1 : 0);
if (inp.moveVec) { mx += inp.moveVec.x; my += inp.moveVec.y; }
var ml = Math.hypot(mx, my);
if (ml > 1) { mx /= ml; my /= ml; }
p.moving = ml > 0.15;
if (p.moving) {
p.walkPhase = (p.walkPhase || 0) + dt * (11 + spdFactor(p));
}
var spd = p.speedBase * p.stats.speedMul;
if (inTar(g, p.x, p.y)) spd *= 0.55;
p.x += mx * spd * dt;
p.y += my * spd * dt;
clampToWorld(p);
if (p.invuln > 0) p.invuln -= dt;
if (p.hurtT > 0) p.hurtT -= dt;
if (p.fireT > 0) p.fireT = Math.max(0, p.fireT - dt);
// molten ground burns in the ashlands
if (g.biome === 'ash' && p.alive && inCrack(g, p.x, p.y)) {
p.hp -= 7 * dt;
if (Math.random() < dt * 6) {
US.entities.spawnParticles(null, p.x, p.y, '#ff8a3d', 1, 0.7, 3);
US.entities.floatText(p.x, p.y - 24, L({ en: 'hot!', vi: 'nóng!' }, metaLang()), '#ff9b6b');
}
if (p.hp <= 0) hurtPlayer(g, 1);
}
// ON FIRE ember trail behind the hero
if (g.onFire && Math.random() < 0.45) {
US.entities.spawnParticles(null,
p.x - Math.cos(p.aim) * 14, p.y - Math.sin(p.aim) * 14,
Math.random() < 0.5 ? '#ff8a3d' : '#ffb938', 1, 0.9, 3);
}
// running dust + alternating footprints
if (p.moving) {
p.dustT = (p.dustT || 0) - dt;
if (p.dustT <= 0) {
p.dustT = 0.13;
US.entities.spawnParticles(null,
p.x - Math.cos(p.aim) * 10, p.y - Math.sin(p.aim) * 10,
'#9a7d52', 1, 0.7, 3);
var pa = p.aim || 0;
g.prints.push({
x: p.x - Math.cos(pa) * 6 + Math.cos(pa + Math.PI / 2) * (p.printSide ? -5 : 5),
y: p.y - Math.sin(pa) * 6 + Math.sin(pa + Math.PI / 2) * (p.printSide ? -5 : 5),
rot: pa, life: 4
});
p.printSide = !p.printSide;
if (g.prints.length > 40) g.prints.shift();
}
}
if (p.stats.regen > 0) p.hp = Math.min(p.stats.maxHp, p.hp + p.stats.regen * dt);
// touch: right stick steers the aim point around the player
if (inp.aimVec && Math.hypot(inp.aimVec.x, inp.aimVec.y) > 0.15) {
inp.aimX = p.x + inp.aimVec.x * 300;
inp.aimY = p.y + inp.aimVec.y * 300;
}
p.aim = Math.atan2(inp.aimY - p.y, inp.aimX - p.x);
// AUTO-AIM: hunter instinct locks the nearest prey in range
g.autoTarget = null;
if (US.save.get().settings.autoAim && p.alive) {
var tgt2 = nearestEnemy(g, p.x, p.y, 700, null);
if (tgt2) {
g.autoTarget = tgt2;
// only steal the crosshair when the player isn't actively aiming
if (!inp.firing) p.aim = Math.atan2(tgt2.y - p.y, tgt2.x - p.x);
}
}
var wantFire = inp.firing || US.save.get().settings.autofire;
p.cooldown -= dt;
if (wantFire && p.cooldown <= 0) {
var st = p.dpsStats();
p.cooldown = 1 / st.rof;
fireWeaponBullets(g, p, p.aim, 1);
}
} else if (g.mode === 'arena') {
g.playerRespawnT -= dt;
if (g.playerRespawnT <= 0) {
p.alive = true;
p.hp = p.maxHp || p.stats.maxHp;
p.hp = p.stats.maxHp;
p.invuln = 2;
p.x = util.rand(120, W - 120);
p.y = util.rand(120, H - 120);
boomFx(g, p.x, p.y, p.skin.body, 16, 160);
}
}
// ---- combo decay ----
if (g.comboTimer > 0) {
g.comboTimer -= dt;
if (g.comboTimer <= 0) g.combo = 0;
}
// ---- heat decay / onfire ----
if (!g.onFire) {
g.sinceKill += dt;
if (g.hangover > 0) g.hangover -= dt;
if (g.sinceKill > 1.3 && g.heat > 0) g.heat = Math.max(0, g.heat - 9 * dt);
} else {
g.onFireT -= dt;
g.heat = Math.max(0, 100 * (g.onFireT / 7));
if (g.onFireT <= 0) endOnFire(g);
}
// ---- campaign spawning ----
if (g.mode === 'campaign') {
for (var q = 0; q < g.spawnQueue.length; q++) {
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--;
}
}
var live = 0;
for (var li = 0; li < g.enemies.length; li++) if (g.enemies[li].alive && !g.enemies[li].isFighter) live++;
if (g.waveActive && g.spawnQueue.length === 0 && live === 0) {
g.waveActive = false;
onWaveClear(g);
}
} else {
updateBots(g, dt);
g.arenaTime += dt;
}
// ---- enemies ----
for (var ei = 0; ei < g.enemies.length; ei++) {
var e = g.enemies[ei];
if (!e.alive) continue;
if (e.isFighter) { if (e.hitFlash > 0) e.hitFlash -= dt; continue; }
e.update(g, dt);
}
// ---- dino-film footstep tremors ----
updateBossStomps(g, dt);
// ---- bullets ----
for (var bi = 0; bi < g.bullets.length; bi++) {
var bl = g.bullets[bi];
if (!bl.alive) continue;
bl.update(g, dt);
}
filterDead(g);
// ---- rings (novas/bombs/explosions) ----
for (var ri = g.rings.length - 1; ri >= 0; ri--) {
var rg = g.rings[ri];
rg.life -= dt;
var prog = 1 - rg.life / rg.maxLife;
rg.r = rg.maxR * easeOut(prog);
if (rg.dmg > 0) {
for (var re = 0; re < g.enemies.length; re++) {
var ee = g.enemies[re];
if (!ee.alive || rg.hit.indexOf(ee.eid) >= 0) continue;
if (ee.spawnAnim > 0 && !rg.nova) continue;
var dd = Math.hypot(ee.x - rg.x, ee.y - rg.y);
if (dd < rg.r + ee.radius) {
rg.hit.push(ee.eid);
if (ee.isFighter) {
hurtFighter(g, ee, rg.dmg, rg.nova ? 0 : rg.ownerEid || 0);
} else {
damageEnemy(g, ee, rg.dmg, false, true);
}
}
}
}
if (rg.life <= 0) g.rings.splice(ri, 1);
}
// ---- pickups ----
for (var pi = 0; pi < g.pickups.length; pi++) {
if (g.pickups[pi].alive) g.pickups[pi].update(g, dt);
}
// ---- day/night cycle (60s; runs start at noon) ----
var cyc = (g.time % 60) / 60;
var dl = Math.cos(cyc * Math.PI * 2);
g.daylight = util.clamp(dl, 0, 1);
g.darkness = (1 - g.daylight) * 0.46;
if (!g.wasNight && g.daylight < 0.12) {
g.wasNight = true;
announce(g, L({ en: '🌙 Night falls — beasts grow bolder', vi: '🌙 Đêm xuống — thú liều hơn' }, metaLang()), '#9fb4ff');
}
if (g.wasNight && g.daylight > 0.5) {
g.wasNight = false;
announce(g, L({ en: '☀️ Dawn breaks', vi: '☀️ Trời sáng rồi' }, metaLang()), '#ffe4bd');
}
// ---- tropical weather front ----
g.weatherT -= dt;
if (g.weather === 'clear') {
if (g.weatherT <= 0) {
g.weather = 'rain';
g.weatherT = util.rand(14, 26);
announce(g, L({ en: '🌧️ Rain sweeps the valley', vi: '🌧️ Mưa trút xuống thung lũng' }, metaLang()), '#9fc4ff');
}
} else {
// lightning roll while it pours
if (Math.random() < dt * 0.22 && g.thunderT < 0) {
g.flashWhite = 0.55;
g.thunderT = 0.18;
}
if (g.thunderT >= 0) {
g.thunderT -= dt;
if (g.thunderT <= 0) { US.audio.sfx('thunder'); g.thunderT = -1; }
}
if (g.weatherT <= 0) {
g.weather = 'clear';
g.weatherT = util.rand(30, 60);
announce(g, L({ en: '🌤️ The rain moves on', vi: '🌤️ Mưa tạnh rồi' }, metaLang()), '#ffe4bd');
}
}
if (g.flashWhite > 0) g.flashWhite -= dt * 2.2;
// ---- ambient wilderness: distant calls every so often ----
g.roarT -= dt;
if (g.roarT <= 0) {
g.roarT = util.rand(14, 30);
US.audio.distantRoar();
}
// ---- fx ----
updateDecals(g, dt);
for (var prn = g.prints.length - 1; prn >= 0; prn--) {
g.prints[prn].life -= dt;
if (g.prints[prn].life <= 0) g.prints.splice(prn, 1);
}
US.entities.updateParticles(dt);
US.entities.updateFloaters(dt);
if (g.shake > 0) g.shake = Math.max(0, g.shake - 30 * dt);
if (g.flashRed > 0) g.flashRed -= dt;
}
/* Jurassic-style ground tremor: every stride of an alpha shakes
the screen a little — harder the closer it stalks to you. */
function updateBossStomps(g, dt) {
var p = g.player;
for (var i = 0; i < g.enemies.length; i++) {
var b = g.enemies[i];
if (!b.alive || !b.isBoss || b.spawnAnim > 0 || g.state !== 'playing') continue;
if (b.stepT === undefined) b.stepT = util.rand(0.15, 0.5); // desync twins
b.stepT -= dt;
if (b.stepT > 0) continue;
var stride = util.clamp(48 / b.radius, 0.42, 0.72);
b.stepT = stride;
// proximity: full force up close, faint rumble across the arena
var d = Math.hypot(p.x - b.x, p.y - b.y);
var prox = util.clamp(1 - d / 950, 0.22, 1);
// tremor
g.shake = Math.min(g.shake + 2.6 * prox, 10);
// dust burst under the striking foot
g.rings.push({
x: b.x + util.rand(-b.radius * 0.5, b.radius * 0.5),
y: b.y + b.radius * 0.6,
r: 4,
maxR: 26 + b.radius * 0.5,
life: 0.32, maxLife: 0.32,
color: '#8f7448', dmg: 0, hit: [], stomp: true
});
US.entities.spawnParticles(null, b.x, b.y + b.radius * 0.5, '#8f7448', 3, 1.1, 3);
// deep thud, louder up close
if (prox > 0.3) US.audio.sfx('stomp', 0.06 + 0.16 * prox);
g.stompCount++;
}
}
function hurtFighter(g, f, dmg, byEid) {
if (!f.alive || f.spawnAnim > 0) return;
if (byEid === 0 && !g.player.alive) return;
f.hp -= dmg;
f.hitFlash = 0.1;
if (f.hp <= 0) {
f.lastHitBy = byEid;
if (byEid === 0) {
fighterDied(g, f, 'player');
} else {
fighterDied(g, f, byEid);
}
}
}
function easeOut(t) { return 1 - (1 - t) * (1 - t); }
function clampToWorld(o) {
o.x = util.clamp(o.x, WALL + o.radius, W - WALL - o.radius);
o.y = util.clamp(o.y, WALL + o.radius, H - WALL - o.radius);
}
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');
US.entities.floatText(g.player.x, g.player.y - 40, '+' + bonus + ' amber', '#ffb938', true);
US.audio.sfx('win');
// short breather then card pick
g.state = 'cards';
setTimeout(function () {
var live = US.game && US.game.get ? US.game.get() : null;
if (live === g && !g.over && US.ui) US.ui.showCardPick(rollCards(g), function (card) {
if ((US.game.get() || null) !== g || g.over) return;
g.player.takeCard(card);
g.state = 'playing';
scheduleNextWave(g);
});
}, 900);
}
function rollCards(g) {
var p = g.player;
var pool = US.CARDS.filter(function (c) {
var taken = p.cards.filter(function (id) { return id === c.id; }).length;
return taken < c.max;
});
var out = [];
var weights = { common: 100, rare: 45, epic: 16 };
while (out.length < 3 && pool.length) {
var total = 0;
pool.forEach(function (c) { total += weights[c.rarity]; });
var roll = Math.random() * total;
var chosen = pool[0], acc = 0;
for (var i = 0; i < pool.length; i++) {
acc += weights[pool[i].rarity];
if (roll <= acc) { chosen = pool[i]; break; }
}
out.push(chosen);
pool.splice(pool.indexOf(chosen), 1);
}
return out;
}
function nearestEnemy(g, x, y, maxDist, excludeIds) {
var best = null, bd = maxDist * maxDist;
for (var i = 0; i < g.enemies.length; i++) {
var e = g.enemies[i];
if (!e.alive || (excludeIds && excludeIds.indexOf(e.eid) >= 0)) continue;
if (e.spawnAnim > 0) continue;
var d2v = util.dist2(x, y, e.x, e.y);
if (d2v < bd) { bd = d2v; best = e; }
}
return best;
}
function filterDead(g) {
var ne = [];
for (var i = 0; i < g.enemies.length; i++) {
var e = g.enemies[i];
if (e.alive || e.isFighter) ne.push(e); // fighters stay listed (respawn logic)
}
g.enemies = ne;
var nb = [];
for (var j = 0; j < g.bullets.length; j++) if (g.bullets[j].alive) nb.push(g.bullets[j]);
g.bullets = nb;
var np = [];
for (var k = 0; k < g.pickups.length; k++) if (g.pickups[k].alive) np.push(g.pickups[k]);
g.pickups = np;
}
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');
} else if (pk.kind === 'heal') {
p.hp = Math.min(p.stats.maxHp, p.hp + pk.amount);
US.entities.floatText(pk.x, pk.y - 10, '+' + pk.amount + ' HP', '#8affc1');
US.audio.sfx('heal');
} else if (pk.kind === 'bomb') {
p.bombs++;
US.entities.floatText(pk.x, pk.y - 10, '+1 💣', '#ff8a3d');
} else if (pk.kind === 'egg') {
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: '🥚 ' + 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);
}
if (pk.kind === 'coin') {
US.save.stat('totalAmber', 1);
questProgress(g, 'collect', 1);
checkAch(g);
}
US.audio.sfx('pickup');
}
/* ================================================================
Rendering
================================================================ */
function render(g, ctx, vw, vh) {
var scale = Math.min(vw / W, vh / H);
var ox = (vw - W * scale) / 2;
var oy = (vh - H * scale) / 2;
var shx = 0, shy = 0;
var sSet = US.save.get().settings;
var sAmt = (!sSet.shake) ? 0 : (sSet.shakeAmt == null ? 1 : sSet.shakeAmt);
if (sAmt > 0 && g.shake > 0) {
shx = util.rand(-g.shake, g.shake) * sAmt;
shy = util.rand(-g.shake, g.shake) * sAmt;
}
ctx.fillStyle = '#07060d';
ctx.fillRect(0, 0, vw, vh);
ctx.save();
ctx.translate(ox + shx, oy + shy);
ctx.scale(scale, scale);
drawArena(g, ctx);
// blood stains from past kills + fresh footprints + biome ground fx
drawDecals(ctx, g);
drawPrints(ctx, g);
US.art.drawBiomeFx(ctx, g);
// rings under everything
for (var i = 0; i < g.rings.length; i++) drawRing(ctx, g.rings[i]);
// pickups
for (var pk = 0; pk < g.pickups.length; pk++) drawPickup(ctx, g.pickups[pk]);
// enemies / bots
for (var ei = 0; ei < g.enemies.length; ei++) {
var e = g.enemies[ei];
if (!e.alive) continue;
if (e.isFighter) drawBot(ctx, e);
else if (e.isBoss) drawBoss(ctx, e);
else drawEnemy(ctx, e);
}
// bullets
for (var bi = 0; bi < g.bullets.length; bi++) drawBullet(ctx, g.bullets[bi]);
// player
if (g.player.alive) drawPlayer(ctx, g.player, g);
US.entities.drawParticles(ctx);
US.entities.drawFloaters(ctx);
// drifting sky: cloud shadows, circling vulture, floating ash
if (sSet.quality !== 'low') {
US.art.drawCloudShadows(ctx, W, H, g.time);
US.art.drawVultureShadow(ctx, W, H, g.time);
US.art.drawAshMotes(ctx, W, H, g.time);
US.art.drawNight(ctx, W, H, g);
if (g.weather === 'rain') US.art.drawRain(ctx, W, H, g.time, 1);
if (g.bloodMoon) {
ctx.fillStyle = 'rgba(150,18,18,' + (0.07 + (g.darkness || 0) * 0.05).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
}
}
ctx.restore();
drawHud(g, ctx, vw, vh, scale, ox, oy);
if (g.flashRed > 0) {
ctx.fillStyle = 'rgba(255,46,80,' + (g.flashRed * 0.8) + ')';
ctx.fillRect(0, 0, vw, vh);
}
if (g.flashWhite > 0) {
ctx.fillStyle = 'rgba(240,246,255,' + (g.flashWhite * 0.85).toFixed(3) + ')';
ctx.fillRect(0, 0, vw, vh);
}
}
function drawArena(g, ctx) {
US.art.drawGround(g, ctx);
}
function glowShape(ctx, color, blur) {
ctx.shadowColor = color;
ctx.shadowBlur = blur || 14;
}
function drawPlayer(ctx, p, g) {
if (!p.alive) return;
ctx.save();
ctx.translate(p.x, p.y);
// on-fire aura (gameplay juice stays here)
if (g.onFire) {
var pulse = 1 + Math.sin(g.time * 14) * 0.15;
ctx.strokeStyle = 'rgba(255,138,61,0.85)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(0, 0, 24 * pulse, 0, Math.PI * 2);
ctx.stroke();
} else if (g.hangover > 0) {
ctx.strokeStyle = 'rgba(156,138,114,0.35)';
ctx.setLineDash([4, 6]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 23, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// spawn shield
if (p.invuln > 0) {
ctx.strokeStyle = 'rgba(232,220,196,' + (0.25 + 0.2 * Math.sin(g.time * 20)) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 27, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
US.art.drawHuman(ctx, {
x: p.x, y: p.y,
radius: p.radius,
aim: p.aim,
walkPhase: p.walkPhase || 0,
moving: !!p.moving,
color: p.skin.body,
body: p.skin.body,
trim: p.skin.trim,
weaponKind: p.weapon.kind,
hurtT: p.hurtT > 0 ? p.hurtT : 0,
fireT: p.fireT > 0 ? p.fireT : 0,
scale: 1.18,
torch: (g.darkness || 0) > 0.22,
seedF: 1.3
}, g.time);
}
function drawEnemy(ctx, e) {
if (e.spawnAnim > 0) {
ctx.save();
ctx.translate(e.x, e.y);
ctx.strokeStyle = e.color;
ctx.globalAlpha = 0.7;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, e.radius * (1.6 - e.spawnAnim), 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return;
}
var art = (e.def && e.def.art) || 'raptor';
switch (art) {
case 'trike': US.art.drawTriceratops(ctx, e, g_time(e)); break;
case 'dilo': US.art.drawDilo(ctx, e, g_time(e)); break;
case 'stego': US.art.drawStego(ctx, e, g_time(e)); break;
case 'pachy': US.art.drawPachy(ctx, e, g_time(e)); break;
case 'ptero': US.art.drawPtero(ctx, e, g_time(e), false); break;
case 'pteroChick': US.art.drawPtero(ctx, e, g_time(e), true); break;
case 'alphaRaptor':
drawAlphaRaptor(ctx, e);
break;
default:
US.art.drawRaptor(ctx, e, g_time(e), { alpha: false });
}
if (e.elite && art !== 'alphaRaptor') {
ctx.strokeStyle = '#ffb938';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius + 5, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawAlphaRaptor(ctx, e) {
// alpha = raptor painter at full scale + crown ring handled inside opts
US.art.drawRaptor(ctx, e, g_time(e), { alpha: true });
ctx.strokeStyle = '#ffb938';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius + 7, 0, Math.PI * 2);
ctx.stroke();
}
function g_time() {
return performance.now() / 1000;
}
function drawBoss(ctx, b) {
if (b.spawnAnim > 0) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.strokeStyle = b.color;
ctx.globalAlpha = 0.8;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, b.radius * (2 - b.spawnAnim), 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return;
}
var art = (b.bossDef && b.bossDef.art) || 'rex';
if (art === 'spino') US.art.drawSpino(ctx, b, g_time(b));
else if (art === 'giga') US.art.drawGiga(ctx, b, g_time(b));
else US.art.drawRex(ctx, b, g_time(b));
}
function drawBot(ctx, f) {
if (!f.alive) return;
if (f.spawnAnim > 0) {
ctx.save();
ctx.translate(f.x, f.y);
ctx.strokeStyle = f.color;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(0, 0, f.radius * (1.8 - f.spawnAnim * 2), 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return;
}
US.art.drawHuman(ctx, {
x: f.x, y: f.y,
radius: f.radius,
aim: f.face || 0,
walkPhase: f.walkPhase || 0,
moving: true,
color: f.color,
trim: US.art.shade(f.color, 40),
weaponKind: f.weapon.kind,
label: f.name.slice(0, 4),
hurtT: f.hitFlash > 0 ? f.hitFlash * 3 : 0,
seedF: f.eid || 0
}, g_time(f));
// hp bar under the name
if (f.hp < f.maxHp) {
ctx.fillStyle = 'rgba(20,14,8,0.72)';
ctx.fillRect(f.x - 14, f.y - f.radius - 8, 28, 4);
ctx.fillStyle = f.color;
ctx.fillRect(f.x - 14, f.y - f.radius - 8, 28 * Math.max(0, f.hp / f.maxHp), 4);
}
}
function drawBullet(ctx, b) {
US.art.drawBullet(ctx, b);
}
function drawPickup(ctx, pk) {
var blink = pk.life < 2.5 && Math.floor(pk.life * 8) % 2 === 0;
US.art.drawPickup(ctx, pk, !blink);
}
function drawRing(ctx, rg) {
var alpha = rg.life / rg.maxLife;
ctx.strokeStyle = rg.color;
ctx.globalAlpha = alpha * 0.9;
ctx.lineWidth = rg.nova || rg.bomb ? 8 : 4;
glowShape(ctx, rg.color, 20);
ctx.beginPath();
ctx.arc(rg.x, rg.y, rg.r, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
/* ---------------- HUD ---------------- */
function drawHud(g, ctx, vw, vh, scale, ox, oy) {
ctx.save();
// HUD anchored to the scaled play area
ctx.translate(ox, oy);
var W2 = vw / scale, H2 = vh / scale;
var pad = 18;
var p = g.player;
/* ---------- shared bits ---------- */
function rrect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function panel(x, y, w, h) {
ctx.fillStyle = 'rgba(14,9,5,0.74)';
rrect(x, y, w, h, 10);
ctx.fill();
ctx.strokeStyle = 'rgba(255,196,130,0.26)';
ctx.lineWidth = 1.5;
rrect(x, y, w, h, 10);
ctx.stroke();
}
function txt(str, x, y, size, col, align, bold) {
ctx.fillStyle = col || '#f3e7cf';
ctx.font = (bold ? 'bold ' : '') + size + 'px system-ui, sans-serif';
ctx.textAlign = align || 'left';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.7)';
ctx.shadowBlur = 3;
ctx.fillText(str, x, y);
ctx.shadowBlur = 0;
}
// soft scrim so panels pop against bright ground
var scrim = ctx.createLinearGradient(0, 0, 0, 120);
scrim.addColorStop(0, 'rgba(10,6,3,0.5)');
scrim.addColorStop(1, 'rgba(10,6,3,0)');
ctx.fillStyle = scrim;
ctx.fillRect(0, 0, W2, 120);
/* ---------- top-left: vitals card ---------- */
var cw = 330, ch = 96;
panel(pad, pad, cw, ch);
var cx0 = pad + 14, cy0 = pad + 14, cwid = cw - 28;
// HP row
txt('HP', cx0, cy0 + 8, 11, 'rgba(243,231,207,0.75)', 'left', true);
var hpPct = p.hp / p.stats.maxHp;
var hx = cx0 + 30, hy = cy0, hw = cwid - 30, hh = 17;
ctx.fillStyle = '#241408';
rrect(hx, hy, hw, hh, 5); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1;
rrect(hx, hy, hw, hh, 5); ctx.stroke();
var hpCol = hpPct > 0.55 ? '#7fb84a' : (hpPct > 0.3 ? '#e8a33d' : '#e04b3a');
ctx.save();
rrect(hx, hy, Math.max(hh * 0.9, hw * Math.max(0, hpPct)), hh, 5);
ctx.clip();
var hg = ctx.createLinearGradient(hx, hy, hx, hy + hh);
hg.addColorStop(0, hpCol);
hg.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = hg;
ctx.fillRect(hx, hy, hw, hh);
ctx.restore();
txt(Math.ceil(p.hp) + ' / ' + p.stats.maxHp, hx + hw / 2, hy + hh / 2 + 1, 12, '#fff', 'center', true);
// heat / ON FIRE row
var ty = cy0 + 25, th = 13;
var fireW = hw * (g.heat / 100);
if (g.onFire) fireW += Math.sin(g.time * 12) * 3;
ctx.fillStyle = '#241408';
rrect(cx0, ty, hw, th, 4); ctx.fill();
ctx.save();
rrect(cx0, ty, Math.max(th * 0.8, Math.min(hw, fireW)), th, 4);
ctx.clip();
ctx.fillStyle = g.onFire ? '#ffb43d' : '#ff8a3d';
ctx.fillRect(cx0, ty, hw, th);
ctx.restore();
txt(
g.onFire ? US.i18n.t('hud.onFire') : (g.hangover > 0 ? US.i18n.t('announce.hangover') : 'HEAT'),
cx0 + hw / 2, ty + th / 2 + 1, 10,
g.onFire ? '#1c130a' : '#ffe4bd', 'center', true);
// weapon + bombs row
var wy = cy0 + 50;
var wname = (p.weapon && p.weapon.name ? p.weapon.name : '').toUpperCase();
txt(wname, cx0, wy + 6, 11, '#e8dcc4', 'left', true);
var maxShow = Math.min(p.bombs, 5);
for (var bi = 0; bi < maxShow; bi++) {
var bxc = cx0 + cwid - 10 - bi * 19;
ctx.fillStyle = '#3a2a16';
ctx.beginPath(); ctx.arc(bxc, wy + 6, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,196,130,0.4)'; ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#ffb938';
ctx.beginPath(); ctx.arc(bxc - 2, wy + 4, 1.6, 0, Math.PI * 2); ctx.fill();
}
if (p.bombs > 5) txt('+' + (p.bombs - 5), cx0 + cwid - 10 - 5 * 19 - 8, wy + 6, 11, '#ff8a3d', 'right', true);
/* ---------- top-center: wave banner / arena board ---------- */
ctx.textBaseline = 'middle';
if (g.mode === 'campaign') {
var midX = W2 / 2;
if (g.waveActive) {
var dnIco = (g.darkness || 0) > 0.35 ? '🌙 ' : '☀️ ';
txt(dnIco + US.i18n.t('hud.wave') + ' ' + g.wave, midX, pad + 16, 26, '#fff', 'center', true);
var remaining = g.enemies.filter(function (e) { return e.alive && !e.isFighter && !e.isBoss; }).length + g.spawnQueue.length;
if (!isBossAlive(g)) {
ctx.fillStyle = 'rgba(14,9,5,0.66)';
rrect(midX - 62, pad + 32, 124, 20, 10); ctx.fill();
txt(remaining + ' ' + US.i18n.t('hud.left'), midX, pad + 42, 12, '#e8dcc4', 'center');
}
}
// full-size boss bar
var boss = getBoss(g);
if (boss) {
var bbw = Math.min(720, W2 - 160), bbh = 21;
var bx = (W2 - bbw) / 2, by = pad + 58;
panel(bx - 5, by - 5, bbw + 10, bbh + 10);
ctx.fillStyle = '#301410';
rrect(bx, by, bbw, bbh, 6); ctx.fill();
var bp = Math.max(0, boss.hp / boss.maxHp);
ctx.save();
rrect(bx, by, Math.max(bbh, bbw * bp), bbh, 6);
ctx.clip();
ctx.fillStyle = boss.color;
ctx.fillRect(bx, by, bbw, bbh);
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(bx, by + bbh * 0.55, bbw, bbh * 0.45);
ctx.restore();
ctx.strokeStyle = 'rgba(255,196,130,0.4)';
ctx.lineWidth = 1.5;
rrect(bx, by, bbw, bbh, 6); ctx.stroke();
txt(boss.bossDef.name.toUpperCase() + ' · ' + US.i18n.t('hud.boss'),
bx + bbw / 2, by + bbh / 2 + 1, 13, '#fff', 'center', true);
txt(Math.ceil(bp * 100) + '%', bx + bbw - 12, by + bbh / 2 + 1, 12, '#ffe4bd', 'right', true);
}
} else {
// arena scoreboard card
var rows = [{ name: US.i18n.t('arena.you'), kills: g.kills, color: p.skin.body }].concat(
g.bots.map(function (b) { return { name: b.name, kills: b.kills, color: b.color }; })
);
rows.sort(function (a, b2) { return b2.kills - a.kills; });
var abw = 250, abh = rows.length * 24 + 38;
var ax = W2 / 2 - abw / 2;
panel(ax, pad, abw, abh);
var sy = pad + 20;
for (var ri = 0; ri < rows.length; ri++) {
var row = rows[ri];
txt((ri + 1) + '.', ax + 16, sy, 13, 'rgba(243,231,207,0.6)', 'left', true);
txt(row.name, ax + 36, sy, 14, row.color, 'left', true);
txt(String(row.kills), ax + abw - 16, sy, 15, '#fff', 'right', true);
sy += 24;
}
txt(US.i18n.t('arena.leader', { n: g.targetKills }), W2 / 2, sy + 2, 11, 'rgba(232,220,196,0.7)', 'center');
}
/* ---------- top-right: economy card + combo ---------- */
var ew = 172, eh = g.bloodMoon ? 80 : 62;
var ex = W2 - pad - ew;
panel(ex, pad, ew, eh);
// amber nugget icon
ctx.fillStyle = '#ffb938';
ctx.beginPath(); ctx.arc(ex + 20, pad + 18, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.arc(ex + 17, pad + 15, 2.2, 0, Math.PI * 2); ctx.fill();
txt('$' + util.fmt(g.runCoins), ex + 34, pad + 18, 18, '#ffb938', 'left', true);
txt(util.fmt(g.score) + ' PTS', ex + 20, pad + 44, 13, '#e8dcc4', 'left', true);
if (g.bloodMoon) txt('🌑 ×2 AMBER', ex + 20, pad + 66, 12, '#ff8f8f', 'left', true);
if (g.combo >= 5) {
var pulseC = 1 + Math.sin(g.time * 10) * 0.05;
var ccx = W2 - pad - ew / 2, ccy = pad + eh + 22;
ctx.save();
ctx.translate(ccx, ccy);
ctx.scale(pulseC, pulseC);
ctx.fillStyle = '#ff8a3d';
rrect(-46, -14, 92, 28, 14); ctx.fill();
ctx.strokeStyle = '#ffd24a'; ctx.lineWidth = 2;
rrect(-46, -14, 92, 28, 14); ctx.stroke();
txt('COMBO ×' + g.combo, 0, 1, 15, '#1c130a', 'center', true);
ctx.restore();
}
/* ---------- bottom-right: ult dial ---------- */
var ur = 30;
var ux = W2 - pad - ur - 6, uy = H2 - pad - ur - 6;
ctx.fillStyle = 'rgba(14,9,5,0.74)';
ctx.beginPath(); ctx.arc(ux, uy, ur, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,196,130,0.26)'; ctx.lineWidth = 1.5;
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(ux, uy, ur - 6, 0, Math.PI * 2); ctx.stroke();
if (p.ult > 0) {
if (p.ult >= 100) glowShape(ctx, p.skin.body, 14);
ctx.strokeStyle = p.ult >= 100 ? '#ffd24a' : p.skin.body;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(ux, uy, ur - 6, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, p.ult / 100));
ctx.stroke();
ctx.lineCap = 'butt';
ctx.shadowBlur = 0;
}
if (p.ult >= 100) {
var pr2 = ur + 4 + Math.sin(g.time * 6) * 2;
ctx.strokeStyle = 'rgba(255,210,74,' + (0.4 + 0.3 * Math.sin(g.time * 6)).toFixed(2) + ')';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(ux, uy, pr2, 0, Math.PI * 2); ctx.stroke();
txt(US.i18n.t('hud.ultReady'), ux, uy - ur - 14, 12, '#ffd24a', 'center', true);
txt('★', ux, uy + 1, 18, '#fff6ea', 'center', true);
} else {
txt(Math.floor(p.ult) + '%', ux, uy + 1, 13, 'rgba(255,255,255,0.85)', 'center', true);
}
// danger vignette when badly hurt
var hpr = p.hp / p.stats.maxHp;
if (p.alive && hpr < 0.3) {
var va = Math.max(0, (0.32 - hpr)) * (0.85 + 0.35 * Math.sin(g.time * 6));
var vg = ctx.createRadialGradient(
W2 / 2, H2 / 2, H2 * 0.34,
W2 / 2, H2 / 2, H2 * 0.78);
vg.addColorStop(0, 'rgba(150,18,8,0)');
vg.addColorStop(1, 'rgba(150,18,8,' + va.toFixed(3) + ')');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W2, H2);
}
ctx.restore();
}
function getBoss(g) {
for (var i = 0; i < g.enemies.length; i++) {
if (g.enemies[i].alive && g.enemies[i].isBoss) return g.enemies[i];
}
return null;
}
function isBossAlive(g) { return !!getBoss(g); }
/* ================================================================
Public API + loop
================================================================ */
var current = null;
var raf = null;
var lastT = 0;
var canvas = null;
US.game = {
get: function () { return current; },
start: function (mode, opts) {
current = newRun(mode, opts);
US.audio.resume();
US.audio.startMusic();
return current;
},
stop: function () {
current = null;
US.entities.clearParticles();
US.entities.clearFloaters();
US.audio.stopMusic();
// NOTE: the RAF loop itself stays alive — it renders the attract
// backdrop while no run is active, so the next start() needs no remount.
},
pause: function () { if (current && current.state === 'playing') { current.state = 'paused'; return true; } return false; },
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';
current.wave = (n | 0) - 1;
scheduleNextWave(current);
return true;
},
useBomb: function () { if (current) useBomb(current); },
acceptRevive: function () {
if (current && current.state === 'revive') { acceptRevive(current); if (US.ui) US.ui.hideRevive(); }
},
declineRevive: function () {
if (current && current.state === 'revive') { endRun(current); if (US.ui) US.ui.hideRevive(); }
},
isBossWave: function () { return current ? current.wave % 5 === 0 && current.waveActive : false; },
mount: function (cv) {
canvas = cv;
if (raf) cancelAnimationFrame(raf);
lastT = performance.now();
function frame(t) {
raf = requestAnimationFrame(frame);
var dt = Math.min(0.05, (t - lastT) / 1000);
lastT = t;
var ctx = cv.getContext('2d');
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var vw = cv.clientWidth, vh = cv.clientHeight;
if (cv.width !== vw * dpr || cv.height !== vh * dpr) {
cv.width = vw * dpr; cv.height = vh * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (current) {
// 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
US.art.drawMenuScene(ctx, vw, vh, t / 1000);
}
}
raf = requestAnimationFrame(frame);
},
// internals exposed for testing
_internals: {
waveBudget: waveBudget,
rollCards: function () { return current ? rollCards(current) : []; },
addHeat: function (v) { if (current) addHeat(current, v); },
REVIVE_COST: REVIVE_COST
}
};
// 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; },
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); },
enemyBullet: function (x, y, nx, ny, s, dmg, c) { if (current) enemyBullet(current, x, y, nx, ny, s, dmg, c); },
tarSlow: function (x, y) { return current ? inTar(current, x, y) : false; },
nightFast: function () { return !!(current && current.darkness > 0.45); },
spawnEnemy: function (id, x, y, m) { if (current) spawnEnemyAt(current, id, x, y, m); },
splitBoss: function (b) { if (current) splitBoss(current, b); },
fireWeapon: function (shooter, ang, scale) { if (current) fireWeaponBullets(current, shooter, ang, scale); }
};
})();