bbc123321 commited on
Commit
492f5c0
·
verified ·
1 Parent(s): 68e0452

Manual changes saved

Browse files
Files changed (1) hide show
  1. index.html +481 -132
index.html CHANGED
@@ -3,13 +3,18 @@
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; }
@@ -91,6 +96,11 @@
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,6 +159,11 @@
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,7 +172,11 @@
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,7 +273,7 @@
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,17 +284,61 @@
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,7 +350,6 @@
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,7 +358,7 @@
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,
@@ -308,7 +370,10 @@
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();
@@ -372,7 +437,7 @@
372
  }
373
  function worldToScreen(wx,wy){ return { x: Math.round(wx - camera.x), y: Math.round(wy - camera.y) }; }
374
 
375
- // Combat utilities
376
  function shootBullet(originX, originY, targetX, targetY, weaponObj, shooterId){
377
  if (!weaponObj || (typeof weaponObj.ammoInMag === 'number' && weaponObj.ammoInMag <= 0)) return false;
378
  const speed = 1100;
@@ -492,37 +557,70 @@
492
  }
493
  }
494
 
495
- // Enemy helpers (equip / collect)
496
  function enemyEquipBestWeapon(e){
 
497
  let bestIdx = -1;
498
  let bestScore = -Infinity;
499
  for (let i=0;i<5;i++){
500
  const it = e.inventory[i];
501
  if (it && it.type === 'weapon'){
502
- const score = (it.weapon.dmg || 1) / (it.weapon.rate || 300);
 
 
 
 
 
503
  if (score > bestScore){ bestScore = score; bestIdx = i; }
504
  }
505
  }
506
- if (bestIdx !== -1) e.equippedIndex = bestIdx;
507
- else e.equippedIndex = -1;
 
 
 
 
 
 
 
 
 
 
 
508
  }
509
 
510
  function enemyPickupCollect(e, p){
 
511
  if (p.type === 'weapon'){
 
 
512
  for (let s=0;s<5;s++){
513
  const it = e.inventory[s];
514
- if (it && it.type==='weapon' && it.weapon.name === p.weapon.name){
515
- it.ammoReserve += p.ammoReserve;
516
- it.ammoInMag = Math.min(it.weapon.magSize, it.ammoInMag + p.ammoInMag);
517
  enemyEquipBestWeapon(e);
 
 
 
 
518
  return;
519
  }
520
  }
 
521
  for (let s=0;s<5;s++){
522
- if (!e.inventory[s]) { e.inventory[s] = { type:'weapon', weapon:p.weapon, ammoInMag:p.ammoInMag, ammoReserve:p.ammoReserve }; enemyEquipBestWeapon(e); return; }
 
 
 
 
 
 
 
 
523
  }
 
524
  let worstIdx = -1, worstScore = Infinity;
525
- const pickupScore = (p.weapon.dmg || 1) / (p.weapon.rate || 300);
526
  for (let s=0;s<5;s++){
527
  const it = e.inventory[s];
528
  if (it && it.type==='weapon'){
@@ -530,11 +628,14 @@
530
  if (score < worstScore){ worstScore = score; worstIdx = s; }
531
  }
532
  }
 
533
  if (pickupScore > worstScore && worstIdx !== -1){
534
- e.inventory[worstIdx] = { type:'weapon', weapon:p.weapon, ammoInMag:p.ammoInMag, ammoReserve:p.ammoReserve };
535
  enemyEquipBestWeapon(e);
 
 
536
  } else {
537
- e.materials += Math.floor((p.ammoReserve || 0) / 2);
538
  }
539
  } else if (p.type === 'medkit'){
540
  for (let s=0;s<5;s++){
@@ -605,6 +706,34 @@
605
  return closest;
606
  }
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  // Bullets update
609
  function bulletsUpdate(dt){
610
  for (let i=bullets.length-1;i>=0;i--){
@@ -676,41 +805,52 @@
676
  return best;
677
  }
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,53 +867,106 @@
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);
747
  if (idx >= 0) pickups.splice(idx,1);
 
 
748
  }
749
  continue;
750
  }
751
  }
752
 
753
- let chestTarget = findNearestChest(e, 900);
 
 
754
  if (chestTarget){
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;
763
- if (loot.type === 'weapon') enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 });
764
- else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
765
- else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  }
767
  continue;
768
  }
769
 
770
  let objTarget = findNearestHarvestable(e, 700);
771
- if (objTarget){
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,17 +983,15 @@
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];
@@ -809,21 +1000,45 @@
809
  e.reloadingUntil = 0;
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);
@@ -831,7 +1046,6 @@
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);
@@ -841,10 +1055,9 @@
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);
@@ -852,20 +1065,20 @@
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);
@@ -883,19 +1096,34 @@
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,7 +1134,14 @@
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,56 +1150,57 @@
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;
958
- if (loot.type === 'weapon') enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 });
959
- else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
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,20 +1210,25 @@
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,14 +1241,12 @@
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,7 +1256,6 @@
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
  }
@@ -1062,7 +1300,7 @@
1062
  }
1063
  }
1064
 
1065
- // Drawing
1066
  function drawWorld(){
1067
  const TILE = 600;
1068
  const cols = Math.ceil(WORLD.width / TILE);
@@ -1161,7 +1399,6 @@
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,7 +1428,6 @@
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();
@@ -1258,6 +1494,108 @@
1258
 
1259
  function drawCrosshair(){}
1260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1261
  // Main loop
1262
  let lastTime = 0;
1263
  function gameLoop(ts){
@@ -1266,13 +1604,25 @@
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,7 +1639,6 @@
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,27 +1657,26 @@
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,7 +1685,6 @@
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,7 +1699,6 @@
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,17 +1721,23 @@
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,20 +1770,17 @@
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();
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>BattleZone Royale - Minimap Added</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
+
14
+ /* Minimap (top-right) */
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
+ #minimapCanvas { width:100%; height:100%; display:block; image-rendering: pixelated; border-radius:6px; background:transparent; }
17
+
18
  /* HUD */
19
  #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; }
20
  #hudGearWrap { position:absolute; right:12px; bottom:12px; display:flex; gap:8px; align-items:center; z-index:30; }
 
96
  <div id="canvasContainer" class="relative bg-gray-900 border-4 border-gray-700 rounded-xl max-w-full max-h-full">
97
  <canvas id="gameCanvas"></canvas>
98
 
99
+ <!-- Minimap (top-right) -->
100
+ <div id="minimap">
101
+ <canvas id="minimapCanvas" width="220" height="140"></canvas>
102
+ </div>
103
+
104
  <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">
105
  <i data-feather="alert-circle" class="mr-2"></i>
106
  <span>STORM APPROACHING! MOVE TO SAFE ZONE!</span>
 
159
  const continueBtn = document.getElementById('continueBtn');
160
  const biomeGrid = document.getElementById('biomeGrid');
161
 
162
+ // Minimap elements and cache
163
+ const minimapCanvas = document.getElementById('minimapCanvas');
164
+ const miniCtx = minimapCanvas.getContext('2d');
165
+ let miniTerrainCache = null;
166
+
167
  // World
168
  const WORLD = { width: 6000, height: 4000 };
169
  let camera = { x:0, y:0 };
 
172
  const ctn = document.getElementById('canvasContainer');
173
  canvas.width = Math.max(900, Math.floor(ctn.clientWidth));
174
  canvas.height = Math.max(560, Math.floor(ctn.clientHeight));
175
+ // keep minimap internal pixel buffer consistent
176
+ minimapCanvas.width = 220;
177
+ minimapCanvas.height = 140;
178
  cameraUpdate();
179
+ miniTerrainCache = null; // rebuild on resize
180
  }
181
  window.addEventListener('resize', resizeCanvas);
182
 
 
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
  }
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
+ // Populate world
335
  function populateWorld(){
336
  chests.length = 0; objects.length = 0; enemies.length = 0; pickups.length = 0;
337
  for (let i=0;i<260;i++){
338
  const x = rand(150, WORLD.width-150);
339
  const y = rand(150, WORLD.height-150);
340
  const loot = generateLootForBiome(biomeAt(x,y));
341
+ if (loot.type === 'materials') loot.amount = 10;
342
  chests.push({ x,y, opened:false, loot });
343
  }
344
  for (let i=0;i<700;i++){
 
350
  const hp = type==='wood'?40 : (type==='stone'?80:160);
351
  objects.push({ x,y, type, hp, maxHp:hp, dead:false });
352
  }
 
353
  const now = performance.now();
354
  for (let i=0;i<49;i++){
355
  const ex = rand(300, WORLD.width-300);
 
358
  id:'e'+i, x:ex, y:ey, radius:14, angle:rand(0,Math.PI*2), speed:110+rand(-20,20),
359
  health: 80 + randInt(0,40), lastMelee:0, meleeRate:800 + randInt(-200,200),
360
  roamTimer: rand(0,3),
361
+ inventory: [null,null,null,null,null],
362
  selectedSlot: 0,
363
  equippedIndex: -1,
364
  materials: 0,
 
370
  gatherTimeLeft: rand(8,16),
371
  target: null,
372
  nextHealTime: 0,
373
+ spawnSafeUntil: now + SPAWN_PROTECT_MS,
374
+ tempTarget: null,
375
+ tempTargetExpiry: 0,
376
+ prioritizeChestsUntil: 0
377
  });
378
  }
379
  updatePlayerCount();
 
437
  }
438
  function worldToScreen(wx,wy){ return { x: Math.round(wx - camera.x), y: Math.round(wy - camera.y) }; }
439
 
440
+ // Combat utilities (kept same as before)
441
  function shootBullet(originX, originY, targetX, targetY, weaponObj, shooterId){
442
  if (!weaponObj || (typeof weaponObj.ammoInMag === 'number' && weaponObj.ammoInMag <= 0)) return false;
443
  const speed = 1100;
 
557
  }
558
  }
559
 
560
+ // Enemy helpers
561
  function enemyEquipBestWeapon(e){
562
+ // prefer weapons that have ammo loaded or reserve; compute score and prefer loaded ones
563
  let bestIdx = -1;
564
  let bestScore = -Infinity;
565
  for (let i=0;i<5;i++){
566
  const it = e.inventory[i];
567
  if (it && it.type === 'weapon'){
568
+ const dmg = (it.weapon && it.weapon.dmg) ? it.weapon.dmg : 1;
569
+ const rate = (it.weapon && it.weapon.rate) ? it.weapon.rate : 300;
570
+ let score = dmg / rate;
571
+ // bonus if currently has ammo in mag
572
+ if (typeof it.ammoInMag === 'number' && it.ammoInMag > 0) score += 0.5;
573
+ if (typeof it.ammoReserve === 'number' && it.ammoReserve > 0) score += 0.2;
574
  if (score > bestScore){ bestScore = score; bestIdx = i; }
575
  }
576
  }
577
+ if (bestIdx !== -1) {
578
+ e.equippedIndex = bestIdx;
579
+ } else {
580
+ e.equippedIndex = -1;
581
+ }
582
+ }
583
+
584
+ function normalizeWeaponPickup(p){
585
+ // ensure fields exist and are numbers
586
+ const w = p.weapon ? p.weapon : makeWeaponProto(p);
587
+ const ammoInMag = (typeof p.ammoInMag === 'number') ? p.ammoInMag : (w.magSize || w.magSize || 12);
588
+ const ammoReserve = (typeof p.ammoReserve === 'number') ? p.ammoReserve : (w.startReserve || (w.magSize*2) || 24);
589
+ return { type:'weapon', weapon: w, ammoInMag: Math.max(0, Math.floor(ammoInMag)), ammoReserve: Math.max(0, Math.floor(ammoReserve)) };
590
  }
591
 
592
  function enemyPickupCollect(e, p){
593
+ if (!e || !p) return;
594
  if (p.type === 'weapon'){
595
+ const normalized = normalizeWeaponPickup(p);
596
+ // merge into existing same weapon
597
  for (let s=0;s<5;s++){
598
  const it = e.inventory[s];
599
+ if (it && it.type==='weapon' && it.weapon.name === normalized.weapon.name){
600
+ it.ammoReserve = (it.ammoReserve || 0) + (normalized.ammoReserve || 0);
601
+ it.ammoInMag = Math.min(it.weapon.magSize, (it.ammoInMag || 0) + (normalized.ammoInMag || 0));
602
  enemyEquipBestWeapon(e);
603
+ // ensure they equip a loaded weapon if present
604
+ if (it.ammoInMag > 0) e.equippedIndex = s;
605
+ e.state = 'combat';
606
+ e.lastAttackedTime = performance.now();
607
  return;
608
  }
609
  }
610
+ // place into empty slot if available
611
  for (let s=0;s<5;s++){
612
+ if (!e.inventory[s]){
613
+ e.inventory[s] = { type:'weapon', weapon: normalized.weapon, ammoInMag: normalized.ammoInMag, ammoReserve: normalized.ammoReserve };
614
+ enemyEquipBestWeapon(e);
615
+ // equip this weapon if it has ammo or if no other better weapon
616
+ if (e.inventory[s].ammoInMag > 0) e.equippedIndex = s;
617
+ e.state = 'combat';
618
+ e.lastAttackedTime = performance.now();
619
+ return;
620
+ }
621
  }
622
+ // replace worst weapon if pickup is better
623
  let worstIdx = -1, worstScore = Infinity;
 
624
  for (let s=0;s<5;s++){
625
  const it = e.inventory[s];
626
  if (it && it.type==='weapon'){
 
628
  if (score < worstScore){ worstScore = score; worstIdx = s; }
629
  }
630
  }
631
+ const pickupScore = (normalized.weapon.dmg || 1) / (normalized.weapon.rate || 300);
632
  if (pickupScore > worstScore && worstIdx !== -1){
633
+ e.inventory[worstIdx] = { type:'weapon', weapon: normalized.weapon, ammoInMag: normalized.ammoInMag, ammoReserve: normalized.ammoReserve };
634
  enemyEquipBestWeapon(e);
635
+ e.state = 'combat';
636
+ e.lastAttackedTime = performance.now();
637
  } else {
638
+ e.materials += Math.floor((normalized.ammoReserve || 0) / 2);
639
  }
640
  } else if (p.type === 'medkit'){
641
  for (let s=0;s<5;s++){
 
706
  return closest;
707
  }
708
 
709
+ // New helper: compute detour waypoint around blocker
710
+ function computeDetourWaypoint(fromX, fromY, blocker, goalX, goalY, padding = 12){
711
+ if (!blocker) return null;
712
+ let br = 0;
713
+ if (blocker.type === 'wall') br = 28;
714
+ else if (blocker.type === 'stone') br = 22;
715
+ else if (blocker.type === 'wood') br = 16;
716
+ else br = 18;
717
+ // vector from blocker to from
718
+ const vx = fromX - blocker.x;
719
+ const vy = fromY - blocker.y;
720
+ const len = Math.hypot(vx, vy) || 0.0001;
721
+ // perp directions
722
+ const px = -vy / len;
723
+ const py = vx / len;
724
+ const radius = br + padding + 8;
725
+ const cand1 = { x: blocker.x + px * radius, y: blocker.y + py * radius };
726
+ const cand2 = { x: blocker.x - px * radius, y: blocker.y - py * radius };
727
+ // choose candidate that's closer to goal and not inside other solids (approx)
728
+ const d1 = Math.hypot(cand1.x - goalX, cand1.y - goalY);
729
+ const d2 = Math.hypot(cand2.x - goalX, cand2.y - goalY);
730
+ const chosen = d1 < d2 ? cand1 : cand2;
731
+ // clamp to world
732
+ chosen.x = Math.max(16, Math.min(WORLD.width-16, chosen.x));
733
+ chosen.y = Math.max(16, Math.min(WORLD.height-16, chosen.y));
734
+ return chosen;
735
+ }
736
+
737
  // Bullets update
738
  function bulletsUpdate(dt){
739
  for (let i=bullets.length-1;i>=0;i--){
 
805
  return best;
806
  }
807
 
808
+ // Enemy AI (improved: detour around obstacles and prioritize chest looting early, ensure equipping)
809
  function updateEnemies(dt, now){
810
+ const minSeparation = 20;
 
 
811
  for (const e of enemies){
812
  if (e.health <= 0) continue;
 
 
813
  if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
814
 
815
+ // clear expired tempTarget
816
+ if (e.tempTarget && now > (e.tempTargetExpiry || 0)) { e.tempTarget = null; e.tempTargetExpiry = 0; }
817
+
818
+ // If in storm move towards safe zone, using detours around blockers
819
  if (storm.active){
820
  const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
821
  if (distToSafeCenter > storm.radius){
 
822
  e.state = 'toSafe';
823
+ if (e.tempTarget){
824
+ const td = Math.hypot(e.tempTarget.x - e.x, e.tempTarget.y - e.y);
825
+ if (td > 8){
826
+ e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x);
827
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
828
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
829
+ moveEntityWithCollision(e, dx, dy, e.radius);
830
+ continue;
831
+ } else {
832
+ e.tempTarget = null;
833
+ e.tempTargetExpiry = 0;
834
+ }
835
+ }
836
+ if (!hasLineOfSight(e.x, e.y, storm.centerX, storm.centerY)){
837
+ const blocker = findBlockingObject(e.x, e.y, storm.centerX, storm.centerY);
838
+ if (blocker){
839
+ e.tempTarget = computeDetourWaypoint(e.x, e.y, blocker, storm.centerX, storm.centerY);
840
+ e.tempTargetExpiry = now + 2500;
841
+ if (e.tempTarget) { e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x); const dx = Math.cos(e.angle) * e.speed * dt * 0.95; const dy = Math.sin(e.angle) * e.speed * dt * 0.95; moveEntityWithCollision(e, dx, dy, e.radius); continue; }
842
+ }
843
+ }
844
  e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
845
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.95;
846
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.95;
847
+ moveEntityWithCollision(e, dx, dy, e.radius);
848
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
849
+ continue;
 
 
 
850
  }
851
  }
852
 
853
+ // Healing logic
 
 
 
 
 
854
  if (e.health < 60 && now >= (e.nextHealTime || 0)){
855
  let medIdx = -1;
856
  for (let s=0;s<5;s++){
 
867
  }
868
  }
869
 
870
+ // State transitions
871
  const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
872
  if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
873
 
874
+ if (e.state === 'gather') e.gatherTimeLeft -= dt;
875
+
876
+ // Early-game chest priority: force chest-seeking for first few seconds to equip weapons quickly
877
+ const prioritizeChests = (e.prioritizeChestsUntil && now < e.prioritizeChestsUntil);
878
  if (e.state === 'gather'){
 
 
879
  if (now >= (e.spawnSafeUntil || 0)){
880
+ // pickups nearby priority (quick ammo/weapon)
881
  let p = findNearestPickup(e, 240);
882
+ if (p && !prioritizeChests){
883
  const angle = Math.atan2(p.y - e.y, p.x - e.x);
884
  e.angle = angle;
885
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
886
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
887
+ moveEntityWithCollision(e, dx, dy, e.radius);
888
  if (Math.hypot(p.x - e.x, p.y - e.y) < 18){
889
  enemyPickupCollect(e, p);
890
  const idx = pickups.indexOf(p);
891
  if (idx >= 0) pickups.splice(idx,1);
892
+ // equip best weapon immediately if any
893
+ enemyEquipBestWeapon(e);
894
  }
895
  continue;
896
  }
897
  }
898
 
899
+ // If we don't have a useful weapon yet, prioritize chests strongly (extended radius)
900
+ let chestSearchRadius = prioritizeChests || !hasUsefulWeapon ? 1400 : 900;
901
+ let chestTarget = findNearestChest(e, chestSearchRadius);
902
  if (chestTarget){
903
  const d = Math.hypot(chestTarget.x - e.x, chestTarget.y - e.y);
904
  if (d > 20){
905
  e.angle = Math.atan2(chestTarget.y - e.y, chestTarget.x - e.x);
906
+ // navigate with detour logic if blocked
907
+ if (!hasLineOfSight(e.x, e.y, chestTarget.x, chestTarget.y)){
908
+ const blocker = findBlockingObject(e.x, e.y, chestTarget.x, chestTarget.y);
909
+ if (blocker){
910
+ if (!e.tempTarget){
911
+ e.tempTarget = computeDetourWaypoint(e.x, e.y, blocker, chestTarget.x, chestTarget.y);
912
+ e.tempTargetExpiry = now + 2500;
913
+ }
914
+ if (e.tempTarget){
915
+ const td = Math.hypot(e.tempTarget.x - e.x, e.tempTarget.y - e.y);
916
+ if (td > 8){
917
+ e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x);
918
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
919
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
920
+ moveEntityWithCollision(e, dx, dy, e.radius);
921
+ continue;
922
+ } else {
923
+ e.tempTarget = null;
924
+ e.tempTargetExpiry = 0;
925
+ }
926
+ }
927
+ }
928
+ }
929
+ // If unobstructed or detour not set/needed, move directly
930
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.95;
931
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.95;
932
+ moveEntityWithCollision(e, dx, dy, e.radius);
933
  } else {
934
+ // open chest and collect
935
+ if (!chestTarget.opened){
936
+ chestTarget.opened = true;
937
+ const loot = chestTarget.loot;
938
+ if (loot.type === 'weapon') {
939
+ const wp = { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 };
940
+ enemyPickupCollect(e, wp);
941
+ } else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
942
+ else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
943
+ // ensure equip
944
+ enemyEquipBestWeapon(e);
945
+ if (e.equippedIndex !== -1){
946
+ const eq = e.inventory[e.equippedIndex];
947
+ if (eq && eq.type==='weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0){
948
+ // immediate reload
949
+ reloadItem(eq);
950
+ }
951
+ }
952
+ // after opening chest, prioritize combat if weapon obtained
953
+ if (e.inventory.some(it => it && it.type==='weapon')) {
954
+ e.state = 'combat';
955
+ e.lastAttackedTime = now;
956
+ }
957
+ }
958
  }
959
  continue;
960
  }
961
 
962
  let objTarget = findNearestHarvestable(e, 700);
963
+ if (objTarget && !prioritizeChests){
964
  const d = Math.hypot(objTarget.x - e.x, objTarget.y - e.y);
965
  if (d > 26){
966
  e.angle = Math.atan2(objTarget.y - e.y, objTarget.x - e.x);
967
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.8;
968
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.8;
969
+ moveEntityWithCollision(e, dx, dy, e.radius);
970
  } else {
971
  objTarget.hp -= 40 * dt;
972
  if (objTarget.hp <= 0 && !objTarget.dead){
 
983
  continue;
984
  }
985
 
986
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.25;
987
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.25;
988
+ moveEntityWithCollision(e, dx, dy, e.radius);
989
  if (Math.random() < 0.01) e.angle += (Math.random()-0.5)*2;
990
  continue;
991
  }
992
 
993
  // Combat state
994
  if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
 
 
995
  if (e.reloadPending){
996
  if (now >= e.reloadingUntil){
997
  const eq = e.inventory[e.equippedIndex];
 
1000
  e.reloadingUntil = 0;
1001
  }
1002
  }
 
 
1003
  if (e.equippedIndex >= 0){
1004
  const eq = e.inventory[e.equippedIndex];
1005
  if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
1006
  e.reloadPending = true;
1007
+ e.reloadingUntil = now + 600 + rand(-100,100);
1008
+ }
1009
+ }
1010
+
1011
+ // If equipped weapon is empty and no reserve, try to swap to another loaded weapon
1012
+ if (e.equippedIndex >= 0){
1013
+ const eq = e.inventory[e.equippedIndex];
1014
+ if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve <= 0){
1015
+ // try to find another weapon with ammo
1016
+ for (let i=0;i<5;i++){
1017
+ const it = e.inventory[i];
1018
+ if (it && it.type === 'weapon' && it.ammoInMag > 0){
1019
+ e.equippedIndex = i;
1020
+ break;
1021
+ }
1022
+ }
1023
+ // if still no mag ammo, pick weapon with reserve
1024
+ if (e.equippedIndex === -1 || (e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].ammoInMag <= 0)){
1025
+ for (let i=0;i<5;i++){
1026
+ const it = e.inventory[i];
1027
+ if (it && it.type === 'weapon' && it.ammoReserve > 0){
1028
+ e.equippedIndex = i;
1029
+ // schedule reload
1030
+ e.reloadPending = true;
1031
+ e.reloadingUntil = now + 500 + rand(-80,80);
1032
+ break;
1033
+ }
1034
+ }
1035
+ }
1036
  }
1037
  }
1038
 
 
1039
  let target = player;
1040
  let bestDist = Math.hypot(player.x - e.x, player.y - e.y);
1041
  if (bestDist > VIEW_RANGE){
 
1042
  const p = findNearestPickup(e, 1200);
1043
  const c = findNearestChest(e, 1200);
1044
  const h = findNearestHarvestable(e, 1200);
 
1046
  if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
1047
  if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
1048
  if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
 
1049
  for (const other of enemies){
1050
  if (other === e || other.health <= 0) continue;
1051
  const d = Math.hypot(other.x - e.x, other.y - e.y);
 
1055
  target = candidate;
1056
  bestDist = cd;
1057
  } else {
1058
+ target = null; bestDist = Infinity;
1059
  }
1060
  } else {
 
1061
  for (const other of enemies){
1062
  if (other === e || other.health <= 0) continue;
1063
  const d = Math.hypot(other.x - e.x, other.y - e.y);
 
1065
  }
1066
  }
1067
 
 
1068
  const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
1069
  if (distToPlayer < 160 && e.health < 35 && e.materials >= 10 && Math.random() < 0.5){
1070
  enemyTryBuild(e);
1071
  }
1072
 
 
1073
  if (!target){
1074
  e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
1075
  e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
1076
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
1077
+ moveEntityWithCollision(e, 0, 0, e.radius);
1078
  continue;
1079
  }
1080
 
1081
+ // If a target exists but blocked, try detour waypoint instead of always breaking/shooting blocker
1082
  const blocked = !hasLineOfSight(e.x, e.y, target.x, target.y);
1083
  if (blocked){
1084
  const blocker = findBlockingObject(e.x, e.y, target.x, target.y);
 
1096
  const angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
1097
  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);
1098
  }
1099
+ }
1100
+ if (!e.tempTarget){
1101
+ const waypoint = computeDetourWaypoint(e.x, e.y, blocker, target.x, target.y);
1102
+ if (waypoint){
1103
+ e.tempTarget = waypoint;
1104
+ e.tempTargetExpiry = now + 3000;
1105
+ }
1106
+ }
1107
+ if (e.tempTarget){
1108
+ const td = Math.hypot(e.tempTarget.x - e.x, e.tempTarget.y - e.y);
1109
+ if (td > 8){
1110
+ e.angle = Math.atan2(e.tempTarget.y - e.y, e.tempTarget.x - e.x);
1111
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.8, Math.sin(e.angle) * e.speed * dt * 0.8, e.radius);
1112
+ } else {
1113
+ e.tempTarget = null;
1114
+ e.tempTargetExpiry = 0;
1115
+ }
1116
  } else {
1117
  e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
1118
+ e.x += Math.cos(e.angle) * e.speed * dt * 0.6;
1119
+ e.y += Math.sin(e.angle) * e.speed * dt * 0.6;
1120
  }
1121
  }
1122
  continue;
1123
  }
1124
  }
1125
 
 
1126
  if (target === player){
 
1127
  if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
1128
  e.lastMelee = now;
1129
  const dmg = 10 + randInt(0,8);
 
1134
  const eq = e.inventory[e.equippedIndex];
1135
  if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
1136
  if (eq.ammoInMag <= 0){
1137
+ // try reload or swap
1138
+ if (eq.ammoReserve > 0 && !e.reloadPending){
1139
+ e.reloadPending = true;
1140
+ e.reloadingUntil = now + 600 + rand(-100,100);
1141
+ } else {
1142
+ // swap to other weapon
1143
+ enemyEquipBestWeapon(e);
1144
+ }
1145
  } else {
1146
  if (hasLineOfSight(e.x, e.y, target.x, target.y)){
1147
  e.lastShot = now;
 
1150
  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);
1151
  e.lastAttackedTime = now;
1152
  } else {
 
1153
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1154
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.6, Math.sin(e.angle) * e.speed * dt * 0.6, e.radius);
 
1155
  }
1156
  }
1157
  } else {
 
1158
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1159
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
1160
  }
1161
  } else {
 
1162
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1163
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
1164
  }
1165
  } else {
 
1166
  const td = Math.hypot(target.x - e.x, target.y - e.y);
1167
+ if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){
1168
  if (td > 20){
1169
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1170
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
1171
  } else {
1172
  enemyPickupCollect(e, target);
1173
  const idx = pickups.indexOf(target);
1174
  if (idx >= 0) pickups.splice(idx,1);
1175
  e.state = 'gather';
1176
+ enemyEquipBestWeapon(e);
1177
  }
1178
+ } else if (target.hasOwnProperty('loot')){
1179
  if (td > 20){
1180
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1181
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
1182
  } else {
1183
+ if (!target.opened){
1184
+ target.opened = true;
1185
+ const loot = target.loot;
1186
+ if (loot.type === 'weapon') enemyPickupCollect(e, { type:'weapon', weapon: makeWeaponProto(loot.weapon), ammoInMag:loot.weapon.magSize||12, ammoReserve:loot.weapon.startReserve||24 });
1187
+ else if (loot.type === 'medkit') enemyPickupCollect(e, { type:'medkit', amount: loot.amount || 1 });
1188
+ else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
1189
+ e.state = 'gather';
1190
+ enemyEquipBestWeapon(e);
1191
+ if (e.equippedIndex !== -1){
1192
+ const eq = e.inventory[e.equippedIndex];
1193
+ if (eq && eq.type==='weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0){
1194
+ reloadItem(eq);
1195
+ }
1196
+ }
1197
+ if (e.inventory.some(it => it && it.type === 'weapon')){ e.state = 'combat'; e.lastAttackedTime = now; }
1198
+ }
1199
  }
1200
+ } else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){
1201
  if (td > 26){
1202
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1203
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.8, Math.sin(e.angle) * e.speed * dt * 0.8, e.radius);
 
1204
  } else {
1205
  target.hp -= 40 * dt;
1206
  if (target.hp <= 0 && !target.dead){
 
1210
  e.state = 'gather';
1211
  }
1212
  } else {
 
1213
  if (td > 40){
1214
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1215
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.7, Math.sin(e.angle) * e.speed * dt * 0.7, e.radius);
 
1216
  } else {
1217
+ if (now - e.lastMelee > e.meleeRate){
1218
+ e.lastMelee = now;
1219
+ if (target && target.health > 0){
1220
+ target.health -= 8 + randInt(0,6);
1221
+ target.lastAttackedTime = now;
1222
+ target.lastAttackerId = e.id;
1223
+ if (target.health <= 0) target.health = 0;
1224
+ }
1225
+ }
1226
  }
1227
  }
1228
  }
1229
  }
1230
 
1231
+ // Separation
1232
  for (let i = 0; i < enemies.length; i++){
1233
  const a = enemies[i];
1234
  if (!a || a.health <= 0) continue;
 
1241
  if (d < minD){
1242
  const overlap = (minD - d) * 0.5;
1243
  const nx = dx / d, ny = dy / d;
 
1244
  b.x += nx * overlap;
1245
  b.y += ny * overlap;
1246
  a.x -= nx * overlap;
1247
  a.y -= ny * overlap;
1248
  }
1249
  }
 
1250
  const pdx = a.x - player.x, pdy = a.y - player.y;
1251
  const pd = Math.hypot(pdx,pdy) || 0.0001;
1252
  const avoidDist = 24;
 
1256
  a.x += nx * overlap;
1257
  a.y += ny * overlap;
1258
  }
 
1259
  a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
1260
  a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
1261
  }
 
1300
  }
1301
  }
1302
 
1303
+ // Drawing world & entities
1304
  function drawWorld(){
1305
  const TILE = 600;
1306
  const cols = Math.ceil(WORLD.width / TILE);
 
1399
  const hpPct = Math.max(0, Math.min(1, e.health/120));
1400
  ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
1401
 
 
1402
  if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
1403
  const we = e.inventory[e.equippedIndex];
1404
  const color = we.weapon.color || '#ddd';
 
1428
  }
1429
  }
1430
 
 
1431
  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)');
1432
  ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
1433
  ctx.restore();
 
1494
 
1495
  function drawCrosshair(){}
1496
 
1497
+ // Minimap: build terrain cache and draw overlay (EXCLUDES enemies & chests)
1498
+ function buildMiniTerrainCache(){
1499
+ const mw = minimapCanvas.width;
1500
+ const mh = minimapCanvas.height;
1501
+ const scaleX = WORLD.width / mw;
1502
+ const scaleY = WORLD.height / mh;
1503
+ const img = miniCtx.createImageData(mw, mh);
1504
+ for (let my=0; my<mh; my++){
1505
+ for (let mx=0; mx<mw; mx++){
1506
+ const wx = Math.floor(mx * scaleX + scaleX/2);
1507
+ const wy = Math.floor(my * scaleY + scaleY/2);
1508
+ const b = biomeAt(wx, wy);
1509
+ let col = [32,58,43];
1510
+ if (b==='desert') col = [203,183,139];
1511
+ else if (b==='forest') col = [22,65,31];
1512
+ else if (b==='oasis') col = [39,75,82];
1513
+ else if (b==='ruins') col = [74,59,59];
1514
+ const idx = (my*mw + mx)*4;
1515
+ img.data[idx] = col[0];
1516
+ img.data[idx+1] = col[1];
1517
+ img.data[idx+2] = col[2];
1518
+ img.data[idx+3] = 255;
1519
+ }
1520
+ }
1521
+ miniTerrainCache = img;
1522
+ }
1523
+
1524
+ function drawMinimap(){
1525
+ const mw = minimapCanvas.width;
1526
+ const mh = minimapCanvas.height;
1527
+ if (!miniTerrainCache) buildMiniTerrainCache();
1528
+ // draw base terrain
1529
+ miniCtx.putImageData(miniTerrainCache, 0, 0);
1530
+
1531
+ // draw world objects (trees/stones/walls) as tiny marks - chests excluded per request
1532
+ miniCtx.save();
1533
+ const scaleX = mw / WORLD.width;
1534
+ const scaleY = mh / WORLD.height;
1535
+ for (const obj of objects){
1536
+ if (obj.dead) continue;
1537
+ const px = Math.round(obj.x * scaleX);
1538
+ const py = Math.round(obj.y * scaleY);
1539
+ if (obj.type === 'wood'){
1540
+ miniCtx.fillStyle = '#3f210f';
1541
+ miniCtx.fillRect(px-1, py-1, 2, 2);
1542
+ } else if (obj.type === 'stone'){
1543
+ miniCtx.fillStyle = '#666';
1544
+ miniCtx.fillRect(px-1, py-1, 2, 2);
1545
+ } else if (obj.type === 'wall'){
1546
+ miniCtx.fillStyle = '#8b5a32';
1547
+ miniCtx.fillRect(px-2, py-2, 4, 4);
1548
+ }
1549
+ }
1550
+ // optionally show pickups (not enemies/chests) - small blue/green dots
1551
+ for (const p of pickups){
1552
+ const px = Math.round(p.x * scaleX);
1553
+ const py = Math.round(p.y * scaleY);
1554
+ if (p.type === 'weapon'){
1555
+ miniCtx.fillStyle = '#ffd86b'; miniCtx.fillRect(px-1, py-1, 2, 2);
1556
+ } else if (p.type === 'medkit'){
1557
+ miniCtx.fillStyle = '#ff6b6b'; miniCtx.fillRect(px-1, py-1, 2, 2);
1558
+ } else if (p.type === 'materials'){
1559
+ miniCtx.fillStyle = '#cfe0a6'; miniCtx.fillRect(px-1, py-1, 2, 2);
1560
+ } else if (p.type === 'ammo'){
1561
+ miniCtx.fillStyle = '#e6e6e6'; miniCtx.fillRect(px-1, py-1, 2, 2);
1562
+ }
1563
+ }
1564
+
1565
+ // Storm safe zone: draw overlay darkening outside safe zone and stroke the safe circle
1566
+ if (storm.active){
1567
+ // darken everything
1568
+ miniCtx.fillStyle = 'rgba(0,0,0,0.35)';
1569
+ miniCtx.fillRect(0,0,mw,mh);
1570
+ // carve out safe zone
1571
+ miniCtx.globalCompositeOperation = 'destination-out';
1572
+ const cx = storm.centerX * scaleX;
1573
+ const cy = storm.centerY * scaleY;
1574
+ // radius scaled by average axis to keep circle shape close
1575
+ const r = storm.radius * ((scaleX + scaleY) / 2);
1576
+ miniCtx.beginPath();
1577
+ miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1578
+ miniCtx.fill();
1579
+ miniCtx.globalCompositeOperation = 'source-over';
1580
+ // border
1581
+ miniCtx.strokeStyle = 'rgba(255,200,80,0.95)';
1582
+ miniCtx.lineWidth = 2;
1583
+ miniCtx.beginPath();
1584
+ miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1585
+ miniCtx.stroke();
1586
+ }
1587
+
1588
+ // draw player dot (always visible)
1589
+ const ppx = Math.round(player.x * (mw / WORLD.width));
1590
+ const ppy = Math.round(player.y * (mh / WORLD.height));
1591
+ miniCtx.fillStyle = '#ffff66';
1592
+ miniCtx.beginPath();
1593
+ miniCtx.arc(ppx, ppy, 3, 0, Math.PI*2);
1594
+ miniCtx.fill();
1595
+
1596
+ miniCtx.restore();
1597
+ }
1598
+
1599
  // Main loop
1600
  let lastTime = 0;
1601
  function gameLoop(ts){
 
1604
  const dt = Math.min(0.05, (ts - lastTime)/1000);
1605
  lastTime = ts;
1606
 
 
1607
  let dx=0, dy=0;
1608
  if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
1609
  if (dx !== 0 || dy !== 0){
1610
  const len = Math.hypot(dx,dy) || 1;
1611
+ const mvx = (dx/len) * player.speed * dt;
1612
+ const mvy = (dy/len) * player.speed * dt;
1613
+ const oldX = player.x, oldY = player.y;
1614
+ player.x += mvx;
1615
+ if (player.x < player.radius) player.x = player.radius;
1616
+ if (player.x > WORLD.width - player.radius) player.x = WORLD.width - player.radius;
1617
+ if (isCollidingSolid(player.x, player.y, player.radius)){
1618
+ player.x = oldX;
1619
+ }
1620
+ player.y += mvy;
1621
+ if (player.y < player.radius) player.y = player.radius;
1622
+ if (player.y > WORLD.height - player.radius) player.y = WORLD.height - player.radius;
1623
+ if (isCollidingSolid(player.x, player.y, player.radius)){
1624
+ player.y = oldY;
1625
+ }
1626
  }
1627
  player.x = Math.max(16, Math.min(WORLD.width-16, player.x));
1628
  player.y = Math.max(16, Math.min(WORLD.height-16, player.y));
 
1639
  if (selected && selected.type === 'weapon') activeWeaponItem = selected;
1640
  }
1641
 
 
1642
  if (mouse.down){
1643
  if (player.equippedIndex === -1) playerMeleeHit();
1644
  else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
 
1657
  if (keys.e){ interactNearby(); keys.e = false; }
1658
  if (keys.q){ tryBuild(); keys.q = false; }
1659
 
 
1660
  updateEnemies(dt, performance.now());
1661
  bulletsUpdate(dt);
1662
 
 
1663
  for (let i=pickups.length-1;i>=0;i--){
1664
  const p = pickups[i];
1665
  if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
1666
  }
1667
 
 
1668
  for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
1669
 
1670
  updatePlayerCount();
1671
  updateStorm(dt);
1672
 
 
1673
  ctx.clearRect(0,0,canvas.width,canvas.height);
1674
  drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer(); drawCrosshair();
1675
  updateHUD();
1676
 
1677
+ // Draw the minimap last so it reflects the latest world state
1678
+ drawMinimap();
1679
+
1680
  if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
1681
 
1682
  requestAnimationFrame(gameLoop);
 
1685
  // Landing -> spawn selection
1686
  let selectedBiome = null;
1687
  function getSpawnForBiome(b){
 
1688
  if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
1689
  if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
1690
  if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
 
1699
  let gameActive = false;
1700
 
1701
  function startGame(biome){
 
1702
  selectedBiome = biome || selectedBiome;
1703
  const spawn = getSpawnForBiome(selectedBiome);
1704
  document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
 
1721
  initHUD();
1722
  cameraUpdate();
1723
 
1724
+ // Set each enemy to prioritize chests for a short time so they grab weapons early, and reposition away from player spawn
1725
  for (const e of enemies){
1726
  if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
1727
  e.x += (Math.random()<0.5? -1:1) * rand(160,260);
1728
  e.y += (Math.random()<0.5? -1:1) * rand(160,260);
1729
  }
 
1730
  e.inventory = [null,null,null,null,null];
1731
  e.equippedIndex = -1;
1732
  e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
1733
  e.state = 'gather';
1734
+ e.nextHealTime = 0;
1735
+ e.tempTarget = null;
1736
+ e.tempTargetExpiry = 0;
1737
+ // this flag makes them aggressively seek chests in the first few seconds
1738
+ e.prioritizeChestsUntil = performance.now() + 6000 + rand(0,3000);
1739
+ // also slightly reduce gatherTimeLeft so they don't linger on harvesting
1740
+ e.gatherTimeLeft = Math.min(e.gatherTimeLeft, 6);
1741
  }
1742
 
1743
  gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
 
1770
  goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
1771
  continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
1772
 
 
1773
  document.querySelectorAll('.biome-selector').forEach(el => {
1774
  el.addEventListener('click', (ev)=>{
 
1775
  document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
1776
  el.classList.add('biome-selected');
1777
  const biome = el.dataset.biome;
1778
  selectedBiome = biome;
 
1779
  startGame(biome);
1780
  });
1781
  });
1782
 
1783
+ // initialization
1784
  resizeCanvas();
1785
  populateWorld();
1786
  feather.replace();