367 lines
15 KiB
JavaScript
367 lines
15 KiB
JavaScript
// ---- 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);
|