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.
443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
/* Neon Rampage — entities: player, enemies, bullets, pickups, particles. */
|
|
(function () {
|
|
'use strict';
|
|
var US = (window.US = window.US || {});
|
|
var R = US.util.rand;
|
|
|
|
function hooks() { return US.entities._hooks || {}; }
|
|
|
|
/* ---------------- Player ---------------- */
|
|
function Player(opts) {
|
|
var hero = opts.hero;
|
|
this.x = opts.x; this.y = opts.y;
|
|
this.vx = 0; this.vy = 0;
|
|
this.radius = 15;
|
|
this.heroId = hero.id;
|
|
this.skin = opts.skin;
|
|
this.stats = {
|
|
maxHp: hero.hp,
|
|
speedMul: 1,
|
|
dmgMul: hero.dmgMul,
|
|
rofMul: 1,
|
|
pellets: 0, // extra projectiles from cards
|
|
pierce: 0,
|
|
crit: hero.crit || 0.05,
|
|
critMul: hero.critMul || 2,
|
|
lifesteal: 0,
|
|
bulletSpeedMul: 1,
|
|
bulletSizeMul: 1,
|
|
magnet: 90,
|
|
explosive: 0,
|
|
ricochet: 0,
|
|
coinMul: 1,
|
|
regen: 0,
|
|
thorns: 0,
|
|
ultRate: 1,
|
|
startBombs: 0
|
|
};
|
|
// permanent amber upgrades persist across every run
|
|
var UPS = US.UPGRADES || {};
|
|
var svUp = (US.save && US.save.get().upgrades) || {};
|
|
Object.keys(UPS).forEach(function (k) {
|
|
var lv = svUp[k] | 0;
|
|
if (lv > 0 && UPS[k].apply) UPS[k].apply(this.stats, lv);
|
|
}, this);
|
|
this.hp = this.stats.maxHp;
|
|
this.speedBase = hero.speed;
|
|
this.weapon = US.WEAPONS[opts.weaponId] || US.WEAPONS.pistol;
|
|
this.cooldown = 0;
|
|
this.bombs = opts.bombs || 0;
|
|
this.ult = 0;
|
|
this.invuln = 1.2; // spawn protection
|
|
this.aim = 0;
|
|
this.firing = false;
|
|
this.cards = [];
|
|
this.alive = true;
|
|
}
|
|
|
|
Player.prototype.takeCard = function (card) {
|
|
card.apply(this.stats);
|
|
if (card.onPick) card.onPick(this);
|
|
this.cards.push(card.id);
|
|
};
|
|
|
|
Player.prototype.dpsStats = function () {
|
|
var s = this.stats, w = this.weapon;
|
|
var gg = US.game && US.game.get ? US.game.get() : null;
|
|
var fire = !!(gg && gg.onFire);
|
|
return {
|
|
dmg: w.dmg * s.dmgMul * (fire ? 2 : 1),
|
|
rof: w.rof * s.rofMul * (fire ? 1.4 : 1),
|
|
pellets: w.pellets + s.pellets
|
|
};
|
|
};
|
|
|
|
/* ---------------- Enemy ---------------- */
|
|
function Enemy(def, x, y, mods) {
|
|
mods = mods || {};
|
|
this.def = def;
|
|
this.id = def.id;
|
|
this.x = x; this.y = y;
|
|
this.vx = 0; this.vy = 0;
|
|
this.radius = def.radius * (mods.size || 1);
|
|
this.maxHp = def.hp * (mods.hp || 1);
|
|
this.hp = this.maxHp;
|
|
this.speed = def.speed * (mods.speed || 1) * R(0.9, 1.12);
|
|
this.contactDmg = def.contact * (mods.dmg || 1);
|
|
this.elite = !!mods.elite;
|
|
this.color = this.elite ? '#ff2e50' : def.color;
|
|
this.hitFlash = 0;
|
|
this.slowT = 0;
|
|
this.touchCd = 0;
|
|
this.shootCd = def.ranged ? R(0.5, 1.6) : 0;
|
|
this.wobble = Math.random() * Math.PI * 2;
|
|
this.face = 0;
|
|
this.seed = Math.random() * 10;
|
|
// boss state
|
|
this.isBoss = !!mods.isBoss;
|
|
this.patterns = def.patterns || [];
|
|
this.patCd = 2;
|
|
this.charging = 0;
|
|
this.chargeDir = { x: 0, y: 0 };
|
|
this.splitStage = mods.splitStage || 0;
|
|
this.spawnAnim = 0.6;
|
|
this.alive = true;
|
|
}
|
|
|
|
Enemy.prototype.update = function (g, dt) {
|
|
if (this.spawnAnim > 0) {
|
|
this.spawnAnim -= dt;
|
|
return; // brief spawn-in grace (drawn as growing ring)
|
|
}
|
|
var p = g.player;
|
|
var dx = p.x - this.x, dy = p.y - this.y;
|
|
var d = Math.hypot(dx, dy) || 1;
|
|
var nx = dx / d, ny = dy / d;
|
|
var sp = this.speed * (this.slowT > 0 ? 0.45 : 1);
|
|
// tar pits bog everything down
|
|
if (hooks().tarSlow && hooks().tarSlow(this.x, this.y)) sp *= 0.55;
|
|
// beasts hunt with confidence under moonlight
|
|
if (hooks().nightFast && hooks().nightFast()) sp *= 1.08;
|
|
if (this.slowT > 0) this.slowT -= dt;
|
|
|
|
var ranged = this.def.ranged;
|
|
if (ranged && d < ranged.range && d > 120) {
|
|
// strafe & shoot
|
|
this.x += (-ny) * sp * 0.6 * dt + nx * sp * 0.2 * dt;
|
|
this.y += (nx) * sp * 0.6 * dt + ny * sp * 0.2 * dt;
|
|
this.face = Math.atan2(ny, nx);
|
|
this.shootCd -= dt;
|
|
if (this.shootCd <= 0) {
|
|
this.shootCd = ranged.rof * R(0.8, 1.2);
|
|
hooks().enemyBullet(this.x, this.y, nx, ny, ranged.speed, ranged.dmg, this.def.color);
|
|
}
|
|
} else if (this.isBoss) {
|
|
this.updateBoss(g, dt, nx, ny, sp, d);
|
|
} else if (this.def.dash) {
|
|
// Pachycephalosaurus: telegraph, then head-butt charge
|
|
var D = this.def.dash;
|
|
if (this.dashing > 0) {
|
|
this.dashing -= dt;
|
|
this.x += Math.cos(this.face) * D.speed * dt;
|
|
this.y += Math.sin(this.face) * D.speed * dt;
|
|
} else {
|
|
this.dashCd = (this.dashCd === undefined) ? R(1.2, D.cd) : this.dashCd - dt;
|
|
this.wobble += dt * 3;
|
|
var wobP = Math.sin(this.wobble) * 0.25;
|
|
this.x += (nx - ny * wobP) * sp * 0.75 * dt;
|
|
this.y += (ny + nx * wobP) * sp * 0.75 * dt;
|
|
this.face = Math.atan2(ny + nx * wobP, nx - ny * wobP);
|
|
if (this.dashCd <= 0 && d < 420) {
|
|
this.dashing = D.dur;
|
|
this.dashCd = D.cd;
|
|
this.face = Math.atan2(ny, nx); // lock aim at wind-up end
|
|
}
|
|
}
|
|
} else {
|
|
// slight per-enemy weave so groups look organic
|
|
this.wobble += dt * 3;
|
|
var wob = Math.sin(this.wobble) * 0.25;
|
|
this.x += (nx - ny * wob) * sp * dt;
|
|
this.y += (ny + nx * wob) * sp * dt;
|
|
this.face = Math.atan2(ny + nx * wob, nx - ny * wob);
|
|
}
|
|
|
|
if (this.charging > 0) {
|
|
this.charging -= dt;
|
|
this.x += this.chargeDir.x * this.speed * 4.2 * dt;
|
|
this.y += this.chargeDir.y * this.speed * 4.2 * dt;
|
|
this.face = Math.atan2(this.chargeDir.y, this.chargeDir.x);
|
|
}
|
|
|
|
if (this.hitFlash > 0) this.hitFlash -= dt;
|
|
if (this.touchCd > 0) this.touchCd -= dt;
|
|
// heavy beasts kick up dust as they stomp around
|
|
if (this.radius >= 17 && Math.random() < dt * 2.5) {
|
|
US.entities.spawnParticles(null, this.x, this.y, '#8f7448', 1, 0.8, 3.5);
|
|
}
|
|
|
|
// contact damage to player
|
|
if (d < this.radius + p.radius && this.touchCd <= 0 && !p.invulnHit()) {
|
|
p.hurt(this.contactDmg);
|
|
this.touchCd = 0.5;
|
|
if (p.stats.thorns > 0) g.damageEnemy(this, p.stats.thorns, false, true);
|
|
}
|
|
};
|
|
|
|
Enemy.prototype.updateBoss = function (g, dt, nx, ny, sp, dist) {
|
|
var pats = this.patterns;
|
|
this.patCd -= dt;
|
|
if (this.charging <= 0) {
|
|
this.x += nx * sp * dt;
|
|
this.y += ny * sp * dt;
|
|
}
|
|
if (this.patCd > 0) return;
|
|
var pick = pats[Math.floor(Math.random() * pats.length)];
|
|
switch (pick) {
|
|
case 'charge':
|
|
this.chargeDir = { x: nx, y: ny };
|
|
this.charging = 0.55;
|
|
this.patCd = 3.2;
|
|
break;
|
|
case 'radial': {
|
|
var n = 14;
|
|
for (var i = 0; i < n; i++) {
|
|
var a = (i / n) * Math.PI * 2 + Math.random() * 0.2;
|
|
g.enemyBullet(this.x, this.y, Math.cos(a), Math.sin(a), 200, 12, this.color);
|
|
}
|
|
this.patCd = 3.4;
|
|
break;
|
|
}
|
|
case 'aimed': {
|
|
for (var b = -1; b <= 1; b++) {
|
|
var ang = Math.atan2(ny, nx) + b * 0.18;
|
|
g.enemyBullet(this.x, this.y, Math.cos(ang), Math.sin(ang), 320, 12, this.color);
|
|
}
|
|
this.patCd = 1.6;
|
|
break;
|
|
}
|
|
case 'spiral': {
|
|
var self = this;
|
|
var count = 0;
|
|
var iv = setInterval(function () {
|
|
if (!self.alive || !US.game || US.game.over) { clearInterval(iv); return; }
|
|
var a = count * 0.55;
|
|
g.enemyBullet(self.x, self.y, Math.cos(a), Math.sin(a), 230, 11, self.color);
|
|
if (++count >= 16) clearInterval(iv);
|
|
}, 70);
|
|
this.patCd = 4.5;
|
|
break;
|
|
}
|
|
case 'spawn': {
|
|
for (var s = 0; s < 3; s++) {
|
|
var a2 = Math.random() * Math.PI * 2;
|
|
hooks().spawnEnemy('grunt', this.x + Math.cos(a2) * 70, this.y + Math.sin(a2) * 70, { hp: 0.7 });
|
|
}
|
|
this.patCd = 6;
|
|
break;
|
|
}
|
|
case 'split': {
|
|
if (this.splitStage === 0 && this.hp < this.maxHp * 0.5) {
|
|
this.alive = false; // replaced below by children via game hook
|
|
hooks().splitBoss(this);
|
|
} else {
|
|
this.patCd = 2;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
/* ---------------- Bullets ---------------- */
|
|
function Bullet(x, y, ang, speed, dmg, color, size, owner, opts) {
|
|
opts = opts || {};
|
|
this.x = x; this.y = y;
|
|
this.vx = Math.cos(ang) * speed;
|
|
this.vy = Math.sin(ang) * speed;
|
|
this.dmg = dmg;
|
|
this.color = color;
|
|
this.size = size;
|
|
this.owner = owner; // 'player' | 'enemy'
|
|
this.life = opts.life || 1.6;
|
|
this.pierceLeft = opts.pierce || 0;
|
|
this.aoe = opts.aoe || 0;
|
|
this.ricochet = opts.ricochet || 0;
|
|
this.crit = !!opts.crit;
|
|
this.kind = opts.kind || null; // sprite key for js/art.js
|
|
this.hitIds = [];
|
|
this.alive = true;
|
|
}
|
|
|
|
Bullet.prototype.update = function (g, dt) {
|
|
this.x += this.vx * dt;
|
|
this.y += this.vy * dt;
|
|
this.life -= dt;
|
|
if (this.life <= 0) { this.alive = false; return; }
|
|
|
|
if (this.owner === 'player') {
|
|
for (var i = 0; i < g.enemies.length; i++) {
|
|
var e = g.enemies[i];
|
|
if (!e.alive || e.spawnAnim > 0) continue;
|
|
if (this.hitIds.indexOf(e.eid) >= 0) continue;
|
|
var dx = e.x - this.x, dy = e.y - this.y;
|
|
if (dx * dx + dy * dy < (e.radius + this.size) * (e.radius + this.size)) {
|
|
this.hitIds.push(e.eid);
|
|
if (e.isFighter) e.lastHitBy = 0; // player kill credit
|
|
hooks().damageEnemy(e, this.dmg, this.crit);
|
|
if (this.aoe && e.alive !== undefined) hooks().explode(this.x, this.y, this.aoe, this.dmg * 0.6);
|
|
if (this.ricochet > 0) {
|
|
var nxt = hooks().nearestEnemy(this.x, this.y, 340, this.hitIds);
|
|
if (nxt) {
|
|
this.ricochet--;
|
|
var a = Math.atan2(nxt.y - this.y, nxt.x - this.x);
|
|
var spd = Math.hypot(this.vx, this.vy);
|
|
this.vx = Math.cos(a) * spd; this.vy = Math.sin(a) * spd;
|
|
this.life = Math.max(this.life, 0.8);
|
|
this.hitIds.length = 0;
|
|
this.hitIds.push(nxt.eid);
|
|
return;
|
|
}
|
|
}
|
|
if (this.pierceLeft > 0) { this.pierceLeft--; }
|
|
else { this.alive = false; }
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// enemy bullets hit the player…
|
|
var p = g.player;
|
|
var pdx = p.x - this.x, pdy = p.y - this.y;
|
|
if (pdx * pdx + pdy * pdy < (p.radius + this.size) * (p.radius + this.size)) {
|
|
if (!p.invulnHit()) p.hurt(this.dmg);
|
|
this.alive = false;
|
|
return;
|
|
}
|
|
// …and in the arena, bot bullets also hit other fighters
|
|
if (typeof this.ownerEid === 'number' && g.mode === 'arena') {
|
|
for (var fi = 0; fi < g.enemies.length; fi++) {
|
|
var f = g.enemies[fi];
|
|
if (!f.alive || !f.isFighter || f.eid === this.ownerEid || f.spawnAnim > 0) continue;
|
|
var fdx = f.x - this.x, fdy = f.y - this.y;
|
|
if (fdx * fdx + fdy * fdy < (f.radius + this.size) * (f.radius + this.size)) {
|
|
f.lastHitBy = this.ownerEid;
|
|
hooks().damageEnemy(f, this.dmg, false, true);
|
|
this.alive = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/* ---------------- Pickups ---------------- */
|
|
function Pickup(x, y, kind, amount) {
|
|
this.x = x; this.y = y;
|
|
this.kind = kind; // coin | heal | bomb
|
|
this.amount = amount || 1;
|
|
this.life = kind === 'coin' ? 12 : 10;
|
|
this.t = Math.random() * Math.PI * 2;
|
|
this.vx = R(-40, 40); this.vy = R(-40, 40);
|
|
this.alive = true;
|
|
}
|
|
|
|
Pickup.prototype.update = function (g, dt) {
|
|
this.life -= dt;
|
|
this.t += dt * 5;
|
|
if (this.life <= 0) { this.alive = false; return; }
|
|
var p = g.player;
|
|
var dx = p.x - this.x, dy = p.y - this.y;
|
|
var d = Math.hypot(dx, dy);
|
|
var range = p.stats.magnet;
|
|
if (d < range) {
|
|
var pull = 260 + (range - d) * 3;
|
|
this.x += (dx / d) * pull * dt;
|
|
this.y += (dy / d) * pull * dt;
|
|
} else {
|
|
this.x += this.vx * dt; this.y += this.vy * dt;
|
|
this.vx *= 0.92; this.vy *= 0.92;
|
|
}
|
|
if (d < p.radius + 10) {
|
|
this.alive = false;
|
|
hooks().collect(this);
|
|
}
|
|
};
|
|
|
|
/* ---------------- Particles ---------------- */
|
|
var particles = [];
|
|
|
|
function spawnParticles(g, x, y, color, n, power, size) {
|
|
for (var i = 0; i < n; i++) {
|
|
if (particles.length > 600) particles.shift();
|
|
var a = Math.random() * Math.PI * 2;
|
|
var v = R(30, 60) * power * (0.4 + Math.random());
|
|
particles.push({
|
|
x: x, y: y,
|
|
vx: Math.cos(a) * v, vy: Math.sin(a) * v,
|
|
life: R(0.25, 0.7), maxLife: 0.7,
|
|
color: color, size: size || 3
|
|
});
|
|
}
|
|
}
|
|
function updateParticles(dt) {
|
|
for (var i = particles.length - 1; i >= 0; i--) {
|
|
var pt = particles[i];
|
|
pt.x += pt.vx * dt; pt.y += pt.vy * dt;
|
|
pt.vx *= 0.94; pt.vy *= 0.94;
|
|
pt.life -= dt;
|
|
if (pt.life <= 0) particles.splice(i, 1);
|
|
}
|
|
}
|
|
function drawParticles(ctx) {
|
|
for (var i = 0; i < particles.length; i++) {
|
|
var pt = particles[i];
|
|
ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife);
|
|
ctx.fillStyle = pt.color;
|
|
ctx.fillRect(pt.x - pt.size / 2, pt.y - pt.size / 2, pt.size, pt.size);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
function clearParticles() { particles.length = 0; }
|
|
|
|
/* ---------------- Floating text ---------------- */
|
|
var floaters = [];
|
|
function floatText(x, y, text, color, big) {
|
|
floaters.push({ x: x, y: y, text: text, color: color, life: 0.9, big: !!big });
|
|
if (floaters.length > 40) floaters.shift();
|
|
}
|
|
function updateFloaters(dt) {
|
|
for (var i = floaters.length - 1; i >= 0; i--) {
|
|
var f = floaters[i];
|
|
f.y -= 34 * dt;
|
|
f.life -= dt;
|
|
if (f.life <= 0) floaters.splice(i, 1);
|
|
}
|
|
}
|
|
function drawFloaters(ctx) {
|
|
ctx.textAlign = 'center';
|
|
for (var i = 0; i < floaters.length; i++) {
|
|
var f = floaters[i];
|
|
ctx.globalAlpha = Math.min(1, f.life * 2);
|
|
ctx.font = (f.big ? 'bold 22px' : 'bold 13px') + ' "Segoe UI", system-ui, sans-serif';
|
|
ctx.fillStyle = f.color;
|
|
ctx.fillText(f.text, f.x, f.y);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
function clearFloaters() { floaters.length = 0; }
|
|
|
|
US.entities = {
|
|
Player: Player,
|
|
Enemy: Enemy,
|
|
Bullet: Bullet,
|
|
Pickup: Pickup,
|
|
spawnParticles: spawnParticles,
|
|
updateParticles: updateParticles,
|
|
drawParticles: drawParticles,
|
|
clearParticles: clearParticles,
|
|
floatText: floatText,
|
|
updateFloaters: updateFloaters,
|
|
drawFloaters: drawFloaters,
|
|
clearFloaters: clearFloaters
|
|
};
|
|
})();
|