Codex CLI commited on
Commit
fd12cd0
·
1 Parent(s): 1390db3

feat(shaman): add shaman enemy type with fireball + teleport; refactor enemy typing; improve visuals, aim, and portal FX\n\n- Add shaman enemy (hood, cape, staff, glowing eyes)\n- Fireball projectile: glow, ring, light, flicker; faster speed\n- Aim fix: target camera, update world matrices, prevent reversed shots\n- Teleport: more frequent, portal FX at depart/arrive\n- Refactor enemies to be type-aware (orc/shaman)\n- Waves: spawn 1 shaman per wave\n- Track player velocity for future prediction\n- Add portal FX system

Browse files
Files changed (14) hide show
  1. README.md +23 -0
  2. index.html +4 -0
  3. src/combat.js +5 -3
  4. src/config.js +10 -0
  5. src/enemies.js +264 -54
  6. src/fx.js +49 -0
  7. src/globals.js +9 -2
  8. src/lighting.js +6 -14
  9. src/main.js +25 -2
  10. src/pickups.js +3 -2
  11. src/player.js +13 -4
  12. src/projectiles.js +82 -9
  13. src/waves.js +7 -1
  14. src/world.js +124 -4
README.md CHANGED
@@ -25,3 +25,26 @@ Performance tips:
25
  - Lower `grassPerChunk` and/or increase `chunkSize` if GPU load is high.
26
  - Keep rocks/bush counts modest; they cast shadows by default.
27
  - Grass and flowers don’t receive/cast shadows for cheaper fills.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  - Lower `grassPerChunk` and/or increase `chunkSize` if GPU load is high.
26
  - Keep rocks/bush counts modest; they cast shadows by default.
27
  - Grass and flowers don’t receive/cast shadows for cheaper fills.
28
+
29
+ ## Performance and Tuning
30
+
31
+ This pass focused on removing CPU spikes in later waves and reducing GPU shadow/render cost. Key changes:
32
+
33
+ - Spatial grid for tree colliders drastically reduces O(N) scans for player/enemy movement and projectile collisions.
34
+ - Enemy line-of-sight checks are throttled (~0.3s) and now use cheap math instead of `THREE.Raycaster` on hundreds of meshes.
35
+ - Foliage no longer casts shadows (still receives), and enemy meshes don’t cast shadows to shrink shadow passes.
36
+ - Shadow maps: sun maps reduced to 1024, moon and flashlight shadows disabled (big win on laptops/low-end GPUs).
37
+ - Render pixel ratio capped at 1.0 and shadow filter uses `PCFShadowMap` (cheaper than soft PCF).
38
+ - Lightweight FPS display added (top-right) to verify gains.
39
+
40
+ If you still need more headroom:
41
+
42
+ - Lower `CFG.waves.maxAlive` from 30 → 24 for fewer concurrent enemies.
43
+ - Lower `CFG.flashlight.intensity` and/or keep it off at night (press `F`).
44
+ - Reduce `CLOUDS.count` or disable clouds/mountains in `src/config.js`.
45
+ - Reduce `CFG.forestSize` or `CFG.treeCount` for fewer blockers.
46
+
47
+ Notes:
48
+
49
+ - All removed geometry from enemies/FX is properly disposed to avoid GPU memory leaks.
50
+ - The LOS heuristic also samples terrain height to prevent shooting through hills without expensive raycasts.
index.html CHANGED
@@ -152,6 +152,10 @@
152
  <div class="label">ENEMIES</div>
153
  <div class="value" id="enemies">0</div>
154
  </div>
 
 
 
 
155
  </div>
156
 
157
  <!-- Health Bottom-Left -->
 
152
  <div class="label">ENEMIES</div>
153
  <div class="value" id="enemies">0</div>
154
  </div>
155
+ <div class="mini-card">
156
+ <div class="label">FPS</div>
157
+ <div class="value" id="fps">-</div>
158
+ </div>
159
  </div>
160
 
161
  <!-- Health Bottom-Left -->
src/combat.js CHANGED
@@ -53,15 +53,17 @@ export function performShooting(delta) {
53
  G.raycaster.setFromCamera(TMP2, G.camera);
54
  G.raycaster.far = CFG.gun.range;
55
 
56
- // Build hit list without creating new arrays
57
  HIT_OBJECTS.length = 0;
58
  for (let i = 0; i < G.enemies.length; i++) {
59
  const e = G.enemies[i];
60
- if (e.alive) HIT_OBJECTS.push(e.mesh);
 
 
61
  }
62
  for (let i = 0; i < G.blockers.length; i++) HIT_OBJECTS.push(G.blockers[i]);
63
 
64
- const hits = G.raycaster.intersectObjects(HIT_OBJECTS, true);
65
 
66
  G.weapon.muzzle.getWorldPosition(TMPv1);
67
 
 
53
  G.raycaster.setFromCamera(TMP2, G.camera);
54
  G.raycaster.far = CFG.gun.range;
55
 
56
+ // Build hit list using lightweight proxies and trunk-only blockers
57
  HIT_OBJECTS.length = 0;
58
  for (let i = 0; i < G.enemies.length; i++) {
59
  const e = G.enemies[i];
60
+ if (!e.alive || !e.hitProxies) continue;
61
+ // push proxies directly; no recursion needed
62
+ for (let k = 0; k < e.hitProxies.length; k++) HIT_OBJECTS.push(e.hitProxies[k]);
63
  }
64
  for (let i = 0; i < G.blockers.length; i++) HIT_OBJECTS.push(G.blockers[i]);
65
 
66
+ const hits = G.raycaster.intersectObjects(HIT_OBJECTS, false);
67
 
68
  G.weapon.muzzle.getWorldPosition(TMPv1);
69
 
src/config.js CHANGED
@@ -61,6 +61,16 @@ export const CFG = {
61
  arrowLife: 6,
62
  arrowHitRadius: 0.6
63
  },
 
 
 
 
 
 
 
 
 
 
64
  gun: {
65
  // Faster, closer to AK full-auto feel (~600 RPM is 10 RPS)
66
  rof: 10.5,
 
61
  arrowLife: 6,
62
  arrowHitRadius: 0.6
63
  },
64
+ // Shaman-specific tuning
65
+ shaman: {
66
+ // Fireball slightly slower than orc arrow (40)
67
+ fireballSpeed: 42,
68
+ fireballDamage: 60,
69
+ fireballLife: 5,
70
+ fireballHitRadius: 0.9,
71
+ teleportCooldown: 6,
72
+ teleportDistance: 12
73
+ },
74
  gun: {
75
  // Faster, closer to AK full-auto feel (~600 RPM is 10 RPS)
76
  rof: 10.5,
src/enemies.js CHANGED
@@ -1,9 +1,9 @@
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
- import { getTerrainHeight } from './world.js';
5
- import { spawnMuzzleFlashAt, spawnDustAt } from './fx.js';
6
- import { spawnEnemyArrow } from './projectiles.js';
7
 
8
  // Reusable temps
9
  const TMPv1 = new THREE.Vector3();
@@ -28,6 +28,11 @@ const MAT = {
28
  const RIVET_MAT = new THREE.MeshStandardMaterial({ color: 0xb0b4b8, metalness: 0.8, roughness: 0.3 });
29
  const STRING_MAT = new THREE.LineBasicMaterial({ color: 0xffffff });
30
 
 
 
 
 
 
31
  function ballisticVelocity(start, target, speed, gravity, preferHigh = false) {
32
  const disp = TMPv1.subVectors(target, start);
33
  const dxz = Math.hypot(disp.x, disp.z);
@@ -47,7 +52,7 @@ function ballisticVelocity(start, target, speed, gravity, preferHigh = false) {
47
  return hdir.multiplyScalar(vxz).add(TMPv3.set(0, vy, 0));
48
  }
49
 
50
- export function spawnEnemy() {
51
  // Spawn near a single wave anchor, not around center
52
  const halfSize = CFG.forestSize / 2;
53
  const anchor = G.waves.spawnAnchor || new THREE.Vector3(
@@ -67,6 +72,139 @@ export function spawnEnemy() {
67
  if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return;
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  // Create enemy mesh (orc with cute helmet + bow)
71
  const enemyGroup = new THREE.Group();
72
 
@@ -80,7 +218,7 @@ export function spawnEnemy() {
80
  // Torso (bulky base under armor)
81
  const torso = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.1, 0.4), tunic);
82
  torso.position.set(0, 1.3, 0);
83
- torso.castShadow = true; torso.receiveShadow = true;
84
  enemyGroup.add(torso);
85
 
86
  // Silver armor: keep back plate + pauldrons; remove front plate for subtler look
@@ -88,7 +226,7 @@ export function spawnEnemy() {
88
  {
89
  const backPlate = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.58, 0.10), metal);
90
  backPlate.position.set(0, 1.34, 0.26);
91
- backPlate.castShadow = true; backPlate.receiveShadow = true;
92
  backPlate.userData = { enemy: null, hitZone: 'body' };
93
  enemyGroup.add(backPlate); armorPieces.push(backPlate);
94
 
@@ -96,21 +234,21 @@ export function spawnEnemy() {
96
  const pauldronGeo = new THREE.SphereGeometry(0.27, 12, 10);
97
  const pL = new THREE.Mesh(pauldronGeo, silver);
98
  pL.scale.y = 0.6; pL.position.set(-0.52, 1.62, -0.02);
99
- pL.castShadow = true; pL.receiveShadow = true; pL.userData = { enemy: null, hitZone: 'body' };
100
  enemyGroup.add(pL); armorPieces.push(pL);
101
  const pR = new THREE.Mesh(pauldronGeo, silver);
102
  pR.scale.y = 0.6; pR.position.set(0.52, 1.62, -0.02);
103
- pR.castShadow = true; pR.receiveShadow = true; pR.userData = { enemy: null, hitZone: 'body' };
104
  enemyGroup.add(pR); armorPieces.push(pR);
105
 
106
  // Leather belt with a simple metal buckle
107
  const belt = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.04, 8, 20), leather);
108
  belt.rotation.x = Math.PI / 2; belt.position.set(0, 1.0, 0);
109
- belt.castShadow = true; belt.receiveShadow = true; belt.userData = { enemy: null, hitZone: 'body' };
110
  enemyGroup.add(belt); armorPieces.push(belt);
111
  const buckle = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.12, 0.03), metal);
112
  buckle.position.set(0, 1.0, -0.34);
113
- buckle.castShadow = true; buckle.receiveShadow = true; buckle.userData = { enemy: null, hitZone: 'body' };
114
  enemyGroup.add(buckle); armorPieces.push(buckle);
115
 
116
  // Small rivets on back plate corners only
@@ -122,7 +260,7 @@ export function spawnEnemy() {
122
  for (const pos of rivets) {
123
  const r = new THREE.Mesh(rivetGeo, RIVET_MAT);
124
  r.position.copy(pos);
125
- r.castShadow = true; r.receiveShadow = true; r.userData = { enemy: null, hitZone: 'body' };
126
  enemyGroup.add(r); armorPieces.push(r);
127
  }
128
  }
@@ -130,13 +268,13 @@ export function spawnEnemy() {
130
  // Head (slightly larger) + cute helmet (small dome)
131
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
132
  head.position.set(0, 1.95, 0);
133
- head.castShadow = true; head.receiveShadow = true;
134
  enemyGroup.add(head);
135
 
136
  const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.33, 12, 10), metal);
137
  helmet.scale.y = 0.7;
138
  helmet.position.set(0, 2.07, 0);
139
- helmet.castShadow = true; helmet.receiveShadow = true;
140
  // Tag helmet as a head hit zone so headshots register even when helmet is hit
141
  helmet.userData = { enemy: null, hitZone: 'head', isHelmet: true };
142
  enemyGroup.add(helmet);
@@ -144,20 +282,20 @@ export function spawnEnemy() {
144
  // Arms
145
  const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
146
  armL.position.set(-0.5, 1.35, 0);
147
- armL.castShadow = true; enemyGroup.add(armL);
148
 
149
  const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
150
  armR.position.set(0.5, 1.35, 0);
151
- armR.castShadow = true; enemyGroup.add(armR);
152
 
153
  // Legs
154
  const legL = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
155
  legL.position.set(-0.22, 0.5, 0);
156
- legL.castShadow = true; enemyGroup.add(legL);
157
 
158
  const legR = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
159
  legR.position.set(0.22, 0.5, 0);
160
- legR.castShadow = true; enemyGroup.add(legR);
161
 
162
  // Bow (curved torus segment + string) held slightly forward on left side
163
  const bowGroup = new THREE.Group();
@@ -185,6 +323,16 @@ export function spawnEnemy() {
185
  enemyGroup.position.set(x, getTerrainHeight(x, z), z);
186
  G.scene.add(enemyGroup);
187
 
 
 
 
 
 
 
 
 
 
 
188
  // Tag hit zones for headshot logic
189
  torso.userData = { enemy: null, hitZone: 'body' };
190
  head.userData = { enemy: null, hitZone: 'head' };
@@ -193,8 +341,11 @@ export function spawnEnemy() {
193
  legL.userData = { enemy: null, hitZone: 'limb' };
194
  legR.userData = { enemy: null, hitZone: 'limb' };
195
  bow.userData = { enemy: null, hitZone: 'gear' };
 
 
196
 
197
  const enemy = {
 
198
  mesh: enemyGroup,
199
  body: torso,
200
  pos: enemyGroup.position,
@@ -207,7 +358,11 @@ export function spawnEnemy() {
207
  projectileSpawn,
208
  shootCooldown: 0,
209
  helmet,
210
- helmetAttached: true
 
 
 
 
211
  };
212
 
213
  enemyGroup.userData = { enemy };
@@ -219,6 +374,9 @@ export function spawnEnemy() {
219
  legR.userData.enemy = enemy;
220
  bow.userData.enemy = enemy;
221
  helmet.userData.enemy = enemy;
 
 
 
222
  // Assign enemy to armor pieces so they count as body hits
223
  for (const part of armorPieces) {
224
  if (part && part.userData) part.userData.enemy = enemy;
@@ -236,9 +394,11 @@ export function updateEnemies(delta, onPlayerDeath) {
236
  if (!enemy.alive) {
237
  // Spawn a quick dust puff at death position, then despawn
238
  spawnDustAt(enemy.pos);
 
239
  enemy.mesh.traverse((obj) => {
240
- if (obj.isMesh && obj.geometry && obj.geometry.dispose) {
241
- obj.geometry.dispose();
 
242
  }
243
  });
244
  G.scene.remove(enemy.mesh);
@@ -256,8 +416,10 @@ export function updateEnemies(delta, onPlayerDeath) {
256
  const moveSpeed = enemy.baseSpeed * delta;
257
  enemy.pos.add(dir.multiplyScalar(moveSpeed));
258
 
259
- // Simple tree avoidance
260
- for (const tree of G.treeColliders) {
 
 
261
  const dx = enemy.pos.x - tree.x;
262
  const dz = enemy.pos.z - tree.z;
263
  const treeDist = Math.sqrt(dx * dx + dz * dz);
@@ -280,38 +442,46 @@ export function updateEnemies(delta, onPlayerDeath) {
280
 
281
  // Ranged conditions
282
  if (dist < CFG.enemy.range && enemy.alive) {
283
- // LOS check against trees/ground
284
- ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
285
- TO_PLAYER.subVectors(G.player.pos, ORIGIN);
286
- const distanceToPlayer = TO_PLAYER.length();
287
- TO_PLAYER.normalize();
288
-
289
- G.raycaster.set(ORIGIN, TO_PLAYER);
290
- G.raycaster.far = distanceToPlayer;
291
- const hits = G.raycaster.intersectObjects(G.blockers, true);
292
- const hasBlocker = hits.length > 0;
293
-
294
- if (!hasBlocker && enemy.shootCooldown <= 0) {
295
-
296
- // Aim with bloom + ballistic compensation; shoot an arrow
297
- const spread = CFG.enemy.bloom;
298
- if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
299
-
300
- TARGET.set(G.player.pos.x, G.player.pos.y - 0.2, G.player.pos.z);
301
- // Add small random jitter to target for bloom
302
- TARGET.x += (G.random() - 0.5) * spread * 20;
303
- TARGET.y += (G.random() - 0.5) * spread * 8;
304
- TARGET.z += (G.random() - 0.5) * spread * 20;
305
-
306
- // If too far for this speed/gravity, don't waste a shot
307
- const dxz = Math.hypot(TARGET.x - START.x, TARGET.z - START.z);
308
- const maxRangeFlat = (CFG.enemy.arrowSpeed * CFG.enemy.arrowSpeed) / CFG.enemy.arrowGravity;
309
- if (dxz <= maxRangeFlat * 0.98) {
310
- let vel = ballisticVelocity(START, TARGET, CFG.enemy.arrowSpeed, CFG.enemy.arrowGravity, false);
311
- if (vel) {
312
- enemy.shootCooldown = 1 / CFG.enemy.rof;
313
- spawnEnemyArrow(START, vel, true);
314
- spawnMuzzleFlashAt(START, 0xffc080);
 
 
 
 
 
 
 
 
315
  }
316
  }
317
  }
@@ -329,6 +499,46 @@ export function updateEnemies(delta, onPlayerDeath) {
329
  }
330
  }
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  // Look at player
333
  enemy.mesh.lookAt(G.player.pos.x, enemy.pos.y + 1.4, G.player.pos.z);
334
  }
 
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
+ import { getTerrainHeight, getNearbyTrees, hasLineOfSight } from './world.js';
5
+ import { spawnMuzzleFlashAt, spawnDustAt, spawnPortalAt } from './fx.js';
6
+ import { spawnEnemyArrow, spawnEnemyFireball } from './projectiles.js';
7
 
8
  // Reusable temps
9
  const TMPv1 = new THREE.Vector3();
 
28
  const RIVET_MAT = new THREE.MeshStandardMaterial({ color: 0xb0b4b8, metalness: 0.8, roughness: 0.3 });
29
  const STRING_MAT = new THREE.LineBasicMaterial({ color: 0xffffff });
30
 
31
+ // Invisible low-poly hit proxies to reduce raycast CPU on shots
32
+ const PROXY_MAT = new THREE.MeshBasicMaterial({ visible: false });
33
+ const PROXY_HEAD = new THREE.SphereGeometry(0.32, 10, 8);
34
+ const PROXY_BODY = new THREE.CapsuleGeometry(0.45, 0.6, 6, 8);
35
+
36
  function ballisticVelocity(start, target, speed, gravity, preferHigh = false) {
37
  const disp = TMPv1.subVectors(target, start);
38
  const dxz = Math.hypot(disp.x, disp.z);
 
52
  return hdir.multiplyScalar(vxz).add(TMPv3.set(0, vy, 0));
53
  }
54
 
55
+ export function spawnEnemy(type = 'orc') {
56
  // Spawn near a single wave anchor, not around center
57
  const halfSize = CFG.forestSize / 2;
58
  const anchor = G.waves.spawnAnchor || new THREE.Vector3(
 
72
  if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return;
73
  }
74
 
75
+ if (type === 'shaman') {
76
+ // Create shaman: red with a cape, fires fireballs and teleports
77
+ const enemyGroup = new THREE.Group();
78
+
79
+ const skin = MAT.skin;
80
+ const robe = new THREE.MeshStandardMaterial({ color: 0x6f0c0c, roughness: 0.9 });
81
+ const capeMat = new THREE.MeshStandardMaterial({ color: 0xcc2222, roughness: 0.95 });
82
+
83
+ // Torso and head
84
+ const torso = new THREE.Mesh(new THREE.BoxGeometry(0.75, 1.15, 0.45), robe);
85
+ torso.position.set(0, 1.3, 0);
86
+ torso.castShadow = false; torso.receiveShadow = true;
87
+ enemyGroup.add(torso);
88
+
89
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
90
+ head.position.set(0, 1.95, 0);
91
+ head.castShadow = false; head.receiveShadow = true;
92
+ enemyGroup.add(head);
93
+
94
+ // Hood (larger dome over head, dark red)
95
+ const hoodMat = new THREE.MeshStandardMaterial({ color: 0x4d0909, roughness: 0.95 });
96
+ const hood = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), hoodMat);
97
+ hood.scale.y = 0.7; hood.position.set(0, 2.03, 0);
98
+ hood.castShadow = false; hood.receiveShadow = true;
99
+ enemyGroup.add(hood);
100
+
101
+ // Glowing eyes
102
+ const eyeMat = new THREE.MeshStandardMaterial({ color: 0x550000, emissive: 0xff2200, emissiveIntensity: 1.2 });
103
+ const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), eyeMat);
104
+ eyeL.position.set(-0.1, 1.98, -0.25);
105
+ const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), eyeMat);
106
+ eyeR.position.set(0.1, 1.98, -0.25);
107
+ enemyGroup.add(eyeL); enemyGroup.add(eyeR);
108
+
109
+ // Simple arms
110
+ const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), robe);
111
+ armL.position.set(-0.5, 1.35, 0);
112
+ armL.castShadow = false; enemyGroup.add(armL);
113
+ const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), robe);
114
+ armR.position.set(0.5, 1.35, 0);
115
+ armR.castShadow = false; enemyGroup.add(armR);
116
+
117
+ // Legs
118
+ const legL = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.75, 0.24), robe);
119
+ legL.position.set(-0.22, 0.5, 0);
120
+ legL.castShadow = false; enemyGroup.add(legL);
121
+ const legR = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.75, 0.24), robe);
122
+ legR.position.set(0.22, 0.5, 0);
123
+ legR.castShadow = false; enemyGroup.add(legR);
124
+
125
+ // Cape (thin panel on back)
126
+ const cape = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.3, 0.04), capeMat);
127
+ cape.position.set(0, 1.18, 0.30);
128
+ cape.castShadow = false; cape.receiveShadow = true;
129
+ enemyGroup.add(cape);
130
+
131
+ // Staff held forward in right hand with glowing tip
132
+ const staffGroup = new THREE.Group();
133
+ const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 1.6, 8), new THREE.MeshStandardMaterial({ color: 0x3b2b1b, roughness: 0.9 }));
134
+ staff.position.set(0, 0.8, 0);
135
+ const orb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 10), new THREE.MeshStandardMaterial({ color: 0xff4a1d, emissive: 0xff2200, emissiveIntensity: 1.5 }));
136
+ orb.position.set(0, 1.6 * 0.5 + 0.16, 0);
137
+ staffGroup.add(staff);
138
+ staffGroup.add(orb);
139
+ staffGroup.position.set(0.42, 1.2, -0.25);
140
+ staffGroup.rotation.x = -0.3; staffGroup.rotation.y = 0.1;
141
+ enemyGroup.add(staffGroup);
142
+
143
+ // Fireball spawn point at the orb tip
144
+ const projectileSpawn = new THREE.Object3D();
145
+ projectileSpawn.position.set(0, 1.6 * 0.5 + 0.16, 0);
146
+ staffGroup.add(projectileSpawn);
147
+
148
+ enemyGroup.position.set(x, getTerrainHeight(x, z), z);
149
+ G.scene.add(enemyGroup);
150
+
151
+ // Invisible hit proxies
152
+ const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
153
+ proxyHead.position.set(0, 1.95, 0);
154
+ proxyHead.userData = { enemy: null, hitZone: 'head' };
155
+ enemyGroup.add(proxyHead);
156
+ const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
157
+ proxyBody.position.set(0, 1.3, 0);
158
+ proxyBody.userData = { enemy: null, hitZone: 'body' };
159
+ enemyGroup.add(proxyBody);
160
+
161
+ // Tag (only proxies are raycasted)
162
+ torso.userData = { enemy: null, hitZone: 'body' };
163
+ head.userData = { enemy: null, hitZone: 'head' };
164
+ armL.userData = { enemy: null, hitZone: 'limb' };
165
+ armR.userData = { enemy: null, hitZone: 'limb' };
166
+ legL.userData = { enemy: null, hitZone: 'limb' };
167
+ legR.userData = { enemy: null, hitZone: 'limb' };
168
+
169
+ const enemy = {
170
+ type: 'shaman',
171
+ mesh: enemyGroup,
172
+ body: torso,
173
+ pos: enemyGroup.position,
174
+ radius: CFG.enemy.radius,
175
+ hp: CFG.enemy.hp,
176
+ baseSpeed: CFG.enemy.baseSpeed + CFG.enemy.speedPerWave * (G.waves.current - 1),
177
+ damagePerSecond: CFG.enemy.dps,
178
+ alive: true,
179
+ deathTimer: 0,
180
+ projectileSpawn,
181
+ shootCooldown: 0,
182
+ helmet: null,
183
+ helmetAttached: false,
184
+ // LOS throttling
185
+ losTimer: 0,
186
+ hasLOS: true,
187
+ hitProxies: [],
188
+ // Teleport ability
189
+ teleTimer: CFG.shaman.teleportCooldown * (0.6 + G.random() * 0.8)
190
+ };
191
+
192
+ enemyGroup.userData = { enemy };
193
+ torso.userData.enemy = enemy;
194
+ head.userData.enemy = enemy;
195
+ armL.userData.enemy = enemy;
196
+ armR.userData.enemy = enemy;
197
+ legL.userData.enemy = enemy;
198
+ legR.userData.enemy = enemy;
199
+ proxyHead.userData.enemy = enemy;
200
+ proxyBody.userData.enemy = enemy;
201
+ enemy.hitProxies.push(proxyBody, proxyHead);
202
+
203
+ G.enemies.push(enemy);
204
+ G.waves.aliveCount++;
205
+ return;
206
+ }
207
+
208
  // Create enemy mesh (orc with cute helmet + bow)
209
  const enemyGroup = new THREE.Group();
210
 
 
218
  // Torso (bulky base under armor)
219
  const torso = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.1, 0.4), tunic);
220
  torso.position.set(0, 1.3, 0);
221
+ torso.castShadow = false; torso.receiveShadow = true;
222
  enemyGroup.add(torso);
223
 
224
  // Silver armor: keep back plate + pauldrons; remove front plate for subtler look
 
226
  {
227
  const backPlate = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.58, 0.10), metal);
228
  backPlate.position.set(0, 1.34, 0.26);
229
+ backPlate.castShadow = false; backPlate.receiveShadow = true;
230
  backPlate.userData = { enemy: null, hitZone: 'body' };
231
  enemyGroup.add(backPlate); armorPieces.push(backPlate);
232
 
 
234
  const pauldronGeo = new THREE.SphereGeometry(0.27, 12, 10);
235
  const pL = new THREE.Mesh(pauldronGeo, silver);
236
  pL.scale.y = 0.6; pL.position.set(-0.52, 1.62, -0.02);
237
+ pL.castShadow = false; pL.receiveShadow = true; pL.userData = { enemy: null, hitZone: 'body' };
238
  enemyGroup.add(pL); armorPieces.push(pL);
239
  const pR = new THREE.Mesh(pauldronGeo, silver);
240
  pR.scale.y = 0.6; pR.position.set(0.52, 1.62, -0.02);
241
+ pR.castShadow = false; pR.receiveShadow = true; pR.userData = { enemy: null, hitZone: 'body' };
242
  enemyGroup.add(pR); armorPieces.push(pR);
243
 
244
  // Leather belt with a simple metal buckle
245
  const belt = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.04, 8, 20), leather);
246
  belt.rotation.x = Math.PI / 2; belt.position.set(0, 1.0, 0);
247
+ belt.castShadow = false; belt.receiveShadow = true; belt.userData = { enemy: null, hitZone: 'body' };
248
  enemyGroup.add(belt); armorPieces.push(belt);
249
  const buckle = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.12, 0.03), metal);
250
  buckle.position.set(0, 1.0, -0.34);
251
+ buckle.castShadow = false; buckle.receiveShadow = true; buckle.userData = { enemy: null, hitZone: 'body' };
252
  enemyGroup.add(buckle); armorPieces.push(buckle);
253
 
254
  // Small rivets on back plate corners only
 
260
  for (const pos of rivets) {
261
  const r = new THREE.Mesh(rivetGeo, RIVET_MAT);
262
  r.position.copy(pos);
263
+ r.castShadow = false; r.receiveShadow = true; r.userData = { enemy: null, hitZone: 'body' };
264
  enemyGroup.add(r); armorPieces.push(r);
265
  }
266
  }
 
268
  // Head (slightly larger) + cute helmet (small dome)
269
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
270
  head.position.set(0, 1.95, 0);
271
+ head.castShadow = false; head.receiveShadow = true;
272
  enemyGroup.add(head);
273
 
274
  const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.33, 12, 10), metal);
275
  helmet.scale.y = 0.7;
276
  helmet.position.set(0, 2.07, 0);
277
+ helmet.castShadow = false; helmet.receiveShadow = true;
278
  // Tag helmet as a head hit zone so headshots register even when helmet is hit
279
  helmet.userData = { enemy: null, hitZone: 'head', isHelmet: true };
280
  enemyGroup.add(helmet);
 
282
  // Arms
283
  const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
284
  armL.position.set(-0.5, 1.35, 0);
285
+ armL.castShadow = false; enemyGroup.add(armL);
286
 
287
  const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
288
  armR.position.set(0.5, 1.35, 0);
289
+ armR.castShadow = false; enemyGroup.add(armR);
290
 
291
  // Legs
292
  const legL = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
293
  legL.position.set(-0.22, 0.5, 0);
294
+ legL.castShadow = false; enemyGroup.add(legL);
295
 
296
  const legR = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
297
  legR.position.set(0.22, 0.5, 0);
298
+ legR.castShadow = false; enemyGroup.add(legR);
299
 
300
  // Bow (curved torus segment + string) held slightly forward on left side
301
  const bowGroup = new THREE.Group();
 
323
  enemyGroup.position.set(x, getTerrainHeight(x, z), z);
324
  G.scene.add(enemyGroup);
325
 
326
+ // Add invisible hit proxies (head + body) for fast ray hits
327
+ const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
328
+ proxyHead.position.set(0, 1.95, 0);
329
+ proxyHead.userData = { enemy: null, hitZone: 'head' };
330
+ enemyGroup.add(proxyHead);
331
+ const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
332
+ proxyBody.position.set(0, 1.3, 0);
333
+ proxyBody.userData = { enemy: null, hitZone: 'body' };
334
+ enemyGroup.add(proxyBody);
335
+
336
  // Tag hit zones for headshot logic
337
  torso.userData = { enemy: null, hitZone: 'body' };
338
  head.userData = { enemy: null, hitZone: 'head' };
 
341
  legL.userData = { enemy: null, hitZone: 'limb' };
342
  legR.userData = { enemy: null, hitZone: 'limb' };
343
  bow.userData = { enemy: null, hitZone: 'gear' };
344
+ proxyHead.userData.enemy = null; // will assign below
345
+ proxyBody.userData.enemy = null;
346
 
347
  const enemy = {
348
+ type: 'orc',
349
  mesh: enemyGroup,
350
  body: torso,
351
  pos: enemyGroup.position,
 
358
  projectileSpawn,
359
  shootCooldown: 0,
360
  helmet,
361
+ helmetAttached: true,
362
+ // LOS throttling to avoid per-frame raycasting
363
+ losTimer: 0,
364
+ hasLOS: true,
365
+ hitProxies: []
366
  };
367
 
368
  enemyGroup.userData = { enemy };
 
374
  legR.userData.enemy = enemy;
375
  bow.userData.enemy = enemy;
376
  helmet.userData.enemy = enemy;
377
+ proxyHead.userData.enemy = enemy;
378
+ proxyBody.userData.enemy = enemy;
379
+ enemy.hitProxies.push(proxyBody, proxyHead);
380
  // Assign enemy to armor pieces so they count as body hits
381
  for (const part of armorPieces) {
382
  if (part && part.userData) part.userData.enemy = enemy;
 
394
  if (!enemy.alive) {
395
  // Spawn a quick dust puff at death position, then despawn
396
  spawnDustAt(enemy.pos);
397
+ // Defer geometry disposal to avoid frame spikes
398
  enemy.mesh.traverse((obj) => {
399
+ if (obj.isMesh && obj.geometry) {
400
+ G.disposeQueue.push(obj.geometry);
401
+ obj.geometry = null;
402
  }
403
  });
404
  G.scene.remove(enemy.mesh);
 
416
  const moveSpeed = enemy.baseSpeed * delta;
417
  enemy.pos.add(dir.multiplyScalar(moveSpeed));
418
 
419
+ // Simple tree avoidance (use spatial grid)
420
+ const nearby = getNearbyTrees(enemy.pos.x, enemy.pos.z, 4);
421
+ for (let ti = 0; ti < nearby.length; ti++) {
422
+ const tree = nearby[ti];
423
  const dx = enemy.pos.x - tree.x;
424
  const dz = enemy.pos.z - tree.z;
425
  const treeDist = Math.sqrt(dx * dx + dz * dz);
 
442
 
443
  // Ranged conditions
444
  if (dist < CFG.enemy.range && enemy.alive) {
445
+ // Throttled line-of-sight check using fast approximations
446
+ enemy.losTimer -= delta;
447
+ if (enemy.losTimer <= 0) {
448
+ ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
449
+ enemy.hasLOS = hasLineOfSight(ORIGIN, G.player.pos);
450
+ enemy.losTimer = 0.28 + G.random() * 0.18;
451
+ }
452
+
453
+ if (enemy.hasLOS && enemy.shootCooldown <= 0) {
454
+ // Ensure world matrices reflect current enemy position before sampling spawn
455
+ enemy.mesh.updateMatrixWorld(true);
456
+ if (enemy.type === 'shaman') {
457
+ // Fireball: aim directly toward camera position (robust), no wobble
458
+ if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
459
+ TARGET.copy(G.camera.position);
460
+ const dir = TMPv2.subVectors(TARGET, START).normalize();
461
+ enemy.shootCooldown = 1 / CFG.enemy.rof;
462
+ spawnEnemyFireball(START, dir, false);
463
+ spawnMuzzleFlashAt(START, 0xff5a22);
464
+ } else {
465
+ // Archer orc: ballistic arrow
466
+ const spread = CFG.enemy.bloom;
467
+ if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
468
+
469
+ TARGET.set(G.player.pos.x, G.player.pos.y - 0.2, G.player.pos.z);
470
+ // Add small random jitter to target for bloom
471
+ TARGET.x += (G.random() - 0.5) * spread * 20;
472
+ TARGET.y += (G.random() - 0.5) * spread * 8;
473
+ TARGET.z += (G.random() - 0.5) * spread * 20;
474
+
475
+ // If too far for this speed/gravity, don't waste a shot
476
+ const dxz = Math.hypot(TARGET.x - START.x, TARGET.z - START.z);
477
+ const maxRangeFlat = (CFG.enemy.arrowSpeed * CFG.enemy.arrowSpeed) / CFG.enemy.arrowGravity;
478
+ if (dxz <= maxRangeFlat * 0.98) {
479
+ let vel = ballisticVelocity(START, TARGET, CFG.enemy.arrowSpeed, CFG.enemy.arrowGravity, false);
480
+ if (vel) {
481
+ enemy.shootCooldown = 1 / CFG.enemy.rof;
482
+ spawnEnemyArrow(START, vel, true);
483
+ spawnMuzzleFlashAt(START, 0xffc080);
484
+ }
485
  }
486
  }
487
  }
 
499
  }
500
  }
501
 
502
+ // Shaman teleport ability (periodic)
503
+ if (enemy.type === 'shaman') {
504
+ enemy.teleTimer -= delta;
505
+ if (enemy.teleTimer <= 0) {
506
+ enemy.teleTimer = CFG.shaman.teleportCooldown * (0.85 + G.random() * 0.3);
507
+ // Attempt to teleport closer towards player by ~distance
508
+ const dirTo = TO_PLAYER.copy(G.player.pos).sub(enemy.pos);
509
+ dirTo.y = 0;
510
+ const dlen = dirTo.length();
511
+ if (dlen > 1) {
512
+ dirTo.normalize();
513
+ let tdist = CFG.shaman.teleportDistance;
514
+ // Don't teleport into the player's radius
515
+ if (dlen - tdist < (G.player.radius + enemy.radius + 2)) {
516
+ tdist = Math.max(0, dlen - (G.player.radius + enemy.radius + 2));
517
+ }
518
+ const nx = enemy.pos.x + dirTo.x * tdist;
519
+ const nz = enemy.pos.z + dirTo.z * tdist;
520
+ // Simple tree clash check
521
+ const trees = getNearbyTrees(nx, nz, 3);
522
+ let blocked = false;
523
+ for (let ti = 0; ti < trees.length; ti++) {
524
+ const tr = trees[ti];
525
+ const dx = nx - tr.x; const dz = nz - tr.z;
526
+ const rr = tr.radius + enemy.radius * 0.8;
527
+ if (dx * dx + dz * dz < rr * rr) { blocked = true; break; }
528
+ }
529
+ if (!blocked) {
530
+ // Portal and dust at departure
531
+ spawnPortalAt(enemy.pos, 0xff5522, 1.0, 0.32);
532
+ spawnDustAt(enemy.pos, 0x9c3322, 0.7, 0.18);
533
+ enemy.pos.set(nx, getTerrainHeight(nx, nz), nz);
534
+ // Portal and dust at arrival
535
+ spawnPortalAt(enemy.pos, 0xff5522, 1.1, 0.36);
536
+ spawnDustAt(enemy.pos, 0xcc4422, 0.9, 0.22);
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
  // Look at player
543
  enemy.mesh.lookAt(G.player.pos.x, enemy.pos.y + 1.4, G.player.pos.z);
544
  }
src/fx.js CHANGED
@@ -81,6 +81,30 @@ export function spawnDustAt(worldPos, color = 0xcdbf9e, size = 0.55, life = 0.14
81
  G.fx.dusts.push({ mesh: quad, life, maxLife: life });
82
  }
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  export function updateFX(delta) {
85
  for (let i = G.fx.tracers.length - 1; i >= 0; i--) {
86
  const t = G.fx.tracers[i];
@@ -134,4 +158,29 @@ export function updateFX(delta) {
134
  G.fx.dusts.splice(i, 1);
135
  }
136
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
 
81
  G.fx.dusts.push({ mesh: quad, life, maxLife: life });
82
  }
83
 
84
+ // Portal effect: additive ring that grows and fades, plus soft light
85
+ export function spawnPortalAt(worldPos, color = 0xff5522, size = 1.1, life = 0.35) {
86
+ const ring = new THREE.Mesh(
87
+ new THREE.TorusGeometry(size * 0.35, size * 0.08, 12, 28),
88
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false })
89
+ );
90
+ ring.position.copy(worldPos);
91
+ ring.rotation.x = Math.PI / 2;
92
+ ring.renderOrder = 12;
93
+ const flare = new THREE.Mesh(
94
+ new THREE.PlaneGeometry(size * 0.9, size * 0.9),
95
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false })
96
+ );
97
+ flare.position.copy(worldPos);
98
+ flare.lookAt(G.camera.position);
99
+ flare.renderOrder = 12;
100
+ const light = new THREE.PointLight(color, 5, size * 6, 2);
101
+ light.position.copy(worldPos);
102
+ G.scene.add(ring);
103
+ G.scene.add(flare);
104
+ G.scene.add(light);
105
+ G.fx.portals.push({ ring, flare, light, life, maxLife: life, rot: Math.random() * Math.PI * 2 });
106
+ }
107
+
108
  export function updateFX(delta) {
109
  for (let i = G.fx.tracers.length - 1; i >= 0; i--) {
110
  const t = G.fx.tracers[i];
 
158
  G.fx.dusts.splice(i, 1);
159
  }
160
  }
161
+ // Portals
162
+ for (let i = G.fx.portals.length - 1; i >= 0; i--) {
163
+ const p = G.fx.portals[i];
164
+ p.life -= delta;
165
+ const t = Math.max(0, p.life / p.maxLife);
166
+ const s = 1 + (1 - t) * 1.6;
167
+ p.rot += delta * 4;
168
+ p.ring.rotation.z = p.rot;
169
+ p.ring.material.opacity = 0.25 + 0.7 * t;
170
+ p.ring.scale.setScalar(s);
171
+ p.flare.material.opacity = 0.15 + 0.45 * t;
172
+ p.flare.scale.setScalar(s * 1.2);
173
+ if (p.light) p.light.intensity = 2 + 8 * t;
174
+ p.flare.lookAt(G.camera.position);
175
+ if (p.life <= 0) {
176
+ G.scene.remove(p.ring);
177
+ G.scene.remove(p.flare);
178
+ if (p.light) G.scene.remove(p.light);
179
+ p.ring.geometry.dispose();
180
+ p.flare.geometry.dispose();
181
+ if (p.ring.material && p.ring.material.dispose) p.ring.material.dispose();
182
+ if (p.flare.material && p.flare.material.dispose) p.flare.material.dispose();
183
+ G.fx.portals.splice(i, 1);
184
+ }
185
+ }
186
  }
src/globals.js CHANGED
@@ -38,6 +38,10 @@ export const G = {
38
  enemies: [],
39
  treeColliders: [],
40
  treeMeshes: [],
 
 
 
 
41
  // Static blockers array for raycasting (ground + trees)
42
  blockers: [],
43
 
@@ -69,7 +73,7 @@ export const G = {
69
  appliedYaw: 0
70
  },
71
 
72
- fx: { tracers: [], impacts: [], flashes: [], dusts: [] },
73
  enemyProjectiles: [],
74
  // Health orbs and other pickups
75
  orbs: [],
@@ -79,6 +83,8 @@ export const G = {
79
  helmets: [],
80
  damageFlash: 0,
81
  healFlash: 0,
 
 
82
 
83
  waves: {
84
  current: 1,
@@ -87,7 +93,8 @@ export const G = {
87
  nextSpawnTimer: 0,
88
  breakTimer: 0,
89
  inBreak: false,
90
- spawnAnchor: null
 
91
  },
92
 
93
  random: null,
 
38
  enemies: [],
39
  treeColliders: [],
40
  treeMeshes: [],
41
+ // Subset of meshes used for bullet blocking (trunks only)
42
+ treeTrunks: [],
43
+ // Spatial index for trees to accelerate queries
44
+ treeGrid: null,
45
  // Static blockers array for raycasting (ground + trees)
46
  blockers: [],
47
 
 
73
  appliedYaw: 0
74
  },
75
 
76
+ fx: { tracers: [], impacts: [], flashes: [], dusts: [], portals: [] },
77
  enemyProjectiles: [],
78
  // Health orbs and other pickups
79
  orbs: [],
 
83
  helmets: [],
84
  damageFlash: 0,
85
  healFlash: 0,
86
+ // Deferred GPU resource disposal queue to avoid frame spikes
87
+ disposeQueue: [],
88
 
89
  waves: {
90
  current: 1,
 
93
  nextSpawnTimer: 0,
94
  breakTimer: 0,
95
  inBreak: false,
96
+ spawnAnchor: null,
97
+ shamansToSpawn: 0
98
  },
99
 
100
  random: null,
src/lighting.js CHANGED
@@ -38,8 +38,8 @@ export function setupLights() {
38
  sun.shadow.camera.bottom = -60;
39
  sun.shadow.camera.near = 0.1;
40
  sun.shadow.camera.far = 240;
41
- sun.shadow.mapSize.width = 2048;
42
- sun.shadow.mapSize.height = 2048;
43
  sun.target = new THREE.Object3D();
44
  G.scene.add(sun);
45
  G.scene.add(sun.target);
@@ -48,15 +48,8 @@ export function setupLights() {
48
  // Moon light (key during night)
49
  const moon = new THREE.DirectionalLight(0x6a8fc5, 0.8);
50
  moon.position.set(50, 80, -50);
51
- moon.castShadow = true;
52
- moon.shadow.camera.left = -60;
53
- moon.shadow.camera.right = 60;
54
- moon.shadow.camera.top = 60;
55
- moon.shadow.camera.bottom = -60;
56
- moon.shadow.camera.near = 0.1;
57
- moon.shadow.camera.far = 240;
58
- moon.shadow.mapSize.width = 2048;
59
- moon.shadow.mapSize.height = 2048;
60
  moon.target = new THREE.Object3D();
61
  G.scene.add(moon);
62
  G.scene.add(moon.target);
@@ -86,9 +79,8 @@ export function setupLights() {
86
  flashlight.penumbra = 0.2;
87
  flashlight.distance = CFG.flashlight.distance;
88
  flashlight.decay = 1.5;
89
- flashlight.castShadow = true;
90
- flashlight.shadow.mapSize.width = 2048;
91
- flashlight.shadow.mapSize.height = 2048;
92
  flashlight.shadow.camera.near = 0.1;
93
  flashlight.shadow.camera.far = CFG.flashlight.distance;
94
  flashlight.visible = CFG.flashlight.on;
 
38
  sun.shadow.camera.bottom = -60;
39
  sun.shadow.camera.near = 0.1;
40
  sun.shadow.camera.far = 240;
41
+ sun.shadow.mapSize.width = 1024;
42
+ sun.shadow.mapSize.height = 1024;
43
  sun.target = new THREE.Object3D();
44
  G.scene.add(sun);
45
  G.scene.add(sun.target);
 
48
  // Moon light (key during night)
49
  const moon = new THREE.DirectionalLight(0x6a8fc5, 0.8);
50
  moon.position.set(50, 80, -50);
51
+ // Disable moon shadows to reduce shadow pass cost
52
+ moon.castShadow = false;
 
 
 
 
 
 
 
53
  moon.target = new THREE.Object3D();
54
  G.scene.add(moon);
55
  G.scene.add(moon.target);
 
79
  flashlight.penumbra = 0.2;
80
  flashlight.distance = CFG.flashlight.distance;
81
  flashlight.decay = 1.5;
82
+ // Flashlight shadowing is expensive; disable for performance
83
+ flashlight.castShadow = false;
 
84
  flashlight.shadow.camera.near = 0.1;
85
  flashlight.shadow.camera.far = CFG.flashlight.distance;
86
  flashlight.visible = CFG.flashlight.on;
src/main.js CHANGED
@@ -28,9 +28,11 @@ function init() {
28
  // Renderer
29
  G.renderer = new THREE.WebGLRenderer({ antialias: true });
30
  G.renderer.setSize(window.innerWidth, window.innerHeight);
31
- G.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
 
32
  G.renderer.shadowMap.enabled = true;
33
- G.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
 
34
  document.body.appendChild(G.renderer.domElement);
35
 
36
  // Scene
@@ -199,4 +201,25 @@ function animate() {
199
  tickForest(G.clock.elapsedTime);
200
 
201
  G.renderer.render(G.scene, G.camera);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  }
 
28
  // Renderer
29
  G.renderer = new THREE.WebGLRenderer({ antialias: true });
30
  G.renderer.setSize(window.innerWidth, window.innerHeight);
31
+ // Lower pixel ratio cap for significant fill-rate savings
32
+ G.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.0));
33
  G.renderer.shadowMap.enabled = true;
34
+ // Slightly cheaper shadow filter
35
+ G.renderer.shadowMap.type = THREE.PCFShadowMap;
36
  document.body.appendChild(G.renderer.domElement);
37
 
38
  // Scene
 
201
  tickForest(G.clock.elapsedTime);
202
 
203
  G.renderer.render(G.scene, G.camera);
204
+
205
+ // Lightweight FPS meter (updates ~2x/sec)
206
+ if (!G._fpsAccum) { G._fpsAccum = 0; G._fpsFrames = 0; G._fpsNext = 0.5; }
207
+ G._fpsAccum += delta; G._fpsFrames++;
208
+ if (G._fpsAccum >= G._fpsNext) {
209
+ const fps = Math.round(G._fpsFrames / G._fpsAccum);
210
+ const el = document.getElementById('fps');
211
+ if (el) el.textContent = String(fps);
212
+ G._fpsAccum = 0; G._fpsFrames = 0;
213
+ }
214
+
215
+ // Process a small budget of deferred disposals to avoid spikes
216
+ if (G.disposeQueue && G.disposeQueue.length) {
217
+ const budget = 24; // dispose up to N geometries per frame
218
+ for (let i = 0; i < budget && G.disposeQueue.length; i++) {
219
+ const geom = G.disposeQueue.pop();
220
+ if (geom && geom.dispose) {
221
+ try { geom.dispose(); } catch (e) { /* noop */ }
222
+ }
223
+ }
224
+ }
225
  }
src/pickups.js CHANGED
@@ -3,7 +3,7 @@ import { G } from './globals.js';
3
  import { CFG } from './config.js';
4
  import { getTerrainHeight } from './world.js';
5
 
6
- // Share orb material to avoid per-orb material allocations
7
  const ORB_MAT = new THREE.MeshStandardMaterial({
8
  color: 0x33ff66,
9
  emissive: 0x1faa4e,
@@ -11,6 +11,7 @@ const ORB_MAT = new THREE.MeshStandardMaterial({
11
  roughness: 0.3,
12
  metalness: 0.0
13
  });
 
14
 
15
  // Spawns N small glowing green health orbs around a position
16
  export function spawnHealthOrbs(center, count) {
@@ -28,7 +29,7 @@ export function spawnHealthOrbs(center, count) {
28
  center.z + Math.sin(t) * r
29
  );
30
 
31
- const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12, 14, 12), ORB_MAT);
32
  sphere.castShadow = true;
33
  sphere.receiveShadow = false;
34
  group.add(sphere);
 
3
  import { CFG } from './config.js';
4
  import { getTerrainHeight } from './world.js';
5
 
6
+ // Share orb material/geometry to avoid per-orb allocations
7
  const ORB_MAT = new THREE.MeshStandardMaterial({
8
  color: 0x33ff66,
9
  emissive: 0x1faa4e,
 
11
  roughness: 0.3,
12
  metalness: 0.0
13
  });
14
+ const ORB_GEO = new THREE.SphereGeometry(0.12, 14, 12);
15
 
16
  // Spawns N small glowing green health orbs around a position
17
  export function spawnHealthOrbs(center, count) {
 
29
  center.z + Math.sin(t) * r
30
  );
31
 
32
+ const sphere = new THREE.Mesh(ORB_GEO, ORB_MAT);
33
  sphere.castShadow = true;
34
  sphere.receiveShadow = false;
35
  group.add(sphere);
src/player.js CHANGED
@@ -1,12 +1,13 @@
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
- import { getTerrainHeight } from './world.js';
5
 
6
  const MOVE = new THREE.Vector3();
7
  const FWD = new THREE.Vector3();
8
  const RIGHT = new THREE.Vector3();
9
  const NEXT = new THREE.Vector3();
 
10
 
11
  export function updatePlayer(delta) {
12
  if (!G.player.alive) return;
@@ -34,13 +35,14 @@ export function updatePlayer(delta) {
34
  // Apply movement with collision
35
  NEXT.copy(G.player.pos).add(MOVE);
36
 
37
- // Tree collisions
38
- for (const tree of G.treeColliders) {
 
 
39
  const dx = NEXT.x - tree.x;
40
  const dz = NEXT.z - tree.z;
41
  const dist = Math.sqrt(dx * dx + dz * dz);
42
  const minDist = G.player.radius + tree.radius;
43
-
44
  if (dist < minDist && dist > 0) {
45
  const pushX = (dx / dist) * (minDist - dist);
46
  const pushZ = (dz / dist) * (minDist - dist);
@@ -68,6 +70,13 @@ export function updatePlayer(delta) {
68
  G.player.grounded = false;
69
  }
70
 
 
 
71
  G.player.pos.copy(NEXT);
 
 
 
 
 
72
  G.camera.position.copy(G.player.pos);
73
  }
 
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
+ import { getTerrainHeight, getNearbyTrees } from './world.js';
5
 
6
  const MOVE = new THREE.Vector3();
7
  const FWD = new THREE.Vector3();
8
  const RIGHT = new THREE.Vector3();
9
  const NEXT = new THREE.Vector3();
10
+ const PREV = new THREE.Vector3();
11
 
12
  export function updatePlayer(delta) {
13
  if (!G.player.alive) return;
 
35
  // Apply movement with collision
36
  NEXT.copy(G.player.pos).add(MOVE);
37
 
38
+ // Tree collisions (use spatial grid)
39
+ const nearTrees = getNearbyTrees(NEXT.x, NEXT.z, 3.5);
40
+ for (let i = 0; i < nearTrees.length; i++) {
41
+ const tree = nearTrees[i];
42
  const dx = NEXT.x - tree.x;
43
  const dz = NEXT.z - tree.z;
44
  const dist = Math.sqrt(dx * dx + dz * dz);
45
  const minDist = G.player.radius + tree.radius;
 
46
  if (dist < minDist && dist > 0) {
47
  const pushX = (dx / dist) * (minDist - dist);
48
  const pushZ = (dz / dist) * (minDist - dist);
 
70
  G.player.grounded = false;
71
  }
72
 
73
+ // Update velocity estimate (units/sec)
74
+ PREV.copy(G.player.pos);
75
  G.player.pos.copy(NEXT);
76
+ if (delta > 0) {
77
+ G.player.vel.copy(G.player.pos).sub(PREV).multiplyScalar(1 / delta);
78
+ // Ignore vertical component for prediction stability
79
+ G.player.vel.y = 0;
80
+ }
81
  G.camera.position.copy(G.player.pos);
82
  }
src/projectiles.js CHANGED
@@ -1,8 +1,8 @@
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
- import { getTerrainHeight } from './world.js';
5
- import { spawnImpact } from './fx.js';
6
 
7
  // Shared arrow geometry/material to avoid per-shot allocations
8
  const ARROW = (() => {
@@ -13,6 +13,17 @@ const ARROW = (() => {
13
  return { shaftGeo, headGeo, shaftMat, headMat };
14
  })();
15
 
 
 
 
 
 
 
 
 
 
 
 
16
  const UP = new THREE.Vector3(0, 1, 0);
17
  const TMPv = new THREE.Vector3();
18
  const TMPq = new THREE.Quaternion();
@@ -45,6 +56,7 @@ export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) {
45
  : TMPv.copy(dirOrVel).normalize().multiplyScalar(speed);
46
 
47
  const projectile = {
 
48
  mesh: group,
49
  pos: group.position,
50
  vel,
@@ -54,15 +66,61 @@ export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) {
54
  G.enemyProjectiles.push(projectile);
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  export function updateEnemyProjectiles(delta, onPlayerDeath) {
58
  const gravity = CFG.enemy.arrowGravity;
59
- const hitR = CFG.enemy.arrowHitRadius;
60
 
61
  for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) {
62
  const p = G.enemyProjectiles[i];
63
 
64
- // Integrate
65
- p.vel.y -= gravity * delta;
66
  p.pos.addScaledVector(p.vel, delta);
67
 
68
  // Re-orient to velocity
@@ -70,26 +128,39 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
70
  TMPq.setFromUnitVectors(UP, vdir);
71
  p.mesh.quaternion.copy(TMPq);
72
 
 
 
 
 
 
 
 
 
 
73
  p.life -= delta;
74
 
75
- // Ground hit against terrain
76
  const gy = getTerrainHeight(p.pos.x, p.pos.z);
77
  if (p.pos.y <= gy) {
78
  TMPv.set(p.pos.x, gy + 0.02, p.pos.z);
79
  spawnImpact(TMPv, UP);
 
80
  G.scene.remove(p.mesh);
81
  G.enemyProjectiles.splice(i, 1);
82
  continue;
83
  }
84
 
85
- // Tree collision (2D cylinder test)
86
- for (const tree of G.treeColliders) {
 
 
87
  const dx = p.pos.x - tree.x;
88
  const dz = p.pos.z - tree.z;
89
  const dist2 = dx * dx + dz * dz;
90
  const r = tree.radius + 0.2; // small allowance for arrow
91
  if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish
92
  spawnImpact(p.pos, UP);
 
93
  G.scene.remove(p.mesh);
94
  G.enemyProjectiles.splice(i, 1);
95
  continue;
@@ -97,9 +168,10 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
97
  }
98
 
99
  // Player collision (sphere)
 
100
  const pr = hitR + G.player.radius * 0.6; // slightly generous
101
  if (p.pos.distanceTo(G.player.pos) < pr) {
102
- const dmg = CFG.enemy.arrowDamage;
103
  G.player.health -= dmg;
104
  G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP);
105
  if (G.player.health <= 0 && G.player.alive) {
@@ -108,6 +180,7 @@ export function updateEnemyProjectiles(delta, onPlayerDeath) {
108
  if (onPlayerDeath) onPlayerDeath();
109
  }
110
  spawnImpact(p.pos, UP);
 
111
  G.scene.remove(p.mesh);
112
  G.enemyProjectiles.splice(i, 1);
113
  continue;
 
1
  import * as THREE from 'three';
2
  import { CFG } from './config.js';
3
  import { G } from './globals.js';
4
+ import { getTerrainHeight, getNearbyTrees } from './world.js';
5
+ import { spawnImpact, spawnMuzzleFlashAt } from './fx.js';
6
 
7
  // Shared arrow geometry/material to avoid per-shot allocations
8
  const ARROW = (() => {
 
13
  return { shaftGeo, headGeo, shaftMat, headMat };
14
  })();
15
 
16
+ // Shared fireball geometry/material (core + outer glow)
17
+ const FIREBALL = (() => {
18
+ const coreGeo = new THREE.SphereGeometry(0.22, 16, 12);
19
+ const coreMat = new THREE.MeshStandardMaterial({ color: 0xff3b1d, emissive: 0xff2200, emissiveIntensity: 1.6, roughness: 0.55 });
20
+ const glowGeo = new THREE.SphereGeometry(0.36, 14, 12);
21
+ const glowMat = new THREE.MeshBasicMaterial({ color: 0xff6622, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
22
+ const ringGeo = new THREE.TorusGeometry(0.28, 0.04, 10, 24);
23
+ const ringMat = new THREE.MeshBasicMaterial({ color: 0xffaa55, transparent: true, opacity: 0.7, blending: THREE.AdditiveBlending, depthWrite: false });
24
+ return { coreGeo, coreMat, glowGeo, glowMat, ringGeo, ringMat };
25
+ })();
26
+
27
  const UP = new THREE.Vector3(0, 1, 0);
28
  const TMPv = new THREE.Vector3();
29
  const TMPq = new THREE.Quaternion();
 
56
  : TMPv.copy(dirOrVel).normalize().multiplyScalar(speed);
57
 
58
  const projectile = {
59
+ kind: 'arrow',
60
  mesh: group,
61
  pos: group.position,
62
  vel,
 
66
  G.enemyProjectiles.push(projectile);
67
  }
68
 
69
+ // Spawns a straight-traveling shaman fireball
70
+ export function spawnEnemyFireball(start, dirOrVel, asVelocity = false) {
71
+ const speed = CFG.shaman.fireballSpeed;
72
+ // Visual group: core + additive glow + fiery ring + light
73
+ const group = new THREE.Group();
74
+ const core = new THREE.Mesh(FIREBALL.coreGeo, FIREBALL.coreMat);
75
+ const glow = new THREE.Mesh(FIREBALL.glowGeo, FIREBALL.glowMat);
76
+ const ring = new THREE.Mesh(FIREBALL.ringGeo, FIREBALL.ringMat);
77
+ ring.rotation.x = Math.PI / 2;
78
+ core.castShadow = true; core.receiveShadow = false;
79
+ glow.castShadow = false; glow.receiveShadow = false;
80
+ group.add(core);
81
+ group.add(glow);
82
+ group.add(ring);
83
+ const light = new THREE.PointLight(0xff5522, 7, 11, 2);
84
+ group.add(light);
85
+
86
+ group.position.copy(start);
87
+ G.scene.add(group);
88
+
89
+ // Compute velocity without aliasing TMP vectors
90
+ const velocity = asVelocity ? dirOrVel.clone() : dirOrVel.clone().normalize().multiplyScalar(speed);
91
+ // Safety: if somehow pointing away from camera, flip (prevents “opposite” shots)
92
+ const toCam = new THREE.Vector3().subVectors(G.camera.position, group.position).normalize();
93
+ if (velocity.clone().normalize().dot(toCam) < 0) velocity.multiplyScalar(-1);
94
+
95
+ // Orient to direction for consistency
96
+ const nd = velocity.clone().normalize();
97
+ TMPq.setFromUnitVectors(UP, nd);
98
+ group.quaternion.copy(TMPq);
99
+
100
+ const projectile = {
101
+ kind: 'fireball',
102
+ mesh: group,
103
+ pos: group.position,
104
+ vel: velocity,
105
+ life: CFG.shaman.fireballLife,
106
+ core,
107
+ glow,
108
+ ring,
109
+ light,
110
+ osc: Math.random() * Math.PI * 2
111
+ };
112
+
113
+ G.enemyProjectiles.push(projectile);
114
+ }
115
+
116
  export function updateEnemyProjectiles(delta, onPlayerDeath) {
117
  const gravity = CFG.enemy.arrowGravity;
 
118
 
119
  for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) {
120
  const p = G.enemyProjectiles[i];
121
 
122
+ // Integrate (gravity only affects arrows)
123
+ if (p.kind === 'arrow') p.vel.y -= gravity * delta;
124
  p.pos.addScaledVector(p.vel, delta);
125
 
126
  // Re-orient to velocity
 
128
  TMPq.setFromUnitVectors(UP, vdir);
129
  p.mesh.quaternion.copy(TMPq);
130
 
131
+ // Fireball visual flicker
132
+ if (p.kind === 'fireball') {
133
+ p.osc += delta * 14;
134
+ const pulse = 1 + Math.sin(p.osc) * 0.18 + (Math.random() - 0.5) * 0.06;
135
+ p.mesh.scale.setScalar(pulse);
136
+ if (p.ring) p.ring.rotation.z += delta * 3;
137
+ if (p.glow) p.glow.material.opacity = 0.6 + Math.abs(Math.sin(p.osc * 1.3)) * 0.5;
138
+ if (p.light) p.light.intensity = 6 + Math.abs(Math.sin(p.osc * 2.1)) * 6;
139
+ }
140
  p.life -= delta;
141
 
142
+ // Ground hit against terrain (fireballs usually won't arc down)
143
  const gy = getTerrainHeight(p.pos.x, p.pos.z);
144
  if (p.pos.y <= gy) {
145
  TMPv.set(p.pos.x, gy + 0.02, p.pos.z);
146
  spawnImpact(TMPv, UP);
147
+ if (p.kind === 'fireball') spawnMuzzleFlashAt(TMPv, 0xff5522);
148
  G.scene.remove(p.mesh);
149
  G.enemyProjectiles.splice(i, 1);
150
  continue;
151
  }
152
 
153
+ // Tree collision (2D cylinder test using spatial grid)
154
+ const nearTrees = getNearbyTrees(p.pos.x, p.pos.z, 3.5);
155
+ for (let ti = 0; ti < nearTrees.length; ti++) {
156
+ const tree = nearTrees[ti];
157
  const dx = p.pos.x - tree.x;
158
  const dz = p.pos.z - tree.z;
159
  const dist2 = dx * dx + dz * dz;
160
  const r = tree.radius + 0.2; // small allowance for arrow
161
  if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish
162
  spawnImpact(p.pos, UP);
163
+ if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
164
  G.scene.remove(p.mesh);
165
  G.enemyProjectiles.splice(i, 1);
166
  continue;
 
168
  }
169
 
170
  // Player collision (sphere)
171
+ const hitR = p.kind === 'arrow' ? CFG.enemy.arrowHitRadius : CFG.shaman.fireballHitRadius;
172
  const pr = hitR + G.player.radius * 0.6; // slightly generous
173
  if (p.pos.distanceTo(G.player.pos) < pr) {
174
+ const dmg = p.kind === 'arrow' ? CFG.enemy.arrowDamage : CFG.shaman.fireballDamage;
175
  G.player.health -= dmg;
176
  G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP);
177
  if (G.player.health <= 0 && G.player.alive) {
 
180
  if (onPlayerDeath) onPlayerDeath();
181
  }
182
  spawnImpact(p.pos, UP);
183
+ if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522);
184
  G.scene.remove(p.mesh);
185
  G.enemyProjectiles.splice(i, 1);
186
  continue;
src/waves.js CHANGED
@@ -12,6 +12,7 @@ export function startNextWave() {
12
  );
13
  G.waves.spawnQueue = waveCount;
14
  G.waves.nextSpawnTimer = 0;
 
15
 
16
  // Choose a single spawn anchor for this wave (not near the center)
17
  const half = CFG.forestSize / 2;
@@ -42,7 +43,12 @@ export function updateWaves(delta) {
42
  if (G.waves.spawnQueue > 0 && G.waves.aliveCount < CFG.waves.maxAlive) {
43
  G.waves.nextSpawnTimer -= delta;
44
  if (G.waves.nextSpawnTimer <= 0) {
45
- spawnEnemy();
 
 
 
 
 
46
  G.waves.spawnQueue--;
47
  const spawnRate = Math.max(
48
  CFG.waves.spawnMin,
 
12
  );
13
  G.waves.spawnQueue = waveCount;
14
  G.waves.nextSpawnTimer = 0;
15
+ G.waves.shamansToSpawn = 1; // exactly 1 shaman per wave
16
 
17
  // Choose a single spawn anchor for this wave (not near the center)
18
  const half = CFG.forestSize / 2;
 
43
  if (G.waves.spawnQueue > 0 && G.waves.aliveCount < CFG.waves.maxAlive) {
44
  G.waves.nextSpawnTimer -= delta;
45
  if (G.waves.nextSpawnTimer <= 0) {
46
+ if (G.waves.shamansToSpawn > 0) {
47
+ spawnEnemy('shaman');
48
+ G.waves.shamansToSpawn--;
49
+ } else {
50
+ spawnEnemy('orc');
51
+ }
52
  G.waves.spawnQueue--;
53
  const spawnRate = Math.max(
54
  CFG.waves.spawnMin,
src/world.js CHANGED
@@ -758,8 +758,12 @@ export function setupGround() {
758
  G.scene.add(ground);
759
  G.ground = ground;
760
  // Keep blockers in sync if trees already exist
761
- if (G.treeMeshes && Array.isArray(G.treeMeshes)) {
 
 
762
  G.blockers = [ground, ...G.treeMeshes];
 
 
763
  }
764
  }
765
 
@@ -772,6 +776,8 @@ export function generateForest() {
772
 
773
  G.treeColliders.length = 0;
774
  G.treeMeshes.length = 0;
 
 
775
 
776
  while (placed < CFG.treeCount && attempts < maxAttempts) {
777
  attempts++;
@@ -817,7 +823,8 @@ export function generateForest() {
817
  foliage2.scale.set(fScale, fScale, fScale);
818
  foliage3.scale.set(fScale, fScale, fScale);
819
  crown.scale.set(fScale, fScale, fScale);
820
- foliage1.castShadow = foliage2.castShadow = foliage3.castShadow = crown.castShadow = true;
 
821
  foliage1.receiveShadow = foliage2.receiveShadow = foliage3.receiveShadow = crown.receiveShadow = true;
822
  tree.add(foliage1, foliage2, foliage3, crown);
823
 
@@ -828,6 +835,7 @@ export function generateForest() {
828
  tree.position.set(x, y, z);
829
  G.scene.add(tree);
830
  G.treeMeshes.push(tree);
 
831
 
832
  // Add collider roughly matching trunk base radius
833
  const trunkBaseRadius = 1.2 * s;
@@ -837,8 +845,120 @@ export function generateForest() {
837
  }
838
  // Update blockers list once trees are generated
839
  if (G.ground) {
840
- G.blockers = [G.ground, ...G.treeMeshes];
841
  } else {
842
- G.blockers = [...G.treeMeshes];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  }
 
844
  }
 
758
  G.scene.add(ground);
759
  G.ground = ground;
760
  // Keep blockers in sync if trees already exist
761
+ if (G.treeTrunks && Array.isArray(G.treeTrunks) && G.treeTrunks.length) {
762
+ G.blockers = [ground, ...G.treeTrunks];
763
+ } else if (G.treeMeshes && Array.isArray(G.treeMeshes) && G.treeMeshes.length) {
764
  G.blockers = [ground, ...G.treeMeshes];
765
+ } else {
766
+ G.blockers = [ground];
767
  }
768
  }
769
 
 
776
 
777
  G.treeColliders.length = 0;
778
  G.treeMeshes.length = 0;
779
+ if (!G.treeTrunks) G.treeTrunks = [];
780
+ G.treeTrunks.length = 0;
781
 
782
  while (placed < CFG.treeCount && attempts < maxAttempts) {
783
  attempts++;
 
823
  foliage2.scale.set(fScale, fScale, fScale);
824
  foliage3.scale.set(fScale, fScale, fScale);
825
  crown.scale.set(fScale, fScale, fScale);
826
+ // Keep only trunk casting shadows; foliage receiving only to reduce shadow pass cost
827
+ foliage1.castShadow = foliage2.castShadow = foliage3.castShadow = crown.castShadow = false;
828
  foliage1.receiveShadow = foliage2.receiveShadow = foliage3.receiveShadow = crown.receiveShadow = true;
829
  tree.add(foliage1, foliage2, foliage3, crown);
830
 
 
835
  tree.position.set(x, y, z);
836
  G.scene.add(tree);
837
  G.treeMeshes.push(tree);
838
+ G.treeTrunks.push(trunk);
839
 
840
  // Add collider roughly matching trunk base radius
841
  const trunkBaseRadius = 1.2 * s;
 
845
  }
846
  // Update blockers list once trees are generated
847
  if (G.ground) {
848
+ G.blockers = [G.ground, ...G.treeTrunks];
849
  } else {
850
+ G.blockers = [...G.treeTrunks];
851
+ }
852
+ // Build spatial index for tree colliders to accelerate queries
853
+ buildTreeGrid();
854
+ }
855
+
856
+ // ---- Spatial index for tree colliders ----
857
+ // Simple uniform grid over the world to reduce O(N) scans
858
+ export function buildTreeGrid(cellSize = 12) {
859
+ const half = CFG.forestSize / 2;
860
+ const minX = -half, minZ = -half;
861
+ const cols = Math.max(1, Math.ceil(CFG.forestSize / cellSize));
862
+ const rows = Math.max(1, Math.ceil(CFG.forestSize / cellSize));
863
+ const cells = new Array(cols * rows);
864
+ for (let i = 0; i < cells.length; i++) cells[i] = [];
865
+ function cellIndex(ix, iz) { return iz * cols + ix; }
866
+ for (const t of G.treeColliders) {
867
+ const ix = Math.max(0, Math.min(cols - 1, Math.floor((t.x - minX) / cellSize)));
868
+ const iz = Math.max(0, Math.min(rows - 1, Math.floor((t.z - minZ) / cellSize)));
869
+ cells[cellIndex(ix, iz)].push(t);
870
+ }
871
+ G.treeGrid = { cellSize, minX, minZ, cols, rows, cells };
872
+ }
873
+
874
+ const _nearTrees = [];
875
+ function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
876
+
877
+ // Gathers tree colliders around a point within a given radius (XZ plane)
878
+ export function getNearbyTrees(x, z, radius = 4) {
879
+ _nearTrees.length = 0;
880
+ const grid = G.treeGrid;
881
+ if (!grid) return _nearTrees;
882
+ const { cellSize, minX, minZ, cols, rows, cells } = grid;
883
+ const r = Math.max(radius, 0);
884
+ const minIx = clamp(Math.floor((x - r - minX) / cellSize), 0, cols - 1);
885
+ const maxIx = clamp(Math.floor((x + r - minX) / cellSize), 0, cols - 1);
886
+ const minIz = clamp(Math.floor((z - r - minZ) / cellSize), 0, rows - 1);
887
+ const maxIz = clamp(Math.floor((z + r - minZ) / cellSize), 0, rows - 1);
888
+ for (let iz = minIz; iz <= maxIz; iz++) {
889
+ for (let ix = minIx; ix <= maxIx; ix++) {
890
+ const cell = cells[iz * cols + ix];
891
+ for (let k = 0; k < cell.length; k++) _nearTrees.push(cell[k]);
892
+ }
893
+ }
894
+ return _nearTrees;
895
+ }
896
+
897
+ const _aabbTrees = [];
898
+ export function getTreesInAABB(minX, minZ, maxX, maxZ) {
899
+ _aabbTrees.length = 0;
900
+ const grid = G.treeGrid;
901
+ if (!grid) return _aabbTrees;
902
+ const { cellSize, minX: gx, minZ: gz, cols, rows, cells } = grid;
903
+ const minIx = clamp(Math.floor((minX - gx) / cellSize), 0, cols - 1);
904
+ const maxIx = clamp(Math.floor((maxX - gx) / cellSize), 0, cols - 1);
905
+ const minIz = clamp(Math.floor((minZ - gz) / cellSize), 0, rows - 1);
906
+ const maxIz = clamp(Math.floor((maxZ - gz) / cellSize), 0, rows - 1);
907
+ for (let iz = minIz; iz <= maxIz; iz++) {
908
+ for (let ix = minIx; ix <= maxIx; ix++) {
909
+ const cell = cells[iz * cols + ix];
910
+ for (let k = 0; k < cell.length; k++) _aabbTrees.push(cell[k]);
911
+ }
912
+ }
913
+ return _aabbTrees;
914
+ }
915
+
916
+ // Fast 2D segment-vs-circle test
917
+ function segIntersectsCircle(x1, z1, x2, z2, cx, cz, r) {
918
+ const vx = x2 - x1, vz = z2 - z1;
919
+ const wx = cx - x1, wz = cz - z1;
920
+ const vv = vx * vx + vz * vz;
921
+ if (vv <= 1e-6) return false;
922
+ let t = (wx * vx + wz * vz) / vv;
923
+ if (t < 0) t = 0; else if (t > 1) t = 1;
924
+ const px = x1 + t * vx, pz = z1 + t * vz;
925
+ const dx = cx - px, dz = cz - pz;
926
+ return (dx * dx + dz * dz) <= r * r;
927
+ }
928
+
929
+ // Approximate line-of-sight using tree cylinders and terrain samples (no raycaster)
930
+ export function hasLineOfSight(from, to) {
931
+ // Terrain occlusion: sample a few points along the ray
932
+ const steps = 6;
933
+ for (let i = 1; i < steps; i++) {
934
+ const t = i / steps;
935
+ const x = from.x + (to.x - from.x) * t;
936
+ const z = from.z + (to.z - from.z) * t;
937
+ const y = from.y + (to.y - from.y) * t;
938
+ const gy = getTerrainHeight(x, z) + 0.1;
939
+ if (y <= gy) return false;
940
+ }
941
+ // Tree occlusion using grid-restricted set
942
+ const minX = Math.min(from.x, to.x) - 2.0;
943
+ const maxX = Math.max(from.x, to.x) + 2.0;
944
+ const minZ = Math.min(from.z, to.z) - 2.0;
945
+ const maxZ = Math.max(from.z, to.z) + 2.0;
946
+ const candidates = getTreesInAABB(minX, minZ, maxX, maxZ);
947
+ for (let i = 0; i < candidates.length; i++) {
948
+ const t = candidates[i];
949
+ // Use a slightly inflated radius to account for foliage
950
+ const r = t.radius + 0.3;
951
+ if (segIntersectsCircle(from.x, from.z, to.x, to.z, t.x, t.z, r)) {
952
+ // If the segment is sufficiently high (e.g., arrow arc), allow pass
953
+ // Estimate height at closest approach t in [0,1]
954
+ const vx = to.x - from.x, vz = to.z - from.z;
955
+ const wx = t.x - from.x, wz = t.z - from.z;
956
+ const vv = vx * vx + vz * vz;
957
+ let u = (wx * vx + wz * vz) / (vv || 1);
958
+ if (u < 0) u = 0; else if (u > 1) u = 1;
959
+ const yAt = from.y + (to.y - from.y) * u;
960
+ if (yAt < 8) return false; // below canopy -> blocked
961
+ }
962
  }
963
+ return true;
964
  }