/* 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); } }; })();