- dmgLiveMul live damage channel wired into fireWeapon, sawTick, fireBeam, orbitals - Dead Weight (execute), Cauterized Veins (burn bonus), Pain Engine, Iron Lung, Slaughter Rhythm, Hollow Choir, Sawbone Coil, Glutton's Gut, Thresher Plate, Blood Moat, Meat Hook, Blood Debt - Iron Lung re-armed per room in enterRoom; Slaughter Rhythm stamps kills - Targeted smoke tests for the five stat-conditional effects; icons + docs
347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
// ---- 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, roomHealChance: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0,
|
|
orbSpeedMul: 1, orbDmgMul: 1, overShield: 0,
|
|
rerolls: 0, rerollPerLevel: 0,
|
|
// phase-3 conditional / hook passives
|
|
dmgLiveMul: 1, executeBonus: 0, burnDamageBonus: 0, choirEvery: 0, sawboneCoil: 0,
|
|
gluttonGut: 0, slaughterRhythm: 0, painEngine: 0, thresherPlate: 0, bloodMoat: 0,
|
|
ironLung: 0, meatHook: 0, bloodDebt: 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 * (1 + (st.rhythmRateBonus || 0)) * 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);
|
|
// Hollow Choir: every 4th shot fires a free extra volley
|
|
if (st.choirEvery > 0) {
|
|
p.choirCount = (p.choirCount || 0) + 1;
|
|
if (p.choirCount >= 4) { p.choirCount = 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.dmgLiveMul * 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();
|
|
|
|
// ---- phase-3 live multipliers & auras ----
|
|
// Slaughter Rhythm: +rate per recent kill, Pain Engine: +dmg after being hit
|
|
p.killStamps = (p.killStamps || []).filter(t => G.time - t < 3);
|
|
const rhythm = st.slaughterRhythm > 0 ? Math.min(p.killStamps.length * st.slaughterRhythm, 0.40) : 0;
|
|
st.rhythmRateBonus = rhythm;
|
|
if (p.painEngineT > 0) p.painEngineT -= dt;
|
|
st.dmgLiveMul = p.painEngineT > 0 ? 1 + st.painEngine : 1;
|
|
|
|
// Thresher Plate: passive contact-damage aura (no need to be hit)
|
|
if (st.thresherPlate > 0) {
|
|
p.thresherTick = (p.thresherTick || 0) - dt;
|
|
if (p.thresherTick <= 0) {
|
|
p.thresherTick = 0.4;
|
|
const range = 46 * Math.sqrt(st.sizeMul);
|
|
for (const e of G.enemies) {
|
|
if (e.hp <= 0 || e.boss) continue;
|
|
if (dist2(e.x, e.y, p.x, p.y) < (range + e.r) * (range + e.r)) {
|
|
damageEnemy(e, 6 * st.thresherPlate * st.dmgMul * st.dmgLiveMul, angleTo(p.x, p.y, e.x, e.y), true, { noProc: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Iron Lung room-block cooldown flag is set in enterRoom
|
|
}
|
|
|
|
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 PRESSURE_DROP_BASE * PRESSURE_UNIT * severity * desperation * churn * pressureDropScale();
|
|
}
|
|
|
|
function hurtPlayer(dmg, ang, attacker) {
|
|
const p = G.player;
|
|
if (p.invT > 0 || p.hp <= 0 || G.mode !== 'play') return;
|
|
// Iron Lung: the first hit each room is blocked
|
|
if (p.ironLungReady) {
|
|
p.ironLungReady = false;
|
|
p.invT = 0.5; p.hitT = 0.1;
|
|
spawnText(p.x, p.y - 20, 'IRON LUNG', '#9fb4bd');
|
|
addShake(2); Sfx.shieldUp();
|
|
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;
|
|
if (reduced > 0 && p.stats.painEngine > 0) p.painEngineT = 4;
|
|
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 });
|
|
}
|
|
}
|
|
}
|