233 lines
14 KiB
JavaScript
233 lines
14 KiB
JavaScript
// ---- The Butcher's Arsenal: 16 horrorcore weapons ----
|
|
// behavior: bullet | spread | boomerang | bounce | cone | flame | homing |
|
|
// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam | sweep
|
|
|
|
// Ammo economy: `ammo` is the magazine (trigger-time / burst feel), `refill`
|
|
// is what one ammo pickup hands back (~10 kills' worth). Sustained-stream
|
|
// weapons use `drain` (ammo per second while held) instead of per-shot cost.
|
|
// Calibrated with tools/ammo_sim.js — every weapon should land ~8-13 kills
|
|
// per refill at base stats.
|
|
const WEAPONS = {
|
|
bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' },
|
|
repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' },
|
|
marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' },
|
|
cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 42, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 110, sweepArc: 2.85, desc: 'A wider, gory whirl of steel' },
|
|
saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 24, refill: 9, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' },
|
|
bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 80, refill: 21, drain: 8, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' },
|
|
hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 70, refill: 42, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' },
|
|
eye: { id: 'eye', name: 'Eye Ballista', held: 58, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 42, refill: 21, interval: 0.30, dmg: 10, spd: 320, range: 0.62, behavior: 'homing', sprite: 'bullet_eye', sfx: 'default', punch: 0.40, desc: 'The eyes follow. They always follow' },
|
|
guthook: { id: 'guthook', name: 'Gut Hook', held: 64, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 11, refill: 5, interval: 0.85, dmg: 48, spd: 720, range: 0.60, behavior: 'pierce_drag', sprite: 'bullet_harpoon', sfx: 'heavy', punch: 0.95, desc: 'Impales and drags the catch' },
|
|
cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 3, ammo: 110, refill: 21, drain: 12, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' },
|
|
fleshmasher: { id: 'fleshmasher', name: 'Flesh Masher', held: 62, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, refill: 4, interval: 0.75, dmg: 34, spd: 999, behavior: 'lob', sprite: 'bullet_gore', sfx: 'lob', punch: 0.90, desc: 'Lobs ground-meat bombs' },
|
|
trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 26, refill: 13, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, desc: 'Bear traps. For bears. And worse' },
|
|
tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 10, refill: 5, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' },
|
|
redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 150, refill: 72, drain: 18, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' },
|
|
spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 7, refill: 4, interval: 0.30, dmg: 190, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' },
|
|
swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 12, refill: 6, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, desc: 'A jar of friends. They are hungry' },
|
|
};
|
|
|
|
// rarity weights per tier, by floor
|
|
function rollWeaponDrop(floor) {
|
|
const table = [
|
|
{ tier: 0, w: Math.max(20, 60 - floor * 5) },
|
|
{ tier: 1, w: 30 + floor * 2 },
|
|
{ tier: 2, w: 10 + floor * 3 },
|
|
{ tier: 3, w: Math.min(4 + floor * 2, 22) },
|
|
];
|
|
const total = table.reduce((s, t) => s + t.w, 0);
|
|
let r = Math.random() * total;
|
|
let tier = 0;
|
|
for (const t of table) { r -= t.w; if (r <= 0) { tier = t.tier; break; } }
|
|
const pool = Object.values(WEAPONS).filter(w => w.tier === tier);
|
|
return choice(pool);
|
|
}
|
|
|
|
// spawn player projectiles for one trigger pull (called when weapon fires)
|
|
function fireWeapon(p, w) {
|
|
const st = p.stats;
|
|
const ang = p.aim;
|
|
const muzzle = w.muzzle || 35;
|
|
let bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle;
|
|
if (w.behavior === 'flame') {
|
|
const rh = ang + Math.PI / 2;
|
|
bx += Math.cos(rh) * 26; by += Math.sin(rh) * 26; // right-hand hold: FX and bullets share one origin
|
|
}
|
|
const dmgMul = st.dmgMul * st.dmgLiveMul;
|
|
const spdMul = st.shotSpeedMul;
|
|
spawnMuzzleFx(bx, by, ang, w.behavior);
|
|
|
|
// One shared volley shape makes split/fan/rear items useful for every
|
|
// projectile weapon instead of only the basic bullet behavior.
|
|
const volley = [ang];
|
|
for (let i = 1; i <= st.split; i++) volley.push(ang + (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 0.035);
|
|
for (let i = 1; i <= st.fan; i++) { volley.push(ang + 0.16 * i); volley.push(ang - 0.16 * i); }
|
|
for (let i = 1; i <= st.rear; i++) volley.push(ang + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
|
|
|
|
const mk = (a, overrides) => {
|
|
const b = Object.assign({
|
|
x: bx, y: by, ang: a,
|
|
spawnX: p.x, spawnY: p.y,
|
|
vx: Math.cos(a) * w.spd * spdMul, vy: Math.sin(a) * w.spd * spdMul,
|
|
r: 5 * st.sizeMul, dmg: w.dmg * dmgMul, pierce: st.pierce, bounce: st.bounce,
|
|
life: (w.range || 0.45) * st.rangeMul,
|
|
t: 0, behavior: w.behavior, sprite: w.sprite,
|
|
homing: w.behavior === 'homing' ? 6 + st.homing * 0.7 : (st.homing ? 1.6 + st.homing * 0.7 : 0),
|
|
lifesteal: w.lifesteal || 0,
|
|
bounces: w.bounces || 0, hit: null, dragTarget: null,
|
|
rangeMul: st.rangeMul, sizeMul: st.sizeMul,
|
|
}, overrides || {});
|
|
G.bullets.push(b);
|
|
return b;
|
|
};
|
|
|
|
switch (w.behavior) {
|
|
case 'spread': {
|
|
// Split adds both full volleys and a little density to a shotgun blast.
|
|
const n = w.pellets + st.split;
|
|
for (const center of volley) {
|
|
for (let i = 0; i < n; i++) mk(center + rand(-w.spread, w.spread) * (0.5 + i / n));
|
|
}
|
|
break;
|
|
}
|
|
case 'cone': case 'flame': {
|
|
for (const center of volley) {
|
|
for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul });
|
|
}
|
|
break;
|
|
}
|
|
case 'boomerang': {
|
|
for (const a of volley) mk(a, { r: 8 * st.sizeMul, pierce: st.pierce, phase: 0, life: (w.range || 1.1) * st.rangeMul });
|
|
break;
|
|
}
|
|
case 'pierce_drag': {
|
|
for (const a of volley) mk(a, { r: 7 * st.sizeMul, pierce: st.pierce, life: (w.range || 0.6) * st.rangeMul });
|
|
break;
|
|
}
|
|
case 'lob': case 'lob_trap': case 'lob_swarm': {
|
|
const primaryD = Math.max(dist(bx, by, mxW(), myW()), 1);
|
|
for (let i = 0; i < volley.length; i++) {
|
|
const a = volley[i];
|
|
const tx = i === 0 ? mxW() : p.x + Math.cos(a) * primaryD;
|
|
const ty = i === 0 ? myW() : p.y + Math.sin(a) * primaryD;
|
|
const d = Math.max(dist(bx, by, tx, ty), 1);
|
|
const flight = clamp(d / (520 * spdMul), 0.12, 0.8);
|
|
mk(a, {
|
|
vx: (tx - bx) / flight, vy: (ty - by) / flight,
|
|
r: 7 * st.sizeMul, pierce: st.pierce, bounce: st.bounce,
|
|
life: flight, lobbed: true,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'slam': {
|
|
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
|
|
for (const a of volley) {
|
|
const reach = 46 * st.rangeMul * Math.sqrt(st.shotSpeedMul);
|
|
const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach;
|
|
const radius = 70 * st.rangeMul * Math.sqrt(st.sizeMul);
|
|
areaDamage(cx, cy, radius, w.dmg * dmgMul * inert, true, { source: 'player' });
|
|
spawnExplosionFx(cx, cy, radius);
|
|
}
|
|
Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 });
|
|
break;
|
|
}
|
|
case 'sweep': {
|
|
const reach = (w.sweepRange || 90) * st.rangeMul;
|
|
const half = (w.sweepArc || 2.4) / 2;
|
|
for (const e of G.enemies) {
|
|
if (e.phased) continue;
|
|
const d = dist(p.x, p.y, e.x, e.y);
|
|
if (d > reach + e.r) continue;
|
|
const a = angleTo(p.x, p.y, e.x, e.y);
|
|
if (Math.abs(angleDelta(a, ang)) > half) continue;
|
|
damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' });
|
|
spawnBlood(e.x, e.y, a, 8, true);
|
|
spawnGibs(e.x, e.y, 3);
|
|
addShake(2);
|
|
}
|
|
spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4);
|
|
addShake(6);
|
|
break;
|
|
}
|
|
default: { // bullet, bounce and homing projectiles
|
|
for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0));
|
|
}
|
|
}
|
|
}
|
|
|
|
// continuous chainsaw damage while held (def = weapon stats; the caller owns
|
|
// the time-denominated ammo drain, so inst is only kept for signature stability)
|
|
function sawTick(p, def, inst, dt) {
|
|
const st = p.stats;
|
|
const angles = [p.aim];
|
|
for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.18);
|
|
for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + i * 0.28); angles.push(p.aim - i * 0.28); }
|
|
for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
|
|
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
|
|
let hitAny = false;
|
|
for (const a of angles) {
|
|
const reach = 58 * st.rangeMul * Math.sqrt(st.shotSpeedMul); // out to the blade (was 30)
|
|
const rh = p.aim + Math.PI / 2; // right-hand side, perpendicular to aim
|
|
const rho = 36 * Math.sqrt(st.sizeMul); // right-hand offset distance
|
|
const cx = p.x + Math.cos(a) * reach + Math.cos(rh) * rho;
|
|
const cy = p.y + Math.sin(a) * reach + Math.sin(rh) * rho;
|
|
const radius = 38 * Math.sqrt(st.sizeMul); // full blade (was 34)
|
|
for (const e of G.enemies) {
|
|
if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) {
|
|
const tickScale = dt / Math.max(0.01, def.interval || 0.1);
|
|
damageEnemy(e, def.dmg * st.dmgMul * st.dmgLiveMul * st.rateMul * inert * tickScale, a, false, {
|
|
source: 'player', procScale: tickScale, procIntervalScale: tickScale,
|
|
});
|
|
hitAny = true;
|
|
}
|
|
}
|
|
if (chance(Math.min(1, dt * 20))) spawnSpark(cx, cy, a);
|
|
if (hitAny) spawnBlood(cx, cy, a, 4);
|
|
}
|
|
if (hitAny) Sfx.sawHit({ x: p.x, y: p.y });
|
|
// ammo cost is time-denominated and handled by the caller (player.js)
|
|
}
|
|
|
|
// fire the Spinal Tap beam
|
|
function fireBeam(p, def, charge) {
|
|
const st = p.stats;
|
|
const muzzle = def.muzzle || 35;
|
|
const angles = [p.aim];
|
|
for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.025);
|
|
for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + 0.16 * i); angles.push(p.aim - 0.16 * i); }
|
|
for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
|
|
const len = 900 * st.rangeMul;
|
|
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
|
|
const dmg = def.dmg * st.dmgMul * st.dmgLiveMul * (0.4 + 0.6 * charge) * inert;
|
|
const width = 14 * Math.sqrt(st.sizeMul);
|
|
for (const ang of angles) {
|
|
const x0 = p.x + Math.cos(ang) * muzzle, y0 = p.y + Math.sin(ang) * muzzle;
|
|
const x2 = x0 + Math.cos(ang) * len, y2 = y0 + Math.sin(ang) * len;
|
|
for (const e of G.enemies) {
|
|
if (distToSegment(e.x, e.y, x0, y0, x2, y2) < e.r + width) {
|
|
damageEnemy(e, dmg, ang, true, { source: 'player' });
|
|
}
|
|
}
|
|
spawnBeam(x0, y0, ang, len);
|
|
}
|
|
const ang = p.aim;
|
|
spawnMuzzleFx(p.x + Math.cos(ang) * muzzle, p.y + Math.sin(ang) * muzzle, ang, 'beam');
|
|
p.recoil = 1;
|
|
p.muzzleT = 0.11;
|
|
p.attackT = 0.5;
|
|
p.actionT = 0;
|
|
addShake(8);
|
|
Sfx.shoot(def);
|
|
if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= 1 / st.ammoEff;
|
|
}
|
|
|
|
// damage every enemy in a radius (slams, explosions)
|
|
function areaDamage(x, y, r, dmg, knockback, opts) {
|
|
for (const e of G.enemies) {
|
|
const d = dist(x, y, e.x, e.y);
|
|
if (d < r + e.r) {
|
|
damageEnemy(e, dmg, angleTo(x, y, e.x, e.y), knockback, opts);
|
|
}
|
|
}
|
|
}
|