Add adaptive progression and item expansion

This commit is contained in:
MeatSlicer Developer committed 2026-07-29 18:30:21 +10:00
commit 8ab0857ee0
298 files changed
+8587

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
// ---- music manager: crossfading loops mapped to game moods ----
const Music = {
current: null, // { audio, name }
fading: null,
bossCount: 0,
TRACKS: {
menu: 'Blood Arcade.mp3',
floors: [
'Rust Hollow Loop.mp3', 'Blood Dice Loop.mp3', 'Crimsonland Requiem.mp3',
'Crimsonland Tool.mp3', 'Blood Arcade_Alt.mp3', 'Rust Hollow Loop_Alt.mp3',
'Blood Dice Loop_alt.mp3', 'Crimsonland Requiem_alt.mp3', 'Crimsonland Tool_alt.mp3',
'Blood Arcade.mp3',
],
bosses: {
bonesaw: ['Bone Saw Loop_Boss.mp3', 'Bone Saw Loop_Alt_Boss.mp3'],
gorecrown: ['Gore Crown Loop_Boss.mp3', 'Gore Crown Loop_Alt_Boss.mp3'],
knifecrawl: ['Knife Floor Crawl_Boss.mp3', 'Knife Floor Crawl_Alt_Boss.mp3'],
vealmother: ['Veal Mother Loop_Boss.mp3', 'Veal Mother Loop_Alt_Boss.mp3'],
flenser: ['Flenser Loop_Boss.mp3', 'Flenser Loop_Alt_Boss.mp3'],
hookchoir: ['Hook Choir Loop_Boss.mp3', 'Hook Choir Loop_Alt_Boss.mp3'],
platefather: ['Plate Father Loop_Boss.mp3', 'Plate Father Loop_Alt_Boss.mp3'],
augerprime: ['Auger Prime Loop_Boss.mp3', 'Auger Prime Loop_Alt_Boss.mp3'],
scald: ['Scald Loop_Boss.mp3', 'Scald Loop_Alt_Boss.mp3'],
},
},
// jukebox: every non-boss track, selectable by the player
PLAYLIST: [
'Blood Arcade.mp3', 'Blood Arcade_Alt.mp3',
'Rust Hollow Loop.mp3', 'Rust Hollow Loop_Alt.mp3',
'Blood Dice Loop.mp3', 'Blood Dice Loop_alt.mp3',
'Crimsonland Requiem.mp3', 'Crimsonland Requiem_alt.mp3',
'Crimsonland Tool.mp3', 'Crimsonland Tool_alt.mp3',
'Bone Saw Loop_Boss.mp3', 'Bone Saw Loop_Alt_Boss.mp3',
'Gore Crown Loop_Boss.mp3', 'Gore Crown Loop_Alt_Boss.mp3',
'Knife Floor Crawl_Boss.mp3', 'Knife Floor Crawl_Alt_Boss.mp3',
'Veal Mother Loop_Boss.mp3', 'Veal Mother Loop_Alt_Boss.mp3',
'Flenser Loop_Boss.mp3', 'Flenser Loop_Alt_Boss.mp3',
'Hook Choir Loop_Boss.mp3', 'Hook Choir Loop_Alt_Boss.mp3',
'Plate Father Loop_Boss.mp3', 'Plate Father Loop_Alt_Boss.mp3',
'Auger Prime Loop_Boss.mp3', 'Auger Prime Loop_Alt_Boss.mp3',
'Scald Loop_Boss.mp3', 'Scald Loop_Alt_Boss.mp3',
],
override: null, // filename when the player picked a track manually
pretty(file) {
return file.replace(/\.mp3$/i, '').replace(/_Boss$/i, '').replace(/_Alt$/i, ' (Alt)').replace(/_alt$/i, ' (Alt)');
},
// the track the game *should* be playing right now (respects manual override)
requestFloorMusic() {
if (this.override) this.play(this.override);
else this.playFloor(G.floor);
},
makeAudio(name) {
const a = new Audio('mp3-music/' + encodeURIComponent(name));
a.loop = true;
a.volume = 0;
a.preload = 'auto';
return a;
},
floorTrack(floor) { return this.TRACKS.floors[(floor - 1) % this.TRACKS.floors.length]; },
bossTrack(kind) {
const pair = this.TRACKS.bosses[kind] || this.TRACKS.bosses.bonesaw;
return pair[this.bossCount % 2];
},
play(name) {
if (this.current && this.current.name === name) return;
if (this.fading) {
this.fading.audio.pause();
if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio);
this.fading = null;
}
if (this.current) this.fading = this.current;
const a = this.makeAudio(name);
a.muted = G.muted;
if (typeof Sfx !== 'undefined') Sfx.attachMusic(a);
a.play().catch(() => {}); // browser may block until user gesture; retried on next call
this.current = { audio: a, name, retryT: 0 };
},
playMenu() { this.play(this.TRACKS.menu); },
playFloor(floor) { this.play(this.floorTrack(floor)); },
playBoss(kind) { this.play(this.bossTrack(kind)); this.bossCount++; },
stop() {
if (this.current) {
this.current.audio.pause();
if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.current.audio);
this.current = null;
}
if (this.fading) {
this.fading.audio.pause();
if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio);
this.fading = null;
}
},
setMuted(m) {
if (this.current) this.current.audio.muted = m;
if (this.fading) this.fading.audio.muted = m;
if (typeof Sfx !== 'undefined') Sfx.setMusicMuted(m);
},
setVolume(v) {
G.musicVol = clamp(v, 0, 1);
try { localStorage.setItem('meatslicer_music_volume', String(G.musicVol)); } catch (e) {}
if (this.current) this.current.audio.volume = Math.min(this.current.audio.volume, G.musicVol);
if (this.fading) this.fading.audio.volume = Math.min(this.fading.audio.volume, G.musicVol);
},
// cycle the jukebox selection: Auto -> track 0 -> ... -> track N -> Auto
cycle(dir) {
const n = this.PLAYLIST.length;
if (G.musicSel === undefined || G.musicSel === null) G.musicSel = -1;
G.musicSel += dir;
if (G.musicSel >= n) G.musicSel = -1;
if (G.musicSel < -1) G.musicSel = n - 1;
if (G.musicSel === -1) {
this.override = null;
addToast('MUSIC: AUTO', 'floor rotation');
} else {
this.override = this.PLAYLIST[G.musicSel];
addToast('MUSIC: ' + this.pretty(this.override));
}
// never interrupt a live boss track — it hands back when the boss dies
if (!G.boss) this.requestFloorMusic();
Sfx.pickup();
},
update(dt) {
const target = G.musicVol === undefined ? 0.55 : G.musicVol;
// Tracks created before the AudioContext resumed keep playing directly;
// attach once the context is running so large SFX can duck the music bus.
if (typeof Sfx !== 'undefined') {
if (this.current) Sfx.attachMusic(this.current.audio);
if (this.fading) Sfx.attachMusic(this.fading.audio);
}
if (this.current && this.current.audio.volume < target) {
this.current.audio.volume = clamp(this.current.audio.volume + dt * 0.8, 0, target);
// If autoplay was blocked, retry at a gentle cadence rather than once
// per frame (which can flood promises and the browser media pipeline).
this.current.retryT -= dt;
if (this.current.audio.paused && this.current.retryT <= 0) {
this.current.retryT = 1;
this.current.audio.play().catch(() => {});
}
}
if (this.fading) {
const v = this.fading.audio.volume - dt * 0.8;
if (v <= 0.01) {
this.fading.audio.volume = 0;
this.fading.audio.pause();
if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio);
this.fading = null;
} else {
this.fading.audio.volume = v;
}
}
},
};
+602
View File
@@ -0,0 +1,602 @@
// ---- nine rotating industrial-horror bosses ----
const BOSS_DEFS = [
{ kind: 'bonesaw', name: 'BONE SAW', sprite: 'boss_bonesaw', hp: 520, spd: 60, r: 45, drawSize: 128, debutCycle: 0 },
{ kind: 'gorecrown', name: 'GORE CROWN', sprite: 'boss_gorecrown', hp: 600, spd: 42, r: 48, drawSize: 128, debutCycle: 0 },
{ kind: 'knifecrawl', name: 'KNIFE FLOOR CRAWL', sprite: 'boss_knifecrawl', hp: 560, spd: 75, r: 45, drawSize: 128, debutCycle: 0 },
{ kind: 'vealmother', name: 'VEAL MOTHER', sprite: 'boss_vealmother', hp: 640, spd: 34, r: 52, drawSize: 128, debutCycle: 1 },
{ kind: 'flenser', name: 'THE FLENSER', sprite: 'boss_flenser', hp: 460, spd: 96, r: 40, drawSize: 128, debutCycle: 1 },
{ kind: 'hookchoir', name: 'HOOK CHOIR', sprite: 'boss_hookchoir', hp: 580, spd: 46, r: 50, drawSize: 128, debutCycle: 1 },
{ kind: 'platefather',name: 'PLATE FATHER', sprite: 'boss_platefather',hp: 700, spd: 38, r: 54, drawSize: 128, debutCycle: 2 },
{ kind: 'augerprime', name: 'AUGER PRIME', sprite: 'boss_augerprime', hp: 600, spd: 52, r: 48, drawSize: 128, debutCycle: 2 },
{ kind: 'scald', name: 'THE SCALD', sprite: 'boss_scald', hp: 620, spd: 40, r: 50, drawSize: 128, debutCycle: 2 },
];
function bossIndexForFloor(floor) { return (floor - 1) % BOSS_DEFS.length; }
function spawnBoss(floor) {
const def = BOSS_DEFS[bossIndexForFloor(floor)];
const cycle = Math.floor((floor - 1) / 3);
const tier = Math.max(0, cycle - def.debutCycle);
const up = powerScore();
const pressureHp = G.pressure;
const hpMul = (1 + (floor - 1) * 0.30) * (1 + up * 0.05) * pressureHp;
const e = {
type: 'boss_' + def.kind, bossKind: def.kind, boss: true, name: def.name,
x: W / 2, y: H / 2 - 100, vx: 0, vy: 0,
r: def.r, hp: def.hp * hpMul, maxHp: def.hp * hpMul,
drawSize: def.drawSize,
spd: def.spd * (1 + cycle * 0.1 + up * 0.01) * G.pressure, dmg: 2,
sprite: def.sprite, elite: false,
hitT: 0, flash: 0, burnT: 0, burnDps: 0, slowT: 0, rootT: 0,
wob: 0, animSeed: rand(0, TAU), animT: 0, attackT: 0, actionT: 0,
spawnT: 0, dead: false,
state: 'idle', stateT: 1.2, atkT: 2.2, sumT: 4,
chargeAng: 0, cycle, tier, atkCount: 0, dashes: 0, spiralOff: 0, enragedPlayed: false,
atkT2: 2.6, alpha: 1, phased: false, dmgTakenMul: def.kind === 'platefather' ? 0.25 : 1,
plates: def.kind === 'platefather' ? 4 : 0, plateDmg: 0,
escorts: [], broodTriggered: false, blinkCount: 0, hookRespawnT: 0,
};
G.enemies.push(e);
G.boss = e;
Sfx.boss(e, 'roar');
addToast(def.name, 'fresh meat wants YOU');
return e;
}
function updateBoss(e, dt) {
e.wob += dt;
const p = G.player;
const a = angleTo(e.x, e.y, p.x, p.y);
const d = dist(e.x, e.y, p.x, p.y);
const enraged = e.hp < e.maxHp * 0.35;
const spdMul = enraged ? 1.35 : 1;
if (enraged && !e.enragedPlayed) { e.enragedPlayed = true; Sfx.boss(e, 'enrage'); }
switch (e.bossKind) {
case 'bonesaw': updateBoneSaw(e, dt, a, d, spdMul, enraged); break;
case 'gorecrown': updateGoreCrown(e, dt, a, d, spdMul, enraged); break;
case 'knifecrawl': updateKnifeCrawl(e, dt, a, d, spdMul, enraged); break;
case 'vealmother': updateVealMother(e, dt, a, d, spdMul, enraged); break;
case 'flenser': updateFlenser(e, dt, a, d, spdMul, enraged); break;
case 'hookchoir': updateHookChoir(e, dt, a, d, spdMul, enraged); break;
case 'platefather': updatePlateFather(e, dt, a, d, spdMul, enraged); break;
case 'augerprime': updateAugerPrime(e, dt, a, d, spdMul, enraged); break;
case 'scald': updateScald(e, dt, a, d, spdMul, enraged); break;
}
}
// --- Bone Saw: telegraphed charges + flings bouncing saws ---
function updateBoneSaw(e, dt, a, d, spdMul, enraged) {
const p = G.player;
e.stateT -= dt; e.atkT -= dt;
if (e.state === 'idle') {
e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt;
if (e.atkT <= 0) {
e.atkT = enraged ? 2.2 : 3.2;
if (chance(0.55)) { // telegraph a charge
e.state = 'aim'; e.stateT = 0.6; e.vx = 0; e.vy = 0;
} else { // fling saws
const n = enraged ? 10 : 6;
for (let i = 0; i < n; i++) {
const fa = a + (i - (n - 1) / 2) * 0.28;
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(fa) * 240, vy: Math.sin(fa) * 240,
r: 8, dmg: 1, life: 3.2, t: 0, sprite: 'bullet_saw',
});
}
Sfx.boss(e, 'saws');
}
}
} else if (e.state === 'aim') {
e.chargeAng = a;
if (e.stateT <= 0) { e.state = 'charge'; e.stateT = 0.85; Sfx.boss(e, 'charge'); }
} else if (e.state === 'charge') {
e.vx = Math.cos(e.chargeAng) * 430 * spdMul;
e.vy = Math.sin(e.chargeAng) * 430 * spdMul;
if (chance(0.5)) spawnBlood(e.x, e.y, e.chargeAng + Math.PI, 2);
// wall slam
const nx = e.x + e.vx * dt, ny = e.y + e.vy * dt;
if (nx < WALL + e.r + 2 || nx > W - WALL - e.r - 2 || ny < WALL + e.r + 2 || ny > H - WALL - e.r - 2) {
e.state = 'idle'; e.stateT = 1; e.atkT = 1.2;
addShake(10); Sfx.explode({ x: e.x, y: e.y });
}
if (e.stateT <= 0) { e.state = 'idle'; e.stateT = 0.8; }
}
}
// --- Gore Crown: rings + summons; cycles add volleys, double rings, spirals, nastier summons ---
function updateGoreCrown(e, dt, a, d, spdMul, enraged) {
e.atkT -= dt; e.sumT -= dt;
// slow relentless chase
e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt;
if (e.atkT <= 0) {
e.atkT = Math.max((enraged ? 1.6 : 2.4) - e.cycle * 0.15, 1.2);
e.atkCount++;
const ringN = Math.min((enraged ? 18 : 12) + e.cycle * 4, 40);
// cycle 2+: alternate between rings and aimed gore volleys
if (e.tier >= 1 && e.atkCount % 2 === 0) {
const n = 3 + Math.min(e.cycle, 4);
for (let i = 0; i < n; i++) {
const fa = a + (i - (n - 1) / 2) * 0.22;
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(fa) * 260, vy: Math.sin(fa) * 260,
r: 6, dmg: 1, life: 3, t: 0, sprite: 'bullet_gore',
});
}
} else {
// rings: cycle 3+ every 3rd is doubled, cycle 4+ rings slowly rotate
if (e.tier >= 3) e.spiralOff += 0.35;
const off = e.spiralOff + rand(0, TAU);
const rings = (e.tier >= 2 && e.atkCount % 3 === 0) ? 2 : 1;
for (let ri = 0; ri < rings; ri++) {
for (let i = 0; i < ringN; i++) {
const fa = off + ri * (Math.PI / ringN) + (i / ringN) * TAU;
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(fa) * 170, vy: Math.sin(fa) * 170,
r: 6, dmg: 1, life: 4, t: 0, sprite: 'bullet_gore',
});
}
}
}
Sfx.boss(e, 'volley');
}
if (e.sumT <= 0) {
e.sumT = Math.max((enraged ? 5 : 7) - e.cycle * 0.5, 3.5);
const n = Math.min(2 + e.cycle, 5, Math.max(0, MAX_ENEMIES - G.enemies.length));
for (let i = 0; i < n; i++) {
const pos = spawnPosAwayFromPlayer();
let type = chance(0.6) ? 'runner' : 'mini';
if (e.tier >= 1 && chance(0.3)) type = 'splitter'; // tier 2+: splitters join
if (e.tier >= 2 && chance(0.25)) type = 'exploder'; // tier 3+: exploders join
G.enemies.push(makeEnemy(type, pos.x, pos.y, G.floor, false));
spawnBlood(pos.x, pos.y, 0, 6);
}
Sfx.boss(e, 'summon');
}
}
// --- Knife Floor Crawl: telegraphs + dashes; cycles add hunting telegraphs, dash trails, volleys, chain dashes ---
function updateKnifeCrawl(e, dt, a, d, spdMul, enraged) {
const p = G.player;
e.atkT -= dt; e.stateT -= dt;
if (e.state === 'idle') {
e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt;
if (e.atkT <= 0) {
e.atkT = Math.max((enraged ? 1.8 : 2.6) - e.cycle * 0.15, 1.3);
const n = Math.min(3 + e.cycle, 6);
for (let i = 0; i < n; i++) {
const tx = i === 0 ? p.x : p.x + rand(-130, 130);
const ty = i === 0 ? p.y : p.y + rand(-130, 130);
G.telegraphs.push({
kind: 'knives',
x: clamp(tx, WALL + 20, W - WALL - 20), y: clamp(ty, WALL + 20, H - WALL - 20),
r: 34, t: 0, dur: Math.max(0.7 - e.cycle * 0.05, 0.45), tick: 0,
track: e.tier >= 1 && i === 0 ? 0.4 : 0, // tier 2+: one telegraph hunts you
});
}
// cycle 3+: cleaver volley
if (e.tier >= 2) {
const vn = 3 + Math.min(e.cycle - 1, 3);
for (let i = 0; i < vn; i++) {
const fa = a + (i - (vn - 1) / 2) * 0.2;
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(fa) * 300, vy: Math.sin(fa) * 300,
r: 6, dmg: 1, life: 2.5, t: 0, sprite: 'bullet_cleaver',
});
}
Sfx.boss(e, 'knives');
}
if (chance(Math.min(enraged ? 0.5 : 0.3 + e.cycle * 0.08, 0.6))) {
e.state = 'dash'; e.stateT = 0.5; e.chargeAng = a;
e.dashes = e.tier >= 3 ? 2 : 1; // tier 4+: chain dash
Sfx.boss(e, 'dash');
}
}
} else if (e.state === 'dash') {
e.vx = Math.cos(e.chargeAng) * 320 * spdMul;
e.vy = Math.sin(e.chargeAng) * 320 * spdMul;
if (e.stateT <= 0) {
// cycle 2+: knives erupt where the dash ends
if (e.tier >= 1) {
G.telegraphs.push({
kind: 'knives', x: clamp(e.x, WALL + 20, W - WALL - 20), y: clamp(e.y, WALL + 20, H - WALL - 20),
r: 34, t: 0, dur: 0.55, tick: 0, track: 0,
});
}
if (e.dashes > 1) { // chain dash
e.dashes--;
e.chargeAng = angleTo(e.x, e.y, p.x, p.y);
e.stateT = 0.45;
} else {
e.state = 'idle';
}
}
}
}
function fireBossBullet(e, ang, speed, sprite, opts) {
const o = opts || {};
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
r: o.r || 6, dmg: o.dmg || 1, life: o.life || 3.5, t: 0,
sprite: sprite || 'bullet_gore', homing: o.homing || 0,
accel: o.accel || 0, bounces: o.bounces || 0,
});
}
function fireBossRing(e, count, speed, sprite, off, opts, gapStart, gapSize) {
for (let i = 0; i < count; i++) {
if (gapStart !== undefined) {
const delta = (i - gapStart + count) % count;
if (delta < gapSize) continue;
}
fireBossBullet(e, (off || 0) + (i / count) * TAU, speed, sprite, opts);
}
}
// --- Veal Mother: brood summons, homing bile, escort-gated shield phase ---
function updateVealMother(e, dt, a, d, spdMul, enraged) {
e.atkT -= dt; e.sumT -= dt; e.stateT -= dt;
const escortAlive = e.escorts.some(m => m && m.hp > 0);
if (e.broodTriggered && !escortAlive && e.dmgTakenMul < 1) {
e.dmgTakenMul = 1; e.state = 'idle';
addToast('BROOD SHIELD BROKEN', 'the mother is exposed');
}
const moveMul = escortAlive ? 0.5 : 1;
e.vx += Math.cos(a) * e.spd * spdMul * moveMul * 3 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * moveMul * 3 * dt;
if (!e.broodTriggered && e.hp < e.maxHp * 0.5) {
e.broodTriggered = true; e.state = 'brood'; e.stateT = 0.7;
const slots = Math.max(0, MAX_ENEMIES - G.enemies.length);
for (let i = 0; i < Math.min(2, slots); i++) {
const ang = a + Math.PI + (i ? 0.7 : -0.7);
const m = makeEnemy('splitter', clamp(e.x + Math.cos(ang) * 90, WALL + 30, W - WALL - 30),
clamp(e.y + Math.sin(ang) * 90, WALL + 30, H - WALL - 30), G.floor, true);
e.escorts.push(m); G.enemies.push(m); Sfx.spawn(m);
}
if (e.escorts.length) e.dmgTakenMul = 0.35;
addToast('BROOD SHIELD', 'kill the splitters');
Sfx.boss(e, 'birth');
}
if (e.atkT <= 0) {
e.atkT = escortAlive ? 1.6 : (enraged ? 2.4 : 3.4);
if (escortAlive) {
fireBossRing(e, 10 + Math.min(e.tier, 3) * 2, 175, 'bullet_syringe', rand(0, TAU), { life: 4 });
} else {
const n = e.tier >= 3 ? 9 : 5;
for (let i = 0; i < n; i++) {
const fa = a + (i - (n - 1) / 2) * 0.18;
fireBossBullet(e, fa, 190, 'bullet_syringe', { life: 3.2, homing: 1.2 });
}
}
e.attackT = 0.35; e.actionT = 0;
Sfx.boss(e, 'bile');
}
if (e.sumT <= 0) {
e.sumT = enraged ? 3.8 : 5.5;
const n = Math.min(3 + e.tier, 6, Math.max(0, MAX_ENEMIES - G.enemies.length));
for (let i = 0; i < n; i++) {
const pos = spawnPosAwayFromPlayer();
let type = 'mini';
if (e.tier >= 1 && chance(0.3)) type = 'splitter';
if (e.tier >= 2 && chance(0.2)) type = 'exploder';
const m = makeEnemy(type, pos.x, pos.y, G.floor, false);
G.enemies.push(m); spawnBlood(pos.x, pos.y, 0, 5); Sfx.spawn(m);
}
e.state = 'birth'; e.stateT = 0.5; Sfx.boss(e, 'birth');
}
if (e.state === 'birth' && e.stateT <= 0) e.state = escortAlive ? 'brood' : 'idle';
}
// --- The Flenser: telegraphed vanish, reposition, cleaver ambush ---
function updateFlenser(e, dt, a, d, spdMul, enraged) {
const p = G.player;
e.atkT -= dt; e.stateT -= dt;
if (e.state === 'idle') {
e.alpha = 1; e.phased = false;
e.vx += Math.cos(a) * e.spd * spdMul * 3.5 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 3.5 * dt;
if (e.atkT <= 0) {
e.atkT = enraged ? 1.5 : 2.4; e.state = 'fade'; e.stateT = 0.35;
e.blinkCount = e.tier >= 3 ? 2 : 1; e.oldX = e.x; e.oldY = e.y;
e.vx = 0; e.vy = 0; Sfx.boss(e, 'blink');
}
} else if (e.state === 'fade') {
e.vx = 0; e.vy = 0; e.alpha = clamp(e.stateT / 0.35, 0, 1);
if (e.stateT <= 0) {
e.state = 'gone'; e.stateT = enraged ? 0.3 : 0.5; e.phased = true; e.alpha = 0;
const behind = p.aim + Math.PI, range = rand(90, 140);
e.x = clamp(p.x + Math.cos(behind) * range, WALL + e.r, W - WALL - e.r);
e.y = clamp(p.y + Math.sin(behind) * range, WALL + e.r, H - WALL - e.r);
G.telegraphs.push({ kind: 'knives', x: e.x, y: e.y, r: 38, t: 0, dur: e.stateT + 0.22, tick: 0, track: 0 });
if (e.tier >= 1) G.telegraphs.push({ kind: 'knives', x: e.oldX, y: e.oldY, r: 30, t: 0, dur: 0.6, tick: 0, track: 0 });
}
} else if (e.state === 'gone') {
e.vx = 0; e.vy = 0;
if (e.stateT <= 0) {
e.state = 'strike'; e.stateT = 0.4; e.phased = false; e.alpha = 1;
const strikeA = angleTo(e.x, e.y, p.x, p.y);
const volleys = e.tier >= 2 ? 2 : 1;
for (let v = 0; v < volleys; v++) for (let i = 0; i < 7; i++) {
fireBossBullet(e, strikeA + (i - 3) * 0.16 + v * 0.08, 330 - v * 35, 'bullet_cleaver', { life: 2.5 });
}
e.chargeAng = strikeA + Math.PI; e.attackT = 0.4; e.actionT = 0;
Sfx.boss(e, 'flay');
}
} else if (e.state === 'strike') {
e.vx = Math.cos(e.chargeAng) * 380 * spdMul;
e.vy = Math.sin(e.chargeAng) * 380 * spdMul;
if (e.stateT <= 0) {
e.blinkCount--;
if (e.blinkCount > 0) {
e.state = 'fade'; e.stateT = 0.35; e.oldX = e.x; e.oldY = e.y; Sfx.boss(e, 'blink');
} else e.state = 'idle';
}
}
}
function spawnHookRing(e, count, radius, spin) {
for (let i = 0; i < count; i++) {
const b = {
x: e.x, y: e.y, vx: 0, vy: 0, r: 7, dmg: 1, life: 20, t: 0,
sprite: 'bullet_harpoon', behavior: 'orbit', anchor: e,
orbA: (i / count) * TAU, orbR: radius, orbTargetR: radius,
orbSpd: spin, releaseSpd: 300,
};
G.ebullets.push(b);
}
}
// --- Hook Choir: chained hook rings wind outward then launch tangentially ---
function updateHookChoir(e, dt, a, d, spdMul, enraged) {
e.atkT -= dt; e.atkT2 -= dt; e.stateT -= dt; e.hookRespawnT -= dt;
e.vx += Math.cos(a) * e.spd * spdMul * 2.5 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 2.5 * dt;
const liveHooks = G.ebullets.filter(b => b.behavior === 'orbit' && b.anchor === e);
if (!liveHooks.length && e.hookRespawnT <= 0) {
spawnHookRing(e, 4 + Math.min(e.tier, 3), 70, (enraged ? 2.65 : 1.9));
if (e.tier >= 1) spawnHookRing(e, 4 + Math.min(e.tier, 2), 110, -(enraged ? 2.25 : 1.6));
}
if (e.state === 'idle' && e.atkT <= 0) {
e.state = 'wind'; e.stateT = 0.7; e.atkT = enraged ? 2.8 : 4;
for (const b of liveHooks) { b.orbTargetR = 170; b.orbSpd *= 1.75; }
Sfx.boss(e, 'chains');
} else if (e.state === 'wind' && e.stateT <= 0) {
e.state = 'idle'; e.hookRespawnT = 1.2;
for (const b of liveHooks) {
const releaseAng = b.orbA + Math.sign(b.orbSpd) * Math.PI / 2;
b.behavior = null; b.anchor = null; b.homing = e.tier >= 3 ? 0.8 : 0;
b.vx = Math.cos(releaseAng) * 300; b.vy = Math.sin(releaseAng) * 300;
}
Sfx.boss(e, 'release');
}
if (e.atkT2 <= 0) {
e.atkT2 = 2.8;
for (let i = -1; i <= 1; i++) fireBossBullet(e, a + i * 0.18, 260, 'bullet_harpoon', { life: 3 });
}
}
function spawnPlateStomp(e) {
const rings = e.tier >= 3 ? [120, 200] : [120];
for (const radius of rings) for (let i = 0; i < 6; i++) {
const ang = (i / 6) * TAU + (radius > 120 ? Math.PI / 6 : 0);
G.hazards.push({ kind: 'fire', hostile: true, owner: e,
x: e.x + Math.cos(ang) * radius, y: e.y + Math.sin(ang) * radius,
r: 32, life: 3.5, t: 0, tick: 0, tickRate: 0.45, dmg: 1, dps: 0 });
}
addShake(9); Sfx.boss(e, 'stomp');
}
// --- Plate Father: breakable armor, projectile walls, tallow stomps ---
function updatePlateFather(e, dt, a, d, spdMul, enraged) {
e.atkT -= dt; e.stateT -= dt;
e.vx += Math.cos(a) * e.spd * spdMul * 2.4 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 2.4 * dt;
if (e.state === 'stomp') {
e.vx = 0; e.vy = 0;
if (e.stateT <= 0) { spawnPlateStomp(e); e.state = 'idle'; }
return;
}
if (e.atkT <= 0) {
e.atkT = enraged ? 2 : 3; e.atkCount++;
if (e.atkCount % 2 === 0) {
e.state = 'stomp'; e.stateT = 0.7; e.attackT = 0.7; e.actionT = 0;
} else {
const count = 20, gap = e.tier >= 1 ? 2 : 3;
const gapStart = irand(0, count - 1), off = rand(0, TAU);
fireBossRing(e, count, 150, 'bullet_gore', off, { life: 4.5 }, gapStart, gap);
if (e.tier >= 2) fireBossRing(e, count, 125, 'bullet_gore', off + Math.PI / count, { life: 5 }, (gapStart + 7) % count, gap);
e.attackT = 0.45; e.actionT = 0; Sfx.boss(e, 'wall');
}
}
}
// --- Auger Prime: rotating beam arms and accelerating saw spit ---
function updateAugerPrime(e, dt, a, d, spdMul, enraged) {
e.atkT -= dt; e.stateT -= dt;
if (e.state === 'idle') {
e.vx += Math.cos(a) * e.spd * spdMul * 2.7 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 2.7 * dt;
if (e.atkT <= 0) {
e.atkT = enraged ? 2.4 : 3.6; e.state = 'spool'; e.stateT = enraged ? 0.5 : 0.8;
e.chargeAng = a; e.vx = 0; e.vy = 0; Sfx.boss(e, 'spool');
}
} else if (e.state === 'spool') {
e.vx = 0; e.vy = 0;
if (e.stateT <= 0) {
e.state = 'sweep'; e.stateT = 2.2;
const arms = e.tier >= 3 ? 3 : (e.tier >= 1 ? 2 : 1);
G.telegraphs.push({ kind: 'sweep', owner: e, ang: e.chargeAng,
spin: (chance(0.5) ? -1 : 1) * (e.tier >= 2 ? 1.65 : 1.15),
len: 420, w: 26, arms, t: 0, dur: 2.2, tick: 0 });
for (let i = -1; i <= 1; i++) fireBossBullet(e, e.chargeAng + i * 0.22, 150, 'bullet_saw', { accel: 260, life: 2.6, bounces: 1 });
Sfx.boss(e, 'sweep');
}
} else if (e.state === 'sweep') {
e.vx = 0; e.vy = 0;
if (e.stateT <= 0) e.state = 'idle';
}
}
// --- The Scald: steam vents, hostile tallow pools, stalling burst rings ---
function updateScald(e, dt, a, d, spdMul, enraged) {
const p = G.player;
e.atkT -= dt;
e.vx += Math.cos(a) * e.spd * spdMul * 2.4 * dt;
e.vy += Math.sin(a) * e.spd * spdMul * 2.4 * dt;
if (e.atkT <= 0) {
e.atkT = enraged ? 1.9 : 2.8; e.atkCount++;
const n = e.tier >= 2 ? 7 : 3 + Math.min(e.tier, 2);
const radius = e.tier >= 1 ? 58 : 46;
const activePools = G.hazards.filter(h => h.hostile).length + G.telegraphs.filter(t => t.kind === 'pool').length;
const availablePools = Math.max(0, 14 - activePools);
for (let i = 0; i < Math.min(n, availablePools); i++) {
const tx = i === 0 ? p.x : p.x + rand(-180, 180);
const ty = i === 0 ? p.y : p.y + rand(-150, 150);
G.telegraphs.push({ kind: 'pool', owner: e,
x: clamp(tx, WALL + radius, W - WALL - radius), y: clamp(ty, WALL + radius, H - WALL - radius),
r: radius, t: 0, dur: 0.6, life: enraged ? 7 : 5 });
}
e.attackT = 0.45; e.actionT = 0; Sfx.boss(e, 'vent');
if (e.atkCount % 3 === 0) {
fireBossRing(e, 16, 220, 'bullet_steam', rand(0, TAU), { accel: -60, life: 3.4, r: 8 });
if (e.tier >= 3) fireBossRing(e, 16, 155, 'bullet_steam', rand(0, TAU), { accel: -35, life: 4.2, r: 8 });
addShake(7); Sfx.boss(e, 'burst');
}
}
}
// telegraphs resolve here (knives erupting, lingering blades)
function updateTelegraphs(dt) {
const p = G.player;
for (let i = G.telegraphs.length - 1; i >= 0; i--) {
const t = G.telegraphs[i];
t.t += dt;
if (t.kind === 'sweep') {
if (!t.owner || t.owner.hp <= 0 || t.t >= t.dur) { G.telegraphs.splice(i, 1); continue; }
t.ang += t.spin * dt; t.tick -= dt;
let hit = false;
for (let arm = 0; arm < t.arms; arm++) {
const ang = t.ang + (arm / t.arms) * TAU;
const ex = t.owner.x + Math.cos(ang) * t.len;
const ey = t.owner.y + Math.sin(ang) * t.len;
if (distToSegment(p.x, p.y, t.owner.x, t.owner.y, ex, ey) < t.w / 2 + p.r) hit = true;
}
if (hit && t.tick <= 0) { t.tick = 0.5; hurtPlayer(1, angleTo(t.owner.x, t.owner.y, p.x, p.y)); }
continue;
}
if (t.kind === 'pool') {
if (t.t >= t.dur) {
G.hazards.push({ kind: 'acid', hostile: true, owner: t.owner,
x: t.x, y: t.y, r: t.r, life: t.life, t: 0, tick: 0, tickRate: 0.4, dmg: 1, dps: 0 });
for (let k = 0; k < 10; k++) addParticle({ type: 'smoke', x: t.x + rand(-t.r, t.r), y: t.y + rand(-t.r, t.r), vx: rand(-15, 15), vy: -rand(40, 100), life: 0.5, t: 0, r: rand(3, 7) });
G.telegraphs.splice(i, 1);
}
continue;
}
// hunting telegraphs track the player briefly before locking
if (t.kind === 'knives' && t.track > 0) {
t.track -= dt;
const ta = angleTo(t.x, t.y, p.x, p.y);
const ts = 140 * dt;
t.x = clamp(t.x + Math.cos(ta) * ts, WALL + 20, W - WALL - 20);
t.y = clamp(t.y + Math.sin(ta) * ts, WALL + 20, H - WALL - 20);
}
if (t.kind === 'knives' && t.t >= t.dur) {
// erupt
if (dist(t.x, t.y, p.x, p.y) < t.r + p.r) hurtPlayer(1, angleTo(t.x, t.y, p.x, p.y));
for (let k = 0; k < 8; k++) {
addParticle({ type: 'spark', x: t.x + rand(-t.r, t.r), y: t.y + rand(-t.r, t.r), vx: 0, vy: -rand(60, 160), life: 0.3, t: 0, r: 2 });
}
Sfx.boss(G.boss || { bossKind: 'knifecrawl', x: t.x, y: t.y }, 'knives');
t.kind = 'linger'; t.t = 0; t.dur = 1.1; t.tick = 0;
} else if (t.kind === 'linger') {
t.tick -= dt;
if (t.tick <= 0) {
t.tick = 0.4;
if (dist(t.x, t.y, p.x, p.y) < t.r) hurtPlayer(1, angleTo(t.x, t.y, p.x, p.y));
}
if (t.t >= t.dur) G.telegraphs.splice(i, 1);
}
}
}
function drawTelegraphs(ctx) {
for (const t of G.telegraphs) {
if (t.kind === 'sweep') {
if (!t.owner) continue;
const pulse = 0.65 + Math.sin(t.t * 24) * 0.2;
for (let arm = 0; arm < t.arms; arm++) {
const ang = t.ang + (arm / t.arms) * TAU;
const ex = t.owner.x + Math.cos(ang) * t.len;
const ey = t.owner.y + Math.sin(ang) * t.len;
ctx.globalAlpha = 0.24 * pulse; ctx.strokeStyle = '#ff442e'; ctx.lineWidth = t.w;
ctx.beginPath(); ctx.moveTo(t.owner.x, t.owner.y); ctx.lineTo(ex, ey); ctx.stroke();
ctx.globalAlpha = 0.85; ctx.strokeStyle = '#ffd36a'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(t.owner.x, t.owner.y); ctx.lineTo(ex, ey); ctx.stroke();
}
ctx.globalAlpha = 1;
} else if (t.kind === 'pool') {
const k = clamp(t.t / t.dur, 0, 1);
ctx.globalAlpha = 0.2 + k * 0.35; ctx.fillStyle = '#b3ca32';
ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (0.7 + k * 0.3), 0, TAU); ctx.fill();
ctx.globalAlpha = 0.8; ctx.strokeStyle = k > 0.75 ? '#fff1a0' : '#d5e64c'; ctx.lineWidth = 2 + k * 3;
ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.15), 0, TAU); ctx.stroke();
ctx.globalAlpha = 1;
} else if (t.kind === 'knives') {
const k = t.t / t.dur;
const pulse = 0.7 + Math.sin(t.t * 26) * 0.3;
ctx.globalAlpha = 0.25 + k * 0.45;
ctx.strokeStyle = k > 0.72 ? '#ffdd9a' : '#ff3550'; ctx.lineWidth = 2 + k * 3;
ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.4), 0, TAU); ctx.stroke();
ctx.globalAlpha = (0.2 + k * 0.35) * pulse;
ctx.beginPath(); ctx.arc(t.x, t.y, t.r + 5, 0, TAU); ctx.stroke();
ctx.globalAlpha = 0.15 + k * 0.3;
ctx.fillStyle = '#e51f3b';
ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.4), 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
} else if (t.kind === 'linger') {
ctx.globalAlpha = 0.8 * (1 - t.t / t.dur);
for (let k = 0; k < 4; k++) {
const a = (k / 4) * TAU + 0.4;
Sprites.draw(ctx, 'bullet_cleaver', t.x + Math.cos(a) * t.r * 0.5, t.y + Math.sin(a) * t.r * 0.5, a + Math.PI / 2, 24);
}
ctx.globalAlpha = 1;
}
}
}
function onBossDeath(e) {
G.boss = null;
Sfx.boss(e, 'death');
G.telegraphs = [];
G.ebullets = [];
G.hazards = G.hazards.filter(h => !h.hostile);
// Isaac-style: boss death ends the fight. Minions are swept without score or drops.
for (let i = G.enemies.length - 1; i >= 0; i--) {
const m = G.enemies[i];
if (m === e || m.boss) continue;
spawnBlood(m.x, m.y, rand(0, TAU), 8);
spawnGibs(m.x, m.y, 3);
spawnCorpse(m);
m.dead = true;
m.hp = 0;
}
addScore(500 + G.floor * 100);
addShake(12);
// boss reward: item pedestal + a real weapon drop + stairs down
spawnItemPedestal(W / 2 - 90, H / 2);
const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff
spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id });
spawnPickup('stairs', W / 2, H / 2 + 80);
addToast(e.name + ' DESTROYED', 'take the meat, take the stairs');
Music.requestFloorMusic();
}
+297
View File
@@ -0,0 +1,297 @@
// ---- projectile simulation (player bullets + enemy bullets) ----
const PROJECTILE_DRAW_SIZE = {
bullet_bone: 16, bullet_saw: 24, bullet_cleaver: 24,
bullet_harpoon: 32, bullet_eye: 24, bullet_syringe: 24, bullet_gore: 24,
bullet_steam: 24,
};
function nearestEnemy(x, y, maxD) {
let best = null, bd = maxD * maxD;
for (const e of G.enemies) {
if (e.phased) continue;
const d = dist2(x, y, e.x, e.y);
if (d < bd) { bd = d; best = e; }
}
return best;
}
function updateBullets(dt) {
const p = G.player;
if (G.bullets.length > 520) G.bullets.splice(0, G.bullets.length - 520);
if (G.ebullets.length > 640) G.ebullets.splice(0, G.ebullets.length - 640);
for (let i = G.bullets.length - 1; i >= 0; i--) {
const b = G.bullets[i];
// Sweep the first frame from the player's pivot to the per-weapon muzzle,
// then sweep between simulation positions so long/fast weapons cannot skip
// enemies standing inside the visible barrel or between frames.
const prevX = b.spawnX === undefined ? b.x : b.spawnX;
const prevY = b.spawnY === undefined ? b.y : b.spawnY;
delete b.spawnX; delete b.spawnY;
b.t += dt;
let dead = false;
// homing steer
if (b.homing) {
const e = nearestEnemy(b.x, b.y, 320);
if (e) {
const want = angleTo(b.x, b.y, e.x, e.y);
const cur = Math.atan2(b.vy, b.vx);
const na = angleLerp(cur, want, clamp(b.homing * dt, 0, 1));
const sp = Math.hypot(b.vx, b.vy);
b.vx = Math.cos(na) * sp; b.vy = Math.sin(na) * sp;
}
}
// boomerang: decelerate then fly back to player
if (b.behavior === 'boomerang') {
b.phase += dt;
if (b.phase > 0.45) {
const a = angleTo(b.x, b.y, p.x, p.y);
const sp = Math.min(Math.hypot(b.vx, b.vy) + 900 * dt, 520);
b.vx = Math.cos(a) * sp; b.vy = Math.sin(a) * sp;
if (dist2(b.x, b.y, p.x, p.y) < 400) dead = true; // caught
} else {
b.vx *= 0.985; b.vy *= 0.985;
}
b.ang += 14 * dt;
}
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.behavior === 'bounce' || b.behavior === 'pierce_drag') b.ang += 12 * dt;
// walls
const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4, outU = b.y < WALL + 4, outD = b.y > H - WALL - 4;
if (outL || outR || outU || outD) {
const bouncesLeft = (b.bounces || 0) + (b.bounce || 0);
if (bouncesLeft > 0) {
if (outL || outR) b.vx = -b.vx;
if (outU || outD) b.vy = -b.vy;
b.x = clamp(b.x, WALL + 4, W - WALL - 4);
b.y = clamp(b.y, WALL + 4, H - WALL - 4);
if (b.bounces > 0) b.bounces--; else if (b.bounce > 0) b.bounce--;
b.ang = Math.atan2(b.vy, b.vx);
spawnSpark(b.x, b.y, b.ang);
Sfx.ricochet({ x: b.x, y: b.y });
} else if (b.lobbed) {
dead = true; // fizzles on wall
} else {
spawnSpark(b.x, b.y, Math.atan2(b.vy, b.vx));
Sfx.wallHit({ x: b.x, y: b.y });
dead = true;
}
}
// expired
if (!dead && b.t >= b.life) {
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 });
}
}
// lobbed projectiles trigger on expiry (reached target)
if (dead && b.lobbed && !b._triggered) {
b._triggered = true;
lobTrigger(b);
}
// enemy collision
if (!dead) {
for (const e of G.enemies) {
if (e.hp <= 0 || e.phased || (b.hit && b.hit.has(e))) continue;
if (e === b.dragTarget) continue;
if (distToSegment(e.x, e.y, prevX, prevY, b.x, b.y) < b.r + e.r) {
const a = Math.atan2(b.vy, b.vx);
const killed = damageEnemy(e, b.dmg, a, true, { source: 'player', bullet: b });
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 === '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 });
}
// 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);
if (b.behavior === 'pierce_drag' && !b.dragTarget && !e.boss) {
b.dragTarget = e; e.dragged = b;
}
if (b.pierce > 0) { b.pierce--; }
else if (b.behavior === 'boomerang' || b.behavior === 'pierce_drag') { /* pass through */ }
else {
dead = true; break;
}
}
}
}
// drag victim along with the harpoon
if (b.dragTarget) {
if (b.dragTarget.hp <= 0 || dead) b.dragTarget.dragged = null, b.dragTarget = null;
else {
b.dragTarget.x = b.x; b.dragTarget.y = b.y;
if (b.t >= b.life) { damageEnemy(b.dragTarget, b.dmg * 0.5, b.ang, true); b.dragTarget.dragged = null; }
}
}
if (dead) G.bullets.splice(i, 1);
}
// ---- enemy bullets ----
for (let i = G.ebullets.length - 1; i >= 0; i--) {
const b = G.ebullets[i];
b.t += dt;
if (b.behavior === 'orbit') {
if (!b.anchor || b.anchor.hp <= 0) {
b.behavior = null;
const releaseAng = (b.orbA || 0) + Math.sign(b.orbSpd || 1) * Math.PI / 2;
const releaseSpd = b.releaseSpd || 280;
b.vx = Math.cos(releaseAng) * releaseSpd;
b.vy = Math.sin(releaseAng) * releaseSpd;
b.anchor = null;
} else {
b.orbA += b.orbSpd * dt;
if (b.orbTargetR !== undefined) b.orbR = lerp(b.orbR, b.orbTargetR, clamp(dt * 5, 0, 1));
b.x = b.anchor.x + Math.cos(b.orbA) * b.orbR;
b.y = b.anchor.y + Math.sin(b.orbA) * b.orbR;
}
}
if (b.behavior !== 'orbit') {
if (b.homing) {
const want = angleTo(b.x, b.y, p.x, p.y);
const cur = Math.atan2(b.vy, b.vx);
const ang = angleLerp(cur, want, clamp(b.homing * dt, 0, 1));
const sp = Math.hypot(b.vx, b.vy);
b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp;
}
if (b.accel) {
const sp = Math.max(0, Math.hypot(b.vx, b.vy) + b.accel * dt);
const ang = Math.atan2(b.vy, b.vx);
b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp;
}
b.x += b.vx * dt; b.y += b.vy * dt;
}
let dead = b.t >= b.life;
if (b.behavior !== 'orbit') {
const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4;
const outU = b.y < WALL + 4, outD = b.y > H - WALL - 4;
if (outL || outR || outU || outD) {
if (b.bounces > 0) {
if (outL || outR) b.vx = -b.vx;
if (outU || outD) b.vy = -b.vy;
b.x = clamp(b.x, WALL + 4, W - WALL - 4);
b.y = clamp(b.y, WALL + 4, H - WALL - 4);
b.bounces--;
} else dead = true;
}
}
if (!dead && p.hp > 0 && dist2(b.x, b.y, p.x, p.y) < (b.r + p.r) * (b.r + p.r)) {
hurtPlayer(b.dmg, Math.atan2(b.vy, b.vx));
dead = true;
}
if (dead) G.ebullets.splice(i, 1);
}
}
function lobTrigger(b) {
if (b.behavior === 'lob') { // Flesh Masher: meat bomb
const radius = 85 * (b.rangeMul || 1) * Math.sqrt(b.sizeMul || 1);
areaDamage(b.x, b.y, radius, b.dmg, true, { source: 'player' });
spawnExplosionFx(b.x, b.y, radius);
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 });
Sfx.trapSet({ x: b.x, y: b.y });
} else if (b.behavior === 'lob_swarm') { // Swarm Jar: homing maggots
const st = G.player.stats;
for (let i = 0; i < 6 + st.split + st.fan; i++) {
const a = rand(0, TAU);
G.bullets.push({
x: b.x, y: b.y, ang: a,
vx: Math.cos(a) * 260, vy: Math.sin(a) * 260,
r: 4 * st.sizeMul, dmg: b.dmg, pierce: st.pierce, bounce: st.bounce, life: 2.2 * st.rangeMul, t: 0,
behavior: 'maggot', sprite: 'bullet_gore', homing: 8 + st.homing * 0.7, sizeMul: st.sizeMul,
});
}
spawnBlood(b.x, b.y, 0, 8);
Sfx.spit({ x: b.x, y: b.y });
}
}
function drawBullets(ctx) {
for (const b of G.bullets) {
let rot = b.ang;
const sp = Math.hypot(b.vx || 0, b.vy || 0);
if (sp > 20 && b.behavior !== 'boomerang') {
const nx = b.vx / sp, ny = b.vy / sp;
ctx.globalAlpha = 0.18;
ctx.strokeStyle = b.behavior === 'cone' ? '#8ee84a' :
(b.behavior === 'flame' ? '#ff8a2a' :
(b.behavior === 'pierce_drag' ? '#b35b42' :
(b.behavior === 'homing' ? '#df6b73' : '#f4dfbc')));
ctx.lineWidth = Math.max(2, b.r * 0.7);
ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - nx * Math.min(22, sp * 0.04), b.y - ny * Math.min(22, sp * 0.04)); ctx.stroke();
ctx.globalAlpha = 1;
}
if (b.behavior === 'flame') {
const life = 1 - b.t / b.life;
ctx.globalAlpha = 0.24 * life;
ctx.fillStyle = '#b01818'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 7, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.8 * life;
ctx.fillStyle = '#ff8020'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 3, 0, TAU); ctx.fill();
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(b.x, b.y, Math.max(2, b.r * 0.45), 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
continue;
}
if (b.behavior === 'cone') {
const life = 1 - b.t / b.life;
ctx.globalAlpha = 0.2 * life;
ctx.fillStyle = '#b9ec55'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 6, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.75 * life;
ctx.fillStyle = '#70c030'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 2, 0, TAU); ctx.fill();
ctx.fillStyle = '#d6ff78'; ctx.beginPath(); ctx.arc(b.x - 2, b.y - 2, 1.6, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
continue;
}
if (b.behavior === 'maggot') {
ctx.fillStyle = '#d0d8b0';
ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(Math.atan2(b.vy, b.vx));
ctx.beginPath(); ctx.ellipse(0, 0, 4.5, 2.4, 0, 0, TAU); ctx.fill();
ctx.fillStyle = '#6b4934'; ctx.fillRect(2.5, -1, 2, 2); ctx.restore();
continue;
}
if (b.sprite === 'bullet_saw') rot = b.t * 18;
Sprites.draw(ctx, b.sprite, b.x, b.y, rot, (PROJECTILE_DRAW_SIZE[b.sprite] || 24) * Math.sqrt(b.sizeMul || 1));
if (b.sprite === 'bullet_saw') {
ctx.globalAlpha = 0.35;
ctx.strokeStyle = '#ffd67d'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(b.x, b.y, 11 + Math.sin(b.t * 28) * 2, 0, TAU); ctx.stroke();
ctx.globalAlpha = 1;
} else if (b.sprite === 'bullet_harpoon' && sp > 20) {
const nx = b.vx / sp, ny = b.vy / sp;
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#6f4b3d'; ctx.lineWidth = 2;
ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(b.x - nx * 7, b.y - ny * 7); ctx.lineTo(b.x - nx * 30, b.y - ny * 30); ctx.stroke();
ctx.setLineDash([]); ctx.globalAlpha = 1;
} else if (b.sprite === 'bullet_cleaver') {
ctx.globalAlpha = 0.28; ctx.strokeStyle = '#f0c8b2'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(b.x, b.y, 13, rot - 1.1, rot + 0.4); ctx.stroke(); ctx.globalAlpha = 1;
}
}
for (const b of G.ebullets) {
if (b.behavior === 'orbit' && b.anchor && b.anchor.hp > 0) {
ctx.globalAlpha = 0.38;
ctx.strokeStyle = '#7b5141'; ctx.lineWidth = 2;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(b.anchor.x, b.anchor.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.setLineDash([]); ctx.globalAlpha = 1;
}
ctx.globalAlpha = 0.16;
ctx.fillStyle = '#ff3650'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 1.9, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
const sprite = b.sprite || 'bullet_gore';
Sprites.draw(ctx, sprite, b.x, b.y, b.t * 6, PROJECTILE_DRAW_SIZE[sprite] || 24);
}
}
+434
View File
@@ -0,0 +1,434 @@
// ---- enemy types, AI, damage & death ----
const ENEMY_TYPES = {
shambler: { hp: 26, spd: 55, dmg: 1, r: 18, drawSize: 64, xp: 1, sprite: 'enemy_shambler' },
runner: { hp: 12, spd: 236, dmg: 1, r: 15, drawSize: 64, xp: 1, sprite: 'enemy_runner' },
spitter: { hp: 20, spd: 60, dmg: 1, r: 18, drawSize: 64, xp: 2, sprite: 'enemy_spitter', ranged: true },
splitter: { hp: 30, spd: 70, dmg: 1, r: 21, drawSize: 64, xp: 1, sprite: 'enemy_splitter', splits: true },
mini: { hp: 6, spd: 120, dmg: 1, r: 10, drawSize: 32, xp: 1, sprite: 'enemy_mini' },
exploder: { hp: 14, spd: 110, dmg: 2, r: 16, drawSize: 64, xp: 2, sprite: 'enemy_exploder', explodes: true },
};
const MAX_ENEMIES = 72;
function makeEnemy(type, x, y, floor, elite) {
const t = ENEMY_TYPES[type];
const up = powerScore();
// floor scaling × player-power scaling (+5% hp / +1% speed per upgrade)
const fscale = (1 + (floor - 1) * 0.22) * (1 + up * 0.05);
const sscale = 1 + (floor - 1) * 0.04 + up * 0.01;
const e = {
type, x, y, vx: 0, vy: 0,
r: t.r * (elite ? 1.45 : 1),
drawSize: t.drawSize * (elite ? 1.5 : 1),
hp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure,
maxHp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure,
spd: t.spd * sscale * (elite ? 0.9 : 1) * rand(0.9, 1.1) * G.pressure,
dmg: t.dmg + (elite ? 1 : 0),
xp: t.xp * (elite ? 3 : 1),
sprite: t.sprite,
elite: !!elite,
hitT: 0, flash: 0, burnT: 0, burnDps: 0, bleedT: 0, bleedDps: 0,
slowT: 0, stunT: 0, rootT: 0,
fireT: rand(1, 2.5), fuse: -1, wob: rand(0, TAU),
animSeed: rand(0, TAU), animT: rand(0, 1), attackT: 0, actionT: 0,
spawnT: 0, dead: false,
boss: false,
};
return e;
}
// pick an enemy type for a floor-scaled wave
function pickEnemyType(floor) {
const table = [
['shambler', 40],
['runner', floor >= 1 ? 25 : 0],
['spitter', floor >= 2 ? 18 : 0],
['splitter', floor >= 2 ? 16 : 0],
['exploder', floor >= 3 ? 14 : 0],
];
const total = table.reduce((s, t) => s + t[1], 0);
let r = Math.random() * total;
for (const [type, w] of table) { r -= w; if (r <= 0) return type; }
return 'shambler';
}
// true if (x,y) is within keep-out range of any of the current room's doorways
function nearAnyDoor(x, y) {
const r = G.cur;
if (!r) return false;
const M = 130; // spawn keep-out radius around doors
if (r.doors.n && Math.abs(x - W / 2) < DOOR_HALF + 30 && y < WALL + M) return true;
if (r.doors.s && Math.abs(x - W / 2) < DOOR_HALF + 30 && y > H - WALL - M) return true;
if (r.doors.e && Math.abs(y - H / 2) < DOOR_HALF + 30 && x > W - WALL - M) return true;
if (r.doors.w && Math.abs(y - H / 2) < DOOR_HALF + 30 && x < WALL + M) return true;
return false;
}
function spawnPosAwayFromPlayer() {
const p = G.player;
for (let tries = 0; tries < 30; tries++) {
const x = rand(WALL + 40, W - WALL - 40);
const y = rand(WALL + 40, H - WALL - 40);
if (dist(x, y, p.x, p.y) <= 200) continue;
if (nearAnyDoor(x, y)) continue;
return { x, y };
}
// fallback: center of the room, away from every door
return { x: rand(W / 2 - 150, W / 2 + 150), y: rand(H / 2 - 120, H / 2 + 120) };
}
function spawnWave(count, floor) {
for (let i = 0; i < count; i++) {
if (G.enemies.length >= MAX_ENEMIES) break;
const pos = spawnPosAwayFromPlayer();
const elite = chance(Math.min(0.03 + floor * 0.02, 0.2));
const e = makeEnemy(pickEnemyType(floor), pos.x, pos.y, floor, elite);
G.enemies.push(e);
spawnBlood(pos.x, pos.y, rand(0, TAU), 4);
Sfx.spawn(e);
}
}
// returns true if the enemy died
function damageEnemy(e, dmg, ang, knockback, opts) {
if (e.hp <= 0) return true;
if (e.phased) return false;
if (!(dmg > 0)) return false; // NaN / zero / negative damage does nothing
// crit roll
const p = G.player;
const o = opts || {};
const crit = !!(p && !o.noCrit && chance(p.stats.crit || 0));
if (crit) dmg *= p.stats.critMul || 2;
const mitigated = e.dmgTakenMul !== undefined && e.dmgTakenMul < 1;
dmg *= e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul;
e.hp -= dmg;
e.flash = 0.12;
spawnText(e.x, e.y, Math.max(1, Math.round(dmg)), mitigated ? '#d0b887' : '#ffb0a0');
if (e.plates > 0) {
e.plateDmg = (e.plateDmg || 0) + dmg;
const threshold = e.maxHp * 0.06;
if (e.plateDmg >= threshold) {
e.plateDmg -= threshold;
e.plates--;
e.rootT = Math.max(e.rootT || 0, 0.45);
e.flash = 0.2;
addShake(6);
spawnGibs(e.x, e.y, 4, true);
spawnText(e.x, e.y - e.r, 'PLATE BROKEN', '#ffd080');
Sfx.boss(e, 'plate');
if (e.plates <= 0) {
e.dmgTakenMul = 1;
addToast('ARMOR BREACHED', 'Plate Father is exposed');
Sfx.boss(e, 'enrage');
}
}
}
if (knockback && !e.boss && !e.dragged) {
const kb = p ? p.stats.knockbackMul || 1 : 1;
e.vx += Math.cos(ang) * 90 * kb; e.vy += Math.sin(ang) * 90 * kb;
}
if (p && !o.noProc) procOnHit(e, dmg, ang, o);
Sfx.hit(e, crit);
if (e.hp <= 0) { killEnemy(e, ang); return true; }
return false;
}
// Shared player-hit payload. Every weapon primitive routes through damageEnemy,
// so items remain useful for bullets, beams, slams, saws, traps and orbitals.
function procOnHit(e, dmg, ang, opts) {
const p = G.player, s = p.stats;
const chanceScale = opts.procScale === undefined ? 1 : opts.procScale;
const magnitudeScale = opts.procMagnitudeScale === undefined ? 1 : opts.procMagnitudeScale;
const intervalScale = opts.procIntervalScale === undefined ? 1 : opts.procIntervalScale;
const procDamage = dmg / Math.max(0.05, intervalScale) * magnitudeScale;
if (s.splinter > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) {
const count = Math.min(18, Math.floor(s.splinter));
for (let i = 0; i < count; i++) {
const sa = rand(0, TAU);
G.bullets.push({
x: e.x, y: e.y, ang: sa,
vx: Math.cos(sa) * 300 * s.shotSpeedMul, vy: Math.sin(sa) * 300 * s.shotSpeedMul,
r: 3 * s.sizeMul, dmg: procDamage * 0.4, pierce: 0, bounce: s.bounce,
life: 0.35 * s.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone',
homing: s.homing ? 1.6 + s.homing * 0.7 : 0, shard: true, sizeMul: s.sizeMul,
});
}
spawnSpark(e.x, e.y, ang);
}
if (s.bleed > 0) {
e.bleedT = Math.max(e.bleedT || 0, 2.2);
e.bleedDps = Math.max(e.bleedDps || 0, procDamage * s.bleed * 0.45);
}
if (s.igniteChance > 0 && chance(clamp(s.igniteChance * chanceScale, 0, 0.9))) {
e.burnT = Math.max(e.burnT || 0, 1.8);
e.burnDps = Math.max(e.burnDps || 0, 5 * s.dmgMul);
}
if (s.slowOnHit > 0 && chance(clamp(s.slowOnHit * chanceScale, 0, 0.9))) e.slowT = Math.max(e.slowT || 0, 1.5);
if (!e.boss && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35);
if (s.pullOnHit > 0 && !e.boss && chance(clamp(s.pullOnHit * chanceScale, 0, 0.8))) {
const toward = angleTo(e.x, e.y, p.x, p.y);
e.vx += Math.cos(toward) * 170; e.vy += Math.sin(toward) * 170;
}
if (s.acidOnHit > 0 && G.hazards.length < 80 && chance(clamp(s.acidOnHit * chanceScale, 0, 0.65))) {
G.hazards.push({ kind: 'acid', x: e.x, y: e.y, r: 18 * s.sizeMul, life: 2.5, t: 0, dps: 5 * s.dmgMul });
}
if (s.chain > 0 && G.enemies.length > 1 && chance(clamp(chanceScale, 0, 1))) {
const hit = new Set([e]);
let from = e;
for (let i = 0; i < Math.min(6, Math.floor(s.chain)); i++) {
let next = null, best = 180 * 180;
for (const target of G.enemies) {
if (target.hp <= 0 || target.phased || hit.has(target)) continue;
const d2 = dist2(from.x, from.y, target.x, target.y);
if (d2 < best) { best = d2; next = target; }
}
if (!next) break;
hit.add(next);
damageEnemy(next, procDamage * 0.35, angleTo(from.x, from.y, next.x, next.y), false, { noProc: true, noCrit: true });
spawnSpark(next.x, next.y, angleTo(from.x, from.y, next.x, next.y));
from = next;
}
}
if (s.mortar > 0) {
p.mortarHits = (p.mortarHits || 0) + chanceScale;
const every = Math.max(2, 7 - Math.min(5, Math.floor(s.mortar)));
if (p.mortarHits >= every) {
p.mortarHits -= every;
const radius = 42 + s.mortar * 5;
for (const target of G.enemies) {
if (target.hp > 0 && target !== e && dist2(e.x, e.y, target.x, target.y) < radius * radius) {
damageEnemy(target, procDamage * 0.55, angleTo(e.x, e.y, target.x, target.y), true, { noProc: true, noCrit: true });
}
}
spawnExplosionFx(e.x, e.y, radius);
}
}
}
function tickEnemyDamage(e, amount) {
if (!(amount > 0) || e.hp <= 0) return;
e.hp -= amount * (e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul);
if (e.hp <= 0) killEnemy(e, rand(0, TAU));
}
function killEnemy(e, ang) {
if (e.dead) return;
spawnCorpse(e);
e.dead = true;
e.hp = 0;
const p = G.player;
if (p.stats.frenzy > 0) p.frenzyT = Math.max(p.frenzyT || 0, 3);
G.kills++;
addScore(e.boss ? 500 : (e.elite ? 40 : 10));
spawnBlood(e.x, e.y, ang || rand(0, TAU), e.boss ? 40 : (e.elite ? 20 : 10), e.boss || e.elite);
spawnGibs(e.x, e.y, e.boss ? 16 : (e.elite ? 8 : 4), e.boss || e.elite);
if (e.boss || e.elite) spawnShockwave(e.x, e.y, e.boss ? e.r * 2.4 : e.r * 1.7, '#b82a38', e.boss ? 0.9 : 0.55);
if (!e.boss) Sfx.enemyDie(e);
// Vampire Dentures heal on kills, naturally normalizing slow and rapid weapons.
const stealChance = clamp((p.stats.lifestealChance || 0) * 3, 0, 0.9);
if (p.hp < p.stats.maxHp && stealChance && chance(stealChance)) {
lifestealPlayer(e.boss ? 4 : (e.elite ? 2 : 1));
}
if (e.boss) { onBossDeath(e); return; }
// Volatile Bile: kills explode — radius and damage scale with tier
if (p.stats.explodeOnKill > 0) {
const rad = 60 + p.stats.explodeOnKill * 15;
areaDamage(e.x, e.y, rad, (10 + p.stats.explodeOnKill * 8) * p.stats.dmgMul, true);
spawnExplosionFx(e.x, e.y, rad);
Sfx.explode({ x: e.x, y: e.y });
}
// drops
const luck = p.stats.luck;
let gemVal = e.xp + (p.stats.bloodlust && chance(p.stats.bloodlust) ? e.xp : 0);
// Bloodrush keeps its XP multiplier and makes the bonus visible as extra crystals.
const xpBonusChance = clamp((p.stats.xpMul || 1) - 1, 0, 0.9);
if (xpBonusChance && chance(xpBonusChance)) gemVal += Math.max(1, Math.round(e.xp * 0.5));
if (e.elite) {
// special monsters: always bonus XP, mostly ammo, sometimes an item
gemVal += irand(3, 5);
spawnGems(e.x, e.y, gemVal);
if (chance(0.18 + luck * 0.1)) {
spawnPickup('item', e.x, e.y, { iid: randomItemId() });
} else if (chance(0.6 + luck * 0.2)) {
spawnPickup('ammo', e.x, e.y);
}
} else {
spawnGems(e.x, e.y, gemVal);
if (chance(0.07 + luck * 0.04)) {
spawnPickup('ammo', e.x, e.y);
} else if (chance(0.022 + luck * 0.03)) {
spawnPickup('heart', e.x, e.y);
}
}
// splitter splits
if (ENEMY_TYPES[e.type].splits && !e.elite) {
Sfx.split(e);
for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) {
const m = makeEnemy('mini', e.x + rand(-12, 12), e.y + rand(-12, 12), G.floor, false);
G.enemies.push(m);
}
}
// exploder goes boom
if (ENEMY_TYPES[e.type].explodes) explodeAt(e.x, e.y, 60, 2, false);
}
function explodeAt(x, y, r, dmg, fromPlayer) {
spawnExplosionFx(x, y, r);
Sfx.explode({ x, y });
if (fromPlayer) {
areaDamage(x, y, r, dmg, true);
} else {
const p = G.player;
if (dist(x, y, p.x, p.y) < r + p.r) hurtPlayer(dmg, angleTo(x, y, p.x, p.y));
}
}
function updateEnemies(dt) {
const p = G.player;
for (let i = G.enemies.length - 1; i >= 0; i--) {
const e = G.enemies[i];
if (e.hp <= 0) { G.enemies.splice(i, 1); continue; }
e.spawnT = Math.min((e.spawnT || 0) + dt, 1);
e.animT = (e.animT || 0) + dt;
if (e.attackT > 0) { e.attackT -= dt; e.actionT += dt; }
// status effects
if (e.flash > 0) e.flash -= dt;
if (e.orbT > 0) e.orbT -= dt;
if (e.burnT > 0) {
e.burnT -= dt;
tickEnemyDamage(e, e.burnDps * dt);
if (chance(0.2)) addParticle({ type: 'flame', x: e.x + rand(-6, 6), y: e.y + rand(-6, 6), vx: 0, vy: -40, life: 0.3, t: 0, r: 3 });
if (e.hp <= 0) { G.enemies.splice(i, 1); continue; }
}
if (e.bleedT > 0) {
e.bleedT -= dt;
tickEnemyDamage(e, e.bleedDps * dt);
if (e.hp <= 0) { G.enemies.splice(i, 1); continue; }
}
if (e.slowT > 0) e.slowT -= dt;
if (e.stunT > 0) { e.stunT -= dt; e.vx = 0; e.vy = 0; continue; }
if (e.rootT > 0) { e.rootT -= dt; e.vx = 0; e.vy = 0; continue; }
if (e.dragged) { e.vx = 0; e.vy = 0; continue; } // harpooned
if (e.boss) { updateBoss(e, dt); }
else updateEnemyAI(e, dt);
// integrate + separation
e.x += e.vx * dt; e.y += e.vy * dt;
const drag = Math.pow(0.82, dt * 60);
e.vx *= drag; e.vy *= drag;
// Resolve each pair once instead of twice. This halves the hottest
// enemy-crowd loop while preserving the same mutual separation.
for (let j = i - 1; j >= 0; j--) {
const o = G.enemies[j];
if (o.hp <= 0) continue;
const d2 = dist2(e.x, e.y, o.x, o.y);
const min = (e.r + o.r) * 0.9;
if (d2 < min * min && d2 > 0.01) {
const d = Math.sqrt(d2), push = (min - d) / d * 30 * dt;
const px = (e.x - o.x) * push, py = (e.y - o.y) * push;
e.x += px; e.y += py; o.x -= px; o.y -= py;
}
}
e.x = clamp(e.x, WALL + e.r, W - WALL - e.r);
e.y = clamp(e.y, WALL + e.r, H - WALL - e.r);
// contact damage
if (e.hitT > 0) e.hitT -= dt;
if (!e.phased && e.hitT <= 0 && p.hp > 0 && dist2(e.x, e.y, p.x, p.y) < (e.r + p.r) * (e.r + p.r)) {
e.attackT = 0.3; e.actionT = 0;
hurtPlayer(e.dmg, angleTo(e.x, e.y, p.x, p.y), e);
e.hitT = 0.7;
}
}
}
function updateEnemyAI(e, dt) {
const p = G.player;
const a = angleTo(e.x, e.y, p.x, p.y);
const d = dist(e.x, e.y, p.x, p.y);
const spd = e.slowT > 0 ? e.spd * 0.45 : e.spd;
const t = ENEMY_TYPES[e.type];
if (t.ranged) {
// spitter: hold ~230px range and fire
if (d > 250) { e.vx += Math.cos(a) * spd * 3 * dt; e.vy += Math.sin(a) * spd * 3 * dt; }
else if (d < 190) { e.vx -= Math.cos(a) * spd * 3 * dt; e.vy -= Math.sin(a) * spd * 3 * dt; }
e.fireT -= dt;
if (e.fireT <= 0 && d < 420) {
e.fireT = e.elite ? 1.1 : 1.9;
e.attackT = 0.34; e.actionT = 0;
const n = e.elite ? 3 : 1;
for (let k = 0; k < n; k++) {
const fa = a + (n > 1 ? (k - 1) * 0.25 : 0);
G.ebullets.push({
x: e.x, y: e.y, vx: Math.cos(fa) * 220, vy: Math.sin(fa) * 220,
r: 5, dmg: 1, life: 3, t: 0, sprite: 'bullet_gore',
});
}
Sfx.spit(e);
}
} else if (t.explodes) {
e.vx += Math.cos(a) * spd * 4 * dt; e.vy += Math.sin(a) * spd * 4 * dt;
if (e.fuse < 0 && d < 56) { e.fuse = 0.5; e.attackT = 0.5; e.actionT = 0; Sfx.fuse(e); }
if (e.fuse >= 0) {
e.fuse -= dt;
e.flash = 0.05;
if (e.fuse <= 0) { e.hp = 0; explodeAt(e.x, e.y, 70, 2, false); }
}
} else {
// chaser with a little wobble
e.wob += dt * 3;
const wa = a + Math.sin(e.wob) * 0.3;
e.vx += Math.cos(wa) * spd * 4 * dt; e.vy += Math.sin(wa) * spd * 4 * dt;
}
}
function drawEnemies(ctx) {
for (const e of G.enemies) {
if (e.hp <= 0) continue;
const p = G.player;
const face = angleTo(e.x, e.y, p.x, p.y);
const speed = Math.min(Math.hypot(e.vx, e.vy) / Math.max(e.spd, 1), 1);
let action = speed > 0.12 ? 'move' : 'idle';
let animTime = e.animT || 0;
if (e.attackT > 0 || (e.boss && e.state !== 'idle')) {
action = 'attack';
animTime = e.attackT > 0 ? e.actionT : (e.animT || 0);
}
if (e.flash > 0) { action = 'hit'; animTime = 0.12 - e.flash; }
let pulse = 1;
if (e.type === 'exploder' && e.fuse >= 0) pulse = 1 + (0.5 - e.fuse) * 0.13 + Math.sin(G.time * 28) * 0.04;
const appear = clamp((e.spawnT || 0) / 0.24, 0.12, 1);
if (e.alpha === undefined || e.alpha > 0.05) Sprites.shadow(ctx, e.x, e.y + e.r * 0.55, e.r * 0.9, e.r * 0.34, (e.boss ? 0.5 : 0.34) * (e.alpha === undefined ? 1 : e.alpha));
Sprites.actor(ctx, e.sprite, e.x, e.y, face, action, animTime,
e.drawSize || (e.boss ? 128 : 64), e.alpha === undefined ? 1 : e.alpha, appear * pulse, appear * pulse);
if (e.plates > 0) {
ctx.save(); ctx.translate(e.x, e.y); ctx.lineWidth = 4;
for (let k = 0; k < e.plates; k++) {
ctx.strokeStyle = k % 2 ? '#8d755d' : '#b79b71';
ctx.beginPath(); ctx.arc(0, 0, e.r + 7, k * Math.PI / 2 + 0.08, k * Math.PI / 2 + 1.25); ctx.stroke();
}
ctx.restore();
}
if (e.elite) {
ctx.strokeStyle = '#ffd060'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7;
ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 3, 0, TAU); ctx.stroke();
ctx.globalAlpha = 1;
}
// boss & elite hp pip
if (e.boss || e.elite) {
const w = e.boss ? 60 : 26;
ctx.fillStyle = '#400a10';
ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w, 4);
ctx.fillStyle = '#d92038';
ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w * clamp(e.hp / e.maxHp, 0, 1), 4);
}
}
}
+208
View File
@@ -0,0 +1,208 @@
// ---- HUD: hearts, XP, weapon, items, boss bar, minimap, toasts ----
function drawHUD(ctx) {
const p = G.player;
ctx.save();
const panelH = p.holstered ? 140 : 112;
drawPixelPanel(ctx, 8, 8, 304, panelH, {
accent: '#cf2942', border: 'rgba(115,43,56,0.95)', blood: true,
seed: G.floor, bloodAlpha: 0.34,
});
ctx.textAlign = 'left';
ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 10px monospace';
ctx.fillText('BUTCHER // VITALS', 18, 23);
ctx.fillStyle = '#d7a934'; ctx.textAlign = 'right';
ctx.fillText('LV ' + String(p.level).padStart(2, '0'), 300, 23);
// Hearts retain their illustrative icons on a cleaner fixed baseline.
const hearts = Math.ceil(p.stats.maxHp / 2);
const maxVitalIcons = 11;
const shownHearts = Math.min(hearts, maxVitalIcons);
for (let i = 0; i < shownHearts; i++) {
const x = 21 + i * 22, y = 39;
const fill = clamp(p.hp - i * 2, 0, 2) / 2;
Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25);
if (fill > 0) {
ctx.save();
ctx.beginPath(); ctx.rect(x - 10, y - 10, 20 * fill, 20); ctx.clip();
Sprites.draw(ctx, 'heart', x, y, 0, 18);
ctx.restore();
}
}
const shownShields = Math.min(p.shieldHp, Math.max(0, maxVitalIcons - shownHearts));
for (let i = 0; i < shownShields; i++) {
const x = 21 + (shownHearts + i) * 22, y = 39;
ctx.fillStyle = '#3bc9e0';
ctx.beginPath(); ctx.arc(x - 3, y - 2, 4, 0, TAU); ctx.arc(x + 3, y - 2, 4, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.moveTo(x - 6.5, y); ctx.lineTo(x, y + 7); ctx.lineTo(x + 6.5, y); ctx.fill();
}
const hiddenVitals = hearts + p.shieldHp - shownHearts - shownShields;
if (hiddenVitals > 0) {
drawPixelTag(ctx, '+' + hiddenVitals, 266, 29, { width: 34, height: 21, color: '#d7b24a', accent: '#8f6724' });
}
const need = xpForLevel(p.level);
drawPixelBar(ctx, 16, 53, 220, 10, p.xp / need, {
fill: '#3d9fc2', glint: '#a5edee', border: '#465d69', segments: 12,
});
ctx.fillStyle = '#88b9c4'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right';
ctx.fillText(Math.floor(p.xp) + ' / ' + need + ' XP', 299, 62);
const w = WEAPONS[p.weapon.id];
ctx.fillStyle = 'rgba(3,2,3,0.6)'; ctx.fillRect(15, 70, 48, 34);
ctx.strokeStyle = 'rgba(116,56,65,0.65)'; ctx.strokeRect(15.5, 70.5, 47, 33);
Sprites.draw(ctx, 'w_' + w.id, 39, 87, 0, 54);
ctx.textAlign = 'left'; ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 12px monospace';
ctx.fillText(w.name.toUpperCase(), 72, 84);
ctx.fillStyle = '#7d6262'; ctx.font = '9px monospace';
ctx.fillText('ACTIVE WEAPON', 72, 99);
const ammoText = p.weapon.ammo === Infinity ? 'SIDEARM' : String(Math.ceil(p.weapon.ammo)).padStart(3, '0');
drawPixelTag(ctx, ammoText, 238, 74, {
width: 58, height: 26,
color: p.weapon.ammo === Infinity ? '#9c8d86' : (p.weapon.ammo < w.ammo * 0.25 ? '#ff4057' : '#e0b94e'),
accent: p.weapon.ammo === Infinity ? '#5d4c50' : '#a47e25', font: 'bold 10px monospace',
});
if (p.holstered) {
const holsterEmpty = p.holstered.ammo <= 0;
ctx.fillStyle = 'rgba(86,40,48,0.35)'; ctx.fillRect(15, 112, 281, 20);
Sprites.draw(ctx, 'w_' + p.holstered.id, 29, 122, 0, 28, false, 0.68);
ctx.fillStyle = holsterEmpty ? '#d94652' : '#aa9290'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
ctx.fillText((holsterEmpty ? '[EMPTY] ' : '[R] ') + WEAPONS[p.holstered.id].name.toUpperCase(), 47, 125);
ctx.fillStyle = holsterEmpty ? '#ef4057' : '#d0aa4a'; ctx.textAlign = 'right';
ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125);
}
const allItemEntries = Object.entries(p.items);
const maxVisibleItems = allItemEntries.length > 24 ? 23 : 24;
const itemEntries = allItemEntries.slice(0, maxVisibleItems);
if (itemEntries.length) {
const cols = Math.min(12, itemEntries.length), rows = Math.ceil(itemEntries.length / 12);
const trayY = panelH + 16, trayW = 28 + cols * 34, trayH = 22 + rows * 34;
drawPixelPanel(ctx, 8, trayY, trayW, trayH, {
accent: '#a77d2b', cut: 5, blood: true, seed: itemEntries.length, bloodAlpha: 0.2,
});
ctx.fillStyle = '#9e857a'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left';
ctx.fillText('IMPLANTS', 17, trayY + 12);
for (let index = 0; index < itemEntries.length; index++) {
const [iid, lvl] = itemEntries[index];
const ix = 28 + (index % 12) * 34;
const iy = trayY + 31 + Math.floor(index / 12) * 34;
Sprites.draw(ctx, 'i_' + iid, ix, iy, 0, 30, false, 0.95);
if (lvl > 1) {
ctx.fillStyle = '#ffd060'; ctx.font = 'bold 8px monospace';
ctx.fillText(romanNum(lvl), ix - 7, iy + 11);
}
}
if (allItemEntries.length > itemEntries.length) drawPixelTag(ctx, '+' + (allItemEntries.length - itemEntries.length), trayW - 49, trayY + trayH - 23, {
width: 30, height: 20, color: '#e0b94e', accent: '#8d6724', font: 'bold 8px monospace',
});
}
drawMinimap(ctx);
drawPixelPanel(ctx, W - 180, H - 104, 172, 96, {
accent: '#b5892f', cut: 6, blood: true, seed: G.kills + 4, bloodAlpha: 0.22,
});
ctx.textAlign = 'left'; ctx.fillStyle = '#88746f'; ctx.font = 'bold 8px monospace';
ctx.fillText('RUN DATA', W - 168, H - 87);
ctx.fillStyle = '#d6aa39'; ctx.font = 'bold 12px monospace';
ctx.fillText('FLOOR ' + String(G.floor).padStart(2, '0'), W - 168, H - 65);
ctx.textAlign = 'right'; ctx.fillStyle = '#a18d87'; ctx.font = '9px monospace';
ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67);
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace';
ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46);
ctx.textAlign = 'left'; ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace';
ctx.fillText('PRESSURE ' + Math.round(G.pressure * 100) + '%', W - 168, H - 28);
ctx.textAlign = 'right'; ctx.fillText('STREAK ' + G.streak, W - 18, H - 28);
drawPixelBar(ctx, W - 168, H - 20, 150, 8, (G.pressure - PRESSURE_MIN) / (PRESSURE_MAX - PRESSURE_MIN), {
fill: G.pressure > 1 ? '#c59231' : '#3d8f99', glint: G.pressure > 1 ? '#f4d86d' : '#8ce0df', border: '#5d3c43', segments: 10,
});
if (G.boss && G.boss.hp > 0) {
const bw = 460, bx = W / 2 - bw / 2, by = H - 48;
drawPixelPanel(ctx, bx, by, bw, 40, {
accent: '#e12842', cut: 6, fill: 'rgba(12,3,6,0.92)', blood: true,
seed: G.floor, bloodAlpha: 0.25,
});
const k = clamp(G.boss.hp / G.boss.maxHp, 0, 1);
ctx.fillStyle = '#efc6bd'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left';
ctx.fillText('TARGET // ' + G.boss.name, bx + 12, by + 15);
ctx.textAlign = 'right'; ctx.fillStyle = '#af7d7c';
ctx.fillText(Math.ceil(G.boss.hp) + ' / ' + Math.ceil(G.boss.maxHp), bx + bw - 12, by + 15);
drawPixelBar(ctx, bx + 11, by + 22, bw - 22, 10, k, {
fill: '#b51e35', glint: '#ff7d82', border: '#6e2936', segments: 20,
});
}
ctx.textAlign = 'center';
for (let i = 0; i < G.toasts.length; i++) {
const t = G.toasts[i];
const a = clamp(2.5 - t.t, 0, 1);
const y = 22 + i * 48;
ctx.globalAlpha = a;
ctx.font = 'bold 13px monospace';
const tw = Math.max(ctx.measureText(t.text).width + 48, 190);
drawPixelPanel(ctx, W / 2 - tw / 2, y, tw, t.sub ? 40 : 28, {
cut: 5, shadow: false, accent: '#c59231', blood: true,
seed: i + G.floor, bloodAlpha: 0.2,
});
ctx.fillStyle = '#e3b746'; ctx.fillText(t.text.toUpperCase(), W / 2, y + 17);
if (t.sub) {
ctx.fillStyle = '#9f8981'; ctx.font = '9px monospace';
ctx.fillText(t.sub.toUpperCase(), W / 2, y + 31);
}
ctx.globalAlpha = 1;
}
if (G.muted) {
drawPixelTag(ctx, '[M] MUTED', 9, H - 31, {
width: 76, height: 22, color: '#a28f87', accent: '#5f2932',
});
}
ctx.restore();
}
function drawMinimap(ctx) {
const rooms = Object.values(G.rooms);
if (!rooms.length) return;
const s = 12, pad = 3;
let minX = 0, maxX = 0, minY = 0, maxY = 0;
for (const r of rooms) {
minX = Math.min(minX, r.gx); maxX = Math.max(maxX, r.gx);
minY = Math.min(minY, r.gy); maxY = Math.max(maxY, r.gy);
}
const gridW = (maxX - minX + 1) * (s + pad) - pad;
const gridH = (maxY - minY + 1) * (s + pad) - pad;
const panelW = Math.max(148, gridW + 24), panelH = Math.max(78, gridH + 38);
const px = W - panelW - 8, py = 8;
drawPixelPanel(ctx, px, py, panelW, panelH, {
accent: '#4a9cad', cut: 6, blood: true, seed: G.floor + 9, bloodAlpha: 0.16,
});
ctx.fillStyle = '#8eb7bd'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left';
ctx.fillText('SECTOR MAP', px + 11, py + 16);
ctx.textAlign = 'right'; ctx.fillStyle = '#d1a839';
ctx.fillText('F-' + String(G.floor).padStart(2, '0'), px + panelW - 11, py + 16);
const ox = px + Math.floor((panelW - gridW) / 2);
const oy = py + 27 + Math.floor((panelH - 32 - gridH) / 2);
for (const r of rooms) {
if (!r.visited && r !== G.cur) continue;
const x = ox + (r.gx - minX) * (s + pad);
const y = oy + (r.gy - minY) * (s + pad);
ctx.fillStyle = '#130b0e'; ctx.fillRect(x - 1, y - 1, s + 2, s + 2);
if (r === G.cur) ctx.fillStyle = '#e9ded0';
else if (r.type === 'boss') ctx.fillStyle = '#b72138';
else if (r.type === 'item') ctx.fillStyle = '#c9a227';
else if (r.cleared) ctx.fillStyle = '#43545a';
else ctx.fillStyle = '#7a4f59';
ctx.fillRect(x, y, s, s);
if (r === G.cur) {
ctx.strokeStyle = '#67c7d2'; ctx.strokeRect(x - 2.5, y - 2.5, s + 5, s + 5);
}
}
}
function drawToastsUpdate(dt) {
for (const t of G.toasts) t.t += dt;
while (G.toasts.length && G.toasts[0].t > 2.5) G.toasts.shift();
}
+47
View File
@@ -0,0 +1,47 @@
// ---- keyboard + mouse input ----
const Input = {
keys: {},
pressed: {}, // edge-triggered, cleared each frame
mx: W / 2, my: H / 2,
mdown: false,
mpressed: false, // edge-triggered mouse
mreleased: false,
anyKey: false,
};
function initInput(canvas) {
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (!Input.keys[k]) Input.pressed[k] = true;
Input.keys[k] = true;
Input.anyKey = true;
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault();
});
window.addEventListener('keyup', (e) => { Input.keys[e.key.toLowerCase()] = false; });
window.addEventListener('blur', () => { Input.keys = {}; Input.mdown = false; });
function setMouse(e) {
const r = canvas.getBoundingClientRect();
Input.mx = (e.clientX - r.left) * (W / r.width);
Input.my = (e.clientY - r.top) * (H / r.height);
}
canvas.addEventListener('mousemove', setMouse);
canvas.addEventListener('mousedown', (e) => {
setMouse(e);
if (e.button === 0) { Input.mdown = true; Input.mpressed = true; Input.anyKey = true; }
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0) { Input.mdown = false; Input.mreleased = true; }
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
function clearInputEdges() {
Input.pressed = {};
Input.mpressed = false;
Input.mreleased = false;
Input.anyKey = false;
}
function keyDown(...ks) { return ks.some(k => Input.keys[k]); }
function keyPressed(...ks) { return ks.some(k => Input.pressed[k]); }
+93
View File
@@ -0,0 +1,93 @@
// ---- Isaac-style stacking passive items ----
const ITEMS = {
hollowpoints: { name: 'Hollow Points', desc: '+25% damage', apply: s => { s.dmgMul *= 1.25; } },
twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', apply: s => { s.rateMul *= 1.20; } },
scalpel: { name: 'Scalpel', desc: '+25% shot speed', apply: s => { s.shotSpeedMul *= 1.25; } },
leadmarrow: { name: 'Lead Marrow', desc: '+25% range', apply: s => { s.rangeMul *= 1.25; } },
piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', apply: s => { s.pierce += 1; } },
ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', apply: s => { s.bounce += 1; } },
splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', apply: s => { s.split += 1; } },
hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', apply: s => { s.fan += 1; } },
homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', apply: s => { s.homing = (s.homing || 0) + 1; } },
orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', apply: s => { s.orbitals += 1; } },
dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', apply: s => { s.lifestealChance += 0.04; } },
volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } },
backstabber: { name: 'Backstabber', desc: '+1 rear shot', apply: s => { s.rear = (s.rear || 0) + 1; } },
splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', apply: s => { s.splinter = (s.splinter || 0) + 2; } },
ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
luckycoin: { name: 'Lucky Coin', desc: 'Better drops', apply: s => { s.luck += 0.2; } },
magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', apply: s => { s.magnet *= 1.6; } },
bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } },
ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', apply: s => { s.chain += 1; } },
mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', apply: s => { s.mortar += 1; } },
bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } },
marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } },
hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', apply: s => { s.critMul += 0.50; } },
bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', apply: s => { s.crit += 0.06; } },
flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', apply: s => { s.bleed += 0.20; } },
emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', apply: s => { s.igniteChance += 0.20; } },
acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', apply: s => { s.acidOnHit += 0.12; } },
hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', apply: s => { s.pullOnHit += 0.18; } },
sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } },
graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate', apply: s => { s.rateMul *= 1.18; } },
deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', apply: s => { s.frenzy += 0.12; } },
orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } },
tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', apply: s => { s.armor += 0.08 / 0.92; } },
deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', apply: s => { s.invBonus += 0.25; } },
hollowbones: { name: 'Hollow Bones', desc: '+12% move speed', apply: s => { s.speedMul *= 1.12; } },
boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } },
wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', apply: s => { s.roomHeal += 1; } },
spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', apply: s => { s.thorns += 8; } },
secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', apply: s => { s.retaliate += 10; } },
twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal 1 heart', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } },
brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } },
crowbait: { name: 'Crow Bait', desc: '+25% drop luck', apply: s => { s.luck += 0.25; } },
gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', apply: s => { s.xpMul *= 1.20; } },
rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } },
};
// 50% of rolls favor items the player already owns (below cap) so high tiers
// are reachable if you commit to them
function randomItemId() {
const p = G.player;
const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < ITEM_LEVEL_CAP) : [];
if (owned.length && chance(0.5)) return choice(owned);
return choice(Object.keys(ITEMS));
}
const ITEM_LEVEL_CAP = 9;
function romanNum(n) { return ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'][n - 1] || ('Lv' + n); }
// duplicate pickups upgrade the item's quality (effect re-applies per tier)
function giveItem(iid) {
const p = G.player;
const item = ITEMS[iid];
const lvl = p.items[iid] || 0;
if (lvl >= ITEM_LEVEL_CAP) {
addScore(150);
spawnGems(p.x, p.y, 5);
addToast(item.name + ' MAX', 'already mastered — +150 score');
Sfx.pickup();
return;
}
item.apply(p.stats, p);
p.items[iid] = lvl + 1;
if (lvl > 0) {
addToast(item.name + ' ' + romanNum(lvl + 1), 'UPGRADED — ' + item.desc);
spawnText(p.x, p.y - 14, 'UPGRADED!', '#ffd060');
} else {
addToast(item.name, item.desc);
spawnText(p.x, p.y - 14, item.name.toUpperCase(), '#c9a227');
}
Sfx.item();
refreshOrbitals(p);
}
// spawn an item pedestal (item room / boss reward)
function spawnItemPedestal(x, y, iid) {
spawnPickup('itemspot', x, y);
spawnPickup('item', x, y, { iid: iid || randomItemId() });
}
+366
View File
@@ -0,0 +1,366 @@
// ---- bootstrap, game loop, mode dispatch ----
let canvas, ctx, lastT = 0;
function init() {
canvas = document.getElementById('game');
ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
initInput(canvas);
Sprites.load();
fitCanvas();
buildAtmosphereLayer();
window.addEventListener('resize', fitCanvas);
if (document.addEventListener) {
document.addEventListener('visibilitychange', () => {
lastT = performance.now();
if (document.hidden) { Input.keys = {}; Input.mdown = false; }
});
}
try { G.best = parseInt(localStorage.getItem('meatslicer_best') || '0', 10) || 0; }
catch (e) { G.best = 0; }
try {
const sv = parseFloat(localStorage.getItem('meatslicer_sfx_volume'));
const mv = parseFloat(localStorage.getItem('meatslicer_music_volume'));
if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1);
if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1);
G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1';
} catch (e) {}
requestAnimationFrame(loop);
}
function setAutoPerk(enabled) {
G.autoPerk = !!enabled;
try { localStorage.setItem('meatslicer_autoperk', G.autoPerk ? '1' : '0'); } catch (e) {}
addToast('AUTO-DRAFT ' + (G.autoPerk ? 'ON' : 'OFF'), G.autoPerk ? 'level-ups now choose randomly' : 'manual mutation drafts restored');
Sfx.menu();
}
function buildAtmosphereLayer() {
if (!document.createElement) return;
const layer = document.createElement('canvas');
layer.width = W; layer.height = H;
const c = layer.getContext('2d');
const vignette = c.createRadialGradient(W / 2, H / 2, 170, W / 2, H / 2, 590);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(0.72, 'rgba(12,2,5,0.12)');
vignette.addColorStop(1, 'rgba(4,1,2,0.64)');
c.fillStyle = vignette; c.fillRect(0, 0, W, H);
// Fixed grain gives the room texture without per-frame random allocations.
for (let i = 0; i < 380; i++) {
const x = hashTile(i * 17, 11) % W, y = hashTile(i * 29, 23) % H;
c.fillStyle = i % 3 ? 'rgba(255,220,205,0.018)' : 'rgba(0,0,0,0.035)';
c.fillRect(x, y, 1, 1);
}
G.atmosphereLayer = layer;
}
function fitCanvas() {
const k = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.97;
canvas.style.width = Math.floor(W * k) + 'px';
canvas.style.height = Math.floor(H * k) + 'px';
}
function startRun() {
Sfx.init(); Sfx.resume();
Sfx.stopAllLoops(); Sfx.menu();
G.floor = 1; G.score = 0; G.kills = 0; G.time = 0;
G.parts = []; G.corpses = []; G.toasts = []; G.pendingLevelups = 0; G.perkChoices = null;
G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = [];
G.shake = 0; G.flash = 0;
initPlayer();
genFloor(1);
enterRoom(0, 0);
G.player.shieldHp = G.player.stats.shieldPerk || 0;
G.mode = 'play';
Music.requestFloorMusic();
addToast('FLOOR 1', 'clear the rooms. find the stairs. feed.');
}
function gameOver() {
G.mode = 'gameover';
Sfx.playerDeath();
if (G.score > (G.best || 0)) {
G.best = G.score;
try { localStorage.setItem('meatslicer_best', String(G.best)); } catch (e) {}
}
Music.playMenu();
}
function loop(now) {
const dt = Math.min((now - lastT) / 1000 || 0.016, 0.05);
lastT = now;
update(dt);
draw();
clearInputEdges();
requestAnimationFrame(loop);
}
function update(dt) {
Music.update(dt);
// global keys
if (keyPressed('m')) {
G.muted = !G.muted;
Music.setMuted(G.muted);
Sfx.setMuted(G.muted);
}
switch (G.mode) {
case 'menu':
if (Input.mpressed || keyPressed('enter', ' ')) startRun();
break;
case 'play':
G.time += dt;
if (keyPressed('p', 'escape')) { G.mode = 'pause'; Sfx.stopAllLoops(); Sfx.menu(); break; }
if (keyPressed('n')) Music.cycle(1);
if (keyPressed('r')) swapWeapon();
if (keyPressed('t')) setAutoPerk(!G.autoPerk);
updatePlayer(dt);
updateRoom(dt);
updateEnemies(dt);
updateTelegraphs(dt);
updateBullets(dt);
updateHazards(dt);
updatePickups(dt);
updateParticles(dt);
drawToastsUpdate(dt);
break;
case 'levelup':
updateLevelup();
updateParticles(dt);
break;
case 'pause':
if (keyPressed('p', 'escape')) { G.mode = 'play'; Sfx.menu(); break; }
if (keyPressed('r')) swapWeapon(true);
if (keyPressed('t')) setAutoPerk(!G.autoPerk);
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(); }
if (keyPressed('.', '>')) { Music.setVolume(G.musicVol + 0.1); Sfx.menu(); }
if (keyPressed('arrowleft', '[')) Music.cycle(-1);
else if (keyPressed('arrowright', ']')) Music.cycle(1);
else if (Input.mpressed) {
// jukebox arrows
if (dist(Input.mx, Input.my, W / 2 - 200, H / 2 + 60) < 22) Music.cycle(-1);
else if (dist(Input.mx, Input.my, W / 2 + 200, H / 2 + 60) < 22) Music.cycle(1);
}
break;
case 'gameover':
updateParticles(dt);
if (keyPressed('r', 'enter', ' ') || Input.mpressed) startRun();
break;
}
if (G.shake > 0) G.shake = Math.max(0, G.shake - dt * 30);
if (G.flash > 0) G.flash = Math.max(0, G.flash - dt);
if (G.transition > 0) G.transition = Math.max(0, G.transition - dt);
}
function draw() {
ctx.fillStyle = '#0a0506';
ctx.fillRect(0, 0, W, H);
if (G.mode === 'menu') { drawMenu(ctx); return; }
// world (with screen shake)
ctx.save();
if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5);
drawRoom(ctx);
drawCorpses(ctx);
drawPickups(ctx);
drawEnemies(ctx);
drawPlayer(ctx);
drawBullets(ctx);
drawParticles(ctx);
drawTelegraphs(ctx);
ctx.restore();
if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0);
// Fine inner frame and corner cuts finish the playfield presentation.
ctx.strokeStyle = 'rgba(104,36,43,0.55)'; ctx.lineWidth = 2;
ctx.strokeRect(2, 2, W - 4, H - 4);
ctx.strokeStyle = 'rgba(224,177,102,0.22)'; ctx.lineWidth = 1;
ctx.beginPath();
for (const [x, y, sx, sy] of [[8, 8, 1, 1], [W - 8, 8, -1, 1], [8, H - 8, 1, -1], [W - 8, H - 8, -1, -1]]) {
ctx.moveTo(x, y + sy * 20); ctx.lineTo(x, y); ctx.lineTo(x + sx * 20, y);
}
ctx.stroke();
drawHUD(ctx);
// hurt vignette
if (G.flash > 0) {
ctx.fillStyle = 'rgba(180,10,25,' + (G.flash * 0.55) + ')';
ctx.fillRect(0, 0, W, H);
}
// room transition fade
if (G.transition > 0) {
ctx.fillStyle = 'rgba(5,2,3,' + clamp(G.transition * 3, 0, 1) + ')';
ctx.fillRect(0, 0, W, H);
}
if (G.mode === 'levelup') drawLevelup(ctx);
else if (G.mode === 'pause') drawPause(ctx);
else if (G.mode === 'gameover') drawGameOver(ctx);
}
function drawMenu(ctx) {
const t = performance.now() / 1000;
const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590);
if (bg && bg.addColorStop) {
bg.addColorStop(0, '#260b11'); bg.addColorStop(0.5, '#100609'); bg.addColorStop(1, '#050204');
ctx.fillStyle = bg;
} else ctx.fillStyle = '#100609';
ctx.fillRect(0, 0, W, H);
// Low-contrast industrial grid replaces decorative circles with a cleaner,
// modern system-screen backdrop while preserving hard pixel geometry.
ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 48) {
ctx.strokeStyle = x % 192 === 0 ? 'rgba(121,35,48,0.11)' : 'rgba(121,35,48,0.045)';
ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); ctx.stroke();
}
for (let y = 0; y <= H; y += 48) {
ctx.strokeStyle = y % 192 === 0 ? 'rgba(121,35,48,0.11)' : 'rgba(121,35,48,0.045)';
ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); ctx.stroke();
}
ctx.globalAlpha = 0.13;
Sprites.draw(ctx, 'bullet_cleaver', 168, 274, -0.2, 150);
Sprites.draw(ctx, 'bullet_cleaver', W - 168, 274, Math.PI + 0.2, 150);
ctx.globalAlpha = 1;
drawPixelTag(ctx, 'SLAUGHTER PROTOCOL // 01', 28, 24, {
width: 188, height: 24, color: '#a99089', accent: '#b6243a',
});
drawPixelTag(ctx, 'LOCAL RUNNER // ONLINE', W - 196, 24, {
width: 168, height: 24, color: '#74aeb5', accent: '#398b99',
});
ctx.textAlign = 'center';
ctx.font = '900 70px monospace';
ctx.fillStyle = '#39070d'; ctx.fillText('MEATSLICER', W / 2 + 4, 248);
ctx.fillStyle = '#f02b48'; ctx.fillText('MEATSLICER', W / 2, 243);
ctx.fillStyle = '#ff9a8a'; ctx.globalAlpha = 0.35; ctx.fillText('MEATSLICER', W / 2, 240); ctx.globalAlpha = 1;
drawBloodTrim(ctx, W / 2 - 230, 255, 460, 11, 0.65);
ctx.fillStyle = '#d4ad3d'; ctx.font = 'bold 12px monospace';
ctx.fillText('A BUTCHER\'S DESCENT // CLEAR · HARVEST · DESCEND', W / 2, 281);
const pulse = 0.75 + Math.sin(t * 3) * 0.18;
drawPixelPanel(ctx, W / 2 - 180, 320, 360, 64, {
accent: '#e22b46', border: 'rgba(218,40,62,' + pulse + ')', lineWidth: 2,
fill: 'rgba(12,4,7,0.9)', blood: true, seed: 4, bloodAlpha: 0.35,
});
ctx.fillStyle = '#7f6062'; ctx.font = 'bold 9px monospace';
ctx.fillText('INITIATE DESCENT', W / 2, 341);
ctx.fillStyle = '#f5e9d6'; ctx.font = 'bold 14px monospace';
ctx.fillText('[ ENTER ] OR CLICK TO START', W / 2, 365);
const controls = ['WASD MOVE', 'MOUSE AIM', 'LMB FIRE', 'R SWAP', 'P PAUSE', 'T AUTO', 'M MUTE', 'N MUSIC'];
ctx.font = 'bold 9px monospace';
const widths = controls.map(label => Math.ceil(ctx.measureText(label).width) + 16);
const total = widths.reduce((sum, width) => sum + width, 0) + (controls.length - 1) * 6;
let tx = (W - total) / 2;
for (let i = 0; i < controls.length; i++) {
drawPixelTag(ctx, controls[i], tx, 420, { width: widths[i], height: 24, color: '#a9958e', accent: i < 3 ? '#8f2637' : '#5f3943' });
tx += widths[i] + 6;
}
ctx.fillStyle = '#655155'; ctx.font = '9px monospace';
ctx.fillText('ROOM-CLEAR COMBAT // PERK DRAFTS // SIXTEEN WEAPONS // CROWNED MEAT', W / 2, 474);
if (G.best) {
drawPixelTag(ctx, 'BEST CUT ' + String(G.best).padStart(6, '0'), W / 2 - 74, 503, {
width: 148, height: 26, color: '#e0b94e', accent: '#a47e25',
});
}
}
function drawPause(ctx) {
ctx.fillStyle = 'rgba(4,2,3,0.78)'; ctx.fillRect(0, 0, W, H);
drawPixelPanel(ctx, W / 2 - 270, 126, 540, 388, {
accent: '#c92a40', border: 'rgba(119,51,61,0.95)', fill: 'rgba(11,5,8,0.96)',
blood: true, seed: 8, bloodAlpha: 0.25,
});
ctx.textAlign = 'center';
ctx.fillStyle = '#7f6768'; ctx.font = 'bold 9px monospace';
ctx.fillText('RUN STATE // SUSPENDED', W / 2, 158);
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 36px monospace';
ctx.fillText('PAUSED', W / 2, 202);
drawBloodTrim(ctx, W / 2 - 130, 213, 260, 8, 0.45);
ctx.fillStyle = '#b89532'; ctx.font = 'bold 10px monospace';
ctx.fillText('JUKEBOX // ACTIVE TRACK', W / 2, 270);
const sel = (G.musicSel === undefined || G.musicSel < 0) ? 'AUTO · FLOOR ROTATION' : Music.pretty(Music.PLAYLIST[G.musicSel]).toUpperCase();
drawPixelPanel(ctx, W / 2 - 170, H / 2 + 20, 340, 82, {
cut: 5, shadow: false, accent: '#468e9d', fill: 'rgba(5,10,12,0.7)', border: '#315b65',
});
ctx.fillStyle = '#8faeb2'; ctx.font = 'bold 9px monospace';
ctx.fillText('NOW PLAYING', W / 2, H / 2 + 43);
ctx.fillStyle = '#e9ded0'; ctx.font = 'bold 14px monospace';
ctx.fillText(sel, W / 2, H / 2 + 70);
for (const sx of [-200, 200]) {
const x = W / 2 + sx, y = H / 2 + 60;
const hover = dist(Input.mx, Input.my, x, y) < 22;
drawPixelPanel(ctx, x - 20, y - 20, 40, 40, {
cut: 5, shadow: false, fill: hover ? '#631526' : '#251017',
border: hover ? '#ef3150' : '#65303b', accent: hover ? '#ef3150' : '#773142',
});
ctx.fillStyle = '#eee0d3'; ctx.font = 'bold 18px monospace';
ctx.fillText(sx < 0 ? '<' : '>', x, y + 6);
}
ctx.textAlign = 'left'; ctx.font = 'bold 9px monospace'; ctx.fillStyle = '#a99a91';
ctx.fillText('SFX [- / +]', W / 2 - 170, 435);
drawPixelBar(ctx, W / 2 - 55, 426, 225, 10, G.sfxVol, {
fill: '#c92a40', glint: '#ff7d82', border: '#63303a', segments: 10,
});
ctx.fillText('MUSIC [, / .]', W / 2 - 170, 445);
drawPixelBar(ctx, W / 2 - 55, 436, 225, 10, G.musicVol, {
fill: '#468e9d', glint: '#a5edee', border: '#315b65', segments: 10,
});
drawPixelTag(ctx, '[P] RESUME', W / 2 - 250, 462, { width: 112, height: 28, color: '#f0e5d7', accent: '#b5243a' });
drawPixelTag(ctx, '[R] SWAP', W / 2 - 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), W / 2, 462, { width: 112, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' });
drawPixelTag(ctx, '[M] MUTE', W / 2 + 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' });
ctx.fillStyle = '#69585a'; ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText('ARROW KEYS / CLICK TO CHANGE TRACK · LIVE BOSS FIGHTS OVERRIDE SELECTION', W / 2, 503);
}
function drawGameOver(ctx) {
ctx.fillStyle = 'rgba(4,1,3,0.86)'; ctx.fillRect(0, 0, W, H);
drawPixelPanel(ctx, W / 2 - 230, 142, 460, 350, {
accent: '#e02945', border: '#772638', fill: 'rgba(12,4,7,0.97)',
blood: true, seed: G.kills + 3, bloodAlpha: 0.48,
});
ctx.textAlign = 'center';
ctx.fillStyle = '#8f6870'; ctx.font = 'bold 9px monospace';
ctx.fillText('RUN TERMINATED // CARCASS RECOVERED', W / 2, 174);
ctx.fillStyle = '#ee2947'; ctx.font = 'bold 48px monospace';
ctx.fillText('BUTCHERED', W / 2, 231);
drawBloodTrim(ctx, W / 2 - 170, 242, 340, G.kills + 3, 0.7);
drawPixelPanel(ctx, W / 2 - 188, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' });
drawPixelPanel(ctx, W / 2 - 58, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' });
drawPixelPanel(ctx, W / 2 + 72, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' });
const stats = [['FLOOR', G.floor], ['KILLS', G.kills], ['SCORE', G.score]];
for (let i = 0; i < stats.length; i++) {
const x = W / 2 - 130 + i * 130;
ctx.fillStyle = '#7e6a66'; ctx.font = 'bold 9px monospace'; ctx.fillText(stats[i][0], x, 300);
ctx.fillStyle = i === 2 ? '#e2b742' : '#eee0d3'; ctx.font = 'bold 18px monospace';
ctx.fillText(String(stats[i][1]).padStart(i === 2 ? 6 : 2, '0'), x, 329);
}
if (G.score >= (G.best || 0) && G.score > 0) {
drawPixelTag(ctx, 'NEW BEST CUT', W / 2 - 68, 367, { width: 136, height: 26, color: '#f1cb53', accent: '#b38b2e' });
}
drawPixelPanel(ctx, W / 2 - 140, 414, 280, 50, {
cut: 5, shadow: false, accent: '#cf2942', border: '#8c293c', fill: '#1a080e',
});
ctx.fillStyle = '#f1e4d5'; ctx.font = 'bold 12px monospace';
ctx.fillText('[ R / ENTER ] CUT AGAIN', W / 2, 444);
}
window.addEventListener('load', init);
+252
View File
@@ -0,0 +1,252 @@
// ---- particles: blood, gibs, flame, smoke, shockwaves, sparks, text, beams ----
const MAX_PARTICLES = 850;
// Cosmetic bursts should never be able to starve the game loop. Important
// particles (damage text and beams) make room by retiring the oldest effect.
function addParticle(p, important) {
if (G.parts.length >= MAX_PARTICLES) {
if (!important) return false;
G.parts.splice(0, Math.min(24, G.parts.length - MAX_PARTICLES + 1));
}
G.parts.push(p);
return true;
}
function spawnBlood(x, y, ang, n, big) {
for (let i = 0; i < n; i++) {
const a = ang + rand(-0.9, 0.9);
const sp = rand(40, big ? 260 : 160);
addParticle({
type: 'blood', x, y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
life: rand(0.3, 0.7), t: 0,
r: rand(1.5, big ? 4.5 : 3),
mist: i % 4 === 0, shade: i % 3,
});
}
// persistent floor decal
if (G.cur && chance(0.55)) {
G.cur.decals.push({
x: x + rand(-14, 14), y: y + rand(-14, 14),
img: 'decal_blood' + irand(1, 4),
s: rand(0.5, big ? 1.6 : 1.0), rot: rand(0, TAU),
});
if (G.cur.decals.length > 220) G.cur.decals.shift();
}
}
// Footstep-time query only: newest decals first, with their rendered scale.
function onGore(x, y) {
const room = G.cur;
if (!room || !room.decals) return false;
for (let i = room.decals.length - 1; i >= 0; i--) {
const decal = room.decals[i], radius = 24 * decal.s;
if (dist2(x, y, decal.x, decal.y) < radius * radius) return true;
}
return false;
}
function spawnGibs(x, y, n, big) {
for (let i = 0; i < n; i++) {
const a = rand(0, TAU), sp = rand(60, big ? 290 : 220);
const bone = chance(big ? 0.3 : 0.18);
const r = rand(2, big ? 7 : 5);
addParticle({
type: 'gib', x, y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
life: rand(0.5, big ? 1.35 : 1.1), t: 0, r,
w: r * rand(1.3, 2.6), h: r * rand(0.65, 1.25),
bone, shade: i % 3, rot: rand(0, TAU), vr: rand(-10, 10),
});
}
}
function spawnSpark(x, y, ang) {
for (let i = 0; i < 4; i++) {
const a = ang + Math.PI + rand(-0.7, 0.7);
const sp = rand(80, 200);
addParticle({ type: 'spark', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.1, 0.25), t: 0, r: 1.5 });
}
}
function spawnText(x, y, str, color) {
addParticle({ type: 'text', x: x + rand(-6, 6), y: y - 10, vx: 0, vy: -45, life: 0.7, t: 0, str, color: color || '#f7ead8' }, true);
}
function spawnBeam(x1, y1, ang, len) {
addParticle({ type: 'beam', x: x1, y: y1, ang, len, life: 0.22, t: 0 }, true);
for (let i = 0; i < 14; i++) {
const d = rand(20, len);
addParticle({
type: 'spark', x: x1 + Math.cos(ang) * d, y: y1 + Math.sin(ang) * d,
vx: rand(-120, 120), vy: rand(-120, 120), life: rand(0.15, 0.35), t: 0, r: 2,
});
}
}
function spawnSmoke(x, y, ang, n, color) {
for (let i = 0; i < n; i++) {
const a = ang + rand(-0.8, 0.8), sp = rand(14, 55);
addParticle({
type: 'smoke', x: x + rand(-3, 3), y: y + rand(-3, 3),
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - rand(5, 24),
life: rand(0.28, 0.65), t: 0, r: rand(3, 7), color: color || '#21161a',
});
}
}
function spawnShockwave(x, y, radius, color, alpha) {
addParticle({
type: 'shockwave', x, y, vx: 0, vy: 0, life: 0.42, t: 0,
r: 5, maxR: radius, color: color || '#ffb06a', alpha: alpha === undefined ? 0.8 : alpha,
});
}
function spawnMuzzleFx(x, y, ang, behavior) {
if (behavior === 'slam') return;
const back = ang + Math.PI;
if (behavior === 'flame') {
for (let i = 0; i < 3; i++) {
const a = ang + rand(-0.22, 0.22), sp = rand(70, 155);
addParticle({ type: 'flame', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.14, 0.3), t: 0, r: rand(3, 6) });
}
spawnSmoke(x, y, back, 1);
} else if (behavior === 'cone') {
for (let i = 0; i < 3; i++) {
const a = ang + rand(-0.3, 0.3), sp = rand(55, 130);
addParticle({ type: 'mist', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.18, 0.34), t: 0, r: rand(2, 5), color: '#82c83d' });
}
} else if (behavior === 'beam') {
spawnShockwave(x, y, 34, '#77e7ff', 0.75);
spawnSpark(x, y, ang);
} else {
const count = behavior === 'spread' ? 4 : 2;
for (let i = 0; i < count; i++) {
const a = back + rand(-0.55, 0.55), sp = rand(45, 120);
addParticle({ type: 'spark', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.08, 0.18), t: 0, r: rand(0.8, 1.6) });
}
if (behavior === 'lob' || behavior === 'lob_trap' || behavior === 'lob_swarm') spawnSmoke(x, y, back, 2);
}
}
function spawnExplosionFx(x, y, r) {
const flames = Math.min(28, 14 + Math.floor(r / 10));
for (let i = 0; i < flames; i++) {
const a = rand(0, TAU), sp = rand(60, 300);
addParticle({ type: 'flame', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.22, 0.58), t: 0, r: rand(3, 8) });
}
spawnShockwave(x, y, r, '#ff9a55', 0.9);
spawnShockwave(x, y, r * 0.66, '#ffd49a', 0.6);
spawnSmoke(x, y, -Math.PI / 2, Math.min(8, 3 + Math.floor(r / 25)));
spawnGibs(x, y, 5, r >= 80);
addShake(6);
}
function spawnCorpse(e) {
G.corpses.push({
sprite: e.sprite, x: e.x, y: e.y,
facing: G.player ? angleTo(e.x, e.y, G.player.x, G.player.y) : 0,
size: e.drawSize || (e.boss ? 128 : 64), t: 0, life: e.boss ? 1.15 : 0.75,
});
if (G.corpses.length > 48) G.corpses.shift();
}
function updateParticles(dt) {
for (let i = G.parts.length - 1; i >= 0; i--) {
const p = G.parts[i];
p.t += dt;
if (p.t >= p.life) { G.parts.splice(i, 1); continue; }
p.x += (p.vx || 0) * dt; p.y += (p.vy || 0) * dt;
if (p.type === 'blood' || p.type === 'gib') {
const drag = Math.pow(0.88, dt * 60);
p.vx *= drag; p.vy *= drag;
} else if (p.type === 'smoke' || p.type === 'mist') {
const drag = Math.pow(0.94, dt * 60);
p.vx *= drag; p.vy *= drag;
}
if (p.rot !== undefined) p.rot += p.vr * dt;
}
for (let i = G.corpses.length - 1; i >= 0; i--) {
const c = G.corpses[i];
c.t += dt;
if (c.t >= c.life) G.corpses.splice(i, 1);
}
if (G.player && G.player.hp <= 0) G.player.deathT = (G.player.deathT || 0) + dt;
}
function drawCorpses(ctx) {
for (const c of G.corpses) {
const fade = clamp((c.life - c.t) * 4, 0, 1);
Sprites.shadow(ctx, c.x, c.y + c.size * 0.2, c.size * 0.3, c.size * 0.1, 0.2 * fade);
Sprites.actor(ctx, c.sprite, c.x, c.y, c.facing, 'death', c.t, c.size, fade);
}
}
function drawParticles(ctx) {
for (const p of G.parts) {
const k = 1 - p.t / p.life;
if (p.type === 'blood') {
const speed = Math.hypot(p.vx || 0, p.vy || 0);
ctx.globalAlpha = k * (p.mist ? 0.55 : 0.92);
ctx.fillStyle = p.shade === 0 ? '#d02032' : (p.shade === 1 ? '#8f0d1c' : '#5d0913');
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(Math.atan2(p.vy || 0, p.vx || 1));
ctx.beginPath();
ctx.ellipse(0, 0, p.r * k + Math.min(4, speed * 0.018), p.r * k * (p.mist ? 0.45 : 0.7) + 0.4, 0, 0, TAU);
ctx.fill(); ctx.restore();
} else if (p.type === 'gib') {
ctx.globalAlpha = k;
ctx.strokeStyle = p.bone ? '#5b4938' : '#3c0710'; ctx.lineWidth = 1;
ctx.fillStyle = p.bone ? '#d2bd98' : (p.shade === 0 ? '#9f1524' : (p.shade === 1 ? '#68101a' : '#3e0a0e'));
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.strokeRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
} else if (p.type === 'spark') {
ctx.globalAlpha = k;
ctx.strokeStyle = '#ffe69a';
ctx.lineWidth = Math.max(1, p.r * k);
ctx.beginPath(); ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 0.025, p.y - p.vy * 0.025); ctx.stroke();
} else if (p.type === 'flame') {
const radius = p.r * k + 1;
ctx.globalAlpha = k * 0.34;
ctx.fillStyle = '#7b1016'; ctx.beginPath(); ctx.arc(p.x, p.y, radius * 1.75, 0, TAU); ctx.fill();
ctx.globalAlpha = k * 0.9;
ctx.fillStyle = k > 0.55 ? '#ff8c22' : '#d3261f'; ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, TAU); ctx.fill();
ctx.fillStyle = '#ffe7a2'; ctx.globalAlpha = k * 0.75;
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(0.8, radius * 0.38), 0, TAU); ctx.fill();
} else if (p.type === 'smoke') {
ctx.globalAlpha = k * 0.36;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1.45 - k * 0.45), 0, TAU); ctx.fill();
} else if (p.type === 'mist') {
ctx.globalAlpha = k * 0.52;
ctx.fillStyle = p.color || '#82c83d';
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (0.7 + (1 - k) * 0.8), 0, TAU); ctx.fill();
} else if (p.type === 'shockwave') {
const progress = 1 - k;
const radius = lerp(p.r, p.maxR, progress);
ctx.globalAlpha = k * p.alpha;
ctx.strokeStyle = p.color; ctx.lineWidth = 1.5 + k * 5;
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke();
ctx.globalAlpha = k * 0.18;
ctx.fillStyle = p.color; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, 0, 0, TAU); ctx.fill();
} else if (p.type === 'beam') {
ctx.globalAlpha = k;
ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3;
ctx.beginPath(); ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x + Math.cos(p.ang) * p.len, p.y + Math.sin(p.ang) * p.len); ctx.stroke();
ctx.strokeStyle = '#4bd7ff'; ctx.lineWidth = 4;
ctx.stroke();
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5;
ctx.stroke();
} else if (p.type === 'text') {
ctx.globalAlpha = Math.min(1, k * 2);
ctx.fillStyle = p.color;
ctx.font = 'bold 12px monospace'; ctx.textAlign = 'center';
ctx.fillText(p.str, p.x, p.y);
}
}
ctx.globalAlpha = 1;
}
+181
View File
@@ -0,0 +1,181 @@
// ---- Crimsonland-style level-up perk draft ----
const PERKS = [
{ id: 'adrenal', name: 'Adrenal Surge', desc: '+3.5% fire rate', apply: s => { s.rateMul *= 1.035; } },
{ id: 'sharpen', name: 'Sharpened', desc: '+4% damage', apply: s => { s.dmgMul *= 1.04; } },
{ id: 'quick', name: 'Quickening', desc: '+3% move speed', apply: s => { s.speedMul *= 1.03; } },
{ id: 'longbone', name: 'Long Bone', desc: '+5% range', apply: s => { s.rangeMul *= 1.05; } },
{ id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } },
{ id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } },
{ id: 'shieldheart', name: 'Shield Heart', desc: '+½ shield heart at the start of each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } },
{ id: 'scavenge', name: 'Scavenger', desc: '+4% drop luck', apply: s => { s.luck += 0.04; } },
{ id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } },
{ id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } },
{ id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } },
{ id: 'critmeat', name: 'Cold Precision', desc: '+15% critical damage', apply: s => { s.critMul += 0.15; } },
{ id: 'flensing', name: 'Flensing Edge', desc: 'Hits inflict light bleeding', apply: s => { s.bleed += 0.08; } },
{ id: 'ember', name: 'Ember Hands', desc: '8% chance to ignite', apply: s => { s.igniteChance += 0.08; } },
{ id: 'frostbile',name: 'Chill Bile', desc: '10% chance to slow', apply: s => { s.slowOnHit += 0.10; } },
{ id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } },
{ id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 1 / 24; } },
{ id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } },
{ id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo consumed', apply: s => { s.ammoEff *= 1.05; } },
{ id: 'boneknit', name: 'Bone Knit', desc: 'Heal ½ heart after each combat room', apply: s => { s.roomHeal += 1; } },
{ id: 'spiteflesh',name: 'Spite Flesh', desc: 'Contact attackers take damage', apply: s => { s.thorns += 4; } },
{ id: 'carrion', name: 'Carrion Sense', desc: '+8% pickup radius and pull speed', apply: s => { s.magnet *= 1.08; s.magnetPull *= 1.08; } },
{ id: 'sinew', name: 'Sinew Weave', desc: '+3% move speed and range', apply: s => { s.speedMul *= 1.03; s.rangeMul *= 1.03; } },
];
const PERK_CARD_W = 170, PERK_CARD_H = 230, PERK_CARD_GAP = 18;
const PERK_CARD_Y = H / 2 - 100;
function perkCardX(i) {
const total = PERK_CARD_W * 3 + PERK_CARD_GAP * 2;
return (W - total) / 2 + i * (PERK_CARD_W + PERK_CARD_GAP);
}
function xpForLevel(level) { return 8 + level * 4; }
function gainXP(v) {
const p = G.player;
p.xp += v;
addScore(Math.round(v));
while (p.xp >= xpForLevel(p.level)) {
p.xp -= xpForLevel(p.level);
p.level++;
p.stats.rerolls += p.stats.rerollPerLevel || 0;
G.pendingLevelups++;
}
}
function openPerkDraft() {
if (G.autoPerk) {
Sfx.levelup();
let guard = 0;
while (G.pendingLevelups > 0 && guard++ < 64) {
grantPerk(choice(PERKS));
G.pendingLevelups--;
}
G.perkChoices = null;
G.mode = 'play';
return;
}
const opts = shuffle([...PERKS]).slice(0, 3);
G.perkChoices = opts;
G.mode = 'levelup';
Sfx.levelup();
}
function grantPerk(perk) {
perk.apply(G.player.stats, G.player);
addToast(perk.name, perk.desc);
Sfx.perk();
}
function choosePerk(i) {
if (!G.perkChoices || i >= G.perkChoices.length) return;
const perk = G.perkChoices[i];
grantPerk(perk);
G.perkChoices = null;
G.pendingLevelups--;
if (G.pendingLevelups > 0) openPerkDraft();
else G.mode = 'play';
}
function updateLevelup() {
if (keyPressed('1')) choosePerk(0);
else if (keyPressed('2')) choosePerk(1);
else if (keyPressed('3')) choosePerk(2);
else if (keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1));
else if (keyPressed('t')) {
setAutoPerk(true);
G.mode = 'play';
openPerkDraft();
}
else if (keyPressed('r') && G.player.stats.rerolls > 0) {
G.player.stats.rerolls--;
G.perkChoices = shuffle([...PERKS]).slice(0, 3);
Sfx.menu();
}
else if (Input.mpressed && G.perkChoices) {
// click a card
for (let i = 0; i < 3; i++) {
const cx = perkCardX(i);
if (Input.mx > cx && Input.mx < cx + PERK_CARD_W && Input.my > PERK_CARD_Y && Input.my < PERK_CARD_Y + PERK_CARD_H) {
choosePerk(i); break;
}
}
if (Input.my > PERK_CARD_Y + PERK_CARD_H + 10 && Input.my < PERK_CARD_Y + PERK_CARD_H + 42 && Input.mx > W / 2 - 115 && Input.mx < W / 2 + 115) {
choosePerk(irand(0, G.perkChoices.length - 1));
}
}
}
function drawLevelup(ctx) {
ctx.fillStyle = 'rgba(7,2,4,0.91)';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.globalAlpha = 0.12;
Sprites.draw(ctx, 'decal_blood2', 122, 96, -0.35, 180);
Sprites.draw(ctx, 'decal_blood3', W - 108, H - 76, 0.5, 190);
ctx.restore();
drawPixelTag(ctx, 'MUTATION DRAFT // LEVEL ' + String(G.player.level).padStart(2, '0'), W / 2 - 108, H / 2 - 184, {
width: 216, height: 24, color: '#cfaca5', accent: '#bf263d',
});
ctx.fillStyle = '#ef2946'; ctx.font = 'bold 30px monospace'; ctx.textAlign = 'center';
ctx.fillText('CHOOSE A CUT', W / 2, H / 2 - 137);
drawBloodTrim(ctx, W / 2 - 145, H / 2 - 126, 290, G.player.level, 0.62);
ctx.fillStyle = '#8d7773'; ctx.font = 'bold 10px monospace';
ctx.fillText('SELECT ONE PERMANENT RUN MODIFIER', W / 2, H / 2 - 101);
for (let i = 0; i < 3; i++) {
const perk = G.perkChoices[i];
const cx = perkCardX(i);
const hover = Input.mx > cx && Input.mx < cx + PERK_CARD_W && Input.my > PERK_CARD_Y && Input.my < PERK_CARD_Y + PERK_CARD_H;
if (hover) {
ctx.save(); ctx.globalAlpha = 0.16; ctx.fillStyle = '#ef3150';
pixelPanelPath(ctx, cx - 4, PERK_CARD_Y - 4, PERK_CARD_W + 8, PERK_CARD_H + 8, 10); ctx.fill();
ctx.restore();
}
drawPixelPanel(ctx, cx, PERK_CARD_Y, PERK_CARD_W, PERK_CARD_H, {
cut: 8, lineWidth: hover ? 2 : 1, fill: hover ? '#321019' : '#16090d',
border: hover ? '#ef3150' : '#63303a', accent: hover ? '#ef3150' : '#992238',
blood: true, seed: i + G.player.level, bloodAlpha: hover ? 0.55 : 0.28,
});
drawPixelTag(ctx, '0' + (i + 1), cx + 10, PERK_CARD_Y + 10, {
width: 34, height: 24, color: '#e8bf47', accent: '#9d7726', fill: '#130b0d',
});
drawPixelPanel(ctx, cx + 47, PERK_CARD_Y + 16, 76, 76, {
cut: 5, shadow: false, fill: 'rgba(3,2,3,0.72)',
border: hover ? '#c93a4e' : '#49252d', accent: hover ? '#dc3049' : '#75303e',
});
Sprites.draw(ctx, 'perk_' + perk.id, cx + PERK_CARD_W / 2, PERK_CARD_Y + 54, 0, 68);
ctx.textAlign = 'center';
ctx.fillStyle = '#eee2d5'; ctx.font = 'bold 13px monospace';
wrapText(ctx, perk.name.toUpperCase(), cx + PERK_CARD_W / 2, PERK_CARD_Y + 119, 150, 16);
ctx.fillStyle = '#a9928b'; ctx.font = '10px monospace';
wrapText(ctx, perk.desc.toUpperCase(), cx + PERK_CARD_W / 2, PERK_CARD_Y + 169, 146, 14);
ctx.fillStyle = hover ? '#d7aa39' : '#5f4d4d'; ctx.font = 'bold 8px monospace';
ctx.fillText(hover ? 'CLICK TO INSTALL' : 'PERMANENT UPGRADE', cx + PERK_CARD_W / 2, PERK_CARD_Y + 215);
}
drawPixelTag(ctx, '[4 / SPACE] RANDOM CUT', W / 2 - 115, PERK_CARD_Y + PERK_CARD_H + 10, {
width: 230, height: 30, color: '#e7c65c', accent: '#a47e25',
});
drawPixelTag(ctx, '[T] AUTO-DRAFT: ' + (G.autoPerk ? 'ON' : 'OFF'), W - 202, 24, {
width: 178, height: 26, color: G.autoPerk ? '#79d9ca' : '#a9958e', accent: G.autoPerk ? '#378b80' : '#67303a',
});
if (G.player.stats.rerolls > 0) drawPixelTag(ctx, '[R] REROLL ×' + G.player.stats.rerolls, 24, 24, {
width: 138, height: 26, color: '#d8b84e', accent: '#947124',
});
}
function wrapText(ctx, text, x, y, maxW, lh) {
const words = text.split(' ');
let line = '', yy = y;
for (const w of words) {
const test = line ? line + ' ' + w : w;
if (ctx.measureText(test).width > maxW && line) {
ctx.fillText(line, x, yy); yy += lh; line = w;
} else line = test;
}
ctx.fillText(line, x, yy);
}
+194
View File
@@ -0,0 +1,194 @@
// ---- pickups: XP gems, hearts, ammo, weapon drops, pedestals, stairs ----
function spawnPickup(type, x, y, data) {
const p = Object.assign({
type, x, y, t: rand(0, TAU),
vx: rand(-40, 40), vy: rand(-40, 40),
magnet: false,
}, data || {});
G.pickups.push(p);
return p;
}
function spawnGems(x, y, value) {
while (value >= 5) { spawnPickup('gem', x + rand(-10, 10), y + rand(-10, 10), { v: 5 }); value -= 5; }
for (let i = 0; i < value; i++) spawnPickup('gem', x + rand(-10, 10), y + rand(-10, 10), { v: 1 });
}
// 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);
}
// room-clear reward burst (weapons come from bosses / item rooms, not room clears)
function spawnRoomReward(x, y, floor) {
spawnGems(x, y, irand(3, 5 + floor));
const luck = G.player.stats.luck;
if (chance(0.24 + luck * 0.3)) spawnPickup('ammo', x + rand(-30, 30), y + rand(-30, 30));
if (chance(0.10 + luck * 0.3)) spawnPickup('heart', x + rand(-30, 30), y + rand(-30, 30));
}
function updatePickups(dt) {
const p = G.player;
const magR = 46 * p.stats.magnet;
for (let i = G.pickups.length - 1; i >= 0; i--) {
const k = G.pickups[i];
if (!k || k.dead) { G.pickups.splice(i, 1); continue; }
if (k.delay) k.delay -= dt;
k.t += dt;
k.x += k.vx * dt; k.y += k.vy * dt;
k.vx *= 0.9; k.vy *= 0.9;
const d = dist(k.x, k.y, p.x, p.y);
// gems & small pickups fly to the player inside magnet radius
if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) {
const a = angleTo(k.x, k.y, p.x, p.y);
const sp = lerp(320, 60, d / magR);
k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt;
}
if (d < p.r + 12 && !(k.delay > 0)) {
collectPickup(k);
G.pickups.splice(i, 1);
}
}
}
function collectPickup(k) {
const p = G.player;
switch (k.type) {
case 'gem':
gainXP(k.v * p.stats.xpMul);
Sfx.gem();
break;
case 'heart':
if (p.hp < p.stats.maxHp) { healPlayer(2); spawnText(p.x, p.y, '+2', '#d92038'); }
else addScore(25);
Sfx.heart();
break;
case 'ammo': {
// Generic rounds refill every finite-ammo weapon the butcher is carrying,
// including a special weapon currently holstered behind the Bone Popper.
const carried = [];
if (p.weapon && p.weapon.id !== 'bonepopper') carried.push(p.weapon);
if (p.holstered && p.holstered.id !== 'bonepopper' && !carried.includes(p.holstered)) carried.push(p.holstered);
let total = 0, wasted = 0;
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 add = Math.max(0, Math.min(wanted, cap - inst.ammo));
inst.ammo += add;
total += add;
wasted += wanted - add;
}
if (wasted > 0) addScore(Math.round(wasted * 2));
if (total > 0) spawnText(p.x, p.y, '+' + total + ' CARRIED AMMO', '#d0b060');
else if (!carried.length) addScore(15);
Sfx.ammo();
break;
}
case 'weapon': {
const w = WEAPONS[k.wid];
// drop the old special with its remaining ammo so it's never wasted
const old = p.weapon.id !== 'bonepopper' ? p.weapon : p.holstered;
if (old) {
const a = rand(0, TAU);
spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34,
{ wid: old.id, ammo: old.ammo, delay: 0.6 });
}
p.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo };
p.holstered = null;
p.fireT = 0; p.charge = 0;
addToast(w.name, w.desc);
spawnText(p.x, p.y, w.name.toUpperCase(), '#ffd060');
Sfx.weapon();
break;
}
case 'item': {
giveItem(k.iid);
for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base
break;
}
case 'stairs':
nextFloor();
break;
}
}
// readable nameplate shown when the player walks up to a pickup
function drawPickupLabel(ctx, text, x, y, color) {
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
const w = Math.ceil(ctx.measureText(text).width) + 18;
drawPixelPanel(ctx, x - w / 2, y - 10, w, 20, {
cut: 3, shadow: false, fill: 'rgba(7,3,5,0.9)', border: 'rgba(117,59,68,0.9)', accent: color,
});
ctx.fillStyle = color;
ctx.fillText(text, x, y + 4);
ctx.restore();
}
function drawPickups(ctx) {
const p = G.player;
for (const k of G.pickups) {
const bob = Math.sin(k.t * 4) * 2.5;
if (k.type !== 'itemspot' && k.type !== 'stairs') Sprites.shadow(ctx, k.x, k.y + 9, 11, 4, 0.3);
if (k.type === 'gem') {
const big = k.v >= 5;
ctx.save();
ctx.globalAlpha = 0.12 + Math.sin(k.t * 6) * 0.04;
ctx.fillStyle = big ? '#55f5dc' : '#55aaff';
ctx.beginPath(); ctx.arc(k.x, k.y + bob, big ? 20 : 14, 0, TAU); ctx.fill();
ctx.restore();
Sprites.draw(ctx, big ? 'gem_big' : 'gem_small', k.x, k.y + bob, k.t * 0.8, big ? 32 : 24, false, 1, 0.85 + Math.abs(Math.sin(k.t * 2)) * 0.15, 1);
}
else if (k.type === 'heart') Sprites.draw(ctx, 'heart', k.x, k.y + bob, Math.sin(k.t * 2) * 0.08, 32, false, 1, 1 + Math.sin(k.t * 5) * 0.05, 1 + Math.sin(k.t * 5) * 0.05);
else if (k.type === 'ammo') Sprites.draw(ctx, 'ammo', k.x, k.y + bob, Math.sin(k.t * 1.5) * 0.04, 32);
else if (k.type === 'weapon') {
ctx.save();
ctx.globalAlpha = 0.24 + Math.sin(k.t * 5) * 0.08;
ctx.strokeStyle = '#ffd76a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(k.x, k.y, 24 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke();
ctx.restore();
Sprites.draw(ctx, 'w_' + k.wid, k.x, k.y + bob, 0, 64);
} else if (k.type === 'itemspot') {
Sprites.draw(ctx, 'pedestal', k.x, k.y, 0, 64);
} else if (k.type === 'item') {
ctx.save();
ctx.globalAlpha = 0.2 + Math.sin(k.t * 3) * 0.07;
ctx.fillStyle = '#ffe075';
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.35;
ctx.strokeStyle = '#ffe9a6'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(k.x, k.y - 28, 25 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke();
ctx.restore();
Sprites.draw(ctx, 'i_' + k.iid, k.x, k.y - 28 + bob, 0, 64);
} else if (k.type === 'stairs') {
Sprites.draw(ctx, 'stairs', k.x, k.y, 0, 64);
ctx.save();
ctx.globalAlpha = 0.5 + Math.sin(k.t * 3) * 0.25;
ctx.strokeStyle = '#d8b84e'; ctx.lineWidth = 2;
const inset = 3 + Math.sin(k.t * 3) * 2;
ctx.strokeRect(k.x - 31 + inset, k.y - 31 + inset, 62 - inset * 2, 62 - inset * 2);
ctx.restore();
}
// hover nameplates when close
if (dist2(k.x, k.y, p.x, p.y) < 90 * 90) {
if (k.type === 'weapon') drawPickupLabel(ctx, WEAPONS[k.wid].name, k.x, k.y - 44, '#ffd060');
else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, '#c9a227');
else if (k.type === 'stairs') drawPickupLabel(ctx, 'STAIRS DOWN', k.x, k.y - 52, '#e8e0d0');
else if (k.type === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060');
}
}
}
+304
View File
@@ -0,0 +1,304 @@
// ---- 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 });
}
}
}
+369
View File
@@ -0,0 +1,369 @@
// ---- floor generation, room state, doors, hazards ----
const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] };
const OPP = { n: 's', s: 'n', e: 'w', w: 'e' };
function genFloor(floor) {
G.rooms = {};
const target = Math.min(5 + floor, 9);
G.rooms['0,0'] = makeRoom(0, 0, 'start');
let cx = 0, cy = 0, guard = 0;
while (Object.keys(G.rooms).length < target && guard++ < 500) {
const [dx, dy] = choice(Object.values(DIRS));
const nx = cx + dx, ny = cy + dy;
if (!G.rooms[nx + ',' + ny]) {
G.rooms[nx + ',' + ny] = makeRoom(nx, ny, 'combat');
if (chance(0.5)) { cx = nx; cy = ny; }
} else { cx = nx; cy = ny; }
}
// assign boss (farthest) and item (second farthest, dist >= 2)
const list = Object.values(G.rooms).sort((a, b) =>
(Math.abs(b.gx) + Math.abs(b.gy)) - (Math.abs(a.gx) + Math.abs(a.gy)));
list[0].type = 'boss';
for (const r of list) {
const d = Math.abs(r.gx) + Math.abs(r.gy);
if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; }
}
// wire doors
for (const r of Object.values(G.rooms)) {
for (const [dir, [dx, dy]] of Object.entries(DIRS)) {
r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)];
}
}
}
function makeRoom(gx, gy, type) {
return { gx, gy, type, doors: {}, cleared: type === 'start', visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] };
}
function roomKey(x, y) { return x + ',' + y; }
function wavesFor(floor) {
const base = 3 + Math.ceil(floor * 1.4);
const waves = [base];
if (floor >= 2) waves.push(Math.ceil(base * 0.7));
if (floor >= 4) waves.push(Math.ceil(base * 0.5));
return waves;
}
function enterRoom(gx, gy) {
// stash the old room's pickups (stairs, pedestals, rewards, gems all persist)
if (G.cur) G.cur.pickups = G.pickups;
G.enemies = [];
G.ebullets = [];
G.bullets = [];
G.hazards = [];
G.telegraphs = [];
G.corpses = [];
G.boss = null;
G.roomLayer = null;
G.roomLayerKey = '';
G.roomDamaged = false;
G.roomEnterT = G.time;
const room = G.rooms[roomKey(gx, gy)];
G.cur = room;
G.pickups = room.pickups;
// spawn protection: brief invincibility walking into a fresh room
if (G.player) G.player.invT = Math.max(G.player.invT, 1.0);
if (!room.visited) {
room.visited = true;
if (room.type === 'combat') {
const w = wavesFor(G.floor);
room.wavesLeft = w.length;
room.pendingWave = w;
spawnWave(w[0], G.floor);
room.wavesLeft--;
room.spawnT = 0;
} else if (room.type === 'boss') {
const boss = spawnBoss(G.floor);
room.bossSpawned = true;
Music.playBoss(boss.bossKind);
} else if (room.type === 'item') {
spawnItemPedestal(W / 2 - 45, H / 2);
// item rooms sometimes also stock a weapon
if (chance(0.35)) {
const w = rollWeaponDrop(G.floor);
spawnPickup('weapon', W / 2 + 55, H / 2, { wid: w.id });
}
room.cleared = true;
}
} else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) {
// re-entering an uncleared room: restart waves
spawnWave(room.pendingWave[room.pendingWave.length - room.wavesLeft], G.floor);
room.wavesLeft--;
room.spawnT = 0;
}
}
function roomLocked() {
const r = G.cur;
if (!r) return false;
if (r.type !== 'combat' && r.type !== 'boss') return false;
return !r.cleared;
}
function updateRoom(dt) {
const r = G.cur;
const p = G.player;
// If the current pressure is clearly overwhelming the build, ease it down
// smoothly. Spawn-time application means living enemies never jump stats.
if (G.enemies.length > 0) {
const lowHealth = p.hp / Math.max(1, p.stats.maxHp) < 0.35;
const stalled = G.time - G.roomEnterT > 90;
if (lowHealth || stalled) {
const decay = lowHealth ? 0.02 : 0.008;
G.pressure = Math.max(PRESSURE_MIN, G.pressure - decay * dt);
}
}
G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12);
// wave progression / room clear
if (r.type === 'combat' && !r.cleared) {
if (G.enemies.length === 0) {
if (r.wavesLeft > 0) {
r.spawnT += dt;
if (r.spawnT > 0.9) {
r.spawnT = 0;
spawnWave(r.pendingWave[r.pendingWave.length - r.wavesLeft], G.floor);
r.wavesLeft--;
}
} else {
r.cleared = true;
recordRoomClear(r);
spawnRoomReward(W / 2, H / 2, G.floor);
Sfx.roomClear();
addScore(50);
}
}
} else if (r.type === 'boss' && r.bossSpawned && !r.cleared) {
if (!G.boss && G.enemies.length === 0) {
r.cleared = true;
recordRoomClear(r);
Sfx.roomClear();
}
}
// door transitions
if (!roomLocked()) {
let dir = null;
if (p.y < WALL + p.r + 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('w', 'arrowup')) dir = 'n';
else if (p.y > H - WALL - p.r - 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('s', 'arrowdown')) dir = 's';
else if (p.x > W - WALL - p.r - 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('d', 'arrowright')) dir = 'e';
else if (p.x < WALL + p.r + 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('a', 'arrowleft')) dir = 'w';
if (dir && r.doors[dir]) {
const [dx, dy] = DIRS[dir];
enterRoom(r.gx + dx, r.gy + dy);
// place player at opposite door
if (dir === 'n') { p.y = H - WALL - p.r - 8; }
if (dir === 's') { p.y = WALL + p.r + 8; }
if (dir === 'e') { p.x = WALL + p.r + 8; }
if (dir === 'w') { p.x = W - WALL - p.r - 8; }
G.transition = 0.3;
Sfx.door();
}
}
}
function recordRoomClear(room) {
if (!G.roomDamaged) {
G.streak++;
G.pressure = Math.min(PRESSURE_MAX, G.pressure + PRESSURE_GAIN);
} else {
G.streak = 0;
}
if (room.type === 'combat' && G.player.stats.roomHeal > 0 && G.player.hp < G.player.stats.maxHp) {
const before = G.player.hp;
healPlayer(G.player.stats.roomHeal);
if (G.player.hp > before) spawnText(G.player.x, G.player.y - 18, 'ROOM KNIT +' + (G.player.hp - before), '#d98991');
}
}
function nextFloor() {
G.floor++;
addScore(250);
Sfx.stairs();
genFloor(G.floor);
enterRoom(0, 0);
// Shield Heart perk: fresh shield hearts each floor
G.player.shieldHp = G.player.stats.shieldPerk || 0;
if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); }
G.player.x = W / 2; G.player.y = H / 2;
addToast('FLOOR ' + G.floor, 'the meat runs deeper here');
Music.requestFloorMusic();
G.transition = 0.5;
}
// ---- hazards (acid pools, fire, bear traps) ----
function updateHazards(dt) {
const p = G.player;
for (let i = G.hazards.length - 1; i >= 0; i--) {
const h = G.hazards[i];
h.t += dt;
if (h.t >= h.life) { G.hazards.splice(i, 1); continue; }
if (h.hostile) {
h.tick = (h.tick || 0) - dt;
if (h.tick <= 0 && dist2(h.x, h.y, p.x, p.y) < (h.r + p.r) * (h.r + p.r)) {
h.tick = h.tickRate || 0.4;
hurtPlayer(h.dmg || 1, angleTo(h.x, h.y, p.x, p.y));
}
}
if (h.kind === 'trap') {
for (const e of G.enemies) {
if (e.boss) continue;
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;
}
}
} else if (!h.hostile || h.dps > 0) { // weapon pools hurt enemies; zero-DPS boss pools do not
for (const e of G.enemies) {
if (e.hp <= 0) continue;
if (e === h.owner) continue;
if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r * 0.5) * (h.r + e.r * 0.5)) {
if (h.kind === 'acid') { e.slowT = 0.4; tickEnemyDamage(e, h.dps * dt); }
else { e.burnT = Math.max(e.burnT, 0.8); e.burnDps = h.dps; }
}
}
}
}
}
// ---- rendering ----
function hashTile(x, y) { return ((x * 73856093) ^ (y * 19349663)) >>> 0; }
const FLOOR_TILE_POOL = [
'tile_floor1', 'tile_floor1', 'tile_floor1',
'tile_floor2', 'tile_floor2', 'tile_floor2',
'tile_floor3', 'tile_floor3',
'tile_floor4', 'tile_floor6', 'tile_floor6', 'tile_floor6',
'tile_floor5', 'tile_floor7', 'tile_floor8', 'tile_floor1',
];
const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4'];
function drawFloorTiles(target, r) {
for (let ty = 0; ty < H / TILE; ty++) {
for (let tx = 0; tx < W / TILE; tx++) {
const h = hashTile(tx + r.gx * 31, ty + r.gy * 31);
const sprite = FLOOR_TILE_POOL[h % FLOOR_TILE_POOL.length];
Sprites.draw(target, sprite, tx * TILE + TILE / 2, ty * TILE + TILE / 2, 0, TILE);
}
}
}
function drawFloor(ctx, r) {
// A room's 600 floor tiles are static. Cache them into one bitmap in the
// browser; headless tests keep the simple direct path.
const canCache = typeof document !== 'undefined' && document.createElement && G.imagesLoaded;
if (!canCache) { drawFloorTiles(ctx, r); return; }
const key = r.gx + ',' + r.gy + ':' + G.floor;
if (!G.roomLayer || G.roomLayerKey !== key) {
const layer = document.createElement('canvas');
layer.width = W; layer.height = H;
const lctx = layer.getContext('2d');
lctx.imageSmoothingEnabled = false;
drawFloorTiles(lctx, r);
G.roomLayer = layer; G.roomLayerKey = key;
}
ctx.drawImage(G.roomLayer, 0, 0);
}
function drawRoom(ctx) {
const r = G.cur;
drawFloor(ctx, r);
// blood decals
for (const d of r.decals) {
Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55);
}
// hazards
for (const h of G.hazards) {
const k = 1 - h.t / h.life;
if (h.kind === 'trap') {
Sprites.draw(ctx, 'bullet_saw', h.x, h.y, h.t * 0.5, 28, false, clamp(k * 3, 0, 1));
} else {
ctx.globalAlpha = clamp(k * 2, 0, 0.55);
const pulse = h.r * (0.82 + Math.sin(h.t * 5) * 0.08);
ctx.fillStyle = h.hostile ? (h.kind === 'acid' ? '#a7c92b' : '#ff642b') : (h.kind === 'acid' ? '#4f9e25' : '#d65b18');
ctx.beginPath(); ctx.arc(h.x, h.y, pulse, 0, TAU); ctx.fill();
ctx.strokeStyle = h.hostile ? '#fff0a0' : (h.kind === 'acid' ? '#a5e34c' : '#ffb13b');
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(h.x, h.y, pulse * 0.72, 0, TAU); ctx.stroke();
ctx.globalAlpha = 1;
}
}
// walls
drawWalls(ctx, r);
}
function drawWalls(ctx, r) {
const locked = roomLocked();
// Draw 64px industrial panels into exact 48px collision bands. Door gaps
// are clipping regions, not skipped whole tiles, so they remain precisely
// 88px wide even though the art grid is larger.
function clipBand(dir) {
ctx.beginPath();
if (dir === 'n' || dir === 's') {
const y = dir === 'n' ? 0 : H - WALL;
if (r.doors[dir]) {
ctx.rect(0, y, W / 2 - DOOR_HALF, WALL);
ctx.rect(W / 2 + DOOR_HALF, y, W / 2 - DOOR_HALF, WALL);
} else ctx.rect(0, y, W, WALL);
} else {
const x = dir === 'w' ? 0 : W - WALL;
if (r.doors[dir]) {
ctx.rect(x, 0, WALL, H / 2 - DOOR_HALF);
ctx.rect(x, H / 2 + DOOR_HALF, WALL, H / 2 - DOOR_HALF);
} else ctx.rect(x, 0, WALL, H);
}
ctx.clip();
}
for (const dir of ['n', 's']) {
ctx.save(); clipBand(dir);
const y = dir === 'n' ? TILE / 2 : H - TILE / 2;
for (let x = 0; x < W; x += TILE) {
const h = hashTile(x / TILE + r.gx * 17, (dir === 'n' ? -1 : 11) + r.gy * 19);
Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x + TILE / 2, y, 0, TILE);
}
ctx.restore();
}
for (const dir of ['w', 'e']) {
ctx.save(); clipBand(dir);
const x = dir === 'w' ? TILE / 2 : W - TILE / 2;
for (let y = 0; y < H; y += TILE) {
const h = hashTile((dir === 'w' ? -1 : 16) + r.gx * 17, y / TILE + r.gy * 19);
Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x, y + TILE / 2, Math.PI / 2, TILE);
}
ctx.restore();
}
// dark inner edge
ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3;
ctx.strokeRect(WALL, WALL, W - WALL * 2, H - WALL * 2);
// doors
for (const [dir, has] of Object.entries(r.doors)) {
if (!has) continue;
const sprite = locked ? 'door_locked' : 'door_open';
let x, y, rot;
if (dir === 'n') { x = W / 2; y = WALL / 2; rot = 0; }
else if (dir === 's') { x = W / 2; y = H - WALL / 2; rot = 0; }
else if (dir === 'e') { x = W - WALL / 2; y = H / 2; rot = Math.PI / 2; }
else { x = WALL / 2; y = H / 2; rot = Math.PI / 2; }
Sprites.draw(ctx, sprite, x, y, rot, 96);
if (locked) {
ctx.save();
ctx.globalAlpha = 0.5 + Math.sin(G.time * 6) * 0.2;
ctx.fillStyle = '#8f1f2a';
if (dir === 'n' || dir === 's') ctx.fillRect(x - DOOR_HALF, y - 6, DOOR_HALF * 2, 12);
else ctx.fillRect(x - 6, y - DOOR_HALF, 12, DOOR_HALF * 2);
ctx.restore();
}
}
}
+387
View File
@@ -0,0 +1,387 @@
// ---- hybrid WebAudio SFX: generated samples with procedural fallbacks ----
const Sfx = {
ctx: null, master: null, comp: null, glue: null, limiter: null, muted: false,
mixBus: null, musicBus: null, musicMute: null, duckGain: null, saturators: {},
buses: {}, active: [], procedural: [], lastPlayed: new Map(), currentWeaponLoop: null,
footstepN: 0, fleshN: 0, wallN: 0, goreN: 0,
lastPunch: -999, lastPunchLevel: 0,
maxVoices: 24,
cooldowns: {
hit: 0.035, crit: 0.07, ricochet: 0.05, wallHit: 0.045, sawHit: 0.09, gem: 0.025,
enemyDie: 0.04, goreBurst: 0.055, footstep: 0.10, lifesteal: 0.12,
loop_wpnloop_bile: 0.12, loop_wpnloop_cauterizer: 0.12,
loop_wpnloop_redhand: 0.11, loop_wpnloop_spinaltap_charge: 0.12,
},
init() {
if (this.ctx) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = this.ctx.createGain();
this.master.gain.value = G.muted ? 0 : (G.sfxVol === undefined ? 0.45 : G.sfxVol);
this.glue = this.ctx.createDynamicsCompressor();
this.glue.threshold.value = -10; this.glue.knee.value = 6; this.glue.ratio.value = 3.5;
this.glue.attack.value = 0.012; this.glue.release.value = 0.14;
this.mixBus = this.ctx.createGain(); this.mixBus.gain.value = 1;
this.limiter = this.ctx.createDynamicsCompressor();
this.limiter.threshold.value = -0.5; this.limiter.knee.value = 0; this.limiter.ratio.value = 20;
this.limiter.attack.value = 0.001; this.limiter.release.value = 0.06;
this.comp = this.glue; // compatibility alias for older diagnostics
this.master.connect(this.glue); this.glue.connect(this.mixBus);
this.mixBus.connect(this.limiter); this.limiter.connect(this.ctx.destination);
const curve = new Float32Array(2048);
const drive = 2.4, norm = Math.tanh(drive);
for (let i = 0; i < curve.length; i++) {
const x = i * 2 / (curve.length - 1) - 1;
curve[i] = Math.tanh(x * drive) / norm;
}
this.buses = {}; this.saturators = {}; this.procedural = [];
for (const [name, level] of Object.entries({ weapons: 0.92, impacts: 0.88, voices: 0.9, ui: 0.72 })) {
const node = this.ctx.createGain(); node.gain.value = level; this.buses[name] = node;
if (name === 'weapons' || name === 'impacts') {
const shaper = this.ctx.createWaveShaper(); shaper.curve = curve; shaper.oversample = '4x';
node.connect(shaper); shaper.connect(this.master); this.saturators[name] = shaper;
} else node.connect(this.master);
}
const sub = this.ctx.createGain(); sub.gain.value = 0.9;
const subFilter = this.ctx.createBiquadFilter(); subFilter.type = 'lowpass'; subFilter.frequency.value = 140;
sub.connect(subFilter); subFilter.connect(this.master); this.buses.sub = sub;
this.musicBus = this.ctx.createGain(); this.musicBus.gain.value = 1;
this.musicMute = this.ctx.createGain(); this.musicMute.gain.value = G.muted ? 0 : 1;
this.duckGain = this.ctx.createGain(); this.duckGain.gain.value = 1;
this.musicBus.connect(this.musicMute); this.musicMute.connect(this.duckGain); this.duckGain.connect(this.mixBus);
if (typeof SfxBank !== 'undefined') { SfxBank.init(this.ctx); SfxBank.preload(); }
} catch (e) { this.ctx = null; }
},
attachMusic(audio) {
if (!audio || audio._sfxSource || audio._sfxAttachFailed || !this.ctx || !this.musicBus ||
this.ctx.state !== 'running' || !this.ctx.createMediaElementSource) return false;
try {
const source = this.ctx.createMediaElementSource(audio);
audio._sfxSource = source; // mark before connect: a media element can only be wrapped once
source.connect(this.musicBus);
return true;
} catch (e) {
audio._sfxAttachFailed = true;
if (typeof console !== 'undefined' && console.warn) console.warn('Music WebAudio attach failed', e);
return false;
}
},
detachMusic(audio) {
if (!audio || !audio._sfxSource) return;
try { audio._sfxSource.disconnect(); } catch (e) {}
// The owning Music entry is discarded immediately; never re-wrap this element.
},
duck(amount, duration) {
if (!this.ctx || !this.duckGain || G.muted) return;
const t = this.ctx.currentTime, gain = this.duckGain.gain;
if (gain.cancelAndHoldAtTime) gain.cancelAndHoldAtTime(t);
else { gain.cancelScheduledValues(t); gain.setValueAtTime(Math.max(0.001, gain.value), t); }
gain.linearRampToValueAtTime(Math.max(0.15, 1 - clamp(amount, 0, 0.85)), t + 0.015);
gain.exponentialRampToValueAtTime(1, t + Math.max(0.06, duration || 0.15));
},
punch(level, pos) {
if (!this.ctx || G.muted || this.muted || level <= 0) return;
const t = this.ctx.currentTime, gap = t - this.lastPunch;
if (gap < 0.055) {
if (level <= this.lastPunchLevel) return;
level *= 0.55;
}
this.lastPunch = t; this.lastPunchLevel = level;
const out = this.output('sub', pos, clamp(level, 0, 1.2));
if (!out) return;
const osc = this.ctx.createOscillator(), env = this.ctx.createGain();
osc.type = 'sine'; osc.frequency.setValueAtTime(78, t);
osc.frequency.exponentialRampToValueAtTime(34, t + 0.11);
env.gain.setValueAtTime(0.85, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.11);
osc.connect(env); env.connect(out); this.trackProcedural(osc); osc.start(t); osc.stop(t + 0.12);
this.noise(0.012, 0.035 * level, 3500, 'impacts', pos, true);
if (level >= 0.7) this.duck(clamp(level * 0.4, 0.22, 0.48), 0.14 + level * 0.06);
},
resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume().catch(() => {}); },
rampNode(node, target, duration) {
if (!node || !this.ctx) return;
const t = this.ctx.currentTime, gain = node.gain;
if (gain.cancelAndHoldAtTime) gain.cancelAndHoldAtTime(t);
else { gain.cancelScheduledValues(t); gain.setValueAtTime(Math.max(0, gain.value), t); }
gain.linearRampToValueAtTime(Math.max(0, target), t + (duration || 0.018));
},
setMuted(m) {
this.muted = !!m;
this.rampNode(this.master, m ? 0 : (G.sfxVol === undefined ? 0.45 : G.sfxVol));
},
setMusicMuted(m) { this.rampNode(this.musicMute, m ? 0 : 1); },
setVolume(v) {
G.sfxVol = clamp(v, 0, 1);
if (!G.muted) this.rampNode(this.master, G.sfxVol);
try { localStorage.setItem('meatslicer_sfx_volume', String(G.sfxVol)); } catch (e) {}
},
canPlay(key) {
if (!this.ctx || G.muted || this.muted) return false;
const now = this.ctx.currentTime, last = this.lastPlayed.get(key) || -999;
if (now - last < (this.cooldowns[key] || 0)) return false;
this.lastPlayed.set(key, now);
return true;
},
output(bus, pos, vol) {
if (!this.ctx) return null;
const gain = this.ctx.createGain();
const p = G.player;
let pan = 0, distanceGain = 1;
if (pos && p) {
pan = clamp((pos.x - p.x) / (W / 2), -1, 1) * 0.75;
const maxD = Math.hypot(W, H);
distanceGain = 1 - 0.35 * clamp(dist(pos.x, pos.y, p.x, p.y) / maxD, 0, 1);
}
gain.gain.value = (vol === undefined ? 1 : vol) * distanceGain;
if (this.ctx.createStereoPanner) {
const panner = this.ctx.createStereoPanner(); panner.pan.value = pan;
gain.connect(panner); panner.connect(this.buses[bus] || this.master);
} else gain.connect(this.buses[bus] || this.master);
return gain;
},
sample(name, bus, pos, gain, key) {
if (typeof SfxBank === 'undefined') return false;
if (!SfxBank.buffers.has(name)) { SfxBank.want(name); return false; }
if (!this.canPlay(key || name)) return true;
this.enforceVoiceLimit();
const out = this.output(bus, pos, 1);
return SfxBank.play(name, out, { gain: (gain || 1) * rand(0.92, 1.08), rate: rand(0.97, 1.03) });
},
enforceVoiceLimit() {
const sampled = typeof SfxBank === 'undefined' ? [] : SfxBank.active;
while (sampled.length + this.procedural.length >= this.maxVoices) {
const sampleVoice = sampled[0], proceduralVoice = this.procedural[0];
if (sampleVoice && (!proceduralVoice || sampleVoice.started <= proceduralVoice.started)) {
sampled.shift();
try { sampleVoice.src.stop(); } catch (e) {}
} else if (proceduralVoice) {
this.procedural.shift();
try { proceduralVoice.src.stop(); } catch (e) {}
} else break;
}
},
trackProcedural(src) {
if (!src || !this.ctx) return;
this.enforceVoiceLimit();
const voice = { src, started: this.ctx.currentTime };
this.procedural.push(voice);
src.onended = () => {
const index = this.procedural.indexOf(voice);
if (index >= 0) this.procedural.splice(index, 1);
};
},
osc(freq, dur, type, vol, slide, bus, pos, detune) {
if (!this.ctx || G.muted) return;
const t = this.ctx.currentTime, o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = type || 'square'; o.frequency.setValueAtTime(Math.max(20, freq), t);
if (detune) o.detune.value = detune;
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
g.gain.setValueAtTime(Math.max(0.001, vol || 0.3), t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(this.output(bus || 'impacts', pos));
this.trackProcedural(o); o.start(t); o.stop(t + dur);
},
blip(freq, dur, type, vol, slide, bus, pos) { this.osc(freq, dur, type, vol, slide, bus, pos, 0); },
tone(freq, dur, type, vol, slide, detune, bus, pos) {
this.osc(freq, dur, type, (vol || 0.25) / 2, slide, bus, pos, -(detune || 8));
this.osc(freq, dur, type, (vol || 0.25) / 2, slide, bus, pos, detune || 8);
},
thump(freq, dur, vol, slideTo, bus, pos) { this.osc(freq, dur, 'sine', vol || 0.4, (slideTo || 45) - freq, bus, pos, 0); },
noise(dur, vol, low, bus, pos, highpass) {
if (!this.ctx || G.muted) return;
const t = this.ctx.currentTime, len = Math.max(1, Math.floor(this.ctx.sampleRate * dur));
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = this.ctx.createBufferSource(), f = this.ctx.createBiquadFilter(), g = this.ctx.createGain();
src.buffer = buf; f.type = highpass ? 'highpass' : 'lowpass'; f.frequency.value = low || 900; g.gain.value = vol || 0.25;
src.connect(f); f.connect(g); g.connect(this.output(bus || 'impacts', pos));
this.trackProcedural(src); src.start(t);
},
click(vol, freq, bus, pos) { this.noise(0.02, vol || 0.15, freq || 1500, bus, pos, true); },
shoot(weapon) {
const w = typeof weapon === 'string' ? null : weapon;
const id = w ? w.id : null, kind = w ? w.sfx : weapon;
const sampleName = id && !['bile', 'cauterizer', 'redhand'].includes(id) ?
(id === 'spinaltap' ? 'wpn_spinaltap_fire' : 'wpn_' + id) : null;
const sampled = sampleName && this.sample(sampleName, 'weapons', null, 0.88, 'shoot_' + id);
this.punch(w ? w.punch : 0.4);
if (sampled) return;
if (!this.canPlay('shoot_' + (id || kind))) return;
switch (kind) {
case 'spread': this.thump(110, .22, .5, 40, 'weapons'); this.noise(.16, .4, 900, 'weapons'); this.click(.2, 1200, 'weapons'); break;
case 'rapid': this.thump(150, .07, .3, 60, 'weapons'); this.blip(rand(700, 900), .05, 'square', .12, -300, 'weapons'); break;
case 'heavy': this.thump(85, .3, .6, 35, 'weapons'); this.noise(.22, .4, 500, 'weapons'); this.click(.25, 800, 'weapons'); break;
case 'flame': this.noise(.09, .12, 700, 'weapons'); this.thump(90, .09, .12, 50, 'weapons'); break;
case 'saw': this.tone(rand(140, 200), .08, 'sawtooth', .16, 0, 12, 'weapons'); this.thump(120, .08, .2, 60, 'weapons'); break;
case 'beam': this.thump(70, .45, .55, 30, 'weapons'); this.blip(200, .4, 'sawtooth', .3, 1800, 'weapons'); this.noise(.35, .3, 3000, 'weapons'); break;
case 'lob': this.thump(180, .16, .4, 70, 'weapons'); this.blip(300, .12, 'triangle', .2, -150, 'weapons'); break;
case 'boomerang': this.blip(450, .1, 'triangle', .18, 350, 'weapons'); this.noise(.12, .14, 2500, 'weapons'); break;
default: this.thump(170, .1, .4, 55, 'weapons'); this.blip(rand(500, 650), .06, 'square', .14, -200, 'weapons'); this.click(.12, 1800, 'weapons');
}
},
syncWeaponLoop(w, active) {
const map = { bile: 'wpnloop_bile', cauterizer: 'wpnloop_cauterizer', redhand: 'wpnloop_redhand', spinaltap: 'wpnloop_spinaltap_charge' };
const wanted = active ? map[w.id] : null;
if (this.currentWeaponLoop && this.currentWeaponLoop !== wanted) this.loopStop(this.currentWeaponLoop);
this.currentWeaponLoop = wanted;
if (!wanted || !this.ctx || G.muted) return;
if (typeof SfxBank !== 'undefined') {
if (SfxBank.loops.has(wanted)) return;
const gain = wanted.includes('redhand') ? .58 : .42;
if (SfxBank.loopStart(wanted, this.output('weapons', null), { gain })) return;
}
if (this.canPlay('loop_' + wanted)) {
if (wanted.includes('redhand')) this.tone(150, .11, 'sawtooth', .16, 20, 10, 'weapons');
else this.noise(.12, .11, wanted.includes('spinaltap') ? 2800 : 800, 'weapons');
}
},
loopStop(name) { if (typeof SfxBank !== 'undefined') SfxBank.loopStop(name); if (this.currentWeaponLoop === name) this.currentWeaponLoop = null; },
stopAllLoops() { if (typeof SfxBank !== 'undefined') SfxBank.stopAll(); this.currentWeaponLoop = null; },
hit(e, crit) {
const pos = e && { x: e.x, y: e.y };
if (crit) {
const sampled = this.sample('imp_crit', 'impacts', pos, .82, 'crit');
this.punch(.14, pos);
if (sampled) return;
if (!this.canPlay('crit')) return;
this.blip(1100, .06, 'square', .18, -500, 'impacts', pos);
return;
}
this.fleshN = (this.fleshN % 4) + 1;
if (this.sample('imp_flesh' + this.fleshN, 'impacts', pos, .68, 'hit')) return;
if (!this.canPlay('hit')) return;
this.noise(.075, .26, 720, 'impacts', pos);
this.thump(170, .065, .18, 70, 'impacts', pos);
},
sawHit(pos) { if (!this.canPlay('sawHit')) return; this.noise(.045, .12, 1700, 'impacts', pos); this.click(.1, 2200, 'impacts', pos); },
enemyDie(e) {
const type = e && e.boss ? null : e && e.type, pos = e && { x: e.x, y: e.y };
const voiced = type && this.sample('mon_' + type + '_die', 'voices', pos, e.elite ? 1 : .78, 'enemyDie');
if (!voiced && this.canPlay('enemyDie')) {
this.noise(.15, .3, 600, 'voices', pos);
this.thump(160, .14, .3, 50, 'voices', pos);
this.blip(200, .12, 'sawtooth', .15, -120, 'voices', pos);
}
this.goreN = (this.goreN % 3) + 1;
const gored = this.sample('gore_burst' + this.goreN, 'impacts', pos, e && e.elite ? .72 : .5, 'goreBurst');
if (!gored && this.canPlay('goreBurst')) this.noise(.12, .2, 430, 'impacts', pos);
this.punch(e && e.elite ? .42 : .28, pos);
},
explode(pos) {
const sampled = this.sample('imp_explosion', 'impacts', pos, .92, 'explode');
this.punch(.82, pos);
if (sampled) return;
if (!this.canPlay('explode')) return;
this.thump(70, .35, .5, 30, 'impacts', pos);
this.noise(.4, .5, 350, 'impacts', pos);
},
hurt() { if (this.sample('plr_hurt', 'voices', null, .8, 'hurt')) return; this.thump(120, .2, .4, 45, 'voices'); this.blip(180, .2, 'sawtooth', .3, -100, 'voices'); },
lifesteal() {
const sampled = this.sample('plr_lifesteal', 'voices', null, .72, 'lifesteal');
this.punch(.12);
if (sampled) return;
if (!this.canPlay('lifesteal')) return;
this.noise(.1, .16, 650, 'voices');
this.blip(260, .14, 'sine', .2, 180, 'voices');
},
playerDeath() { this.stopAllLoops(); if (this.sample('plr_death', 'voices', null, 1, 'playerDeath')) return; this.thump(80, .65, .7, 25, 'voices'); this.noise(.7, .45, 350, 'voices'); },
footstep(strength, wet) {
if (!this.canPlay('footstep')) return;
this.footstepN = (this.footstepN % 4) + 1;
const dryName = 'plr_step' + this.footstepN;
const wanted = wet ? 'plr_step_wet' + this.footstepN : dryName;
const force = strength === undefined ? 1 : clamp(strength, .55, 1);
const side = this.footstepN % 2 ? .96 : 1.04;
const gain = .66 * force * side;
let sampled = false;
if (typeof SfxBank !== 'undefined' && SfxBank.buffers.has(wanted)) {
this.enforceVoiceLimit();
sampled = SfxBank.play(wanted, this.output('impacts', null), {
gain: gain * rand(.96, 1.04), rate: rand(.965, 1.035),
});
} else if (typeof SfxBank !== 'undefined') {
SfxBank.want(wanted);
// Keep the gait audible while a wet variant is still decoding.
if (wet && SfxBank.buffers.has(dryName)) {
this.enforceVoiceLimit();
sampled = SfxBank.play(dryName, this.output('impacts', null), { gain: gain * .9, rate: rand(.97, 1.03) });
}
}
this.punch(.18 * force * side);
if (!sampled) {
if (wet) {
this.noise(.08, .17 * force, 360, 'impacts');
this.noise(.025, .05 * force, 1500, 'impacts', null, true);
} else this.noise(.055, .13 * force, 420, 'impacts');
}
},
ricochet(pos) { if (this.sample('imp_ricochet', 'impacts', pos, .5, 'ricochet')) return; this.blip(rand(1400, 1900), .045, 'triangle', .12, -500, 'impacts', pos); },
wallHit(pos) {
this.wallN = (this.wallN % 3) + 1;
if (this.sample('imp_wall' + this.wallN, 'impacts', pos, .58, 'wallHit')) return;
if (!this.canPlay('wallHit')) return;
this.noise(.045, .18, 2400, 'impacts', pos, true);
this.thump(150, .04, .1, 80, 'impacts', pos);
},
trapSet(pos) { this.blip(240, .08, 'square', .13, -80, 'impacts', pos); this.click(.12, 900, 'impacts', pos); },
trapSnap(pos) { if (this.sample('imp_trap_snap', 'impacts', pos, .75, 'trapSnap')) return; this.noise(.09, .28, 1200, 'impacts', pos); this.thump(170, .08, .2, 80, 'impacts', pos); },
split(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_splitter_split', 'voices', p, .75, 'split')) return; this.noise(.12, .25, 700, 'voices', p); },
fuse(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_exploder_fuse', 'voices', p, .65, 'fuse')) return; this.blip(820, .25, 'square', .16, 300, 'voices', p); },
spawn(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_spawn', 'voices', p, e.elite ? .7 : .4, 'spawn')) return; this.noise(.12, .12, 500, 'voices', p); },
spit(e) { const p = e && { x: e.x, y: e.y }; if (this.sample('mon_spitter_attack', 'voices', p, .68, 'spit')) return; this.blip(350, .1, 'sawtooth', .15, -150, 'voices', p); this.thump(140, .08, .18, 70, 'voices', p); },
boss(e, event) {
const pos = e && { x: e.x, y: e.y };
const name = event === 'death' ? 'boss_death' : (event === 'enrage' ? 'boss_enrage' : 'boss_' + e.bossKind + '_' + event);
const gain = event === 'death' ? 1 : .88;
const sampled = this.sample(name, 'voices', pos, gain, 'boss_' + event);
const weight = event === 'death' ? 1 : (event === 'roar' ? .92 : (event === 'enrage' || event === 'stomp' ? .82 : .55));
this.punch(weight, pos);
if (event === 'death' || event === 'roar' || event === 'enrage') this.duck(event === 'death' ? .48 : .35, event === 'death' ? .34 : .22);
if (sampled) return;
if (!this.canPlay('boss_' + event)) return;
if (event === 'death') {
this.thump(55, .8, .75, 25, 'voices', pos);
this.noise(.8, .5, 300, 'voices', pos);
} else {
this.thump(60, .6, .5, 30, 'voices', pos);
this.blip(90, .6, 'sawtooth', .35, 40, 'voices', pos);
this.noise(.5, .35, 300, 'voices', pos);
}
},
bossRoar(e) { if (e) this.boss(e, 'roar'); else { this.thump(60, .6, .5, 30, 'voices'); this.noise(.5, .35, 300, 'voices'); } },
ui(name, fallback) { if (this.sample('ui_' + name, 'ui', null, .72, 'ui_' + name)) return true; if (fallback) fallback(); return false; },
pickup() { this.ui('menu', () => this.blip(600, .07, 'square', .15, 300, 'ui')); },
gem() { this.ui('gem', () => this.blip(rand(800, 1100), .05, 'sine', .12, 200, 'ui')); },
heart() { this.ui('heart', () => { this.blip(400, .15, 'sine', .25, 200, 'ui'); this.thump(140, .12, .2, 70, 'ui'); }); },
ammo() { this.ui('ammo', () => { this.click(.14, 1600, 'ui'); this.blip(520, .08, 'square', .12, -80, 'ui'); }); },
weapon() { this.ui('weapon', () => { this.thump(120, .14, .4, 50, 'ui'); this.blip(300, .1, 'square', .25, 200, 'ui'); this.blip(450, .12, 'square', .2, 250, 'ui'); }); },
item() { this.ui('item', () => { this.thump(100, .2, .4, 45, 'ui'); this.blip(350, .15, 'triangle', .3, 150, 'ui'); this.blip(525, .2, 'triangle', .25, 200, 'ui'); }); },
levelup() { this.ui('levelup', () => { this.blip(400, .12, 'square', .25, 200, 'ui'); setTimeout(() => this.blip(600, .12, 'square', .25, 200, 'ui'), 100); setTimeout(() => this.blip(800, .2, 'square', .25, 200, 'ui'), 200); }); },
perk() { this.ui('perk', () => this.blip(500, .1, 'square', .25, 300, 'ui')); },
menu() { this.ui('menu', () => this.blip(620, .055, 'square', .11, 120, 'ui')); },
door(pos) { this.noise(.25, .3, 400, 'impacts', pos); this.thump(90, .2, .35, 40, 'impacts', pos); },
roomClear() { this.blip(260, .18, 'triangle', .2, 280, 'ui'); this.thump(90, .18, .25, 40, 'ui'); },
stairs() { this.ui('stairs', () => { this.blip(250, .3, 'triangle', .3, 250, 'ui'); this.thump(80, .3, .35, 40, 'ui'); }); },
shieldBreak() { if (this.sample('plr_shield_break', 'impacts', null, .85, 'shieldBreak')) return; this.noise(.15, .3, 2600, 'impacts'); this.blip(900, .18, 'triangle', .2, -650, 'impacts'); },
shieldUp() { if (this.sample('plr_shield_up', 'ui', null, .7, 'shieldUp')) return; this.blip(500, .16, 'sine', .18, 380, 'ui'); },
};
+199
View File
@@ -0,0 +1,199 @@
// ---- decoded sample bank: lazy loading, MP3 silence trimming, pooled playback ----
// Sfx remains the public API. This helper returns false until a requested clip
// has decoded, allowing Sfx to use its procedural fallback without blocking.
const SfxBank = {
ctx: null,
buffers: new Map(),
pending: new Map(),
failed: new Set(),
active: [],
loops: new Map(),
FILES: {
wpn_bonepopper: 'wpn_bonepopper.mp3',
wpn_repeater: 'wpn_repeater.mp3',
wpn_marrow: 'wpn_marrow.mp3',
wpn_cleaver: 'wpn_cleaver.mp3',
wpn_saw: 'wpn_saw.mp3',
wpn_hemophage: 'wpn_hemophage.mp3',
wpn_eye: 'wpn_eye.mp3',
wpn_guthook: 'wpn_guthook.mp3',
wpn_fleshmasher: 'wpn_fleshmasher.mp3',
wpn_trapqueen: 'wpn_trapqueen.mp3',
wpn_tenderizer: 'wpn_tenderizer.mp3',
wpn_swarmjar: 'wpn_swarmjar.mp3',
wpn_spinaltap_fire: 'wpn_spinaltap_fire.mp3',
wpnloop_bile: 'wpnloop_bile.mp3',
wpnloop_cauterizer: 'wpnloop_cauterizer.mp3',
wpnloop_redhand: 'wpnloop_redhand.mp3',
wpnloop_spinaltap_charge: 'wpnloop_spinaltap_charge.mp3',
boss_bonesaw_roar: 'boss_bonesaw_roar.mp3',
boss_bonesaw_charge: 'boss_bonesaw_charge.mp3',
boss_bonesaw_saws: 'boss_bonesaw_saws.mp3',
boss_gorecrown_roar: 'boss_gorecrown_roar.mp3',
boss_gorecrown_volley: 'boss_gorecrown_volley.mp3',
boss_gorecrown_summon: 'boss_gorecrown_summon.mp3',
boss_knifecrawl_roar: 'boss_knifecrawl_roar.mp3',
boss_knifecrawl_dash: 'boss_knifecrawl_dash.mp3',
boss_knifecrawl_knives: 'boss_knifecrawl_knives.mp3',
boss_vealmother_roar: 'boss_vealmother_roar.mp3',
boss_vealmother_birth: 'boss_vealmother_birth.mp3',
boss_vealmother_bile: 'boss_vealmother_bile.mp3',
boss_flenser_roar: 'boss_flenser_roar.mp3',
boss_flenser_blink: 'boss_flenser_blink.mp3',
boss_flenser_flay: 'boss_flenser_flay.mp3',
boss_hookchoir_roar: 'boss_hookchoir_roar.mp3',
boss_hookchoir_chains: 'boss_hookchoir_chains.mp3',
boss_hookchoir_release: 'boss_hookchoir_release.mp3',
boss_platefather_roar: 'boss_platefather_roar.mp3',
boss_platefather_wall: 'boss_platefather_wall.mp3',
boss_platefather_stomp: 'boss_platefather_stomp.mp3',
boss_platefather_plate: 'boss_platefather_plate.mp3',
boss_augerprime_roar: 'boss_augerprime_roar.mp3',
boss_augerprime_spool: 'boss_augerprime_spool.mp3',
boss_augerprime_sweep: 'boss_augerprime_sweep.mp3',
boss_scald_roar: 'boss_scald_roar.mp3',
boss_scald_vent: 'boss_scald_vent.mp3',
boss_scald_burst: 'boss_scald_burst.mp3',
boss_enrage: 'boss_enrage.mp3',
boss_death: 'boss_death.mp3',
mon_shambler_die: 'mon_shambler_die.mp3',
mon_runner_die: 'mon_runner_die.mp3',
mon_spitter_attack: 'mon_spitter_attack.mp3',
mon_spitter_die: 'mon_spitter_die.mp3',
mon_splitter_die: 'mon_splitter_die.mp3',
mon_splitter_split: 'mon_splitter_split.mp3',
mon_mini_die: 'mon_mini_die.mp3',
mon_exploder_fuse: 'mon_exploder_fuse.mp3',
mon_exploder_die: 'mon_exploder_die.mp3',
mon_spawn: 'mon_spawn.mp3',
ui_gem: 'ui_gem.mp3',
ui_heart: 'ui_heart.mp3',
ui_ammo: 'ui_ammo.mp3',
ui_weapon: 'ui_weapon.mp3',
ui_item: 'ui_item.mp3',
ui_levelup: 'ui_levelup.mp3',
ui_perk: 'ui_perk.mp3',
ui_stairs: 'ui_stairs.mp3',
ui_menu: 'ui_menu.mp3',
imp_crit: 'imp_crit.mp3',
imp_ricochet: 'imp_ricochet.mp3',
imp_explosion: 'imp_explosion.mp3',
plr_hurt: 'plr_hurt.mp3',
plr_lifesteal: 'plr_lifesteal.mp3',
plr_death: 'plr_death.mp3',
plr_shield_break: 'plr_shield_break.mp3',
plr_shield_up: 'plr_shield_up.mp3',
plr_step1: 'plr_step1.mp3',
plr_step2: 'plr_step2.mp3',
plr_step3: 'plr_step3.mp3',
plr_step4: 'plr_step4.mp3',
plr_step_wet1: 'plr_step_wet1.mp3',
plr_step_wet2: 'plr_step_wet2.mp3',
plr_step_wet3: 'plr_step_wet3.mp3',
plr_step_wet4: 'plr_step_wet4.mp3',
imp_flesh1: 'imp_flesh1.mp3',
imp_flesh2: 'imp_flesh2.mp3',
imp_flesh3: 'imp_flesh3.mp3',
imp_flesh4: 'imp_flesh4.mp3',
imp_wall1: 'imp_wall1.mp3',
imp_wall2: 'imp_wall2.mp3',
imp_wall3: 'imp_wall3.mp3',
gore_burst1: 'gore_burst1.mp3',
gore_burst2: 'gore_burst2.mp3',
gore_burst3: 'gore_burst3.mp3',
imp_trap_snap: 'imp_trap_snap.mp3',
},
init(ctx) { this.ctx = ctx || null; },
want(name) {
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=33')
.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)))
.catch(() => this.failed.add(name))
.finally(() => this.pending.delete(name));
this.pending.set(name, job);
},
preload() { for (const name of Object.keys(this.FILES)) this.want(name); },
trim(buffer) {
const threshold = 0.002;
const channels = buffer.numberOfChannels;
const length = buffer.length;
let first = 0, last = length - 1;
outerStart: for (; first < length; first++) {
for (let c = 0; c < channels; c++) {
if (Math.abs(buffer.getChannelData(c)[first]) >= threshold) break outerStart;
}
}
outerEnd: for (; last > first; last--) {
for (let c = 0; c < channels; c++) {
if (Math.abs(buffer.getChannelData(c)[last]) >= threshold) break outerEnd;
}
}
// Keep 3 ms before the transient and 20 ms after the tail to avoid clicks.
const padIn = Math.floor(buffer.sampleRate * 0.003);
const padOut = Math.floor(buffer.sampleRate * 0.02);
first = Math.max(0, first - padIn);
last = Math.min(length - 1, last + padOut);
return { buffer, offset: first / buffer.sampleRate, duration: Math.max(0.03, (last - first + 1) / buffer.sampleRate) };
},
play(name, output, opts) {
const clip = this.buffers.get(name);
if (!clip || !this.ctx || !output) { this.want(name); return false; }
opts = opts || {};
const src = this.ctx.createBufferSource();
const gain = this.ctx.createGain();
src.buffer = clip.buffer;
src.playbackRate.value = opts.rate || 1;
gain.gain.value = opts.gain === undefined ? 1 : opts.gain;
src.connect(gain); gain.connect(output);
const voice = { src, gain, name, started: this.ctx.currentTime };
this.active.push(voice);
src.onended = () => { const i = this.active.indexOf(voice); if (i >= 0) this.active.splice(i, 1); };
src.start(0, clip.offset, clip.duration);
return true;
},
loopStart(name, output, opts) {
if (this.loops.has(name)) return true;
const clip = this.buffers.get(name);
if (!clip || !this.ctx || !output) { this.want(name); return false; }
opts = opts || {};
const src = this.ctx.createBufferSource();
const gain = this.ctx.createGain();
src.buffer = clip.buffer; src.loop = true;
src.loopStart = clip.offset; src.loopEnd = clip.offset + clip.duration;
src.playbackRate.value = opts.rate || 1;
gain.gain.value = 0.001;
src.connect(gain); gain.connect(output);
src.start(0, clip.offset);
gain.gain.exponentialRampToValueAtTime(Math.max(0.001, opts.gain || 0.5), this.ctx.currentTime + 0.04);
this.loops.set(name, { src, gain });
return true;
},
loopStop(name) {
const voice = this.loops.get(name);
if (!voice || !this.ctx) return;
const end = this.ctx.currentTime + 0.06;
voice.gain.gain.cancelScheduledValues(this.ctx.currentTime);
voice.gain.gain.setValueAtTime(Math.max(0.001, voice.gain.gain.value), this.ctx.currentTime);
voice.gain.gain.exponentialRampToValueAtTime(0.001, end);
voice.src.stop(end + 0.01);
this.loops.delete(name);
},
stopAll() { for (const name of [...this.loops.keys()]) this.loopStop(name); },
};
+294
View File
@@ -0,0 +1,294 @@
// ---- sprite loading + procedural fallback rendering ----
// Every sprite is optional: if assets/<name>.png failed to load, we draw
// a vector fallback so the game is always playable.
const SPRITE_MANIFEST = [
'player', 'player_legs',
'enemy_shambler', 'enemy_runner', 'enemy_spitter', 'enemy_splitter', 'enemy_mini', 'enemy_exploder',
'boss_bonesaw', 'boss_gorecrown', 'boss_knifecrawl',
'boss_vealmother', 'boss_flenser', 'boss_hookchoir',
'boss_platefather', 'boss_augerprime', 'boss_scald',
'tile_floor1', 'tile_floor2', 'tile_floor3', 'tile_floor4',
'tile_floor5', 'tile_floor6', 'tile_floor7', 'tile_floor8',
'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4',
'door_open', 'door_locked',
'bullet_bone', 'bullet_saw', 'bullet_cleaver', 'bullet_harpoon', 'bullet_eye', 'bullet_syringe', 'bullet_gore', 'bullet_steam',
'gem_small', 'gem_big', 'heart', 'ammo', 'pedestal', 'stairs',
'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4',
// level-up perk emblems
'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart',
'perk_magnetb', 'perk_shieldheart', 'perk_scavenge', 'perk_bloodrush', 'perk_deadeye',
'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile',
'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
// weapon icons
'w_bonepopper', 'w_repeater', 'w_marrow', 'w_cleaver', 'w_saw', 'w_bile', 'w_hemophage', 'w_eye',
'w_guthook', 'w_cauterizer', 'w_fleshmasher', 'w_trapqueen', 'w_tenderizer', 'w_redhand', 'w_spinaltap', 'w_swarmjar',
// player upper bodies with the weapon and gripping hands baked together
'pt_bonepopper', 'pt_repeater', 'pt_marrow', 'pt_cleaver', 'pt_saw', 'pt_bile', 'pt_hemophage', 'pt_eye',
'pt_guthook', 'pt_cauterizer', 'pt_fleshmasher', 'pt_trapqueen', 'pt_tenderizer', 'pt_redhand', 'pt_spinaltap', 'pt_swarmjar',
// item icons
'i_hollowpoints', 'i_twitch', 'i_scalpel', 'i_leadmarrow', 'i_piercegaze', 'i_ricochet',
'i_splittongue', 'i_hydramaw', 'i_homingtumor', 'i_orbitalknives', 'i_dentures', 'i_volatilebile',
'i_backstabber', 'i_splinterbone', 'i_ironstomach', 'i_luckycoin', 'i_magnetmaw', 'i_bloodlust', 'i_ghoulheart',
'i_chainsinew', 'i_mortarbone', 'i_bloatrounds', 'i_marrowglut', 'i_hollowneedle', 'i_bloodshoteye',
'i_flayerkiss', 'i_emberjar', 'i_acidgland', 'i_hookrounds', 'i_sledgerounds', 'i_graftedtrigger',
'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate',
'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine',
'i_crowbait', 'i_gorgingleech', 'i_rerollrib',
// full 64px/128px eight-direction action atlases
'player_sheet', 'player_legs_sheet',
'enemy_shambler_sheet', 'enemy_runner_sheet', 'enemy_spitter_sheet',
'enemy_splitter_sheet', 'enemy_mini_sheet', 'enemy_exploder_sheet',
'boss_bonesaw_sheet', 'boss_gorecrown_sheet', 'boss_knifecrawl_sheet',
'boss_vealmother_sheet', 'boss_flenser_sheet', 'boss_hookchoir_sheet',
'boss_platefather_sheet', 'boss_augerprime_sheet', 'boss_scald_sheet',
];
// These planned icons intentionally use the built-in gold '?' treatment until
// the second-pass image generation is approved. Keeping them in the manifest
// documents the asset contract without issuing noisy 404 requests at runtime.
const SPRITE_FALLBACK_ONLY = new Set([
'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile',
'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit',
'perk_spiteflesh', 'perk_carrion', 'perk_sinew',
'i_chainsinew', 'i_mortarbone', 'i_bloatrounds', 'i_marrowglut', 'i_hollowneedle', 'i_bloodshoteye',
'i_flayerkiss', 'i_emberjar', 'i_acidgland', 'i_hookrounds', 'i_sledgerounds', 'i_graftedtrigger',
'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate',
'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine',
'i_crowbait', 'i_gorgingleech', 'i_rerollrib',
]);
const ACTOR_ANIMS = {
idle: { frames: 4, fps: 5, offset: 0 },
move: { frames: 8, fps: 12, offset: 32 },
attack: { frames: 6, fps: 15, offset: 96 },
hit: { frames: 3, fps: 18, offset: 144 },
death: { frames: 8, fps: 12, offset: 168 },
};
const Sprites = {
imgs: {},
flashCanvas: null,
flashCtx: null,
shadow(ctx, x, y, rx, ry, alpha) {
ctx.save();
ctx.globalAlpha = alpha === undefined ? 0.32 : alpha;
ctx.fillStyle = '#050203';
ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill();
ctx.restore();
},
load() {
let pending = 0;
for (const name of SPRITE_MANIFEST) {
if (SPRITE_FALLBACK_ONLY.has(name)) continue;
pending++;
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=34';
}
},
get(name) { return this.imgs[name] || null; },
// Compact player-only strip: eight forward-facing stride frames.
// Runtime rotation is smooth; distance-driven phase keeps contacts from skating.
legs(ctx, x, y, facing, phase, targetW) {
const sheet = this.imgs.player_legs_sheet;
if (!sheet) {
this.draw(ctx, 'player_legs', x, y, facing, targetW);
return;
}
const frameSize = 96;
const frameCount = Math.max(1, Math.floor(sheet.width / frameSize));
let frame = Math.floor(Math.max(0, phase) / (TAU / frameCount)) % frameCount;
if (frame < 0) frame += frameCount;
const tw = targetW || frameSize;
ctx.save();
ctx.translate(x, y);
ctx.rotate(facing);
ctx.drawImage(sheet, frame * frameSize, 0, frameSize, frameSize,
-tw / 2, -tw / 2, tw, tw);
ctx.restore();
},
actor(ctx, name, x, y, facing, action, time, targetW, alpha, scaleX, scaleY) {
const sheet = this.imgs[name + '_sheet'];
const def = ACTOR_ANIMS[action] || ACTOR_ANIMS.idle;
if (!sheet) {
this.draw(ctx, name, x, y, facing, targetW, action === 'hit', alpha, scaleX, scaleY);
return;
}
const frameSize = name.startsWith('boss_') ? 128 : (name === 'player' ? 96 : 64);
let direction = Math.round(facing / (TAU / 8)) % 8;
if (direction < 0) direction += 8;
const rawFrame = Math.floor(Math.max(0, time) * def.fps);
const frame = action === 'death' ? Math.min(def.frames - 1, rawFrame) : rawFrame % def.frames;
const cell = def.offset + direction * def.frames + frame;
const sx = (cell % 8) * frameSize, sy = Math.floor(cell / 8) * frameSize;
const tw = targetW || frameSize;
ctx.save();
ctx.translate(x, y);
if (scaleX !== undefined || scaleY !== undefined) ctx.scale(scaleX || 1, scaleY || 1);
if (alpha !== undefined) ctx.globalAlpha = alpha;
ctx.drawImage(sheet, sx, sy, frameSize, frameSize, -tw / 2, -tw / 2, tw, tw);
ctx.restore();
},
// draw centered at x,y with rotation (radians), scaled to targetW pixels wide
// (fallbacks are authored in a canonical 32px box, so they scale the same way)
draw(ctx, name, x, y, rot, targetW, flash, alpha, scaleX, scaleY) {
const img = this.imgs[name];
const tw = targetW || 32;
ctx.save();
ctx.translate(x, y);
if (rot) ctx.rotate(rot);
if (scaleX !== undefined || scaleY !== undefined) ctx.scale(scaleX || 1, scaleY || 1);
if (alpha !== undefined) ctx.globalAlpha = alpha;
if (img) {
const s = tw / img.width;
if (flash) {
// Isolate masking from the already-opaque room canvas.
if (!this.flashCanvas && typeof document !== 'undefined' && document.createElement) {
this.flashCanvas = document.createElement('canvas');
this.flashCtx = this.flashCanvas.getContext('2d');
}
if (this.flashCanvas) {
this.flashCanvas.width = img.width; this.flashCanvas.height = img.height;
const fc = this.flashCtx;
fc.imageSmoothingEnabled = false;
fc.clearRect(0, 0, img.width, img.height);
fc.globalCompositeOperation = 'source-over';
fc.drawImage(img, 0, 0);
fc.globalCompositeOperation = 'source-atop';
fc.fillStyle = '#fff4e6'; fc.fillRect(0, 0, img.width, img.height);
ctx.drawImage(this.flashCanvas, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s);
} else ctx.drawImage(img, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s);
} else ctx.drawImage(img, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s);
} else {
this.fallback(ctx, name, tw / 32, flash);
}
ctx.restore();
},
// ---- vector fallbacks (only used when a PNG is missing) ----
fallback(ctx, name, s, flash) {
ctx.save();
ctx.scale(s, s);
if (flash) { ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff'; }
const C = (c) => flash ? '#fff' : c;
if (name === 'player_legs') {
ctx.fillStyle = C('#3e302b');
ctx.fillRect(-10, -9, 17, 7); ctx.fillRect(-8, 5, 17, 7);
ctx.fillStyle = C('#666f75');
ctx.fillRect(4, -9, 7, 7); ctx.fillRect(6, 5, 7, 7);
ctx.fillStyle = C('#9a6c3b');
ctx.fillRect(-7, -5, 10, 12);
} else if (name.startsWith('pt_')) {
ctx.fillStyle = C('#7e1926');
ctx.beginPath(); ctx.ellipse(-4, 0, 13, 12, 0, 0, TAU); ctx.fill();
ctx.fillStyle = C('#ddaF8f');
ctx.beginPath(); ctx.arc(-1, -8, 5, 0, TAU); ctx.fill();
ctx.fillRect(2, -7, 16, 5); ctx.fillRect(2, 3, 16, 5);
ctx.fillStyle = C('#ddd2bd');
ctx.fillRect(-5, -14, 10, 4);
ctx.fillStyle = C('#737b80');
ctx.fillRect(12, -3, 17, 6);
ctx.fillStyle = C('#ddaF8f');
ctx.beginPath(); ctx.arc(13, -3, 3, 0, TAU); ctx.arc(13, 4, 3, 0, TAU); ctx.fill();
} else if (name === 'player') {
ctx.fillStyle = C('#c9a227');
ctx.beginPath(); ctx.arc(0, 0, 11, 0, TAU); ctx.fill(); // body
ctx.fillStyle = C('#7a1212');
ctx.fillRect(2, -4, 14, 8); // cleaver arm
ctx.fillStyle = C('#e8e0d0');
ctx.beginPath(); ctx.arc(-3, -3, 3.5, 0, TAU); ctx.arc(-3, 4, 3.5, 0, TAU); ctx.fill(); // eyes
} else if (name.startsWith('enemy_')) {
const cols = { enemy_shambler: '#6a8f3c', enemy_runner: '#a33e2e', enemy_spitter: '#7a4f9e', enemy_splitter: '#8f6b3c', enemy_mini: '#7a5a30', enemy_exploder: '#c93b3b' };
const r = name === 'enemy_mini' ? 6 : (name === 'enemy_exploder' ? 10 : 11);
ctx.fillStyle = C(cols[name] || '#888');
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fill();
ctx.fillStyle = C('#1a0505');
ctx.beginPath(); ctx.arc(3, -3, 2.2, 0, TAU); ctx.arc(3, 3, 2.2, 0, TAU); ctx.fill();
if (name === 'enemy_exploder') { ctx.strokeStyle = C('#ff0'); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, 0, r + 3, 0, TAU); ctx.stroke(); }
} else if (name.startsWith('boss_')) {
const cols = {
boss_bonesaw: '#b8b0a0', boss_gorecrown: '#8f1f2e', boss_knifecrawl: '#5a6b7a',
boss_vealmother: '#b98d78', boss_flenser: '#d2544c', boss_hookchoir: '#74645a',
boss_platefather: '#8e7c64', boss_augerprime: '#4f857e', boss_scald: '#b69245',
};
ctx.fillStyle = C(cols[name] || '#a55');
ctx.beginPath(); ctx.arc(0, 0, 15, 0, TAU); ctx.fill();
ctx.strokeStyle = C('#2a0a0a'); ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = C('#ff2020');
ctx.beginPath(); ctx.arc(5, -4, 2.5, 0, TAU); ctx.arc(5, 4, 2.5, 0, TAU); ctx.fill();
} else if (name.startsWith('tile_floor')) {
const cols = { tile_floor1: '#241417', tile_floor2: '#2a1518', tile_floor3: '#1f1215' };
ctx.fillStyle = C(cols[name] || '#241417');
ctx.fillRect(-16, -16, 32, 32);
ctx.fillStyle = C('#33191c');
ctx.fillRect(-10, -6, 6, 4); ctx.fillRect(4, 8, 8, 3);
} else if (name.startsWith('tile_wall')) {
ctx.fillStyle = C('#3d1f22'); ctx.fillRect(-16, -16, 32, 32);
ctx.fillStyle = C('#54282c'); ctx.fillRect(-16, -16, 32, 5);
} else if (name === 'door_open' || name === 'door_locked') {
ctx.fillStyle = C('#180d0e'); ctx.fillRect(-15, -8, 30, 16);
if (name === 'door_locked') {
ctx.fillStyle = C('#8f2f2f');
for (let i = -13; i < 15; i += 6) { ctx.beginPath(); ctx.moveTo(i, -8); ctx.lineTo(i + 3, 0); ctx.lineTo(i, 8); ctx.fill(); }
}
} else if (name.startsWith('bullet_')) {
const cols = { bullet_bone: '#e8e0d0', bullet_saw: '#c0c8d0', bullet_cleaver: '#d0d8e0', bullet_harpoon: '#b09070', bullet_eye: '#e0e0f0', bullet_syringe: '#70e090', bullet_gore: '#c03b6b', bullet_steam: '#e8e0b0' };
ctx.fillStyle = C(cols[name] || '#fff');
if (name === 'bullet_saw') {
for (let i = 0; i < 6; i++) { ctx.rotate(TAU / 6); ctx.fillRect(3, -1.5, 5, 3); }
ctx.beginPath(); ctx.arc(0, 0, 4, 0, TAU); ctx.fill();
} else if (name === 'bullet_eye') {
ctx.beginPath(); ctx.arc(0, 0, 5, 0, TAU); ctx.fill();
ctx.fillStyle = C('#c02020'); ctx.beginPath(); ctx.arc(1, 0, 2.2, 0, TAU); ctx.fill();
} else if (name === 'bullet_harpoon' || name === 'bullet_cleaver') {
ctx.fillRect(-7, -2, 14, 4); ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(4, -4); ctx.lineTo(4, 4); ctx.fill();
} else {
ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, TAU); ctx.fill();
}
} else if (name.startsWith('gem_')) {
const big = name === 'gem_big';
ctx.fillStyle = C(big ? '#3be0c9' : '#3b8fe0');
const r = big ? 7 : 4.5;
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath(); ctx.fill();
} else if (name === 'heart') {
ctx.fillStyle = C('#d92038');
ctx.beginPath(); ctx.arc(-3, -2, 4, 0, TAU); ctx.arc(3, -2, 4, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.moveTo(-6.5, 0); ctx.lineTo(0, 7); ctx.lineTo(6.5, 0); ctx.fill();
} else if (name === 'ammo') {
ctx.fillStyle = C('#8a6f3c'); ctx.fillRect(-7, -5, 14, 10);
ctx.fillStyle = C('#d0b060'); ctx.fillRect(-5, -3, 3, 6); ctx.fillRect(-1, -3, 3, 6); ctx.fillRect(3, -3, 3, 6);
} else if (name === 'pedestal') {
ctx.fillStyle = C('#5a4a52'); ctx.fillRect(-10, 2, 20, 8);
ctx.fillStyle = C('#7a6a72'); ctx.fillRect(-7, -8, 14, 10);
} else if (name === 'stairs') {
ctx.fillStyle = C('#120a0c'); ctx.fillRect(-14, -14, 28, 28);
ctx.strokeStyle = C('#6b4a3a'); ctx.lineWidth = 3;
for (let i = -10; i <= 8; i += 6) { ctx.beginPath(); ctx.moveTo(i, -12); ctx.lineTo(i, 12); ctx.stroke(); }
} else if (name.startsWith('decal_blood')) {
ctx.fillStyle = C('#5a0a12');
ctx.beginPath(); ctx.arc(0, 0, 10, 0, TAU); ctx.arc(8, 4, 5, 0, TAU); ctx.arc(-7, 5, 4, 0, TAU); ctx.fill();
} else if (name.startsWith('w_')) {
ctx.fillStyle = C('#b8a888'); ctx.fillRect(-8, -3, 16, 6);
ctx.fillStyle = C('#6b5333'); ctx.fillRect(-4, 3, 5, 6);
} else if (name.startsWith('i_') || name.startsWith('perk_')) {
ctx.fillStyle = C('#c9a227');
ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill();
ctx.fillStyle = C('#2a1015');
ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('?', 0, 0);
} else {
ctx.fillStyle = C('#f0f'); ctx.fillRect(-8, -8, 16, 16);
}
ctx.restore();
},
};
+61
View File
@@ -0,0 +1,61 @@
// ---- global game state ----
const W = 960, H = 640, TILE = 64, WALL = 48, DOOR_HALF = 44;
const G = {
mode: 'menu', // menu | play | levelup | pause | gameover
time: 0,
floor: 1,
score: 0,
kills: 0,
player: null,
rooms: {}, // "x,y" -> room
cur: null, // current room
enemies: [],
bullets: [], // player projectiles
ebullets: [], // enemy projectiles
pickups: [],
parts: [], // particles
corpses: [], // short-lived frame-animated deaths
hazards: [], // acid/fire pools, traps (hurt enemies)
telegraphs: [], // boss telegraphs -> effects (hurt player)
toasts: [],
shake: 0,
flash: 0, // red screen flash when hurt
boss: null, // live boss ref
pendingLevelups: 0,
perkChoices: null,
autoPerk: false,
pressure: 1,
streak: 0,
roomDamaged: false,
roomEnterT: 0,
recentHits: [],
transition: 0, // room transition fade
muted: false,
sfxVol: 0.45,
musicVol: 0.55,
imagesLoaded: false,
roomLayer: null,
roomLayerKey: '',
atmosphereLayer: null,
};
const PERK_POWER_WEIGHT = 0.5;
const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60, PRESSURE_GAIN = 0.01;
// total player power: perks drafted (level - 1) + item tiers owned.
// monsters scale against this so difficulty tracks the build.
function powerScore() {
const p = G.player;
if (!p) return 0;
let n = (p.level - 1) * PERK_POWER_WEIGHT;
for (const iid in p.items) n += p.items[iid];
return Math.max(0, n);
}
function addShake(n) { G.shake = Math.min(G.shake + n, 18); }
function addToast(text, sub) {
G.toasts.push({ text, sub: sub || '', t: 0 });
if (G.toasts.length > 3) G.toasts.shift();
}
function addScore(n) { G.score += n; }
+136
View File
@@ -0,0 +1,136 @@
// ---- math / helper utilities ----
const TAU = Math.PI * 2;
function rand(a, b) { return a + Math.random() * (b - a); }
function irand(a, b) { return Math.floor(rand(a, b + 1)); }
function choice(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function chance(p) { return Math.random() < p; }
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
function dist2(x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; }
function angleTo(x1, y1, x2, y2) { return Math.atan2(y2 - y1, x2 - x1); }
function angleLerp(a, b, t) {
let d = (b - a) % TAU;
if (d > Math.PI) d -= TAU;
if (d < -Math.PI) d += TAU;
return a + d * t;
}
// distance from point to segment (for beam weapons)
function distToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const l2 = dx * dx + dy * dy;
if (l2 === 0) return dist(px, py, x1, y1);
let t = ((px - x1) * dx + (py - y1) * dy) / l2;
t = clamp(t, 0, 1);
return dist(px, py, x1 + t * dx, y1 + t * dy);
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Deterministic blood trim for UI panels. It is deliberately static so richer
// presentation never adds per-frame allocations or visual noise during combat.
function drawBloodTrim(ctx, x, y, w, seed, alpha) {
const oldAlpha = typeof ctx.globalAlpha === 'number' ? ctx.globalAlpha : 1;
const usable = Math.max(1, Math.floor(w - 18));
ctx.save();
ctx.globalAlpha = oldAlpha * (alpha === undefined ? 0.72 : alpha);
ctx.fillStyle = '#861020';
ctx.fillRect(x + 2, y, w - 4, 3);
for (let i = 0; i < 6; i++) {
const px = x + 9 + ((i * 47 + (seed || 0) * 29) % usable);
const r = 2 + ((i + (seed || 0)) % 3);
ctx.beginPath(); ctx.arc(px, y + 2, r, 0, TAU); ctx.fill();
if ((i + (seed || 0)) % 2 === 0) {
const drip = 4 + ((i * 7 + (seed || 0) * 3) % 13);
ctx.fillRect(px - 1, y + 2, 2, drip);
ctx.beginPath(); ctx.arc(px, y + 2 + drip, 1.6, 0, TAU); ctx.fill();
}
}
ctx.fillStyle = 'rgba(235,54,70,0.45)';
ctx.fillRect(x + 4, y, Math.max(10, w * 0.22), 1);
ctx.restore();
}
function pixelPanelPath(ctx, x, y, w, h, cut) {
const c = Math.max(0, Math.min(cut === undefined ? 7 : cut, w / 3, h / 3));
ctx.beginPath();
ctx.moveTo(x + c, y); ctx.lineTo(x + w - c, y);
ctx.lineTo(x + w, y + c); ctx.lineTo(x + w, y + h - c);
ctx.lineTo(x + w - c, y + h); ctx.lineTo(x + c, y + h);
ctx.lineTo(x, y + h - c); ctx.lineTo(x, y + c); ctx.closePath();
}
// Shared hard-edged panel language for HUD and modal screens. Chamfered corners,
// one-pixel highlights, and square corner pins keep it modern without losing the
// authored 2D pixel aesthetic.
function drawPixelPanel(ctx, x, y, w, h, opts) {
const o = opts || {};
const cut = o.cut === undefined ? 7 : o.cut;
ctx.save();
if (o.shadow !== false) {
ctx.fillStyle = o.shadowColor || 'rgba(0,0,0,0.5)';
pixelPanelPath(ctx, x + 4, y + 5, w, h, cut); ctx.fill();
}
ctx.fillStyle = o.fill || 'rgba(10,5,8,0.88)';
pixelPanelPath(ctx, x, y, w, h, cut); ctx.fill();
ctx.strokeStyle = o.border || 'rgba(111,45,57,0.9)';
ctx.lineWidth = o.lineWidth || 1;
pixelPanelPath(ctx, x + 0.5, y + 0.5, w - 1, h - 1, Math.max(1, cut - 0.5)); ctx.stroke();
ctx.strokeStyle = o.highlight || 'rgba(255,217,186,0.08)';
ctx.beginPath(); ctx.moveTo(x + cut + 2, y + 2); ctx.lineTo(x + w - cut - 2, y + 2); ctx.stroke();
const accent = o.accent || '#8f1f2f';
ctx.fillStyle = accent;
ctx.fillRect(x + cut, y, Math.max(16, Math.min(w * 0.34, 92)), 2);
ctx.fillRect(x + 2, y + cut, 2, Math.max(12, Math.min(h * 0.32, 34)));
ctx.fillStyle = 'rgba(210,171,115,0.28)';
ctx.fillRect(x + cut, y + 5, 3, 3);
ctx.fillRect(x + w - cut - 3, y + 5, 3, 3);
ctx.fillRect(x + cut, y + h - 8, 3, 3);
ctx.fillRect(x + w - cut - 3, y + h - 8, 3, 3);
if (o.blood) drawBloodTrim(ctx, x + 2, y + 2, w - 4, o.seed || 0, o.bloodAlpha === undefined ? 0.38 : o.bloodAlpha);
ctx.restore();
}
function drawPixelBar(ctx, x, y, w, h, value, opts) {
const o = opts || {};
const k = clamp(value, 0, 1);
ctx.save();
ctx.fillStyle = o.track || '#11080c'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = o.fill || '#b51d35'; ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), Math.max(1, h - 4));
if (k > 0) {
ctx.fillStyle = o.glint || 'rgba(255,210,180,0.3)';
ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), 1);
}
const segments = o.segments || 0;
if (segments > 1) {
ctx.fillStyle = o.divider || 'rgba(5,2,4,0.6)';
for (let i = 1; i < segments; i++) {
const sx = x + Math.round((w * i) / segments);
ctx.fillRect(sx, y + 1, 1, h - 2);
}
}
ctx.strokeStyle = o.border || '#55303a'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
ctx.restore();
}
function drawPixelTag(ctx, text, x, y, opts) {
const o = opts || {};
ctx.save();
ctx.font = o.font || 'bold 10px monospace';
const w = o.width || Math.ceil(ctx.measureText(text).width) + 16;
const h = o.height || 22;
drawPixelPanel(ctx, x, y, w, h, {
cut: 4, shadow: false, fill: o.fill || 'rgba(15,7,10,0.86)',
border: o.border || 'rgba(101,48,58,0.8)', accent: o.accent || '#7d1a29',
});
ctx.fillStyle = o.color || '#cdbdb2'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, x + w / 2, y + h / 2 + 1);
ctx.restore();
return w;
}
+200
View File
@@ -0,0 +1,200 @@
// ---- 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
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: 0, 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: 1, 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: 2, 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: 2, 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' },
};
// rarity weights per tier, by floor
function rollWeaponDrop(floor) {
const table = [
{ tier: 0, w: Math.max(20, 60 - floor * 5) },
{ tier: 1, w: 30 + floor * 2 },
{ tier: 2, w: 10 + floor * 3 },
{ tier: 3, w: Math.min(4 + floor * 2, 22) },
];
const total = table.reduce((s, t) => s + t.w, 0);
let r = Math.random() * total;
let tier = 0;
for (const t of table) { r -= t.w; if (r <= 0) { tier = t.tier; break; } }
const pool = Object.values(WEAPONS).filter(w => w.tier === tier);
return choice(pool);
}
// spawn player projectiles for one trigger pull (called when weapon fires)
function fireWeapon(p, w) {
const st = p.stats;
const ang = p.aim;
const muzzle = w.muzzle || 35;
const bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle;
const dmgMul = st.dmgMul;
const spdMul = st.shotSpeedMul;
spawnMuzzleFx(bx, by, ang, w.behavior);
// One shared volley shape makes split/fan/rear items useful for every
// projectile weapon instead of only the basic bullet behavior.
const volley = [ang];
for (let i = 1; i <= st.split; i++) volley.push(ang + (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 0.035);
for (let i = 1; i <= st.fan; i++) { volley.push(ang + 0.16 * i); volley.push(ang - 0.16 * i); }
for (let i = 1; i <= st.rear; i++) volley.push(ang + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
const mk = (a, overrides) => {
const b = Object.assign({
x: bx, y: by, ang: a,
spawnX: p.x, spawnY: p.y,
vx: Math.cos(a) * w.spd * spdMul, vy: Math.sin(a) * w.spd * spdMul,
r: 5 * st.sizeMul, dmg: w.dmg * dmgMul, pierce: st.pierce, bounce: st.bounce,
life: (w.range || 0.45) * st.rangeMul,
t: 0, behavior: w.behavior, sprite: w.sprite,
homing: w.behavior === 'homing' ? 6 + st.homing * 0.7 : (st.homing ? 1.6 + st.homing * 0.7 : 0),
lifesteal: w.lifesteal || 0,
bounces: w.bounces || 0, hit: null, dragTarget: null,
rangeMul: st.rangeMul, sizeMul: st.sizeMul,
}, overrides || {});
G.bullets.push(b);
return b;
};
switch (w.behavior) {
case 'spread': {
// Split adds both full volleys and a little density to a shotgun blast.
const n = w.pellets + st.split;
for (const center of volley) {
for (let i = 0; i < n; i++) mk(center + rand(-w.spread, w.spread) * (0.5 + i / n));
}
break;
}
case 'cone': case 'flame': {
for (const center of volley) {
for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul });
}
break;
}
case 'boomerang': {
for (const a of volley) mk(a, { r: 8 * st.sizeMul, pierce: st.pierce, phase: 0, life: (w.range || 1.1) * st.rangeMul });
break;
}
case 'pierce_drag': {
for (const a of volley) mk(a, { r: 7 * st.sizeMul, pierce: st.pierce, life: (w.range || 0.6) * st.rangeMul });
break;
}
case 'lob': case 'lob_trap': case 'lob_swarm': {
const primaryD = Math.max(dist(bx, by, Input.mx, Input.my), 1);
for (let i = 0; i < volley.length; i++) {
const a = volley[i];
const tx = i === 0 ? Input.mx : p.x + Math.cos(a) * primaryD;
const ty = i === 0 ? Input.my : p.y + Math.sin(a) * primaryD;
const d = Math.max(dist(bx, by, tx, ty), 1);
const flight = clamp(d / (520 * spdMul), 0.12, 0.8);
mk(a, {
vx: (tx - bx) / flight, vy: (ty - by) / flight,
r: 7 * st.sizeMul, pierce: st.pierce, bounce: st.bounce,
life: flight, lobbed: true,
});
}
break;
}
case 'slam': {
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
for (const a of volley) {
const reach = 46 * st.rangeMul * Math.sqrt(st.shotSpeedMul);
const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach;
const radius = 70 * st.rangeMul * Math.sqrt(st.sizeMul);
areaDamage(cx, cy, radius, w.dmg * dmgMul * inert, true, { source: 'player' });
spawnExplosionFx(cx, cy, radius);
}
Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 });
break;
}
default: { // bullet, bounce and homing projectiles
for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0));
}
}
}
// continuous chainsaw damage while held (def = weapon stats, inst = ammo holder)
function sawTick(p, def, inst, dt) {
const st = p.stats;
const angles = [p.aim];
for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.18);
for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + i * 0.28); angles.push(p.aim - i * 0.28); }
for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
let hitAny = false;
for (const a of angles) {
const reach = 30 * st.rangeMul * Math.sqrt(st.shotSpeedMul);
const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach;
const radius = 34 * Math.sqrt(st.sizeMul);
for (const e of G.enemies) {
if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) {
const tickScale = dt / Math.max(0.01, def.interval || 0.1);
damageEnemy(e, def.dmg * st.dmgMul * st.rateMul * inert * tickScale, a, false, {
source: 'player', procScale: tickScale, procIntervalScale: tickScale,
});
hitAny = true;
}
}
if (chance(Math.min(1, dt * 20))) spawnSpark(cx, cy, a);
if (hitAny) spawnBlood(cx, cy, a, 4);
}
if (hitAny) Sfx.sawHit({ x: p.x, y: p.y });
inst.ammo -= dt * 18 / st.ammoEff;
}
// fire the Spinal Tap beam
function fireBeam(p, def, charge) {
const st = p.stats;
const angles = [p.aim];
for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.025);
for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + 0.16 * i); angles.push(p.aim - 0.16 * i); }
for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18);
const len = 900 * st.rangeMul;
const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06;
const dmg = def.dmg * st.dmgMul * (0.4 + 0.6 * charge) * inert;
const width = 14 * Math.sqrt(st.sizeMul);
for (const ang of angles) {
const x2 = p.x + Math.cos(ang) * len, y2 = p.y + Math.sin(ang) * len;
for (const e of G.enemies) {
if (distToSegment(e.x, e.y, p.x, p.y, x2, y2) < e.r + width) {
damageEnemy(e, dmg, ang, true, { source: 'player' });
}
}
spawnBeam(p.x, p.y, ang, len);
}
const ang = p.aim;
const muzzle = def.muzzle || 35;
spawnMuzzleFx(p.x + Math.cos(ang) * muzzle, p.y + Math.sin(ang) * muzzle, ang, 'beam');
p.recoil = 1;
p.muzzleT = 0.11;
p.attackT = 0.5;
p.actionT = 0;
addShake(8);
Sfx.shoot(def);
p.weapon.ammo -= 1 / st.ammoEff;
}
// damage every enemy in a radius (slams, explosions)
function areaDamage(x, y, r, dmg, knockback, opts) {
for (const e of G.enemies) {
const d = dist(x, y, e.x, e.y);
if (d < r + e.r) {
damageEnemy(e, dmg, angleTo(x, y, e.x, e.y), knockback, opts);
}
}
}