Spaces:
Running
Running
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- README.md +23 -0
- index.html +4 -0
- src/combat.js +5 -3
- src/config.js +10 -0
- src/enemies.js +264 -54
- src/fx.js +49 -0
- src/globals.js +9 -2
- src/lighting.js +6 -14
- src/main.js +25 -2
- src/pickups.js +3 -2
- src/player.js +13 -4
- src/projectiles.js +82 -9
- src/waves.js +7 -1
- 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
|
| 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
|
|
|
|
|
|
|
| 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,
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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
|
| 241 |
-
obj.geometry
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
//
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 42 |
-
sun.shadow.mapSize.height =
|
| 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
|
| 52 |
-
moon.
|
| 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 |
-
|
| 90 |
-
flashlight.
|
| 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 |
-
|
|
|
|
| 32 |
G.renderer.shadowMap.enabled = true;
|
| 33 |
-
|
|
|
|
| 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
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 841 |
} else {
|
| 842 |
-
G.blockers = [...G.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|