/* Box Horde - concatenated for GHL paste. Do not edit by hand; rebuild from js/*.js */
/* Load order: input.js, arena.js, entities.js, waves.js, multiplier.js, combat.js, fx.js, render.js, audio.js, ui.js, main.js */
/* ===== input.js ===== */
/* Bxh.input — keyboard capture for P1/P2 + UI edge keys. */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var captureEnabled = false;
var soloMode = true;
var bound = false;
var keysDown = Object.create(null);
var edgeCodes = Object.create(null);
var uiEdge = Object.create(null);
var cached = {
1: blankState(),
2: blankState()
};
var prevWeaponHeld = { 1: false, 2: false };
var nextWeaponHeld = { 1: false, 2: false };
var captureLostCbs = [];
var rootEl = null;
function blankState() {
return {
up: false,
down: false,
left: false,
right: false,
fire: false,
prevWeapon: false,
nextWeapon: false,
moveX: 0,
moveY: 0
};
}
function codeOf(e) {
if (e.code) return e.code;
var k = e.keyCode;
if (k === 37) return "ArrowLeft";
if (k === 38) return "ArrowUp";
if (k === 39) return "ArrowRight";
if (k === 40) return "ArrowDown";
if (k === 32) return "Space";
if (k === 13) return "Enter";
if (k === 27) return "Escape";
if (k === 80 || k === 112) return "KeyP";
if (k === 87) return "KeyW";
if (k === 65) return "KeyA";
if (k === 83) return "KeyS";
if (k === 68) return "KeyD";
if (k === 81) return "KeyQ";
if (k === 69) return "KeyE";
if (k === 188) return "Comma";
if (k === 190) return "Period";
if (k === 191) return "Slash";
if (k >= 49 && k <= 57) return "Digit" + (k - 48);
return "";
}
function isGameplayCode(code) {
return (
code === "ArrowUp" ||
code === "ArrowDown" ||
code === "ArrowLeft" ||
code === "ArrowRight" ||
code === "Slash" ||
code === "Comma" ||
code === "Period" ||
code === "KeyW" ||
code === "KeyA" ||
code === "KeyS" ||
code === "KeyD" ||
code === "KeyQ" ||
code === "KeyE" ||
code === "Space"
);
}
function markUiEdge(name) {
uiEdge[name] = true;
}
function onKeyDown(e) {
var code = codeOf(e);
if (!code) return;
var first = !keysDown[code];
keysDown[code] = true;
if (first) {
edgeCodes[code] = true;
if (code === "Escape" || code === "KeyP") markUiEdge("pause");
if (code === "Enter") markUiEdge("confirm");
if (code === "Space") markUiEdge("confirm");
if (code === "Digit1") markUiEdge("digit1");
if (code === "Digit2") markUiEdge("digit2");
if (code === "Digit3") markUiEdge("digit3");
if (code === "Digit4") markUiEdge("digit4");
if (code === "Digit5") markUiEdge("digit5");
if (code === "Digit6") markUiEdge("digit6");
}
if (captureEnabled && isGameplayCode(code)) {
if (
code === "ArrowUp" ||
code === "ArrowDown" ||
code === "ArrowLeft" ||
code === "ArrowRight" ||
code === "Space" ||
code === "Slash" ||
code === "KeyW" ||
code === "KeyA" ||
code === "KeyS" ||
code === "KeyD"
) {
if (e.preventDefault) e.preventDefault();
}
}
}
function onKeyUp(e) {
var code = codeOf(e);
if (!code) return;
keysDown[code] = false;
}
function onBlur() {
keysDown = Object.create(null);
releaseCapture();
}
/**
* Hand the keyboard back to the host page. Without this the game keeps
* swallowing arrows/space site-wide after the visitor scrolls away.
*/
function releaseCapture() {
if (!captureEnabled) return;
Bxh.input.setCaptureEnabled(false);
for (var i = 0; i < captureLostCbs.length; i++) {
try {
captureLostCbs[i]();
} catch (err) {}
}
}
function onDocPointerDown(e) {
if (!captureEnabled || !rootEl) return;
var t = e.target;
if (t && rootEl.contains && rootEl.contains(t)) return;
releaseCapture();
}
function held(code) {
return !!keysDown[code];
}
function normalizeMove(mx, my) {
if (mx !== 0 && my !== 0) {
var inv = 1 / Math.sqrt(2);
return { x: mx * inv, y: my * inv };
}
return { x: mx, y: my };
}
/** Sync held dirs/fire; leave prevWeapon/nextWeapon untouched. */
function syncContinuous(id) {
var c = cached[id];
if (!captureEnabled) {
c.up = c.down = c.left = c.right = false;
c.fire = false;
c.moveX = 0;
c.moveY = 0;
return;
}
var up, down, left, right, fire;
if (id === 1) {
up = held("ArrowUp");
down = held("ArrowDown");
left = held("ArrowLeft");
right = held("ArrowRight");
fire = held("Slash");
if (soloMode && held("Space")) fire = true;
} else {
up = held("KeyW");
down = held("KeyS");
left = held("KeyA");
right = held("KeyD");
fire = held("Space");
}
c.up = up;
c.down = down;
c.left = left;
c.right = right;
c.fire = fire;
var mx = (right ? 1 : 0) - (left ? 1 : 0);
var my = (down ? 1 : 0) - (up ? 1 : 0);
var n = normalizeMove(mx, my);
c.moveX = n.x;
c.moveY = n.y;
}
function refreshWeaponEdges(id) {
var c = cached[id];
if (!captureEnabled) {
c.prevWeapon = false;
c.nextWeapon = false;
prevWeaponHeld[id] = false;
nextWeaponHeld[id] = false;
return;
}
var prevW = id === 1 ? held("Comma") : held("KeyQ");
var nextW = id === 1 ? held("Period") : held("KeyE");
c.prevWeapon = prevW && !prevWeaponHeld[id];
c.nextWeapon = nextW && !nextWeaponHeld[id];
prevWeaponHeld[id] = prevW;
nextWeaponHeld[id] = nextW;
}
Bxh.input = {
spaceDown: false,
init: function (node) {
rootEl = node || rootEl;
if (bound) return;
bound = true;
window.addEventListener("keydown", onKeyDown, false);
window.addEventListener("keyup", onKeyUp, false);
window.addEventListener("blur", onBlur, false);
document.addEventListener("pointerdown", onDocPointerDown, true);
},
/** Notified when the visitor clicks away and the game stops reading keys. */
onCaptureLost: function (cb) {
if (typeof cb === "function") captureLostCbs.push(cb);
},
update: function () {
Bxh.input.spaceDown = captureEnabled && held("Space");
refreshWeaponEdges(1);
refreshWeaponEdges(2);
syncContinuous(1);
syncContinuous(2);
},
getPlayer: function (id) {
id = id === 2 ? 2 : 1;
syncContinuous(id);
return cached[id];
},
consumePress: function (code) {
if (edgeCodes[code]) {
edgeCodes[code] = false;
return true;
}
return false;
},
wasPressed: function (keyName) {
if (uiEdge[keyName]) {
uiEdge[keyName] = false;
return true;
}
return false;
},
reset: function () {
keysDown = Object.create(null);
edgeCodes = Object.create(null);
uiEdge = Object.create(null);
prevWeaponHeld[1] = prevWeaponHeld[2] = false;
nextWeaponHeld[1] = nextWeaponHeld[2] = false;
cached[1] = blankState();
cached[2] = blankState();
Bxh.input.spaceDown = false;
},
setCaptureEnabled: function (on) {
captureEnabled = !!on;
if (!captureEnabled) {
keysDown = Object.create(null);
Bxh.input.spaceDown = false;
prevWeaponHeld[1] = prevWeaponHeld[2] = false;
nextWeaponHeld[1] = nextWeaponHeld[2] = false;
cached[1] = blankState();
cached[2] = blankState();
}
},
isCaptureEnabled: function () {
return captureEnabled;
},
setSoloMode: function (on) {
soloMode = !!on;
},
isSoloMode: function () {
return soloMode;
}
};
})();
/* ===== arena.js ===== */
/* Bxh.arena — three rooms, outer walls with door gaps, circle-AABB move. */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var W = 960;
var H = 540;
var WALL = 24;
var DOOR = 72;
function rect(x, y, w, h) {
return { x: x, y: y, w: w, h: h };
}
/** Outer walls with centered openings on each edge for spawn doors. */
function buildOuterWalls() {
var solids = [];
var midX = W / 2;
var midY = H / 2;
var halfDoor = DOOR / 2;
// Top wall (gap at center)
solids.push(rect(0, 0, midX - halfDoor, WALL));
solids.push(rect(midX + halfDoor, 0, W - (midX + halfDoor), WALL));
// Bottom
solids.push(rect(0, H - WALL, midX - halfDoor, WALL));
solids.push(rect(midX + halfDoor, H - WALL, W - (midX + halfDoor), WALL));
// Left
solids.push(rect(0, 0, WALL, midY - halfDoor));
solids.push(rect(0, midY + halfDoor, WALL, H - (midY + halfDoor)));
// Right
solids.push(rect(W - WALL, 0, WALL, midY - halfDoor));
solids.push(rect(W - WALL, midY + halfDoor, WALL, H - (midY + halfDoor)));
return solids;
}
function doorSpawns() {
var pad = WALL + 18;
return [
{ x: W / 2, y: pad },
{ x: W / 2, y: H - pad },
{ x: pad, y: H / 2 },
{ x: W - pad, y: H / 2 }
];
}
function playerSpawnsDefault() {
return [
{ x: W * 0.42, y: H * 0.5 },
{ x: W * 0.58, y: H * 0.5 }
];
}
function roomBox() {
var solids = buildOuterWalls();
// Scattered crate solids
solids.push(rect(220, 160, 44, 44));
solids.push(rect(680, 140, 48, 40));
solids.push(rect(400, 340, 50, 46));
solids.push(rect(720, 360, 42, 42));
solids.push(rect(300, 400, 40, 40));
solids.push(rect(520, 180, 46, 36));
return {
id: "box",
name: "The Box",
blurb: "Open floor. Kite freely.",
solids: solids,
spawns: doorSpawns(),
crates: [
{ x: 180, y: 270 },
{ x: 780, y: 270 },
{ x: 480, y: 140 },
{ x: 480, y: 400 }
],
playerSpawns: playerSpawnsDefault()
};
}
function roomColumns() {
var solids = buildOuterWalls();
var cols = [200, 360, 520, 680];
var rows = [150, 270, 390];
var i, j;
for (i = 0; i < cols.length; i++) {
for (j = 0; j < rows.length; j++) {
// Skip center-ish pillars slightly for playable lanes
if (i === 1 && j === 1) continue;
if (i === 2 && j === 1) continue;
solids.push(rect(cols[i] - 22, rows[j] - 22, 44, 44));
}
}
return {
id: "columns",
name: "The Columns",
blurb: "Pillars break sight lines.",
solids: solids,
spawns: doorSpawns(),
crates: [
{ x: 280, y: 270 },
{ x: 680, y: 270 },
{ x: 480, y: 200 },
{ x: 480, y: 340 }
],
playerSpawns: playerSpawnsDefault()
};
}
function roomChoke() {
var solids = buildOuterWalls();
// Narrow corridors / bridge-like solids — two horizontal halls with a mid bridge
// Upper blocking mass with corridor gaps
solids.push(rect(WALL, 120, 280, 36));
solids.push(rect(W - WALL - 280, 120, 280, 36));
solids.push(rect(WALL, H - 156, 280, 36));
solids.push(rect(W - WALL - 280, H - 156, 280, 36));
// Vertical choke walls leaving a center bridge lane
solids.push(rect(340, WALL, 36, 170));
solids.push(rect(584, WALL, 36, 170));
solids.push(rect(340, H - WALL - 170, 36, 170));
solids.push(rect(584, H - WALL - 170, 36, 170));
// Center island / bridge rails — 40px lane so a 24px-wide actor fits through
solids.push(rect(400, 222, 160, 28));
solids.push(rect(400, 290, 160, 28));
return {
id: "choke",
name: "The Choke",
blurb: "Narrow halls. Pack them in.",
solids: solids,
spawns: doorSpawns(),
crates: [
{ x: 200, y: 270 },
{ x: 760, y: 270 },
{ x: 480, y: 200 },
{ x: 480, y: 340 }
],
playerSpawns: [
{ x: 140, y: 270 },
{ x: 820, y: 270 }
]
};
}
var ROOMS = [roomBox(), roomColumns(), roomChoke()];
var current = ROOMS[0];
function circleHitsAabb(cx, cy, r, s) {
var nearestX = Math.max(s.x, Math.min(cx, s.x + s.w));
var nearestY = Math.max(s.y, Math.min(cy, s.y + s.h));
var dx = cx - nearestX;
var dy = cy - nearestY;
return dx * dx + dy * dy < r * r;
}
function resolveAgainstSolids(x, y, radius, solids) {
var i, s, nearestX, nearestY, dx, dy, dist, overlap, len;
for (i = 0; i < solids.length; i++) {
s = solids[i];
nearestX = Math.max(s.x, Math.min(x, s.x + s.w));
nearestY = Math.max(s.y, Math.min(y, s.y + s.h));
dx = x - nearestX;
dy = y - nearestY;
dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
if (dist < 0.0001) {
// Center inside AABB — push out along smallest axis
var left = x - s.x;
var right = s.x + s.w - x;
var top = y - s.y;
var bottom = s.y + s.h - y;
var m = Math.min(left, right, top, bottom);
if (m === left) x = s.x - radius;
else if (m === right) x = s.x + s.w + radius;
else if (m === top) y = s.y - radius;
else y = s.y + s.h + radius;
} else {
overlap = radius - dist;
len = dist;
x += (dx / len) * overlap;
y += (dy / len) * overlap;
}
}
}
return { x: x, y: y };
}
function clampPlayable(x, y, radius) {
var minX = WALL + radius;
var maxX = W - WALL - radius;
var minY = WALL + radius;
var maxY = H - WALL - radius;
// Allow door corridors slightly outside wall thickness at openings
if (x >= W / 2 - DOOR / 2 && x <= W / 2 + DOOR / 2) {
minY = radius + 2;
maxY = H - radius - 2;
}
if (y >= H / 2 - DOOR / 2 && y <= H / 2 + DOOR / 2) {
minX = radius + 2;
maxX = W - radius - 2;
}
return {
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y))
};
}
function segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
function cross(ox, oy, px, py, qx, qy) {
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox);
}
var d1 = cross(cx, cy, dx, dy, ax, ay);
var d2 = cross(cx, cy, dx, dy, bx, by);
var d3 = cross(ax, ay, bx, by, cx, cy);
var d4 = cross(ax, ay, bx, by, dx, dy);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true;
}
return false;
}
function segmentHitsAabb(x0, y0, x1, y1, s) {
// Quick reject: both endpoints outside same side
var left = s.x;
var right = s.x + s.w;
var top = s.y;
var bottom = s.y + s.h;
// Point inside
function inside(px, py) {
return px >= left && px <= right && py >= top && py <= bottom;
}
if (inside(x0, y0) || inside(x1, y1)) return true;
// Edges
if (segmentsIntersect(x0, y0, x1, y1, left, top, right, top)) return true;
if (segmentsIntersect(x0, y0, x1, y1, right, top, right, bottom)) return true;
if (segmentsIntersect(x0, y0, x1, y1, right, bottom, left, bottom)) return true;
if (segmentsIntersect(x0, y0, x1, y1, left, bottom, left, top)) return true;
return false;
}
Bxh.arena = {
WIDTH: W,
HEIGHT: H,
WALL: WALL,
ROOMS: ROOMS,
setRoom: function (roomId) {
var i;
current = ROOMS[0];
for (i = 0; i < ROOMS.length; i++) {
if (ROOMS[i].id === roomId) {
current = ROOMS[i];
break;
}
}
},
getRoom: function () {
return current || ROOMS[0];
},
resolveMove: function (x, y, radius, dx, dy) {
var solids = (current && current.solids) || [];
var candidates = [
{ x: x + dx, y: y + dy },
{ x: x + dx, y: y },
{ x: x, y: y + dy },
{ x: x, y: y }
];
var i, c, stepped, pass;
for (i = 0; i < candidates.length; i++) {
c = candidates[i];
stepped = { x: c.x, y: c.y };
for (pass = 0; pass < 3; pass++) {
stepped = resolveAgainstSolids(stepped.x, stepped.y, radius, solids);
}
stepped = clampPlayable(stepped.x, stepped.y, radius);
if (!circleHitsSolid(stepped.x, stepped.y, radius, solids)) {
return stepped;
}
}
stepped = resolveAgainstSolids(x, y, radius, solids);
return clampPlayable(stepped.x, stepped.y, radius);
},
segmentHitsSolid: function (x0, y0, x1, y1) {
var solids = (current && current.solids) || [];
var i;
for (i = 0; i < solids.length; i++) {
if (segmentHitsAabb(x0, y0, x1, y1, solids[i])) return true;
}
return false;
},
getSolids: function () {
return (current && current.solids) || [];
},
getSpawns: function () {
return (current && current.spawns) || [];
},
drawFloor: function (ctx) {
if (!ctx) return;
ctx.fillStyle = "#c8c2b0";
ctx.fillRect(0, 0, W, H);
var solids = (current && current.solids) || [];
var i, s;
ctx.fillStyle = "#7a7a7a";
for (i = 0; i < solids.length; i++) {
s = solids[i];
ctx.fillRect(s.x, s.y, s.w, s.h);
}
}
};
function circleHitsSolid(cx, cy, r, solids) {
var i;
for (i = 0; i < solids.length; i++) {
if (circleHitsAabb(cx, cy, r, solids[i])) return true;
}
return false;
}
})();
/* ===== entities.js ===== */
/* Bxh.entities — players, enemies, projectiles, crates, AI + regen. */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var players = [];
var enemies = [];
var bullets = [];
var fireballs = [];
var crates = [];
var placedBarrels = [];
var grenades = [];
var rockets = [];
var spawnFlashes = [];
var nextEnemyId = 1;
var nextFireballId = 1;
var mode = "solo";
var p2Joined = false;
var simTime = 0;
var PLAYER_R = 12;
var PLAYER_SPEED = 140;
var PLAYER_HP = 100;
var REGEN_DELAY = 2;
var REGEN_RATE = 12;
var IFRAME_TIME = 0.55;
var CONTACT_DAMAGE = 10;
var DEVIL_FIRE_CD = 1.6;
var DEVIL_SUPPRESS = 0.8;
var FIREBALL_SPEED = 130;
var FIREBALL_R = 7;
var CONTACT_KNOCK = 9;
/* Kept well under PLAYER_SPEED so kiting always works, but fast enough to close in. */
var ZOMBIE_SPEED = 46;
var ZOMBIE_HP = 30;
var DEVIL_SPEED = 62;
var DEVIL_HP = 90;
var ENEMY_SOFT_CAP = 80;
var SEPARATION = 28;
function livingPlayers() {
var out = [];
var i;
for (i = 0; i < players.length; i++) {
if (players[i].alive) out.push(players[i]);
}
return out;
}
function makePlayer(id, x, y, skin) {
return {
id: id,
x: x,
y: y,
r: PLAYER_R,
hp: PLAYER_HP,
maxHp: PLAYER_HP,
alive: true,
facingX: 0,
facingY: -1,
weaponId: "pistol",
ammo: {},
fireCooldown: 0,
iFrames: 0,
lastHurt: -999,
skin: skin || (id === 1 ? "p1" : "p2"),
color: id === 1 ? "#3570c4" : "#8e5bd0",
speed: PLAYER_SPEED,
joined: id === 1
};
}
function makeEnemy(type, x, y) {
var isDevil = type === "devil";
return {
id: nextEnemyId++,
type: isDevil ? "devil" : "zombie",
x: x,
y: y,
r: isDevil ? 14 : 13,
hp: isDevil ? DEVIL_HP : ZOMBIE_HP,
maxHp: isDevil ? DEVIL_HP : ZOMBIE_HP,
speed: isDevil ? DEVIL_SPEED : ZOMBIE_SPEED,
flash: 0,
lastDamaged: -999,
_prevHp: isDevil ? DEVIL_HP : ZOMBIE_HP,
fireCooldown: isDevil ? 0.4 + Math.random() * 0.6 : 0,
color: isDevil ? "#c62828" : "#8a8a8a",
telegraph: 0,
alive: true
};
}
function nearestPlayer(ex, ey) {
var best = null;
var bestD = Infinity;
var i, p, dx, dy, d;
for (i = 0; i < players.length; i++) {
p = players[i];
if (!p.alive) continue;
dx = p.x - ex;
dy = p.y - ey;
d = dx * dx + dy * dy;
if (d < bestD) {
bestD = d;
best = p;
}
}
return best;
}
function hurtPlayer(p, amount) {
if (!p.alive || p.iFrames > 0) return;
p.hp -= amount;
p.lastHurt = simTime;
p.iFrames = IFRAME_TIME;
if (Bxh.fx) Bxh.fx.hurtFlash();
if (p.hp <= 0) {
p.hp = 0;
p.alive = false;
p.downX = p.x;
p.downY = p.y;
if (Bxh.fx) {
Bxh.fx.addBlood(p.x, p.y);
Bxh.fx.shake(6);
}
if (Bxh.audio) Bxh.audio.beep("hurt");
if (Bxh.ui && Bxh.ui.toast) Bxh.ui.toast("P" + p.id + " DOWN");
}
}
function updatePlayers(dt) {
if (!Bxh.input) return;
var i, p, inp, dx, dy, moved, len;
for (i = 0; i < players.length; i++) {
p = players[i];
if (!p.alive) continue;
if (p.id === 2 && !p2Joined) continue;
inp = Bxh.input.getPlayer(p.id);
dx = inp.moveX * p.speed * dt;
dy = inp.moveY * p.speed * dt;
if (inp.moveX !== 0 || inp.moveY !== 0) {
len = Math.sqrt(inp.moveX * inp.moveX + inp.moveY * inp.moveY) || 1;
p.facingX = inp.moveX / len;
p.facingY = inp.moveY / len;
}
if (Bxh.arena && (dx !== 0 || dy !== 0)) {
moved = Bxh.arena.resolveMove(p.x, p.y, p.r, dx, dy);
p.x = moved.x;
p.y = moved.y;
} else {
p.x += dx;
p.y += dy;
}
/* fireCooldown decremented in Bxh.combat.processWeaponInput */
if (p.iFrames > 0) p.iFrames -= dt;
if (simTime - p.lastHurt >= REGEN_DELAY && p.hp < p.maxHp) {
p.hp = Math.min(p.maxHp, p.hp + REGEN_RATE * dt);
}
}
}
function separateEnemies(e, dt) {
var j, o, dx, dy, d, push;
for (j = 0; j < enemies.length; j++) {
o = enemies[j];
if (o === e || !o.alive) continue;
dx = e.x - o.x;
dy = e.y - o.y;
d = Math.sqrt(dx * dx + dy * dy);
if (d > 0.001 && d < SEPARATION) {
push = ((SEPARATION - d) / SEPARATION) * 40 * dt;
e.x += (dx / d) * push;
e.y += (dy / d) * push;
}
}
}
function updateEnemies(dt) {
var i, e, target, dx, dy, dist, moved, spd;
for (i = 0; i < enemies.length; i++) {
e = enemies[i];
if (!e.alive) continue;
// Track damage from combat (hp drops) for Devil suppression
if (e._prevHp == null) e._prevHp = e.hp;
if (e.hp < e._prevHp) {
e.lastDamaged = simTime;
e.flash = 0.12;
}
e._prevHp = e.hp;
target = nearestPlayer(e.x, e.y);
if (target) {
dx = target.x - e.x;
dy = target.y - e.y;
dist = Math.sqrt(dx * dx + dy * dy) || 1;
e._faceX = dx / dist;
e._faceY = dy / dist;
}
if (e.telegraph > 0) {
e.telegraph -= dt;
continue;
}
if (e.flash > 0) e.flash -= dt;
if (e.fireCooldown > 0) e.fireCooldown -= dt;
if (target) {
dx = target.x - e.x;
dy = target.y - e.y;
dist = Math.sqrt(dx * dx + dy * dy) || 1;
spd = e.speed * dt;
dx = (dx / dist) * spd;
dy = (dy / dist) * spd;
if (Bxh.arena) {
moved = Bxh.arena.resolveMove(e.x, e.y, e.r, dx, dy);
e.x = moved.x;
e.y = moved.y;
} else {
e.x += dx;
e.y += dy;
}
}
separateEnemies(e, dt);
// Contact damage — shoves the player clear so they are never pinned
if (target) {
dx = target.x - e.x;
dy = target.y - e.y;
dist = Math.sqrt(dx * dx + dy * dy);
if (dist < e.r + target.r) {
if (target.iFrames <= 0) {
var kx = (dx / (dist || 1)) * CONTACT_KNOCK;
var ky = (dy / (dist || 1)) * CONTACT_KNOCK;
if (Bxh.arena) {
moved = Bxh.arena.resolveMove(target.x, target.y, target.r, kx, ky);
target.x = moved.x;
target.y = moved.y;
}
}
hurtPlayer(target, CONTACT_DAMAGE);
}
}
// Devil fireball when not suppressed
if (e.type === "devil" && target && e.fireCooldown <= 0) {
if (simTime - e.lastDamaged > DEVIL_SUPPRESS) {
dx = target.x - e.x;
dy = target.y - e.y;
dist = Math.sqrt(dx * dx + dy * dy) || 1;
fireballs.push({
id: nextFireballId++,
x: e.x,
y: e.y,
vx: (dx / dist) * FIREBALL_SPEED,
vy: (dy / dist) * FIREBALL_SPEED,
r: FIREBALL_R,
damage: 18,
alive: true,
ownerId: e.id
});
e.fireCooldown = DEVIL_FIRE_CD;
}
}
}
// Cull dead in place — other modules hold a live reference to this array
var w = 0;
for (i = 0; i < enemies.length; i++) {
if (enemies[i].alive && enemies[i].hp > 0) enemies[w++] = enemies[i];
}
enemies.length = w;
}
/** Movement and culling only; Bxh.combat owns fireball-vs-player hits. */
function updateFireballs(dt) {
var i, f, nx, ny;
for (i = fireballs.length - 1; i >= 0; i--) {
f = fireballs[i];
if (!f.alive) {
fireballs.splice(i, 1);
continue;
}
nx = f.x + f.vx * dt;
ny = f.y + f.vy * dt;
if (Bxh.arena && Bxh.arena.segmentHitsSolid(f.x, f.y, nx, ny)) {
fireballs.splice(i, 1);
continue;
}
f.x = nx;
f.y = ny;
if (f.x < -20 || f.y < -20 || f.x > 980 || f.y > 560) {
fireballs.splice(i, 1);
}
}
}
function updateCrates(dt) {
var i, c, j, p, dx, dy;
var CRATE_RESPAWN = 12;
for (i = 0; i < crates.length; i++) {
c = crates[i];
if (c.taken) {
c.respawnT = (c.respawnT || 0) + (dt || 0);
if (c.respawnT >= CRATE_RESPAWN) {
c.taken = false;
c.respawnT = 0;
}
continue;
}
for (j = 0; j < players.length; j++) {
p = players[j];
if (!p.alive) continue;
if (p.id === 2 && !p2Joined) continue;
dx = p.x - c.x;
dy = p.y - c.y;
if (dx * dx + dy * dy < (p.r + (c.r || 14)) * (p.r + (c.r || 14))) {
c.taken = true;
c.respawnT = 0;
if (Bxh.combat && typeof Bxh.combat.applyCrate === "function") {
Bxh.combat.applyCrate(p, c);
}
break;
}
}
}
}
function updateSpawnFlashes(dt) {
var i;
for (i = spawnFlashes.length - 1; i >= 0; i--) {
spawnFlashes[i].t -= dt;
if (spawnFlashes[i].t <= 0) spawnFlashes.splice(i, 1);
}
}
Bxh.entities = {
reset: function (gameMode) {
mode = gameMode === "coop" ? "coop" : "solo";
p2Joined = mode === "coop";
players = [];
enemies = [];
bullets = [];
fireballs = [];
crates = [];
placedBarrels = [];
grenades = [];
rockets = [];
spawnFlashes = [];
nextEnemyId = 1;
nextFireballId = 1;
simTime = 0;
},
spawnPlayers: function (gameMode, room) {
mode = gameMode === "coop" ? "coop" : "solo";
p2Joined = mode === "coop";
room = room || (Bxh.arena && Bxh.arena.getRoom()) || null;
var spawns = (room && room.playerSpawns) || [
{ x: 420, y: 270 },
{ x: 540, y: 270 }
];
var s1 = spawns[0] || { x: 420, y: 270 };
var s2 = spawns[1] || { x: 540, y: 270 };
players = [makePlayer(1, s1.x, s1.y, "p1")];
if (mode === "coop") {
players.push(makePlayer(2, s2.x, s2.y, "p2"));
players[1].joined = true;
}
},
joinP2: function () {
if (p2Joined) return;
p2Joined = true;
mode = "coop";
var room = Bxh.arena && Bxh.arena.getRoom();
var spawns = (room && room.playerSpawns) || [];
var s2 = spawns[1] || { x: 540, y: 270 };
var existing = null;
var i;
for (i = 0; i < players.length; i++) {
if (players[i].id === 2) existing = players[i];
}
if (existing) {
existing.alive = true;
existing.hp = existing.maxHp;
existing.x = s2.x;
existing.y = s2.y;
existing.joined = true;
} else {
var p = makePlayer(2, s2.x, s2.y, "p2");
p.joined = true;
players.push(p);
}
if (Bxh.ui && typeof Bxh.ui.toast === "function") {
Bxh.ui.toast("P2 joined");
}
},
isP2Joined: function () {
return p2Joined;
},
IFRAME_TIME: IFRAME_TIME,
ZOMBIE_SPEED: ZOMBIE_SPEED,
ZOMBIE_HP: ZOMBIE_HP,
DEVIL_SPEED: DEVIL_SPEED,
DEVIL_HP: DEVIL_HP,
/**
* Co-op rescue: bring downed players back at partial health at wave start.
* No-op when nobody is left standing, so a wipe still ends the run.
*/
reviveDowned: function (hpFraction) {
var frac = hpFraction != null ? hpFraction : 0.5;
var revived = [];
var i, p, room, spawns, s;
if (!livingPlayers().length) return revived;
room = Bxh.arena && Bxh.arena.getRoom();
spawns = (room && room.playerSpawns) || [];
for (i = 0; i < players.length; i++) {
p = players[i];
if (p.alive) continue;
if (p.id === 2 && !p2Joined) continue;
s = spawns[p.id - 1];
if (s) {
p.x = s.x;
p.y = s.y;
}
p.alive = true;
p.hp = Math.max(1, Math.round(p.maxHp * frac));
p.iFrames = 2;
p.lastHurt = simTime;
revived.push(p.id);
}
return revived;
},
getPlayers: function () {
return players;
},
getEnemies: function () {
return enemies;
},
getBullets: function () {
return bullets;
},
getFireballs: function () {
return fireballs;
},
getCrates: function () {
return crates;
},
getPlacedBarrels: function () {
return placedBarrels;
},
getGrenades: function () {
return grenades;
},
getRockets: function () {
return rockets;
},
getSpawnFlashes: function () {
return spawnFlashes;
},
addEnemy: function (e) {
if (!e) return null;
if (enemies.length >= ENEMY_SOFT_CAP) return null;
if (e.id == null) e.id = nextEnemyId++;
if (e.alive == null) e.alive = true;
enemies.push(e);
return e;
},
spawnEnemy: function (type, x, y, telegraph) {
if (enemies.length >= ENEMY_SOFT_CAP) return null;
var e = makeEnemy(type, x, y);
e.telegraph = telegraph != null ? telegraph : 0.45;
enemies.push(e);
spawnFlashes.push({ x: x, y: y, t: e.telegraph, type: e.type });
return e;
},
spawnCrateFromRoom: function (room) {
room = room || (Bxh.arena && Bxh.arena.getRoom());
crates = [];
if (!room || !room.crates) return crates;
var i, c;
for (i = 0; i < room.crates.length; i++) {
c = room.crates[i];
crates.push({
x: c.x,
y: c.y,
r: 14,
taken: false,
kind: "ammo"
});
}
return crates;
},
canSpawnMore: function () {
return enemies.length < ENEMY_SOFT_CAP;
},
getEnemySoftCap: function () {
return ENEMY_SOFT_CAP;
},
livingEnemyCount: function () {
var n = 0;
var i;
for (i = 0; i < enemies.length; i++) {
if (enemies[i].alive && enemies[i].hp > 0) n++;
}
return n;
},
update: function (dt) {
dt = dt || 0;
simTime += dt;
updatePlayers(dt);
updateEnemies(dt);
updateFireballs(dt);
updateCrates(dt);
updateSpawnFlashes(dt);
},
getSimTime: function () {
return simTime;
}
};
})();
/* ===== waves.js ===== */
/* Bxh.waves — breather → spawn wave N → active until clear. */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var BREATHER = 2.5;
var TELEGRAPH = 0.45;
var ENEMY_CAP = 80;
var SPAWN_STAGGER = 0.08;
var wave = 0;
var phase = "breather";
var timer = BREATHER;
var spawnQueue = [];
var spawnAcc = 0;
var waveStartCbs = [];
var announcedWave = 0;
function livingEnemies() {
if (Bxh.entities && typeof Bxh.entities.livingEnemyCount === "function") {
return Bxh.entities.livingEnemyCount();
}
if (!Bxh.entities) return 0;
var list = Bxh.entities.getEnemies() || [];
var n = 0;
var i;
for (i = 0; i < list.length; i++) {
if (list[i].alive !== false && list[i].hp > 0) n++;
}
return n;
}
function zombieCountFor(n) {
return 6 + n * 3;
}
function devilCountFor(n) {
if (n < 3) return 0;
// Formula Math.floor((N-2)/2); ensure at least 1 from wave 3, soft ~12
return Math.min(12, Math.max(1, Math.floor((n - 2) / 2)));
}
function pickSpawnPoint(arena) {
var spawns = (arena && arena.getSpawns && arena.getSpawns()) || [];
if (!spawns.length && Bxh.arena) spawns = Bxh.arena.getSpawns() || [];
if (!spawns.length) {
return { x: 480, y: 40 };
}
return spawns[Math.floor(Math.random() * spawns.length)];
}
function buildQueue(n, arena) {
var q = [];
var z = zombieCountFor(n);
var d = devilCountFor(n);
var i, pt;
for (i = 0; i < z; i++) {
pt = pickSpawnPoint(arena);
q.push({ type: "zombie", x: pt.x, y: pt.y });
}
for (i = 0; i < d; i++) {
pt = pickSpawnPoint(arena);
q.push({ type: "devil", x: pt.x, y: pt.y });
}
// Shuffle lightly
for (i = q.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = q[i];
q[i] = q[j];
q[j] = tmp;
}
return q;
}
function beginWave(arena) {
wave += 1;
phase = "spawning";
spawnQueue = buildQueue(wave, arena);
spawnAcc = 0;
announcedWave = wave;
if (Bxh.ui && typeof Bxh.ui.toast === "function") {
Bxh.ui.toast("WAVE " + wave);
}
if (Bxh.ui && typeof Bxh.ui.announce === "function") {
Bxh.ui.announce("Wave " + wave);
}
if (Bxh.combat && typeof Bxh.combat.waveRestock === "function") {
Bxh.combat.waveRestock();
}
var i;
for (i = 0; i < waveStartCbs.length; i++) {
try {
waveStartCbs[i](wave);
} catch (err) {}
}
}
function spawnOne(entry) {
if (!Bxh.entities) return false;
if (livingEnemies() >= ENEMY_CAP) return false;
if (typeof Bxh.entities.spawnEnemy === "function") {
return !!Bxh.entities.spawnEnemy(entry.type, entry.x, entry.y, TELEGRAPH);
}
if (typeof Bxh.entities.addEnemy === "function") {
var isDevil = entry.type === "devil";
var hp = isDevil ? Bxh.entities.DEVIL_HP : Bxh.entities.ZOMBIE_HP;
return !!Bxh.entities.addEnemy({
type: entry.type,
x: entry.x,
y: entry.y,
r: isDevil ? 14 : 13,
hp: hp,
maxHp: hp,
speed: isDevil ? Bxh.entities.DEVIL_SPEED : Bxh.entities.ZOMBIE_SPEED,
flash: 0,
lastDamaged: -999,
fireCooldown: entry.type === "devil" ? 0.5 : 0,
color: entry.type === "devil" ? "#c62828" : "#8a8a8a",
telegraph: TELEGRAPH,
alive: true
});
}
return false;
}
Bxh.waves = {
reset: function () {
wave = 0;
phase = "breather";
timer = BREATHER;
spawnQueue = [];
spawnAcc = 0;
announcedWave = 0;
},
update: function (dt, entities, arena) {
arena = arena || Bxh.arena;
entities = entities || Bxh.entities;
dt = Math.min(dt || 0, 0.05);
if (phase === "breather") {
timer -= dt;
if (timer <= 0) {
beginWave(arena);
}
return;
}
if (phase === "spawning") {
spawnAcc += dt;
while (spawnQueue.length && spawnAcc >= SPAWN_STAGGER) {
if (livingEnemies() >= ENEMY_CAP) {
spawnQueue.length = 0;
break;
}
spawnAcc -= SPAWN_STAGGER;
spawnOne(spawnQueue.shift());
}
if (!spawnQueue.length) {
phase = livingEnemies() > 0 ? "active" : "breather";
if (phase === "breather") timer = BREATHER;
}
return;
}
// active while fighting (contracts alias 'clear' unused; empty field → breather)
if (phase === "active" || phase === "clear") {
if (livingEnemies() <= 0 && spawnQueue.length === 0) {
phase = "breather";
timer = BREATHER;
}
}
},
getWave: function () {
return wave;
},
getEnemyCount: function () {
return livingEnemies();
},
getPhase: function () {
return phase;
},
/** Seconds until the next wave spawns, or 0 while a wave is in progress. */
getBreatherRemaining: function () {
return phase === "breather" ? Math.max(0, timer) : 0;
},
onWaveStart: function (cb) {
if (typeof cb === "function") waveStartCbs.push(cb);
},
/** Pending spawns still in queue (for HUD/debug). */
getPendingSpawns: function () {
return spawnQueue.length;
}
};
})();
/* ===== multiplier.js ===== */
/* Bxh.multiplier — shared score + combo unlock ladder */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var UNLOCKS = [
{ id: "pistol", at: 1, label: "Pistol" },
{ id: "uzi", at: 5, label: "Uzi" },
{ id: "shotgun", at: 10, label: "Shotgun" },
{ id: "barrel", at: 15, label: "Barrel" },
{ id: "grenade", at: 20, label: "Grenades" },
{ id: "rapidUzi", at: 25, label: "Rapid Uzi" },
{ id: "rocket", at: 40, label: "Rocket" }
];
var value = 1;
var decayT = 0;
var decayMax = 2.2;
var score = 0;
var bestCombo = 1;
var unlocks = { pistol: true };
function calcDecayMax(v) {
return Math.max(0.55, 2.2 - v * 0.04);
}
function checkUnlocks() {
var neu = [];
for (var i = 0; i < UNLOCKS.length; i++) {
var u = UNLOCKS[i];
if (value >= u.at && !unlocks[u.id]) {
unlocks[u.id] = true;
neu.push(u.id);
}
}
return neu;
}
Bxh.multiplier = {
UNLOCKS: UNLOCKS,
reset: function () {
value = 1;
decayMax = calcDecayMax(value);
decayT = decayMax;
score = 0;
bestCombo = 1;
unlocks = { pistol: true };
},
update: function (dt) {
if (value <= 1) {
decayT = decayMax;
return;
}
decayT -= dt;
if (decayT <= 0) {
value = Math.max(1, value - 1);
decayMax = calcDecayMax(value);
decayT = decayMax;
}
},
onKill: function (baseScore) {
baseScore = baseScore || 100;
var pointsAdded = Math.floor(baseScore * value);
score += pointsAdded;
value += 1;
if (value > bestCombo) bestCombo = value;
decayMax = calcDecayMax(value);
decayT = decayMax;
var newUnlocks = checkUnlocks();
return { pointsAdded: pointsAdded, multiplier: value, newUnlocks: newUnlocks };
},
get: function () {
return {
value: value,
decayT: decayT,
decayMax: decayMax,
score: score,
bestCombo: bestCombo,
unlocks: unlocks
};
},
isUnlocked: function (id) {
return !!unlocks[id];
}
};
})();
/* ===== combat.js ===== */
/* Bxh.combat — weapons, projectiles, damage, crates */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var WEAPON_ORDER = ["pistol", "uzi", "shotgun", "barrel", "grenade", "rocket"];
var WEAPONS = {
pistol: {
label: "PISTOL",
cooldown: 0.28,
damage: 18,
speed: 520,
life: 0.9,
r: 3,
infinite: true,
mag: 0
},
uzi: {
label: "UZI",
cooldown: 0.09,
rapidCooldown: 0.055,
damage: 10,
speed: 560,
life: 0.75,
r: 2.5,
mag: 80,
restock: 40
},
shotgun: {
label: "SHOTGUN",
cooldown: 0.55,
damage: 12,
speed: 480,
life: 0.35,
r: 2.5,
pellets: 6,
spread: 0.38,
mag: 24,
restock: 8
},
barrel: {
label: "BARREL",
cooldown: 0.45,
mag: 6,
restock: 2
},
grenade: {
label: "GRENADE",
cooldown: 0.7,
speed: 220,
fuse: 1.1,
radius: 70,
damage: 55,
mag: 10,
restock: 3
},
rocket: {
label: "ROCKET",
cooldown: 1.1,
speed: 260,
life: 2.2,
r: 5,
radius: 95,
damage: 90,
mag: 6,
restock: 2
}
};
var BARREL_HP = 1;
var BARREL_RADIUS = 78;
var BARREL_DAMAGE = 70;
var FIREBALL_DAMAGE = 18;
var SHOOT_SFX_INTERVAL = 0.075;
var prevEdge = {};
var dryFireT = 0;
var shootSfxT = 0;
function mul() {
return Bxh.multiplier;
}
function fx() {
return Bxh.fx;
}
function audio() {
return Bxh.audio;
}
function ent() {
return Bxh.entities;
}
function arena() {
return Bxh.arena;
}
function ensureAmmo(player) {
if (!player.ammo || typeof player.ammo !== "object") {
player.ammo = {};
}
var id;
for (id in WEAPONS) {
if (!WEAPONS.hasOwnProperty(id)) continue;
var w = WEAPONS[id];
if (w.infinite) continue;
if (typeof player.ammo[id] !== "number") {
player.ammo[id] = w.mag || 0;
}
}
}
function getAmmo(player, weaponId) {
ensureAmmo(player);
var w = WEAPONS[weaponId];
if (!w) return 0;
if (w.infinite) return Infinity;
return player.ammo[weaponId] || 0;
}
function spendAmmo(player, weaponId, n) {
var w = WEAPONS[weaponId];
if (!w || w.infinite) return true;
ensureAmmo(player);
if ((player.ammo[weaponId] || 0) < n) return false;
player.ammo[weaponId] -= n;
return true;
}
function facingAngle(player) {
var fx0 = player.facingX || 0;
var fy0 = player.facingY || 0;
if (fx0 === 0 && fy0 === 0) return -Math.PI / 2;
return Math.atan2(fy0, fx0);
}
function normFacing(player) {
var fx0 = player.facingX || 0;
var fy0 = player.facingY || 0;
var len = Math.sqrt(fx0 * fx0 + fy0 * fy0);
if (len < 0.01) return { x: 0, y: -1 };
return { x: fx0 / len, y: fy0 / len };
}
function dist2(ax, ay, bx, by) {
var dx = ax - bx;
var dy = ay - by;
return dx * dx + dy * dy;
}
function circleHit(ax, ay, ar, bx, by, br) {
var rr = ar + br;
return dist2(ax, ay, bx, by) <= rr * rr;
}
function pushBullet(b) {
var list = ent().getBullets();
list.push(b);
}
/** Rate-limited so the rapid-fire uzi does not machine-gun the Web Audio graph. */
function shootBeep() {
if (shootSfxT > 0) return;
shootSfxT = SHOOT_SFX_INTERVAL;
if (audio()) audio().beep("shoot");
}
function pushGrenade(g) {
ent().getGrenades().push(g);
}
function pushRocket(r) {
ent().getRockets().push(r);
}
function pushBarrel(b) {
ent().getPlacedBarrels().push(b);
}
function weaponCooldown(weaponId) {
var w = WEAPONS[weaponId];
if (!w) return 0.3;
if (weaponId === "uzi" && mul() && mul().isUnlocked("rapidUzi")) {
return w.rapidCooldown;
}
return w.cooldown;
}
function toastUnlock(id) {
var label = id.toUpperCase();
for (var i = 0; i < (mul().UNLOCKS || []).length; i++) {
if (mul().UNLOCKS[i].id === id) {
label = mul().UNLOCKS[i].label.toUpperCase();
break;
}
}
if (Bxh.ui && Bxh.ui.toast) Bxh.ui.toast(label + " UNLOCKED!");
if (audio()) audio().beep("unlock");
}
function handleKillResult(res, x, y) {
if (!res) return;
if (fx()) {
fx().addBlood(x, y);
if (!reducedMotion()) fx().addBlood(x + 4, y - 2);
fx().addFloater(x, y - 8, "+" + res.pointsAdded);
if (res.multiplier >= 10) fx().shake(3);
else fx().shake(1.5);
}
if (audio()) audio().beep("kill");
if (res.newUnlocks && res.newUnlocks.length) {
for (var i = 0; i < res.newUnlocks.length; i++) {
toastUnlock(res.newUnlocks[i]);
}
}
}
function reducedMotion() {
return !!(Bxh.state && Bxh.state.reducedMotion);
}
function nowSec() {
return typeof performance !== "undefined" && performance.now
? performance.now() / 1000
: Date.now() / 1000;
}
function killEnemy(enemy, list, index) {
var base = enemy.type === "devil" ? 300 : 100;
var res = mul() ? mul().onKill(base) : null;
handleKillResult(res, enemy.x, enemy.y);
enemy.alive = false;
enemy.hp = 0;
list.splice(index, 1);
}
function damageEnemy(enemy, dmg, list, index) {
if (!enemy || enemy.alive === false) return true;
enemy.hp -= dmg;
enemy.flash = 0.08;
/* entities.update tracks lastDamaged via hp drop + simTime */
if (enemy.hp <= 0) {
killEnemy(enemy, list, index);
return true;
}
return false;
}
function knockPlayer(player, knockX, knockY) {
if (!player || !player.alive) return;
if (knockX || knockY) {
player.x += knockX || 0;
player.y += knockY || 0;
if (arena() && arena().resolveMove) {
var r = arena().resolveMove(player.x, player.y, player.r || 12, 0, 0);
player.x = r.x;
player.y = r.y;
}
}
}
function hurtPlayer(player, dmg, knockX, knockY) {
if (!player || !player.alive) return;
if (player.iFrames && player.iFrames > 0) return;
if (dmg <= 0) {
knockPlayer(player, knockX, knockY);
return;
}
player.hp -= dmg;
player.lastHurt =
ent() && typeof ent().getSimTime === "function" ? ent().getSimTime() : nowSec();
player.iFrames = (ent() && ent().IFRAME_TIME) || 0.55;
knockPlayer(player, knockX, knockY);
if (fx()) {
fx().shake(2.5);
fx().hurtFlash();
}
if (audio()) audio().beep("hurt");
if (player.hp <= 0) {
player.hp = 0;
player.alive = false;
player.downX = player.x;
player.downY = player.y;
if (fx()) {
fx().addBlood(player.x, player.y);
fx().shake(6);
}
if (Bxh.ui && Bxh.ui.toast) Bxh.ui.toast("P" + player.id + " DOWN");
}
}
function explode(x, y, radius, damage, opts) {
opts = opts || {};
var enemies = ent().getEnemies();
var i;
for (i = enemies.length - 1; i >= 0; i--) {
var e = enemies[i];
var d = Math.sqrt(dist2(x, y, e.x, e.y));
if (d <= radius + (e.r || 10)) {
var falloff = 1 - d / (radius + 1);
var dmg = damage * (0.45 + 0.55 * falloff);
damageEnemy(e, dmg, enemies, i);
}
}
var barrels = ent().getPlacedBarrels();
for (i = barrels.length - 1; i >= 0; i--) {
var b = barrels[i];
if (b._exploding) continue;
if (Math.sqrt(dist2(x, y, b.x, b.y)) <= radius * 0.85) {
detonateBarrel(b, i);
}
}
var players = ent().getPlayers();
for (i = 0; i < players.length; i++) {
var p = players[i];
if (!p.alive) continue;
var pd = Math.sqrt(dist2(x, y, p.x, p.y));
if (pd > radius + (p.r || 12)) continue;
var nx = (p.x - x) / (pd || 1);
var ny = (p.y - y) / (pd || 1);
if (opts.knockOnly) {
knockPlayer(p, nx * 16, ny * 16);
continue;
}
var selfDmg = opts.ownerId === p.id ? (opts.selfDamage || damage * 0.55) : (opts.ffDamage || damage * 0.2);
if (opts.noPlayerDamage) {
/* knock only */
p.x += nx * 12;
p.y += ny * 12;
} else if (selfDmg > 0) {
hurtPlayer(p, selfDmg, nx * 14, ny * 14);
}
}
if (fx()) {
fx().addBlood(x, y);
fx().shake(opts.shake || 5);
fx().addFloater(x, y, opts.floater || "BOOM");
}
}
function detonateBarrel(barrel, index) {
var barrels = ent().getPlacedBarrels();
if (barrel._exploding) return;
barrel._exploding = true;
var x = barrel.x;
var y = barrel.y;
if (typeof index === "number") barrels.splice(index, 1);
else {
var idx = barrels.indexOf(barrel);
if (idx >= 0) barrels.splice(idx, 1);
}
explode(x, y, BARREL_RADIUS, BARREL_DAMAGE, {
knockOnly: false,
selfDamage: 12,
ffDamage: 8,
ownerId: barrel.ownerId,
shake: 6,
floater: "BOOM"
});
}
function spawnBullet(player, angle, wcfg) {
var face = normFacing(player);
var ox = player.x + face.x * ((player.r || 12) + 4);
var oy = player.y + face.y * ((player.r || 12) + 4);
var cos = Math.cos(angle);
var sin = Math.sin(angle);
pushBullet({
x: ox,
y: oy,
vx: cos * wcfg.speed,
vy: sin * wcfg.speed,
r: wcfg.r,
damage: wcfg.damage,
ownerId: player.id,
life: wcfg.life
});
if (fx()) fx().addMuzzle(ox, oy, angle);
}
function tryFire(player) {
if (!player || !player.alive) return false;
if ((player.fireCooldown || 0) > 0) return false;
var weaponId = player.weaponId || "pistol";
if (mul() && !mul().isUnlocked(weaponId)) {
weaponId = "pistol";
player.weaponId = "pistol";
}
var w = WEAPONS[weaponId];
if (!w) return false;
var ang = facingAngle(player);
if (weaponId === "barrel") {
if (!spendAmmo(player, "barrel", 1)) return false;
var f = normFacing(player);
var bx = player.x + f.x * 28;
var by = player.y + f.y * 28;
if (arena() && arena().resolveMove) {
var pr = arena().resolveMove(bx, by, 10, 0, 0);
bx = pr.x;
by = pr.y;
}
pushBarrel({
x: bx,
y: by,
r: 10,
hp: BARREL_HP,
ownerId: player.id
});
player.fireCooldown = w.cooldown;
if (audio()) audio().beep("ui");
return true;
}
if (weaponId === "grenade") {
if (!spendAmmo(player, "grenade", 1)) return false;
var gf = normFacing(player);
pushGrenade({
x: player.x + gf.x * 16,
y: player.y + gf.y * 16,
vx: gf.x * w.speed,
vy: gf.y * w.speed,
r: 6,
fuse: w.fuse,
ownerId: player.id,
damage: w.damage,
radius: w.radius
});
player.fireCooldown = w.cooldown;
if (fx()) fx().addMuzzle(player.x, player.y, ang);
if (audio()) audio().beep("shoot");
return true;
}
if (weaponId === "rocket") {
if (!spendAmmo(player, "rocket", 1)) return false;
var rf = normFacing(player);
pushRocket({
x: player.x + rf.x * 18,
y: player.y + rf.y * 18,
vx: rf.x * w.speed,
vy: rf.y * w.speed,
r: w.r,
life: w.life,
ownerId: player.id,
damage: w.damage,
radius: w.radius
});
player.fireCooldown = w.cooldown;
if (fx()) fx().addMuzzle(player.x + rf.x * 18, player.y + rf.y * 18, ang);
if (audio()) audio().beep("shoot");
return true;
}
if (weaponId === "shotgun") {
if (!spendAmmo(player, "shotgun", 1)) return false;
var count = 5 + ((Math.random() * 3) | 0); /* 5-7 */
var base = ang;
var spread = w.spread;
for (var i = 0; i < count; i++) {
var t = count === 1 ? 0 : i / (count - 1) - 0.5;
spawnBullet(player, base + t * spread * 2, w);
}
player.fireCooldown = w.cooldown;
if (audio()) audio().beep("shoot");
return true;
}
/* pistol / uzi */
if (!w.infinite && !spendAmmo(player, weaponId, 1)) return false;
spawnBullet(player, ang, w);
player.fireCooldown = weaponCooldown(weaponId);
shootBeep();
return true;
}
/** Direct slot pick (number keys). Falls back to a toast when still locked. */
function selectWeaponSlot(player, slot) {
if (!player) return false;
var id = WEAPON_ORDER[slot];
if (!id) return false;
ensureAmmo(player);
if (mul() && !mul().isUnlocked(id)) {
if (Bxh.ui && Bxh.ui.toast) {
Bxh.ui.toast((WEAPONS[id] ? WEAPONS[id].label : id.toUpperCase()) + " LOCKED");
}
return false;
}
if (player.weaponId === id) return true;
player.weaponId = id;
if (Bxh.ui && Bxh.ui.toast) {
Bxh.ui.toast((WEAPONS[id] ? WEAPONS[id].label : id.toUpperCase()) + " selected");
}
if (audio()) audio().beep("ui");
return true;
}
function cycleWeapon(player, dir) {
if (!player) return;
ensureAmmo(player);
var order = WEAPON_ORDER;
var cur = player.weaponId || "pistol";
var idx = order.indexOf(cur);
if (idx < 0) idx = 0;
var steps = 0;
do {
idx = (idx + dir + order.length) % order.length;
steps++;
var id = order[idx];
if (!mul() || mul().isUnlocked(id)) {
player.weaponId = id;
if (Bxh.ui && Bxh.ui.toast) {
var lab = (WEAPONS[id] && WEAPONS[id].label) || id.toUpperCase();
Bxh.ui.toast(lab + " selected");
}
if (audio()) audio().beep("ui");
return;
}
} while (steps < order.length);
}
function applyCrate(player, crate) {
if (!player || !player.alive) return;
ensureAmmo(player);
var id;
for (id in WEAPONS) {
if (!WEAPONS.hasOwnProperty(id)) continue;
var w = WEAPONS[id];
if (w.infinite) continue;
if (mul() && !mul().isUnlocked(id)) continue;
var cur = player.ammo[id] || 0;
var add = Math.max(2, Math.floor((w.mag || 10) * 0.35));
player.ammo[id] = Math.min(w.mag, cur + add);
}
if (player.hp < player.maxHp) {
player.hp = Math.min(player.maxHp, player.hp + 25);
}
if (crate) crate.taken = true;
if (audio()) audio().beep("ui");
if (fx()) fx().addFloater(player.x, player.y - 16, "AMMO");
}
function waveRestock() {
var players = ent() ? ent().getPlayers() : [];
for (var p = 0; p < players.length; p++) {
var player = players[p];
if (!player.alive) continue;
ensureAmmo(player);
for (var id in WEAPONS) {
if (!WEAPONS.hasOwnProperty(id)) continue;
var w = WEAPONS[id];
if (w.infinite) continue;
if (mul() && !mul().isUnlocked(id)) continue;
var add = w.restock || Math.floor((w.mag || 10) * 0.25);
player.ammo[id] = Math.min(w.mag, (player.ammo[id] || 0) + add);
}
}
}
function processWeaponInput(dt) {
if (!Bxh.input || !ent()) return;
var players = ent().getPlayers();
for (var i = 0; i < players.length; i++) {
var player = players[i];
if (!player.alive) continue;
if (player.fireCooldown > 0) player.fireCooldown -= dt;
var inp = Bxh.input.getPlayer(player.id);
if (!inp) continue;
/* solo: Space also fires P1 */
var fire = !!inp.fire;
if (
player.id === 1 &&
Bxh.state &&
Bxh.state.mode === "solo" &&
Bxh.input.spaceDown
) {
fire = true;
}
var key = player.id;
if (!prevEdge[key]) prevEdge[key] = { prev: false, next: false };
if (inp.prevWeapon && !prevEdge[key].prev) cycleWeapon(player, -1);
if (inp.nextWeapon && !prevEdge[key].next) cycleWeapon(player, 1);
prevEdge[key].prev = !!inp.prevWeapon;
prevEdge[key].next = !!inp.nextWeapon;
/* edge weapons from input may already be one-shot; still fire while held */
if (fire) {
var fired = tryFire(player);
if (!fired && dryFireT <= 0) {
var wid = player.weaponId || "pistol";
var ww = WEAPONS[wid];
var outOfAmmo =
ww &&
!ww.infinite &&
(player.ammo[wid] || 0) < 1;
if ((player.fireCooldown || 0) > 0 || outOfAmmo) {
if (audio()) audio().beep("dryfire");
dryFireT = 0.28;
}
}
}
}
}
function updateBullets(dt) {
var bullets = ent().getBullets();
var enemies = ent().getEnemies();
var barrels = ent().getPlacedBarrels();
var ar = arena();
var i, j;
for (i = bullets.length - 1; i >= 0; i--) {
var b = bullets[i];
var nx = b.x + b.vx * dt;
var ny = b.y + b.vy * dt;
b.life -= dt;
if (ar && ar.segmentHitsSolid && ar.segmentHitsSolid(b.x, b.y, nx, ny)) {
bullets.splice(i, 1);
continue;
}
b.x = nx;
b.y = ny;
if (
b.life <= 0 ||
b.x < -20 ||
b.y < -20 ||
b.x > (ar ? ar.WIDTH : 960) + 20 ||
b.y > (ar ? ar.HEIGHT : 540) + 20
) {
bullets.splice(i, 1);
continue;
}
var hit = false;
for (j = barrels.length - 1; j >= 0; j--) {
var bar = barrels[j];
if (circleHit(b.x, b.y, b.r, bar.x, bar.y, bar.r || 10)) {
detonateBarrel(bar, j);
bullets.splice(i, 1);
hit = true;
break;
}
}
if (hit) continue;
for (j = enemies.length - 1; j >= 0; j--) {
var e = enemies[j];
if (e.alive === false || e.hp <= 0) continue;
if (circleHit(b.x, b.y, b.r, e.x, e.y, e.r || 10)) {
damageEnemy(e, b.damage, enemies, j);
bullets.splice(i, 1);
hit = true;
break;
}
}
}
}
/* Entities moves/culls fireballs; combat owns the hit so damage gets full feedback. */
function updateFireballs() {
var balls = ent().getFireballs();
var players = ent().getPlayers();
var i, j;
for (i = balls.length - 1; i >= 0; i--) {
var f = balls[i];
if (f.alive === false) continue;
for (j = 0; j < players.length; j++) {
var p = players[j];
if (!p.alive) continue;
if (circleHit(f.x, f.y, f.r || 6, p.x, p.y, p.r || 12)) {
var d = Math.sqrt(dist2(f.x, f.y, p.x, p.y)) || 1;
hurtPlayer(p, f.damage || FIREBALL_DAMAGE, ((p.x - f.x) / d) * 10, ((p.y - f.y) / d) * 10);
f.alive = false;
balls.splice(i, 1);
break;
}
}
}
}
function updateGrenades(dt) {
var list = ent().getGrenades();
var ar = arena();
for (var i = list.length - 1; i >= 0; i--) {
var g = list[i];
var nx = g.x + g.vx * dt;
var ny = g.y + g.vy * dt;
/* soft drag */
g.vx *= 1 - 1.2 * dt;
g.vy *= 1 - 1.2 * dt;
if (ar && ar.segmentHitsSolid && ar.segmentHitsSolid(g.x, g.y, nx, ny)) {
g.vx *= -0.35;
g.vy *= -0.35;
} else {
g.x = nx;
g.y = ny;
}
if (ar && ar.resolveMove) {
var rr = ar.resolveMove(g.x, g.y, g.r || 6, 0, 0);
g.x = rr.x;
g.y = rr.y;
}
g.fuse -= dt;
if (g.fuse <= 0) {
list.splice(i, 1);
explode(g.x, g.y, g.radius || 70, g.damage || 55, {
knockOnly: true,
ownerId: g.ownerId,
shake: 4,
floater: "BOOM"
});
}
}
}
function updateRockets(dt) {
var list = ent().getRockets();
var enemies = ent().getEnemies();
var barrels = ent().getPlacedBarrels();
var ar = arena();
var i, j;
for (i = list.length - 1; i >= 0; i--) {
var r = list[i];
var nx = r.x + r.vx * dt;
var ny = r.y + r.vy * dt;
r.life -= dt;
var boom = false;
if (ar && ar.segmentHitsSolid && ar.segmentHitsSolid(r.x, r.y, nx, ny)) {
boom = true;
}
r.x = nx;
r.y = ny;
if (!boom) {
for (j = enemies.length - 1; j >= 0; j--) {
if (circleHit(r.x, r.y, r.r, enemies[j].x, enemies[j].y, enemies[j].r || 10)) {
boom = true;
break;
}
}
}
if (!boom) {
for (j = barrels.length - 1; j >= 0; j--) {
if (circleHit(r.x, r.y, r.r, barrels[j].x, barrels[j].y, barrels[j].r || 10)) {
boom = true;
break;
}
}
}
if (r.life <= 0) boom = true;
if (boom) {
list.splice(i, 1);
explode(r.x, r.y, r.radius || 95, r.damage || 90, {
ownerId: r.ownerId,
selfDamage: 40,
ffDamage: 12,
shake: 7,
floater: "BOOM"
});
}
}
}
function updateBarrelTouches() {
var barrels = ent().getPlacedBarrels();
var enemies = ent().getEnemies();
for (var i = barrels.length - 1; i >= 0; i--) {
var b = barrels[i];
for (var j = 0; j < enemies.length; j++) {
var e = enemies[j];
if (circleHit(b.x, b.y, b.r || 10, e.x, e.y, e.r || 10)) {
detonateBarrel(b, i);
break;
}
}
}
}
Bxh.combat = {
WEAPONS: WEAPONS,
WEAPON_ORDER: WEAPON_ORDER,
reset: function () {
prevEdge = {};
dryFireT = 0;
shootSfxT = 0;
},
update: function (dt, world) {
if (!ent()) return;
if (dryFireT > 0) dryFireT -= dt;
if (shootSfxT > 0) shootSfxT -= dt;
processWeaponInput(dt);
updateBullets(dt);
updateFireballs();
updateGrenades(dt);
updateRockets(dt);
updateBarrelTouches();
/* world reserved for future hooks */
void world;
},
tryFire: tryFire,
cycleWeapon: cycleWeapon,
selectWeaponSlot: selectWeaponSlot,
applyCrate: applyCrate,
waveRestock: waveRestock,
getWeaponLabel: function (id) {
return (WEAPONS[id] && WEAPONS[id].label) || String(id || "").toUpperCase();
},
getAmmoDisplay: function (player) {
if (!player) return "-";
var id = player.weaponId || "pistol";
var w = WEAPONS[id];
if (!w) return "-";
if (w.infinite) return "INF";
return String(getAmmo(player, id) | 0);
}
};
})();
/* ===== fx.js ===== */
/* Bxh.fx — blood splatters, floaters, muzzles, screen shake */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var BLOOD_CAP = 120;
var FLOATER_CAP = 40;
var MUZZLE_CAP = 24;
var blood = [];
var floaters = [];
var muzzles = [];
var shakeAmt = 0;
var shakeX = 0;
var shakeY = 0;
var hurtT = 0;
var HURT_TIME = 0.32;
function reduced() {
return !!(Bxh.state && Bxh.state.reducedMotion);
}
function makeSplat(x, y) {
var blobs = [];
var n = reduced() ? 2 : 3 + ((Math.random() * 3) | 0);
var i;
for (i = 0; i < n; i++) {
blobs.push({
dx: (Math.random() - 0.5) * 16,
dy: (Math.random() - 0.5) * 12,
r: 5 + Math.random() * 12,
a: 0.35 + Math.random() * 0.4,
rot: Math.random() * Math.PI
});
}
return {
x: x + (Math.random() - 0.5) * 6,
y: y + (Math.random() - 0.5) * 6,
r: 10,
a: 0.5,
blobs: blobs
};
}
Bxh.fx = {
reset: function () {
blood.length = 0;
floaters.length = 0;
muzzles.length = 0;
shakeAmt = 0;
shakeX = 0;
shakeY = 0;
hurtT = 0;
},
update: function (dt) {
var i;
if (hurtT > 0) hurtT = Math.max(0, hurtT - dt);
for (i = floaters.length - 1; i >= 0; i--) {
var f = floaters[i];
f.life -= dt;
f.y += f.vy * dt;
f.vy -= 18 * dt;
if (f.life <= 0) floaters.splice(i, 1);
}
for (i = muzzles.length - 1; i >= 0; i--) {
muzzles[i].life -= dt;
if (muzzles[i].life <= 0) muzzles.splice(i, 1);
}
if (shakeAmt > 0) {
shakeAmt = Math.max(0, shakeAmt - dt * 18);
if (reduced() || shakeAmt <= 0) {
shakeX = 0;
shakeY = 0;
shakeAmt = 0;
} else {
shakeX = (Math.random() - 0.5) * 2 * shakeAmt;
shakeY = (Math.random() - 0.5) * 2 * shakeAmt;
}
} else {
shakeX = 0;
shakeY = 0;
}
},
addBlood: function (x, y) {
blood.push(makeSplat(x, y));
if (!reduced() && Math.random() > 0.45) {
blood.push(makeSplat(x + (Math.random() - 0.5) * 10, y + (Math.random() - 0.5) * 10));
}
while (blood.length > BLOOD_CAP) blood.shift();
},
addFloater: function (x, y, text) {
floaters.push({
x: x,
y: y,
text: String(text),
life: 0.95,
maxLife: 0.95,
vy: -48
});
while (floaters.length > FLOATER_CAP) floaters.shift();
},
addMuzzle: function (x, y, angle) {
if (reduced()) return;
muzzles.push({
x: x,
y: y,
angle: angle || 0,
life: 0.07,
maxLife: 0.07
});
while (muzzles.length > MUZZLE_CAP) muzzles.shift();
},
shake: function (amount) {
if (reduced()) return;
shakeAmt = Math.min(12, shakeAmt + (amount || 4));
},
/** Red edge flash so taking a hit reads even in a crowded frame. */
hurtFlash: function () {
hurtT = HURT_TIME;
},
getHurt: function () {
return HURT_TIME > 0 ? hurtT / HURT_TIME : 0;
},
getBlood: function () {
return blood;
},
getFloaters: function () {
return floaters;
},
getMuzzles: function () {
return muzzles;
},
getShake: function () {
return { x: shakeX, y: shakeY, amount: shakeAmt };
}
};
})();
/* ===== render.js ===== */
/* Bxh.render — fake-isometric blocky 3D (Canvas 2D) */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var W = 960;
var H = 540;
var canvas = null;
var ctx = null;
var bobT = 0;
function ent() {
return Bxh.entities;
}
function arena() {
return Bxh.arena;
}
function fx() {
return Bxh.fx;
}
function reduced() {
return !!(Bxh.state && Bxh.state.reducedMotion);
}
function shade(hex, amount) {
var h = (hex || "#888888").replace("#", "");
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
var n = parseInt(h, 16);
if (isNaN(n)) return hex;
var r = (n >> 16) & 255;
var g = (n >> 8) & 255;
var b = n & 255;
r = Math.max(0, Math.min(255, r + amount));
g = Math.max(0, Math.min(255, g + amount));
b = Math.max(0, Math.min(255, b + amount));
return (
"#" +
((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)
);
}
/** Footprint center (x,y), size w×d, height h, top color */
function drawBox(c, x, y, w, d, h, topColor) {
var hw = w * 0.5;
var hd = d * 0.5;
var side = shade(topColor, -38);
var front = shade(topColor, -55);
var top = shade(topColor, 28);
/* ground shadow */
c.fillStyle = "rgba(0,0,0,0.22)";
c.beginPath();
c.ellipse(x + 3, y + hd * 0.35 + 2, hw * 0.95, hd * 0.55, 0, 0, Math.PI * 2);
c.fill();
/* south face */
c.fillStyle = front;
c.fillRect(x - hw, y - hd + h * 0.15, w, d - h * 0.05);
/* east-ish side strip */
c.fillStyle = side;
c.fillRect(x + hw - Math.max(3, w * 0.18), y - hd - h * 0.55, Math.max(3, w * 0.18), d + h * 0.55);
/* top face (raised) */
c.fillStyle = top;
c.fillRect(x - hw, y - hd - h, w, d * 0.72);
c.strokeStyle = "#111";
c.lineWidth = 1.5;
c.strokeRect(x - hw + 0.5, y - hd - h + 0.5, w - 1, d * 0.72 - 1);
c.strokeRect(x - hw + 0.5, y - hd + h * 0.15 + 0.5, w - 1, d - h * 0.05 - 1);
}
/**
* A head seen from above: top face plus a thin lip. Using a full drawBox here
* would extrude a dark front face straight across the torso and flatten the
* whole sprite into a black blob.
*/
function drawCap(c, x, y, w, d, color) {
var hw = w * 0.5;
var hd = d * 0.5;
c.fillStyle = shade(color, -45);
c.fillRect(x - hw, y - hd + d * 0.7, w, d * 0.3);
c.fillStyle = color;
c.fillRect(x - hw, y - hd, w, d * 0.7);
c.strokeStyle = "#111";
c.lineWidth = 1.25;
c.strokeRect(x - hw + 0.5, y - hd + 0.5, w - 1, d - 1);
}
function drawCylinder(c, x, y, r, h, bodyColor, stripeColor) {
c.fillStyle = "rgba(0,0,0,0.2)";
c.beginPath();
c.ellipse(x + 2, y + 2, r * 1.05, r * 0.55, 0, 0, Math.PI * 2);
c.fill();
c.fillStyle = shade(bodyColor, -30);
c.beginPath();
c.ellipse(x, y, r, r * 0.55, 0, 0, Math.PI * 2);
c.fill();
c.fillStyle = bodyColor;
c.fillRect(x - r, y - h, r * 2, h);
c.fillStyle = shade(bodyColor, -40);
c.fillRect(x + r * 0.55, y - h, r * 0.45, h);
if (stripeColor) {
c.fillStyle = stripeColor;
c.fillRect(x - r, y - h * 0.55, r * 2, Math.max(3, h * 0.18));
}
c.fillStyle = shade(bodyColor, 35);
c.beginPath();
c.ellipse(x, y - h, r, r * 0.55, 0, 0, Math.PI * 2);
c.fill();
c.strokeStyle = "#111";
c.lineWidth = 1.25;
c.beginPath();
c.ellipse(x, y - h, r, r * 0.55, 0, 0, Math.PI * 2);
c.stroke();
}
function drawFloor(c) {
c.fillStyle = "#c8c2b0";
c.fillRect(0, 0, W, H);
c.fillStyle = "rgba(0,0,0,0.04)";
for (var y = 0; y < H; y += 18) {
c.fillRect(0, y, W, 1);
}
/* soft corner vignette */
c.fillStyle = "rgba(80,70,50,0.08)";
c.fillRect(0, 0, W, 28);
c.fillRect(0, H - 28, W, 28);
}
function drawSolids(c) {
var solids = arena() ? arena().getSolids() : [];
for (var i = 0; i < solids.length; i++) {
var s = solids[i];
var cx = s.x + s.w * 0.5;
var cy = s.y + s.h * 0.5;
var elev = Math.min(22, 8 + Math.min(s.w, s.h) * 0.12);
drawBox(c, cx, cy, s.w, s.h, elev, "#7a756c");
}
}
function drawBlood(c) {
if (!fx()) return;
var list = fx().getBlood();
for (var i = 0; i < list.length; i++) {
var b = list[i];
var blobs = b.blobs || [{ dx: 0, dy: 0, r: b.r, a: b.a }];
for (var j = 0; j < blobs.length; j++) {
var bl = blobs[j];
c.fillStyle = "rgba(200, 24, 24, " + (bl.a != null ? bl.a : b.a) + ")";
c.beginPath();
c.ellipse(
b.x + (bl.dx || 0),
b.y + (bl.dy || 0),
bl.r || b.r,
(bl.r || b.r) * 0.62,
bl.rot || 0,
0,
Math.PI * 2
);
c.fill();
}
}
}
function drawCrates(c) {
if (!ent()) return;
var crates = ent().getCrates();
for (var i = 0; i < crates.length; i++) {
var cr = crates[i];
if (cr.taken) continue;
drawBox(c, cr.x, cr.y, 22, 22, 14, "#e07020");
}
}
function drawBarrels(c) {
if (!ent()) return;
var list = ent().getPlacedBarrels();
for (var i = 0; i < list.length; i++) {
var b = list[i];
drawCylinder(c, b.x, b.y, b.r || 10, 16, "#8a8a8a", "#d02020");
}
}
function facingAngle(e) {
if (e.facingX != null || e.facingY != null) {
return Math.atan2(e.facingY || 0, e.facingX || 0);
}
return -Math.PI / 2;
}
function drawEnemy(c, e) {
if (!e.alive && e.alive !== undefined) return;
var bob = reduced() ? 0 : Math.sin(bobT * 8 + e.id) * 1.2;
var isDevil = e.type === "devil";
/* Sickly green / hot red so bodies never read as grey scenery boxes. */
var body = isDevil ? "#d4392a" : "#93a860";
var r = e.r || 13;
var ang = 0;
/* face toward nearest motion — use velocity proxy via last seek: arms forward to player-ish using facing if any */
if (e._faceX != null) ang = Math.atan2(e._faceY || 0, e._faceX || 1);
else ang = Math.atan2(0, 1);
if (e.telegraph > 0) {
c.fillStyle = "rgba(40,30,20,0.35)";
c.beginPath();
c.arc(e.x, e.y, r + 8, 0, Math.PI * 2);
c.fill();
}
var glow = isDevil && typeof e.fireCooldown === "number" && e.fireCooldown < 0.35;
if (glow) {
c.fillStyle = "rgba(255,80,40,0.25)";
c.beginPath();
c.arc(e.x, e.y, r + 10, 0, Math.PI * 2);
c.fill();
}
drawBox(c, e.x, e.y + bob, r * 1.8, r * 1.5, 11, body);
drawCap(c, e.x, e.y - r * 0.72 + bob, r * 0.8, r * 0.66, isDevil ? "#4a1512" : "#37301f");
if (isDevil) {
c.fillStyle = "#111";
c.beginPath();
c.moveTo(e.x - 6, e.y - r * 1.35 + bob);
c.lineTo(e.x - 2, e.y - r * 1.75 + bob);
c.lineTo(e.x + 1, e.y - r * 1.25 + bob);
c.fill();
c.beginPath();
c.moveTo(e.x + 6, e.y - r * 1.35 + bob);
c.lineTo(e.x + 2, e.y - r * 1.75 + bob);
c.lineTo(e.x - 1, e.y - r * 1.25 + bob);
c.fill();
}
/* forward arms */
var ax = Math.cos(ang);
var ay = Math.sin(ang);
drawBox(
c,
e.x + ax * (r * 0.85),
e.y + ay * (r * 0.85) + bob,
7,
5,
4,
isDevil ? "#a32418" : "#7a8c4c"
);
if (e.flash > 0) {
c.fillStyle = "rgba(255,255,255,0.35)";
c.beginPath();
c.arc(e.x, e.y, r + 4, 0, Math.PI * 2);
c.fill();
}
}
/** Marker where a player went down so co-op partners can find the body. */
function drawDownMarker(c, p) {
if (p.downX == null) return;
var x = p.downX;
var y = p.downY;
var pulse = reduced() ? 1 : 0.65 + Math.sin(bobT * 5) * 0.35;
c.fillStyle = "rgba(30,20,20,0.35)";
c.beginPath();
c.ellipse(x, y + 4, 16, 8, 0, 0, Math.PI * 2);
c.fill();
c.strokeStyle = "rgba(224, 64, 32, " + pulse + ")";
c.lineWidth = 2.5;
c.beginPath();
c.moveTo(x - 8, y - 8);
c.lineTo(x + 8, y + 8);
c.moveTo(x + 8, y - 8);
c.lineTo(x - 8, y + 8);
c.stroke();
c.font = "bold 11px Impact, Arial Black, sans-serif";
c.textAlign = "center";
c.textBaseline = "middle";
c.lineWidth = 3;
c.strokeStyle = "rgba(0,0,0,0.8)";
c.fillStyle = p.skin === "p2" ? "#c9a4f5" : "#8fbaff";
c.strokeText("P" + p.id + " DOWN", x, y - 20);
c.fillText("P" + p.id + " DOWN", x, y - 20);
}
function drawPlayer(c, p) {
if (!p.alive) return;
var bob = reduced() ? 0 : Math.sin(bobT * 10 + p.id) * 1.1;
var r = p.r || 12;
/* P2 is violet, not green — zombies own the green end of the palette. */
var torso = p.skin === "p2" ? "#8e5bd0" : "#3570c4";
var ang = facingAngle(p);
var fx = Math.cos(ang);
var fy = Math.sin(ang);
drawBox(c, p.x, p.y + bob, r * 1.85, r * 1.55, 12, torso);
drawCap(c, p.x, p.y - r * 0.75 + bob, r * 0.82, r * 0.68, "#2a2620");
/* team tag on the torso top face */
c.fillStyle = p.skin === "p2" ? "#ecdcff" : "#e8e0c8";
c.fillRect(p.x - r * 0.62, p.y - r * 1.32 + bob, r * 1.24, 3);
/* gun */
var gx = p.x + fx * (r * 1.1);
var gy = p.y + fy * (r * 1.1) + bob;
drawBox(c, gx, gy, 14, 5, 4, "#222");
/* small world HP only */
var barW = 28;
var barH = 4;
var bx = p.x - barW / 2;
var by = p.y - r - 22 + bob;
var ratio = Math.max(0, Math.min(1, p.hp / (p.maxHp || 100)));
c.fillStyle = "#111";
c.fillRect(bx - 1, by - 1, barW + 2, barH + 2);
c.fillStyle = "#333";
c.fillRect(bx, by, barW, barH);
c.fillStyle = ratio > 0.35 ? "#3dcc3d" : "#e04020";
c.fillRect(bx, by, barW * ratio, barH);
if (p.iFrames > 0 && ((p.iFrames * 20) | 0) % 2 === 0) {
c.fillStyle = "rgba(255,90,70,0.4)";
c.beginPath();
c.arc(p.x, p.y - 4 + bob, r + 8, 0, Math.PI * 2);
c.fill();
}
}
function drawBullets(c) {
if (!ent()) return;
var list = ent().getBullets();
c.strokeStyle = "#ffffff";
c.lineWidth = 2;
c.lineCap = "round";
for (var i = 0; i < list.length; i++) {
var b = list[i];
var len = 10;
var spd = Math.sqrt((b.vx || 0) * (b.vx || 0) + (b.vy || 0) * (b.vy || 0)) || 1;
var ux = (b.vx || 0) / spd;
var uy = (b.vy || 0) / spd;
c.beginPath();
c.moveTo(b.x - ux * len, b.y - uy * len);
c.lineTo(b.x + ux * 2, b.y + uy * 2);
c.stroke();
}
}
function drawFireballs(c) {
if (!ent()) return;
var list = ent().getFireballs();
for (var i = 0; i < list.length; i++) {
var f = list[i];
var rr = f.r || 7;
c.fillStyle = "rgba(0,0,0,0.2)";
c.beginPath();
c.ellipse(f.x + 2, f.y + 2, rr, rr * 0.5, 0, 0, Math.PI * 2);
c.fill();
var g = c.createRadialGradient(f.x, f.y, 1, f.x, f.y, rr + 4);
g.addColorStop(0, "#ffe080");
g.addColorStop(0.45, "#ff6020");
g.addColorStop(1, "rgba(180,40,0,0.15)");
c.fillStyle = g;
c.beginPath();
c.arc(f.x, f.y, rr + 2, 0, Math.PI * 2);
c.fill();
}
}
function drawGrenades(c) {
if (!ent()) return;
var list = ent().getGrenades();
for (var i = 0; i < list.length; i++) {
var g = list[i];
drawCylinder(c, g.x, g.y, g.r || 6, 10, "#2a5a2a", null);
if (((g.fuse || 0) * 10) % 2 < 1) {
c.fillStyle = "#ff3030";
c.beginPath();
c.arc(g.x, g.y - 12, 2.5, 0, Math.PI * 2);
c.fill();
}
}
}
function drawRockets(c) {
if (!ent()) return;
var list = ent().getRockets();
for (var i = 0; i < list.length; i++) {
var r = list[i];
var ang = Math.atan2(r.vy || 0, r.vx || 1);
c.save();
c.translate(r.x, r.y);
c.rotate(ang);
drawBox(c, 0, 0, 16, 7, 5, "#444444");
c.fillStyle = "#ffaa40";
c.fillRect(-12, -2, 5, 4);
c.restore();
}
}
function drawSpawnTelegraphs(c) {
if (!ent() || typeof ent().getSpawnFlashes !== "function") return;
var flashes = ent().getSpawnFlashes();
if (!flashes) return;
for (var i = 0; i < flashes.length; i++) {
var s = flashes[i];
var a = s.t != null ? Math.min(1, s.t) : 0.5;
c.fillStyle = "rgba(40, 30, 20, " + 0.28 * a + ")";
c.beginPath();
c.ellipse(s.x, s.y, 18 + (1 - a) * 8, 10 + (1 - a) * 4, 0, 0, Math.PI * 2);
c.fill();
}
}
function drawMuzzles(c) {
if (!fx()) return;
var list = fx().getMuzzles();
for (var i = 0; i < list.length; i++) {
var m = list[i];
var t = m.life / (m.maxLife || 0.06);
c.save();
c.translate(m.x, m.y);
c.rotate(m.angle || 0);
c.fillStyle = "rgba(255, 230, 90, " + t + ")";
c.beginPath();
c.moveTo(0, 0);
c.lineTo(16, -7);
c.lineTo(22, 0);
c.lineTo(16, 7);
c.closePath();
c.fill();
c.restore();
}
}
function drawFloaters(c) {
if (!fx()) return;
var list = fx().getFloaters();
c.font = "bold 16px Impact, Arial Black, sans-serif";
c.textAlign = "center";
c.textBaseline = "middle";
for (var i = 0; i < list.length; i++) {
var f = list[i];
var a = Math.max(0, f.life / (f.maxLife || 0.95));
c.lineWidth = 3;
c.strokeStyle = "rgba(0,0,0," + a + ")";
c.fillStyle = "rgba(255,255,255," + a + ")";
c.strokeText(f.text, f.x, f.y);
c.fillText(f.text, f.x, f.y);
}
}
function sortByY(a, b) {
return (a.y || 0) - (b.y || 0);
}
function drawActors(c) {
var items = [];
var i;
if (ent()) {
var enemies = ent().getEnemies() || [];
for (i = 0; i < enemies.length; i++) {
if (enemies[i].alive !== false) items.push({ y: enemies[i].y, kind: "e", ref: enemies[i] });
}
var players = ent().getPlayers() || [];
for (i = 0; i < players.length; i++) {
if (players[i].alive) items.push({ y: players[i].y, kind: "p", ref: players[i] });
}
}
items.sort(sortByY);
for (i = 0; i < items.length; i++) {
if (items[i].kind === "e") drawEnemy(c, items[i].ref);
else drawPlayer(c, items[i].ref);
}
}
function drawDownMarkers(c) {
if (!ent()) return;
var players = ent().getPlayers() || [];
for (var i = 0; i < players.length; i++) {
if (!players[i].alive) drawDownMarker(c, players[i]);
}
}
/** Red edge wash on damage — drawn outside the shake transform. */
function drawHurtVignette(c) {
if (!Bxh.state || Bxh.state.screen !== "playing") return;
if (!fx() || typeof fx().getHurt !== "function") return;
var t = fx().getHurt();
if (t <= 0) return;
var g = c.createRadialGradient(W / 2, H / 2, H * 0.42, W / 2, H / 2, H * 0.85);
g.addColorStop(0, "rgba(190,20,20,0)");
g.addColorStop(1, "rgba(190,20,20," + (0.38 * t).toFixed(3) + ")");
c.fillStyle = g;
c.fillRect(0, 0, W, H);
}
Bxh.render = {
init: function (c) {
canvas = c;
if (!canvas) {
ctx = null;
return;
}
ctx = canvas.getContext("2d");
canvas.width = W;
canvas.height = H;
},
draw: function (world, dt) {
if (!ctx || !canvas) return;
void world;
if (!reduced()) bobT += typeof dt === "number" ? dt : 0.016;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
var sh = fx() ? fx().getShake() : { x: 0, y: 0 };
ctx.save();
ctx.translate(sh.x || 0, sh.y || 0);
drawFloor(ctx);
drawBlood(ctx);
drawSpawnTelegraphs(ctx);
drawSolids(ctx);
drawCrates(ctx);
drawBarrels(ctx);
drawDownMarkers(ctx);
drawActors(ctx);
drawBullets(ctx);
drawFireballs(ctx);
drawGrenades(ctx);
drawRockets(ctx);
drawMuzzles(ctx);
drawFloaters(ctx);
ctx.restore();
drawHurtVignette(ctx);
}
};
})();
/* ===== audio.js ===== */
/* Bxh.audio — tiny Web Audio API beeps */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var ctx = null;
var ready = false;
function ensureCtx() {
if (ctx) return ctx;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch (e) {
return null;
}
return ctx;
}
function resume() {
var c = ensureCtx();
if (!c) return null;
if (c.state === "suspended") {
try {
c.resume();
} catch (e) {
/* ignore */
}
}
ready = c.state === "running";
return c;
}
function tone(freq, dur, type, vol, when) {
var c = resume();
if (!c) return;
try {
var t0 = c.currentTime + (when || 0);
var osc = c.createOscillator();
var gain = c.createGain();
osc.type = type || "square";
osc.frequency.setValueAtTime(freq, t0);
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(vol || 0.08, t0 + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(gain);
gain.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
} catch (e) {
/* fail silently */
}
}
var PATTERNS = {
shoot: function () {
tone(520, 0.05, "square", 0.05);
},
kill: function () {
tone(180, 0.08, "sawtooth", 0.07);
tone(90, 0.12, "square", 0.05, 0.04);
},
hurt: function () {
tone(140, 0.1, "triangle", 0.09);
tone(70, 0.14, "sawtooth", 0.06, 0.03);
},
unlock: function () {
tone(440, 0.08, "square", 0.07);
tone(660, 0.1, "square", 0.07, 0.08);
tone(880, 0.14, "square", 0.06, 0.16);
},
ui: function () {
tone(330, 0.05, "square", 0.04);
},
dryfire: function () {
tone(90, 0.04, "square", 0.035);
tone(60, 0.05, "triangle", 0.03, 0.02);
}
};
Bxh.audio = {
init: function () {
ensureCtx();
function unlock() {
resume();
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
}
window.addEventListener("pointerdown", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
},
beep: function (type) {
try {
var fn = PATTERNS[type] || PATTERNS.ui;
fn();
} catch (e) {
/* fail silently */
}
}
};
})();
/* ===== ui.js ===== */
/* Bxh.ui — Shell screens, HUD, toasts, button wiring */
(function () {
"use strict";
var Bxh = (window.Bxh = window.Bxh || {});
var RING_R = 15.5;
var RING_CIRC = 2 * Math.PI * RING_R;
var TOAST_MS = 2200;
var SCORE_PAD = 9;
var DEFAULT_ROOMS = [
{ id: "box", name: "The Box", blurb: "Open floor. Kite freely." },
{ id: "columns", name: "The Columns", blurb: "Pillars break sight lines." },
{ id: "choke", name: "The Choke", blurb: "Narrow halls. Pack them in." }
];
var DEFAULT_UNLOCKS = [
{ id: "pistol", at: 1, label: "Pistol (start, infinite ammo)" },
{ id: "uzi", at: 5, label: "Uzi" },
{ id: "shotgun", at: 10, label: "Shotgun" },
{ id: "barrel", at: 15, label: "Barrel" },
{ id: "grenade", at: 20, label: "Grenades" },
{ id: "rapidUzi", at: 25, label: "Rapid Uzi" },
{ id: "rocket", at: 40, label: "Rocket" }
];
var root = null;
var els = {};
var selectedRoom = 0;
var highScore = 0;
var currentScreen = "focus";
var handlers = {};
var toastTimer = null;
var roomCount = 3;
var results = null;
function $(sel, ctx) {
return (ctx || root).querySelector(sel);
}
function $all(sel, ctx) {
return Array.prototype.slice.call((ctx || root).querySelectorAll(sel));
}
function padScore(n) {
var s = String(Math.max(0, Math.floor(Number(n) || 0)));
while (s.length < SCORE_PAD) s = "0" + s;
return s;
}
function padWave(n) {
var w = Math.max(0, Math.floor(Number(n) || 0));
return w < 10 ? "0" + w : String(w);
}
function setHidden(el, hidden) {
if (!el) return;
if (hidden) el.setAttribute("hidden", "");
else el.removeAttribute("hidden");
}
function getRooms() {
if (Bxh.arena && Array.isArray(Bxh.arena.ROOMS) && Bxh.arena.ROOMS.length) {
return Bxh.arena.ROOMS;
}
return DEFAULT_ROOMS;
}
function getUnlockDefs() {
if (Bxh.multiplier && Array.isArray(Bxh.multiplier.UNLOCKS) && Bxh.multiplier.UNLOCKS.length) {
return Bxh.multiplier.UNLOCKS;
}
return DEFAULT_UNLOCKS;
}
function isUnlocked(id) {
if (Bxh.multiplier && typeof Bxh.multiplier.isUnlocked === "function") {
return !!Bxh.multiplier.isUnlocked(id);
}
return id === "pistol";
}
function cacheEls() {
els.hud = $("[data-bxh-hud]");
els.wave = $("[data-bxh-wave]");
els.enemies = $("[data-bxh-enemies]");
els.score = $("[data-bxh-score]");
els.comboVal = $("[data-bxh-combo-val]");
els.comboRing = $("[data-bxh-combo-ring]");
els.join = $("[data-bxh-join]");
els.toast = $("[data-bxh-toast]");
els.hiscore = $("[data-bxh-hiscore]");
els.rooms = $("[data-bxh-rooms]");
els.unlocks = $("[data-bxh-unlocks]");
els.finalScore = $("[data-bxh-final-score]");
els.finalCombo = $("[data-bxh-final-combo]");
els.finalWaves = $("[data-bxh-final-waves]");
els.newbest = $("[data-bxh-newbest]");
els.live = $("[data-bxh-live]");
els.focus = $('[data-bxh-screen="focus"]');
els.screens = $all("[data-bxh-screen]");
els.combo = $("[data-bxh-combo]");
els.p1Hp = $("[data-bxh-p1-hp]");
els.p2Hp = $("[data-bxh-p2-hp]");
els.p1Weapon = $("[data-bxh-p1-weapon]");
els.p2Weapon = $("[data-bxh-p2-weapon]");
els.p1Card = $("[data-bxh-p1-status]");
els.p2Card = $("[data-bxh-p2-status]");
}
function playerWeaponText(player) {
if (!player) return "-";
if (Bxh.combat && Bxh.combat.getWeaponLabel && Bxh.combat.getAmmoDisplay) {
return (
Bxh.combat.getWeaponLabel(player.weaponId || "pistol") +
":" +
Bxh.combat.getAmmoDisplay(player)
);
}
var id = (player.weaponId || "pistol").toUpperCase();
if (player.weaponId === "pistol") return id + ":INF";
var a = player.ammo && player.ammo[player.weaponId];
return id + ":" + (typeof a === "number" ? a : "?");
}
function setDockPlayer(slot, player, waiting) {
var hpEl = slot === 1 ? els.p1Hp : els.p2Hp;
var weEl = slot === 1 ? els.p1Weapon : els.p2Weapon;
var card = slot === 1 ? els.p1Card : els.p2Card;
if (waiting) {
if (hpEl) {
hpEl.style.width = "0%";
hpEl.classList.remove("is-low");
}
if (weEl) weEl.textContent = "WASD to join";
if (card) card.classList.add("is-inactive");
return;
}
if (!player) {
if (hpEl) {
hpEl.style.width = "0%";
hpEl.classList.remove("is-low");
}
if (weEl) weEl.textContent = slot === 1 ? "PISTOL:INF" : "-";
if (card) card.classList.toggle("is-inactive", slot === 2);
return;
}
var max = player.maxHp || 100;
var ratio = Math.max(0, Math.min(1, (player.hp || 0) / max));
if (hpEl) {
hpEl.style.width = Math.round(ratio * 100) + "%";
hpEl.classList.toggle("is-low", ratio <= 0.35 && player.alive);
}
if (weEl) {
weEl.textContent = player.alive ? playerWeaponText(player) : "DOWN";
}
if (card) card.classList.toggle("is-inactive", !player.alive);
}
function updateDock(snapshot) {
var showJoin = !!(snapshot && snapshot.showJoin);
var players =
Bxh.entities && typeof Bxh.entities.getPlayers === "function"
? Bxh.entities.getPlayers()
: [];
var p1 = null;
var p2 = null;
var i;
for (i = 0; i < players.length; i++) {
if (players[i].id === 1) p1 = players[i];
if (players[i].id === 2) p2 = players[i];
}
setDockPlayer(1, p1, false);
if (showJoin || !p2) setDockPlayer(2, null, true);
else setDockPlayer(2, p2, false);
}
function populateRooms() {
if (!els.rooms) return;
var rooms = getRooms();
roomCount = rooms.length;
els.rooms.innerHTML = "";
rooms.forEach(function (room, i) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "bxh-game__room";
btn.setAttribute("data-bxh-action", "room");
btn.setAttribute("data-bxh-room", String(i));
btn.setAttribute("aria-selected", i === selectedRoom ? "true" : "false");
if (i === selectedRoom) btn.classList.add("is-selected");
var num = document.createElement("span");
num.className = "bxh-game__room-num";
num.textContent = "ROOM " + (i + 1);
var name = document.createElement("span");
name.className = "bxh-game__room-name";
name.textContent = room.name || "Room " + (i + 1);
var blurb = document.createElement("span");
blurb.className = "bxh-game__room-blurb";
blurb.textContent = room.blurb || "";
btn.appendChild(num);
btn.appendChild(name);
btn.appendChild(blurb);
els.rooms.appendChild(btn);
});
}
function syncRoomSelection() {
if (!els.rooms) return;
var max = Math.max(0, roomCount - 1);
if (selectedRoom < 0) selectedRoom = 0;
if (selectedRoom > max) selectedRoom = max;
$all("[data-bxh-room]", els.rooms).forEach(function (btn) {
var i = parseInt(btn.getAttribute("data-bxh-room"), 10) || 0;
var on = i === selectedRoom;
btn.classList.toggle("is-selected", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
}
function populateUnlocks() {
if (!els.unlocks) return;
var defs = getUnlockDefs();
els.unlocks.innerHTML = "";
defs.forEach(function (u) {
var li = document.createElement("li");
var unlocked = isUnlocked(u.id);
li.className = "bxh-game__unlock " + (unlocked ? "is-unlocked" : "is-locked");
li.setAttribute("data-bxh-unlock", u.id);
var at = document.createElement("span");
at.className = "bxh-game__unlock-at";
at.textContent = "x" + (u.at != null ? u.at : "?");
var label = document.createElement("span");
label.className = "bxh-game__unlock-label";
label.textContent = u.label || u.id;
li.appendChild(at);
li.appendChild(label);
els.unlocks.appendChild(li);
});
}
function refreshUnlockStates() {
if (!els.unlocks) return;
$all("[data-bxh-unlock]", els.unlocks).forEach(function (li) {
var id = li.getAttribute("data-bxh-unlock");
var unlocked = isUnlocked(id);
li.classList.toggle("is-unlocked", unlocked);
li.classList.toggle("is-locked", !unlocked);
});
}
function setComboRing(decayT, decayMax) {
if (!els.comboRing) return;
var max = Number(decayMax);
if (!(max > 0)) max = 1;
var t = Number(decayT);
if (!(t >= 0)) t = 0;
var ratio = Math.max(0, Math.min(1, t / max));
els.comboRing.style.strokeDasharray = String(RING_CIRC);
els.comboRing.style.strokeDashoffset = String(RING_CIRC * (1 - ratio));
}
function fillGameOver() {
var r = results;
if (!r) {
r = { score: 0, bestCombo: 1, wave: 0, newBest: false };
if (Bxh.multiplier && typeof Bxh.multiplier.get === "function") {
var m = Bxh.multiplier.get();
if (m) {
r.score = m.score || 0;
r.bestCombo = m.bestCombo != null ? m.bestCombo : m.value || 1;
}
}
if (Bxh.waves && typeof Bxh.waves.getWave === "function") {
r.wave = Bxh.waves.getWave() || 0;
}
}
if (els.finalScore) els.finalScore.textContent = String(r.score);
if (els.finalCombo) els.finalCombo.textContent = "x" + r.bestCombo;
if (els.finalWaves) els.finalWaves.textContent = String(r.wave);
setHidden(els.newbest, !r.newBest);
}
function callHandler(name, arg) {
var fn = handlers[name];
if (typeof fn === "function") fn(arg);
}
function onRootClick(ev) {
var focusEl = els.focus;
if (focusEl && !focusEl.hasAttribute("hidden")) {
var t = ev.target;
var hitFocus =
focusEl === t ||
(focusEl.contains && focusEl.contains(t)) ||
(t && t.closest && t.closest('[data-bxh-screen="focus"]'));
if (hitFocus) {
callHandler("onFocus");
return;
}
}
var actionEl = ev.target.closest && ev.target.closest("[data-bxh-action]");
if (!actionEl || !root.contains(actionEl)) return;
/* If the focus gate is somehow still up, unlock first so Solo/Co-op work. */
if (focusEl && !focusEl.hasAttribute("hidden")) {
callHandler("onFocus");
}
var action = actionEl.getAttribute("data-bxh-action");
switch (action) {
case "solo":
callHandler("onSolo");
break;
case "coop":
callHandler("onCoop");
break;
case "room": {
var idx = parseInt(actionEl.getAttribute("data-bxh-room"), 10);
if (isNaN(idx)) idx = 0;
selectedRoom = idx;
syncRoomSelection();
callHandler("onRoomPick", idx);
break;
}
case "confirmRoom":
callHandler("onConfirmRoom", selectedRoom);
break;
case "resume":
callHandler("onResume");
break;
case "quit":
callHandler("onQuit");
break;
case "retry":
callHandler("onRetry");
break;
case "rooms":
callHandler("onRooms");
break;
default:
break;
}
}
Bxh.ui = {
init: function (rootEl) {
root = rootEl;
if (!root) return;
cacheEls();
populateRooms();
populateUnlocks();
syncRoomSelection();
setComboRing(1, 1);
if (els.comboRing) {
els.comboRing.setAttribute("stroke-dasharray", String(RING_CIRC));
els.comboRing.setAttribute("stroke-dashoffset", "0");
}
if (els.hiscore) els.hiscore.textContent = String(highScore);
root.removeEventListener("click", onRootClick);
root.addEventListener("click", onRootClick);
updateDock({ showJoin: true });
if (currentScreen) Bxh.ui.setScreen(currentScreen);
},
setScreen: function (name) {
currentScreen = name || "attract";
if (!root) return;
var showHud = currentScreen === "playing";
setHidden(els.hud, !showHud);
els.screens.forEach(function (screen) {
var id = screen.getAttribute("data-bxh-screen");
var show = id === currentScreen;
if (currentScreen === "playing") show = false;
setHidden(screen, !show);
});
if (currentScreen === "roomSelect") {
populateRooms();
syncRoomSelection();
}
if (currentScreen === "paused") {
if (!els.unlocks || !els.unlocks.children.length) populateUnlocks();
else refreshUnlockStates();
}
if (currentScreen === "gameOver") {
fillGameOver();
}
if (currentScreen === "attract" && els.hiscore) {
els.hiscore.textContent = String(highScore);
}
},
updateHud: function (snapshot) {
if (!snapshot) return;
var score = snapshot.score;
var mult = snapshot.multiplier;
var decayT = snapshot.decayT;
var decayMax = snapshot.decayMax;
var wave = snapshot.wave;
var enemies = snapshot.enemies;
var showJoin = snapshot.showJoin;
if (els.score && score != null) els.score.textContent = padScore(score);
if (els.comboVal && mult != null) els.comboVal.textContent = "x" + mult;
if (decayT != null || decayMax != null) {
var dt = decayT != null ? decayT : 0;
var dm = decayMax != null ? decayMax : 1;
setComboRing(dt, dm);
if (els.combo) {
els.combo.classList.toggle("is-warn", dm > 0 && dt / dm < 0.25 && (mult || 1) > 1);
}
}
if (els.wave && wave != null) els.wave.textContent = "WAVE " + padWave(wave);
if (els.enemies) {
var nextIn = Number(snapshot.nextWaveIn) || 0;
if (nextIn > 0) els.enemies.textContent = "NEXT IN " + Math.ceil(nextIn);
else if (enemies == null || enemies === "") els.enemies.textContent = "";
else els.enemies.textContent = enemies + " LEFT";
}
if (els.join) setHidden(els.join, !showJoin);
updateDock(snapshot);
if (snapshot.toast) Bxh.ui.toast(snapshot.toast);
},
toast: function (message) {
if (!els.toast) return;
els.toast.textContent = message || "";
setHidden(els.toast, !message);
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
if (!message) return;
toastTimer = setTimeout(function () {
setHidden(els.toast, true);
toastTimer = null;
}, TOAST_MS);
},
announce: function (message) {
if (!els.live) return;
els.live.textContent = "";
// Force announce re-read for repeated messages
void els.live.offsetWidth;
els.live.textContent = message || "";
},
getSelectedRoomIndex: function () {
return selectedRoom;
},
setSelectedRoomIndex: function (i) {
var idx = parseInt(i, 10);
if (isNaN(idx)) idx = 0;
selectedRoom = idx;
syncRoomSelection();
},
setHighScore: function (n) {
var v = Math.max(0, Math.floor(Number(n) || 0));
highScore = v;
if (els.hiscore) els.hiscore.textContent = String(highScore);
},
/** Run summary supplied by main so the game-over card has one source of truth. */
setResults: function (r) {
results = r || null;
},
bindButtons: function (map) {
handlers = map || {};
},
getRoot: function () {
return root;
}
};
})();
/* ===== main.js ===== */
/* Box Horde — main state machine + game loop (parent Phase 2) */
(function () {
"use strict";
var VERSION = "1.0.1";
var Bxh = (window.Bxh = window.Bxh || {});
var HS_KEY = "bxh-highscore";
/* GHL may evaluate the bundle twice after hydration — reuse the live API. */
if (window.BxhGame && window.BxhGame.version === VERSION) {
window.BxhGame.init(document);
return;
}
Bxh.state = {
screen: "focus",
mode: "solo",
focused: false,
roomId: "box",
reducedMotion: false,
paused: false,
p2Joined: false,
running: false
};
var rootNode = null;
var lastTs = 0;
var rafId = 0;
var pendingMode = "solo";
var highScore = 0;
function loadHighScore() {
try {
highScore = parseInt(localStorage.getItem(HS_KEY) || "0", 10) || 0;
} catch (e) {
highScore = 0;
}
}
function saveHighScore(score) {
if (score > highScore) {
highScore = score;
try {
localStorage.setItem(HS_KEY, String(highScore));
} catch (e) {}
return true;
}
return false;
}
function roomIdFromIndex(i) {
var rooms = (Bxh.arena && Bxh.arena.ROOMS) || [];
if (rooms[i]) return rooms[i].id;
return "box";
}
function setScreen(name) {
Bxh.state.screen = name;
Bxh.state.paused = name === "paused";
if (Bxh.ui) Bxh.ui.setScreen(name);
}
function beginRun() {
var idx = Bxh.ui ? Bxh.ui.getSelectedRoomIndex() : 0;
var roomId = roomIdFromIndex(idx);
Bxh.state.roomId = roomId;
Bxh.state.mode = pendingMode;
Bxh.state.p2Joined = pendingMode === "coop";
Bxh.state.running = true;
if (Bxh.arena) Bxh.arena.setRoom(roomId);
if (Bxh.multiplier) Bxh.multiplier.reset();
if (Bxh.fx) Bxh.fx.reset();
if (Bxh.combat) Bxh.combat.reset();
if (Bxh.waves) Bxh.waves.reset();
if (Bxh.input) {
Bxh.input.reset();
Bxh.input.setCaptureEnabled(true);
if (Bxh.input.setSoloMode) Bxh.input.setSoloMode(pendingMode === "solo");
}
if (Bxh.entities) {
Bxh.entities.reset(pendingMode);
Bxh.entities.spawnPlayers(pendingMode, Bxh.arena.getRoom());
if (Bxh.entities.spawnCrateFromRoom) {
Bxh.entities.spawnCrateFromRoom(Bxh.arena.getRoom());
}
}
setScreen("playing");
if (Bxh.ui) {
Bxh.ui.announce("Wave starting");
}
if (Bxh.audio) Bxh.audio.beep("ui");
}
function endRun() {
Bxh.state.running = false;
var m = Bxh.multiplier ? Bxh.multiplier.get() : { score: 0, bestCombo: 1 };
var wave = Bxh.waves ? Bxh.waves.getWave() : 0;
var isNew = saveHighScore(m.score);
if (Bxh.ui) {
Bxh.ui.setHighScore(highScore);
Bxh.ui.setResults({
score: m.score,
bestCombo: m.bestCombo || 1,
wave: wave,
newBest: isNew
});
Bxh.ui.announce("Game over. Score " + m.score);
}
setScreen("gameOver");
}
function allPlayersDead() {
if (!Bxh.entities) return false;
var ps = Bxh.entities.getPlayers();
if (!ps.length) return false;
for (var i = 0; i < ps.length; i++) {
if (ps[i].alive) return false;
}
return true;
}
function tryJoinP2() {
if (Bxh.state.mode !== "solo" || Bxh.state.p2Joined) return;
if (!Bxh.input || !Bxh.state.running || Bxh.state.screen !== "playing") return;
var p2 = Bxh.input.getPlayer(2);
/* WASD only — Space stays P1 fire in solo */
if (p2.up || p2.down || p2.left || p2.right) {
Bxh.state.p2Joined = true;
Bxh.state.mode = "coop";
if (Bxh.entities && Bxh.entities.joinP2) Bxh.entities.joinP2();
if (Bxh.input && Bxh.input.setSoloMode) Bxh.input.setSoloMode(false);
if (Bxh.ui) {
Bxh.ui.announce("Player two joined");
}
}
}
var WEAPON_SLOT_KEYS = ["digit1", "digit2", "digit3", "digit4", "digit5", "digit6"];
function processWeaponSlotKeys() {
if (!Bxh.input || !Bxh.combat || !Bxh.entities) return;
var players = Bxh.entities.getPlayers();
var p1 = null;
var i;
for (i = 0; i < players.length; i++) {
if (players[i].id === 1) p1 = players[i];
}
for (i = 0; i < WEAPON_SLOT_KEYS.length; i++) {
if (Bxh.input.wasPressed(WEAPON_SLOT_KEYS[i]) && p1 && p1.alive) {
Bxh.combat.selectWeaponSlot(p1, i);
}
}
}
function syncHud() {
if (!Bxh.ui) return;
var m = Bxh.multiplier ? Bxh.multiplier.get() : { value: 1, decayT: 1, decayMax: 1, score: 0 };
Bxh.ui.updateHud({
score: m.score,
multiplier: m.value,
decayT: m.decayT,
decayMax: m.decayMax,
wave: Bxh.waves ? Bxh.waves.getWave() : 0,
enemies: Bxh.waves ? Bxh.waves.getEnemyCount() : 0,
nextWaveIn: Bxh.waves && Bxh.waves.getBreatherRemaining ? Bxh.waves.getBreatherRemaining() : 0,
showJoin: Bxh.state.running && !Bxh.state.p2Joined && Bxh.state.mode === "solo"
});
}
function tick(ts) {
rafId = requestAnimationFrame(tick);
if (!lastTs) lastTs = ts;
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (Bxh.input) Bxh.input.update();
// UI keyboard while not playing
if (Bxh.state.focused && Bxh.input) {
if (Bxh.state.screen === "roomSelect") {
if (Bxh.input.wasPressed("digit1")) {
Bxh.ui.setSelectedRoomIndex(0);
Bxh.ui.setScreen("roomSelect");
}
if (Bxh.input.wasPressed("digit2")) {
Bxh.ui.setSelectedRoomIndex(1);
Bxh.ui.setScreen("roomSelect");
}
if (Bxh.input.wasPressed("digit3")) {
Bxh.ui.setSelectedRoomIndex(2);
Bxh.ui.setScreen("roomSelect");
}
if (Bxh.input.wasPressed("confirm")) beginRun();
}
if (Bxh.state.screen === "playing" && Bxh.input.wasPressed("pause")) {
setScreen("paused");
if (Bxh.ui) Bxh.ui.setScreen("paused");
} else if (Bxh.state.screen === "paused" && Bxh.input.wasPressed("pause")) {
setScreen("playing");
}
}
if (Bxh.state.screen === "playing" && Bxh.state.running) {
tryJoinP2();
processWeaponSlotKeys();
var world = {
entities: Bxh.entities,
arena: Bxh.arena,
multiplier: Bxh.multiplier,
fx: Bxh.fx,
state: Bxh.state
};
if (Bxh.entities) Bxh.entities.update(dt, world);
if (Bxh.waves) Bxh.waves.update(dt, Bxh.entities, Bxh.arena);
if (Bxh.combat) Bxh.combat.update(dt, world);
if (Bxh.multiplier) Bxh.multiplier.update(dt);
if (Bxh.fx) Bxh.fx.update(dt);
if (Bxh.render) Bxh.render.draw(world, dt);
syncHud();
if (allPlayersDead()) endRun();
} else if (Bxh.render && Bxh.arena) {
/* paused / menus: redraw the frozen board so the backdrop stays live */
Bxh.render.draw(
{
entities: Bxh.entities,
arena: Bxh.arena,
multiplier: Bxh.multiplier,
fx: Bxh.fx,
state: Bxh.state
},
0
);
}
}
function ensureFocused(playBeep) {
Bxh.state.focused = true;
if (Bxh.input) Bxh.input.setCaptureEnabled(true);
if (rootNode && rootNode.focus) rootNode.focus();
if (Bxh.state.screen === "focus") setScreen("attract");
if (playBeep && Bxh.audio) Bxh.audio.beep("ui");
}
function onFocus() {
ensureFocused(true);
}
function bindUi() {
if (!Bxh.ui) return;
Bxh.ui.bindButtons({
onFocus: onFocus,
onSolo: function () {
ensureFocused(false);
pendingMode = "solo";
setScreen("roomSelect");
},
onCoop: function () {
ensureFocused(false);
pendingMode = "coop";
setScreen("roomSelect");
},
onRoomPick: function (index) {
ensureFocused(false);
if (Bxh.ui) Bxh.ui.setSelectedRoomIndex(index);
},
onConfirmRoom: function () {
ensureFocused(false);
beginRun();
},
onResume: function () {
ensureFocused(false);
setScreen("playing");
},
onQuit: function () {
Bxh.state.running = false;
setScreen("attract");
},
onRetry: function () {
ensureFocused(false);
beginRun();
},
onRooms: function () {
ensureFocused(false);
setScreen("roomSelect");
}
});
}
function onCaptureLost() {
Bxh.state.focused = false;
if (Bxh.state.screen === "playing" && Bxh.state.running) {
setScreen("paused");
}
}
function onRootPointerDown(ev) {
/* Focus gate: any press on the overlay (or its label) unlocks the game.
GHL hydration often drops the click listener; pointerdown is a backup. */
if (Bxh.state.screen === "focus") {
var t = ev && ev.target;
var onGate =
t &&
t.closest &&
(t.closest('[data-bxh-screen="focus"]') || t.closest(".bxh-game__focus"));
if (onGate) ensureFocused(true);
return;
}
ensureFocused(false);
}
function onWaveStart() {
if (!Bxh.entities || !Bxh.entities.reviveDowned) return;
var revived = Bxh.entities.reviveDowned(0.5);
if (revived.length && Bxh.ui && Bxh.ui.toast) {
Bxh.ui.toast("P" + revived.join(" & P") + " REVIVED");
}
}
var watchStarted = false;
var retryTimer = null;
var retryCount = 0;
var globalHooksWired = false;
function wireNode(node) {
if (!node || node.getAttribute("data-bxh-ready") === "true") return false;
node.setAttribute("data-bxh-ready", "true");
rootNode = node;
Bxh.state.reducedMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
loadHighScore();
if (Bxh.ui) {
Bxh.ui.init(node);
Bxh.ui.setHighScore(highScore);
}
if (Bxh.input) {
Bxh.input.init(node);
Bxh.input.setCaptureEnabled(false);
if (!globalHooksWired && Bxh.input.onCaptureLost) {
Bxh.input.onCaptureLost(onCaptureLost);
}
}
if (!globalHooksWired && Bxh.waves && Bxh.waves.onWaveStart) {
Bxh.waves.onWaveStart(onWaveStart);
}
globalHooksWired = true;
node.removeEventListener("pointerdown", onRootPointerDown);
node.addEventListener("pointerdown", onRootPointerDown);
var canvas = node.querySelector(".bxh-game__canvas");
if (Bxh.render && canvas) Bxh.render.init(canvas);
if (Bxh.audio) Bxh.audio.init();
if (Bxh.arena) Bxh.arena.setRoom("box");
bindUi();
/* Fresh DOM after GHL hydration always starts at the click-to-play gate. */
setScreen("focus");
if (!rafId) {
lastTs = 0;
rafId = requestAnimationFrame(tick);
}
return true;
}
function init(rootDoc) {
rootDoc = rootDoc || document;
var nodes = rootDoc.querySelectorAll("[data-bxh-component='game']");
var i;
for (i = 0; i < nodes.length; i++) wireNode(nodes[i]);
}
function start() {
init(document);
watchDom();
}
/**
* GHL/Nuxt often replaces Custom Code DOM after our first init, wiping
* listeners. Re-bind whenever a fresh game root appears.
*/
function watchDom() {
if (watchStarted || typeof MutationObserver === "undefined") return;
watchStarted = true;
try {
var obs = new MutationObserver(function () {
init(document);
});
obs.observe(document.documentElement, { childList: true, subtree: true });
} catch (err) {}
/* Also poll briefly — hydrationDone is not always fired. */
if (retryTimer) clearInterval(retryTimer);
retryCount = 0;
retryTimer = setInterval(function () {
retryCount += 1;
init(document);
if (retryCount >= 20) {
clearInterval(retryTimer);
retryTimer = null;
}
}, 500);
}
window.BxhGame = { version: VERSION, init: init, start: start };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
document.addEventListener("hydrationDone", start);
window.addEventListener("load", start);
})();