bbc123321 commited on
Commit
8c0a208
·
verified ·
1 Parent(s): 610dc13

Manual changes saved

Browse files
Files changed (1) hide show
  1. index.html +166 -199
index.html CHANGED
@@ -3,16 +3,18 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>BattleZone Royale - Collisions, AI & Minimap</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
- /* Minimap moved to top-right */
14
- #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); }
 
15
  #minimapCanvas { width:100%; height:100%; display:block; image-rendering: pixelated; border-radius:6px; background:transparent; }
 
16
  /* HUD */
17
  #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; }
18
  #hudGearWrap { position:absolute; right:12px; bottom:12px; display:flex; gap:8px; align-items:center; z-index:30; }
@@ -94,7 +96,7 @@
94
  <div id="canvasContainer" class="relative bg-gray-900 border-4 border-gray-700 rounded-xl max-w-full max-h-full">
95
  <canvas id="gameCanvas"></canvas>
96
 
97
- <!-- Minimap (moved to top-right) -->
98
  <div id="minimap">
99
  <canvas id="minimapCanvas" width="220" height="140"></canvas>
100
  </div>
@@ -157,8 +159,10 @@
157
  const continueBtn = document.getElementById('continueBtn');
158
  const biomeGrid = document.getElementById('biomeGrid');
159
 
 
160
  const minimapCanvas = document.getElementById('minimapCanvas');
161
  const miniCtx = minimapCanvas.getContext('2d');
 
162
 
163
  // World
164
  const WORLD = { width: 6000, height: 4000 };
@@ -168,7 +172,11 @@
168
  const ctn = document.getElementById('canvasContainer');
169
  canvas.width = Math.max(900, Math.floor(ctn.clientWidth));
170
  canvas.height = Math.max(560, Math.floor(ctn.clientHeight));
 
 
 
171
  cameraUpdate();
 
172
  }
173
  window.addEventListener('resize', resizeCanvas);
174
 
@@ -265,7 +273,7 @@
265
  function generateLootForBiome(b){
266
  const roll = Math.random();
267
  if (roll < 0.35) return { type:'medkit', amount:1 };
268
- if (roll < 0.7) return { type:'materials', amount: 10 }; // always 10
269
  const weapons = [
270
  { name:'Pistol', dmg:12, rate:320, color:'#ffd86b', magSize:12, startReserve:24 },
271
  { name:'SMG', dmg:6, rate:120, color:'#8ef0ff', magSize:30, startReserve:90 },
@@ -276,8 +284,8 @@
276
  }
277
 
278
  // Behaviour tuning
279
- const VIEW_RANGE = 1200; // if player is farther than this, enemies switch target
280
- const SPAWN_PROTECT_MS = 1200; // time after spawn they ignore ground pickups to avoid instant pickup
281
 
282
  // collision helpers
283
  function getObjectRadius(obj){
@@ -293,13 +301,11 @@
293
  }
294
 
295
  function isCollidingSolid(x,y,r){
296
- // objects are solid if not dead
297
  for (const o of objects){
298
  if (o.dead) continue;
299
  const rr = getObjectRadius(o);
300
  if (circleOverlap(x,y,r,o.x,o.y,rr)) return true;
301
  }
302
- // chests solid if not opened
303
  for (const c of chests){
304
  if (c.opened) continue;
305
  if (circleOverlap(x,y,r,c.x,c.y,chestRadius())) return true;
@@ -307,35 +313,32 @@
307
  return false;
308
  }
309
 
310
- // move with axis-based collision (allows sliding)
311
  function moveEntityWithCollision(entity, dx, dy, radius){
312
- // move along x
313
  const oldX = entity.x, oldY = entity.y;
314
  let nx = entity.x + dx;
315
  entity.x = nx;
316
  if (entity.x < radius) entity.x = radius;
317
  if (entity.x > WORLD.width - radius) entity.x = WORLD.width - radius;
318
  if (isCollidingSolid(entity.x, entity.y, radius)){
319
- entity.x = oldX; // revert X
320
  }
321
- // move along y
322
  let ny = entity.y + dy;
323
  entity.y = ny;
324
  if (entity.y < radius) entity.y = radius;
325
  if (entity.y > WORLD.height - radius) entity.y = WORLD.height - radius;
326
  if (isCollidingSolid(entity.x, entity.y, radius)){
327
- entity.y = oldY; // revert Y
328
  }
329
  }
330
 
331
- // Populate world - spawn many objects and enemies
332
  function populateWorld(){
333
  chests.length = 0; objects.length = 0; enemies.length = 0; pickups.length = 0;
334
  for (let i=0;i<260;i++){
335
  const x = rand(150, WORLD.width-150);
336
  const y = rand(150, WORLD.height-150);
337
  const loot = generateLootForBiome(biomeAt(x,y));
338
- if (loot.type === 'materials') loot.amount = 10; // ensure 10
339
  chests.push({ x,y, opened:false, loot });
340
  }
341
  for (let i=0;i<700;i++){
@@ -347,7 +350,6 @@
347
  const hp = type==='wood'?40 : (type==='stone'?80:160);
348
  objects.push({ x,y, type, hp, maxHp:hp, dead:false });
349
  }
350
- // spawn enemies - ensure they start unarmed and must loot to obtain weapons
351
  const now = performance.now();
352
  for (let i=0;i<49;i++){
353
  const ex = rand(300, WORLD.width-300);
@@ -356,7 +358,7 @@
356
  id:'e'+i, x:ex, y:ey, radius:14, angle:rand(0,Math.PI*2), speed:110+rand(-20,20),
357
  health: 80 + randInt(0,40), lastMelee:0, meleeRate:800 + randInt(-200,200),
358
  roamTimer: rand(0,3),
359
- inventory: [null,null,null,null,null], // explicitly no weapons at spawn
360
  selectedSlot: 0,
361
  equippedIndex: -1,
362
  materials: 0,
@@ -364,12 +366,11 @@
364
  reloadingUntil: 0,
365
  reloadPending: false,
366
  lastAttackedTime: 0,
367
- lastAttackerId: null,
368
  state: 'gather',
369
  gatherTimeLeft: rand(8,16),
370
  target: null,
371
  nextHealTime: 0,
372
- spawnSafeUntil: now + SPAWN_PROTECT_MS // cooldown: ignore nearby ground pickups for short time
373
  });
374
  }
375
  updatePlayerCount();
@@ -433,7 +434,7 @@
433
  }
434
  function worldToScreen(wx,wy){ return { x: Math.round(wx - camera.x), y: Math.round(wy - camera.y) }; }
435
 
436
- // Combat utilities
437
  function shootBullet(originX, originY, targetX, targetY, weaponObj, shooterId){
438
  if (!weaponObj || (typeof weaponObj.ammoInMag === 'number' && weaponObj.ammoInMag <= 0)) return false;
439
  const speed = 1100;
@@ -553,7 +554,7 @@
553
  }
554
  }
555
 
556
- // Enemy helpers (equip / collect)
557
  function enemyEquipBestWeapon(e){
558
  let bestIdx = -1;
559
  let bestScore = -Infinity;
@@ -666,11 +667,6 @@
666
  return closest;
667
  }
668
 
669
- function findEnemyById(id){
670
- if (!id) return null;
671
- return enemies.find(en => en.id === id) || null;
672
- }
673
-
674
  // Bullets update
675
  function bulletsUpdate(dt){
676
  for (let i=bullets.length-1;i>=0;i--){
@@ -692,7 +688,6 @@
692
  if (Math.hypot(e.x - b.x, e.y - b.y) < 14){
693
  e.health -= b.dmg;
694
  e.lastAttackedTime = performance.now();
695
- e.lastAttackerId = b.shooter; // record who attacked them
696
  if (e.health <= 0){ e.health = 0; if (b.shooter === 'player'){ player.kills++; player.materials += 2; updatePlayerCount(); } }
697
  bullets.splice(i,1); break;
698
  }
@@ -743,39 +738,29 @@
743
  return best;
744
  }
745
 
746
- // Enemy AI with gather-first + separation + reload with delay
747
  function updateEnemies(dt, now){
748
- // basic separation step variables
749
- const minSeparation = 20; // minimal distance between enemies
750
- // Update each enemy movement and actions
751
  for (const e of enemies){
752
  if (e.health <= 0) continue;
753
-
754
- // ensure spawn protection exists
755
  if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
756
 
757
- // If storm is active and enemy is outside safe zone, move to safe zone first
758
  if (storm.active){
759
  const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
760
  if (distToSafeCenter > storm.radius){
761
- // move towards safe zone center
762
  e.state = 'toSafe';
763
  e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
764
- const speedMult = 0.95;
765
- const dx = Math.cos(e.angle) * e.speed * dt * speedMult;
766
- const dy = Math.sin(e.angle) * e.speed * dt * speedMult;
767
  moveEntityWithCollision(e, dx, dy, e.radius);
768
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
769
- continue; // priority movement to safe zone
770
  }
771
  }
772
 
773
- // if recently attacked go combat
774
  if (now - e.lastAttackedTime < 4000) e.state = 'combat';
775
-
776
  if (e.state === 'gather') e.gatherTimeLeft -= dt;
777
 
778
- // Medkit-only heal (consume medkit, with cooldown)
779
  if (e.health < 60 && now >= (e.nextHealTime || 0)){
780
  let medIdx = -1;
781
  for (let s=0;s<5;s++){
@@ -792,13 +777,10 @@
792
  }
793
  }
794
 
795
- // Transition gather->combat only if they actually have weapons/ammo or enough materials or time expired
796
  const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
797
  if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
798
 
799
  if (e.state === 'gather'){
800
- // Prioritize pickup -> chest -> harvest -> roam
801
- // Respect spawn protection so they don't instantly pick up something they spawned on
802
  if (now >= (e.spawnSafeUntil || 0)){
803
  let p = findNearestPickup(e, 240);
804
  if (p){
@@ -858,7 +840,6 @@
858
  continue;
859
  }
860
 
861
- // roam
862
  const dx = Math.cos(e.angle) * e.speed * dt * 0.25;
863
  const dy = Math.sin(e.angle) * e.speed * dt * 0.25;
864
  moveEntityWithCollision(e, dx, dy, e.radius);
@@ -866,10 +847,8 @@
866
  continue;
867
  }
868
 
869
- // Combat state
870
  if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
871
-
872
- // Handle reload pending (delayed reload to simulate player reload)
873
  if (e.reloadPending){
874
  if (now >= e.reloadingUntil){
875
  const eq = e.inventory[e.equippedIndex];
@@ -878,73 +857,84 @@
878
  e.reloadingUntil = 0;
879
  }
880
  }
881
-
882
- // If equipped weapon empty and reserve present, start reload (delayed)
883
  if (e.equippedIndex >= 0){
884
  const eq = e.inventory[e.equippedIndex];
885
  if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
886
  e.reloadPending = true;
887
- e.reloadingUntil = now + 600 + rand(-100,100); // ~600ms reload time
888
  }
889
  }
890
 
891
- // choose a target: respect lastAttackerId first (retaliate), then player if in view range, else nearest meaningful target (including other enemies)
892
- let target = null;
893
- if (e.lastAttackerId && e.lastAttackerId !== 'player'){
894
- const attacker = findEnemyById(e.lastAttackerId);
895
- if (attacker && attacker.health > 0){
896
- target = attacker;
 
 
 
 
 
 
 
 
 
 
 
 
897
  } else {
898
- e.lastAttackerId = null;
 
 
 
 
 
 
899
  }
900
  }
901
 
902
- if (!target){
903
- const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
904
- if (distToPlayer <= VIEW_RANGE){
905
- target = player;
906
- } else {
907
- // look for pickups/chests/harvestables/nearby enemies to fight
908
- const p = findNearestPickup(e, 1200);
909
- const c = findNearestChest(e, 1200);
910
- const h = findNearestHarvestable(e, 1200);
911
- let candidate = null, cd = Infinity;
912
- if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
913
- if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
914
- if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
915
- // consider nearby enemy targets (higher aggression probability)
916
- for (const other of enemies){
917
- if (other === e || other.health <= 0) continue;
918
- const d = Math.hypot(other.x - e.x, other.y - e.y);
919
- if (d < cd && d <= 800){
920
- // preferentially go after enemies if already in combat or if random chance
921
- if (Math.random() < 0.6 || e.state === 'combat'){
922
- candidate = other; cd = d;
923
- }
924
- }
925
- }
926
- if (candidate){
927
- target = candidate;
928
- } else {
929
- target = null;
930
- }
931
- }
932
  }
933
 
934
- // If we still have no target, roam
935
  if (!target){
936
  e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
937
  e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
938
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
939
- // ensure no collisions after roam
940
  moveEntityWithCollision(e, 0, 0, e.radius);
941
  continue;
942
  }
943
 
944
- // If target is world object (chest/pickup/harvestable/ally), handle accordingly
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
945
  if (target === player){
946
- // Attack player
947
- const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
948
  if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
949
  e.lastMelee = now;
950
  const dmg = 10 + randInt(0,8);
@@ -955,7 +945,6 @@
955
  const eq = e.inventory[e.equippedIndex];
956
  if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
957
  if (eq.ammoInMag <= 0){
958
- // reload will be handled above
959
  } else {
960
  if (hasLineOfSight(e.x, e.y, target.x, target.y)){
961
  e.lastShot = now;
@@ -964,48 +953,34 @@
964
  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);
965
  e.lastAttackedTime = now;
966
  } else {
967
- // move to get LOS (with collision)
968
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
969
- const dx = Math.cos(e.angle) * e.speed * dt * 0.6;
970
- const dy = Math.sin(e.angle) * e.speed * dt * 0.6;
971
- moveEntityWithCollision(e, dx, dy, e.radius);
972
  }
973
  }
974
  } else {
975
- // unarmed behavior: rush to melee
976
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
977
- const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
978
- const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
979
- moveEntityWithCollision(e, dx, dy, e.radius);
980
  }
981
  } else {
982
- // no weapon: rush in
983
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
984
- const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
985
- const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
986
- moveEntityWithCollision(e, dx, dy, e.radius);
987
  }
988
  } else {
989
- // Non-player target (pickup/chest/harvestable/other enemy)
990
  const td = Math.hypot(target.x - e.x, target.y - e.y);
991
- if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){ // a pickup
992
  if (td > 20){
993
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
994
- const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
995
- const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
996
- moveEntityWithCollision(e, dx, dy, e.radius);
997
  } else {
998
  enemyPickupCollect(e, target);
999
  const idx = pickups.indexOf(target);
1000
  if (idx >= 0) pickups.splice(idx,1);
1001
  e.state = 'gather';
1002
  }
1003
- } else if (target.hasOwnProperty('loot')){ // chest
1004
  if (td > 20){
1005
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1006
- const dx = Math.cos(e.angle) * e.speed * dt * 0.9;
1007
- const dy = Math.sin(e.angle) * e.speed * dt * 0.9;
1008
- moveEntityWithCollision(e, dx, dy, e.radius);
1009
  } else {
1010
  target.opened = true;
1011
  const loot = target.loot;
@@ -1014,12 +989,10 @@
1014
  else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
1015
  e.state = 'gather';
1016
  }
1017
- } else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){ // harvestable object
1018
  if (td > 26){
1019
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1020
- const dx = Math.cos(e.angle) * e.speed * dt * 0.8;
1021
- const dy = Math.sin(e.angle) * e.speed * dt * 0.8;
1022
- moveEntityWithCollision(e, dx, dy, e.radius);
1023
  } else {
1024
  target.hp -= 40 * dt;
1025
  if (target.hp <= 0 && !target.dead){
@@ -1029,25 +1002,17 @@
1029
  e.state = 'gather';
1030
  }
1031
  } else {
1032
- // target is another enemy -> fight them
1033
  if (td > 40){
1034
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1035
- const dx = Math.cos(e.angle) * e.speed * dt * 0.7;
1036
- const dy = Math.sin(e.angle) * e.speed * dt * 0.7;
1037
- moveEntityWithCollision(e, dx, dy, e.radius);
1038
  } else {
1039
- // in melee range, do melee attacks
1040
  if (now - e.lastMelee > e.meleeRate){
1041
  e.lastMelee = now;
1042
- // damage target if it's an enemy
1043
  if (target && target.health > 0){
1044
  target.health -= 8 + randInt(0,6);
1045
  target.lastAttackedTime = now;
1046
  target.lastAttackerId = e.id;
1047
- if (target.health <= 0){
1048
- target.health = 0;
1049
- // don't credit player
1050
- }
1051
  }
1052
  }
1053
  }
@@ -1055,7 +1020,7 @@
1055
  }
1056
  }
1057
 
1058
- // Separation pass to avoid clumping (simple repulsion)
1059
  for (let i = 0; i < enemies.length; i++){
1060
  const a = enemies[i];
1061
  if (!a || a.health <= 0) continue;
@@ -1068,14 +1033,12 @@
1068
  if (d < minD){
1069
  const overlap = (minD - d) * 0.5;
1070
  const nx = dx / d, ny = dy / d;
1071
- // push both away proportional to overlap
1072
  b.x += nx * overlap;
1073
  b.y += ny * overlap;
1074
  a.x -= nx * overlap;
1075
  a.y -= ny * overlap;
1076
  }
1077
  }
1078
- // also avoid getting on top of player
1079
  const pdx = a.x - player.x, pdy = a.y - player.y;
1080
  const pd = Math.hypot(pdx,pdy) || 0.0001;
1081
  const avoidDist = 24;
@@ -1085,7 +1048,6 @@
1085
  a.x += nx * overlap;
1086
  a.y += ny * overlap;
1087
  }
1088
- // clamp to world
1089
  a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
1090
  a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
1091
  }
@@ -1130,7 +1092,7 @@
1130
  }
1131
  }
1132
 
1133
- // Drawing
1134
  function drawWorld(){
1135
  const TILE = 600;
1136
  const cols = Math.ceil(WORLD.width / TILE);
@@ -1148,25 +1110,13 @@
1148
  ctx.strokeStyle = 'rgba(0,0,0,0.08)'; ctx.strokeRect(s.x,s.y,TILE,TILE);
1149
  }
1150
  }
1151
- // Storm effect: color outside safe zone (overlay) and leave inside uncolored
1152
  if (storm.active){
1153
- // fill whole screen with overlay
1154
- ctx.save();
1155
- ctx.fillStyle = 'rgba(10,30,80,0.45)';
1156
- ctx.fillRect(0,0,canvas.width,canvas.height);
1157
- // Clear the safe circle area by using destination-out
1158
  const sc = worldToScreen(storm.centerX, storm.centerY);
1159
- ctx.globalCompositeOperation = 'destination-out';
1160
- ctx.beginPath();
1161
- ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2);
1162
- ctx.fill();
1163
- // restore to draw border
1164
- ctx.globalCompositeOperation = 'source-over';
1165
- ctx.strokeStyle = 'rgba(255,200,80,0.9)';
1166
- ctx.lineWidth = 3;
1167
- ctx.beginPath();
1168
- ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2);
1169
- ctx.stroke();
1170
  ctx.restore();
1171
  }
1172
  }
@@ -1241,7 +1191,6 @@
1241
  const hpPct = Math.max(0, Math.min(1, e.health/120));
1242
  ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
1243
 
1244
- // visible equipped weapon
1245
  if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
1246
  const we = e.inventory[e.equippedIndex];
1247
  const color = we.weapon.color || '#ddd';
@@ -1271,7 +1220,6 @@
1271
  }
1272
  }
1273
 
1274
- // state marker
1275
  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)');
1276
  ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
1277
  ctx.restore();
@@ -1338,20 +1286,19 @@
1338
 
1339
  function drawCrosshair(){}
1340
 
1341
- // Minimap drawing
1342
- function drawMinimap(){
1343
  const mw = minimapCanvas.width;
1344
  const mh = minimapCanvas.height;
1345
  const scaleX = WORLD.width / mw;
1346
  const scaleY = WORLD.height / mh;
1347
- // draw terrain by per-pixel sampling of biomeAt (cheap for 220x140)
1348
  const img = miniCtx.createImageData(mw, mh);
1349
  for (let my=0; my<mh; my++){
1350
  for (let mx=0; mx<mw; mx++){
1351
  const wx = Math.floor(mx * scaleX + scaleX/2);
1352
  const wy = Math.floor(my * scaleY + scaleY/2);
1353
  const b = biomeAt(wx, wy);
1354
- let col = [32,58,43]; // default
1355
  if (b==='desert') col = [203,183,139];
1356
  else if (b==='forest') col = [22,65,31];
1357
  else if (b==='oasis') col = [39,75,82];
@@ -1363,47 +1310,82 @@
1363
  img.data[idx+3] = 255;
1364
  }
1365
  }
1366
- miniCtx.putImageData(img, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1367
 
1368
- // draw storm overlay: color outside safe zone, leave inside transparent
1369
  if (storm.active){
1370
- miniCtx.save();
1371
- miniCtx.fillStyle = 'rgba(10,30,80,0.55)';
1372
  miniCtx.fillRect(0,0,mw,mh);
 
1373
  miniCtx.globalCompositeOperation = 'destination-out';
1374
- const cx = (storm.centerX) / WORLD.width * mw;
1375
- const cy = (storm.centerY) / WORLD.height * mh;
1376
- const r = storm.radius / WORLD.width * mw; // approximate scale (keep aspect roughly)
 
1377
  miniCtx.beginPath();
1378
  miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1379
  miniCtx.fill();
1380
  miniCtx.globalCompositeOperation = 'source-over';
1381
  // border
1382
- miniCtx.strokeStyle = 'rgba(255,200,80,0.9)';
1383
  miniCtx.lineWidth = 2;
1384
  miniCtx.beginPath();
1385
  miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1386
  miniCtx.stroke();
1387
- miniCtx.restore();
1388
  }
1389
 
1390
- // draw player dot
1391
- const px = player.x / WORLD.width * mw;
1392
- const py = player.y / WORLD.height * mh;
1393
  miniCtx.fillStyle = '#ffff66';
1394
  miniCtx.beginPath();
1395
- miniCtx.arc(px, py, 3, 0, Math.PI*2);
1396
  miniCtx.fill();
1397
 
1398
- // optionally draw safe-zone center dot
1399
- if (storm.active){
1400
- const cx = (storm.centerX) / WORLD.width * mw;
1401
- const cy = (storm.centerY) / WORLD.height * mh;
1402
- miniCtx.fillStyle = 'rgba(255,200,80,0.9)';
1403
- miniCtx.beginPath();
1404
- miniCtx.arc(cx, cy, 2, 0, Math.PI*2);
1405
- miniCtx.fill();
1406
- }
1407
  }
1408
 
1409
  // Main loop
@@ -1414,15 +1396,12 @@
1414
  const dt = Math.min(0.05, (ts - lastTime)/1000);
1415
  lastTime = ts;
1416
 
1417
- // player movement - axis separated to allow sliding against solids
1418
  let dx=0, dy=0;
1419
  if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
1420
  if (dx !== 0 || dy !== 0){
1421
  const len = Math.hypot(dx,dy) || 1;
1422
  const mvx = (dx/len) * player.speed * dt;
1423
  const mvy = (dy/len) * player.speed * dt;
1424
- // axis movement with collision
1425
- // move x first
1426
  const oldX = player.x, oldY = player.y;
1427
  player.x += mvx;
1428
  if (player.x < player.radius) player.x = player.radius;
@@ -1430,7 +1409,6 @@
1430
  if (isCollidingSolid(player.x, player.y, player.radius)){
1431
  player.x = oldX;
1432
  }
1433
- // then y
1434
  player.y += mvy;
1435
  if (player.y < player.radius) player.y = player.radius;
1436
  if (player.y > WORLD.height - player.radius) player.y = WORLD.height - player.radius;
@@ -1453,7 +1431,6 @@
1453
  if (selected && selected.type === 'weapon') activeWeaponItem = selected;
1454
  }
1455
 
1456
- // player attack
1457
  if (mouse.down){
1458
  if (player.equippedIndex === -1) playerMeleeHit();
1459
  else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
@@ -1472,28 +1449,24 @@
1472
  if (keys.e){ interactNearby(); keys.e = false; }
1473
  if (keys.q){ tryBuild(); keys.q = false; }
1474
 
1475
- // update
1476
  updateEnemies(dt, performance.now());
1477
  bulletsUpdate(dt);
1478
 
1479
- // player pickup auto-collect
1480
  for (let i=pickups.length-1;i>=0;i--){
1481
  const p = pickups[i];
1482
  if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
1483
  }
1484
 
1485
- // clean dead objects
1486
  for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
1487
 
1488
  updatePlayerCount();
1489
  updateStorm(dt);
1490
 
1491
- // render
1492
  ctx.clearRect(0,0,canvas.width,canvas.height);
1493
  drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer(); drawCrosshair();
1494
  updateHUD();
1495
 
1496
- // minimap draw
1497
  drawMinimap();
1498
 
1499
  if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
@@ -1504,7 +1477,6 @@
1504
  // Landing -> spawn selection
1505
  let selectedBiome = null;
1506
  function getSpawnForBiome(b){
1507
- // choose a spawn area region for each biome
1508
  if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
1509
  if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
1510
  if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
@@ -1519,7 +1491,6 @@
1519
  let gameActive = false;
1520
 
1521
  function startGame(biome){
1522
- // set current biome & spawn where selected
1523
  selectedBiome = biome || selectedBiome;
1524
  const spawn = getSpawnForBiome(selectedBiome);
1525
  document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
@@ -1542,17 +1513,16 @@
1542
  initHUD();
1543
  cameraUpdate();
1544
 
1545
- // small safety: ensure enemies don't spawn right on player
1546
  for (const e of enemies){
1547
  if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
1548
  e.x += (Math.random()<0.5? -1:1) * rand(160,260);
1549
  e.y += (Math.random()<0.5? -1:1) * rand(160,260);
1550
  }
1551
- // ensure they are unarmed at start
1552
  e.inventory = [null,null,null,null,null];
1553
  e.equippedIndex = -1;
1554
  e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
1555
  e.state = 'gather';
 
1556
  }
1557
 
1558
  gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
@@ -1585,20 +1555,17 @@
1585
  goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
1586
  continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
1587
 
1588
- // Biome click behavior: select & start spawn there
1589
  document.querySelectorAll('.biome-selector').forEach(el => {
1590
  el.addEventListener('click', (ev)=>{
1591
- // visual selection feedback
1592
  document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
1593
  el.classList.add('biome-selected');
1594
  const biome = el.dataset.biome;
1595
  selectedBiome = biome;
1596
- // start game immediately with the selected biome spawn region
1597
  startGame(biome);
1598
  });
1599
  });
1600
 
1601
- // initialisation
1602
  resizeCanvas();
1603
  populateWorld();
1604
  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>
 
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 };
 
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){
 
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;
 
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,
 
366
  reloadingUntil: 0,
367
  reloadPending: false,
368
  lastAttackedTime: 0,
 
369
  state: 'gather',
370
  gatherTimeLeft: rand(8,16),
371
  target: null,
372
  nextHealTime: 0,
373
+ spawnSafeUntil: now + SPAWN_PROTECT_MS
374
  });
375
  }
376
  updatePlayerCount();
 
434
  }
435
  function worldToScreen(wx,wy){ return { x: Math.round(wx - camera.x), y: Math.round(wy - camera.y) }; }
436
 
437
+ // Combat utilities (kept same as before)
438
  function shootBullet(originX, originY, targetX, targetY, weaponObj, shooterId){
439
  if (!weaponObj || (typeof weaponObj.ammoInMag === 'number' && weaponObj.ammoInMag <= 0)) return false;
440
  const speed = 1100;
 
554
  }
555
  }
556
 
557
+ // Enemy helpers
558
  function enemyEquipBestWeapon(e){
559
  let bestIdx = -1;
560
  let bestScore = -Infinity;
 
667
  return closest;
668
  }
669
 
 
 
 
 
 
670
  // Bullets update
671
  function bulletsUpdate(dt){
672
  for (let i=bullets.length-1;i>=0;i--){
 
688
  if (Math.hypot(e.x - b.x, e.y - b.y) < 14){
689
  e.health -= b.dmg;
690
  e.lastAttackedTime = performance.now();
 
691
  if (e.health <= 0){ e.health = 0; if (b.shooter === 'player'){ player.kills++; player.materials += 2; updatePlayerCount(); } }
692
  bullets.splice(i,1); break;
693
  }
 
738
  return best;
739
  }
740
 
741
+ // Enemy AI (kept as in previous)
742
  function updateEnemies(dt, now){
743
+ const minSeparation = 20;
 
 
744
  for (const e of enemies){
745
  if (e.health <= 0) continue;
 
 
746
  if (!e.spawnSafeUntil) e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
747
 
 
748
  if (storm.active){
749
  const distToSafeCenter = Math.hypot(e.x - storm.centerX, e.y - storm.centerY);
750
  if (distToSafeCenter > storm.radius){
 
751
  e.state = 'toSafe';
752
  e.angle = Math.atan2(storm.centerY - e.y, storm.centerX - e.x);
753
+ const dx = Math.cos(e.angle) * e.speed * dt * 0.95;
754
+ const dy = Math.sin(e.angle) * e.speed * dt * 0.95;
 
755
  moveEntityWithCollision(e, dx, dy, e.radius);
756
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*0.5;
757
+ continue;
758
  }
759
  }
760
 
 
761
  if (now - e.lastAttackedTime < 4000) e.state = 'combat';
 
762
  if (e.state === 'gather') e.gatherTimeLeft -= dt;
763
 
 
764
  if (e.health < 60 && now >= (e.nextHealTime || 0)){
765
  let medIdx = -1;
766
  for (let s=0;s<5;s++){
 
777
  }
778
  }
779
 
 
780
  const hasUsefulWeapon = e.inventory.some(it => it && it.type === 'weapon' && (it.ammoInMag > 0 || it.ammoReserve > 0));
781
  if ((e.gatherTimeLeft <= 0 || e.materials >= 20 || hasUsefulWeapon) && e.state === 'gather') e.state = 'combat';
782
 
783
  if (e.state === 'gather'){
 
 
784
  if (now >= (e.spawnSafeUntil || 0)){
785
  let p = findNearestPickup(e, 240);
786
  if (p){
 
840
  continue;
841
  }
842
 
 
843
  const dx = Math.cos(e.angle) * e.speed * dt * 0.25;
844
  const dy = Math.sin(e.angle) * e.speed * dt * 0.25;
845
  moveEntityWithCollision(e, dx, dy, e.radius);
 
847
  continue;
848
  }
849
 
850
+ // Combat
851
  if (e.equippedIndex === -1) enemyEquipBestWeapon(e);
 
 
852
  if (e.reloadPending){
853
  if (now >= e.reloadingUntil){
854
  const eq = e.inventory[e.equippedIndex];
 
857
  e.reloadingUntil = 0;
858
  }
859
  }
 
 
860
  if (e.equippedIndex >= 0){
861
  const eq = e.inventory[e.equippedIndex];
862
  if (eq && eq.type === 'weapon' && eq.ammoInMag <= 0 && eq.ammoReserve > 0 && !e.reloadPending && now >= e.reloadingUntil){
863
  e.reloadPending = true;
864
+ e.reloadingUntil = now + 600 + rand(-100,100);
865
  }
866
  }
867
 
868
+ let target = player;
869
+ let bestDist = Math.hypot(player.x - e.x, player.y - e.y);
870
+ if (bestDist > VIEW_RANGE){
871
+ const p = findNearestPickup(e, 1200);
872
+ const c = findNearestChest(e, 1200);
873
+ const h = findNearestHarvestable(e, 1200);
874
+ let candidate = null, cd = Infinity;
875
+ if (p){ const d=Math.hypot(p.x-e.x,p.y-e.y); if (d<cd){ candidate=p; cd=d; } }
876
+ if (c){ const d=Math.hypot(c.x-e.x,c.y-e.y); if (d<cd){ candidate=c; cd=d; } }
877
+ if (h){ const d=Math.hypot(h.x-e.x,h.y-e.y); if (d<cd){ candidate=h; cd=d; } }
878
+ for (const other of enemies){
879
+ if (other === e || other.health <= 0) continue;
880
+ const d = Math.hypot(other.x - e.x, other.y - e.y);
881
+ if (d < cd && d <= 800){ candidate = other; cd = d; }
882
+ }
883
+ if (candidate){
884
+ target = candidate;
885
+ bestDist = cd;
886
  } else {
887
+ target = null; bestDist = Infinity;
888
+ }
889
+ } else {
890
+ for (const other of enemies){
891
+ if (other === e || other.health <= 0) continue;
892
+ const d = Math.hypot(other.x - e.x, other.y - e.y);
893
+ if (d < bestDist && Math.random() < 0.6){ bestDist = d; target = other; }
894
  }
895
  }
896
 
897
+ const distToPlayer = Math.hypot(player.x - e.x, player.y - e.y);
898
+ if (distToPlayer < 160 && e.health < 35 && e.materials >= 10 && Math.random() < 0.5){
899
+ enemyTryBuild(e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
  }
901
 
 
902
  if (!target){
903
  e.x += Math.cos(e.angle) * e.speed * dt * 0.7;
904
  e.y += Math.sin(e.angle) * e.speed * dt * 0.7;
905
  if (Math.random() < 0.02) e.angle += (Math.random()-0.5)*1.5;
 
906
  moveEntityWithCollision(e, 0, 0, e.radius);
907
  continue;
908
  }
909
 
910
+ const blocked = !hasLineOfSight(e.x, e.y, target.x, target.y);
911
+ if (blocked){
912
+ const blocker = findBlockingObject(e.x, e.y, target.x, target.y);
913
+ if (blocker){
914
+ const db = Math.hypot(blocker.x - e.x, blocker.y - e.y);
915
+ if (db < 36){
916
+ blocker.hp -= 18 * dt * 2;
917
+ if (blocker.hp <= 0){ blocker.dead = true; e.materials += (blocker.type === 'wood' ? 3 : 6); }
918
+ } else {
919
+ if (e.equippedIndex >= 0){
920
+ const eq = e.inventory[e.equippedIndex];
921
+ if (eq && eq.type === 'weapon' && eq.ammoInMag > 0 && !e.reloadPending && now - e.lastShot > (eq.weapon.rate || 300)){
922
+ e.lastShot = now;
923
+ eq.ammoInMag -= 1;
924
+ const angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
925
+ 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);
926
+ }
927
+ } else {
928
+ e.angle = Math.atan2(blocker.y - e.y, blocker.x - e.x);
929
+ e.x += Math.cos(e.angle) * e.speed * dt * 0.8;
930
+ e.y += Math.sin(e.angle) * e.speed * dt * 0.8;
931
+ }
932
+ }
933
+ continue;
934
+ }
935
+ }
936
+
937
  if (target === player){
 
 
938
  if (distToPlayer < 36 && now - e.lastMelee > e.meleeRate){
939
  e.lastMelee = now;
940
  const dmg = 10 + randInt(0,8);
 
945
  const eq = e.inventory[e.equippedIndex];
946
  if (eq && eq.type === 'weapon' && !e.reloadPending && now - (e.lastShot||0) > (eq.weapon.rate || 300)){
947
  if (eq.ammoInMag <= 0){
 
948
  } else {
949
  if (hasLineOfSight(e.x, e.y, target.x, target.y)){
950
  e.lastShot = now;
 
953
  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);
954
  e.lastAttackedTime = now;
955
  } else {
 
956
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
957
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.6, Math.sin(e.angle) * e.speed * dt * 0.6, e.radius);
 
 
958
  }
959
  }
960
  } else {
 
961
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
962
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
 
963
  }
964
  } else {
 
965
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
966
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
 
967
  }
968
  } else {
 
969
  const td = Math.hypot(target.x - e.x, target.y - e.y);
970
+ if (target.type === 'weapon' || target.type === 'medkit' || target.type === 'materials' || target.type === 'ammo'){
971
  if (td > 20){
972
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
973
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
 
974
  } else {
975
  enemyPickupCollect(e, target);
976
  const idx = pickups.indexOf(target);
977
  if (idx >= 0) pickups.splice(idx,1);
978
  e.state = 'gather';
979
  }
980
+ } else if (target.hasOwnProperty('loot')){
981
  if (td > 20){
982
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
983
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.9, Math.sin(e.angle) * e.speed * dt * 0.9, e.radius);
 
 
984
  } else {
985
  target.opened = true;
986
  const loot = target.loot;
 
989
  else if (loot.type === 'materials') enemyPickupCollect(e, { type:'materials', amount: loot.amount || 10 });
990
  e.state = 'gather';
991
  }
992
+ } else if (target.hasOwnProperty('type') && (target.type === 'wood' || target.type === 'stone')){
993
  if (td > 26){
994
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
995
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.8, Math.sin(e.angle) * e.speed * dt * 0.8, e.radius);
 
 
996
  } else {
997
  target.hp -= 40 * dt;
998
  if (target.hp <= 0 && !target.dead){
 
1002
  e.state = 'gather';
1003
  }
1004
  } else {
 
1005
  if (td > 40){
1006
  e.angle = Math.atan2(target.y - e.y, target.x - e.x);
1007
+ moveEntityWithCollision(e, Math.cos(e.angle) * e.speed * dt * 0.7, Math.sin(e.angle) * e.speed * dt * 0.7, e.radius);
 
 
1008
  } else {
 
1009
  if (now - e.lastMelee > e.meleeRate){
1010
  e.lastMelee = now;
 
1011
  if (target && target.health > 0){
1012
  target.health -= 8 + randInt(0,6);
1013
  target.lastAttackedTime = now;
1014
  target.lastAttackerId = e.id;
1015
+ if (target.health <= 0) target.health = 0;
 
 
 
1016
  }
1017
  }
1018
  }
 
1020
  }
1021
  }
1022
 
1023
+ // Separation
1024
  for (let i = 0; i < enemies.length; i++){
1025
  const a = enemies[i];
1026
  if (!a || a.health <= 0) continue;
 
1033
  if (d < minD){
1034
  const overlap = (minD - d) * 0.5;
1035
  const nx = dx / d, ny = dy / d;
 
1036
  b.x += nx * overlap;
1037
  b.y += ny * overlap;
1038
  a.x -= nx * overlap;
1039
  a.y -= ny * overlap;
1040
  }
1041
  }
 
1042
  const pdx = a.x - player.x, pdy = a.y - player.y;
1043
  const pd = Math.hypot(pdx,pdy) || 0.0001;
1044
  const avoidDist = 24;
 
1048
  a.x += nx * overlap;
1049
  a.y += ny * overlap;
1050
  }
 
1051
  a.x = Math.max(12, Math.min(WORLD.width-12, a.x));
1052
  a.y = Math.max(12, Math.min(WORLD.height-12, a.y));
1053
  }
 
1092
  }
1093
  }
1094
 
1095
+ // Drawing world & entities
1096
  function drawWorld(){
1097
  const TILE = 600;
1098
  const cols = Math.ceil(WORLD.width / TILE);
 
1110
  ctx.strokeStyle = 'rgba(0,0,0,0.08)'; ctx.strokeRect(s.x,s.y,TILE,TILE);
1111
  }
1112
  }
 
1113
  if (storm.active){
 
 
 
 
 
1114
  const sc = worldToScreen(storm.centerX, storm.centerY);
1115
+ ctx.save();
1116
+ const grad = ctx.createRadialGradient(sc.x, sc.y, storm.radius*0.15, sc.x, sc.y, storm.radius);
1117
+ grad.addColorStop(0,'rgba(100,149,237,0.02)'); grad.addColorStop(1,'rgba(100,149,237,0.45)');
1118
+ ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(sc.x, sc.y, storm.radius, 0, Math.PI*2); ctx.fill();
1119
+ 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();
 
 
 
 
 
 
1120
  ctx.restore();
1121
  }
1122
  }
 
1191
  const hpPct = Math.max(0, Math.min(1, e.health/120));
1192
  ctx.fillStyle='#ff6b6b'; ctx.fillRect(-18,-22,36*hpPct,6);
1193
 
 
1194
  if (e.equippedIndex >= 0 && e.inventory[e.equippedIndex] && e.inventory[e.equippedIndex].type === 'weapon'){
1195
  const we = e.inventory[e.equippedIndex];
1196
  const color = we.weapon.color || '#ddd';
 
1220
  }
1221
  }
1222
 
 
1223
  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)');
1224
  ctx.beginPath(); ctx.arc(18, -18, 5, 0, Math.PI*2); ctx.fill();
1225
  ctx.restore();
 
1286
 
1287
  function drawCrosshair(){}
1288
 
1289
+ // Minimap: build terrain cache and draw overlay (EXCLUDES enemies & chests)
1290
+ function buildMiniTerrainCache(){
1291
  const mw = minimapCanvas.width;
1292
  const mh = minimapCanvas.height;
1293
  const scaleX = WORLD.width / mw;
1294
  const scaleY = WORLD.height / mh;
 
1295
  const img = miniCtx.createImageData(mw, mh);
1296
  for (let my=0; my<mh; my++){
1297
  for (let mx=0; mx<mw; mx++){
1298
  const wx = Math.floor(mx * scaleX + scaleX/2);
1299
  const wy = Math.floor(my * scaleY + scaleY/2);
1300
  const b = biomeAt(wx, wy);
1301
+ let col = [32,58,43];
1302
  if (b==='desert') col = [203,183,139];
1303
  else if (b==='forest') col = [22,65,31];
1304
  else if (b==='oasis') col = [39,75,82];
 
1310
  img.data[idx+3] = 255;
1311
  }
1312
  }
1313
+ miniTerrainCache = img;
1314
+ }
1315
+
1316
+ function drawMinimap(){
1317
+ const mw = minimapCanvas.width;
1318
+ const mh = minimapCanvas.height;
1319
+ if (!miniTerrainCache) buildMiniTerrainCache();
1320
+ // draw base terrain
1321
+ miniCtx.putImageData(miniTerrainCache, 0, 0);
1322
+
1323
+ // draw world objects (trees/stones/walls) as tiny marks - chests excluded per request
1324
+ miniCtx.save();
1325
+ const scaleX = mw / WORLD.width;
1326
+ const scaleY = mh / WORLD.height;
1327
+ for (const obj of objects){
1328
+ if (obj.dead) continue;
1329
+ const px = Math.round(obj.x * scaleX);
1330
+ const py = Math.round(obj.y * scaleY);
1331
+ if (obj.type === 'wood'){
1332
+ miniCtx.fillStyle = '#3f210f';
1333
+ miniCtx.fillRect(px-1, py-1, 2, 2);
1334
+ } else if (obj.type === 'stone'){
1335
+ miniCtx.fillStyle = '#666';
1336
+ miniCtx.fillRect(px-1, py-1, 2, 2);
1337
+ } else if (obj.type === 'wall'){
1338
+ miniCtx.fillStyle = '#8b5a32';
1339
+ miniCtx.fillRect(px-2, py-2, 4, 4);
1340
+ }
1341
+ }
1342
+ // optionally show pickups (not enemies/chests) - small blue/green dots
1343
+ for (const p of pickups){
1344
+ const px = Math.round(p.x * scaleX);
1345
+ const py = Math.round(p.y * scaleY);
1346
+ if (p.type === 'weapon'){
1347
+ miniCtx.fillStyle = '#ffd86b'; miniCtx.fillRect(px-1, py-1, 2, 2);
1348
+ } else if (p.type === 'medkit'){
1349
+ miniCtx.fillStyle = '#ff6b6b'; miniCtx.fillRect(px-1, py-1, 2, 2);
1350
+ } else if (p.type === 'materials'){
1351
+ miniCtx.fillStyle = '#cfe0a6'; miniCtx.fillRect(px-1, py-1, 2, 2);
1352
+ } else if (p.type === 'ammo'){
1353
+ miniCtx.fillStyle = '#e6e6e6'; miniCtx.fillRect(px-1, py-1, 2, 2);
1354
+ }
1355
+ }
1356
 
1357
+ // Storm safe zone: draw overlay darkening outside safe zone and stroke the safe circle
1358
  if (storm.active){
1359
+ // darken everything
1360
+ miniCtx.fillStyle = 'rgba(0,0,0,0.35)';
1361
  miniCtx.fillRect(0,0,mw,mh);
1362
+ // carve out safe zone
1363
  miniCtx.globalCompositeOperation = 'destination-out';
1364
+ const cx = storm.centerX * scaleX;
1365
+ const cy = storm.centerY * scaleY;
1366
+ // radius scaled by average axis to keep circle shape close
1367
+ const r = storm.radius * ((scaleX + scaleY) / 2);
1368
  miniCtx.beginPath();
1369
  miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1370
  miniCtx.fill();
1371
  miniCtx.globalCompositeOperation = 'source-over';
1372
  // border
1373
+ miniCtx.strokeStyle = 'rgba(255,200,80,0.95)';
1374
  miniCtx.lineWidth = 2;
1375
  miniCtx.beginPath();
1376
  miniCtx.arc(cx, cy, r, 0, Math.PI*2);
1377
  miniCtx.stroke();
 
1378
  }
1379
 
1380
+ // draw player dot (always visible)
1381
+ const ppx = Math.round(player.x * (mw / WORLD.width));
1382
+ const ppy = Math.round(player.y * (mh / WORLD.height));
1383
  miniCtx.fillStyle = '#ffff66';
1384
  miniCtx.beginPath();
1385
+ miniCtx.arc(ppx, ppy, 3, 0, Math.PI*2);
1386
  miniCtx.fill();
1387
 
1388
+ miniCtx.restore();
 
 
 
 
 
 
 
 
1389
  }
1390
 
1391
  // Main loop
 
1396
  const dt = Math.min(0.05, (ts - lastTime)/1000);
1397
  lastTime = ts;
1398
 
 
1399
  let dx=0, dy=0;
1400
  if (keys.w) dy -= 1; if (keys.s) dy += 1; if (keys.a) dx -= 1; if (keys.d) dx += 1;
1401
  if (dx !== 0 || dy !== 0){
1402
  const len = Math.hypot(dx,dy) || 1;
1403
  const mvx = (dx/len) * player.speed * dt;
1404
  const mvy = (dy/len) * player.speed * dt;
 
 
1405
  const oldX = player.x, oldY = player.y;
1406
  player.x += mvx;
1407
  if (player.x < player.radius) player.x = player.radius;
 
1409
  if (isCollidingSolid(player.x, player.y, player.radius)){
1410
  player.x = oldX;
1411
  }
 
1412
  player.y += mvy;
1413
  if (player.y < player.radius) player.y = player.radius;
1414
  if (player.y > WORLD.height - player.radius) player.y = WORLD.height - player.radius;
 
1431
  if (selected && selected.type === 'weapon') activeWeaponItem = selected;
1432
  }
1433
 
 
1434
  if (mouse.down){
1435
  if (player.equippedIndex === -1) playerMeleeHit();
1436
  else if (activeWeaponItem && activeWeaponItem.type === 'weapon'){
 
1449
  if (keys.e){ interactNearby(); keys.e = false; }
1450
  if (keys.q){ tryBuild(); keys.q = false; }
1451
 
 
1452
  updateEnemies(dt, performance.now());
1453
  bulletsUpdate(dt);
1454
 
 
1455
  for (let i=pickups.length-1;i>=0;i--){
1456
  const p = pickups[i];
1457
  if (Math.hypot(p.x - player.x, p.y - player.y) < 18){ pickupCollect(p); pickups.splice(i,1); updateHUD(); }
1458
  }
1459
 
 
1460
  for (let i=objects.length-1;i>=0;i--) if (objects[i].dead) objects.splice(i,1);
1461
 
1462
  updatePlayerCount();
1463
  updateStorm(dt);
1464
 
 
1465
  ctx.clearRect(0,0,canvas.width,canvas.height);
1466
  drawWorld(); drawObjects(); drawChests(); drawPickups(); drawEnemies(); drawBullets(); drawPlayer(); drawCrosshair();
1467
  updateHUD();
1468
 
1469
+ // Draw the minimap last so it reflects the latest world state
1470
  drawMinimap();
1471
 
1472
  if (storm.active && playerInStorm()) stormWarning.classList.remove('hidden'); else stormWarning.classList.add('hidden');
 
1477
  // Landing -> spawn selection
1478
  let selectedBiome = null;
1479
  function getSpawnForBiome(b){
 
1480
  if (!b) return { x: WORLD.width/2 + (Math.random()-0.5)*400, y: WORLD.height/2 + (Math.random()-0.5)*400 };
1481
  if (b === 'desert') return { x: rand(200, WORLD.width*0.3), y: rand(200, WORLD.height*0.3) };
1482
  if (b === 'forest') return { x: rand(WORLD.width*0.7, WORLD.width-200), y: rand(200, WORLD.height*0.3) };
 
1491
  let gameActive = false;
1492
 
1493
  function startGame(biome){
 
1494
  selectedBiome = biome || selectedBiome;
1495
  const spawn = getSpawnForBiome(selectedBiome);
1496
  document.getElementById('currentBiome').textContent = selectedBiome ? selectedBiome.toUpperCase() : 'CENTER';
 
1513
  initHUD();
1514
  cameraUpdate();
1515
 
 
1516
  for (const e of enemies){
1517
  if (Math.hypot(e.x - player.x, e.y - player.y) < 180){
1518
  e.x += (Math.random()<0.5? -1:1) * rand(160,260);
1519
  e.y += (Math.random()<0.5? -1:1) * rand(160,260);
1520
  }
 
1521
  e.inventory = [null,null,null,null,null];
1522
  e.equippedIndex = -1;
1523
  e.spawnSafeUntil = performance.now() + SPAWN_PROTECT_MS;
1524
  e.state = 'gather';
1525
+ e.nextHealTime = 0;
1526
  }
1527
 
1528
  gameTime = 300; storm.active = false; storm.radius = storm.maxRadius;
 
1555
  goHomeBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); gameScreen.classList.add('hidden'); landingScreen.classList.remove('hidden'); });
1556
  continueBtn.addEventListener('click', ()=>{ victoryScreen.classList.add('hidden'); startGame(selectedBiome); });
1557
 
 
1558
  document.querySelectorAll('.biome-selector').forEach(el => {
1559
  el.addEventListener('click', (ev)=>{
 
1560
  document.querySelectorAll('.biome-selector').forEach(x=>x.classList.remove('biome-selected'));
1561
  el.classList.add('biome-selected');
1562
  const biome = el.dataset.biome;
1563
  selectedBiome = biome;
 
1564
  startGame(biome);
1565
  });
1566
  });
1567
 
1568
+ // initialization
1569
  resizeCanvas();
1570
  populateWorld();
1571
  feather.replace();