Files
MeatSlicer/js/player.js
T
MeatSlicer Developer a02cff52cf Add pressure dial, wave spawn telegraphs, and readability pass
- Bigger gore-red Minis (r10->14, sprite 32->48) with regenerated art
- Wider, walkable doorways: clear passage for open, teeth only when locked
- 500ms spawn telegraph so wave spawns no longer blindside the player
- Item stickiness ramps 10%/owned item (cap 50%) instead of flat 50%
- Title-screen Pressure Dial (-5..+5) tunes rise/relief; score x live pressure
- Vitals HUD: two-row hearts, half-container outlines, half-heart shield pips
- Readable 8-page field manual (bigger panel + fonts, implants split)
- HUD opacity slider in pause menu (boss bar + toasts stay opaque)
- Scrap Feed now has diminishing returns like Thick Hide
- Docs, cache-bust v37, and smoke coverage for all of the above
2026-07-30 00:01:06 +10:00

305 lines
11 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,
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 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;
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 });
}
}
}