Spaces:
Running
Running
| <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 ; } | |
| .mobile-buttons-hud { display:none ; } /* 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> |