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
This commit is contained in:
MeatSlicer Developer committed 2026-07-30 22:06:10 +10:00
1 parent 7d33e36c1e
commit d9eae34df4
18 files changed
+380 -101

No files matched your search

+1
View File
@@ -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"*
+3 -3
View File
@@ -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.
+28 -24
View File
@@ -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
```
+21 -21
View File
@@ -6,31 +6,31 @@
<meta name="theme-color" content="#0a0305">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><text y=%2252%22 x=%2250%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-size=%2252%22>🔪</text></svg>">
<title>MEATSLICER</title>
<link rel="stylesheet" href="css/style.css?v=43">
<link rel="stylesheet" href="css/style.css?v=44">
</head>
<body>
<div id="wrap">
<canvas id="game" width="960" height="640"></canvas>
</div>
<script src="js/utils.js?v=43"></script>
<script src="js/state.js?v=43"></script>
<script src="js/input.js?v=43"></script>
<script src="js/sfx.js?v=43"></script>
<script src="js/sfxbank.js?v=43"></script>
<script src="js/audio.js?v=43"></script>
<script src="js/sprites.js?v=43"></script>
<script src="js/particles.js?v=43"></script>
<script src="js/weapons.js?v=43"></script>
<script src="js/bullets.js?v=43"></script>
<script src="js/pickups.js?v=43"></script>
<script src="js/items.js?v=43"></script>
<script src="js/perks.js?v=43"></script>
<script src="js/enemies.js?v=43"></script>
<script src="js/bosses.js?v=43"></script>
<script src="js/rooms.js?v=43"></script>
<script src="js/player.js?v=43"></script>
<script src="js/hud.js?v=43"></script>
<script src="js/help.js?v=43"></script>
<script src="js/main.js?v=43"></script>
<script src="js/utils.js?v=44"></script>
<script src="js/state.js?v=44"></script>
<script src="js/input.js?v=44"></script>
<script src="js/sfx.js?v=44"></script>
<script src="js/sfxbank.js?v=44"></script>
<script src="js/audio.js?v=44"></script>
<script src="js/sprites.js?v=44"></script>
<script src="js/particles.js?v=44"></script>
<script src="js/weapons.js?v=44"></script>
<script src="js/bullets.js?v=44"></script>
<script src="js/pickups.js?v=44"></script>
<script src="js/items.js?v=44"></script>
<script src="js/perks.js?v=44"></script>
<script src="js/enemies.js?v=44"></script>
<script src="js/bosses.js?v=44"></script>
<script src="js/rooms.js?v=44"></script>
<script src="js/player.js?v=44"></script>
<script src="js/hud.js?v=44"></script>
<script src="js/help.js?v=44"></script>
<script src="js/main.js?v=44"></script>
</body>
</html>
+2 -1
View File
@@ -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
+13 -6
View File
@@ -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;
+3 -1
View File
@@ -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;
+4 -12
View File
@@ -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;
+15
View File
@@ -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);
+9 -3
View File
@@ -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
+1 -1
View File
@@ -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)))
+1 -1
View File
@@ -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';
}
},
+1 -2
View File
@@ -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
+23 -17
View File
@@ -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
+1 -1
View File
@@ -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",
+22 -8
View File
@@ -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;
+231
View File
@@ -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);
+1
View File
@@ -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;
}