From f71d2028500e5bbd6ea7e0c17ce710e840dbd683 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 16:35:21 +1000 Subject: [PATCH] Rebalance weapon tiers via quantitative DPS/range/accuracy/pool scoring tools/score_weapons.js ranks the arsenal with a behavior-aware model: effective DPS (bounce/boomerang/AoE/DoT multipliers), real engagement range, spread-based accuracy, and damage pool per ammo load. Tier moves: - Cleaver Cadence 0->1 (440px range + return re-hit outguns the starters) - Bile Blunderbuss 1->2 (134 effDPS + slow + pools is secretly a heavy) - The Cauterizer 2->3 (252 effDPS, highest sustained DPS in the game) - The Tenderizer 2->3 (134 effDPS AoE, outscores Spinal Tap) Tiers now: T0 repeater/marrow, T1 cleaver/saw/hemophage/eye, T2 bile/guthook/fleshmasher/trapqueen/redhand, T3 cauterizer/tenderizer/ spinaltap/swarmjar. Smoke test pins the 2/4/5/4 distribution. --- docs/weapons.md | 112 ++++++++++++++++++++--------------------- js/weapons.js | 8 +-- test/smoke.js | 2 + tools/score_weapons.js | 100 ++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 60 deletions(-) create mode 100644 tools/score_weapons.js diff --git a/docs/weapons.md b/docs/weapons.md index 97b212d..4c42690 100644 --- a/docs/weapons.md +++ b/docs/weapons.md @@ -12,7 +12,7 @@ Weapons have **no upgrade levels** — their stats are fixed. All scaling comes - Press **R** to swap between them. Holstered weapons keep their remaining ammo. - Picking up a new special weapon drops your current one (with its remaining ammo) so you can swap back. - When a special weapon runs dry, you automatically fall back to the Bone Popper. -- Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's +15% `ammoEff` stretches every magazine). +- Special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's additive `ammoEff` stretches every magazine). ### Where weapons come from @@ -97,6 +97,10 @@ Rapid-fire bullet hose. Low per-shot damage, excellent proc-per-second carrier f Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but the short lifetime makes it strictly close-range. +--- + +## Tier 1 — Uncommon + ### Cleaver Cadence > *It always comes back* @@ -112,10 +116,6 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you (caught within 20 px). **Always pierces** — it can hit enemies on both the outbound and return trip. ---- - -## Tier 1 — Uncommon - ### Sawblade Launcher > *Bouncing, hungry steel* @@ -132,24 +132,6 @@ Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls into packs. -### Bile Blunderbuss - -> *A brutal corrosive torrent; pools linger* - -| Stat | Value | -|---|---| -| Damage | 7 | -| Fire interval | 0.12 s (rapid, hold to spray) | -| Projectiles per shot | 2 | -| Projectile speed | 340 | -| Spread | 0.32 rad | -| Range | 0.36 s | -| Pierce | 2 (+ item pierce) | -| Ammo | 70 | -| Recoil | 0.34 | - -Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool. - ### Hemophage > *Drinks what it touches* @@ -186,6 +168,24 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest. ## Tier 2 — Rare +### Bile Blunderbuss + +> *A brutal corrosive torrent; pools linger* + +| Stat | Value | +|---|---| +| Damage | 7 | +| Fire interval | 0.12 s (rapid, hold to spray) | +| Projectiles per shot | 2 | +| Projectile speed | 340 | +| Spread | 0.32 rad | +| Range | 0.36 s | +| Pierce | 2 (+ item pierce) | +| Ammo | 70 | +| Recoil | 0.34 | + +Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 3.5 s life, DPS = bullet damage × 1.5). Projectiles that expire mid-flight have a separate 35% chance to drop a pool. + ### Gut Hook > *Impales and drags the catch* @@ -201,24 +201,6 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest. Harpoon that pierces everything and **drags the first non-boss enemy it hits** along its flight path. When the projectile expires, the dragged enemy takes 50% of the bullet's damage again. Excellent for yanking a priority target out of a pack. -### The Cauterizer - -> *Incinerates anything brave enough to get close* - -| Stat | Value | -|---|---| -| Damage | 4.5 per flame tick | -| Fire interval | 0.05 s (20 ticks/sec, hold to spray) | -| Projectiles per shot | 2 | -| Projectile speed | 310 | -| Spread | 0.34 rad | -| Range | 0.36 s (very short) | -| Pierce | 2 (+ item pierce) | -| Ammo | 110 | -| Recoil | 0.32 | - -Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range. - ### Flesh Masher > *Lobs ground-meat bombs* @@ -250,21 +232,6 @@ Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s). Area denial. Traps persist on the floor and damage any enemy that steps on them — pre-lay them along doorways and kiting paths. -### The Tenderizer - -> *A room-shaking close-range meat paste* - -| Stat | Value | -|---|---| -| Damage | 60 (instant area hit) | -| Fire interval | 0.58 s | -| Range | 46 × rangeMul × √shotSpeedMul from player | -| Blast radius | 70 × rangeMul × √sizeMul | -| Ammo | 26 | -| Recoil | 1.00 (max), screen shake 5 | - -Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own. - ### Red Right Hand > *A chainsaw that liquefies anything in reach* @@ -284,6 +251,39 @@ Continuous melee chainsaw — hold LMB and walk into things. Damage scales with ## Tier 3 — Legendary +### The Cauterizer + +> *Incinerates anything brave enough to get close* + +| Stat | Value | +|---|---| +| Damage | 4.5 per flame tick | +| Fire interval | 0.05 s (20 ticks/sec, hold to spray) | +| Projectiles per shot | 2 | +| Projectile speed | 310 | +| Spread | 0.34 rad | +| Range | 0.36 s (very short) | +| Pierce | 2 (+ item pierce) | +| Ammo | 110 | +| Recoil | 0.32 | + +Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DPS base). Melt hordes that get close; useless at range. + +### The Tenderizer + +> *A room-shaking close-range meat paste* + +| Stat | Value | +|---|---| +| Damage | 60 (instant area hit) | +| Fire interval | 0.58 s | +| Range | 46 × rangeMul × √shotSpeedMul from player | +| Blast radius | 70 × rangeMul × √sizeMul | +| Ammo | 26 | +| Recoil | 1.00 (max), screen shake 5 | + +Point-blank slam — no projectile, an instant circular blast in front of you. Bonus "inert" damage scaling: +6% per point of pierce + bounce + homing you own. + ### Spinal Tap > *Charge it. Delete a line of meat* diff --git a/js/weapons.js b/js/weapons.js index 4b09f78..44dddf6 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -6,16 +6,16 @@ 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: 140, 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: 42, 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: 0, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' }, + cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' }, saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 26, 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: 1, ammo: 70, 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' }, + bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 2, ammo: 70, 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: 44, 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: 32, 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: 14, 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: 2, ammo: 110, 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' }, + cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 3, ammo: 110, 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, 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: 12, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, ammoWeight: 0.75, desc: 'Bear traps. For bears. And worse' }, - tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 2, ammo: 26, 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' }, + tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 3, ammo: 26, 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: 160, 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: 9, interval: 0.30, dmg: 95, 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: 7, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, ammoWeight: 0.8, desc: 'A jar of friends. They are hungry' }, diff --git a/test/smoke.js b/test/smoke.js index dbf4dc9..39e5697 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -147,6 +147,8 @@ check('every weapon has layered render and muzzle geometry', Object.values(ctx.W Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0)); check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0)); check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16); +check('quantitative tier rebalance: 2/4/5/4 distribution', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).filter(w => w.tier === t).length === [2, 4, 5, 4][t])); +check('every drop tier has at least one weapon', [0, 1, 2, 3].every(t => Object.values(ctx.WEAPONS).some(w => w.tier === t))); check('visceral DSP and fallback events are wired', ['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function')); const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => { diff --git a/tools/score_weapons.js b/tools/score_weapons.js new file mode 100644 index 0000000..f28d081 --- /dev/null +++ b/tools/score_weapons.js @@ -0,0 +1,100 @@ +// Quantitative weapon-tier scorer: ranks the arsenal by a composite of +// effective DPS, range, accuracy, and damage pool per ammo load. +// Run: node tools/score_weapons.js +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const sandbox = { Math, Object, Infinity, NaN }; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', 'weapons.js'), 'utf8'), sandbox); +const WEAPONS = vm.runInContext('WEAPONS', sandbox); + +// Behavior-aware effective DPS model. Multipliers are conservative estimates +// of how often the gimmick actually connects in a room of ~8 enemies. +function effectiveDps(w) { + const base = w.dmg / w.interval; + switch (w.behavior) { + case 'bullet': return base; + case 'spread': return (w.dmg * (w.pellets || 1)) / w.interval * 0.75; // not every pellet lands + case 'boomerang': return base * 1.6; // return pass often re-hits + case 'bounce': return base * (1 + Math.min(w.bounces || 0, 3) * 0.35); // wall bounces re-hit + case 'cone': return (w.dmg * 2) / w.interval * 1.15; // 2/volley + acid pools + case 'flame': return (w.dmg * 2) / w.interval * 1.40; // 2/volley + burn DoT + case 'homing': return base; // accuracy handled separately + case 'pierce_drag': return base * 1.4; // pierce + drag re-hit + case 'lob': return base * 1.3; // AoE average multi-hit + case 'lob_trap': return base * 1.3; // direct + trap zone control + case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots + case 'slam': return base * 1.3; // AoE multi-hit + case 'saw': return base * 1.1; // continuous contact + case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce + default: return base; + } +} + +// Nominal engagement range in px. +function effectiveRange(w) { + switch (w.behavior) { + case 'lob': case 'lob_trap': case 'lob_swarm': return 520; // cursor-targeted + case 'slam': return 46 * (w.range || 1) + 70; // reach + blast radius + case 'saw': return 90; // chainsaw reach + case 'beam': return 900; // screen-length beam + default: return (w.spd || 300) * (w.range || 0.45); // projectile travel distance + } +} + +// 0..1 hit reliability from spread, homing, and targeting style. +function accuracy(w) { + if (w.behavior === 'homing' || w.behavior === 'lob_swarm') return 1.0; + if (['slam', 'saw', 'beam'].includes(w.behavior)) return 0.95; // aimed AoE can't really miss + if (['lob', 'lob_trap'].includes(w.behavior)) return 0.9; // cursor-targeted + return Math.max(0.2, 1 - (w.spread || 0) / 0.6); +} + +// Total damage in one ammo load (with the same behavior multipliers as DPS). +function damagePool(w) { + if (w.ammo === Infinity) return Infinity; + const perShot = effectiveDps(w) * w.interval; + return perShot * w.ammo; +} + +const rows = []; +for (const w of Object.values(WEAPONS)) { + if (w.tier < 0) continue; // sidearm excluded + rows.push({ + id: w.id, name: w.name, cur: w.tier, + dps: effectiveDps(w), + range: effectiveRange(w), + acc: accuracy(w), + pool: damagePool(w), + }); +} + +const maxDps = Math.max(...rows.map(r => r.dps)); +const maxRange = Math.max(...rows.map(r => r.range)); +const maxPool = Math.max(...rows.map(r => r.pool)); +// linear for DPS/range (real gameplay gaps), log for the pool (huge spread) +const norm = (v, max) => v / max; +const normLog = (v, max) => Math.log1p(v) / Math.log1p(max); + +for (const r of rows) { + r.score = 100 * (0.45 * norm(r.dps, maxDps) + 0.15 * norm(r.range, maxRange) + 0.10 * r.acc + 0.30 * normLog(r.pool, maxPool)); +} +rows.sort((a, b) => b.score - a.score); + +console.log('rank weapon cur DPS rng acc pool score'); +console.log('---- ---------------- --- ----- ---- ---- ------ -----'); +for (const r of rows) { + console.log( + String(rows.indexOf(r) + 1).padEnd(5), + r.name.padEnd(17), + String(r.cur).padEnd(4), + r.dps.toFixed(1).padStart(6), + String(Math.round(r.range)).padStart(5), + r.acc.toFixed(2).padStart(5), + String(Math.round(r.pool)).padStart(7), + r.score.toFixed(1).padStart(6), + ); +}