590 lines
25 KiB
JavaScript
590 lines
25 KiB
JavaScript
// ---- bootstrap, game loop, mode dispatch ----
|
||
|
||
let canvas, ctx, lastT = 0;
|
||
|
||
const PRESSURE_SLIDER_RECT = { x: 300, y: 470, w: 360, h: 46 };
|
||
const MENU_EXIT_BUTTON = { x: W - 208, y: H - 48, w: 180, h: 28 };
|
||
const PAUSE_MENU_BUTTON = { x: W / 2 - 75, y: 526, w: 150, h: 28 };
|
||
const PAUSE_EXIT_BUTTON = { x: W / 2 + 87, y: 526, w: 150, h: 28 };
|
||
const CONFIRM_WINDOW = 3;
|
||
|
||
function pauseBars() {
|
||
const x = W / 2 - 55, w = 225;
|
||
return [
|
||
{ rect: { x, y: 418, w, h: 10 }, set: v => Sfx.setVolume(v) },
|
||
{ rect: { x, y: 446, w, h: 10 }, set: v => Music.setVolume(v) },
|
||
{ rect: { x, y: 474, w, h: 10 }, set: v => setHudAlpha(0.25 + v * 0.75) },
|
||
];
|
||
}
|
||
|
||
function init() {
|
||
canvas = document.getElementById('game');
|
||
ctx = canvas.getContext('2d');
|
||
ctx.imageSmoothingEnabled = false;
|
||
G.devMode = debugEnabled();
|
||
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';
|
||
const pd = parseFloat(localStorage.getItem('meatslicer_pressure_dial'));
|
||
if (Number.isFinite(pd)) G.pressureDial = clamp(Math.round(pd), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
||
const ha = parseFloat(localStorage.getItem('meatslicer_hud_alpha'));
|
||
if (Number.isFinite(ha)) G.hudAlpha = clamp(ha, 0.25, 1);
|
||
} catch (e) {}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
function setPressureDial(n) {
|
||
G.pressureDial = clamp(Math.round(n), PRESSURE_DIAL_MIN, PRESSURE_DIAL_MAX);
|
||
try { localStorage.setItem('meatslicer_pressure_dial', String(G.pressureDial)); } catch (e) {}
|
||
Sfx.menu();
|
||
}
|
||
|
||
function setHudAlpha(v) {
|
||
G.hudAlpha = clamp(Math.round(v * 100) / 100, 0.25, 1);
|
||
try { localStorage.setItem('meatslicer_hud_alpha', String(G.hudAlpha)); } catch (e) {}
|
||
}
|
||
|
||
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 resetRun() {
|
||
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.pauseHelp = false; G.helpPage = 0;
|
||
G.confirmAction = null; G.confirmT = 0;
|
||
G.deathLockT = 0;
|
||
G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = [];
|
||
G.shake = 0; G.flash = 0;
|
||
G.debugUsed = false; G.debugFlags = {}; G.debugTimescale = 1; G.debugFrameStep = false;
|
||
initPlayer();
|
||
genFloor(1);
|
||
enterRoom(0, 0);
|
||
G.player.shieldHp = G.player.stats.shieldPerk || 0;
|
||
}
|
||
|
||
function startRun() {
|
||
resetRun();
|
||
G.mode = 'play';
|
||
Music.requestFloorMusic();
|
||
addToast('FLOOR 1', 'clear the rooms. find the stairs. feed.');
|
||
}
|
||
|
||
function returnToMenu() {
|
||
resetRun();
|
||
G.mode = 'menu';
|
||
G.toasts = [];
|
||
Music.playMenu();
|
||
}
|
||
|
||
function canQuitDesktop() {
|
||
return !!(window.MSDesktop && typeof window.MSDesktop.quit === 'function');
|
||
}
|
||
|
||
function quitToDesktop() {
|
||
if (canQuitDesktop()) window.MSDesktop.quit();
|
||
}
|
||
|
||
function confirmAction(name, action) {
|
||
if (G.confirmAction === name && G.confirmT > 0) {
|
||
G.confirmAction = null; G.confirmT = 0;
|
||
action();
|
||
return;
|
||
}
|
||
G.confirmAction = name;
|
||
G.confirmT = CONFIRM_WINDOW;
|
||
Sfx.menu();
|
||
}
|
||
|
||
function gameOver() {
|
||
G.mode = 'gameover';
|
||
G.deathLockT = DEATH_LOCK;
|
||
spawnPlayerGore(G.player.x, G.player.y);
|
||
Sfx.playerDeath();
|
||
if (!G.debugUsed && G.score > (G.best || 0)) {
|
||
G.best = G.score;
|
||
try { localStorage.setItem('meatslicer_best', String(G.best)); } catch (e) {}
|
||
}
|
||
Music.playMenu();
|
||
}
|
||
|
||
function loop(now) {
|
||
let dt = Math.min((now - lastT) / 1000 || 0.016, 0.05);
|
||
lastT = now;
|
||
dt *= (G.debugTimescale || 1);
|
||
if (G.debugFlags.freeze) {
|
||
if (G.debugFrameStep) { G.debugFrameStep = false; dt = 1 / 60; }
|
||
else dt = 0;
|
||
}
|
||
update(dt);
|
||
draw();
|
||
clearInputEdges();
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
function update(dt) {
|
||
Music.update(dt);
|
||
if (G.deathLockT > 0) G.deathLockT = Math.max(0, G.deathLockT - dt);
|
||
if (G.confirmT > 0) {
|
||
G.confirmT = Math.max(0, G.confirmT - dt);
|
||
if (G.confirmT <= 0) G.confirmAction = null;
|
||
}
|
||
|
||
// global keys
|
||
if (keyPressed('m')) {
|
||
G.muted = !G.muted;
|
||
Music.setMuted(G.muted);
|
||
Sfx.setMuted(G.muted);
|
||
}
|
||
|
||
switch (G.mode) {
|
||
case 'menu': {
|
||
const sl = PRESSURE_SLIDER_RECT;
|
||
const exitClick = Input.mpressed && inRect(Input.mx, Input.my, MENU_EXIT_BUTTON);
|
||
if (canQuitDesktop() && (keyPressed('x') || exitClick)) {
|
||
confirmAction('desktop', quitToDesktop);
|
||
break;
|
||
}
|
||
if (keyPressed('arrowleft', 'a', '-', '_')) setPressureDial(G.pressureDial - 1);
|
||
if (keyPressed('arrowright', 'd', '=', '+')) setPressureDial(G.pressureDial + 1);
|
||
if (Input.mpressed && inRect(Input.mx, Input.my, sl)) G.menuDialDrag = true;
|
||
if (G.menuDialDrag && Input.mdown) {
|
||
setPressureDial(Math.round(((Input.mx - sl.x) / sl.w) * 20) - 10);
|
||
}
|
||
if (!Input.mdown) G.menuDialDrag = false;
|
||
if (keyPressed('enter', ' ') || (Input.mpressed && !inRect(Input.mx, Input.my, sl) && !exitClick)) startRun();
|
||
break;
|
||
}
|
||
case 'play':
|
||
G.time += dt;
|
||
if (G.devMode && keyPressed('`', '~')) { G.debugReturn = 'play'; G.mode = 'debug'; Sfx.menu(); break; }
|
||
if (keyPressed('p', 'escape')) { G.mode = 'pause'; G.confirmAction = null; G.confirmT = 0; Sfx.stopAllLoops(); Sfx.menu(); break; }
|
||
if (keyPressed('n')) Music.cycle(1);
|
||
if (keyPressed('r')) swapWeapon();
|
||
if (keyPressed(' ')) useActive();
|
||
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(dt);
|
||
// A click that installed a perk must not carry the held button into play;
|
||
// updatePlayer fires on Input.mdown, so the very next frame would shoot.
|
||
// Same mask pattern as the pinned debug console below.
|
||
if (G.mode !== 'levelup') Input.mdown = false;
|
||
updateParticles(dt);
|
||
break;
|
||
case 'debug':
|
||
updateDebug();
|
||
// pinned mode lets the world keep running behind the panel
|
||
if (G.debugPin) {
|
||
G.time += dt;
|
||
// mask trigger input so clicking panel buttons doesn't fire the weapon
|
||
const md = Input.mdown, mp = Input.mpressed, mr = Input.mreleased;
|
||
Input.mdown = Input.mpressed = Input.mreleased = false;
|
||
updatePlayer(dt);
|
||
Input.mdown = md; Input.mpressed = mp; Input.mreleased = mr;
|
||
updateRoom(dt); updateEnemies(dt); updateTelegraphs(dt);
|
||
updateBullets(dt); updateHazards(dt); updatePickups(dt); updateParticles(dt);
|
||
drawToastsUpdate(dt);
|
||
} else {
|
||
updateParticles(dt);
|
||
}
|
||
break;
|
||
case 'pause': {
|
||
if (G.pauseHelp) { updatePauseHelp(); break; }
|
||
if (G.devMode && keyPressed('`', '~')) { G.debugReturn = 'pause'; G.mode = 'debug'; Sfx.menu(); break; }
|
||
const menuClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON);
|
||
const exitClick = Input.mpressed && inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
||
if (keyPressed('q') || menuClick) { confirmAction('menu', returnToMenu); break; }
|
||
if (canQuitDesktop() && (keyPressed('x') || exitClick)) { confirmAction('desktop', quitToDesktop); break; }
|
||
if (keyPressed('p', 'escape')) { G.mode = 'play'; G.confirmAction = null; G.confirmT = 0; Sfx.menu(); break; }
|
||
if (keyPressed('h') || (Input.mpressed && inRect(Input.mx, Input.my, HELP_BUTTON))) {
|
||
G.pauseHelp = true; G.helpPage = 0; G.confirmAction = null; G.confirmT = 0; 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(';', ':')) { setHudAlpha(G.hudAlpha - 0.1); Sfx.menu(); }
|
||
if (keyPressed("'", '"')) { setHudAlpha(G.hudAlpha + 0.1); Sfx.menu(); }
|
||
// click-and-drag on the three pause sliders
|
||
{
|
||
const bars = pauseBars();
|
||
if (Input.mpressed) {
|
||
for (let i = 0; i < bars.length; i++) if (inRect(Input.mx, Input.my, bars[i].rect)) { G.pauseDrag = i; break; }
|
||
}
|
||
if (G.pauseDrag !== null && G.pauseDrag !== undefined && Input.mdown) {
|
||
const b = bars[G.pauseDrag];
|
||
const v = clamp((Input.mx - b.rect.x) / b.rect.w, 0, 1);
|
||
b.set(v);
|
||
}
|
||
if (!Input.mdown) G.pauseDrag = null;
|
||
}
|
||
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 (G.deathLockT <= 0 && (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; }
|
||
|
||
updateCamera();
|
||
|
||
// world (with camera + screen shake)
|
||
ctx.save();
|
||
ctx.translate(-G.cam.x, -G.cam.y);
|
||
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);
|
||
if (G.devMode) drawDebugOverlays(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); if (G.pauseHelp) drawPauseHelp(ctx); }
|
||
else if (G.mode === 'gameover') drawGameOver(ctx);
|
||
else if (G.mode === 'debug') drawDebug(ctx);
|
||
if (G.devMode) { if (G.debugPin && G.mode !== 'debug') drawDebugPin(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;
|
||
}
|
||
|
||
// ---- pressure dial ----
|
||
drawPressureDial(ctx);
|
||
|
||
ctx.fillStyle = '#655155'; ctx.font = '9px monospace';
|
||
ctx.fillText('ROOM-CLEAR COMBAT // PERK DRAFTS // SIXTEEN WEAPONS // CROWNED MEAT', W / 2, 530);
|
||
if (G.best) {
|
||
drawPixelTag(ctx, 'BEST CUT ' + String(G.best).padStart(6, '0'), W / 2 - 74, 556, {
|
||
width: 148, height: 26, color: '#e0b94e', accent: '#a47e25',
|
||
});
|
||
}
|
||
const exitHover = inRect(Input.mx, Input.my, MENU_EXIT_BUTTON);
|
||
const desktopExit = canQuitDesktop();
|
||
const exitConfirm = desktopExit && G.confirmAction === 'desktop';
|
||
drawPixelTag(ctx, !desktopExit ? '[X] DESKTOP APP ONLY' : (exitConfirm ? '[X] CONFIRM EXIT' : '[X] EXIT TO DESKTOP'), MENU_EXIT_BUTTON.x, MENU_EXIT_BUTTON.y, {
|
||
width: MENU_EXIT_BUTTON.w, height: MENU_EXIT_BUTTON.h,
|
||
color: !desktopExit ? '#65595b' : (exitConfirm ? '#ffd36a' : (exitHover ? '#f5e9d6' : '#ad8f8c')),
|
||
accent: !desktopExit ? '#3a2d31' : (exitConfirm ? '#d69a22' : (exitHover ? '#e22b46' : '#6e3843')),
|
||
});
|
||
}
|
||
|
||
function drawPressureDial(ctx) {
|
||
const sl = PRESSURE_SLIDER_RECT;
|
||
const d = G.pressureDial;
|
||
const gain = pressureGainUnits(d), drop = pressureDropUnits(d);
|
||
const accent = d < 0 ? '#3d8f99' : (d === 0 ? '#c59231' : '#e22b46');
|
||
drawPixelPanel(ctx, sl.x, sl.y, sl.w, sl.h, {
|
||
cut: 6, shadow: false, accent, fill: 'rgba(12,5,8,0.92)', border: '#63303a',
|
||
});
|
||
const name = d <= -9 ? 'FORGIVING' : d <= -6 ? 'MERCIFUL' : d <= -3 ? 'EASED' : d <= 0 ? 'STANDARD' :
|
||
d <= 3 ? 'TENSE' : d <= 5 ? 'SAVAGE' : d <= 7 ? 'RELENTLESS' : d <= 9 ? 'ATROCIOUS' : 'MEAT GRINDER';
|
||
ctx.textAlign = 'left'; ctx.fillStyle = '#9e857a'; ctx.font = 'bold 8px monospace';
|
||
ctx.fillText('PRESSURE DIAL', sl.x + 10, sl.y + 12);
|
||
ctx.textAlign = 'right'; ctx.fillStyle = accent; ctx.font = 'bold 9px monospace';
|
||
ctx.fillText((d > 0 ? '+' : '') + d + ' ' + name, sl.x + sl.w - 10, sl.y + 12);
|
||
// track with 20 steps / 21 notches
|
||
const bx = sl.x + 34, bw = sl.w - 68, by = sl.y + 20, bh = 10;
|
||
drawPixelBar(ctx, bx, by, bw, bh, (d + 10) / 20, {
|
||
fill: accent, glint: '#f4d86d', border: '#5d3c43', segments: 20,
|
||
});
|
||
// bright notch marker
|
||
const nx = bx + Math.round((d + 10) / 20 * bw);
|
||
ctx.fillStyle = '#f5e9d6';
|
||
ctx.fillRect(nx - 1, by - 2, 2, bh + 4);
|
||
// end labels
|
||
ctx.fillStyle = '#7d6262'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center';
|
||
ctx.fillText('-10', bx - 18, by + 9); ctx.fillText('+10', bx + bw + 18, by + 9);
|
||
// derived readout
|
||
ctx.fillStyle = '#8f7770'; ctx.font = '8px monospace';
|
||
const rise = (gain >= 0 ? '+' : '') + gain.toFixed(1);
|
||
ctx.fillText('RISE ' + rise + '/10 · RELIEF ' + drop.toFixed(1) + '/10 · SCORE ×LIVE PRESSURE', sl.x + sl.w / 2, sl.y + sl.h - 7);
|
||
}
|
||
|
||
function drawPause(ctx) {
|
||
ctx.fillStyle = 'rgba(4,2,3,0.78)'; ctx.fillRect(0, 0, W, H);
|
||
drawPixelPanel(ctx, W / 2 - 270, 104, 540, 452, {
|
||
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, 136);
|
||
ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 36px monospace';
|
||
ctx.fillText('PAUSED', W / 2, 180);
|
||
drawBloodTrim(ctx, W / 2 - 130, 191, 260, 8, 0.45);
|
||
|
||
ctx.fillStyle = '#b89532'; ctx.font = 'bold 10px monospace';
|
||
ctx.fillText('JUKEBOX // ACTIVE TRACK', W / 2, 248);
|
||
const sel = (G.musicSel === undefined || G.musicSel < 0) ? 'AUTO · FLOOR ROTATION' : Music.pretty(Music.PLAYLIST[G.musicSel]).toUpperCase();
|
||
drawPixelPanel(ctx, W / 2 - 170, 318, 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, 341);
|
||
ctx.fillStyle = '#e9ded0'; ctx.font = 'bold 14px monospace';
|
||
ctx.fillText(sel, W / 2, 368);
|
||
|
||
for (const sx of [-200, 200]) {
|
||
const x = W / 2 + sx, y = 358;
|
||
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);
|
||
}
|
||
|
||
// three sliders: SFX / MUSIC / HUD (drag or key pairs)
|
||
const sliders = [
|
||
{ label: 'SFX [- / +]', val: G.sfxVol, colors: { fill: '#c92a40', glint: '#ff7d82', border: '#63303a', segments: 10 } },
|
||
{ label: 'MUSIC [, / .]', val: G.musicVol, colors: { fill: '#468e9d', glint: '#a5edee', border: '#315b65', segments: 10 } },
|
||
{ label: "HUD [; / ']", val: (G.hudAlpha - 0.25) / 0.75, colors: { fill: '#b5892f', glint: '#f4d86d', border: '#5d3c43', segments: 10 } },
|
||
];
|
||
const bars = pauseBars();
|
||
ctx.textAlign = 'left'; ctx.font = 'bold 9px monospace'; ctx.fillStyle = '#a99a91';
|
||
for (let i = 0; i < sliders.length; i++) {
|
||
const by = bars[i].rect.y;
|
||
ctx.fillStyle = '#a99a91'; ctx.fillText(sliders[i].label, W / 2 - 170, by + 9);
|
||
drawPixelBar(ctx, bars[i].rect.x, by, bars[i].rect.w, 10, sliders[i].val, sliders[i].colors);
|
||
ctx.fillStyle = '#e9ded0'; ctx.textAlign = 'right';
|
||
const pct = i === 2 ? Math.round(G.hudAlpha * 100) : Math.round(sliders[i].val * 100);
|
||
ctx.fillText(pct + '%', W / 2 + 185, by + 9);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
|
||
const gridX = [W / 2 - 237, W / 2 - 75, W / 2 + 87];
|
||
drawPixelTag(ctx, '[P] RESUME', gridX[0], 492, { width: 150, height: 28, color: '#f0e5d7', accent: '#b5243a' });
|
||
drawPixelTag(ctx, '[R] SWAP WEAPON', gridX[1], 492, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||
drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), gridX[2], 492, { width: 150, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' });
|
||
const menuHover = inRect(Input.mx, Input.my, PAUSE_MENU_BUTTON);
|
||
const exitHover = inRect(Input.mx, Input.my, PAUSE_EXIT_BUTTON);
|
||
const menuConfirm = G.confirmAction === 'menu';
|
||
const desktopExit = canQuitDesktop();
|
||
const exitConfirm = desktopExit && G.confirmAction === 'desktop';
|
||
drawPixelTag(ctx, '[M] MUTE AUDIO', gridX[0], 526, { width: 150, height: 28, color: '#bba9a1', accent: '#6e3843' });
|
||
drawPixelTag(ctx, menuConfirm ? '[Q] CONFIRM MENU' : '[Q] MAIN MENU', PAUSE_MENU_BUTTON.x, PAUSE_MENU_BUTTON.y, {
|
||
width: PAUSE_MENU_BUTTON.w, height: PAUSE_MENU_BUTTON.h,
|
||
color: menuConfirm ? '#ffd36a' : (menuHover ? '#f5e9d6' : '#bba9a1'),
|
||
accent: menuConfirm ? '#d69a22' : (menuHover ? '#b5243a' : '#6e3843'),
|
||
});
|
||
drawPixelTag(ctx, !desktopExit ? '[X] DESKTOP ONLY' : (exitConfirm ? '[X] CONFIRM EXIT' : '[X] EXIT'), PAUSE_EXIT_BUTTON.x, PAUSE_EXIT_BUTTON.y, {
|
||
width: PAUSE_EXIT_BUTTON.w, height: PAUSE_EXIT_BUTTON.h,
|
||
color: !desktopExit ? '#65595b' : (exitConfirm ? '#ffd36a' : (exitHover ? '#f5e9d6' : '#bba9a1')),
|
||
accent: !desktopExit ? '#3a2d31' : (exitConfirm ? '#d69a22' : (exitHover ? '#b5243a' : '#6e3843')),
|
||
});
|
||
|
||
const hb = HELP_BUTTON;
|
||
const helpHover = inRect(Input.mx, Input.my, hb);
|
||
drawPixelTag(ctx, '[?] HELP', hb.x, hb.y, {
|
||
width: hb.w, height: hb.h,
|
||
color: helpHover ? '#f1cb53' : '#b89c46', accent: helpHover ? '#c9a227' : '#7c6424',
|
||
});
|
||
}
|
||
|
||
function drawGameOver(ctx) {
|
||
const reveal = clamp((DEATH_LOCK - G.deathLockT) / 0.9, 0, 1);
|
||
ctx.save();
|
||
ctx.globalAlpha = reveal;
|
||
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.debugUsed) {
|
||
drawPixelTag(ctx, '[ DEBUG RUN — SCORE NOT RECORDED ]', W / 2 - 128, 367, { width: 256, height: 26, color: '#e02945', accent: '#77202c' });
|
||
} else 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: G.deathLockT > 0 ? '#77552a' : '#cf2942',
|
||
border: G.deathLockT > 0 ? '#604727' : '#8c293c', fill: '#1a080e',
|
||
});
|
||
ctx.fillStyle = G.deathLockT > 0 ? '#d4ad3d' : '#f1e4d5'; ctx.font = 'bold 12px monospace';
|
||
ctx.fillText(G.deathLockT > 0 ? '[ INPUT LOCKED · ' + G.deathLockT.toFixed(1) + ' ]' : '[ R / ENTER ] CUT AGAIN', W / 2, 444);
|
||
ctx.restore();
|
||
}
|
||
|
||
window.addEventListener('load', init);
|