Spaces:
Running
Running
Manual changes saved
Browse files- 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 -
|
| 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 |
-
|
|
|
|
| 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 (
|
| 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 };
|
| 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;
|
| 280 |
-
const SPAWN_PROTECT_MS = 1200;
|
| 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;
|
| 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;
|
| 328 |
}
|
| 329 |
}
|
| 330 |
|
| 331 |
-
// Populate world
|
| 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;
|
| 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],
|
| 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
|
| 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
|
| 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
|
| 747 |
function updateEnemies(dt, now){
|
| 748 |
-
|
| 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
|
| 765 |
-
const
|
| 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;
|
| 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
|
| 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);
|
| 888 |
}
|
| 889 |
}
|
| 890 |
|
| 891 |
-
|
| 892 |
-
let
|
| 893 |
-
if (
|
| 894 |
-
const
|
| 895 |
-
|
| 896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
} else {
|
| 898 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
}
|
| 900 |
}
|
| 901 |
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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'){
|
| 992 |
if (td > 20){
|
| 993 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 994 |
-
|
| 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')){
|
| 1004 |
if (td > 20){
|
| 1005 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 1006 |
-
|
| 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')){
|
| 1018 |
if (td > 26){
|
| 1019 |
e.angle = Math.atan2(target.y - e.y, target.x - e.x);
|
| 1020 |
-
|
| 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 |
-
|
| 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
|
| 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.
|
| 1160 |
-
ctx.
|
| 1161 |
-
|
| 1162 |
-
ctx.fill();
|
| 1163 |
-
|
| 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
|
| 1342 |
-
function
|
| 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];
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
|
| 1368 |
-
//
|
| 1369 |
if (storm.active){
|
| 1370 |
-
|
| 1371 |
-
miniCtx.fillStyle = 'rgba(
|
| 1372 |
miniCtx.fillRect(0,0,mw,mh);
|
|
|
|
| 1373 |
miniCtx.globalCompositeOperation = 'destination-out';
|
| 1374 |
-
const cx =
|
| 1375 |
-
const cy =
|
| 1376 |
-
|
|
|
|
| 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.
|
| 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
|
| 1392 |
-
const
|
| 1393 |
miniCtx.fillStyle = '#ffff66';
|
| 1394 |
miniCtx.beginPath();
|
| 1395 |
-
miniCtx.arc(
|
| 1396 |
miniCtx.fill();
|
| 1397 |
|
| 1398 |
-
|
| 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
|
| 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 |
-
//
|
| 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();
|