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.
1965 lines
65 KiB
JavaScript
1965 lines
65 KiB
JavaScript
/* 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); }
|
||
};
|
||
})();
|