Files
primal-rampage/js/game.js
T
Primal Rampage Bot 2024ea48b5 Primal Rampage — prehistoric survival wave shooter
Vanilla JS + Canvas 2D, zero dependencies.

Gameplay: campaign waves vs 12 dino species + 3 bosses, arena mode,
7 weapons, hero talents, pets, ult/bombs, card roguelite picks.
World: day/night cycle, weather fronts (rain+lightning), Blood Moon
weekends, 3 act biomes (jungle -> swamp -> ashlands with lava veins).
Meta: gems economy, upgrade tree, quests, bestiary, achievements,
shop (weapons/heroes/skins incl. premium), revive, ad-cap system,
endless scaling past wave 25, live-painted shop/bestiary icons,
pro main menu, auto-aim toggle (F). Headless smoke suite included.
2026-08-23 04:26:58 +00:00

1965 lines
65 KiB
JavaScript
Raw 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'
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);
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.spawnQueue = buildWaveQueue(g, g.wave);
// act biomes: jungle -> swamp -> ashlands
var wantBio = biomeFor(g.wave);
if (g.biome !== wantBio) enterBiome(g, wantBio);
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;
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 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;
// ---- 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 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) {
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);
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
}, 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;
g.player.bombs += g.player.stats.startBombs | 0;
if (US.save.get().pet) g.pet = makePet(g);
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;
// 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];
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);
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;
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 p = g.player;
if (pk.kind === 'coin') {
var amt = Math.max(1, Math.round(pk.amount * p.stats.coinMul));
if (g.bloodMoon) amt *= 2;
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 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();
g.pet = makePet(g);
announce(g, L({
en: '🥚 A raptor hatched: "' + names[pi] + '"!',
vi: '🥚 Raptor con nở rồi: "' + names[pi] + '"!'
}, metaLang()), '#ffd24a');
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()); },
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) {
update(current, dt);
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; },
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); },
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); }
};
})();