BOX HORDE

Survive the rooms. Keep the combo alive.

High score: 0

Click to play

P1
PISTOL:INF
P2
WASD to join
Player 1
Move
/ Fire (solo: Space)
,. Cycle weapons
16 Pick weapon
Player 2
WASD Move / join
Space Fire
QE Weapons
System
PEsc Pause
Keep combo high to unlock guns
Downed players revive next wave
/* 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); })();