From d9eae34df4a30c999552decb5483913be42b9936 Mon Sep 17 00:00:00 2001 From: MeatSlicer Developer Date: Thu, 30 Jul 2026 22:06:10 +1000 Subject: [PATCH] Normalize ammo economy: per-weapon refill, stream drain, behavior fixes - Every special weapon gets an explicit 'refill' (units per ammo pickup, tuned so one pickup funds ~10 kills) replacing the ammoRefillFraction log2 heuristic and ammoWeight overrides - Bile Blunderbuss + The Cauterizer join Red Right Hand as time-denominated stream weapons ('drain' per second) instead of per-shot cost - Sawblades ricochet through enemies (bounce fix), traps catch 3 victims, lobbed projectiles arc overhead and always trigger at the target point (Flesh Masher / Trap Queen / Swarm Jar payloads now actually deploy) - Boss rooms stock one ammo on entry and drop one guaranteed on death - Cauterizer burn trimmed (2.2s x2 -> 1.5s x0.8), bile acid pools trimmed (3.5s x1.5 -> 2.5s x0.8), Marrow Draught now covers stream drain - New tools/ammo_sim.js calibrates every weapon into the 8-13 kills/refill band (all 15 pass); smoke test guards the ammo structure - v0.3.0, cache bump v=44 --- docs/bosses.md | 1 + docs/gameplay.md | 6 +- docs/weapons.md | 52 +++++----- index.html | 42 ++++---- js/bosses.js | 3 +- js/bullets.js | 19 ++-- js/help.js | 4 +- js/pickups.js | 16 +-- js/player.js | 15 +++ js/rooms.js | 12 ++- js/sfxbank.js | 2 +- js/sprites.js | 2 +- js/state.js | 3 +- js/weapons.js | 40 ++++--- package.json | 2 +- test/smoke.js | 30 ++++-- tools/ammo_sim.js | 231 +++++++++++++++++++++++++++++++++++++++++ tools/score_weapons.js | 1 + 18 files changed, 380 insertions(+), 101 deletions(-) create mode 100644 tools/ammo_sim.js diff --git a/docs/bosses.md b/docs/bosses.md index 4c43a86..0772fbf 100644 --- a/docs/bosses.md +++ b/docs/bosses.md @@ -40,6 +40,7 @@ Killing a boss grants **500 + floor × 100** score, clears all hostile bullets/t - **1 item pedestal** (left of room center) - **1 weapon drop** rolled at **floor + 2** (right of center) +- **1 ammo pickup** (above center) — the boss room also stocks one when you walk in, so you can afford to spend the magazine on the fight - **Stairs Down** (below center) > *"[NAME] DESTROYED — take the meat, take the stairs"* diff --git a/docs/gameplay.md b/docs/gameplay.md index 69cd0ea..a74bba5 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -16,7 +16,7 @@ A run looks like this: 2. **Clear combat rooms.** Doors lock until every wave of enemies in the room is dead. 3. **Find the item room.** A free passive item (plus a 35% chance of a weapon). 4. **Kill the floor boss.** The boss room is always the farthest room from the start. -5. **Take the loot, take the stairs.** Bosses drop an item pedestal, a weapon, and the stairs down. Walk onto the stairs to descend. +5. **Take the loot, take the stairs.** Bosses drop an item pedestal, a weapon, guaranteed ammo, and the stairs down. Walk onto the stairs to descend. 6. Repeat, deeper and harder, until you are butchered. ### Score @@ -175,7 +175,7 @@ Clearing a combat room drops: |---|---| | **XP Gem** (small, value 1 / big, value 5) | Grants XP × your `xpMul`. Fills the level bar; each level-up opens the Mutation Draft. | | **Heart** | Heals 2 HP. At full HP: +25 score instead. | -| **Ammo** | Refills carried special weapons (current + holstered) by a fraction of their capacity × `ammoPickupMul`, up to 150% capacity. Overflow becomes score. | +| **Ammo** | Refills carried special weapons (current + holstered) by their authored `refill` × `ammoPickupMul`, up to 150% of magazine size. Overflow becomes score. | | **Weapon** | Equips on walk-over. Your old special weapon drops with its remaining ammo (Bone Popper is never dropped). | | **Item** (on pedestal) | Grants/upgrades a passive item. Hovering near a pedestal shows its name. | | **Stairs Down** | Appears after every boss kill. Walk over to descend. | @@ -192,7 +192,7 @@ Clearing a combat room drops: |---|---| | Normal enemy | XP gems (enemy's XP value) · 5.25% + luck×3% ammo · 2.2% + luck×3% heart | | Elite enemy | 3× XP + 3–5 bonus gems · 18% + luck×10% **item** · else 45% + luck×15% ammo | -| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + stairs | +| Boss | 1 item pedestal + 1 weapon (rolled at floor + 2) + 1 guaranteed ammo + stairs · the boss room also stocks 1 ammo when you enter | **Luck** is a player stat (Lucky Coin, Crow Bait, Scavenger perk) that scales most drop chances as shown above. diff --git a/docs/weapons.md b/docs/weapons.md index 4c42690..3a2421e 100644 --- a/docs/weapons.md +++ b/docs/weapons.md @@ -12,7 +12,9 @@ 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 additive `ammoEff` stretches every magazine). +- Most special weapons consume ammo per shot: `1 / ammoEff` units per shot (so Brass Magazine's additive `ammoEff` stretches every magazine). +- **Stream weapons** (Bile Blunderbuss, The Cauterizer, Red Right Hand) instead drain ammo per second while held: `drain / ammoEff` units per second. Their magazines are measured in seconds of fire. +- Each special has two ammo numbers: **`ammo`** (magazine size — how long a fresh weapon lasts) and **`refill`** (units returned per ammo pickup — tuned so one pickup funds roughly ten kills). Pickups can overfill up to 150% of the magazine; overflow converts to score. ### Where weapons come from @@ -75,7 +77,7 @@ Your eternal fallback. Never runs dry, never holstered — swapping with R alway | Projectile speed | 560 | | Spread | 0.10 rad | | Range | 0.42 s | -| Ammo | 140 | +| Ammo | 84 (refill 42 per pickup) | | Recoil | 0.18 | Rapid-fire bullet hose. Low per-shot damage, excellent proc-per-second carrier for on-hit item effects. @@ -92,7 +94,7 @@ Rapid-fire bullet hose. Low per-shot damage, excellent proc-per-second carrier f | Projectile speed | 500 | | Spread | 0.55 rad (wide) | | Range | 0.38 s (short) | -| Ammo | 42 | +| Ammo | 20 (refill 6 per pickup) | | Recoil | 0.92 (heavy) | Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but the short lifetime makes it strictly close-range. @@ -111,7 +113,7 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but | Fire interval | 0.38 s | | Projectile speed | 400 (returns at up to 520) | | Range | 1.10 s | -| Ammo | 34 | +| Ammo | 26 (refill 10 per pickup) | | Recoil | 0.42 | 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. @@ -125,12 +127,12 @@ Boomerang: flies out while decelerating for 0.45 s, then accelerates back to you | Damage | 13 | | Fire interval | 0.34 s | | Projectile speed | 360 | -| Wall bounces | 5 (plus bounces from Ricochet Ribs) | +| Bounces | 5, shared between walls and enemies (plus bounces from Ricochet Ribs) | | Range | 0.60 s | -| Ammo | 26 | +| Ammo | 24 (refill 9 per pickup) | | Recoil | 0.45 | -Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls into packs. +Spinning ricochet blades. Each enemy hit spends one bounce and the blade tears through; at zero bounces it dies on the next enemy (or wall). Deadly in cramped rooms where blades ping off walls into packs. ### Hemophage @@ -143,7 +145,7 @@ Spinning ricochet blades. Deadly in cramped rooms where blades ping off walls in | Projectile speed | 480 | | Range | 0.45 s | | Lifesteal | 12.5% chance per hit to heal 1 HP | -| Ammo | 44 | +| Ammo | 70 (refill 42 per pickup) | | Recoil | 0.35 | The only weapon with built-in lifesteal. Healing only procs while you're below max HP. @@ -159,7 +161,7 @@ The only weapon with built-in lifesteal. Healing only procs while you're below m | Projectile speed | 320 | | Range | 0.62 s | | Homing | 6 + homing × 0.7 (strongest base homing) | -| Ammo | 32 | +| Ammo | 42 (refill 21 per pickup) | | Recoil | 0.40 | Aggressively seeking projectiles. Aim roughly, let the eyes do the rest. @@ -181,10 +183,10 @@ Aggressively seeking projectiles. Aim roughly, let the eyes do the rest. | Spread | 0.32 rad | | Range | 0.36 s | | Pierce | 2 (+ item pierce) | -| Ammo | 70 | +| Ammo | 80 (refill 21 per pickup), **drains 8/sec while held** | | 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. +Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **acid pool** (radius 24 × sizeMul, 2.5 s life, DPS = bullet damage × 0.8). Projectiles that expire mid-flight have a separate 35% chance to drop a pool. Ammo is time-denominated: a full tank is 10 seconds of spray. ### Gut Hook @@ -196,7 +198,7 @@ Corrosive cone spray. On hit: **slows for 1.6 s** and 45% chance to leave an **a | Fire interval | 0.85 s (slow) | | Projectile speed | 720 (fastest projectile) | | Range | 0.60 s | -| Ammo | 14 | +| Ammo | 11 (refill 5 per pickup) | | Recoil | 0.95 | 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. @@ -211,10 +213,10 @@ Harpoon that pierces everything and **drags the first non-boss enemy it hits** a | Fire interval | 0.75 s | | Behavior | Lobbed to cursor, explodes on arrival | | Blast radius | 85 × rangeMul × √sizeMul | -| Ammo | 14 | +| Ammo | 14 (refill 4 per pickup) | | Recoil | 0.90 | -Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s). The blast damages and knocks back everything in radius. Arcs over the chaos — no line of sight needed. +Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s). The blast damages and knocks back everything in radius. Arcs over the chaos — no line of sight needed, and lobbed shots pass over enemies mid-flight to detonate at the target point. ### Trap Queen @@ -227,10 +229,11 @@ Grenade lobber. Flight time auto-computed from distance (clamped 0.12–0.8 s). | Behavior | Lobbed; leaves a trap where it lands | | Trap life | 12 s × rangeMul | | Trap radius | 16 × sizeMul | -| Ammo | 12 (refills at 75% rate — `ammoWeight: 0.75`) | +| Trap charges | 3 victims per trap | +| Ammo | 26 (refill 13 per pickup) | | Recoil | 0.65 | -Area denial. Traps persist on the floor and damage any enemy that steps on them — pre-lay them along doorways and kiting paths. +Area denial. Traps persist on the floor and snap shut on up to **three** victims each (damage + 2.2 s root per victim) before breaking — pre-lay them along doorways and kiting paths. ### Red Right Hand @@ -242,7 +245,7 @@ Area denial. Traps persist on the floor and damage any enemy that steps on them | Damage interval | 0.10 s | | Reach | 30 × rangeMul × √shotSpeedMul from player | | Arc radius | 34 × √sizeMul | -| Ammo | 160 (drains 18/sec while held, ÷ ammoEff) | +| Ammo | 150 (refill 72 per pickup; drains 18/sec while held, ÷ ammoEff) | | Recoil | 0.38 | Continuous melee chainsaw — hold LMB and walk into things. Damage scales with `dmgMul × rateMul`, plus the same +6%-per-pierce/bounce/homing inert bonus as the Tenderizer. Sparks fly, ammo drains. @@ -264,10 +267,10 @@ Continuous melee chainsaw — hold LMB and walk into things. Damage scales with | Spread | 0.34 rad | | Range | 0.36 s (very short) | | Pierce | 2 (+ item pierce) | -| Ammo | 110 | +| Ammo | 110 (refill 21 per pickup), **drains 12/sec while held** | | 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. +Flamethrower. Hits **ignite for 1.5 s** with burn DPS = bullet damage × 0.8 (3.6 DPS base). Melt hordes that get close; useless at range. Ammo is time-denominated: a full tank is ~9 seconds of flame. ### The Tenderizer @@ -279,7 +282,7 @@ Flamethrower. Hits **ignite for 2.2 s** with burn DPS = bullet damage × 2 (9 DP | Fire interval | 0.58 s | | Range | 46 × rangeMul × √shotSpeedMul from player | | Blast radius | 70 × rangeMul × √sizeMul | -| Ammo | 26 | +| Ammo | 10 (refill 5 per pickup) | | 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. @@ -294,7 +297,7 @@ Point-blank slam — no projectile, an instant circular blast in front of you. B | Charge time | 0.85 s (minimum 40% charge = 0.34 s to fire) | | Beam length | 900 × rangeMul | | Beam width | 14 × √sizeMul | -| Ammo | 9 | +| Ammo | 7 (refill 4 per pickup) | | Recoil | 1.00, screen shake 8 | **Hold LMB to charge, release to fire** an instant hitscan beam that deletes everything in a line. Damage scales `0.4 + 0.6 × charge fraction` — full charge is 2.5× a hasty snap shot. A ring around your character shows charge state (blue = full). Charge rate scales with `rateMul × √shotSpeedMul`. @@ -310,10 +313,10 @@ Point-blank slam — no projectile, an instant circular blast in front of you. B | Maggots per jar | 6 (+ split + fan bonuses) | | Maggot speed / life | 260 / 2.2 s × rangeMul | | Maggot homing | 8 + homing × 0.7 (strongest in game) | -| Ammo | 7 (refills at 80% rate — `ammoWeight: 0.8`) | +| Ammo | 12 (refill 6 per pickup) | | Recoil | 0.70 | -Lobs a jar that shatters into a pack of homing maggots. Maggots **inherit your pierce and bounce stats** and relentlessly chase enemies for their full lifetime. Throw and forget. +Lobs a jar that shatters into a pack of homing maggots where it lands. The jar arcs over enemies mid-flight, so aim at the pack — not through it. Maggots **inherit your pierce and bounce stats** and relentlessly chase enemies for their full lifetime. Throw and forget. --- @@ -324,7 +327,8 @@ bullet damage = weapon dmg × dmgMul × (critMul if crit) fire interval = weapon interval / (rateMul × frenzyMul) proj speed = weapon spd × shotSpeedMul proj lifetime = weapon range × rangeMul -ammo per shot = 1 / ammoEff +ammo per shot = 1 / ammoEff (stream weapons: drain / ammoEff per second) +ammo refill = weapon refill × ammoPickupMul per pickup (cap 150% of magazine) knockback = 90 × knockbackMul ``` diff --git a/index.html b/index.html index 41d061c..a2d8ff7 100644 --- a/index.html +++ b/index.html @@ -6,31 +6,31 @@ MEATSLICER - +
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/js/bosses.js b/js/bosses.js index fb5b919..b60c454 100644 --- a/js/bosses.js +++ b/js/bosses.js @@ -592,8 +592,9 @@ function onBossDeath(e) { } addScore(500 + G.floor * 100); addShake(12); - // boss reward: item pedestal + a real weapon drop + stairs down + // boss reward: item pedestal + a real weapon drop + ammo + stairs down spawnItemPedestal(W / 2 - 90, H / 2, null, 'boss'); + spawnPickup('ammo', W / 2, H / 2 - 40); // 30% chance of a bonus active pedestal (additive — never replaces the passive) if (chance(0.30)) spawnActivePedestal(W / 2 - 160, H / 2 + 60); const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff diff --git a/js/bullets.js b/js/bullets.js index dd8cf08..5925c46 100644 --- a/js/bullets.js +++ b/js/bullets.js @@ -87,7 +87,7 @@ function updateBullets(dt) { dead = true; // bile congeals into acid pools if (b.behavior === 'cone' && chance(0.35)) { - G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 3.5, t: 0, dps: b.dmg * 1.5 }); + G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 2.5, t: 0, dps: b.dmg * 0.8 }); } // Sawbone Coil: bullets split into 2 shards on expiry if (p.stats.sawboneCoil > 0 && !b.shard && b.behavior !== 'lob' && b.behavior !== 'lob_trap' && b.behavior !== 'lob_swarm') { @@ -111,8 +111,9 @@ function updateBullets(dt) { lobTrigger(b); } - // enemy collision - if (!dead) { + // enemy collision (lobbed projectiles arc overhead and only trigger at + // their target point — or on a wall — never mid-flight) + if (!dead && !b.lobbed) { for (const e of G.enemies) { if (e.hp <= 0 || e.phased || (b.hit && b.hit.has(e))) continue; if (e === b.dragTarget) continue; @@ -122,10 +123,10 @@ function updateBullets(dt) { if (!b.hit) b.hit = new Set(); b.hit.add(e); spawnBlood(b.x, b.y, a, 3); - if (b.behavior === 'flame') { e.burnT = Math.max(e.burnT, 2.2); e.burnDps = Math.max(e.burnDps || 0, b.dmg * 2); } + if (b.behavior === 'flame') { e.burnT = Math.max(e.burnT, 1.5); e.burnDps = Math.max(e.burnDps || 0, b.dmg * 0.8); } if (b.behavior === 'cone') { e.slowT = Math.max(e.slowT, 1.6); - if (G.hazards.length < 80 && chance(0.45)) G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 3.5, t: 0, dps: b.dmg * 1.5 }); + if (G.hazards.length < 80 && chance(0.45)) G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 2.5, t: 0, dps: b.dmg * 0.8 }); } // Hemophage remains an on-hit drain, naturally limited by its fire interval. if (b.lifesteal && p.hp < p.stats.maxHp && chance(b.lifesteal * 0.25)) lifestealPlayer(1); @@ -134,6 +135,12 @@ function updateBullets(dt) { } if (b.pierce > 0) { b.pierce--; } else if (b.behavior === 'boomerang' || b.behavior === 'pierce_drag') { /* pass through */ } + // Sawblades ricochet through meat the same way they ricochet off + // walls: each enemy hit spends one bounce, shared with wall bounces. + else if (b.behavior === 'bounce' && ((b.bounces || 0) + (b.bounce || 0)) > 0) { + if (b.bounces > 0) b.bounces--; else b.bounce--; + spawnSpark(b.x, b.y, a + Math.PI); + } else { dead = true; break; } @@ -217,7 +224,7 @@ function lobTrigger(b) { spawnBlood(b.x, b.y, 0, 16, true); Sfx.explode({ x: b.x, y: b.y }); } else if (b.behavior === 'lob_trap') { // Trap Queen - G.hazards.push({ kind: 'trap', x: b.x, y: b.y, r: 16 * (b.sizeMul || 1), life: 12 * (b.rangeMul || 1), t: 0, dmg: b.dmg }); + G.hazards.push({ kind: 'trap', x: b.x, y: b.y, r: 16 * (b.sizeMul || 1), life: 12 * (b.rangeMul || 1), t: 0, dmg: b.dmg, charges: 3 }); Sfx.trapSet({ x: b.x, y: b.y }); } else if (b.behavior === 'lob_swarm') { // Swarm Jar: homing maggots const st = G.player.stats; diff --git a/js/help.js b/js/help.js index d2ff432..8d72d02 100644 --- a/js/help.js +++ b/js/help.js @@ -151,7 +151,9 @@ function drawHelpArsenal(ctx, px, py) { if (row % 2 === 0) { ctx.fillStyle = 'rgba(255,220,200,0.03)'; ctx.fillRect(x - 4, ty - 11, HELP_PANEL.w - 72, 15); } const dps = Math.round(w.dmg / Math.max(0.01, w.interval) * (w.pellets || 1)); const vals = [w.name.toUpperCase(), w.tier < 0 ? 'SIDE' : 'T' + w.tier, String(w.dmg), - w.interval.toFixed(2) + 's', w.ammo === Infinity ? '∞' : String(w.ammo), String(dps), w.behavior.toUpperCase()]; + w.interval.toFixed(2) + 's', + w.ammo === Infinity ? '∞' : (w.drain ? w.ammo + '·' + w.drain + '/s' : String(w.ammo)), + String(dps), w.behavior.toUpperCase()]; ctx.fillStyle = '#c9b8ae'; for (let c = 0; c < cols.length; c++) ctx.fillText(vals[c], x + cols[c], ty); ty += 15; diff --git a/js/pickups.js b/js/pickups.js index e0e3424..9481ada 100644 --- a/js/pickups.js +++ b/js/pickups.js @@ -16,17 +16,9 @@ function spawnGems(x, y, value) { } -// Ammo drops preserve useful trigger time: low-damage rapid weapons receive -// a larger share, while heavy multi-pellet shots receive fewer rounds. -function ammoRefillFraction(w) { - const effectiveDamage = Math.max(1, (w.dmg || 1) * (w.pellets || 1)); - const interval = Math.max(0.05, w.interval ?? 0.3); - const damageWeight = Math.log2(effectiveDamage / 8); - const rateWeight = Math.log2(interval / 0.3); - // Heavy, slow attacks push the fraction down; light, rapid attacks push it up. - const heaviness = clamp((damageWeight + rateWeight) / 4, -1, 1); - return clamp(0.62 - heaviness * 0.28, 0.30, 0.88) * (w.ammoWeight || 1) * AMMO_REFILL_SCALE; -} +// Ammo pickups hand back each weapon's authored `refill` (see WEAPONS), tuned +// so one pickup funds roughly ten kills. Magazine size (`ammo`) is a separate +// feel dial; the economy dial lives on `refill`. // room-clear reward burst (weapons come from bosses / item rooms, not room clears) function spawnRoomReward(x, y, floor) { @@ -87,7 +79,7 @@ function collectPickup(k) { for (const inst of carried) { const def = WEAPONS[inst.id]; const cap = def.ammo * 1.5; - const wanted = Math.max(1, Math.ceil(def.ammo * ammoRefillFraction(def) * p.stats.ammoPickupMul)); + const wanted = Math.max(1, Math.ceil((def.refill || 1) * p.stats.ammoPickupMul)); const add = Math.max(0, Math.min(wanted, cap - inst.ammo)); inst.ammo += add; total += add; diff --git a/js/player.js b/js/player.js index 99be8d9..6dd72a6 100644 --- a/js/player.js +++ b/js/player.js @@ -111,11 +111,26 @@ function updatePlayer(dt) { if (w.behavior === 'saw') { if (Input.mdown && p.weapon.ammo > 0) { sawTick(p, w, p.weapon, dt); + if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= dt * (w.drain || 18) / st.ammoEff; p.muzzleT = 0.04; p.recoil = 0.18 + Math.sin(G.time * 45) * 0.05; if (p.attackT <= 0) p.actionT = 0; p.attackT = 0.2; } else p.fireT = 0; + } else if (w.drain) { + // cone / flame streams: projectiles are free, ammo is time-denominated + if (Input.mdown && p.weapon.ammo > 0 && !(p.panicRoomT > 0)) { + if (p.fireT <= 0) { + fireWeapon(p, w); + p.recoil = Math.min(1, p.recoil + 0.45); + p.muzzleT = 0.07; + p.attackT = 0.4; p.actionT = 0; + p.fireT = rate; + Sfx.shoot(w); + } + if (!(p.marrowDraughtT > 0)) p.weapon.ammo -= dt * w.drain / st.ammoEff; + } + else p.fireT = 0; } else if (w.behavior === 'beam') { if (Input.mdown && p.weapon.ammo > 0) { p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime); diff --git a/js/rooms.js b/js/rooms.js index b6cdbf3..faed00c 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -82,6 +82,8 @@ function enterRoom(gx, gy) { } else if (room.type === 'boss') { const boss = spawnBoss(G.floor); room.bossSpawned = true; + // boss fights are a pure ammo drain, so stock the room on the way in + spawnPickup('ammo', W / 2, H / 2 + 140); Music.playBoss(boss.bossKind); } else if (room.type === 'item') { spawnItemPedestal(W / 2 - 45, H / 2, null, 'room'); @@ -239,14 +241,18 @@ function updateHazards(dt) { } if (h.kind === 'trap') { for (const e of G.enemies) { - if (e.boss) continue; + if (e.boss || e.hp <= 0) continue; + if (h.caught && h.caught.has(e)) continue; // each victim springs it once if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r) * (h.r + e.r)) { e.rootT = 2.2; damageEnemy(e, h.dmg, rand(0, TAU), false); spawnBlood(e.x, e.y, 0, 6); Sfx.trapSnap({ x: h.x, y: h.y }); - G.hazards.splice(i, 1); - break; + // a trap snaps shut on a few victims before it breaks + if (!h.caught) h.caught = new Set(); + h.caught.add(e); + h.charges = (h.charges === undefined ? 1 : h.charges) - 1; + if (h.charges <= 0) { G.hazards.splice(i, 1); break; } } } } else if (!h.hostile || h.dps > 0) { // weapon pools hurt enemies; zero-DPS boss pools do not diff --git a/js/sfxbank.js b/js/sfxbank.js index 10ae324..53223eb 100644 --- a/js/sfxbank.js +++ b/js/sfxbank.js @@ -111,7 +111,7 @@ const SfxBank = { if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; const file = this.FILES[name]; if (!file || !this.ctx || typeof fetch !== 'function') return; - const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=43') + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=44') .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(bytes => this.ctx.decodeAudioData(bytes)) .then(buffer => this.buffers.set(name, this.trim(buffer))) diff --git a/js/sprites.js b/js/sprites.js index b668c1d..3e8e1fb 100644 --- a/js/sprites.js +++ b/js/sprites.js @@ -84,7 +84,7 @@ const Sprites = { const img = new Image(); img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; }; img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; }; - img.src = 'assets/' + name + '.png?v=43'; + img.src = 'assets/' + name + '.png?v=44'; } }, diff --git a/js/state.js b/js/state.js index a33b027..299f017 100644 --- a/js/state.js +++ b/js/state.js @@ -45,8 +45,7 @@ const G = { }; const PERK_POWER_WEIGHT = 0.5; -// ammo economy: global dials for refill size and drop frequency -const AMMO_REFILL_SCALE = 0.75; +// ammo economy: global dial for drop frequency (refill size is per-weapon) const AMMO_DROP_SCALE = 0.75; const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60; const PRESSURE_UNIT = 0.01; // one "point" on the dial scale diff --git a/js/weapons.js b/js/weapons.js index 44dddf6..2c32daf 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -2,23 +2,28 @@ // behavior: bullet | spread | boomerang | bounce | cone | flame | homing | // pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam +// 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: 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: 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: 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: 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: 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' }, + 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.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: 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: 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: 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 @@ -128,7 +133,8 @@ function fireWeapon(p, w) { } } -// continuous chainsaw damage while held (def = weapon stats, inst = ammo holder) +// 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]; @@ -154,7 +160,7 @@ function sawTick(p, def, inst, dt) { if (hitAny) spawnBlood(cx, cy, a, 4); } if (hitAny) Sfx.sawHit({ x: p.x, y: p.y }); - inst.ammo -= dt * 18 / st.ammoEff; + // ammo cost is time-denominated and handled by the caller (player.js) } // fire the Spinal Tap beam diff --git a/package.json b/package.json index c6256af..926fe2d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meatslicer", "productName": "MeatSlicer", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "A Butcher's Descent - clear, harvest, descend.", "main": "desktop/main.js", diff --git a/test/smoke.js b/test/smoke.js index 91070a6..20f60f2 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -733,14 +733,10 @@ console.log('== generic ammo drops =='); step(2, 16); check('ammo reaches holstered weapon while sidearm is selected', p.holstered.ammo > holsteredAmmo); - const repeaterRefill = Math.ceil(ctx.WEAPONS.repeater.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.repeater)); - const spinalRefill = Math.ceil(ctx.WEAPONS.spinaltap.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.spinaltap)); - check('rapid low-damage weapons receive more rounds than heavy weapons', repeaterRefill > spinalRefill * 10); - const sameDamageFast = ctx.ammoRefillFraction({ dmg: 8, interval: 0.1, ammo: 50 }); - const sameDamageSlow = ctx.ammoRefillFraction({ dmg: 8, interval: 1.0, ammo: 50 }); - check('fire rate explicitly increases ammo refill share', sameDamageFast > sameDamageSlow); - check('persistent utility weapon overrides dampen refill size', - ctx.WEAPONS.trapqueen.ammoWeight < 1 && ctx.WEAPONS.swarmjar.ammoWeight < 1); + check('rapid low-damage weapons receive more rounds than heavy weapons', + ctx.WEAPONS.repeater.refill > ctx.WEAPONS.spinaltap.refill * 10); + check('stream weapons are time-denominated (drain, not per-shot)', + ctx.WEAPONS.bile.drain > 0 && ctx.WEAPONS.cauterizer.drain > 0 && ctx.WEAPONS.redhand.drain > 0); const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5; p.weapon = { id: 'repeater', ammo: repeaterCap - 1 }; @@ -756,6 +752,24 @@ console.log('== generic ammo drops =='); ctx.G.pickups.length = 0; } +console.log('== ammo economy structure =='); +{ + // Kills-per-refill is calibrated empirically by tools/ammo_sim.js (target + // band ~8-13). Here we guard the structural rules that keep it meaningful. + const bad = []; + for (const w of Object.values(ctx.WEAPONS)) { + if (w.ammo === Infinity) continue; + if (!Number.isInteger(w.refill) || w.refill < 1) bad.push(w.id + ':refill-missing'); + else if (w.refill > w.ammo) bad.push(w.id + ':refill-exceeds-magazine'); + // a full magazine must stay a usable burst: not a 1s sneeze, not endless + const seconds = w.drain ? w.ammo / w.drain + : w.ammo * (w.behavior === 'beam' ? (w.chargeTime + w.interval) : w.interval); + if (seconds < 3 || seconds > 20) bad.push(w.id + ':magazine-' + seconds.toFixed(1) + 's'); + } + check('every special weapon has a sane authored refill + magazine', bad.length === 0); + if (bad.length) console.log(' BAD-AMMO: ' + bad.join(', ')); +} + console.log('== drop economy: weapons are rare, elites feed you =='); { const p = ctx.G.player; diff --git a/tools/ammo_sim.js b/tools/ammo_sim.js new file mode 100644 index 0000000..d1bf700 --- /dev/null +++ b/tools/ammo_sim.js @@ -0,0 +1,231 @@ +// Ammo economy calibration harness: simulates each special weapon in live +// headless combat and reports kills per magazine / per ammo pickup (refill). +// The design target is ~8-13 kills per refill — one ammo pickup should fund +// roughly the ~10 kills it takes to find the next one. +// +// Run: node tools/ammo_sim.js [weaponId ...] +// Exit code 1 if any simulated weapon lands outside the 8-13 band. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +// ---- deterministic RNG so runs are reproducible ---- +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ---- fake canvas 2d context (same stub family as test/smoke.js) ---- +function fakeCtx() { + return new Proxy({}, { + get(t, prop) { + if (prop === 'measureText') return () => ({ width: 50 }); + if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) }); + if (prop === 'createLinearGradient' || prop === 'createRadialGradient') + return () => ({ addColorStop() {} }); + if (typeof prop === 'string') { + if (!(prop in t)) t[prop] = (typeof prop === 'string' && /^[a-z]/.test(prop)) ? function () {} : undefined; + } + return t[prop]; + }, + set(t, prop, v) { t[prop] = v; return true; }, + }); +} + +function boot(seed) { + let simTime = 0; + const rng = mulberry32(seed); + const mathShim = Object.create(Math); + mathShim.random = rng; + const canvasStub = { + width: 960, height: 640, style: {}, + getContext: () => fakeCtx(), + addEventListener: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }), + }; + const sandbox = { + console, + performance: { now: () => simTime }, + requestAnimationFrame: () => {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => canvasStub }, + Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } }, + Audio: class { + constructor() { this.volume = 0; this.paused = true; } + play() { this.paused = false; return Promise.resolve(); } + pause() { this.paused = true; } + }, + AudioContext: undefined, + setTimeout: (fn) => fn(), + Math: mathShim, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined, + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + sandbox.window.addEventListener = () => {}; + vm.createContext(sandbox); + const files = ['utils', 'state', 'input', 'sfx', 'sfxbank', 'audio', 'sprites', 'particles', 'weapons', + 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'help', 'main']; + for (const f of files) { + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), sandbox, { filename: f + '.js' }); + } + vm.runInContext('Object.assign(this, { G, Input, WEAPONS, W, H, WALL, ENEMY_TYPES })', sandbox); + sandbox.__tick = (dtMs) => { simTime += dtMs; sandbox.update(dtMs / 1000); sandbox.clearInputEdges(); }; + return sandbox; +} + +// aim classes: how much simulated hand jitter the weapon class gets +const AIM_JITTER = { + bullet: 0.07, spread: 0.05, bounce: 0.07, boomerang: 0.05, pierce_drag: 0.04, + cone: 0.03, flame: 0.03, + homing: 0, lob: 0, lob_trap: 0, lob_swarm: 0, slam: 0, saw: 0, beam: 0, +}; + +const FLOOR = 3; // mid-game enemy mix +const CROWD = 8; // enemies kept alive in the arena +const MAX_SECONDS = 240; // dry-magazine safety valve + +function simulate(wid, seed) { + const ctx = boot(seed); + const rng = mulberry32(seed ^ 0x9e3779b9); // outer-scope decisions stay seeded too + const w = ctx.WEAPONS[wid]; + // skip menu straight into a run, then take over the arena + vm.runInContext('startRun()', ctx); + for (let i = 0; i < 5; i++) ctx.__tick(16); + const G = ctx.G, p = G.player; + G.floor = FLOOR; + p.weapon = { id: wid, ammo: w.ammo }; + p.holstered = null; + p.hp = p.stats.maxHp = 100000; // unkillable for the measurement + p.stats.magnet = 0; // never vacuum drops + p.stats.xpMul = 0; // never level up + p.stats.luck = 0; + p.x = ctx.W / 2; p.y = ctx.H / 2; + G.enemies = []; + G.pickups = []; + G.mode = 'play'; + + // measure true weapon damage (direct hits + DoT ticks) + let damage = 0; + ctx.__simDamage = (n) => { damage += n; }; + vm.runInContext(` + (() => { + const origD = damageEnemy, origT = tickEnemyDamage; + damageEnemy = function (e, dmg, ang, kb, opts) { __simDamage(dmg); return origD(e, dmg, ang, kb, opts); }; + tickEnemyDamage = function (e, amount) { __simDamage(amount); return origT(e, amount); }; + })() + `, ctx); + + const killsAtStart = G.kills; + const jitter = AIM_JITTER[w.behavior] || 0; + let t = 0, chargeHeld = false; + const dt = 1 / 60; + + while (p.weapon.id === wid && p.weapon.ammo > 0 && t < MAX_SECONDS) { + // top up the arena with the floor's native mix (occasional elite). + // Enemies arrive in small clusters at engagement range — the sim stands + // in for a player fighting *in* a wave, not one waiting across the room. + while (G.enemies.length < CROWD) { + const cluster = 1 + Math.floor(rng() * 2.4); // 1-3 arriving together + const a = rng() * Math.PI * 2; + const d = 100 + rng() * 110; + for (let c = 0; c < cluster && G.enemies.length < CROWD + 3; c++) { + const ca = a + (rng() - 0.5) * 0.6; + const cd = d + (rng() - 0.5) * 50; + const ex = Math.max(ctx.WALL + 20, Math.min(ctx.W - ctx.WALL - 20, p.x + Math.cos(ca) * cd)); + const ey = Math.max(ctx.WALL + 20, Math.min(ctx.H - ctx.WALL - 20, p.y + Math.sin(ca) * cd)); + const elite = rng() < 0.09; + G.enemies.push(ctx.makeEnemy(ctx.pickEnemyType(FLOOR), ex, ey, FLOOR, elite)); + } + if (G.enemies.length >= CROWD) break; + } + + // aim at the nearest living enemy (with class-appropriate hand jitter) + let best = null, bd = Infinity; + for (const e of G.enemies) { + if (e.hp <= 0) continue; + const d2 = (e.x - p.x) * (e.x - p.x) + (e.y - p.y) * (e.y - p.y); + if (d2 < bd) { bd = d2; best = e; } + } + if (best) { + const baseA = Math.atan2(best.y - p.y, best.x - p.x); + const off = jitter ? (rng() + rng() - 1) * jitter : 0; + ctx.Input.mx = p.x + Math.cos(baseA + off) * 300; + ctx.Input.my = p.y + Math.sin(baseA + off) * 300; + if (w.behavior.startsWith('lob')) { + // lobbed shots land where the cursor is, so lead moving targets + const d = Math.sqrt(bd); + const flight = Math.max(0.12, Math.min(0.8, d / 520)); + ctx.Input.mx = best.x + (best.vx || 0) * flight; + ctx.Input.my = best.y + (best.vy || 0) * flight; + } + } + + // trigger scripting: beam needs charge/release cycles, everything else holds + if (w.behavior === 'beam') { + if (!chargeHeld) { ctx.Input.mdown = true; chargeHeld = true; } + if (p.charge >= w.chargeTime) { ctx.Input.mdown = false; ctx.Input.mreleased = true; chargeHeld = false; } + } else { + ctx.Input.mdown = true; + } + + G.pickups.length = 0; // drops never reach the player + ctx.__tick(16); + ctx.Input.mreleased = false; + t += dt; + } + ctx.Input.mdown = false; + + const kills = G.kills - killsAtStart; + const ammoUsed = w.ammo - Math.max(0, p.weapon.id === wid ? p.weapon.ammo : 0); + return { + id: wid, name: w.name, + ammo: w.ammo, refill: w.refill, + seconds: t, + kills, + damage, + ammoUsed, + dmgPerAmmo: damage / Math.max(1, ammoUsed), + killsPerMag: kills, + killsPerRefill: kills * (w.refill / Math.max(1, ammoUsed)), + }; +} + +const args = process.argv.slice(2); +const ids = args.length ? args + : Object.values(boot(1).WEAPONS).filter(w => w.ammo !== Infinity).map(w => w.id); + +console.log('floor ' + FLOOR + ' arena, crowd ' + CROWD + ', deterministic seed'); +console.log('weapon ammo refill time kills dmg/ammo kills/refill band'); +console.log('---------------- ---- ------ ----- ----- -------- ------------ ----'); +let outOfBand = 0; +for (const wid of ids) { + // average two seeds to dampen single-run luck without losing determinism + const runs = [simulate(wid, 1337), simulate(wid, 9001)]; + const r = { + id: wid, name: runs[0].name, ammo: runs[0].ammo, refill: runs[0].refill, + seconds: (runs[0].seconds + runs[1].seconds) / 2, + kills: (runs[0].kills + runs[1].kills) / 2, + dmgPerAmmo: (runs[0].dmgPerAmmo + runs[1].dmgPerAmmo) / 2, + killsPerRefill: (runs[0].killsPerRefill + runs[1].killsPerRefill) / 2, + }; + const ok = r.killsPerRefill >= 8 && r.killsPerRefill <= 13; + if (!ok) outOfBand++; + console.log( + r.name.padEnd(17), + String(r.ammo).padStart(5), + String(r.refill).padStart(7), + (r.seconds.toFixed(1) + 's').padStart(7), + r.kills.toFixed(0).padStart(6), + r.dmgPerAmmo.toFixed(1).padStart(9), + r.killsPerRefill.toFixed(1).padStart(12), + (ok ? ' ok' : ' OUT') , + ); +} +console.log(outOfBand ? '\n' + outOfBand + ' weapon(s) outside the 8-13 kills/refill band' : '\nall weapons inside the 8-13 kills/refill band'); +process.exit(outOfBand ? 1 : 0); diff --git a/tools/score_weapons.js b/tools/score_weapons.js index f28d081..721b8c7 100644 --- a/tools/score_weapons.js +++ b/tools/score_weapons.js @@ -56,6 +56,7 @@ function accuracy(w) { // Total damage in one ammo load (with the same behavior multipliers as DPS). function damagePool(w) { if (w.ammo === Infinity) return Infinity; + if (w.drain) return effectiveDps(w) * (w.ammo / w.drain); // time-denominated magazine const perShot = effectiveDps(w) * w.interval; return perShot * w.ammo; }