Spaces:
Running
Running
Manual changes saved
Browse files- index.html +163 -271
index.html
CHANGED
|
@@ -3,24 +3,18 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>BattleZone Royale -
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<script src="https://unpkg.com/feather-icons"></script>
|
| 9 |
<style>
|
| 10 |
html,body { height:100%; margin:0; background:#0b1220; color:#fff; font-family:monospace; }
|
| 11 |
#canvasContainer { position:relative; flex:1; display:flex; justify-content:center; align-items:center; height:100vh; overflow:hidden; }
|
| 12 |
#gameCanvas { display:block; user-select:none; cursor:crosshair; box-shadow:0 0 20px rgba(0,0,0,.5); width:100%; height:100%; }
|
| 13 |
-
|
| 14 |
-
/* Minimap moved to top-right and won't block input */
|
| 15 |
-
#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; }
|
| 16 |
-
/* allow visuals (but pointer-events stays off for whole minimap so it doesn't capture mouse) */
|
| 17 |
-
#minimapCanvas { width:100%; height:100%; display:block; image-rendering: pixelated; border-radius:6px; background:transparent; }
|
| 18 |
-
|
| 19 |
/* HUD */
|
| 20 |
#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; }
|
| 21 |
#hudGearWrap { position:absolute; right:12px; bottom:12px; display:flex; gap:8px; align-items:center; z-index:30; }
|
| 22 |
-
.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;
|
| 23 |
-
.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;
|
| 24 |
.selected { outline: 2px solid rgba(255,215,0,0.9); box-shadow: 0 0 6px rgba(255,215,0,0.12); }
|
| 25 |
.equipped { box-shadow: inset 0 -6px 14px rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.04); }
|
| 26 |
.medkit-count { position:absolute; right:4px; bottom:2px; font-size:10px; color:#ffd; }
|
|
@@ -97,11 +91,6 @@
|
|
| 97 |
<div id="canvasContainer" class="relative bg-gray-900 border-4 border-gray-700 rounded-xl max-w-full max-h-full">
|
| 98 |
<canvas id="gameCanvas"></canvas>
|
| 99 |
|
| 100 |
-
<!-- Minimap (top-right) -->
|
| 101 |
-
<div id="minimap">
|
| 102 |
-
<canvas id="minimapCanvas" width="220" height="140"></canvas>
|
| 103 |
-
</div>
|
| 104 |
-
|
| 105 |
<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">
|
| 106 |
<i data-feather="alert-circle" class="mr-2"></i>
|
| 107 |
<span>STORM APPROACHING! MOVE TO SAFE ZONE!</span>
|
|
@@ -160,9 +149,6 @@
|
|
| 160 |
const continueBtn = document.getElementById('continueBtn');
|
| 161 |
const biomeGrid = document.getElementById('biomeGrid');
|
| 162 |
|
| 163 |
-
const minimapCanvas = document.getElementById('minimapCanvas');
|
| 164 |
-
const miniCtx = minimapCanvas.getContext('2d');
|
| 165 |
-
|
| 166 |
// World
|
| 167 |
const WORLD = { width: 6000, height: 4000 };
|
| 168 |
let camera = { x:0, y:0 };
|
|
@@ -171,12 +157,7 @@
|
|
| 171 |
const ctn = document.getElementById('canvasContainer');
|
| 172 |
canvas.width = Math.max(900, Math.floor(ctn.clientWidth));
|
| 173 |
canvas.height = Math.max(560, Math.floor(ctn.clientHeight));
|
| 174 |
-
// keep minimap internal pixel buffer consistent
|
| 175 |
-
minimapCanvas.width = 220;
|
| 176 |
-
minimapCanvas.height = 140;
|
| 177 |
cameraUpdate();
|
| 178 |
-
// invalidate minimap terrain cache to adjust if world/scale changed
|
| 179 |
-
miniTerrainCache = null;
|
| 180 |
}
|
| 181 |
window.addEventListener('resize', resizeCanvas);
|
| 182 |
|
|
@@ -273,7 +254,7 @@
|
|
| 273 |
function generateLootForBiome(b){
|
| 274 |
const roll = Math.random();
|
| 275 |
if (roll < 0.35) return { type:'medkit', amount:1 };
|
| 276 |
-
if (roll < 0.7) return { type:'materials', amount: 10 };
|
| 277 |
const weapons = [
|
| 278 |
{ name:'Pistol', dmg:12, rate:320, color:'#ffd86b', magSize:12, startReserve:24 },
|
| 279 |
{ name:'SMG', dmg:6, rate:120, color:'#8ef0ff', magSize:30, startReserve:90 },
|
|
@@ -284,60 +265,17 @@
|
|
| 284 |
}
|
| 285 |
|
| 286 |
// Behaviour tuning
|
| 287 |
-
const VIEW_RANGE = 1200;
|
| 288 |
-
const SPAWN_PROTECT_MS = 1200;
|
| 289 |
-
|
| 290 |
-
// collision helpers
|
| 291 |
-
function getObjectRadius(obj){
|
| 292 |
-
if (!obj) return 18;
|
| 293 |
-
if (obj.type === 'wall') return 28;
|
| 294 |
-
if (obj.type === 'stone') return 18;
|
| 295 |
-
if (obj.type === 'wood') return 18;
|
| 296 |
-
return 16;
|
| 297 |
-
}
|
| 298 |
-
function chestRadius(){ return 18; }
|
| 299 |
-
function circleOverlap(x1,y1,r1,x2,y2,r2){
|
| 300 |
-
return Math.hypot(x1-x2,y1-y2) < (r1 + r2);
|
| 301 |
-
}
|
| 302 |
-
|
| 303 |
-
function isCollidingSolid(x,y,r){
|
| 304 |
-
for (const o of objects){
|
| 305 |
-
if (o.dead) continue;
|
| 306 |
-
const rr = getObjectRadius(o);
|
| 307 |
-
if (circleOverlap(x,y,r,o.x,o.y,rr)) return true;
|
| 308 |
-
}
|
| 309 |
-
for (const c of chests){
|
| 310 |
-
if (c.opened) continue;
|
| 311 |
-
if (circleOverlap(x,y,r,c.x,c.y,chestRadius())) return true;
|
| 312 |
-
}
|
| 313 |
-
return false;
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
function moveEntityWithCollision(entity, dx, dy, radius){
|
| 317 |
-
const oldX = entity.x, oldY = entity.y;
|
| 318 |
-
let nx = entity.x + dx;
|
| 319 |
-
entity.x = nx;
|
| 320 |
-
if (entity.x < radius) entity.x = radius;
|
| 321 |
-
if (entity.x > WORLD.width - radius) entity.x = WORLD.width - radius;
|
| 322 |
-
if (isCollidingSolid(entity.x, entity.y, radius)){
|
| 323 |
-
entity.x = oldX;
|
| 324 |
-
}
|
| 325 |
-
let ny = entity.y + dy;
|
| 326 |
-
entity.y = ny;
|
| 327 |
-
if (entity.y < radius) entity.y = radius;
|
| 328 |
-
if (entity.y > WORLD.height - radius) entity.y = WORLD.height - radius;
|
| 329 |
-
if (isCollidingSolid(entity.x, entity.y, radius)){
|
| 330 |
-
entity.y = oldY;
|
| 331 |
-
}
|
| 332 |
-
}
|
| 333 |
|
|
|
|
| 334 |
function populateWorld(){
|
| 335 |
chests.length = 0; objects.length = 0; enemies.length = 0; pickups.length = 0;
|
| 336 |
for (let i=0;i<260;i++){
|
| 337 |
const x = rand(150, WORLD.width-150);
|
| 338 |
const y = rand(150, WORLD.height-150);
|
| 339 |
const loot = generateLootForBiome(biomeAt(x,y));
|
| 340 |
-
if (loot.type === 'materials') loot.amount = 10;
|
| 341 |
chests.push({ x,y, opened:false, loot });
|
| 342 |
}
|
| 343 |
for (let i=0;i<700;i++){
|
|
@@ -349,6 +287,7 @@
|
|
| 349 |
const hp = type==='wood'?40 : (type==='stone'?80:160);
|
| 350 |
objects.push({ x,y, type, hp, maxHp:hp, dead:false });
|
| 351 |
}
|
|
|
|
| 352 |
const now = performance.now();
|
| 353 |
for (let i=0;i<49;i++){
|
| 354 |
const ex = rand(300, WORLD.width-300);
|
|
@@ -357,7 +296,7 @@
|
|
| 357 |
id:'e'+i, x:ex, y:ey, radius:14, angle:rand(0,Math.PI*2), speed:110+rand(-20,20),
|
| 358 |
health: 80 + randInt(0,40), lastMelee:0, meleeRate:800 + randInt(-200,200),
|
| 359 |
roamTimer: rand(0,3),
|
| 360 |
-
inventory: [null,null,null,null,null],
|
| 361 |
selectedSlot: 0,
|
| 362 |
equippedIndex: -1,
|
| 363 |
materials: 0,
|
|
@@ -365,12 +304,11 @@
|
|
| 365 |
reloadingUntil: 0,
|
| 366 |
reloadPending: false,
|
| 367 |
lastAttackedTime: 0,
|
| 368 |
-
lastAttackerId: null,
|
| 369 |
state: 'gather',
|
| 370 |
gatherTimeLeft: rand(8,16),
|
| 371 |
target: null,
|
| 372 |
nextHealTime: 0,
|
| 373 |
-
spawnSafeUntil: now + SPAWN_PROTECT_MS
|
| 374 |
});
|
| 375 |
}
|
| 376 |
updatePlayerCount();
|
|
@@ -667,11 +605,6 @@
|
|
| 667 |
return closest;
|
| 668 |
}
|
| 669 |
|
| 670 |
-
function findEnemyById(id){
|
| 671 |
-
if (!id) return null;
|
| 672 |
-
return enemies.find(en => en.id === id) || null;
|
| 673 |
-
}
|
| 674 |
-
|
| 675 |
// Bullets update
|
| 676 |
function bulletsUpdate(dt){
|
| 677 |
for (let i=bullets.length-1;i>=0;i--){
|
|
@@ -693,7 +626,6 @@
|
|
| 693 |
if (Math.hypot(e.x - b.x, e.y - b.y) < 14){
|
| 694 |
e.health -= b.dmg;
|
| 695 |
e.lastAttackedTime = performance.now();
|
| 696 |
-
e.lastAttackerId = b.shooter;
|
| 697 |
if (e.health <= 0){ e.health = 0; if (b.shooter === 'player'){ player.kills++; player.materials += 2; updatePlayerCount(); } }
|
| 698 |
bullets.splice(i,1); break;
|
| 699 |
}
|
|
@@ -746,29 +678,39 @@
|
|
| 746 |
|
| 747 |
// Enemy AI with gather-first + separation + reload with delay
|
| 748 |
function updateEnemies(dt, now){
|
| 749 |
-
|
|
|
|
|
|
|
| 750 |
for (const e of enemies){
|
| 751 |
if (e.health <= 0) continue;
|
|
|
|
|
|
|
| 752 |
if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
|
| 753 |
|
| 754 |
-
// storm
|
| 755 |
if (storm.active){
|
| 756 |
const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
|
| 757 |
if (distToSafeCenter > storm.radius){
|
|
|
|
| 758 |
e.state = 'toSafe';
|
| 759 |
e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 764 |
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
| 766 |
}
|
| 767 |
}
|
| 768 |
|
|
|
|
| 769 |
if (now - e.lastAttackedTime < 4000) e.state = 'combat';
|
|
|
|
| 770 |
if (e.state === 'gather') e.gatherTimeLeft -= dt;
|
| 771 |
|
|
|
|
| 772 |
if (e.health < 60 && now >= (e.nextHealTime || 0)){
|
| 773 |
let medIdx = -1;
|
| 774 |
for (let s=0;s<5;s++){
|
|
@@ -785,18 +727,20 @@
|
|
| 785 |
}
|
| 786 |
}
|
| 787 |
|
|
|
|
| 788 |
const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
|
| 789 |
if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
|
| 790 |
|
| 791 |
if (e.state === 'gather'){
|
|
|
|
|
|
|
| 792 |
if (now >= (e.spawnSafeUntil || 0)){
|
| 793 |
let p = findNearestPickup(e, 240);
|
| 794 |
if (p){
|
| 795 |
const angle = Math.atan2(p.y - e.y, p.x - e.x);
|
| 796 |
e.angle = angle;
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 800 |
if (Math.hypot(p.x - e.x, p.y - e.y) < 18){
|
| 801 |
enemyPickupCollect(e, p);
|
| 802 |
const idx = pickups.indexOf(p);
|
|
@@ -811,9 +755,8 @@
|
|
| 811 |
const d = Math.hypot(chestTarget.x - e.x, chestTarget.y - e.y);
|
| 812 |
if (d > 20){
|
| 813 |
e.angle = Math.atan2(chestTarget.y - e.y, chestTarget.x - e.x);
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 817 |
} else {
|
| 818 |
chestTarget.opened = true;
|
| 819 |
const loot = chestTarget.loot;
|
|
@@ -829,9 +772,8 @@
|
|
| 829 |
const d = Math.hypot(objTarget.x - e.x, objTarget.y - e.y);
|
| 830 |
if (d > 26){
|
| 831 |
e.angle = Math.atan2(objTarget.y - e.y, objTarget.x - e.x);
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 835 |
} else {
|
| 836 |
objTarget.hp -= 40 * dt;
|
| 837 |
if (objTarget.hp <= 0 && !objTarget.dead){
|
|
@@ -848,15 +790,17 @@
|
|
| 848 |
continue;
|
| 849 |
}
|
| 850 |
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
if (Math.random() < 0.01) e.angle += (Math.random()-0.5)*2;
|
| 855 |
continue;
|
| 856 |
}
|
| 857 |
|
|
|
|
| 858 |
if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
|
| 859 |
|
|
|
|
| 860 |
if (e.reloadPending){
|
| 861 |
if (now >= e.reloadingUntil){
|
| 862 |
const eq = e.inventory[e.equippedIndex];
|
|
@@ -866,56 +810,92 @@
|
|
| 866 |
}
|
| 867 |
}
|
| 868 |
|
|
|
|
| 869 |
if (e.equippedIndex >= 0){
|
| 870 |
const eq = e.inventory[e.equippedIndex];
|
| 871 |
if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
|
| 872 |
e.reloadPending = true;
|
| 873 |
-
e.reloadingUntil = now + 600 + rand(-100,100);
|
| 874 |
}
|
| 875 |
}
|
| 876 |
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
if (
|
| 887 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
} else {
|
| 889 |
-
|
| 890 |
-
const c = findNearestChest(e, 1200);
|
| 891 |
-
const h = findNearestHarvestable(e, 1200);
|
| 892 |
-
let candidate = null, cd = Infinity;
|
| 893 |
-
if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
|
| 894 |
-
if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
|
| 895 |
-
if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
|
| 896 |
-
for (const other of enemies){
|
| 897 |
-
if (other === e || other.health <= 0) continue;
|
| 898 |
-
const d = Math.hypot(other.x - e.x, other.y - e.y);
|
| 899 |
-
if (d < cd && d <= 800){
|
| 900 |
-
if (Math.random() < 0.6 || e.state === 'combat'){
|
| 901 |
-
candidate = other; cd = d;
|
| 902 |
-
}
|
| 903 |
-
}
|
| 904 |
-
}
|
| 905 |
-
if (candidate) target = candidate;
|
| 906 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
}
|
| 908 |
|
|
|
|
| 909 |
if (!target){
|
| 910 |
e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
|
| 911 |
e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
|
| 912 |
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
|
| 913 |
-
moveEntityWithCollision(e, 0, 0, e.radius);
|
| 914 |
continue;
|
| 915 |
}
|
| 916 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
if (target === player){
|
| 918 |
-
|
| 919 |
if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
|
| 920 |
e.lastMelee = now;
|
| 921 |
const dmg = 10 + randInt(0,8);
|
|
@@ -926,7 +906,7 @@
|
|
| 926 |
const eq = e.inventory[e.equippedIndex];
|
| 927 |
if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
|
| 928 |
if (eq.ammoInMag <= 0){
|
| 929 |
-
// reload handled above
|
| 930 |
} else {
|
| 931 |
if (hasLineOfSight(e.x, e.y, target.x, target.y)){
|
| 932 |
e.lastShot = now;
|
|
@@ -935,44 +915,43 @@
|
|
| 935 |
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);
|
| 936 |
e.lastAttackedTime = now;
|
| 937 |
} else {
|
|
|
|
| 938 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 942 |
}
|
| 943 |
}
|
| 944 |
} else {
|
|
|
|
| 945 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 949 |
}
|
| 950 |
} else {
|
|
|
|
| 951 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 955 |
}
|
| 956 |
} else {
|
|
|
|
| 957 |
const td = Math.hypot(target.x - e.x, target.y - e.y);
|
| 958 |
-
if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){
|
| 959 |
if (td > 20){
|
| 960 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 964 |
} else {
|
| 965 |
enemyPickupCollect(e, target);
|
| 966 |
const idx = pickups.indexOf(target);
|
| 967 |
if (idx >= 0) pickups.splice(idx,1);
|
| 968 |
e.state = 'gather';
|
| 969 |
}
|
| 970 |
-
} else if (target.hasOwnProperty('loot')){
|
| 971 |
if (td > 20){
|
| 972 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 976 |
} else {
|
| 977 |
target.opened = true;
|
| 978 |
const loot = target.loot;
|
|
@@ -981,12 +960,11 @@
|
|
| 981 |
else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
|
| 982 |
e.state = 'gather';
|
| 983 |
}
|
| 984 |
-
} else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){
|
| 985 |
if (td > 26){
|
| 986 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 990 |
} else {
|
| 991 |
target.hp -= 40 * dt;
|
| 992 |
if (target.hp <= 0 && !target.dead){
|
|
@@ -996,27 +974,20 @@
|
|
| 996 |
e.state = 'gather';
|
| 997 |
}
|
| 998 |
} else {
|
|
|
|
| 999 |
if (td > 40){
|
| 1000 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
moveEntityWithCollision(e, dx, dy, e.radius);
|
| 1004 |
} else {
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
if (target && target.health > 0){
|
| 1008 |
-
target.health -= 8 + randInt(0,6);
|
| 1009 |
-
target.lastAttackedTime = now;
|
| 1010 |
-
target.lastAttackerId = e.id;
|
| 1011 |
-
if (target.health <= 0) target.health = 0;
|
| 1012 |
-
}
|
| 1013 |
-
}
|
| 1014 |
}
|
| 1015 |
}
|
| 1016 |
}
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
-
// Separation
|
| 1020 |
for (let i = 0; i < enemies.length; i++){
|
| 1021 |
const a = enemies[i];
|
| 1022 |
if (!a || a.health <= 0) continue;
|
|
@@ -1029,12 +1000,14 @@
|
|
| 1029 |
if (d < minD){
|
| 1030 |
const overlap = (minD - d) * 0.5;
|
| 1031 |
const nx = dx / d, ny = dy / d;
|
|
|
|
| 1032 |
b.x += nx * overlap;
|
| 1033 |
b.y += ny * overlap;
|
| 1034 |
a.x -= nx * overlap;
|
| 1035 |
a.y -= ny * overlap;
|
| 1036 |
}
|
| 1037 |
}
|
|
|
|
| 1038 |
const pdx = a.x - player.x, pdy = a.y - player.y;
|
| 1039 |
const pd = Math.hypot(pdx,pdy) || 0.0001;
|
| 1040 |
const avoidDist = 24;
|
|
@@ -1044,6 +1017,7 @@
|
|
| 1044 |
a.x += nx * overlap;
|
| 1045 |
a.y += ny * overlap;
|
| 1046 |
}
|
|
|
|
| 1047 |
a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
|
| 1048 |
a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
|
| 1049 |
}
|
|
@@ -1107,20 +1081,12 @@
|
|
| 1107 |
}
|
| 1108 |
}
|
| 1109 |
if (storm.active){
|
| 1110 |
-
ctx.save();
|
| 1111 |
-
ctx.fillStyle = 'rgba(10,30,80,0.45)';
|
| 1112 |
-
ctx.fillRect(0,0,canvas.width,canvas.height);
|
| 1113 |
const sc = worldToScreen(storm.centerX, storm.centerY);
|
| 1114 |
-
ctx.
|
| 1115 |
-
ctx.
|
| 1116 |
-
|
| 1117 |
-
ctx.fill();
|
| 1118 |
-
ctx.
|
| 1119 |
-
ctx.strokeStyle = 'rgba(255,200,80,0.9)';
|
| 1120 |
-
ctx.lineWidth = 3;
|
| 1121 |
-
ctx.beginPath();
|
| 1122 |
-
ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2);
|
| 1123 |
-
ctx.stroke();
|
| 1124 |
ctx.restore();
|
| 1125 |
}
|
| 1126 |
}
|
|
@@ -1195,6 +1161,7 @@
|
|
| 1195 |
const hpPct = Math.max(0, Math.min(1, e.health/120));
|
| 1196 |
ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
|
| 1197 |
|
|
|
|
| 1198 |
if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
|
| 1199 |
const we = e.inventory[e.equippedIndex];
|
| 1200 |
const color = we.weapon.color || '#ddd';
|
|
@@ -1224,6 +1191,7 @@
|
|
| 1224 |
}
|
| 1225 |
}
|
| 1226 |
|
|
|
|
| 1227 |
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)');
|
| 1228 |
ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
|
| 1229 |
ctx.restore();
|
|
@@ -1288,79 +1256,7 @@
|
|
| 1288 |
ctx.restore();
|
| 1289 |
}
|
| 1290 |
|
| 1291 |
-
|
| 1292 |
-
let miniTerrainCache = null;
|
| 1293 |
-
function buildMiniTerrainCache(){
|
| 1294 |
-
const mw = minimapCanvas.width;
|
| 1295 |
-
const mh = minimapCanvas.height;
|
| 1296 |
-
const scaleX = WORLD.width / mw;
|
| 1297 |
-
const scaleY = WORLD.height / mh;
|
| 1298 |
-
const img = miniCtx.createImageData(mw, mh);
|
| 1299 |
-
for (let my=0; my<mh; my++){
|
| 1300 |
-
for (let mx=0; mx<mw; mx++){
|
| 1301 |
-
const wx = Math.floor(mx * scaleX + scaleX/2);
|
| 1302 |
-
const wy = Math.floor(my * scaleY + scaleY/2);
|
| 1303 |
-
const b = biomeAt(wx, wy);
|
| 1304 |
-
let col = [32,58,43];
|
| 1305 |
-
if (b==='desert') col = [203,183,139];
|
| 1306 |
-
else if (b==='forest') col = [22,65,31];
|
| 1307 |
-
else if (b==='oasis') col = [39,75,82];
|
| 1308 |
-
else if (b==='ruins') col = [74,59,59];
|
| 1309 |
-
const idx = (my*mw + mx)*4;
|
| 1310 |
-
img.data[idx] = col[0];
|
| 1311 |
-
img.data[idx+1] = col[1];
|
| 1312 |
-
img.data[idx+2] = col[2];
|
| 1313 |
-
img.data[idx+3] = 255;
|
| 1314 |
-
}
|
| 1315 |
-
}
|
| 1316 |
-
miniTerrainCache = img;
|
| 1317 |
-
}
|
| 1318 |
-
|
| 1319 |
-
function drawMinimap(){
|
| 1320 |
-
const mw = minimapCanvas.width;
|
| 1321 |
-
const mh = minimapCanvas.height;
|
| 1322 |
-
if (!miniTerrainCache) buildMiniTerrainCache();
|
| 1323 |
-
miniCtx.putImageData(miniTerrainCache, 0, 0);
|
| 1324 |
-
|
| 1325 |
-
// draw storm overlay: color outside safe zone, leave inside transparent
|
| 1326 |
-
if (storm.active){
|
| 1327 |
-
miniCtx.save();
|
| 1328 |
-
miniCtx.fillStyle = 'rgba(10,30,80,0.55)';
|
| 1329 |
-
miniCtx.fillRect(0,0,mw,mh);
|
| 1330 |
-
miniCtx.globalCompositeOperation = 'destination-out';
|
| 1331 |
-
const cx = (storm.centerX) / WORLD.width * mw;
|
| 1332 |
-
const cy = (storm.centerY) / WORLD.height * mh;
|
| 1333 |
-
const r = storm.radius / WORLD.width * mw;
|
| 1334 |
-
miniCtx.beginPath();
|
| 1335 |
-
miniCtx.arc(cx, cy, r, 0, Math.PI*2);
|
| 1336 |
-
miniCtx.fill();
|
| 1337 |
-
miniCtx.globalCompositeOperation = 'source-over';
|
| 1338 |
-
miniCtx.strokeStyle = 'rgba(255,200,80,0.9)';
|
| 1339 |
-
miniCtx.lineWidth = 2;
|
| 1340 |
-
miniCtx.beginPath();
|
| 1341 |
-
miniCtx.arc(cx, cy, r, 0, Math.PI*2);
|
| 1342 |
-
miniCtx.stroke();
|
| 1343 |
-
miniCtx.restore();
|
| 1344 |
-
}
|
| 1345 |
-
|
| 1346 |
-
// draw player dot
|
| 1347 |
-
const px = player.x / WORLD.width * mw;
|
| 1348 |
-
const py = player.y / WORLD.height * mh;
|
| 1349 |
-
miniCtx.fillStyle = '#ffff66';
|
| 1350 |
-
miniCtx.beginPath();
|
| 1351 |
-
miniCtx.arc(px, py, 3, 0, Math.PI*2);
|
| 1352 |
-
miniCtx.fill();
|
| 1353 |
-
|
| 1354 |
-
// draw safe-zone center dot
|
| 1355 |
-
if (storm.active){
|
| 1356 |
-
const cx = (storm.centerX) / WORLD.width * mw;
|
| 1357 |
-
const cy = (storm.centerY) / WORLD.height * mh;
|
| 1358 |
-
miniCtx.fillStyle = 'rgba(255,200,80,0.9)';
|
| 1359 |
-
miniCtx.beginPath();
|
| 1360 |
-
miniCtx.arc(cx, cy, 2, 0, Math.PI*2);
|
| 1361 |
-
miniCtx.fill();
|
| 1362 |
-
}
|
| 1363 |
-
}
|
| 1364 |
|
| 1365 |
// Main loop
|
| 1366 |
let lastTime = 0;
|
|
@@ -1370,25 +1266,13 @@
|
|
| 1370 |
const dt = Math.min(0.05, (ts - lastTime)/1000);
|
| 1371 |
lastTime = ts;
|
| 1372 |
|
|
|
|
| 1373 |
let dx=0, dy=0;
|
| 1374 |
if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
|
| 1375 |
if (dx !== 0 || dy !== 0){
|
| 1376 |
const len = Math.hypot(dx,dy) || 1;
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
const oldX = player.x, oldY = player.y;
|
| 1380 |
-
player.x += mvx;
|
| 1381 |
-
if (player.x < player.radius) player.x = player.radius;
|
| 1382 |
-
if (player.x > WORLD.width - player.radius) player.x = WORLD.width - player.radius;
|
| 1383 |
-
if (isCollidingSolid(player.x, player.y, player.radius)){
|
| 1384 |
-
player.x = oldX;
|
| 1385 |
-
}
|
| 1386 |
-
player.y += mvy;
|
| 1387 |
-
if (player.y < player.radius) player.y = player.radius;
|
| 1388 |
-
if (player.y > WORLD.height - player.radius) player.y = WORLD.height - player.radius;
|
| 1389 |
-
if (isCollidingSolid(player.x, player.y, player.radius)){
|
| 1390 |
-
player.y = oldY;
|
| 1391 |
-
}
|
| 1392 |
}
|
| 1393 |
player.x = Math.max(16, Math.min(WORLD.width-16, player.x));
|
| 1394 |
player.y = Math.max(16, Math.min(WORLD.height-16, player.y));
|
|
@@ -1405,6 +1289,7 @@
|
|
| 1405 |
if (selected && selected.type === 'weapon') activeWeaponItem = selected;
|
| 1406 |
}
|
| 1407 |
|
|
|
|
| 1408 |
if (mouse.down){
|
| 1409 |
if (player.equippedIndex === -1) playerMeleeHit();
|
| 1410 |
else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
|
|
@@ -1423,26 +1308,27 @@
|
|
| 1423 |
if (keys.e){ interactNearby(); keys.e = false; }
|
| 1424 |
if (keys.q){ tryBuild(); keys.q = false; }
|
| 1425 |
|
|
|
|
| 1426 |
updateEnemies(dt, performance.now());
|
| 1427 |
bulletsUpdate(dt);
|
| 1428 |
|
|
|
|
| 1429 |
for (let i=pickups.length-1;i>=0;i--){
|
| 1430 |
const p = pickups[i];
|
| 1431 |
if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
|
| 1432 |
}
|
| 1433 |
|
|
|
|
| 1434 |
for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
|
| 1435 |
|
| 1436 |
updatePlayerCount();
|
| 1437 |
updateStorm(dt);
|
| 1438 |
|
|
|
|
| 1439 |
ctx.clearRect(0,0,canvas.width,canvas.height);
|
| 1440 |
-
drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer();
|
| 1441 |
updateHUD();
|
| 1442 |
|
| 1443 |
-
// minimap draw (cached terrain + overlays) - cheap each frame now
|
| 1444 |
-
drawMinimap();
|
| 1445 |
-
|
| 1446 |
if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
|
| 1447 |
|
| 1448 |
requestAnimationFrame(gameLoop);
|
|
@@ -1451,6 +1337,7 @@
|
|
| 1451 |
// Landing -> spawn selection
|
| 1452 |
let selectedBiome = null;
|
| 1453 |
function getSpawnForBiome(b){
|
|
|
|
| 1454 |
if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
|
| 1455 |
if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
|
| 1456 |
if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
|
|
@@ -1465,6 +1352,7 @@
|
|
| 1465 |
let gameActive = false;
|
| 1466 |
|
| 1467 |
function startGame(biome){
|
|
|
|
| 1468 |
selectedBiome = biome || selectedBiome;
|
| 1469 |
const spawn = getSpawnForBiome(selectedBiome);
|
| 1470 |
document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
|
|
@@ -1487,16 +1375,17 @@
|
|
| 1487 |
initHUD();
|
| 1488 |
cameraUpdate();
|
| 1489 |
|
|
|
|
| 1490 |
for (const e of enemies){
|
| 1491 |
if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
|
| 1492 |
e.x += (Math.random()<0.5? -1:1) * rand(160,260);
|
| 1493 |
e.y += (Math.random()<0.5? -1:1) * rand(160,260);
|
| 1494 |
}
|
|
|
|
| 1495 |
e.inventory = [null,null,null,null,null];
|
| 1496 |
e.equippedIndex = -1;
|
| 1497 |
e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
|
| 1498 |
e.state = 'gather';
|
| 1499 |
-
e.nextHealTime = 0;
|
| 1500 |
}
|
| 1501 |
|
| 1502 |
gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
|
|
@@ -1529,17 +1418,20 @@
|
|
| 1529 |
goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
|
| 1530 |
continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
|
| 1531 |
|
|
|
|
| 1532 |
document.querySelectorAll('.biome-selector').forEach(el => {
|
| 1533 |
el.addEventListener('click', (ev)=>{
|
|
|
|
| 1534 |
document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
|
| 1535 |
el.classList.add('biome-selected');
|
| 1536 |
const biome = el.dataset.biome;
|
| 1537 |
selectedBiome = biome;
|
|
|
|
| 1538 |
startGame(biome);
|
| 1539 |
});
|
| 1540 |
});
|
| 1541 |
|
| 1542 |
-
//
|
| 1543 |
resizeCanvas();
|
| 1544 |
populateWorld();
|
| 1545 |
feather.replace();
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>BattleZone Royale - Spawn & AI Fixes</title>
|
| 7 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
<script src="https://unpkg.com/feather-icons"></script>
|
| 9 |
<style>
|
| 10 |
html,body { height:100%; margin:0; background:#0b1220; color:#fff; font-family:monospace; }
|
| 11 |
#canvasContainer { position:relative; flex:1; display:flex; justify-content:center; align-items:center; height:100vh; overflow:hidden; }
|
| 12 |
#gameCanvas { display:block; user-select:none; cursor:crosshair; box-shadow:0 0 20px rgba(0,0,0,.5); width:100%; height:100%; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
/* HUD */
|
| 14 |
#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; }
|
| 15 |
#hudGearWrap { position:absolute; right:12px; bottom:12px; display:flex; gap:8px; align-items:center; z-index:30; }
|
| 16 |
+
.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; }
|
| 17 |
+
.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; }
|
| 18 |
.selected { outline: 2px solid rgba(255,215,0,0.9); box-shadow: 0 0 6px rgba(255,215,0,0.12); }
|
| 19 |
.equipped { box-shadow: inset 0 -6px 14px rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.04); }
|
| 20 |
.medkit-count { position:absolute; right:4px; bottom:2px; font-size:10px; color:#ffd; }
|
|
|
|
| 91 |
<div id="canvasContainer" class="relative bg-gray-900 border-4 border-gray-700 rounded-xl max-w-full max-h-full">
|
| 92 |
<canvas id="gameCanvas"></canvas>
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
<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">
|
| 95 |
<i data-feather="alert-circle" class="mr-2"></i>
|
| 96 |
<span>STORM APPROACHING! MOVE TO SAFE ZONE!</span>
|
|
|
|
| 149 |
const continueBtn = document.getElementById('continueBtn');
|
| 150 |
const biomeGrid = document.getElementById('biomeGrid');
|
| 151 |
|
|
|
|
|
|
|
|
|
|
| 152 |
// World
|
| 153 |
const WORLD = { width: 6000, height: 4000 };
|
| 154 |
let camera = { x:0, y:0 };
|
|
|
|
| 157 |
const ctn = document.getElementById('canvasContainer');
|
| 158 |
canvas.width = Math.max(900, Math.floor(ctn.clientWidth));
|
| 159 |
canvas.height = Math.max(560, Math.floor(ctn.clientHeight));
|
|
|
|
|
|
|
|
|
|
| 160 |
cameraUpdate();
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
window.addEventListener('resize', resizeCanvas);
|
| 163 |
|
|
|
|
| 254 |
function generateLootForBiome(b){
|
| 255 |
const roll = Math.random();
|
| 256 |
if (roll < 0.35) return { type:'medkit', amount:1 };
|
| 257 |
+
if (roll < 0.7) return { type:'materials', amount: 10 }; // always 10
|
| 258 |
const weapons = [
|
| 259 |
{ name:'Pistol', dmg:12, rate:320, color:'#ffd86b', magSize:12, startReserve:24 },
|
| 260 |
{ name:'SMG', dmg:6, rate:120, color:'#8ef0ff', magSize:30, startReserve:90 },
|
|
|
|
| 265 |
}
|
| 266 |
|
| 267 |
// Behaviour tuning
|
| 268 |
+
const VIEW_RANGE = 1200; // if player is farther than this, enemies switch target
|
| 269 |
+
const SPAWN_PROTECT_MS = 1200; // time after spawn they ignore ground pickups to avoid instant pickup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
+
// Populate world - spawn many objects and enemies
|
| 272 |
function populateWorld(){
|
| 273 |
chests.length = 0; objects.length = 0; enemies.length = 0; pickups.length = 0;
|
| 274 |
for (let i=0;i<260;i++){
|
| 275 |
const x = rand(150, WORLD.width-150);
|
| 276 |
const y = rand(150, WORLD.height-150);
|
| 277 |
const loot = generateLootForBiome(biomeAt(x,y));
|
| 278 |
+
if (loot.type === 'materials') loot.amount = 10; // ensure 10
|
| 279 |
chests.push({ x,y, opened:false, loot });
|
| 280 |
}
|
| 281 |
for (let i=0;i<700;i++){
|
|
|
|
| 287 |
const hp = type==='wood'?40 : (type==='stone'?80:160);
|
| 288 |
objects.push({ x,y, type, hp, maxHp:hp, dead:false });
|
| 289 |
}
|
| 290 |
+
// spawn enemies - ensure they start unarmed and must loot to obtain weapons
|
| 291 |
const now = performance.now();
|
| 292 |
for (let i=0;i<49;i++){
|
| 293 |
const ex = rand(300, WORLD.width-300);
|
|
|
|
| 296 |
id:'e'+i, x:ex, y:ey, radius:14, angle:rand(0,Math.PI*2), speed:110+rand(-20,20),
|
| 297 |
health: 80 + randInt(0,40), lastMelee:0, meleeRate:800 + randInt(-200,200),
|
| 298 |
roamTimer: rand(0,3),
|
| 299 |
+
inventory: [null,null,null,null,null], // explicitly no weapons at spawn
|
| 300 |
selectedSlot: 0,
|
| 301 |
equippedIndex: -1,
|
| 302 |
materials: 0,
|
|
|
|
| 304 |
reloadingUntil: 0,
|
| 305 |
reloadPending: false,
|
| 306 |
lastAttackedTime: 0,
|
|
|
|
| 307 |
state: 'gather',
|
| 308 |
gatherTimeLeft: rand(8,16),
|
| 309 |
target: null,
|
| 310 |
nextHealTime: 0,
|
| 311 |
+
spawnSafeUntil: now + SPAWN_PROTECT_MS // cooldown: ignore nearby ground pickups for short time
|
| 312 |
});
|
| 313 |
}
|
| 314 |
updatePlayerCount();
|
|
|
|
| 605 |
return closest;
|
| 606 |
}
|
| 607 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
// Bullets update
|
| 609 |
function bulletsUpdate(dt){
|
| 610 |
for (let i=bullets.length-1;i>=0;i--){
|
|
|
|
| 626 |
if (Math.hypot(e.x - b.x, e.y - b.y) < 14){
|
| 627 |
e.health -= b.dmg;
|
| 628 |
e.lastAttackedTime = performance.now();
|
|
|
|
| 629 |
if (e.health <= 0){ e.health = 0; if (b.shooter === 'player'){ player.kills++; player.materials += 2; updatePlayerCount(); } }
|
| 630 |
bullets.splice(i,1); break;
|
| 631 |
}
|
|
|
|
| 678 |
|
| 679 |
// Enemy AI with gather-first + separation + reload with delay
|
| 680 |
function updateEnemies(dt, now){
|
| 681 |
+
// basic separation step variables
|
| 682 |
+
const minSeparation = 20; // minimal distance between enemies
|
| 683 |
+
// Update each enemy movement and actions
|
| 684 |
for (const e of enemies){
|
| 685 |
if (e.health <= 0) continue;
|
| 686 |
+
|
| 687 |
+
// ensure spawn protection exists
|
| 688 |
if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
|
| 689 |
|
| 690 |
+
// If storm is active and enemy is outside safe zone, move to safe zone first
|
| 691 |
if (storm.active){
|
| 692 |
const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
|
| 693 |
if (distToSafeCenter > storm.radius){
|
| 694 |
+
// move towards safe zone center
|
| 695 |
e.state = 'toSafe';
|
| 696 |
e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
|
| 697 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.95;
|
| 698 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.95;
|
| 699 |
+
// avoid getting stuck: allow minor wandering
|
|
|
|
| 700 |
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
|
| 701 |
+
// clamp
|
| 702 |
+
e.x = Math.max(12, Math.min(WORLD.width-12, e.x));
|
| 703 |
+
e.y = Math.max(12, Math.min(WORLD.height-12, e.y));
|
| 704 |
+
continue; // priority movement to safe zone
|
| 705 |
}
|
| 706 |
}
|
| 707 |
|
| 708 |
+
// if recently attacked go combat
|
| 709 |
if (now - e.lastAttackedTime < 4000) e.state = 'combat';
|
| 710 |
+
|
| 711 |
if (e.state === 'gather') e.gatherTimeLeft -= dt;
|
| 712 |
|
| 713 |
+
// Medkit-only heal (consume medkit, with cooldown)
|
| 714 |
if (e.health < 60 && now >= (e.nextHealTime || 0)){
|
| 715 |
let medIdx = -1;
|
| 716 |
for (let s=0;s<5;s++){
|
|
|
|
| 727 |
}
|
| 728 |
}
|
| 729 |
|
| 730 |
+
// Transition gather->combat only if they actually have weapons/ammo or enough materials or time expired
|
| 731 |
const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
|
| 732 |
if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
|
| 733 |
|
| 734 |
if (e.state === 'gather'){
|
| 735 |
+
// Prioritize pickup -> chest -> harvest -> roam
|
| 736 |
+
// Respect spawn protection so they don't instantly pick up something they spawned on
|
| 737 |
if (now >= (e.spawnSafeUntil || 0)){
|
| 738 |
let p = findNearestPickup(e, 240);
|
| 739 |
if (p){
|
| 740 |
const angle = Math.atan2(p.y - e.y, p.x - e.x);
|
| 741 |
e.angle = angle;
|
| 742 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 743 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 744 |
if (Math.hypot(p.x - e.x, p.y - e.y) < 18){
|
| 745 |
enemyPickupCollect(e, p);
|
| 746 |
const idx = pickups.indexOf(p);
|
|
|
|
| 755 |
const d = Math.hypot(chestTarget.x - e.x, chestTarget.y - e.y);
|
| 756 |
if (d > 20){
|
| 757 |
e.angle = Math.atan2(chestTarget.y - e.y, chestTarget.x - e.x);
|
| 758 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 759 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 760 |
} else {
|
| 761 |
chestTarget.opened = true;
|
| 762 |
const loot = chestTarget.loot;
|
|
|
|
| 772 |
const d = Math.hypot(objTarget.x - e.x, objTarget.y - e.y);
|
| 773 |
if (d > 26){
|
| 774 |
e.angle = Math.atan2(objTarget.y - e.y, objTarget.x - e.x);
|
| 775 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.8;
|
| 776 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.8;
|
|
|
|
| 777 |
} else {
|
| 778 |
objTarget.hp -= 40 * dt;
|
| 779 |
if (objTarget.hp <= 0 && !objTarget.dead){
|
|
|
|
| 790 |
continue;
|
| 791 |
}
|
| 792 |
|
| 793 |
+
// roam
|
| 794 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.25;
|
| 795 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.25;
|
| 796 |
if (Math.random() < 0.01) e.angle += (Math.random()-0.5)*2;
|
| 797 |
continue;
|
| 798 |
}
|
| 799 |
|
| 800 |
+
// Combat state
|
| 801 |
if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
|
| 802 |
|
| 803 |
+
// Handle reload pending (delayed reload to simulate player reload)
|
| 804 |
if (e.reloadPending){
|
| 805 |
if (now >= e.reloadingUntil){
|
| 806 |
const eq = e.inventory[e.equippedIndex];
|
|
|
|
| 810 |
}
|
| 811 |
}
|
| 812 |
|
| 813 |
+
// If equipped weapon empty and reserve present, start reload (delayed)
|
| 814 |
if (e.equippedIndex >= 0){
|
| 815 |
const eq = e.inventory[e.equippedIndex];
|
| 816 |
if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
|
| 817 |
e.reloadPending = true;
|
| 818 |
+
e.reloadingUntil = now + 600 + rand(-100,100); // ~600ms reload time
|
| 819 |
}
|
| 820 |
}
|
| 821 |
|
| 822 |
+
// choose a target: prefer player if within view range, otherwise pick something else (pickup/chest/harvest/nearest enemy)
|
| 823 |
+
let target = player;
|
| 824 |
+
let bestDist = Math.hypot(player.x - e.x, player.y - e.y);
|
| 825 |
+
if (bestDist > VIEW_RANGE){
|
| 826 |
+
// player is too far: find the nearest meaningful target
|
| 827 |
+
const p = findNearestPickup(e, 1200);
|
| 828 |
+
const c = findNearestChest(e, 1200);
|
| 829 |
+
const h = findNearestHarvestable(e, 1200);
|
| 830 |
+
let candidate = null, cd = Infinity;
|
| 831 |
+
if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
|
| 832 |
+
if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
|
| 833 |
+
if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
|
| 834 |
+
// try target nearest enemy (tend to form small groups) but don't go after a dead one
|
| 835 |
+
for (const other of enemies){
|
| 836 |
+
if (other === e || other.health <= 0) continue;
|
| 837 |
+
const d = Math.hypot(other.x - e.x, other.y - e.y);
|
| 838 |
+
if (d < cd && d <= 800){ candidate = other; cd = d; }
|
| 839 |
+
}
|
| 840 |
+
if (candidate){
|
| 841 |
+
target = candidate;
|
| 842 |
+
bestDist = cd;
|
| 843 |
} else {
|
| 844 |
+
target = null; bestDist = Infinity; // no specific target - roam
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
}
|
| 846 |
+
} else {
|
| 847 |
+
// still consider switching to another close enemy sometimes
|
| 848 |
+
for (const other of enemies){
|
| 849 |
+
if (other === e || other.health <= 0) continue;
|
| 850 |
+
const d = Math.hypot(other.x - e.x, other.y - e.y);
|
| 851 |
+
if (d < bestDist && Math.random() < 0.6){ bestDist = d; target = other; }
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// retreat/build if low HP and has materials
|
| 856 |
+
const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
|
| 857 |
+
if (distToPlayer < 160 && e.health < 35 && e.materials >= 10 && Math.random() < 0.5){
|
| 858 |
+
enemyTryBuild(e);
|
| 859 |
}
|
| 860 |
|
| 861 |
+
// If no target (roaming), do simple roam
|
| 862 |
if (!target){
|
| 863 |
e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
|
| 864 |
e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
|
| 865 |
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
|
|
|
|
| 866 |
continue;
|
| 867 |
}
|
| 868 |
|
| 869 |
+
const blocked = !hasLineOfSight(e.x, e.y, target.x, target.y);
|
| 870 |
+
if (blocked){
|
| 871 |
+
const blocker = findBlockingObject(e.x, e.y, target.x, target.y);
|
| 872 |
+
if (blocker){
|
| 873 |
+
const db = Math.hypot(blocker.x - e.x, blocker.y - e.y);
|
| 874 |
+
if (db < 36){
|
| 875 |
+
blocker.hp -= 18 * dt * 2;
|
| 876 |
+
if (blocker.hp <= 0){ blocker.dead = true; e.materials += (blocker.type === 'wood' ? 3 : 6); }
|
| 877 |
+
} else {
|
| 878 |
+
if (e.equippedIndex >= 0){
|
| 879 |
+
const eq = e.inventory[e.equippedIndex];
|
| 880 |
+
if (eq && eq.type === 'weapon' && eq.ammoInMag > 0 && !e.reloadPending && now - e.lastShot > (eq.weapon.rate || 300)){
|
| 881 |
+
e.lastShot = now;
|
| 882 |
+
eq.ammoInMag -= 1;
|
| 883 |
+
const angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
|
| 884 |
+
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);
|
| 885 |
+
}
|
| 886 |
+
} else {
|
| 887 |
+
e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
|
| 888 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.8;
|
| 889 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.8;
|
| 890 |
+
}
|
| 891 |
+
}
|
| 892 |
+
continue;
|
| 893 |
+
}
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
// If target is player, perform attack logic; if target is other entity/object, adapt accordingly
|
| 897 |
if (target === player){
|
| 898 |
+
// Attack: if melee range
|
| 899 |
if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
|
| 900 |
e.lastMelee = now;
|
| 901 |
const dmg = 10 + randInt(0,8);
|
|
|
|
| 906 |
const eq = e.inventory[e.equippedIndex];
|
| 907 |
if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
|
| 908 |
if (eq.ammoInMag <= 0){
|
| 909 |
+
// reload will be handled above
|
| 910 |
} else {
|
| 911 |
if (hasLineOfSight(e.x, e.y, target.x, target.y)){
|
| 912 |
e.lastShot = now;
|
|
|
|
| 915 |
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);
|
| 916 |
e.lastAttackedTime = now;
|
| 917 |
} else {
|
| 918 |
+
// move to get LOS
|
| 919 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 920 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.6;
|
| 921 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.6;
|
|
|
|
| 922 |
}
|
| 923 |
}
|
| 924 |
} else {
|
| 925 |
+
// unarmed behavior: rush to melee
|
| 926 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 927 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 928 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 929 |
}
|
| 930 |
} else {
|
| 931 |
+
// no weapon: rush in
|
| 932 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 933 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 934 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 935 |
}
|
| 936 |
} else {
|
| 937 |
+
// Target is a chest/pickup/harvestable/other enemy
|
| 938 |
const td = Math.hypot(target.x - e.x, target.y - e.y);
|
| 939 |
+
if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){ // a pickup
|
| 940 |
if (td > 20){
|
| 941 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 942 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 943 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 944 |
} else {
|
| 945 |
enemyPickupCollect(e, target);
|
| 946 |
const idx = pickups.indexOf(target);
|
| 947 |
if (idx >= 0) pickups.splice(idx,1);
|
| 948 |
e.state = 'gather';
|
| 949 |
}
|
| 950 |
+
} else if (target.hasOwnProperty('loot')){ // chest
|
| 951 |
if (td > 20){
|
| 952 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 953 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.9;
|
| 954 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.9;
|
|
|
|
| 955 |
} else {
|
| 956 |
target.opened = true;
|
| 957 |
const loot = target.loot;
|
|
|
|
| 960 |
else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
|
| 961 |
e.state = 'gather';
|
| 962 |
}
|
| 963 |
+
} else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){ // harvestable object
|
| 964 |
if (td > 26){
|
| 965 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 966 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.8;
|
| 967 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.8;
|
|
|
|
| 968 |
} else {
|
| 969 |
target.hp -= 40 * dt;
|
| 970 |
if (target.hp <= 0 && !target.dead){
|
|
|
|
| 974 |
e.state = 'gather';
|
| 975 |
}
|
| 976 |
} else {
|
| 977 |
+
// target is another enemy (group up / follow)
|
| 978 |
if (td > 40){
|
| 979 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 980 |
+
e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
|
| 981 |
+
e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
|
|
|
|
| 982 |
} else {
|
| 983 |
+
// stay near ally
|
| 984 |
+
if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 985 |
}
|
| 986 |
}
|
| 987 |
}
|
| 988 |
}
|
| 989 |
|
| 990 |
+
// Separation pass to avoid clumping (simple repulsion)
|
| 991 |
for (let i = 0; i < enemies.length; i++){
|
| 992 |
const a = enemies[i];
|
| 993 |
if (!a || a.health <= 0) continue;
|
|
|
|
| 1000 |
if (d < minD){
|
| 1001 |
const overlap = (minD - d) * 0.5;
|
| 1002 |
const nx = dx / d, ny = dy / d;
|
| 1003 |
+
// push both away proportional to overlap
|
| 1004 |
b.x += nx * overlap;
|
| 1005 |
b.y += ny * overlap;
|
| 1006 |
a.x -= nx * overlap;
|
| 1007 |
a.y -= ny * overlap;
|
| 1008 |
}
|
| 1009 |
}
|
| 1010 |
+
// also avoid getting on top of player
|
| 1011 |
const pdx = a.x - player.x, pdy = a.y - player.y;
|
| 1012 |
const pd = Math.hypot(pdx,pdy) || 0.0001;
|
| 1013 |
const avoidDist = 24;
|
|
|
|
| 1017 |
a.x += nx * overlap;
|
| 1018 |
a.y += ny * overlap;
|
| 1019 |
}
|
| 1020 |
+
// clamp to world
|
| 1021 |
a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
|
| 1022 |
a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
|
| 1023 |
}
|
|
|
|
| 1081 |
}
|
| 1082 |
}
|
| 1083 |
if (storm.active){
|
|
|
|
|
|
|
|
|
|
| 1084 |
const sc = worldToScreen(storm.centerX, storm.centerY);
|
| 1085 |
+
ctx.save();
|
| 1086 |
+
const grad = ctx.createRadialGradient(sc.x, sc.y, storm.radius*0.15, sc.x, sc.y, storm.radius);
|
| 1087 |
+
grad.addColorStop(0,'rgba(100,149,237,0.02)'); grad.addColorStop(1,'rgba(100,149,237,0.45)');
|
| 1088 |
+
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2); ctx.fill();
|
| 1089 |
+
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();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
ctx.restore();
|
| 1091 |
}
|
| 1092 |
}
|
|
|
|
| 1161 |
const hpPct = Math.max(0, Math.min(1, e.health/120));
|
| 1162 |
ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
|
| 1163 |
|
| 1164 |
+
// visible equipped weapon
|
| 1165 |
if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
|
| 1166 |
const we = e.inventory[e.equippedIndex];
|
| 1167 |
const color = we.weapon.color || '#ddd';
|
|
|
|
| 1191 |
}
|
| 1192 |
}
|
| 1193 |
|
| 1194 |
+
// state marker
|
| 1195 |
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)');
|
| 1196 |
ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
|
| 1197 |
ctx.restore();
|
|
|
|
| 1256 |
ctx.restore();
|
| 1257 |
}
|
| 1258 |
|
| 1259 |
+
function drawCrosshair(){}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
|
| 1261 |
// Main loop
|
| 1262 |
let lastTime = 0;
|
|
|
|
| 1266 |
const dt = Math.min(0.05, (ts - lastTime)/1000);
|
| 1267 |
lastTime = ts;
|
| 1268 |
|
| 1269 |
+
// player movement
|
| 1270 |
let dx=0, dy=0;
|
| 1271 |
if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
|
| 1272 |
if (dx !== 0 || dy !== 0){
|
| 1273 |
const len = Math.hypot(dx,dy) || 1;
|
| 1274 |
+
player.x += (dx/len) * player.speed * dt;
|
| 1275 |
+
player.y += (dy/len) * player.speed * dt;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1276 |
}
|
| 1277 |
player.x = Math.max(16, Math.min(WORLD.width-16, player.x));
|
| 1278 |
player.y = Math.max(16, Math.min(WORLD.height-16, player.y));
|
|
|
|
| 1289 |
if (selected && selected.type === 'weapon') activeWeaponItem = selected;
|
| 1290 |
}
|
| 1291 |
|
| 1292 |
+
// player attack
|
| 1293 |
if (mouse.down){
|
| 1294 |
if (player.equippedIndex === -1) playerMeleeHit();
|
| 1295 |
else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
|
|
|
|
| 1308 |
if (keys.e){ interactNearby(); keys.e = false; }
|
| 1309 |
if (keys.q){ tryBuild(); keys.q = false; }
|
| 1310 |
|
| 1311 |
+
// update
|
| 1312 |
updateEnemies(dt, performance.now());
|
| 1313 |
bulletsUpdate(dt);
|
| 1314 |
|
| 1315 |
+
// player pickup auto-collect
|
| 1316 |
for (let i=pickups.length-1;i>=0;i--){
|
| 1317 |
const p = pickups[i];
|
| 1318 |
if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
|
| 1319 |
}
|
| 1320 |
|
| 1321 |
+
// clean dead objects
|
| 1322 |
for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
|
| 1323 |
|
| 1324 |
updatePlayerCount();
|
| 1325 |
updateStorm(dt);
|
| 1326 |
|
| 1327 |
+
// render
|
| 1328 |
ctx.clearRect(0,0,canvas.width,canvas.height);
|
| 1329 |
+
drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer(); drawCrosshair();
|
| 1330 |
updateHUD();
|
| 1331 |
|
|
|
|
|
|
|
|
|
|
| 1332 |
if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
|
| 1333 |
|
| 1334 |
requestAnimationFrame(gameLoop);
|
|
|
|
| 1337 |
// Landing -> spawn selection
|
| 1338 |
let selectedBiome = null;
|
| 1339 |
function getSpawnForBiome(b){
|
| 1340 |
+
// choose a spawn area region for each biome
|
| 1341 |
if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
|
| 1342 |
if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
|
| 1343 |
if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
|
|
|
|
| 1352 |
let gameActive = false;
|
| 1353 |
|
| 1354 |
function startGame(biome){
|
| 1355 |
+
// set current biome & spawn where selected
|
| 1356 |
selectedBiome = biome || selectedBiome;
|
| 1357 |
const spawn = getSpawnForBiome(selectedBiome);
|
| 1358 |
document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
|
|
|
|
| 1375 |
initHUD();
|
| 1376 |
cameraUpdate();
|
| 1377 |
|
| 1378 |
+
// small safety: ensure enemies don't spawn right on player
|
| 1379 |
for (const e of enemies){
|
| 1380 |
if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
|
| 1381 |
e.x += (Math.random()<0.5? -1:1) * rand(160,260);
|
| 1382 |
e.y += (Math.random()<0.5? -1:1) * rand(160,260);
|
| 1383 |
}
|
| 1384 |
+
// ensure they are unarmed at start
|
| 1385 |
e.inventory = [null,null,null,null,null];
|
| 1386 |
e.equippedIndex = -1;
|
| 1387 |
e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
|
| 1388 |
e.state = 'gather';
|
|
|
|
| 1389 |
}
|
| 1390 |
|
| 1391 |
gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
|
|
|
|
| 1418 |
goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
|
| 1419 |
continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
|
| 1420 |
|
| 1421 |
+
// Biome click behavior: select & start spawn there
|
| 1422 |
document.querySelectorAll('.biome-selector').forEach(el => {
|
| 1423 |
el.addEventListener('click', (ev)=>{
|
| 1424 |
+
// visual selection feedback
|
| 1425 |
document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
|
| 1426 |
el.classList.add('biome-selected');
|
| 1427 |
const biome = el.dataset.biome;
|
| 1428 |
selectedBiome = biome;
|
| 1429 |
+
// start game immediately with the selected biome spawn region
|
| 1430 |
startGame(biome);
|
| 1431 |
});
|
| 1432 |
});
|
| 1433 |
|
| 1434 |
+
// initialisation
|
| 1435 |
resizeCanvas();
|
| 1436 |
populateWorld();
|
| 1437 |
feather.replace();
|