battle-zone-royale / index.html
bbc123321's picture
Manual changes saved
67248b3 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BattleZone Royale</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/feather-icons"></script>
<style>
html,body { height:100%; margin:0; background:#0b1220; color:#fff; font-family:monospace; }
#canvasContainer { position:relative; flex:1; display:flex; justify-content:center; align-items:center; height:100vh; overflow:hidden; }
#gameCanvas { display:block; user-select:none; cursor:crosshair; box-shadow:0 0 20px rgba(0,0,0,.5); width:100%; height:100%; }
/* Minimap (top-right) */
#minimap { position: absolute; top:12px; right:12px; width:220px; height:140px; border-radius:8px; background: rgba(0,0,0,0.45); padding:6px; z-index:40; box-shadow: 0 6px 30px rgba(0,0,0,0.6); pointer-events: none; }
#minimapCanvas { width:100%; height:100%; display:block; image-rendering: pixelated; border-radius:6px; background:transparent; }
/* HUD */
#hudHealth { position:absolute; left:12px; bottom:12px; background:rgba(0,0,0,0.55); padding:6px 8px; border-radius:8px; font-weight:700; font-size:13px; display:flex; align-items:center; gap:8px; z-index:30; }
#hudGearWrap { position:absolute; right:12px; bottom:12px; display:flex; gap:8px; align-items:center; z-index:30; }
.pickaxe-slot { width:46px; height:46px; background: rgba(255,255,255,0.03); border-radius:6px; display:flex; align-items:center; justify-content:center; cursor:pointer; }
.gear-slot { min-width:46px; height:36px; background: rgba(255,255,255,0.03); border-radius:6px; display:flex; align-items:center; justify-content:center; font-size:11px; color:#ddd; padding:4px; position:relative; cursor:pointer; }
.selected { outline: 2px solid rgba(255,215,0,0.9); box-shadow: 0 0 6px rgba(255,215,0,0.12); }
.equipped { box-shadow: inset 0 -6px 14px rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.04); }
.medkit-count { position:absolute; right:4px; bottom:2px; font-size:10px; color:#ffd; }
.biome-selected { outline: 3px solid rgba(96,165,250,0.9); }
/* Loading screen */
#loadingScreen {
position: absolute;
inset: 0;
z-index: 80;
display: none;
align-items: center;
justify-content: center;
overflow: hidden;
color: #fff;
padding: 24px;
box-sizing: border-box;
}
/* animated gradient background */
.loading-bg {
position: absolute;
inset: -20%;
background: linear-gradient(120deg, #0b1220 0%, #123049 30%, #5b2a6a 60%, #2f4b2f 100%);
filter: blur(20px) saturate(1.1);
animation: bgShift 18s linear infinite;
transform: scale(1.1);
opacity: 0.85;
}
@keyframes bgShift {
0% { filter: hue-rotate(0deg) blur(20px); transform: scale(1.06) rotate(0.01deg); }
25% { filter: hue-rotate(45deg) blur(18px); transform: scale(1.08) rotate(0.02deg); }
50% { filter: hue-rotate(90deg) blur(22px); transform: scale(1.04) rotate(-0.01deg); }
75% { filter: hue-rotate(180deg) blur(20px); transform: scale(1.07) rotate(0.01deg); }
100% { filter: hue-rotate(360deg) blur(20px); transform: scale(1.06) rotate(-0.02deg); }
}
.loading-card {
position: relative;
z-index: 2;
max-width: 880px;
width: calc(100% - 48px);
background: rgba(6,10,18,0.55);
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 12px 40px rgba(0,0,0,0.7);
border-radius: 12px;
padding: 20px;
display:flex;
gap:18px;
align-items:center;
}
.loading-left {
flex: 1;
min-width: 260px;
display:flex; flex-direction:column; gap:10px;
}
.loading-right {
width: 260px;
display:flex; flex-direction:column; gap:12px; align-items:center;
}
.loader-spinner {
width: 84px; height:84px; border-radius:50%; position:relative;
display:flex; align-items:center; justify-content:center;
}
.ring {
position:absolute; inset:0; border-radius:50%; box-shadow: inset 0 0 24px rgba(255,255,255,0.02);
border: 6px solid rgba(255,255,255,0.06);
animation: ringRotate 1.8s linear infinite;
}
.ring::after {
content:'';
position:absolute; width:16px; height:16px; right:8px; top:50%; transform: translateY(-50%);
background: linear-gradient(90deg,#ffd86b,#8ef0ff);
border-radius:50%;
}
@keyframes ringRotate { to { transform: rotate(360deg); } }
.loader-dots { display:flex; gap:6px; }
.dot { width:10px; height:10px; border-radius:50%; background:#ffd86b; opacity:0.6; animation: dotPulse 1.2s infinite; }
.dot:nth-child(2){ animation-delay: 0.15s; background:#8ef0ff; }
.dot:nth-child(3){ animation-delay: 0.3s; background:#ff9fb8; }
@keyframes dotPulse { 0%{ transform: scale(.8); opacity:0.4 } 50%{ transform: scale(1.2); opacity:1 } 100%{ transform: scale(.8); opacity:0.4 } }
.tips { font-size:14px; color:#e8eef7; background: rgba(0,0,0,0.18); padding:10px; border-radius:8px; line-height:1.3; }
.loading-title { font-size:18px; font-weight:800; color:#ffd86b; display:flex; align-items:center; gap:8px; }
.progress-wrap { width:100%; background: rgba(255,255,255,0.04); height:14px; border-radius:8px; overflow:hidden; }
.progress-bar { height:100%; width:0%; background: linear-gradient(90deg,#ffd86b,#8ef0ff); transition: width 0.12s linear; }
.loading-count { font-size:12px; color:#dfe9f9; }
.disabled-pane { pointer-events: none; opacity: 0.6; filter: grayscale(12%); }
@media (max-width: 820px){
.loading-card { flex-direction:column; align-items:center; text-align:center; }
.loading-right { width:100%; }
}
/* Mobile controls */
.mobile-controls { position:absolute; inset:0; z-index:60; pointer-events:none; }
.mobile-joystick {
position: absolute;
left: 12px;
bottom: 12px;
width: 140px;
height: 140px;
border-radius: 50%;
background: rgba(255,255,255,0.04);
display:flex;
align-items:center;
justify-content:center;
pointer-events:auto;
touch-action:none;
-webkit-user-select:none;
user-select:none;
}
.mobile-joystick.aim {
right: 12px;
left: auto;
}
.mobile-joystick.small {
width:110px; height:110px;
}
.mobile-joystick .thumb {
width:58px; height:58px; border-radius:50%;
background: rgba(255,255,255,0.08);
display:block;
transform: translate(0,0);
transition: transform 0.06s linear;
}
.mobile-joystick.small .thumb { width:44px; height:44px; }
/* Buttons now reside left of pickaxe slot in HUD; this container is shown only on mobile */
.mobile-buttons-hud {
display:flex;
gap:8px;
align-items:center;
pointer-events:auto;
}
.mobile-btn {
width:76px;
height:76px;
border-radius:12px;
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(0,0,0,0.25));
display:flex;
align-items:center;
justify-content:center;
font-weight:700;
font-size:14px;
color:#fff;
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
-webkit-user-select:none;
user-select:none;
touch-action:none;
}
.mobile-btn.small { width:60px; height:48px; font-size:12px; border-radius:10px; }
.mobile-controls.hidden { display:none; }
@media (min-width: 900px){
.mobile-controls { display:none !important; }
.mobile-buttons-hud { display:none !important; } /* hide HUD buttons on desktop too */
}
/* Make minimap smaller on narrow/mobile */
@media (max-width: 900px){
#minimap { width:120px; height:78px; right:8px; top:8px; padding:5px; }
}
/* New: Player overlay image that always follows player on screen */
.player-overlay {
position: absolute;
pointer-events: none;
z-index: 50;
transform-origin: center bottom; /* anchor point under the image near player's head */
will-change: transform, left, top;
}
/* New: HUD materials counter (left of kills) */
.hud-materials {
background:#111827;
padding:.5rem .75rem;
border-radius:.5rem;
display:flex;
align-items:center;
gap:.5rem;
}
.hud-materials img { width:18px; height:18px; border-radius:3px; object-fit:cover; opacity:.95; }
.hud-materials span { font-weight:700; color:#cfe0a6; }
</style>
</head>
<body>
<!-- Landing -->
<div id="landingScreen" class="flex flex-col items-center justify-center h-screen p-6">
<h1 class="text-5xl font-bold text-yellow-400 mb-6 flex items-center">
<i data-feather="target" class="mr-2"></i> BattleZone Royale
</h1>
<h2 class="text-2xl text-yellow-300 mb-4">Choose Landing Zone</h2>
<div id="biomeGrid" class="grid grid-cols-2 gap-6 max-w-xl w-full mb-6">
<div class="biome-selector bg-gray-700 p-6 rounded-lg cursor-pointer hover:bg-yellow-800 flex flex-col items-center" data-biome="desert">
<i data-feather="sun" class="text-yellow-300 mb-2"></i>
<h3 class="font-bold text-lg">Golden Dunes</h3>
<p class="text-xs">High loot, high risk</p>
</div>
<div class="biome-selector bg-gray-700 p-6 rounded-lg cursor-pointer hover:bg-green-800 flex flex-col items-center" data-biome="forest">
<i data-feather="tree" class="text-green-300 mb-2"></i>
<h3 class="font-bold text-lg">Shadow Forest</h3>
<p class="text-xs">Good cover, medium loot</p>
</div>
<div class="biome-selector bg-gray-700 p-6 rounded-lg cursor-pointer hover:bg-blue-800 flex flex-col items-center" data-biome="oasis">
<i data-feather="droplet" class="text-blue-300 mb-2"></i>
<h3 class="font-bold text-lg">Crystal Oasis</h3>
<p class="text-xs">Medium loot, low risk</p>
</div>
<div class="biome-selector bg-gray-700 p-6 rounded-lg cursor-pointer hover:bg-purple-800 flex flex-col items-center" data-biome="ruins">
<i data-feather="layers" class="text-purple-300 mb-2"></i>
<h3 class="font-bold text-lg">Ancient Ruins</h3>
<p class="text-xs">Legendary loot, dangerous</p>
</div>
</div>
<div class="bg-gray-800 p-4 rounded-lg max-w-xl w-full text-sm">
<h3 class="font-bold text-yellow-400 mb-2">Controls</h3>
<ul class="list-disc pl-5 space-y-1">
<li><strong>WASD</strong> — Move</li>
<li><strong>Mouse</strong> — Aim</li>
<li><strong>Left Click</strong> — Shoot (if weapon equipped or in selected slot) or pickaxe melee</li>
<li><strong>E</strong> — Use medkit in selected slot OR Interact / Loot (press once)</li>
<li><strong>Q</strong> — Build (costs 10 materials)</li>
<li><strong>R</strong> — Reload selected weapon</li>
<li><strong>1-5</strong> — Select inventory slot (also equips it)</li>
<li><strong>F</strong> — Equip / Unequip selected slot (pickaxe is the default)</li>
</ul>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingScreen" aria-hidden="true">
<div class="loading-bg" aria-hidden="true"></div>
<div class="loading-card" role="status" aria-live="polite">
<div class="loading-left">
<div class="loading-title"><i data-feather="download"></i> Preparing Drop Zone</div>
<div class="tips" id="loadingTip">Loading tips...</div>
<div class="loading-count" id="loadingTimerText">Landing in 20s</div>
<div style="height:8px"></div>
<div class="progress-wrap" aria-hidden="true">
<div id="loadingProgress" class="progress-bar"></div>
</div>
</div>
<div class="loading-right">
<div class="loader-spinner" aria-hidden="true">
<div class="ring"></div>
</div>
<div class="loader-dots" aria-hidden="true">
<div class="dot"></div><div class="dot"></div><div class="dot"></div>
</div>
<div style="height:6px"></div>
<div style="font-size:12px;color:#dbeafe;">Optimizing match assets...</div>
</div>
</div>
</div>
<!-- Game -->
<div id="gameScreen" class="hidden h-screen flex flex-col">
<header class="flex justify-between items-center border-b border-yellow-500 px-6 py-4" style="flex-shrink:0;">
<h1 class="text-3xl font-bold text-yellow-400 flex items-center"><i data-feather="target" class="mr-2"></i>BattleZone Royale</h1>
<div class="flex space-x-4 items-center">
<!-- Materials HUD will be inserted dynamically here by script (left of kills) -->
<div style="background:#111827;padding:.5rem .75rem;border-radius:.5rem;display:flex;align-items:center;gap:.5rem;">
<i data-feather="award"></i><span id="killCount">0</span>
</div>
<div style="background:#111827;padding:.5rem .75rem;border-radius:.5rem;display:flex;align-items:center;gap:.5rem;">
<i data-feather="map-pin"></i><span id="currentBiome">-</span>
</div>
<div style="background:#111827;padding:.5rem .75rem;border-radius:.5rem;display:flex;align-items:center;gap:.5rem;">
<i data-feather="clock"></i><span id="gameTimer">5:00</span>
</div>
<div style="background:#111827;padding:.5rem .75rem;border-radius:.5rem;display:flex;align-items:center;gap:.5rem;">
<i data-feather="users"></i><span id="playerCount">1/50</span>
</div>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<div id="canvasContainer" class="relative bg-gray-900 border-4 border-gray-700 rounded-xl max-w-full max-h-full">
<canvas id="gameCanvas"></canvas>
<!-- Minimap (top-right) -->
<div id="minimap">
<canvas id="minimapCanvas" width="220" height="140"></canvas>
</div>
<div id="stormWarning" class="hidden absolute top-6 left-1/2 transform -translate-x-1/2 bg-red-900 bg-opacity-80 text-white px-6 py-3 rounded-lg flex items-center">
<i data-feather="alert-circle" class="mr-2"></i>
<span>STORM APPROACHING! MOVE TO SAFE ZONE!</span>
</div>
<div id="deathScreen" class="hidden absolute inset-0 bg-black bg-opacity-80 flex flex-col items-center justify-center rounded-xl">
<i data-feather="skull" class="w-16 h-16 text-red-500 mb-4"></i>
<h2 class="text-4xl font-bold text-red-500 mb-4">YOU DIED!</h2>
<p class="text-xl mb-6">Better luck next time, soldier!</p>
<button id="respawnBtn" class="bg-yellow-500 hover:bg-yellow-600 text-black font-bold py-3 px-6 rounded-lg flex items-center">
<i data-feather="refresh-cw" class="mr-2"></i> Respawn
</button>
</div>
<div id="victoryScreen" class="hidden absolute inset-0 bg-black bg-opacity-80 flex flex-col items-center justify-center rounded-xl">
<h2 class="text-5xl font-bold text-green-400 mb-4">VICTORY</h2>
<p class="text-xl mb-6">You are the last player standing!</p>
<div class="flex gap-4">
<button id="goHomeBtn" class="bg-yellow-500 hover:bg-yellow-600 text-black font-bold py-3 px-6 rounded-lg flex items-center">
<i data-feather="home" class="mr-2"></i> Go to Home
</button>
<button id="continueBtn" class="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg flex items-center">
<i data-feather="repeat" class="mr-2"></i> Play Again
</button>
</div>
</div>
<div id="hudHealth" class="hidden">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffd86b" stroke-width="1.6"><path d="M20.8 8.6a5.5 5.5 0 0 0-7.8 0L12 10.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 22l7.8-3.6 1-1a5.5 5.5 0 0 0 0-7.8z"></path></svg>
<span id="hudHealthText">100%</span>
</div>
<div id="hudGearWrap" class="hidden">
<!-- Mobile buttons moved here (left of pickaxe slot) -->
<div id="mobileButtonsHud" class="mobile-buttons-hud hidden" aria-hidden="true">
<div id="shootBtn" class="mobile-btn small" aria-label="Shoot">SHOOT</div>
<div id="reloadBtn" class="mobile-btn small" aria-label="Reload">RELOAD</div>
<div id="interactBtn" class="mobile-btn small" aria-label="Interact">USE</div>
<div id="buildBtn" class="mobile-btn small" aria-label="Build">BUILD</div>
</div>
<div id="pickaxeSlot" class="pickaxe-slot" title="Pickaxe (press F to equip)">⛏️</div>
<div id="hudGear" aria-label="gear"></div>
</div>
<!-- Mobile Controls: left joystick (movement) and right joystick (aim) -->
<div id="mobileControls" class="mobile-controls hidden" aria-hidden="true">
<div id="mobileJoystick" class="mobile-joystick" role="application" aria-label="Movement joystick">
<div id="joystickThumb" class="thumb"></div>
</div>
<div id="mobileJoystickAim" class="mobile-joystick aim small" role="application" aria-label="Aiming joystick">
<div id="joystickThumbAim" class="thumb"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// Global mobile detection
const IS_MOBILE = ('ontouchstart' in window) || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
// DOM references
const landingScreen = document.getElementById('landingScreen');
const gameScreen = document.getElementById('gameScreen');
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const hudHealth = document.getElementById('hudHealth');
const hudHealthText = document.getElementById('hudHealthText');
const hudGearWrap = document.getElementById('hudGearWrap');
const hudGear = document.getElementById('hudGear');
const pickaxeSlot = document.getElementById('pickaxeSlot');
const stormWarning = document.getElementById('stormWarning');
const deathScreen = document.getElementById('deathScreen');
const victoryScreen = document.getElementById('victoryScreen');
const goHomeBtn = document.getElementById('goHomeBtn');
const continueBtn = document.getElementById('continueBtn');
// Mobile button references (in HUD)
const mobileButtonsHud = document.getElementById('mobileButtonsHud');
const mobileControls = document.getElementById('mobileControls');
// Loading screen elements
const loadingScreen = document.getElementById('loadingScreen');
const loadingTipEl = document.getElementById('loadingTip');
const loadingProgressEl = document.getElementById('loadingProgress');
const loadingTimerText = document.getElementById('loadingTimerText');
// Minimap elements and cache
const minimapCanvas = document.getElementById('minimapCanvas');
const miniCtx = minimapCanvas.getContext('2d');
let miniTerrainCache = null;
// World
const WORLD = { width: 6000, height: 4000 };
let camera = { x:0, y:0 };
function resizeCanvas(){
const ctn = document.getElementById('canvasContainer');
canvas.width = Math.max(900, Math.floor(ctn.clientWidth));
canvas.height = Math.max(560, Math.floor(ctn.clientHeight));
// make minimap smaller on mobile
if (IS_MOBILE){
minimapCanvas.width = 120;
minimapCanvas.height = 78;
} else {
minimapCanvas.width = 220;
minimapCanvas.height = 140;
}
cameraUpdate();
miniTerrainCache = null; // rebuild on resize
}
window.addEventListener('resize', resizeCanvas);
// Player
const player = {
id:'player', x: WORLD.width/2, y: WORLD.height/2, radius:16, angle:0, speed:220,
health:100, armor:0, kills:0, materials:0,
inventory: [null,null,null,null,null],
selectedSlot:0,
equippedIndex: -1,
lastShot:0, lastMelee:0,
lastMedkitUsed: 0 // cooldown tracker for instant medkit presses
};
// Input
const keys = { w:false,a:false,s:false,d:false,e:false,q:false,r:false,f:false };
const mouse = { canvasX:0, canvasY:0, worldX:0, worldY:0, down:false };
function equipSlot(index){ player.equippedIndex = index; updateHUD(); }
window.addEventListener('keydown',(e)=>{
const k = e.key.toLowerCase();
if (gameActive){
if (k in keys) keys[k] = true;
if (['1','2','3','4','5'].includes(k)) {
const idx = parseInt(k)-1;
player.selectedSlot = idx;
equipSlot(idx);
updateHUD();
}
if (k === 'f') {
if (player.equippedIndex === player.selectedSlot) equipSlot(-1);
else equipSlot(player.selectedSlot);
updateHUD();
}
if (k === 'r') keys.r = true;
} else {
if (['1','2','3','4','5'].includes(k)) {
player.selectedSlot = parseInt(k)-1;
updateHUD();
}
}
});
window.addEventListener('keyup',(e)=>{
const k = e.key.toLowerCase();
if (k in keys) keys[k] = false;
// Press E once to use medkit or interact (no holding)
if (k === 'e') {
attemptUseOrInteract();
}
if (k === 'q') keys.q = true;
});
canvas.addEventListener('mousemove',(e)=>{
const rect = canvas.getBoundingClientRect();
mouse.canvasX = e.clientX - rect.left;
mouse.canvasY = e.clientY - rect.top;
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
});
canvas.addEventListener('mousedown', (e)=>{
const rect = canvas.getBoundingClientRect();
mouse.canvasX = e.clientX - rect.left;
mouse.canvasY = e.clientY - rect.top;
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
mouse.down = true;
});
window.addEventListener('mouseup', ()=> mouse.down = false);
// Touch support for canvas aiming (still supported)
(function addCanvasTouchHandlers(){
let canvasTouchId = null;
canvas.addEventListener('touchstart', (ev) => {
for (const t of ev.changedTouches){
const rect = canvas.getBoundingClientRect();
const x = t.clientX - rect.left, y = t.clientY - rect.top;
if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height){
canvasTouchId = t.identifier;
mouse.canvasX = x; mouse.canvasY = y;
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
ev.preventDefault();
break;
}
}
}, { passive:false });
canvas.addEventListener('touchmove', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === canvasTouchId){
const rect = canvas.getBoundingClientRect();
mouse.canvasX = t.clientX - rect.left; mouse.canvasY = t.clientY - rect.top;
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
ev.preventDefault();
}
}
}, { passive:false });
window.addEventListener('touchend', (ev) => {
for (const t of ev.changedTouches) if (t.identifier === canvasTouchId) canvasTouchId = null;
});
window.addEventListener('touchcancel', (ev) => {
for (const t of ev.changedTouches) if (t.identifier === canvasTouchId) canvasTouchId = null;
});
})();
// Entities
const bullets = [];
const chests = [];
const objects = [];
const enemies = [];
const pickups = [];
function rand(min,max){ return Math.random()*(max-min)+min; }
function randInt(min,max){ return Math.floor(Math.random()*(max-min))+min; }
function biomeAt(x,y){
const bx = Math.floor(x / 500), by = Math.floor(y / 500);
const seed = (bx*73856093) ^ (by*19349663);
const r = Math.abs(Math.sin(seed)) % 1;
if (r < 0.25) return 'desert';
if (r < 0.55) return 'forest';
if (r < 0.75) return 'oasis';
return 'ruins';
}
function makeWeaponProto(w){
return { name:w.name, dmg:w.dmg, rate:w.rate, color:w.color, magSize:w.magSize || 12, startReserve:w.startReserve || (w.magSize*2 || 24) };
}
function generateLootForBiome(b){
const roll = Math.random();
if (roll < 0.35) return { type:'medkit', amount:1 };
if (roll < 0.7) return { type:'materials', amount: 10 };
const weapons = [
{ name:'Pistol', dmg:12, rate:320, color:'#ffd86b', magSize:12, startReserve:24 },
{ name:'SMG', dmg:6, rate:120, color:'#8ef0ff', magSize:30, startReserve:90 },
{ name:'Shotgun', dmg:22, rate:800, color:'#ff9fb8', magSize:6, startReserve:18 },
{ name:'Rifle', dmg:18, rate:400, color:'#c7ff9a', magSize:20, startReserve:60 }
];
return { type:'weapon', weapon: weapons[randInt(0, weapons.length)] };
}
// Behaviour tuning
const VIEW_RANGE = 1200;
const SPAWN_PROTECT_MS = 1200;
// collision helpers
function getObjectRadius(obj){
if (!obj) return 18;
if (obj.type === 'wall') return 28;
if (obj.type === 'stone') return 18;
if (obj.type === 'wood') return 18;
return 16;
}
function chestRadius(){ return 18; }
function circleOverlap(x1,y1,r1,x2,y2,r2){
return Math.hypot(x1-x2,y1-y2) < (r1 + r2);
}
function isCollidingSolid(x,y,r){
for (const o of objects){
if (o.dead) continue;
const rr = getObjectRadius(o);
if (circleOverlap(x,y,r,o.x,o.y,rr)) return true;
}
for (const c of chests){
if (c.opened) continue;
if (circleOverlap(x,y,r,c.x,c.y,chestRadius())) return true;
}
return false;
}
function moveEntityWithCollision(entity, dx, dy, radius){
const oldX = entity.x, oldY = entity.y;
let nx = entity.x + dx;
entity.x = nx;
if (entity.x < radius) entity.x = radius;
if (entity.x > WORLD.width - radius) entity.x = WORLD.width - radius;
if (isCollidingSolid(entity.x, entity.y, radius)){
entity.x = oldX;
}
let ny = entity.y + dy;
entity.y = ny;
if (entity.y < radius) entity.y = radius;
if (entity.y > WORLD.height - radius) entity.y = WORLD.height - radius;
if (isCollidingSolid(entity.x, entity.y, radius)){
entity.y = oldY;
}
}
// Populate world
function populateWorld(){
chests.length = 0; objects.length = 0; enemies.length = 0; pickups.length = 0;
for (let i=0;i<260;i++){
const x = rand(150, WORLD.width-150);
const y = rand(150, WORLD.height-150);
const loot = generateLootForBiome(biomeAt(x,y));
if (loot.type === 'materials') loot.amount = 10;
chests.push({ x,y, opened:false, loot });
}
for (let i=0;i<700;i++){
const t = Math.random();
let type='wood';
if (t>0.78) type='stone';
if (t>0.96) type='wall';
const x = rand(60, WORLD.width-60), y = rand(60, WORLD.height-60);
const hp = type==='wood'?40 : (type==='stone'?80:160);
objects.push({ x,y, type, hp, maxHp:hp, dead:false });
}
const now = performance.now();
for (let i=0;i<49;i++){
const ex = rand(300, WORLD.width-300);
const ey = rand(300, WORLD.height-300);
const enemy = {
id:'e'+i, x:ex, y:ey, radius:14, angle:rand(0,Math.PI*2), speed:110+rand(-20,20),
health: 80 + randInt(0,40), lastMelee:0, meleeRate:800 + randInt(-200,200),
roamTimer: rand(0,3),
inventory: [null,null,null,null,null],
selectedSlot: 0,
equippedIndex: -1,
materials: 0,
lastShot: 0,
reloadingUntil: 0,
reloadPending: false,
lastAttackedTime: 0,
state: 'gather',
gatherTimeLeft: rand(8,16),
target: null,
nextHealTime: 0,
spawnSafeUntil: now + SPAWN_PROTECT_MS,
tempTarget: null,
tempTargetExpiry: 0,
prioritizeChestsUntil: 0,
// medkit state for enemies
usingMedkit: false, usingMedkitStart: 0, usingMedkitUntil: 0, usingMedkitSlot: -1
};
enemy.prioritizeChestsUntil = now + 10000 + rand(0,2000);
enemy.gatherTimeLeft = Math.min(enemy.gatherTimeLeft, 10);
enemies.push(enemy);
}
updatePlayerCount();
}
// HUD
function initHUD(){
hudHealth.classList.remove('hidden');
hudGearWrap.classList.remove('hidden');
hudGear.innerHTML = '';
for (let i=0;i<5;i++){
const slot = document.createElement('div');
slot.className = 'gear-slot';
slot.dataset.index = i;
slot.addEventListener('click', ()=> {
const idx = parseInt(slot.dataset.index);
player.selectedSlot = idx;
if (player.equippedIndex === idx) equipSlot(-1);
else equipSlot(idx);
updateHUD();
});
hudGear.appendChild(slot);
}
pickaxeSlot.onclick = () => {
if (player.equippedIndex === -1) {
equipSlot(player.selectedSlot);
} else {
equipSlot(-1);
}
updateHUD();
};
updateHUD();
}
function updateHUD(){
const now = performance.now();
let healthText = `${Math.max(0,Math.floor(player.health))}%`;
hudHealthText.textContent = healthText;
const slots = hudGear.querySelectorAll('.gear-slot');
slots.forEach(s => {
const idx = parseInt(s.dataset.index);
const it = player.inventory[idx];
s.classList.toggle('selected', idx === player.selectedSlot);
s.classList.toggle('equipped', player.equippedIndex === idx);
if (!it) s.innerHTML = 'Empty';
else if (it.type === 'weapon') s.innerHTML = `<div style="text-align:center;"><div style="font-size:11px">${it.weapon.name}</div><div style="color:${it.weapon.color}">${it.ammoInMag}/${it.ammoReserve}</div></div>`;
else if (it.type === 'medkit') s.innerHTML = `<div style="font-size:12px">Med</div><div class="medkit-count">x${it.amount}</div>`;
else if (it.type === 'materials') s.innerHTML = `<div style="font-size:11px">Mat</div><div class="medkit-count">x${it.amount}</div>`;
else s.innerHTML = 'Item';
});
pickaxeSlot.classList.toggle('selected', player.equippedIndex === -1);
pickaxeSlot.title = (player.equippedIndex === -1) ? 'Pickaxe (equipped)' : 'Pickaxe (click or press F to equip)';
feather.replace();
}
// Camera
function cameraUpdate(){
if (!canvas.width || !canvas.height) return;
camera.x = player.x - canvas.width/2;
camera.y = player.y - canvas.height/2;
camera.x = Math.max(0, Math.min(camera.x, WORLD.width - canvas.width));
camera.y = Math.max(0, Math.min(camera.y, WORLD.height - canvas.height));
}
function worldToScreen(wx,wy){ return { x: Math.round(wx - camera.x), y: Math.round(wy - camera.y) }; }
// Combat utilities
function shootBullet(originX, originY, targetX, targetY, weaponObj, shooterId){
if (!weaponObj || (typeof weaponObj.ammoInMag === 'number' && weaponObj.ammoInMag <= 0)) return false;
const speed = 1100;
const angle = Math.atan2(targetY-originY, targetX-originX);
if (typeof weaponObj.ammoInMag === 'number') weaponObj.ammoInMag -= 1;
const dmg = weaponObj.weapon && weaponObj.weapon.dmg ? weaponObj.weapon.dmg : (weaponObj.dmg || 10);
const color = (weaponObj.weapon && weaponObj.weapon.color) || weaponObj.color || '#fff';
bullets.push({
x: originX, y: originY, vx: Math.cos(angle)*speed, vy: Math.sin(angle)*speed,
dmg: dmg, color: color, life:1.6, traveled:0,
shooter: shooterId, born: performance.now(), tracer:false
});
return true;
}
function reloadItem(item){
if (!item || item.type !== 'weapon') return;
const need = item.weapon.magSize - item.ammoInMag;
if (need <= 0 || item.ammoReserve <= 0) return;
const take = Math.min(need, item.ammoReserve);
item.ammoInMag += take;
item.ammoReserve -= take;
}
function reloadEquipped(){
let item = null;
if (player.equippedIndex >= 0) item = player.inventory[player.equippedIndex];
else item = player.inventory[player.selectedSlot];
reloadItem(item);
updateHUD();
}
function playerMeleeHit(){
const now = performance.now();
if (now - player.lastMelee < 350) return;
player.lastMelee = now;
for (const e of enemies){
if (e.health <= 0) continue;
const d = Math.hypot(e.x - player.x, e.y - player.y);
if (d < 36){ e.health -= 18; if (e.health <= 0) { e.health = 0; player.kills++; player.materials += 2; updatePlayerCount(); e.lastAttackedTime = performance.now(); } }
}
for (const obj of objects){
if (obj.dead) continue;
const d = Math.hypot(obj.x - player.x, obj.y - player.y);
if (d < 36){
obj.hp -= 20;
if (obj.hp <= 0){ obj.dead = true; player.materials += (obj.type === 'wood' ? 3 : 6); }
}
}
}
// Player interact & medkit usage - instantaneous on press, usable while moving/taking damage
function attemptUseOrInteract(){
const sel = player.selectedSlot;
const selItem = player.inventory[sel];
if (selItem && selItem.type === 'medkit'){
useMedkitInstant(sel);
} else {
interactNearby();
}
}
function useMedkitInstant(slot){
const it = player.inventory[slot];
if (!it || it.type !== 'medkit' || it.amount <= 0) return;
const now = performance.now();
// small cooldown to avoid spamming
if (player.lastMedkitUsed && now - player.lastMedkitUsed < 700) return;
player.lastMedkitUsed = now;
it.amount -= 1;
player.health = Math.min(100, player.health + 50);
if (it.amount <= 0) player.inventory[slot] = null;
updateHUD();
}
function interactNearby(){
const sel = player.selectedSlot;
const selItem = player.inventory[sel];
const range = 56;
for (const chest of chests){
if (chest.opened) continue;
const d = Math.hypot(chest.x - player.x, chest.y - player.y);
if (d < range){
chest.opened = true;
const loot = chest.loot;
const px = chest.x + rand(-20,20), py = chest.y + rand(-20,20);
if (loot.type === 'weapon') pickups.push({ x:px, y:py, type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag: loot.weapon.magSize || 12, ammoReserve: loot.weapon.startReserve || (loot.weapon.magSize*2 || 24) });
else if (loot.type === 'medkit') pickups.push({ x:px, y:py, type:'medkit', amount: loot.amount || 1 });
else if (loot.type === 'materials') pickups.push({ x:px, y:py, type:'materials', amount: loot.amount || 10 });
if (Math.random() < 0.25) pickups.push({ x:px+8, y:py+8, type:'ammo', forWeapon: null, amount: randInt(6,30) });
updateHUD();
return;
}
}
for (let i=pickups.length-1;i>=0;i--){
const p = pickups[i];
const d = Math.hypot(p.x - player.x, p.y - player.y);
if (d < range){
pickupCollect(p);
pickups.splice(i,1);
updateHUD();
return;
}
}
}
function pickupCollect(p){
if (p.type === 'weapon'){
let merged=false;
for (let s=0;s<5;s++){
const it = player.inventory[s];
if (it && it.type==='weapon' && it.weapon.name === p.weapon.name){
it.ammoReserve += p.ammoReserve;
it.ammoInMag = Math.min(it.weapon.magSize, it.ammoInMag + p.ammoInMag);
merged=true; break;
}
}
if (!merged){
let placed=false;
for (let s=0;s<5;s++){ if (!player.inventory[s]) { player.inventory[s] = { type:'weapon', weapon:p.weapon, ammoInMag:p.ammoInMag, ammoReserve:p.ammoReserve }; placed=true; break; } }
if (!placed) player.inventory[player.selectedSlot] = { type:'weapon', weapon:p.weapon, ammoInMag:p.ammoInMag, ammoReserve:p.ammoReserve };
}
} else if (p.type === 'medkit'){
let stacked=false;
for (let s=0;s<5;s++){ const it=player.inventory[s]; if (it && it.type==='medkit'){ it.amount += p.amount; stacked=true; break; } }
if (!stacked){ let placed=false; for (let s=0;s<5;s++){ if (!player.inventory[s]) { player.inventory[s] = { type:'medkit', amount:p.amount }; placed=true; break; } } if (!placed) player.materials += 5; }
} else if (p.type === 'materials'){
player.materials += p.amount;
} else if (p.type === 'ammo'){
let added=false;
for (let s=0;s<5;s++){ const it=player.inventory[s]; if (it && it.type==='weapon'){ it.ammoReserve += p.amount; added=true; break; } }
if (!added) player.materials += p.amount;
}
// Update materials HUD whenever materials change
if (typeof updateMaterialsHUD === 'function') updateMaterialsHUD();
}
// Enemy medkit logic kept unchanged (they still take 3s)
function enemyEquipBestWeapon(e){
let bestIdx = -1;
let bestScore = -Infinity;
for (let i=0;i<5;i++){
const it = e.inventory[i];
if (it && it.type === 'weapon'){
const score = (it.weapon.dmg || 1) / (it.weapon.rate || 300) + (it.ammoInMag > 0 ? 0.5 : 0);
if (score > bestScore){ bestScore = score; bestIdx = i; }
}
}
if (bestIdx !== -1) e.equippedIndex = bestIdx;
else e.equippedIndex = -1;
}
function enemyStartMedkitUse(e, now){
if (e.usingMedkit) return false;
for (let s=0;s<5;s++){
const it = e.inventory[s];
if (it && it.type === 'medkit' && it.amount > 0){
e.usingMedkit = true;
e.usingMedkitStart = now;
e.usingMedkitUntil = now + 3000;
e.usingMedkitSlot = s;
e.nextHealTime = now + 5000;
return true;
}
}
return false;
}
function applyEnemyMedkitUseNow(e){
const s = e.usingMedkitSlot;
if (s >= 0){
const it = e.inventory[s];
if (it && it.type === 'medkit'){
it.amount -= 1;
e.health = Math.min(120, e.health + 50);
if (it.amount <= 0) e.inventory[s] = null;
}
}
e.usingMedkit = false;
e.usingMedkitSlot = -1;
e.usingMedkitStart = 0;
e.usingMedkitUntil = 0;
}
function enemyPickupCollect(e, p){
if (!e || !p) return;
if (p.type === 'weapon'){
const w = p.weapon ? p.weapon : makeWeaponProto(p);
const ammoInMag = (typeof p.ammoInMag === 'number') ? p.ammoInMag : (w.magSize || 12);
const ammoReserve = (typeof p.ammoReserve === 'number') ? p.ammoReserve : (w.startReserve || 24);
for (let s=0;s<5;s++){
const it = e.inventory[s];
if (it && it.type==='weapon' && it.weapon.name === w.name){
it.ammoReserve += ammoReserve;
it.ammoInMag = Math.min(it.weapon.magSize, (it.ammoInMag || 0) + ammoInMag);
enemyEquipBestWeapon(e);
if (it.ammoInMag > 0) e.equippedIndex = s;
e.state = 'combat';
e.lastAttackedTime = performance.now();
return;
}
}
for (let s=0;s<5;s++){
if (!e.inventory[s]) {
e.inventory[s] = { type:'weapon', weapon: w, ammoInMag: ammoInMag, ammoReserve: ammoReserve };
enemyEquipBestWeapon(e);
if (e.inventory[s].ammoInMag > 0) e.equippedIndex = s;
e.state = 'combat';
e.lastAttackedTime = performance.now();
return;
}
}
let worstIdx = -1, worstScore = Infinity;
for (let s=0;s<5;s++){
const it = e.inventory[s];
if (it && it.type==='weapon'){
const score = (it.weapon.dmg || 1) / (it.weapon.rate || 300);
if (score < worstScore){ worstScore = score; worstIdx = s; }
}
}
const pickupScore = (w.dmg || 1) / (w.rate || 300);
if (pickupScore > worstScore && worstIdx !== -1){
e.inventory[worstIdx] = { type:'weapon', weapon: w, ammoInMag: ammoInMag, ammoReserve: ammoReserve };
enemyEquipBestWeapon(e);
e.state = 'combat';
e.lastAttackedTime = performance.now();
} else {
e.materials += Math.floor((ammoReserve || 0) / 2);
}
} else if (p.type === 'medkit'){
for (let s=0;s<5;s++){
const it = e.inventory[s];
if (it && it.type==='medkit'){ it.amount += p.amount;
if (e.health < 60) {
if (!e.usingMedkit) enemyStartMedkitUse(e, performance.now());
}
return;
}
}
for (let s=0;s<5;s++){ if (!e.inventory[s]) { e.inventory[s] = { type:'medkit', amount:p.amount };
if (e.health < 60) enemyStartMedkitUse(e, performance.now());
return; } }
e.materials += 3;
} else if (p.type === 'materials'){
e.materials += p.amount;
} else if (p.type === 'ammo'){
for (let s=0;s<5;s++){ const it=e.inventory[s]; if (it && it.type==='weapon'){ it.ammoReserve += p.amount; return; } }
e.materials += p.amount;
}
}
function tryBuild(){
if (player.materials < 10) return;
player.materials -= 10;
const bx = player.x + Math.cos(player.angle) * 48;
const by = player.y + Math.sin(player.angle) * 48;
objects.push({ x:bx, y:by, type:'wall', hp:160, maxHp:160, dead:false });
updateHUD();
if (typeof updateMaterialsHUD === 'function') updateMaterialsHUD();
}
function enemyTryBuild(e){
if (e.materials < 10) return false;
e.materials -= 10;
const bx = e.x + Math.cos(e.angle) * 48;
const by = e.y + Math.sin(e.angle) * 48;
objects.push({ x:bx, y:by, type:'wall', hp:160, maxHp:160, dead:false });
return true;
}
// LOS helpers
function hasLineOfSight(x1,y1,x2,y2){
const vx = x2 - x1, vy = y2 - y1;
const vlen2 = vx*vx + vy*vy;
for (const obj of objects){
if (obj.dead) continue;
let br=0;
if (obj.type==='wall') br=28; else if (obj.type==='stone') br=22; else if (obj.type==='wood') br=16; else continue;
const wx = obj.x - x1, wy = obj.y - y1;
const c1 = vx*wx + vy*wy;
const t = vlen2>0 ? c1/vlen2 : 0;
if (t < 0 || t > 1) continue;
const projx = x1 + vx * t, projy = y1 + vy * t;
const dist = Math.hypot(projx - obj.x, projy - obj.y);
if (dist < br) return false;
}
return true;
}
function findBlockingObject(x1,y1,x2,y2){
const vx = x2 - x1, vy = y2 - y1;
const vlen2 = vx*vx + vy*vy;
let closest = null; let closestT = Infinity;
for (const obj of objects){
if (obj.dead) continue;
let br=0;
if (obj.type==='wall') br=28; else if (obj.type==='stone') br=22; else if (obj.type==='wood') br=16; else continue;
const wx = obj.x - x1, wy = obj.y - y1;
const c1 = vx*wx + vy*wy;
const t = vlen2>0 ? c1/vlen2 : 0;
if (t < 0 || t > 1) continue;
const projx = x1 + vx * t, projy = y1 + vy * t;
const dist = Math.hypot(projx - obj.x, projy - obj.y);
if (dist < br && t < closestT){ closestT = t; closest = obj; }
}
return closest;
}
// New helper: compute detour waypoint around blocker
function computeDetourWaypoint(fromX, fromY, blocker, goalX, goalY, padding = 12){
if (!blocker) return null;
let br = 0;
if (blocker.type === 'wall') br = 28;
else if (blocker.type === 'stone') br = 22;
else if (blocker.type === 'wood') br = 16;
else br = 18;
const vx = fromX - blocker.x;
const vy = fromY - blocker.y;
const len = Math.hypot(vx, vy) || 0.0001;
const px = -vy / len;
const py = vx / len;
const radius = br + padding + 8;
const cand1 = { x: blocker.x + px * radius, y: blocker.y + py * radius };
const cand2 = { x: blocker.x - px * radius, y: blocker.y - py * radius };
const d1 = Math.hypot(cand1.x - goalX, cand1.y - goalY);
const d2 = Math.hypot(cand2.x - goalX, cand2.y - goalY);
const chosen = d1 < d2 ? cand1 : cand2;
chosen.x = Math.max(16, Math.min(WORLD.width-16, chosen.x));
chosen.y = Math.max(16, Math.min(WORLD.height-16, chosen.y));
return chosen;
}
// Bullets update (keep same but removed medkit cancellation when taking damage)
function bulletsUpdate(dt){
for (let i=bullets.length-1;i>=0;i--){
const b = bullets[i];
b.x += b.vx * dt; b.y += b.vy * dt;
b.traveled += Math.hypot(b.vx*dt, b.vy*dt);
b.life -= dt;
// 1) Hit player (if bullet not from player) — damage then despawn
const hitRadiusPlayer = 16;
if (b.shooter !== 'player'){
const dPlayer = Math.hypot(player.x - b.x, player.y - b.y);
if (dPlayer < hitRadiusPlayer){
// apply damage
player.health -= b.dmg;
if (player.health <= 0){ player.health = 0; playerDeath(); }
bullets.splice(i,1);
continue;
}
}
// 2) Hit any enemy (if not the shooter) — damage then despawn
let hitEnemy = null;
for (const e of enemies){
if (e.health <= 0) continue;
const d = Math.hypot(e.x - b.x, e.y - b.y);
if (d < 14 && b.shooter !== e.id){
// apply damage
e.health -= b.dmg;
e.lastAttackedTime = performance.now();
// cancel enemy medkit if they are healing
if (e.usingMedkit){
e.usingMedkit = false; e.usingMedkitSlot = -1; e.usingMedkitStart = 0; e.usingMedkitUntil = 0;
}
if (e.health <= 0){
e.health = 0;
if (b.shooter === 'player'){ player.kills++; player.materials += 2; updatePlayerCount(); }
}
hitEnemy = e;
break;
}
}
if (hitEnemy){
bullets.splice(i,1);
continue;
}
// 3) Hit harvestable terrain or wall (wood/stone/wall) — reduce hp and despawn
let hitObj = null;
for (const obj of objects){
if (obj.dead) continue;
const rr = getObjectRadius(obj);
const d = Math.hypot(obj.x - b.x, obj.y - b.y);
if (d < rr + 2){
obj.hp -= b.dmg;
if (obj.hp <= 0){
obj.dead = true;
if (b.shooter === 'player') player.materials += (obj.type === 'wood' ? 3 : 6);
}
hitObj = obj;
break;
}
}
if (hitObj){
bullets.splice(i,1);
continue;
}
// 4) Hit chests
for (const chest of chests){
if (chest.opened) continue;
const d = Math.hypot(chest.x - b.x, chest.y - b.y);
if (d < 18){
chest.opened = true;
const loot = chest.loot;
const px = chest.x + rand(-20,20), py = chest.y + rand(-20,20);
if (loot.type === 'weapon') pickups.push({ x:px, y:py, type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag: loot.weapon.magSize || 12, ammoReserve: loot.weapon.startReserve || (loot.weapon.magSize*2 || 24) });
else if (loot.type === 'medkit') pickups.push({ x:px, y:py, type:'medkit', amount: loot.amount || 1 });
else if (loot.type === 'materials') pickups.push({ x:px, y:py, type:'materials', amount: loot.amount || 10 });
bullets.splice(i,1);
break;
}
}
if (!bullets[i]) continue;
// 5) Bullets expire naturally
if (b.life <= 0 || b.traveled > 2600) bullets.splice(i,1);
}
}
// Helpers to find nearest (unchanged)
function findNearestChest(e, maxDist = 1200){
let best = null; let bd = Infinity;
for (const c of chests){ if (c.opened) continue; const d = Math.hypot(c.x - e.x, c.y - e.y); if (d < bd && d <= maxDist){ bd = d; best = c; } }
return best;
}
function findNearestHarvestable(e, maxDist = 1000){
let best = null; let bd = Infinity;
for (const o of objects){ if (o.dead || o.type === 'wall') continue; const d = Math.hypot(o.x - e.x, o.y - e.y); if (d < bd && d <= maxDist){ bd = d; best = o; } }
return best;
}
function findNearestPickup(e, maxDist = 500){
let best = null; let bd = Infinity;
for (const p of pickups){ const d = Math.hypot(p.x - e.x, p.y - e.y); if (d < bd && d <= maxDist){ bd = d; best = p; } }
return best;
}
// Enemy AI (keeps behavior; medkit usage for enemies remains as 3s)
function updateEnemies(dt, now){
const minSeparation = 20;
for (const e of enemies){
if (e.health <= 0) continue;
if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
// Auto-open chests on proximity
for (const chest of chests){
if (chest.opened) continue;
const distChest = Math.hypot(chest.x - e.x, chest.y - e.y);
if (distChest <= (e.radius + chestRadius() + 6)){
chest.opened = true;
const loot = chest.loot;
if (loot.type === 'weapon'){
enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag: loot.weapon.magSize || 12, ammoReserve: loot.weapon.startReserve || (loot.weapon.magSize*2 || 24) });
} else if (loot.type === 'medkit'){
enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
} else if (loot.type === 'materials'){
enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
}
enemyEquipBestWeapon(e);
break;
}
}
// Handle enemy medkit usage finishing/cancelling
if (e.usingMedkit){
if (e.lastAttackedTime > e.usingMedkitStart){
e.usingMedkit = false; e.usingMedkitSlot = -1; e.usingMedkitStart = 0; e.usingMedkitUntil = 0;
} else if (now >= e.usingMedkitUntil){
applyEnemyMedkitUseNow(e);
} else {
continue;
}
}
// STORM behavior
if (storm.active){
const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
if (distToSafeCenter > storm.radius){
e.state = 'toSafe';
if (e.tempTarget && now < (e.tempTargetExpiry || 0)){
const td = Math.hypot(e.tempTarget.x - e.x, e.tempTarget.y - e.y);
if (td > 8){
e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.95, Math.sin(e.angle) * e.speed * dt * 0.95, e.radius);
continue;
} else { e.tempTarget = null; e.tempTargetExpiry = 0; }
}
if (hasLineOfSight(e.x, e.y, storm.centerX, storm.centerY)){
e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 1.0, Math.sin(e.angle) * e.speed * dt * 1.0, e.radius);
continue;
}
const blocker = findBlockingObject(e.x, e.y, storm.centerX, storm.centerY);
if (blocker){
const db = Math.hypot(blocker.x - e.x, blocker.y - e.y);
if (db < 48){
if (e.equippedIndex >= 0){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type === 'weapon' && eq.ammoInMag > 0 && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
e.lastShot = now;
eq.ammoInMag -= 1;
const angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
shootBullet(e.x + Math.cos(angle)*12, e.y + Math.sin(angle)*12, blocker.x + (Math.random()-0.5)*6, blocker.y + (Math.random()-0.5)*6, eq, e.id);
e.angle = angle + (Math.random()-0.10)*0.10;
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.50, Math.sin(e.angle) * e.speed * dt * 0.50, e.radius);
continue;
}
}
blocker.hp -= 28 * dt;
if (blocker.hp <= 0){
blocker.dead = true;
e.materials += (blocker.type === 'wood' ? 3 : 6);
} else {
e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.4, Math.sin(e.angle) * e.speed * dt * 0.4, e.radius);
}
continue;
}
const waypoint = computeDetourWaypoint(e.x, e.y, blocker, storm.centerX, storm.centerY, 12);
if (waypoint){
e.tempTarget = waypoint;
e.tempTargetExpiry = now + 3500;
const td2 = Math.hypot(e.tempTarget.x - e.x, e.tempTarget.y - e.y);
if (td2 > 8){
e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.95, Math.sin(e.angle) * e.speed * dt * 0.95, e.radius);
continue;
} else { e.tempTarget = null; e.tempTargetExpiry = 0; }
}
e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.6, Math.sin(e.angle) * e.speed * dt * 0.6, e.radius);
continue;
}
e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.95, Math.sin(e.angle) * e.speed * dt * 0.95, e.radius);
continue;
}
}
// NORMAL behavior
if (now - e.lastAttackedTime < 4000) e.state = 'combat';
if (e.state === 'gather') e.gatherTimeLeft -= dt;
if (e.health < 60 && now >= (e.nextHealTime || 0) && !e.usingMedkit){
if (enemyStartMedkitUse(e, now)){
continue;
}
}
const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
if (e.state === 'gather'){
if (now >= (e.spawnSafeUntil || 0)){
let p = findNearestPickup(e, 240);
if (p){
const angle = Math.atan2(p.y - e.y, p.x - e.x);
e.angle = angle;
const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
moveEntityWithCollision(e, dx, dy, e.radius);
if (Math.hypot(p.x - e.x, p.y - e.y) < 18){
enemyPickupCollect(e, p);
const idx = pickups.indexOf(p);
if (idx >= 0) pickups.splice(idx,1);
}
continue;
}
}
let chestTarget = findNearestChest(e, 900);
if (chestTarget){
const d = Math.hypot(chestTarget.x - e.x, chestTarget.y - e.y);
if (d > 20){
e.angle = Math.atan2(chestTarget.y - e.y, chestTarget.x - e.x);
const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
moveEntityWithCollision(e, dx, dy, e.radius);
} else {
if (!chestTarget.opened){
chestTarget.opened = true;
const loot = chestTarget.loot;
if (loot.type === 'weapon') enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 });
else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
enemyEquipBestWeapon(e);
if (e.equippedIndex !== -1){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type==='weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0){
reloadItem(eq);
}
}
}
}
continue;
}
let objTarget = findNearestHarvestable(e, 700);
if (objTarget){
const d = Math.hypot(objTarget.x - e.x, objTarget.y - e.y);
if (d > 26){
e.angle = Math.atan2(objTarget.y - e.y, objTarget.x - e.x);
const dx = Math.cos(e.angle) * e.speed * dt * 0.8;
const dy = Math.sin(e.angle) * e.speed * dt * 0.8;
moveEntityWithCollision(e, dx, dy, e.radius);
} else {
objTarget.hp -= 40 * dt;
if (objTarget.hp <= 0 && !objTarget.dead){
objTarget.dead = true;
e.materials += (objTarget.type === 'wood' ? 3 : 6);
if (Math.random() < 0.08){
const lootRoll = Math.random();
if (lootRoll < 0.4) enemyPickupCollect(e, { type:'medkit', amount:1 });
else if (lootRoll < 0.9) enemyPickupCollect(e, { type:'materials', amount: randInt(3,8) });
else enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto({ name:'Pistol', dmg:12, rate:320, color:'#ffd86b', magSize:12, startReserve:24 }), ammoInMag:12, ammoReserve:24 });
}
}
}
continue;
}
const dx = Math.cos(e.angle) * e.speed * dt * 0.25;
const dy = Math.sin(e.angle) * e.speed * dt * 0.25;
moveEntityWithCollision(e, dx, dy, e.radius);
if (Math.random() < 0.01) e.angle += (Math.random()-0.5)*2;
continue;
}
// Combat logic ...
if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
if (e.reloadPending){
if (now >= e.reloadingUntil){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type === 'weapon') reloadItem(eq);
e.reloadPending = false;
e.reloadingUntil = 0;
}
}
if (e.equippedIndex >= 0){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
e.reloadPending = true;
e.reloadingUntil = now + 600 + rand(-100,100);
}
}
let target = player;
let bestDist = Math.hypot(player.x - e.x, player.y - e.y);
if (bestDist > VIEW_RANGE){
const p = findNearestPickup(e, 1200);
const c = findNearestChest(e, 1200);
const h = findNearestHarvestable(e, 1200);
let candidate = null, cd = Infinity;
if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
for (const other of enemies){
if (other === e || other.health <= 0) continue;
const d = Math.hypot(other.x - e.x, other.y - e.y);
if (d < cd && d <= 800){ candidate = other; cd = d; }
}
if (candidate){
target = candidate;
bestDist = cd;
} else {
target = null; bestDist = Infinity;
}
} else {
for (const other of enemies){
if (other === e || other.health <= 0) continue;
const d = Math.hypot(other.x - e.x, other.y - e.y);
if (d < bestDist && Math.random() < 0.6){ bestDist = d; target = other; }
}
}
const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
if (distToPlayer < 160 && e.health < 35 && e.materials >= 10 && Math.random() < 0.5){
enemyTryBuild(e);
}
if (!target){
e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
moveEntityWithCollision(e, 0, 0, e.radius);
continue;
}
const blocked = !hasLineOfSight(e.x, e.y, target.x, target.y);
if (blocked){
const blocker = findBlockingObject(e.x, e.y, target.x, target.y);
if (blocker){
const db = Math.hypot(blocker.x - e.x, blocker.y - e.y);
if (db < 36){
blocker.hp -= 18 * dt * 2;
if (blocker.hp <= 0){ blocker.dead = true; e.materials += (blocker.type === 'wood' ? 3 : 6); }
} else {
if (e.equippedIndex >= 0){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type === 'weapon' && eq.ammoInMag > 0 && !e.reloadPending && now - e.lastShot > (eq.weapon.rate || 300)){
e.lastShot = now;
eq.ammoInMag -= 1;
const angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
shootBullet(e.x + Math.cos(angle)*12, e.y + Math.sin(angle)*12, blocker.x + (Math.random()-0.5)*6, blocker.y + (Math.random()-0.5)*6, eq, e.id);
}
} else {
e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
e.x += Math.cos(e.angle) * e.speed * dt * 0.8;
e.y += Math.sin(e.angle) * e.speed * dt * 0.8;
}
}
continue;
}
}
if (target === player){
if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
e.lastMelee = now;
const dmg = 10 + randInt(0,8);
player.health -= dmg;
e.lastAttackedTime = now;
if (player.health <= 0) { player.health = 0; playerDeath(); }
} else if (e.equippedIndex >= 0){
const eq = e.inventory[e.equippedIndex];
if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
if (eq.ammoInMag <= 0){
} else {
if (hasLineOfSight(e.x, e.y, target.x, target.y)){
e.lastShot = now;
eq.ammoInMag -= 1;
const angle = Math.atan2(target.y - e.y, target.x - e.x);
shootBullet(e.x + Math.cos(angle)*12, e.y + Math.sin(angle)*12, target.x + (Math.random()-0.5)*6, target.y + (Math.random()-0.5)*6, eq, e.id);
e.lastAttackedTime = now;
} else {
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.6, Math.sin(e.angle) * e.speed * dt * 0.6, e.radius);
}
}
} else {
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
}
} else {
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
}
} else {
const td = Math.hypot(target.x - e.x, target.y - e.y);
if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){
if (td > 20){
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
} else {
enemyPickupCollect(e, target);
const idx = pickups.indexOf(target);
if (idx >= 0) pickups.splice(idx,1);
e.state = 'gather';
}
} else if (target.hasOwnProperty('loot')){
if (td > 20){
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
} else {
target.opened = true;
const loot = target.loot;
if (loot.type === 'weapon') enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 });
else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
e.state = 'gather';
}
} else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){
if (td > 26){
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.8, Math.sin(e.angle) * e.speed * dt * 0.8, e.radius);
} else {
target.hp -= 40 * dt;
if (target.hp <= 0 && !target.dead){
target.dead = true;
e.materials += (target.type === 'wood' ? 3 : 6);
}
e.state = 'gather';
}
} else {
if (td > 40){
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.7, Math.sin(e.angle) * e.speed * dt * 0.7, e.radius);
} else {
if (now - e.lastMelee > e.meleeRate){
e.lastMelee = now;
if (target && target.health > 0){
target.health -= 8 + randInt(0,6);
target.lastAttackedTime = now;
target.lastAttackerId = e.id;
if (target.health <= 0) target.health = 0;
}
}
}
}
}
}
// Separation and clipping
for (let i = 0; i < enemies.length; i++){
const a = enemies[i];
if (!a || a.health <= 0) continue;
for (let j = i+1; j < enemies.length; j++){
const b = enemies[j];
if (!b || b.health <= 0) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx,dy) || 0.0001;
const minD = minSeparation;
if (d < minD){
const overlap = (minD - d) * 0.5;
const nx = dx / d, ny = dy / d;
b.x += nx * overlap;
b.y += ny * overlap;
a.x -= nx * overlap;
a.y -= ny * overlap;
}
}
const pdx = a.x - player.x, pdy = a.y - player.y;
const pd = Math.hypot(pdx,pdy) || 0.0001;
const avoidDist = 24;
if (pd < avoidDist){
const overlap = (avoidDist - pd);
const nx = pdx / pd, ny = pdy / pd;
a.x += nx * overlap;
a.y += ny * overlap;
}
a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
}
}
// Storm
const storm = { maxRadius: 2400, radius:2400, centerX: WORLD.width/2, centerY: WORLD.height/2, damagePerSecond:1, closingSpeed: 0.6, active:false };
let stormDamageAccumulator = 0;
function playerInStorm(){ return Math.hypot(player.x - storm.centerX, player.y - storm.centerY) > storm.radius; }
function updateStorm(dt){
if (!storm.active) return;
storm.radius -= storm.closingSpeed * dt * 60;
if (storm.radius < 120) storm.radius = 120;
if (playerInStorm()){
const prog = 1 - storm.radius / storm.maxRadius;
const rate = storm.damagePerSecond * (1 + prog*4);
stormDamageAccumulator += rate * dt;
while (stormDamageAccumulator >= 1){
stormDamageAccumulator -=1;
player.health -= 1;
if (player.health <= 0){ player.health = 0; playerDeath(); }
}
} else stormDamageAccumulator = 0;
for (const e of enemies){
if (e.health <= 0) continue;
const d = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
if (d > storm.radius){
e.health -= 8 * dt;
if (e.health <= 0){ e.health = 0; updatePlayerCount(); }
}
}
}
function updatePlayerCount(){
const aliveEnemies = enemies.filter(e => e.health > 0).length;
document.getElementById('playerCount').textContent = `${1 + aliveEnemies}/50`;
document.getElementById('killCount').textContent = `${player.kills}`;
if (gameActive && aliveEnemies === 0 && player.health > 0){
gameActive = false;
victoryScreen.classList.remove('hidden');
clearInterval(timerInterval);
}
}
// Drawing functions (kept)
function drawWorld(){
const TILE = 600;
const cols = Math.ceil(WORLD.width / TILE);
const rows = Math.ceil(WORLD.height / TILE);
for (let by=0;by<rows;by++){
for (let bx=0;bx<cols;bx++){
const x=bx*TILE,y=by*TILE; const b=biomeAt(x+1,y+1);
let color='#203a2b';
if (b==='desert') color='#cbb78b';
else if (b==='forest') color='#16411f';
else if (b==='oasis') color='#274b52';
else if (b==='ruins') color='#4a3b3b';
const s = worldToScreen(x,y);
ctx.fillStyle = color; ctx.fillRect(s.x,s.y,TILE,TILE);
ctx.strokeStyle = 'rgba(0,0,0,0.08)'; ctx.strokeRect(s.x,s.y,TILE,TILE);
}
}
if (storm.active){
const sc = worldToScreen(storm.centerX, storm.centerY);
ctx.save();
const grad = ctx.createRadialGradient(sc.x, sc.y, storm.radius*0.15, sc.x, sc.y, storm.radius);
grad.addColorStop(0,'rgba(100,149,237,0.02)'); grad.addColorStop(1,'rgba(100,149,237,0.45)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(255,200,80,0.9)'; ctx.lineWidth=4; ctx.beginPath(); ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2); ctx.stroke();
ctx.restore();
}
}
function drawObjects(){
for (const obj of objects){
if (obj.dead) continue;
const s = worldToScreen(obj.x, obj.y);
ctx.save();
const h = obj.type==='wood'?18:(obj.type==='stone'?12:28);
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(s.x,s.y+8,18,8,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle = obj.type==='wood'? '#6b3b1a' : (obj.type==='stone'? '#6b6b6b' : '#8b5a32');
ctx.fillRect(s.x-12, s.y-h, 24, h);
if (obj.type==='wall'){
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(s.x-18, s.y-h-10, 36, 6);
const hpPct = Math.max(0, obj.hp / obj.maxHp);
ctx.fillStyle = '#ffc966'; ctx.fillRect(s.x-18, s.y-h-10, 36*hpPct, 6);
}
ctx.restore();
}
}
function drawChests(){
const now = performance.now();
for (const chest of chests){
if (chest.opened) continue;
const s = worldToScreen(chest.x, chest.y);
ctx.save();
const glowRadius = 28 + Math.sin(now/300 + chest.x*0.001)*6;
const g = ctx.createRadialGradient(s.x, s.y-6, 6, s.x, s.y-6, glowRadius);
g.addColorStop(0,'rgba(255,215,0,0.95)'); g.addColorStop(0.6,'rgba(255,215,0,0.25)'); g.addColorStop(1,'rgba(255,215,0,0.00)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(s.x, s.y-6, glowRadius, 0, Math.PI*2); ctx.fill();
ctx.fillStyle='#a56b2a'; ctx.fillRect(s.x-18,s.y-12,36,20);
ctx.fillStyle='#caa15e'; ctx.fillRect(s.x-18,s.y-20,36,8);
ctx.restore();
}
}
function drawPickups(){
for (const p of pickups){
const s = worldToScreen(p.x,p.y);
ctx.save();
const glowRadius = 14 + Math.sin(performance.now()/250 + p.x*0.001)*4;
if (p.type === 'weapon'){
const g = ctx.createRadialGradient(s.x,s.y,2,s.x,s.y,glowRadius);
g.addColorStop(0,'rgba(255,255,255,0.95)'); g.addColorStop(0.6, `${p.weapon.color}33`); g.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(s.x,s.y,glowRadius,0,Math.PI*2); ctx.fill();
ctx.fillStyle = p.weapon.color || '#ffd86b'; ctx.fillRect(s.x-10,s.y-6,20,12);
} else if (p.type === 'medkit'){
ctx.fillStyle = '#ff6b6b'; ctx.beginPath(); ctx.arc(s.x,s.y,10,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font='10px monospace'; ctx.textAlign='center'; ctx.fillText('MED', s.x, s.y+2);
} else if (p.type === 'materials'){
ctx.fillStyle = '#cfe0a6'; ctx.fillRect(s.x-8,s.y-8,16,16);
ctx.fillStyle = '#000'; ctx.font='10px monospace'; ctx.textAlign='center'; ctx.fillText('MAT', s.x, s.y+2);
} else if (p.type === 'ammo'){
ctx.fillStyle = '#e6e6e6'; ctx.fillRect(s.x-6,s.y-6,12,12);
ctx.fillStyle = '#000'; ctx.font='9px monospace'; ctx.textAlign='center'; ctx.fillText('AM', s.x, s.y+2);
}
ctx.restore();
}
}
function drawEnemies(){
for (const e of enemies){
if (e.health <= 0) continue;
const s = worldToScreen(e.x,e.y);
ctx.save();
ctx.translate(s.x,s.y); ctx.rotate(e.angle);
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(0,12,14,6,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#ff6b6b'; ctx.beginPath(); ctx.moveTo(12,0); ctx.lineTo(-10,-8); ctx.lineTo(-10,8); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(-18,-22,36,6);
const hpPct = Math.max(0, Math.min(1, e.health/120));
ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
const we = e.inventory[e.equippedIndex];
const color = we.weapon.color || '#ddd';
ctx.save();
ctx.translate(12, 2);
ctx.rotate(-0.08);
ctx.fillStyle = color;
ctx.fillRect(0, -4, 24, 8);
ctx.fillStyle = '#222';
ctx.fillRect(18, -2, 6, 4);
const magPct = Math.max(0, we.ammoInMag / we.weapon.magSize);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(4, 6, 18, 4);
ctx.fillStyle = '#0f0';
ctx.fillRect(4, 6, 18 * magPct, 4);
ctx.restore();
} else {
if (e.equippedIndex === -1){
ctx.save();
ctx.translate(12, 6);
ctx.rotate(-0.22);
ctx.fillStyle = '#8b6b4a';
ctx.fillRect(-2,0,4,10);
ctx.fillStyle = '#cfcfcf';
ctx.fillRect(-6,-4,10,4);
ctx.restore();
} else {
const it = e.inventory[e.equippedIndex];
if (it && it.type === 'medkit'){
ctx.save();
ctx.translate(12, 2);
ctx.rotate(-0.05);
ctx.fillStyle = '#ff6b6b';
ctx.fillRect(0, -6, 10, 10);
ctx.fillStyle = '#fff';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText('M', 5, 2);
ctx.restore();
}
}
}
ctx.fillStyle = e.state === 'gather' ? 'rgba(0,200,200,0.9)' : (e.state === 'combat' ? 'rgba(255,80,80,0.95)' : 'rgba(255,200,80,0.9)');
ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
function drawBullets(){
for (const b of bullets){
const s = worldToScreen(b.x,b.y);
ctx.save();
if (b.tracer){ ctx.fillStyle = b.color; ctx.globalAlpha=0.9; ctx.beginPath(); ctx.arc(s.x,s.y,3,0,Math.PI*2); ctx.fill(); }
else {
const backX = s.x - (b.vx * 0.02);
const backY = s.y - (b.vy * 0.02);
const grad = ctx.createLinearGradient(backX,backY,s.x,s.y);
grad.addColorStop(0,'rgba(255,255,255,0)'); grad.addColorStop(0.7,b.color); grad.addColorStop(1,'#fff');
ctx.strokeStyle = grad; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(backX,backY); ctx.lineTo(s.x,s.y); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(s.x,s.y,2.5,0,Math.PI*2); ctx.fill();
}
ctx.restore();
}
}
function drawPlayer(){
const s = worldToScreen(player.x,player.y);
ctx.save();
ctx.translate(s.x,s.y); ctx.rotate(player.angle);
ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(0,14,18,8,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle='yellow'; ctx.beginPath(); ctx.moveTo(18,0); ctx.lineTo(-12,-10); ctx.lineTo(-12,10); ctx.closePath(); ctx.fill();
let activeWeaponItem = null;
if (player.equippedIndex >= 0) activeWeaponItem = player.inventory[player.equippedIndex];
else {
const selected = player.inventory[player.selectedSlot];
if (selected && selected.type === 'weapon') activeWeaponItem = selected;
}
if (player.equippedIndex === -1){
ctx.save();
ctx.translate(12, 6);
ctx.rotate(-0.2);
ctx.fillStyle = '#8b6b4a';
ctx.fillRect(-2,0,4,18);
ctx.fillStyle = '#cfcfcf';
ctx.fillRect(-8,-6,12,6);
ctx.restore();
} else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
ctx.save();
ctx.translate(12, 2);
ctx.rotate(-0.05);
ctx.fillStyle = activeWeaponItem.weapon.color || '#fff';
ctx.fillRect(0,-4,26,8);
ctx.fillStyle = '#222';
ctx.fillRect(18,-2,8,4);
const magPct = Math.max(0, activeWeaponItem.ammoInMag / activeWeaponItem.weapon.magSize);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(4,6,18,4);
ctx.fillStyle = '#0f0';
ctx.fillRect(4,6,18*magPct,4);
ctx.restore();
}
ctx.restore();
}
function drawCrosshair(){}
// Minimap functions
function buildMiniTerrainCache(){
const mw = minimapCanvas.width;
const mh = minimapCanvas.height;
const scaleX = WORLD.width / mw;
const scaleY = WORLD.height / mh;
const img = miniCtx.createImageData(mw, mh);
for (let my=0; my<mh; my++){
for (let mx=0; mx<mw; mx++){
const wx = Math.floor(mx * scaleX + scaleX/2);
const wy = Math.floor(my * scaleY + scaleY/2);
const b = biomeAt(wx, wy);
let col = [32,58,43];
if (b==='desert') col = [203,183,139];
else if (b==='forest') col = [22,65,31];
else if (b==='oasis') col = [39,75,82];
else if (b==='ruins') col = [74,59,59];
const idx = (my*mw + mx)*4;
img.data[idx] = col[0];
img.data[idx+1] = col[1];
img.data[idx+2] = col[2];
img.data[idx+3] = 255;
}
}
miniTerrainCache = img;
}
function drawMinimap(){
const mw = minimapCanvas.width;
const mh = minimapCanvas.height;
if (!miniTerrainCache) buildMiniTerrainCache();
miniCtx.putImageData(miniTerrainCache, 0, 0);
miniCtx.save();
const scaleX = mw / WORLD.width;
const scaleY = mh / WORLD.height;
for (const obj of objects){
if (obj.dead) continue;
const px = Math.round(obj.x * scaleX);
const py = Math.round(obj.y * scaleY);
if (obj.type === 'wood'){
miniCtx.fillStyle = '#3f210f';
miniCtx.fillRect(px-1, py-1, 2, 2);
} else if (obj.type === 'stone'){
miniCtx.fillStyle = '#666';
miniCtx.fillRect(px-1, py-1, 2, 2);
} else if (obj.type === 'wall'){
miniCtx.fillStyle = '#8b5a32';
miniCtx.fillRect(px-2, py-2, 4, 4);
}
}
for (const p of pickups){
const px = Math.round(p.x * scaleX);
const py = Math.round(p.y * scaleY);
if (p.type === 'weapon'){
miniCtx.fillStyle = '#ffd86b'; miniCtx.fillRect(px-1, py-1, 2, 2);
} else if (p.type === 'medkit'){
miniCtx.fillStyle = '#ff6b6b'; miniCtx.fillRect(px-1, py-1, 2, 2);
} else if (p.type === 'materials'){
miniCtx.fillStyle = '#cfe0a6'; miniCtx.fillRect(px-1, py-1, 2, 2);
} else if (p.type === 'ammo'){
miniCtx.fillStyle = '#e6e6e6'; miniCtx.fillRect(px-1, py-1, 2, 2);
}
}
if (storm.active){
miniCtx.fillStyle = 'rgba(0,0,0,0.35)';
miniCtx.fillRect(0,0,mw,mh);
miniCtx.globalCompositeOperation = 'destination-out';
const cx = storm.centerX * scaleX;
const cy = storm.centerY * scaleY;
const r = storm.radius * ((scaleX + scaleY) / 2);
miniCtx.beginPath();
miniCtx.arc(cx, cy, r, 0, Math.PI*2);
miniCtx.fill();
miniCtx.globalCompositeOperation = 'source-over';
miniCtx.strokeStyle = 'rgba(255,200,80,0.95)';
miniCtx.lineWidth = 2;
miniCtx.beginPath();
miniCtx.arc(cx, cy, r, 0, Math.PI*2);
miniCtx.stroke();
}
const ppx = Math.round(player.x * (mw / WORLD.width));
const ppy = Math.round(player.y * (mh / WORLD.height));
miniCtx.fillStyle = '#ffff66';
miniCtx.beginPath();
miniCtx.arc(ppx, ppy, 3, 0, Math.PI*2);
miniCtx.fill();
miniCtx.restore();
}
// --- NEW: Player overlay & materials HUD setup (creates elements & helpers) ---
(function(){
// Create materials HUD left of kills
const headerRight = document.querySelector('header .flex.space-x-4') || document.querySelector('header > div:last-child');
const materialsWrap = document.createElement('div');
materialsWrap.className = 'hud-materials';
materialsWrap.id = 'hudMaterials';
materialsWrap.innerHTML = '<img src="https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png" alt="player-icon"/><span id="materialsCount">0</span>';
// insert materials HUD before killCount group
const killGroup = document.getElementById('killCount')?.parentElement;
if (killGroup && headerRight){
headerRight.insertBefore(materialsWrap, killGroup);
} else {
// fallback append
if (headerRight) headerRight.prepend(materialsWrap);
}
// Create player overlay image element that will follow the player
const canvasContainer = document.getElementById('canvasContainer');
const overlayImg = document.createElement('img');
overlayImg.id = 'playerOverlayImg';
overlayImg.className = 'player-overlay';
overlayImg.src = 'https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png';
overlayImg.style.width = '50x'; // default size on screen; adjust as desired
overlayImg.style.height = '50px';
overlayImg.style.transform = 'translate(-80%,-138%)'; // center horizontally, sit above player
overlayImg.style.filter = 'drop-shadow(0 6px 16px rgba(0,0,0,0.6))';
canvasContainer.appendChild(overlayImg);
// Preload image to avoid flicker
const pre = new Image();
pre.src = overlayImg.src;
// Update HUD materials whenever needed (call updateMaterialsHUD())
window.updateMaterialsHUD = function(){
const el = document.getElementById('materialsCount');
if (el) el.textContent = String(player.materials || 0);
};
// initial
updateMaterialsHUD();
// Overlay updater: positions overlayImg above player in screen coords
// Call this from the game loop each frame after cameraUpdate() and before drawing UI.
window.overlayUpdate = function(){
const imgEl = document.getElementById('playerOverlayImg');
if (!imgEl) return;
// get player's screen position relative to canvas container
const rect = canvas.getBoundingClientRect();
// Convert world -> screen using existing worldToScreen then apply container offsets
const s = worldToScreen(player.x, player.y);
// s.x/s.y are coordinates inside canvas; now compute absolute positions in page
const absLeft = rect.left + s.x;
const absTop = rect.top + s.y;
// place element so that its bottom center is anchored around player's head (slightly above)
const offsetY = -46; // vertical offset in px to lift the image above the player's sprite; tweak if needed
imgEl.style.left = (absLeft) + 'px';
imgEl.style.top = (absTop + offsetY) + 'px';
// ensure scale based on camera zoom or distance if you later add zoom; for now constant size
// Update materials HUD text too (keeps in sync)
updateMaterialsHUD();
};
// Expose a helper to change overlay size (optional)
window.setPlayerOverlaySize = function(px){
const el = document.getElementById('playerOverlayImg');
if (!el) return;
el.style.width = px + 'px';
el.style.height = px + 'px';
};
})();
// Main loop
let lastTime = 0;
function gameLoop(ts){
if (!gameActive) return;
if (!lastTime) lastTime = ts;
const dt = Math.min(0.05, (ts - lastTime)/1000);
lastTime = ts;
// Movement from keys/joystick
let dx=0, dy=0;
if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
if (dx !== 0 || dy !== 0){
const len = Math.hypot(dx,dy) || 1;
const mvx = (dx/len) * player.speed * dt;
const mvy = (dy/len) * player.speed * dt;
const oldX = player.x, oldY = player.y;
player.x += mvx;
if (player.x < player.radius) player.x = player.radius;
if (player.x > WORLD.width - player.radius) player.x = WORLD.width - player.radius;
if (isCollidingSolid(player.x, player.y, player.radius)){
player.x = oldX;
}
player.y += mvy;
if (player.y < player.radius) player.y = player.radius;
if (player.y > WORLD.height - player.radius) player.y = WORLD.height - player.radius;
if (isCollidingSolid(player.x, player.y, player.radius)){
player.y = oldY;
}
// Note: medkit use is instant now; moving does not cancel medkit
}
player.x = Math.max(16, Math.min(WORLD.width-16, player.x));
player.y = Math.max(16, Math.min(WORLD.height-16, player.y));
cameraUpdate();
// Update overlay position after camera updates
if (typeof overlayUpdate === 'function') overlayUpdate();
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
let activeWeaponItem = null;
if (player.equippedIndex >= 0) activeWeaponItem = player.inventory[player.equippedIndex];
else {
const selected = player.inventory[player.selectedSlot];
if (selected && selected.type === 'weapon') activeWeaponItem = selected;
}
if (mouse.down){
if (player.equippedIndex === -1) playerMeleeHit();
else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
const now = performance.now();
if (now - player.lastShot > (activeWeaponItem.weapon.rate || 300)){
if (activeWeaponItem.ammoInMag > 0){
player.lastShot = now;
shootBullet(player.x + Math.cos(player.angle)*18, player.y + Math.sin(player.angle)*18, mouse.worldX, mouse.worldY, activeWeaponItem, 'player');
updateHUD();
}
}
}
}
if (keys.r) { reloadEquipped(); keys.r = false; }
if (keys.q){ tryBuild(); keys.q = false; }
updateEnemies(dt, performance.now());
bulletsUpdate(dt);
for (let i=pickups.length-1;i>=0;i--){
const p = pickups[i];
if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
}
for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
updatePlayerCount();
updateStorm(dt);
ctx.clearRect(0,0,canvas.width,canvas.height);
drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer(); drawCrosshair();
updateHUD();
drawMinimap();
if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
requestAnimationFrame(gameLoop);
}
// Landing -> spawn selection
let selectedBiome = null;
function getSpawnForBiome(b){
if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
if (b === 'oasis') return { x: rand(200, WORLD.width*0.3), y: rand(WORLD.height*0.7, WORLD.height-200) };
if (b === 'ruins') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(WORLD.height*0.7, WORLD.height-200) };
return { x: WORLD.width/2, y: WORLD.height/2 };
}
// Start/end
let timerInterval = null;
let gameTime = 300;
let gameActive = false;
function startGame(biome){
selectedBiome = biome || selectedBiome;
const spawn = getSpawnForBiome(selectedBiome);
document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
victoryScreen.classList.add('hidden');
deathScreen.classList.add('hidden');
gameActive = true;
landingScreen.classList.add('hidden');
gameScreen.classList.remove('hidden');
resizeCanvas();
player.x = spawn.x;
player.y = spawn.y;
player.health = 100; player.armor = 0; player.kills = 0; player.materials = 0;
player.inventory = [null,null,null,null,null];
player.selectedSlot = 0; player.equippedIndex = -1; player.lastShot = 0; player.lastMelee = 0;
player.lastMedkitUsed = 0;
populateWorld();
initHUD();
cameraUpdate();
for (const e of enemies){
if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
e.x += (Math.random()<0.5? -1:1) * rand(160,260);
e.y += (Math.random()<0.5? -1:1) * rand(160,260);
}
e.inventory = e.inventory || [null,null,null,null,null];
e.equippedIndex = (e.inventory && e.inventory.findIndex(it => it && it.type === 'weapon') !== -1) ? e.inventory.findIndex(it => it && it.type === 'weapon') : -1;
e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
e.state = 'gather';
e.nextHealTime = 0;
e.tempTarget = null;
e.tempTargetExpiry = 0;
e.prioritizeChestsUntil = e.prioritizeChestsUntil || (performance.now() + 6000 + rand(0,3000));
e.usingMedkit = false; e.usingMedkitSlot = -1;
}
gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
document.getElementById('gameTimer').textContent = '5:00';
timerInterval && clearInterval(timerInterval);
timerInterval = setInterval(()=>{
if (!gameActive){ clearInterval(timerInterval); return; }
gameTime--;
const m = Math.floor(gameTime/60), s = gameTime%60;
document.getElementById('gameTimer').textContent = `${m}:${s<10?'0'+s:s}`;
if (gameTime === 240 && !storm.active){
storm.active = true;
storm.centerX = rand(400, WORLD.width-400);
storm.centerY = rand(400, WORLD.height-400);
storm.radius = storm.maxRadius;
stormWarning.classList.remove('hidden');
setTimeout(()=>stormWarning.classList.add('hidden'),4000);
}
if (gameTime <= 0){ clearInterval(timerInterval); endGame(); }
}, 1000);
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
function endGame(){ gameActive = false; alert('Match over!'); }
function playerDeath(){
gameActive = false;
deathScreen.classList.remove('hidden');
}
document.getElementById('respawnBtn').addEventListener('click', ()=>{ deathScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
// Loading screen logic (unchanged)
const loadingTips = [
"Stick to cover when approaching buildings — open areas get you killed.",
"Loot chests quickly and move — enemies can hear opening animations.",
"Use medkits only when safe; they take time and leave you vulnerable.",
"Shotguns excel at close range; rifles win in open sightlines.",
"Build walls to create temporary cover from enemy fire.",
"Reload during lulls — don't wait until you're out in a fight.",
"Watch the minimap for pickups and storm position.",
"Materials stack up; farming early helps late-game survivability."
];
let loadingTimerId = null;
let tipRotateId = null;
let loadingStart = 0;
const LOADING_DURATION = 20000; // 20 seconds
function showLoadingForBiome(biome){
document.querySelectorAll('.biome-selector').forEach(x => { x.classList.add('disabled-pane'); x.style.pointerEvents = 'none'; });
selectedBiome = biome;
loadingTipEl.textContent = loadingTips[Math.floor(Math.random()*loadingTips.length)];
loadingProgressEl.style.width = '0%';
loadingTimerText.textContent = `Landing in ${Math.ceil(LOADING_DURATION/1000)}s`;
loadingScreen.style.display = 'flex';
loadingScreen.setAttribute('aria-hidden', 'false');
loadingStart = performance.now();
let tipIndex = Math.floor(Math.random()*loadingTips.length);
tipRotateId = setInterval(()=>{
tipIndex = (tipIndex + 1) % loadingTips.length;
loadingTipEl.style.opacity = '0';
setTimeout(()=> {
loadingTipEl.textContent = loadingTips[tipIndex];
loadingTipEl.style.opacity = '1';
}, 180);
}, 4000);
loadingTimerId = setInterval(() => {
const elapsed = performance.now() - loadingStart;
const pct = Math.min(1, elapsed / LOADING_DURATION);
loadingProgressEl.style.width = `${Math.floor(pct*100)}%`;
const remaining = Math.max(0, Math.ceil((LOADING_DURATION - elapsed)/1000));
loadingTimerText.textContent = `Landing in ${remaining}s`;
if (pct >= 1){
clearInterval(loadingTimerId);
clearInterval(tipRotateId);
setTimeout(()=> {
loadingScreen.style.display = 'none';
loadingScreen.setAttribute('aria-hidden', 'true');
document.querySelectorAll('.biome-selector').forEach(x => { x.classList.remove('disabled-pane'); x.style.pointerEvents = ''; });
startGame(biome);
}, 220);
}
}, 100);
}
// Override biome clicks to use loading screen
document.querySelectorAll('.biome-selector').forEach(el => {
el.addEventListener('click', (ev)=>{
document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
el.classList.add('biome-selected');
const biome = el.dataset.biome;
selectedBiome = biome;
showLoadingForBiome(biome);
});
});
// Mobile controls wiring: movement joystick + aim joystick + HUD buttons
(function setupMobileControls(){
const joystick = document.getElementById('mobileJoystick');
const thumb = document.getElementById('joystickThumb');
const shootBtn = document.getElementById('shootBtn');
const interactBtn = document.getElementById('interactBtn');
const buildBtn = document.getElementById('buildBtn');
const reloadBtn = document.getElementById('reloadBtn');
const aimJoystick = document.getElementById('mobileJoystickAim');
const aimThumb = document.getElementById('joystickThumbAim');
if (!IS_MOBILE) {
mobileControls.classList.add('hidden');
if (mobileButtonsHud) mobileButtonsHud.classList.add('hidden');
return;
}
mobileControls.classList.remove('hidden');
mobileControls.setAttribute('aria-hidden','false');
if (mobileButtonsHud) { mobileButtonsHud.classList.remove('hidden'); mobileButtonsHud.setAttribute('aria-hidden','false'); }
// Movement joystick
let joyTouchId = null;
let joyCenter = null;
const maxRadius = 52; // thumb allowed radius
const deadzone = 8;
function resetJoystick(){
thumb.style.transform = `translate(0px,0px)`;
keys.w = keys.a = keys.s = keys.d = false;
}
function handleJoyStart(t){
const rect = joystick.getBoundingClientRect();
joyCenter = { x: rect.left + rect.width/2, y: rect.top + rect.height/2 };
joyTouchId = t.identifier;
updateJoystick(t.clientX, t.clientY);
}
function updateJoystick(clientX, clientY){
if (!joyCenter) return;
let dx = clientX - joyCenter.x;
let dy = clientY - joyCenter.y;
const dist = Math.hypot(dx, dy);
let nx = dx, ny = dy;
if (dist > maxRadius){
nx = dx / dist * maxRadius;
ny = dy / dist * maxRadius;
}
thumb.style.transform = `translate(${nx}px, ${ny}px)`;
if (dist < deadzone){
keys.w = keys.a = keys.s = keys.d = false;
return;
}
keys.w = (dy < -10);
keys.s = (dy > 10);
keys.a = (dx < -10);
keys.d = (dx > 10);
}
function handleJoyEnd(){
joyTouchId = null;
joyCenter = null;
resetJoystick();
}
joystick.addEventListener('touchstart', (ev) => {
for (const t of ev.changedTouches){
if (joyTouchId === null){
handleJoyStart(t);
ev.preventDefault();
break;
}
}
}, { passive:false });
window.addEventListener('touchmove', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === joyTouchId){
updateJoystick(t.clientX, t.clientY);
ev.preventDefault();
break;
}
}
}, { passive:false });
window.addEventListener('touchend', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === joyTouchId){
handleJoyEnd();
ev.preventDefault();
break;
}
}
});
window.addEventListener('touchcancel', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === joyTouchId){
handleJoyEnd();
ev.preventDefault();
break;
}
}
});
// On-screen shoot button: set mouse.down while pressed
let shootTouchId = null;
function shootPressStart(e){
if (e.changedTouches){
for (const t of e.changedTouches){
if (shootTouchId === null){
shootTouchId = t.identifier;
mouse.down = true;
// if aim joystick not used, aim forward in facing direction (so shooting has a direction)
if (!aimActive) {
const rect = canvas.getBoundingClientRect();
mouse.canvasX = rect.width * 0.6;
mouse.canvasY = rect.height * 0.5;
mouse.worldX = mouse.canvasX + camera.x;
mouse.worldY = mouse.canvasY + camera.y;
}
e.preventDefault();
break;
}
}
} else {
mouse.down = true;
}
}
function shootPressEnd(e){
if (e.changedTouches){
for (const t of e.changedTouches){
if (t.identifier === shootTouchId){
shootTouchId = null;
mouse.down = false;
e.preventDefault();
break;
}
}
} else {
mouse.down = false;
}
}
shootBtn.addEventListener('touchstart', shootPressStart, { passive:false });
shootBtn.addEventListener('touchend', shootPressEnd, { passive:false });
shootBtn.addEventListener('touchcancel', shootPressEnd, { passive:false });
shootBtn.addEventListener('mousedown', (e)=>{ mouse.down = true; e.preventDefault(); });
window.addEventListener('mouseup', ()=> mouse.down = false);
// Interact button
function interactActivate(e){
attemptUseOrInteract();
if (e && e.preventDefault) e.preventDefault();
}
interactBtn.addEventListener('touchstart', interactActivate, { passive:false });
interactBtn.addEventListener('mousedown', interactActivate);
// Build button
function buildActivate(e){
tryBuild();
if (e && e.preventDefault) e.preventDefault();
}
buildBtn.addEventListener('touchstart', buildActivate, { passive:false });
buildBtn.addEventListener('mousedown', buildActivate);
// Reload button
function reloadActivate(e){
reloadEquipped();
if (e && e.preventDefault) e.preventDefault();
}
reloadBtn.addEventListener('touchstart', reloadActivate, { passive:false });
reloadBtn.addEventListener('mousedown', reloadActivate);
// Prevent buttons from stealing canvas touch when pressing them
[shootBtn, interactBtn, buildBtn, reloadBtn].forEach(b => {
b.addEventListener('touchmove', (ev)=> ev.preventDefault(), { passive:false });
});
// Aim joystick (right)
let aimTouchId = null;
let aimCenter = null;
const aimMaxRadius = 44; // smaller control
let aimActive = false;
function resetAim(){
aimThumb.style.transform = `translate(0px,0px)`;
aimActive = false;
}
function aimStart(t){
const rect = aimJoystick.getBoundingClientRect();
aimCenter = { x: rect.left + rect.width/2, y: rect.top + rect.height/2 };
aimTouchId = t.identifier;
aimActive = true;
updateAim(t.clientX, t.clientY);
}
function updateAim(clientX, clientY){
if (!aimCenter) return;
let dx = clientX - aimCenter.x;
let dy = clientY - aimCenter.y;
const dist = Math.hypot(dx, dy);
let nx = dx, ny = dy;
if (dist > aimMaxRadius){
nx = dx / dist * aimMaxRadius;
ny = dy / dist * aimMaxRadius;
}
aimThumb.style.transform = `translate(${nx}px, ${ny}px)`;
// compute angle and set player's aim
if (Math.hypot(nx, ny) < 6) return;
const angle = Math.atan2(ny, nx); // aim direction relative to screen; convert to world
// Map aim direction to a point in world in front of player (distance scaled)
const aimDistance = 600; // how far the aim projects
const targetWorldX = player.x + Math.cos(angle) * aimDistance;
const targetWorldY = player.y + Math.sin(angle) * aimDistance;
mouse.worldX = targetWorldX;
mouse.worldY = targetWorldY;
// update canvas coords based on camera
mouse.canvasX = mouse.worldX - camera.x;
mouse.canvasY = mouse.worldY - camera.y;
player.angle = Math.atan2(mouse.worldY - player.y, mouse.worldX - player.x);
}
function aimEnd(){
aimTouchId = null;
aimCenter = null;
resetAim();
aimActive = false;
}
aimJoystick.addEventListener('touchstart', (ev) => {
for (const t of ev.changedTouches){
if (aimTouchId === null){
aimStart(t);
ev.preventDefault();
break;
}
}
}, { passive:false });
window.addEventListener('touchmove', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === aimTouchId){
updateAim(t.clientX, t.clientY);
ev.preventDefault();
break;
}
}
}, { passive:false });
window.addEventListener('touchend', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === aimTouchId){
aimEnd();
ev.preventDefault();
break;
}
}
}, { passive:false });
window.addEventListener('touchcancel', (ev) => {
for (const t of ev.changedTouches){
if (t.identifier === aimTouchId){
aimEnd();
ev.preventDefault();
break;
}
}
});
})();
// Initialize UI state
feather.replace();
resizeCanvas();
</script>
<!-- LOBBY APPEND START -->
<style>
#lobbyInjectBtn {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
background: linear-gradient(180deg,#ffd86b,#f0c84a);
color:#071021;
border: none;
padding:8px 12px;
border-radius:10px;
font-weight:800;
cursor:pointer;
box-shadow:0 8px 20px rgba(0,0,0,0.45);
}
#lobbyScreenInjected {
position: fixed;
inset: 0;
z-index: 9998;
display: none;
align-items: center;
justify-content: center;
background: rgba(6,10,18,0.85);
padding: 20px;
box-sizing: border-box;
}
#lobbyCardInjected {
width: min(960px, 96%);
background: rgba(8,12,20,0.96);
border-radius: 12px;
padding: 18px;
border: 1px solid rgba(255,255,255,0.04);
color:#eaf6ff;
box-shadow: 0 14px 48px rgba(0,0,0,0.7);
display:flex;
gap:16px;
align-items:flex-start;
flex-wrap:wrap;
}
#lobbyLeftInjected { width:220px; display:flex; flex-direction:column; gap:10px; align-items:center; }
#lobbyPlayerImgInjected { width:180px; height:180px; border-radius:10px; object-fit:cover; background:#071021; }
.lobbyStatInjected { background:rgba(255,255,255,0.03); padding:8px 10px; border-radius:8px; font-weight:700; color:#fff; font-size:14px; }
.lobbyBtnInjected {
background: linear-gradient(180deg,#ffd86b,#f0c84a);
color:#071021;
border:none;
padding:10px 14px;
border-radius:10px;
cursor:pointer;
font-weight:800;
}
.lobbyAltBtnInjected {
background: rgba(255,255,255,0.03);
color:#e2eeff;
border:1px solid rgba(255,255,255,0.04);
padding:8px 10px;
border-radius:8px;
cursor:pointer;
}
.lobbyModalInjected { position:fixed; inset:0; display:none; align-items:center; justify-content:center; z-index:10000; background: rgba(0,0,0,0.45); }
.lobbyModalCardInjected { width:min(720px,96%); background: rgba(8,12,20,0.98); padding:14px; border-radius:10px; border:1px solid rgba(255,255,255,0.04); color:#dff4ff; }
.lobbyListInjected { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
.lobbyItemInjected { display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); padding:10px; border-radius:8px; }
.smallMutedInjected { font-size:13px; color:#9fb0c9; }
@media (max-width:720px){
#lobbyLeftInjected { width:100%; flex-direction:row; gap:10px; }
#lobbyPlayerImgInjected { width:84px; height:84px; }
}
/* Jeffery visuals */
.jeffery-shop-left { display:flex; gap:10px; align-items:center; }
.jeffery-thumb { width:64px; height:64px; object-fit:cover; border-radius:8px; background:#071021; }
.jeffery-name { font-weight:800; }
.jeffery-price { font-weight:700; color:#ffd86b; }
</style>
<!-- Inject open button (fixed) -->
<button id="lobbyInjectBtn" aria-controls="lobbyScreenInjected" title="Open Lobby">Lobby</button>
<!-- Lobby Overlay -->
<div id="lobbyScreenInjected" role="dialog" aria-modal="true" aria-hidden="true">
<div id="lobbyCardInjected">
<div id="lobbyLeftInjected">
<img id="lobbyPlayerImgInjected" src="https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png" alt="Player" />
<div style="text-align:center;">
<div style="font-weight:900; font-size:18px;" id="lobbyPlayerNameInjected">You</div>
<div class="smallMutedInjected" id="lobbyPlayerSkinInjected">Skin: Default</div>
</div>
</div>
<div style="flex:1; min-width:220px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;">
<div>
<div style="font-size:20px;font-weight:900;color:#ffd86b">Lobby</div>
<div class="smallMutedInjected">Open the shop, change room, or return to the landing screen</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<div class="lobbyStatInjected">B-Bucks: <span id="lobbyBBucksInjected" style="margin-left:8px;color:#fff">0</span></div>
<div class="lobbyStatInjected">Wins: <span id="lobbyWinsInjected" style="margin-left:8px;color:#fff">0</span></div>
<div class="lobbyStatInjected">All Kills: <span id="lobbyKillsInjected" style="margin-left:8px;color:#fff">0</span></div>
</div>
</div>
<div style="margin-top:12px; display:flex; gap:10px; flex-wrap:wrap;">
<button id="lobbyStartInjected" class="lobbyBtnInjected" title="Return to landing screen">Start (choose landing)</button>
<button id="lobbyChangeRoomInjected" class="lobbyAltBtnInjected">Changing Room</button>
<button id="lobbyShopInjected" class="lobbyAltBtnInjected">Shop</button>
<button id="lobbyCloseInjected" class="lobbyAltBtnInjected">Close</button>
</div>
<div style="margin-top:12px;">
<div style="font-weight:800">Cosmetics & Skins</div>
<div class="smallMutedInjected" id="lobbyOwnedInjected">No cosmetics owned</div>
</div>
</div>
</div>
</div>
<!-- Change Room Modal -->
<div class="lobbyModalInjected" id="lobbyChangeRoomModalInjected" aria-hidden="true">
<div class="lobbyModalCardInjected" role="dialog" aria-modal="true" aria-label="Changing Room">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:900">Changing Room</div>
<div class="smallMutedInjected">Equip cosmetics or skins</div>
</div>
<div><button class="lobbyAltBtnInjected" id="lobbyChangeRoomCloseInjected">Close</button></div>
</div>
<div class="lobbyListInjected" id="lobbyChangeRoomListInjected"></div>
</div>
</div>
<!-- Shop Modal -->
<div class="lobbyModalInjected" id="lobbyShopModalInjected" aria-hidden="true">
<div class="lobbyModalCardInjected" role="dialog" aria-modal="true" aria-label="Shop">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:900">Shop</div>
<div class="smallMutedInjected">Buy cosmetics with B-Bucks (demo items)</div>
</div>
<div><button class="lobbyAltBtnInjected" id="lobbyShopCloseInjected">Close</button></div>
</div>
<div class="lobbyListInjected" id="lobbyShopItemListInjected"></div>
</div>
</div>
<script>
(function(){
// Local storage key and default state
const LS_KEY = 'battlezone_lobby_v1';
const defaultState = { bBucks:0, wins:0, allKills:0, equippedSkin:'Default', cosmetics:[] };
// Jeffery skin definition (only shop item)
const JEFFERY = {
id: 'skin_jeffery',
type: 'skin',
name: 'Jeffery Skin',
price: 1000,
image: 'https://obnoxious-scarlet-qx2lqxczk8.edgeone.app/canvas%20(1).png',
desc: 'A bold new look — changes your in-lobby and in-game appearance.'
};
// Default skin definition
const DEFAULT = {
id: 'skin_default',
type: 'skin',
name: 'Default',
image: 'https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png',
desc: 'The regular default look.'
};
function loadState(){
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return { ...defaultState };
return { ...defaultState, ...JSON.parse(raw) };
} catch(e){
console.error('lobby load error', e);
return { ...defaultState };
}
}
function persistState(s){
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(e){ console.error('lobby save error', e); }
// dispatch storage event to notify other code
window.dispatchEvent(new Event('storage'));
}
const state = loadState();
// Elements
const openBtn = document.getElementById('lobbyInjectBtn');
const screen = document.getElementById('lobbyScreenInjected');
const startBtn = document.getElementById('lobbyStartInjected');
const changeRoomBtn = document.getElementById('lobbyChangeRoomInjected');
const shopBtn = document.getElementById('lobbyShopInjected');
const closeBtn = document.getElementById('lobbyCloseInjected');
const playerImg = document.getElementById('lobbyPlayerImgInjected');
const playerSkinEl = document.getElementById('lobbyPlayerSkinInjected');
const bbucksEl = document.getElementById('lobbyBBucksInjected');
const winsEl = document.getElementById('lobbyWinsInjected');
const killsEl = document.getElementById('lobbyKillsInjected');
const ownedEl = document.getElementById('lobbyOwnedInjected');
const changeModal = document.getElementById('lobbyChangeRoomModalInjected');
const changeModalClose = document.getElementById('lobbyChangeRoomCloseInjected');
const changeList = document.getElementById('lobbyChangeRoomListInjected');
const shopModal = document.getElementById('lobbyShopModalInjected');
const shopModalClose = document.getElementById('lobbyShopCloseInjected');
const shopList = document.getElementById('lobbyShopItemListInjected');
function updateUI(){
bbucksEl.textContent = state.bBucks || 0;
winsEl.textContent = state.wins || 0;
killsEl.textContent = state.allKills || 0;
playerSkinEl.textContent = 'Skin: ' + (state.equippedSkin || 'Default');
ownedEl.textContent = state.cosmetics && state.cosmetics.length ? 'Owned: ' + state.cosmetics.join(', ') : 'No cosmetics owned';
// Update player image to reflect equipped skin
if (playerImg) {
if (state.equippedSkin === JEFFERY.name) playerImg.src = JEFFERY.image;
else playerImg.src = DEFAULT.image;
}
}
function showLobby(){ screen.style.display = 'flex'; screen.setAttribute('aria-hidden','false'); updateUI(); }
function hideLobby(){ screen.style.display = 'none'; screen.setAttribute('aria-hidden','true'); }
// Ensure the Lobby button exists and works
if (openBtn) openBtn.addEventListener('click', ()=> { showLobby(); });
if (closeBtn) closeBtn.addEventListener('click', ()=> { hideLobby(); });
// Start: hide lobby and attempt to show existing landing screen if present
if (startBtn) startBtn.addEventListener('click', ()=>{
hideLobby();
const landing = document.getElementById('landingScreen');
if (landing){
landing.classList.remove('hidden');
landing.style.display = '';
const gs = document.getElementById('gameScreen');
if (gs) gs.classList.add('hidden');
} else if (typeof startGame === 'function') {
try { startGame(); } catch(e){ console.warn('startGame invocation failed', e); }
} else {
alert('Landing screen not found in page — lobby closed.');
}
});
// Change Room modal
if (changeRoomBtn) changeRoomBtn.addEventListener('click', ()=>{
buildChangeList();
if (changeModal) { changeModal.style.display = 'flex'; changeModal.setAttribute('aria-hidden','false'); }
});
if (changeModalClose) changeModalClose.addEventListener('click', ()=>{
if (changeModal) { changeModal.style.display = 'none'; changeModal.setAttribute('aria-hidden','true'); }
});
function createChangeRow(item){
const wrap = document.createElement('div');
wrap.className = 'lobbyItemInjected';
const left = document.createElement('div');
left.innerHTML = `<div style="font-weight:800">${item.name}</div><div class="smallMutedInjected">${item.desc}</div>`;
if (item.image){
const img = document.createElement('img');
img.src = item.image;
img.alt = item.name;
img.style.width = '48px';
img.style.height = '48px';
img.style.objectFit = 'cover';
img.style.borderRadius = '8px';
img.style.marginRight = '8px';
left.prepend(img);
left.style.display = 'flex';
left.style.alignItems = 'center';
left.style.gap = '8px';
}
const right = document.createElement('div');
const equip = document.createElement('button');
equip.className = 'lobbyAltBtnInjected';
equip.textContent = 'Equip';
equip.addEventListener('click', () => {
if (item.type === 'skin') state.equippedSkin = item.name;
persistState(state);
updateUI();
alert('Equipped: ' + item.name);
});
right.appendChild(equip);
wrap.appendChild(left); wrap.appendChild(right);
return wrap;
}
function buildChangeList(){
if (!changeList) return;
changeList.innerHTML = '';
// Always show Default option
changeList.appendChild(createChangeRow({
id: DEFAULT.id, name: DEFAULT.name, desc: DEFAULT.desc, image: DEFAULT.image, type: 'skin'
}));
// Show Jeffery if owned, otherwise show info row
if (state.cosmetics && state.cosmetics.includes(JEFFERY.id)) {
changeList.appendChild(createChangeRow({
id: JEFFERY.id, name: JEFFERY.name, desc: JEFFERY.desc, image: JEFFERY.image, type: 'skin'
}));
} else {
const info = document.createElement('div');
info.className = 'lobbyItemInjected';
info.innerHTML = `<div style="font-weight:800">${JEFFERY.name} (not owned)</div><div class="smallMutedInjected">Purchase it in the Shop for ${JEFFERY.price} B-Bucks.</div>`;
changeList.appendChild(info);
}
}
// Shop modal build (only Jeffery)
if (shopBtn) shopBtn.addEventListener('click', ()=> {
buildShopList();
if (shopModal) { shopModal.style.display = 'flex'; shopModal.setAttribute('aria-hidden','false'); }
});
if (shopModalClose) shopModalClose.addEventListener('click', ()=> {
if (shopModal) { shopModal.style.display = 'none'; shopModal.setAttribute('aria-hidden','true'); }
});
function buildShopList(){
if (!shopList) return;
shopList.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'lobbyItemInjected';
const left = document.createElement('div');
left.className = 'jeffery-shop-left';
const thumb = document.createElement('img');
thumb.className = 'jeffery-thumb';
thumb.src = JEFFERY.image;
thumb.alt = JEFFERY.name;
const info = document.createElement('div');
info.innerHTML = `<div class="jeffery-name">${JEFFERY.name}</div><div class="smallMutedInjected">${JEFFERY.desc}</div>`;
left.appendChild(thumb);
left.appendChild(info);
const right = document.createElement('div');
right.style.display = 'flex';
right.style.gap = '8px';
right.style.alignItems = 'center';
const price = document.createElement('div');
price.className = 'jeffery-price smallMutedInjected';
price.textContent = JEFFERY.price + ' B-Bucks';
const buy = document.createElement('button');
buy.className = 'lobbyAltBtnInjected';
buy.textContent = 'Buy';
buy.addEventListener('click', ()=> {
if ((state.bBucks || 0) >= JEFFERY.price){
state.bBucks = (state.bBucks || 0) - JEFFERY.price;
state.cosmetics = state.cosmetics || [];
if (!state.cosmetics.includes(JEFFERY.id)) state.cosmetics.push(JEFFERY.id);
persistState(state);
updateUI();
alert('Purchased: ' + JEFFERY.name + '. Equip it from the Changing Room.');
} else {
alert('Not enough B-Bucks. Earn them by winning (1 B-Buck per win).');
}
});
right.appendChild(price);
right.appendChild(buy);
wrap.appendChild(left);
wrap.appendChild(right);
shopList.appendChild(wrap);
}
// Function to credit a win
let lastWinTimestamp = 0;
function creditWin(fromPlayerKills){
const now = Date.now();
if (now - lastWinTimestamp < 1500) return;
lastWinTimestamp = now;
state.wins = (state.wins || 0) + 1;
state.bBucks = (state.bBucks || 0) + 1; // 1 B-Buck per win
if (typeof fromPlayerKills === 'number') state.allKills = (state.allKills || 0) + fromPlayerKills;
persistState(state);
updateUI();
}
// Observe victory screen presence (if exists)
(function hookVictoryDetection(){
const victoryEl = document.getElementById('victoryScreen');
if (victoryEl){
const observer = new MutationObserver((mutations)=>{
for (const m of mutations){
if (m.type === 'attributes'){
const comp = window.getComputedStyle(victoryEl);
const visible = comp && comp.display !== 'none' && comp.visibility !== 'hidden' && parseFloat(comp.opacity||'1') > 0;
if (visible) {
let kills = undefined;
try { if (typeof player !== 'undefined' && player && typeof player.kills === 'number') kills = player.kills; } catch(e){}
creditWin(kills);
}
}
}
});
observer.observe(victoryEl, { attributes:true, attributeFilter:['class','style','aria-hidden'] });
} else {
const cont = document.getElementById('continueBtn');
if (cont){
cont.addEventListener('click', ()=> {
let kills = undefined;
try { if (typeof player !== 'undefined' && player && typeof player.kills === 'number') kills = player.kills; } catch(e){}
creditWin(kills);
});
}
}
})();
// Hook goHomeBtn if exists to auto-open lobby
const goHome = document.getElementById('goHomeBtn');
if (goHome){
goHome.addEventListener('click', ()=> {
persistState(state);
showLobby();
});
}
// Expose show/hide
window.showLobbyInjected = showLobby;
window.hideLobbyInjected = hideLobby;
// Initialize UI
// Ensure defaults exist in localStorage
(function ensureDefaults(){
let s = loadState();
let changed = false;
if (typeof s.bBucks !== 'number') { s.bBucks = defaultState.bBucks; changed = true; }
if (typeof s.wins !== 'number') { s.wins = defaultState.wins; changed = true; }
if (typeof s.allKills !== 'number') { s.allKills = defaultState.allKills; changed = true; }
if (!Array.isArray(s.cosmetics)) { s.cosmetics = []; changed = true; }
if (!s.equippedSkin) { s.equippedSkin = defaultState.equippedSkin; changed = true; }
if (changed) persistState(s);
Object.assign(state, s);
})();
updateUI();
// Keep UI in-sync if localStorage changed in another tab
window.addEventListener('storage', (ev)=>{
if (ev.key === LS_KEY){
Object.assign(state, loadState());
updateUI();
} else {
Object.assign(state, loadState());
updateUI();
}
});
// Ensure the lobby button remains if DOM replaced
const topLevelObserver = new MutationObserver(()=> {
if (!document.getElementById('lobbyInjectBtn')) {
document.body.appendChild(openBtn);
}
});
topLevelObserver.observe(document.documentElement, { childList:true, subtree:true });
console.log('Lobby injection loaded. Use the yellow "Lobby" button at top-right to open.');
})();
</script>
<!-- LOBBY APPEND END -->
<!-- LOBBY NAME EDIT + CHEAT CODE APPEND START -->
<style>
/* Simple styles for inline name edit and cheat modal */
#lobbyNameInput {
display:none;
background:transparent;
border:1px dashed rgba(255,255,255,0.08);
color:#eaf6ff;
padding:6px 8px;
border-radius:6px;
font-weight:800;
font-size:18px;
width:180px;
}
#cheatModalInjected {
position:fixed; inset:0; display:none; align-items:center; justify-content:center; z-index:11000;
background: rgba(0,0,0,0.6);
}
#cheatCardInjected {
width:320px; background: rgba(10,14,20,0.98); padding:14px; border-radius:10px; border:1px solid rgba(255,255,255,0.04); color:#eaf6ff;
}
#cheatCardInjected label { display:block; margin-top:8px; font-size:13px; color:#9fb0c9; }
#cheatCardInjected input[type="text"], #cheatCardInjected input[type="number"] {
width:100%; padding:8px; margin-top:6px; border-radius:8px; border:1px solid rgba(255,255,255,0.04); background:rgba(255,255,255,0.02); color:#eaf6ff;
}
#cheatCardInjected .row { display:flex; gap:8px; margin-top:10px; }
#cheatCardInjected .btn { flex:1; padding:8px; border-radius:8px; border:none; cursor:pointer; font-weight:800; }
#cheatCardInjected .btn.primary { background: linear-gradient(180deg,#ffd86b,#f0c84a); color:#071021; }
#cheatCardInjected .btn.ghost { background: rgba(255,255,255,0.03); color:#dff4ff; border:1px solid rgba(255,255,255,0.04); }
</style>
<!-- Inline name input — will be inserted next to the displayed name -->
<input id="lobbyNameInput" type="text" aria-label="Edit player name" maxlength="36" />
<!-- Cheat modal -->
<div id="cheatModalInjected" aria-hidden="true">
<div id="cheatCardInjected" role="dialog" aria-modal="true" aria-label="Cheat Menu">
<div style="font-weight:900; font-size:16px;">Cheat Menu</div>
<div class="smallMutedInjected">Set values directly (changes persist to localStorage)</div>
<label for="cheatNameInput">Player Name</label>
<input id="cheatNameInput" type="text" placeholder="PlayerName#1234" maxlength="36" />
<label for="cheatBBucksInput">B-Bucks</label>
<input id="cheatBBucksInput" type="number" min="0" step="1" />
<label for="cheatWinsInput">All-time Wins</label>
<input id="cheatWinsInput" type="number" min="0" step="1" />
<label for="cheatKillsInput">All-time Kills</label>
<input id="cheatKillsInput" type="number" min="0" step="1" />
<div class="row">
<button class="btn primary" id="cheatApplyBtn">Apply</button>
<button class="btn ghost" id="cheatCloseBtn">Close</button>
</div>
<div style="margin-top:8px; font-size:12px; color:#9fb0c9;">Tip: type 42a98b to toggle this menu.</div>
</div>
</div>
<script>
(function(){
// Locate the injected lobby elements (handles both original appended variants)
const nameElSelectors = [
'#lobbyPlayerNameInjected',
'#lobbyPlayerName',
'#lobbyPlayerNameInjected', // duplicate safe
];
let nameDisplay = null;
for (const sel of nameElSelectors) {
const el = document.querySelector(sel);
if (el) { nameDisplay = el; break; }
}
const nameInput = document.getElementById('lobbyNameInput');
// Find the persistent state key used by the lobby code
const LS_KEY = 'battlezone_lobby_v1';
function readState(){
try { return JSON.parse(localStorage.getItem(LS_KEY)) || {}; } catch(e){ return {}; }
}
function writeState(s){
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(e){ console.error(e); }
// Notify others by dispatching storage event in same tab via custom event
window.dispatchEvent(new Event('storage'));
}
// Initialize name display and input
function initName() {
if (!nameDisplay) return;
nameDisplay.style.cursor = 'text';
// On click, show the input over the nameDisplay
nameDisplay.addEventListener('click', (e) => {
e.stopPropagation();
const rect = nameDisplay.getBoundingClientRect();
nameInput.style.position = 'fixed';
nameInput.style.left = (rect.left) + 'px';
nameInput.style.top = (rect.top) + 'px';
nameInput.style.width = Math.max(120, rect.width) + 'px';
nameInput.value = nameDisplay.textContent.trim();
nameInput.style.display = 'block';
nameInput.focus();
nameInput.select();
});
// Double-click also edits
nameDisplay.addEventListener('dblclick', (e) => {
e.stopPropagation();
nameDisplay.dispatchEvent(new Event('click'));
});
// Save on blur or Enter
nameInput.addEventListener('blur', () => {
commitNameEdit();
});
nameInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
commitNameEdit();
nameInput.blur();
} else if (ev.key === 'Escape') {
nameInput.style.display = 'none';
}
});
// Load initial name from state
const st = readState();
if (st.playerName) {
nameDisplay.textContent = st.playerName;
}
}
function commitNameEdit(){
if (!nameDisplay) { nameInput.style.display='none'; return; }
const nv = nameInput.value.trim() || 'You';
nameDisplay.textContent = nv;
// persist into the same state object used by lobby
const s = readState();
s.playerName = nv;
writeState(s);
// Also, if nameDisplay isn't the one inside lobby (rare), update other displays
const other = document.querySelectorAll('#lobbyPlayerNameInjected, #lobbyPlayerName');
other.forEach(el => { if (el) el.textContent = nv; });
nameInput.style.display = 'none';
}
// Cheat code sequence detection
const sequence = '42a98b';
let buffer = '';
const cheatModal = document.getElementById('cheatModalInjected');
const cheatName = document.getElementById('cheatNameInput');
const cheatBBucks = document.getElementById('cheatBBucksInput');
const cheatWins = document.getElementById('cheatWinsInput');
const cheatKills = document.getElementById('cheatKillsInput');
const cheatApply = document.getElementById('cheatApplyBtn');
const cheatClose = document.getElementById('cheatCloseBtn');
// Fill cheat inputs with current state
function populateCheatFields(){
const s = readState();
cheatName.value = s.playerName || '';
cheatBBucks.value = Number(s.bBucks || 0);
cheatWins.value = Number(s.wins || 0);
cheatKills.value = Number(s.allKills || 0);
}
// Toggle cheat modal
function toggleCheat(show){
if (show) {
populateCheatFields();
cheatModal.style.display = 'flex';
cheatModal.setAttribute('aria-hidden','false');
cheatName.focus();
} else {
cheatModal.style.display = 'none';
cheatModal.setAttribute('aria-hidden','true');
}
}
// Listen for global key presses to capture sequence (letters/digits only)
window.addEventListener('keydown', (e) => {
// ignore if focus in an input/textarea to avoid interfering
const tag = document.activeElement && document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const key = e.key.toLowerCase();
// only allow alnum
if (/^[a-z0-9]$/.test(key)) {
buffer += key;
if (buffer.length > sequence.length) buffer = buffer.slice(-sequence.length);
if (buffer === sequence) {
toggleCheat(true);
buffer = '';
}
} else {
// clear buffer for other keys
buffer = '';
}
});
// Apply cheat changes
cheatApply.addEventListener('click', () => {
const s = readState();
// Name
const nm = (cheatName.value || '').trim();
if (nm) s.playerName = nm;
// Numbers (coerce to ints >=0)
const bb = parseInt(cheatBBucks.value, 10);
const ws = parseInt(cheatWins.value, 10);
const ks = parseInt(cheatKills.value, 10);
if (!Number.isNaN(bb) && bb >= 0) s.bBucks = bb;
if (!Number.isNaN(ws) && ws >= 0) s.wins = ws;
if (!Number.isNaN(ks) && ks >= 0) s.allKills = ks;
writeState(s);
// Update visible lobby UI if present
const bbEl = document.querySelector('#lobbyBBucksInjected');
const winsEl = document.querySelector('#lobbyWinsInjected');
const killsEl = document.querySelector('#lobbyKillsInjected');
const nameEls = [document.querySelector('#lobbyPlayerNameInjected'), document.querySelector('#lobbyPlayerName')];
if (bbEl) bbEl.textContent = s.bBucks || 0;
if (winsEl) winsEl.textContent = s.wins || 0;
if (killsEl) killsEl.textContent = s.allKills || 0;
for (const ne of nameEls) if (ne) ne.textContent = s.playerName || 'You';
// If the equipped skin value was changed by cheat, update player image
const playerImg = document.getElementById('lobbyPlayerImgInjected');
if (playerImg) {
if (s.equippedSkin === 'Jeffery Skin') {
playerImg.src = 'https://obnoxious-scarlet-qx2lqxczk8.edgeone.app/canvas%20(1).png';
} else {
playerImg.src = 'https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png';
}
}
toggleCheat(false);
alert('Cheat values applied.');
});
cheatClose.addEventListener('click', ()=> toggleCheat(false));
// Close cheat modal if clicking outside card
cheatModal.addEventListener('click', (e) => {
if (e.target === cheatModal) toggleCheat(false);
});
// Initialize name editing
initName();
// Also listen to storage events so name updates across tabs
window.addEventListener('storage', () => {
const s = readState();
if (nameDisplay && s.playerName) nameDisplay.textContent = s.playerName;
});
// On load, if saved playerName exists, set it in the lobby display(s)
(function applySavedNameToDisplays(){
const s = readState();
if (s.playerName) {
const els = document.querySelectorAll('#lobbyPlayerNameInjected, #lobbyPlayerName');
els.forEach(el => el.textContent = s.playerName);
}
// Also apply equipped skin image if present
const playerImg = document.getElementById('lobbyPlayerImgInjected');
if (playerImg && s.equippedSkin) {
if (s.equippedSkin === 'Jeffery Skin') {
playerImg.src = '';
} else {
playerImg.src = 'https://www.vhv.rs/dpng/d/17-179278_fortnite-default-skin-blonde-hair-hd-png-download.png';
}
}
})();
})();
</script>
<!-- LOBBY NAME EDIT + CHEAT CODE APPEND END -->
</body>
</html>