// ---- the butcher (player) ---- function initPlayer() { G.player = { x: W / 2, y: H / 2, r: 20, hp: 6, shieldHp: 0, aim: 0, bodyFacing: 0, fireT: 0, charge: 0, invT: 0, frenzyT: 0, step: 0, stepTick: 0, moveBlend: 0, recoil: 0, muzzleT: 0, animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0, weapon: { id: 'bonepopper', ammo: Infinity }, holstered: null, // a special weapon set aside while the pistol is out level: 1, xp: 0, items: {}, // iid -> quality tier (1..3) orbAng: 0, stats: { dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1, pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0, orbitals: 0, lifestealChance: 0, explodeOnKill: 0, armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0, bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1, roomHeal: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0, orbSpeedMul: 1, orbDmgMul: 1, overShield: 0, rerolls: 0, rerollPerLevel: 0, maxHp: 6, }, }; } function refreshOrbitals(p) { /* orbital count is read live from stats */ } // R: swap between the Bone Popper and your picked-up special weapon function swapWeapon(silent) { const p = G.player; if (!p.holstered && p.weapon.id === 'bonepopper') return; // nothing to swap if (p.weapon.id === 'bonepopper' && p.holstered && p.holstered.ammo <= 0) { if (!silent) addToast(WEAPONS[p.holstered.id].name, 'empty — find ammo'); Sfx.menu(); return; } const tmp = p.weapon; p.weapon = p.holstered || { id: 'bonepopper', ammo: Infinity }; p.holstered = tmp.id === 'bonepopper' ? null : tmp; // the pistol itself is never holstered p.charge = 0; p.fireT = 0; Sfx.pickup(); if (!silent) addToast(WEAPONS[p.weapon.id].name, p.weapon.id === 'bonepopper' ? 'sidearm out' : 'back in hand'); } function updatePlayer(dt) { const p = G.player; const st = p.stats; if (p.invT > 0) p.invT -= dt; if (p.hitT > 0) p.hitT -= dt; if (p.frenzyT > 0) p.frenzyT -= dt; p.animT += dt; if (p.attackT > 0) { p.attackT -= dt; p.actionT += dt; } // movement let mx = 0, my = 0; if (keyDown('w', 'arrowup')) my -= 1; if (keyDown('s', 'arrowdown')) my += 1; if (keyDown('a', 'arrowleft')) mx -= 1; if (keyDown('d', 'arrowright')) mx += 1; if (mx || my) { const len = Math.hypot(mx, my); const spd = 178 * st.speedMul; p.x += (mx / len) * spd * dt; p.y += (my / len) * spd * dt; p.step += dt * 9 * Math.min(st.speedMul, 1.8); const stepTick = Math.floor(p.step / Math.PI); if (stepTick !== p.stepTick && p.hp > 0) Sfx.footstep(p.moveBlend, onGore(p.x, p.y)); p.stepTick = stepTick; } else { // Finish on a planted contact frame instead of freezing halfway through a stride. const planted = Math.round(p.step / Math.PI) * Math.PI; p.step = lerp(p.step, planted, clamp(dt * 10, 0, 1)); p.stepTick = Math.round(p.step / Math.PI); } p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1)); p.recoil = Math.max(0, p.recoil - dt * 7); p.muzzleT = Math.max(0, p.muzzleT - dt); p.x = clamp(p.x, WALL + p.r, W - WALL - p.r); p.y = clamp(p.y, WALL + p.r, H - WALL - p.r); // aim p.aim = angleTo(p.x, p.y, Input.mx, Input.my); // The waist and legs turn smoothly with travel while the weapon torso aims freely. // Once planted, the lower body turns back under the independently aimed torso. if (mx || my) { p.bodyFacing = angleLerp(p.bodyFacing, Math.atan2(my, mx), clamp(dt * 12, 0, 1)); } else { p.bodyFacing = angleLerp(p.bodyFacing, p.aim, clamp(dt * 8, 0, 1)); } // firing const w = WEAPONS[p.weapon.id]; const sustained = ['cone', 'flame', 'saw', 'beam'].includes(w.behavior); Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0); if (p.fireT > 0) p.fireT -= dt; const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1; const effectiveRate = st.rateMul * frenzyMul; const rate = w.interval / effectiveRate; if (w.behavior === 'saw') { if (Input.mdown && p.weapon.ammo > 0) { sawTick(p, w, p.weapon, dt); 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.behavior === 'beam') { if (Input.mdown && p.weapon.ammo > 0) { p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime); } else if (Input.mreleased && p.charge > 0) { if (p.charge >= w.chargeTime * 0.4) fireBeam(p, w, p.charge / w.chargeTime); p.charge = 0; } else { p.charge = Math.max(0, p.charge - dt * 2); } } else { if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) { fireWeapon(p, w); p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45)); p.muzzleT = 0.07; p.attackT = 0.4; p.actionT = 0; p.fireT = rate; if (p.weapon.ammo !== Infinity) p.weapon.ammo -= 1 / st.ammoEff; Sfx.shoot(w); if (w.behavior === 'slam') addShake(5); } } // out of ammo -> back to the Bone Popper if (p.weapon.ammo <= 0 && p.weapon.id !== 'bonepopper') { const spent = p.weapon; p.weapon = { id: 'bonepopper', ammo: Infinity }; p.holstered = spent; p.charge = 0; p.fireT = 0; Sfx.stopAllLoops(); addToast('Bone Popper', WEAPONS[spent.id].name + ' is empty'); } // orbital knives if (st.orbitals > 0) { p.orbAng += dt * 3.2 * st.orbSpeedMul; const n = st.orbitals; for (let i = 0; i < n; i++) { const a = p.orbAng + (i / n) * TAU; const ox = p.x + Math.cos(a) * 58, oy = p.y + Math.sin(a) * 58; for (const e of G.enemies) { if (e.orbT > 0) continue; if (dist2(ox, oy, e.x, e.y) < (12 + e.r) * (12 + e.r)) { e.orbT = 0.35; damageEnemy(e, 12 * st.dmgMul * st.orbDmgMul, a + Math.PI / 2, true, { source: 'player', procScale: 0.5, procMagnitudeScale: 0.5, }); spawnBlood(e.x, e.y, a, 3); } } } } // open queued level-ups if (G.pendingLevelups > 0 && G.mode === 'play') openPerkDraft(); } function armorBlockChance(rating) { return Math.min(0.75, Math.max(0, rating) / (1 + Math.max(0, rating))); } function pressureRelief(dmgTaken) { const p = G.player; const hpFrac = clamp(p.hp / Math.max(1, p.stats.maxHp), 0, 1); const severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2); const desperation = 1 + (1 - hpFrac) * 2; const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35; return 0.03 * severity * desperation * churn; } function hurtPlayer(dmg, ang, attacker) { const p = G.player; if (p.invT > 0 || p.hp <= 0 || G.mode !== 'play') return; if (p.stats.armor > 0 && chance(armorBlockChance(p.stats.armor))) { p.invT = 0.2; p.hitT = 0.1; spawnText(p.x, p.y - 20, 'BLOCKED', '#d8c991'); addShake(2); return; } let reduced = Math.max(1, Math.round(dmg)); // shield hearts absorb first (from the Shield Heart perk) if (p.shieldHp > 0) { const absorbed = Math.min(p.shieldHp, reduced); p.shieldHp -= absorbed; reduced -= absorbed; if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); } } p.hp -= reduced; p.hitT = 0.18; p.invT = 0.9 + p.stats.invBonus; G.flash = 0.35; addShake(7); Sfx.hurt(); spawnBlood(p.x, p.y, ang, 8); if (ang) { p.x += Math.cos(ang) * 14; p.y += Math.sin(ang) * 14; } if (reduced > 0) { G.roomDamaged = true; G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); G.recentHits.push(G.time); G.pressure = clamp(G.pressure - pressureRelief(reduced), PRESSURE_MIN, PRESSURE_MAX); G.streak = 0; } if (attacker && attacker.hp > 0 && p.stats.thorns > 0) { damageEnemy(attacker, p.stats.thorns * p.stats.dmgMul, angleTo(p.x, p.y, attacker.x, attacker.y), true, { noProc: true }); } if (p.stats.retaliate > 0) { areaDamage(p.x, p.y, 85 * Math.sqrt(p.stats.sizeMul), p.stats.retaliate * p.stats.dmgMul, true, { noProc: true }); spawnExplosionFx(p.x, p.y, 70); } if (p.hp <= 0) { p.hp = 0; p.deathT = 0; gameOver(); } } function healPlayer(n) { const p = G.player; const missing = Math.max(0, p.stats.maxHp - p.hp); const healed = Math.min(missing, n); p.hp += healed; const overflow = n - healed; if (overflow > 0 && p.stats.overShield > 0) { p.shieldHp = Math.min((p.stats.shieldPerk || 0) + p.stats.overShield, p.shieldHp + overflow); } } // Lifesteal has dedicated feedback so ordinary healing sources do not double-print. function lifestealPlayer(amount) { const p = G.player; if (!p || p.hp >= p.stats.maxHp) return false; const before = p.hp; healPlayer(amount); const healed = p.hp - before; if (healed <= 0) return false; const hearts = healed / 2; const label = hearts === 0.5 ? '+½ HEART' : ('+' + hearts + (hearts === 1 ? ' HEART' : ' HEARTS')); spawnText(p.x, p.y - 18, label, '#d92038'); Sfx.lifesteal(); return true; } function drawPlayer(ctx) { const p = G.player; const blink = p.invT > 0 && Math.floor(G.time * 20) % 2 === 0; if (blink && p.hp > 0) return; // orbital knives const st = p.stats; if (st.orbitals > 0) { for (let i = 0; i < st.orbitals; i++) { const a = p.orbAng + (i / st.orbitals) * TAU; Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 58, p.y + Math.sin(a) * 58, a + Math.PI / 2, 24); } } const w = WEAPONS[p.weapon.id]; const kick = p.recoil * 3; Sprites.shadow(ctx, p.x, p.y + 17, 27 + p.moveBlend * 2, 9, 0.46); if (p.hp <= 0) { // Preserve the authored full-body fall while the live character stays layered. Sprites.actor(ctx, 'player', p.x, p.y, p.bodyFacing, 'death', p.deathT || 0, 96); } else { Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124); const torsoOffset = (w.torsoFwd || 18) - kick; const torsoX = p.x + Math.cos(p.aim) * torsoOffset; const torsoY = p.y + Math.sin(p.aim) * torsoOffset; Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, p.aim, w.torsoW || 116, p.hitT > 0); } if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam') { const muzzleDist = (w.muzzle || 40) - kick; const mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist; ctx.save(); ctx.translate(mx, my); ctx.rotate(p.aim); ctx.globalAlpha = p.muzzleT / 0.07; ctx.fillStyle = '#ffe6a0'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(13, -5); ctx.lineTo(9, 0); ctx.lineTo(13, 5); ctx.closePath(); ctx.fill(); ctx.restore(); } // Spinal Tap charge indicator if (w.behavior === 'beam' && p.charge > 0.05) { const k = p.charge / w.chargeTime; ctx.strokeStyle = k >= 1 ? '#60c0ff' : '#2a5a8a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 8, -Math.PI / 2, -Math.PI / 2 + TAU * k); ctx.stroke(); if (k >= 1 && chance(0.3)) { addParticle({ type: 'spark', x: p.x + rand(-10, 10), y: p.y + rand(-10, 10), vx: 0, vy: -30, life: 0.2, t: 0, r: 1.5 }); } } }