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 |
}
|