From 85238be844ba5b95b0d73a0ae253b0460de766c0 Mon Sep 17 00:00:00 2001 From: Quintine Date: Thu, 13 Aug 2026 13:50:50 +1000 Subject: [PATCH] feat: clickable pause buttons, 2x dash with 30% slow, cleaver melee sweep --- docs/gameplay.md | 2 +- docs/weapons.md | 14 +++++++------- js/main.js | 34 ++++++++++++++++++++++------------ js/particles.js | 10 ++++++++++ js/player.js | 6 +++--- js/utils.js | 1 + js/weapons.js | 19 +++++++++++++++++-- test/smoke.js | 8 ++++---- tools/ammo_sim.js | 2 +- tools/score_weapons.js | 1 + 10 files changed, 67 insertions(+), 30 deletions(-) diff --git a/docs/gameplay.md b/docs/gameplay.md index d1934a2..c7203ad 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -54,7 +54,7 @@ Your best score is saved in `localStorage` as `meatslicer_best` and shown on the | **T** | Toggle Auto-Draft (auto-pick random perk on level-up) | | **M** | Mute / unmute | | **N** | Next music track | -| **Shift** | Dash in the movement direction; 0.6s of 50% slow after (net 95% of walking distance) | +| **Shift** | Dash in the movement direction; 2× length, 0.6s of 30% slow | ### Perk draft screen (level-up) diff --git a/docs/weapons.md b/docs/weapons.md index a22726b..5697282 100644 --- a/docs/weapons.md +++ b/docs/weapons.md @@ -106,18 +106,18 @@ Shotgun. Up to 66 damage per trigger pull at point blank before multipliers, but ### Cleaver Cadence -> *It always comes back* +> *A wide, dramatic melee sweep* | Stat | Value | |---|---| -| Damage | 15 | -| Fire interval | 0.38 s | -| Projectile speed | 400 (returns at up to 520) | -| Range | 1.10 s | +| Damage | 34 | +| Fire interval | 0.55 s | +| Reach | 90 px | +| Arc | 2.4 rad (~137°) | | Ammo | 26 (refill 10 per pickup) | -| Recoil | 0.42 | +| Recoil | 0.95 (heavy) | -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. +Melee sweep: one wide swing across the aim direction hits every enemy within 90 px inside a ~137° arc for 34 damage with heavy knockback. ### Sawblade Launcher diff --git a/js/main.js b/js/main.js index 4193a23..03db435 100644 --- a/js/main.js +++ b/js/main.js @@ -7,6 +7,10 @@ const MENU_EXIT_BUTTON = { x: W - 208, y: H - 48, w: 180, h: 28 }; const PAUSE_MENU_BUTTON = { x: W / 2 - 75, y: 526, w: 150, h: 28 }; const PAUSE_EXIT_BUTTON = { x: W / 2 + 87, y: 526, w: 150, h: 28 }; const HD_TOGGLE_BUTTON = { x: 28, y: H - 48, w: 220, h: 28 }; +const PAUSE_RESUME_BUTTON = { x: W / 2 - 237, y: 492, w: 150, h: 28 }; +const PAUSE_SWAP_BUTTON = { x: W / 2 - 75, y: 492, w: 150, h: 28 }; +const PAUSE_AUTO_BUTTON = { x: W / 2 + 87, y: 492, w: 150, h: 28 }; +const PAUSE_MUTE_BUTTON = { x: W / 2 - 237, y: 526, w: 150, h: 28 }; const CONFIRM_WINDOW = 3; function pauseBars() { @@ -59,6 +63,11 @@ function setPressureDial(n) { try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {} Sfx.menu(); } +function toggleMute() { + G.muted = !G.muted; + Music.setMuted(G.muted); + Sfx.setMuted(G.muted); +} function setHudAlpha(v) { G.hudAlpha = clamp(Math.round(v * 100) / 100, 0.25, 1); @@ -194,11 +203,7 @@ function update(dt) { } // global keys - if (keyPressed('m')) { - G.muted = !G.muted; - Music.setMuted(G.muted); - Sfx.setMuted(G.muted); - } + if (keyPressed('m')) toggleMute(); switch (G.mode) { case 'menu': { @@ -265,12 +270,13 @@ function update(dt) { const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON); if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; } if (canQuitDesktop() && (keyPressed('x') || exitClick)) { confirmAction('desktop', quitToDesktop); break; } - if (keyPressed('p', 'escape')) { G.mode = 'play'; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; } + if (keyPressed('p', 'escape') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_RESUME_BUTTON))) { G.mode = 'play'; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; } if (keyPressed('h') || (Input.mpressed && inRect(Input.mx, Input.my, HELP_BUTTON))) { G.pauseHelp = true; G.helpPage = 0; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; } - if (keyPressed('r')) swapWeapon(true); - if (keyPressed('t')) setAutoPerk(!G.autoPerk); + if (keyPressed('r') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON))) swapWeapon(true); + if (keyPressed('t') || (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_AUTO_BUTTON))) setAutoPerk(!G.autoPerk); + if (Input.mpressed && inRect(Input.mx, Input.my, PAUSE_MUTE_BUTTON)) { toggleMute(); break; } if (keyPressed('-', '_')) { Sfx.setVolume(G.sfxVol - 0.1); Sfx.menu(); } if (keyPressed('=', '+')) { Sfx.setVolume(G.sfxVol + 0.1); Sfx.menu(); } if (keyPressed(',', '<')) { Music.setVolume(G.musicVol - 0.1); Sfx.menu(); } @@ -541,15 +547,19 @@ function drawPause(ctx) { } const gridX = [W / 2 - 237, W / 2 - 75, W / 2 + 87]; - drawPixelTag(ctx, '[P] RESUME', gridX[0], 492, { width: 150, height: 28, color: '#f0e5d7', accent: '#b5243a' }); - drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' }); - drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), gridX[2], 492, { width: 150, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' }); + const resumeHover = inRect(Input.mx, Input.my, PAUSE_RESUME_BUTTON); + const swapHover = inRect(Input.mx, Input.my, PAUSE_SWAP_BUTTON); + const autoHover = inRect(Input.mx, Input.my, PAUSE_AUTO_BUTTON); + const muteHover = inRect(Input.mx, Input.my, PAUSE_MUTE_BUTTON); + drawPixelTag(ctx, '[P] RESUME', gridX[0], 492, { width: 150, height: 28, color: resumeHover ? '#f5e9d6' : '#bba9a1', accent: resumeHover ? '#b5243a' : '#6e3843' }); + drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: swapHover ? '#f5e9d6' : '#bba9a1', accent: swapHover ? '#b5243a' : '#6e3843' }); + drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), gridX[2], 492, { width: 150, height: 28, color: G.autoPerk ? '#79d9ca' : (autoHover ? '#f5e9d6' : '#bba9a1'), accent: G.autoPerk ? '#378b80' : (autoHover ? '#b5243a' : '#6e3843') }); const menuHover = inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON); const exitHover = inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON); const menuConfirm = G.confirmAction === 'menu'; const desktopExit = canQuitDesktop(); const exitConfirm = desktopExit && G.confirmAction === 'desktop'; - drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' }); + drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: muteHover ? '#f5e9d6' : '#bba9a1', accent: muteHover ? '#b5243a' : '#6e3843' }); drawPixelTag(ctx, menuConfirm ? '[Q] CONFIRM MENU' : '[Q] MAIN MENU', PAUSE_MENU_BUTTON.x, PAUSE_MENU_BUTTON.y, { width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h, color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'), diff --git a/js/particles.js b/js/particles.js index b7cf972..de0d867 100644 --- a/js/particles.js +++ b/js/particles.js @@ -104,6 +104,10 @@ function spawnShockwave(x, y, radius, color, alpha, important) { }, important); } +function spawnSweepFx(x, y, ang, reach, arc) { + addParticle({ type: 'sweep', x, y, ang, arc, r: 20, maxR: reach, life: 0.18, t: 0 }, true); +} + function spawnMuzzleFx(x, y, ang, behavior) { if (behavior === 'slam') return; const back = ang + Math.PI; @@ -254,6 +258,12 @@ function drawParticles(ctx) { ctx.stroke(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); + } else if (p.type === 'sweep') { + const progress = 1 - k; + const radius = lerp(p.r, p.maxR, progress); + ctx.globalAlpha = k; + ctx.strokeStyle = '#f0c8b2'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(p.x, p.y, radius, p.ang - p.arc / 2, p.ang + p.arc / 2); ctx.stroke(); } else if (p.type === 'text') { ctx.globalAlpha = Math.min(1, k * 2); ctx.fillStyle = p.color; diff --git a/js/player.js b/js/player.js index 98e6b49..082f9e7 100644 --- a/js/player.js +++ b/js/player.js @@ -1,6 +1,6 @@ // ---- the butcher (player) ---- -const DASH_SLOW_T = 0.6; // seconds of 50% move speed after a dash; doubles as the dash cooldown +const DASH_SLOW_T = 0.6; // seconds of 30% move speed after a dash; doubles as the dash cooldown function defaultPlayerStats() { return { @@ -86,7 +86,7 @@ function updatePlayer(dt) { const len = Math.hypot(mx, my); const firing = held ? (w.id === 'bonepopper' ? 0.95 : 0.85) : 1; if (keyPressed('shift') && p.dashSlowT <= 0) { - const dashD = 0.45 * (178 * st.speedMul * firing) * DASH_SLOW_T; + const dashD = 0.9 * (178 * st.speedMul * firing) * DASH_SLOW_T; p.x += (mx / len) * dashD; p.y += (my / len) * dashD; p.dashSlowT = DASH_SLOW_T; @@ -94,7 +94,7 @@ function updatePlayer(dt) { spawnShockwave(p.x, p.y, 26, '#d8c9a8', 0.35); Sfx.duck(); } - const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.5 : 1); + const spd = 178 * st.speedMul * firing * (p.dashSlowT > 0 ? 0.3 : 1); p.x += (mx / len) * spd * dt; p.y += (my / len) * spd * dt; p.step += dt * 9 * Math.min(st.speedMul, 1.8); diff --git a/js/utils.js b/js/utils.js index 77862d3..c9e16af 100644 --- a/js/utils.js +++ b/js/utils.js @@ -16,6 +16,7 @@ function angleLerp(a, b, t) { if (d < -Math.PI) d += TAU; return a + d * t; } +function angleDelta(a, b) { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; } // distance from point to segment (for beam weapons) function distToSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; diff --git a/js/weapons.js b/js/weapons.js index 3c33734..0bbf317 100644 --- a/js/weapons.js +++ b/js/weapons.js @@ -1,6 +1,6 @@ // ---- The Butcher's Arsenal: 16 horrorcore weapons ---- // behavior: bullet | spread | boomerang | bounce | cone | flame | homing | -// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam +// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam | sweep // Ammo economy: `ammo` is the magazine (trigger-time / burst feel), `refill` // is what one ammo pickup hands back (~10 kills' worth). Sustained-stream @@ -11,7 +11,7 @@ const WEAPONS = { bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' }, repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 84, refill: 42, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' }, marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 20, refill: 6, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' }, - cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' }, + cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 1, ammo: 26, refill: 10, interval: 0.55, dmg: 34, behavior: 'sweep', sprite: 'bullet_cleaver', sfx: 'heavy', punch: 0.95, sweepRange: 90, sweepArc: 2.4, desc: 'A wide, dramatic melee sweep' }, 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' }, @@ -131,6 +131,21 @@ function fireWeapon(p, w) { Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 }); break; } + case 'sweep': { + const reach = (w.sweepRange || 90) * st.rangeMul; + const half = (w.sweepArc || 2.4) / 2; + for (const e of G.enemies) { + if (e.phased) continue; + const d = dist(p.x, p.y, e.x, e.y); + if (d > reach + e.r) continue; + const a = angleTo(p.x, p.y, e.x, e.y); + if (Math.abs(angleDelta(a, ang)) > half) continue; + damageEnemy(e, w.dmg * dmgMul, a, true, { source: 'player' }); + } + spawnSweepFx(p.x, p.y, ang, reach, w.sweepArc || 2.4); + addShake(4); + break; + } default: { // bullet, bounce and homing projectiles for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0)); } diff --git a/test/smoke.js b/test/smoke.js index c33627d..0ac852a 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -1069,7 +1069,7 @@ console.log('== per-weapon muzzle spawn distances =='); p.aim = 0; const badMuzzles = []; for (const w of Object.values(ctx.WEAPONS)) { - if (['slam', 'saw', 'beam', 'flame'].includes(w.behavior)) continue; + if (['slam', 'saw', 'beam', 'flame', 'sweep'].includes(w.behavior)) continue; ctx.G.bullets.length = 0; ctx.fireWeapon(p, w); const b = ctx.G.bullets[0]; @@ -1200,7 +1200,7 @@ console.log('== dash: burst, slow, cooldown =='); release('shift'); const dashDx = p.x - xBeforeDash; check('dashSlowT > 0 after dash', p.dashSlowT > 0); - check('dash burst within 5% of 0.45*178*0.6', Math.abs(dashDx - 0.45*178*0.6) <= 0.05 * 0.45*178*0.6); + check('dash burst within 5% of 0.9*178*0.6', Math.abs(dashDx - 0.9*178*0.6) <= 0.05 * 0.9*178*0.6); let slowDist = 0; let slowFrames = 0; let sampledSlowDx = 0; @@ -1225,8 +1225,8 @@ console.log('== dash: burst, slow, cooldown =='); slowFrames++; } } - check('slow per-frame dx is roughly half baseline', sampledSlowDx > 0 && sampledSlowDx >= 0.4 * baselineDx && sampledSlowDx <= 0.6 * baselineDx); - check('total dash (burst + slow) within 8% of 0.95*178*0.6', Math.abs((dashDx + slowDist) - 0.95*178*0.6) <= 0.08 * 0.95*178*0.6); + check('slow per-frame dx is roughly 30% of baseline', sampledSlowDx > 0 && sampledSlowDx >= 0.25 * baselineDx && sampledSlowDx <= 0.35 * baselineDx); + check('total dash (burst + slow) within 8% of 1.2*178*0.6', Math.abs((dashDx + slowDist) - 1.2*178*0.6) <= 0.08 * 1.2*178*0.6); ctx.Input.keys.d = false; step(3, 16); const xBeforeIdle = p.x; diff --git a/tools/ammo_sim.js b/tools/ammo_sim.js index d1bf700..8ec5d7a 100644 --- a/tools/ammo_sim.js +++ b/tools/ammo_sim.js @@ -83,7 +83,7 @@ function boot(seed) { 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, + homing: 0, lob: 0, lob_trap: 0, lob_swarm: 0, slam: 0, saw: 0, beam: 0, sweep: 0, }; const FLOOR = 3; // mid-game enemy mix diff --git a/tools/score_weapons.js b/tools/score_weapons.js index 721b8c7..40a3687 100644 --- a/tools/score_weapons.js +++ b/tools/score_weapons.js @@ -28,6 +28,7 @@ function effectiveDps(w) { case 'lob_trap': return base * 1.3; // direct + trap zone control case 'lob_swarm': return (w.dmg * 6) / w.interval * 0.85; // 6 homing maggots case 'slam': return base * 1.3; // AoE multi-hit + case 'sweep': return base * 1.5; // arc can hit multiple enemies case 'saw': return base * 1.1; // continuous contact case 'beam': return (w.dmg * 1.0) / ((w.chargeTime || 0.8) + w.interval) * 1.2; // full-charge cycle, line pierce default: return base;