Codex CLI commited on
Commit
874a51f
·
1 Parent(s): fd12cd0

feat(grenades): implement grenade mechanics including throwing, explosion effects, and HUD integration

Browse files
Files changed (10) hide show
  1. index.html +5 -1
  2. src/audio.js +36 -0
  3. src/config.js +13 -0
  4. src/events.js +13 -0
  5. src/fx.js +45 -0
  6. src/globals.js +9 -1
  7. src/grenades.js +261 -0
  8. src/hud.js +6 -0
  9. src/main.js +13 -0
  10. src/waves.js +3 -0
index.html CHANGED
@@ -156,6 +156,10 @@
156
  <div class="label">FPS</div>
157
  <div class="value" id="fps">-</div>
158
  </div>
 
 
 
 
159
  </div>
160
 
161
  <!-- Health Bottom-Left -->
@@ -185,7 +189,7 @@
185
  <div id="overlay-content">
186
  <h1>Orcs In The Forest</h1>
187
  <p>Click to Start</p>
188
- <p style="font-size: 14px;">Move: WASD | Look: Mouse | Fire: Click | Reload: R | Light: F | Pause: ESC</p>
189
  </div>
190
  </div>
191
 
 
156
  <div class="label">FPS</div>
157
  <div class="value" id="fps">-</div>
158
  </div>
159
+ <div class="mini-card">
160
+ <div class="label">GRENADES</div>
161
+ <div class="value" id="grenades">0</div>
162
+ </div>
163
  </div>
164
 
165
  <!-- Health Bottom-Left -->
 
189
  <div id="overlay-content">
190
  <h1>Orcs In The Forest</h1>
191
  <p>Click to Start</p>
192
+ <p style="font-size: 14px;">Move: WASD | Look: Mouse | Fire: Click | Reload: R | Grenade: Hold G then release | Light: F | Pause: ESC</p>
193
  </div>
194
  </div>
195
 
src/audio.js CHANGED
@@ -115,6 +115,42 @@ export function playHeadshot() {
115
  click.start(t0);
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  // FM metal ping helper
119
  function fmPing(t, { freq = 650, mod = 1200, index = 1.2, dur = 0.14, gain = 0.28, bpFreq = 1800, q = 8 }) {
120
  const o = ctx.createOscillator(); o.type = 'sine';
 
115
  click.start(t0);
116
  }
117
 
118
+ // Explosion: bass thump + noise burst with decay
119
+ export function playExplosion() {
120
+ ensureContext();
121
+ const t0 = ctx.currentTime;
122
+ const masterVol = (CFG.audio?.master ?? 0.6);
123
+ const base = 0.9 * masterVol;
124
+
125
+ // Low boom
126
+ const boom = ctx.createOscillator();
127
+ boom.type = 'sine';
128
+ boom.frequency.setValueAtTime(120, t0);
129
+ boom.frequency.exponentialRampToValueAtTime(45, t0 + 0.5);
130
+ const bGain = ctx.createGain();
131
+ bGain.gain.setValueAtTime(0.0001, t0);
132
+ bGain.gain.linearRampToValueAtTime(base * 0.8, t0 + 0.02);
133
+ bGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.8);
134
+ boom.connect(bGain).connect(master);
135
+ boom.start(t0); boom.stop(t0 + 0.9);
136
+
137
+ // Noise blast (band-limited)
138
+ const len = Math.floor(ctx.sampleRate * 0.4);
139
+ const buf = ctx.createBuffer(1, len, ctx.sampleRate);
140
+ const ch = buf.getChannelData(0);
141
+ for (let i = 0; i < len; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / len);
142
+ const noise = ctx.createBufferSource();
143
+ noise.buffer = buf;
144
+ const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600;
145
+ const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 80;
146
+ const nGain = ctx.createGain();
147
+ nGain.gain.setValueAtTime(0.0001, t0);
148
+ nGain.gain.linearRampToValueAtTime(base * 0.7, t0 + 0.01);
149
+ nGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.45);
150
+ noise.connect(hp).connect(lp).connect(nGain).connect(master);
151
+ noise.start(t0); noise.stop(t0 + 0.5);
152
+ }
153
+
154
  // FM metal ping helper
155
  function fmPing(t, { freq = 650, mod = 1200, index = 1.2, dur = 0.14, gain = 0.28, bpFreq = 1800, q = 8 }) {
156
  const o = ctx.createOscillator(); o.type = 'sine';
src/config.js CHANGED
@@ -101,6 +101,19 @@ export const CFG = {
101
  impactLife: 0.25,
102
  muzzleLife: 0.05
103
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  audio: {
105
  master: 0.6,
106
  gunshotVol: 0.9,
 
101
  impactLife: 0.25,
102
  muzzleLife: 0.05
103
  },
104
+ // Player grenades
105
+ grenade: {
106
+ speed: 24, // initial throw speed
107
+ gravity: 20, // matches enemy arrow gravity
108
+ fuse: 3.0, // seconds after priming
109
+ radius: 6.5, // explosion radius
110
+ maxDamage: 140, // center damage to enemies
111
+ selfMaxDamage: 90,// center damage to player
112
+ previewSteps: 36, // points along arc preview
113
+ previewDt: 0.06, // timestep used for preview sim
114
+ minPitchDeg: 28, // minimum upward throw angle for a nice arc (not enforced if yBoost used)
115
+ yBoost: 3.5 // additional upward speed added for nicer arc (scaled by horizontal aim)
116
+ },
117
  audio: {
118
  master: 0.6,
119
  gunshotVol: 0.9,
src/events.js CHANGED
@@ -2,6 +2,7 @@ import { G } from './globals.js';
2
  import { CFG } from './config.js';
3
  import { showOverlay } from './hud.js';
4
  import { initAudio, resumeAudio } from './audio.js';
 
5
 
6
  export function setupEvents({ startGame, restartGame, beginReload, updateWeaponAnchor }) {
7
  const overlay = document.getElementById('overlay');
@@ -40,6 +41,12 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
40
  case 'KeyS': G.input.s = true; break;
41
  case 'KeyD': G.input.d = true; break;
42
  case 'ShiftLeft': G.input.sprint = true; break;
 
 
 
 
 
 
43
  case 'Space':
44
  if (G.state === 'playing' && G.player.grounded) {
45
  // Jump
@@ -75,6 +82,12 @@ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponA
75
  case 'KeyS': G.input.s = false; break;
76
  case 'KeyD': G.input.d = false; break;
77
  case 'ShiftLeft': G.input.sprint = false; break;
 
 
 
 
 
 
78
  }
79
  });
80
 
 
2
  import { CFG } from './config.js';
3
  import { showOverlay } from './hud.js';
4
  import { initAudio, resumeAudio } from './audio.js';
5
+ import { primeGrenade, releaseGrenade } from './grenades.js';
6
 
7
  export function setupEvents({ startGame, restartGame, beginReload, updateWeaponAnchor }) {
8
  const overlay = document.getElementById('overlay');
 
41
  case 'KeyS': G.input.s = true; break;
42
  case 'KeyD': G.input.d = true; break;
43
  case 'ShiftLeft': G.input.sprint = true; break;
44
+ case 'KeyG':
45
+ if (G.state === 'playing') {
46
+ G.input.grenade = true;
47
+ primeGrenade();
48
+ }
49
+ break;
50
  case 'Space':
51
  if (G.state === 'playing' && G.player.grounded) {
52
  // Jump
 
82
  case 'KeyS': G.input.s = false; break;
83
  case 'KeyD': G.input.d = false; break;
84
  case 'ShiftLeft': G.input.sprint = false; break;
85
+ case 'KeyG':
86
+ if (G.input.grenade) {
87
+ G.input.grenade = false;
88
+ releaseGrenade();
89
+ }
90
+ break;
91
  }
92
  });
93
 
src/fx.js CHANGED
@@ -105,6 +105,27 @@ export function spawnPortalAt(worldPos, color = 0xff5522, size = 1.1, life = 0.3
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];
@@ -183,4 +204,28 @@ export function updateFX(delta) {
183
  G.fx.portals.splice(i, 1);
184
  }
185
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
 
105
  G.fx.portals.push({ ring, flare, light, life, maxLife: life, rot: Math.random() * Math.PI * 2 });
106
  }
107
 
108
+ // Grenade explosion: additive glow sphere + shock ring + light
109
+ export function spawnExplosionAt(worldPos, radius = 6) {
110
+ const glow = new THREE.Mesh(
111
+ new THREE.SphereGeometry(radius * 0.4, 18, 14),
112
+ new THREE.MeshBasicMaterial({ color: 0xffaa55, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false })
113
+ );
114
+ glow.position.copy(worldPos);
115
+ const ring = new THREE.Mesh(
116
+ new THREE.TorusGeometry(radius * 0.25, radius * 0.08, 12, 28),
117
+ new THREE.MeshBasicMaterial({ color: 0xffdd99, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false })
118
+ );
119
+ ring.position.copy(worldPos);
120
+ ring.rotation.x = Math.PI / 2;
121
+ const light = new THREE.PointLight(0xffa050, 9, radius * 3.5, 2);
122
+ light.position.copy(worldPos);
123
+ G.scene.add(glow);
124
+ G.scene.add(ring);
125
+ G.scene.add(light);
126
+ G.explosions.push({ glow, ring, light, life: 0.5, maxLife: 0.5 });
127
+ }
128
+
129
  export function updateFX(delta) {
130
  for (let i = G.fx.tracers.length - 1; i >= 0; i--) {
131
  const t = G.fx.tracers[i];
 
204
  G.fx.portals.splice(i, 1);
205
  }
206
  }
207
+
208
+ // Explosions
209
+ for (let i = G.explosions.length - 1; i >= 0; i--) {
210
+ const e = G.explosions[i];
211
+ e.life -= delta;
212
+ const t = Math.max(0, e.life / e.maxLife);
213
+ const s = 1 + (1 - t) * 2.2;
214
+ if (e.glow) {
215
+ e.glow.material.opacity = 0.3 + 0.7 * t;
216
+ e.glow.scale.setScalar(s);
217
+ }
218
+ if (e.ring) {
219
+ e.ring.material.opacity = 0.2 + 0.7 * t;
220
+ e.ring.scale.setScalar(0.9 + (1 - t) * 2.6);
221
+ e.ring.rotation.z += delta * 2.5;
222
+ }
223
+ if (e.light) e.light.intensity = 2 + 10 * t;
224
+ if (e.life <= 0) {
225
+ if (e.glow) { G.scene.remove(e.glow); e.glow.geometry.dispose(); if (e.glow.material?.dispose) e.glow.material.dispose(); }
226
+ if (e.ring) { G.scene.remove(e.ring); e.ring.geometry.dispose(); if (e.ring.material?.dispose) e.ring.material.dispose(); }
227
+ if (e.light) { G.scene.remove(e.light); }
228
+ G.explosions.splice(i, 1);
229
+ }
230
+ }
231
  }
src/globals.js CHANGED
@@ -30,7 +30,8 @@ export const G = {
30
  d: false,
31
  shoot: false,
32
  sprint: false,
33
- jump: false
 
34
  },
35
 
36
  player: null, // initialized in main
@@ -74,6 +75,13 @@ export const G = {
74
  },
75
 
76
  fx: { tracers: [], impacts: [], flashes: [], dusts: [], portals: [] },
 
 
 
 
 
 
 
77
  enemyProjectiles: [],
78
  // Health orbs and other pickups
79
  orbs: [],
 
30
  d: false,
31
  shoot: false,
32
  sprint: false,
33
+ jump: false,
34
+ grenade: false
35
  },
36
 
37
  player: null, // initialized in main
 
75
  },
76
 
77
  fx: { tracers: [], impacts: [], flashes: [], dusts: [], portals: [] },
78
+ // Player grenades and preview helpers
79
+ grenades: [],
80
+ heldGrenade: null,
81
+ grenadeCount: 0,
82
+ grenadePreview: null,
83
+ // Explosion VFX
84
+ explosions: [],
85
  enemyProjectiles: [],
86
  // Health orbs and other pickups
87
  orbs: [],
src/grenades.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { updateHUD } from './hud.js';
6
+ import { spawnExplosionAt } from './fx.js';
7
+ import { playExplosion } from './audio.js';
8
+ import { spawnHealthOrbs } from './pickups.js';
9
+
10
+ const TMPv = new THREE.Vector3();
11
+ const FORWARD = new THREE.Vector3();
12
+ // Shared grenade mesh resources
13
+ const GRENADE = (() => {
14
+ // Shared resources for a more realistic grenade
15
+ const bodyGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.22, 12);
16
+ const capGeo = new THREE.CylinderGeometry(0.09, 0.09, 0.05, 12);
17
+ const leverGeo = new THREE.BoxGeometry(0.16, 0.03, 0.04);
18
+ const ringGeo = new THREE.TorusGeometry(0.06, 0.01, 8, 20);
19
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2b4c2b, roughness: 0.8, metalness: 0.1 }); // olive green
20
+ const metalMat = new THREE.MeshStandardMaterial({ color: 0x777777, roughness: 0.5, metalness: 0.6 });
21
+ const pinMat = new THREE.MeshStandardMaterial({ color: 0xb0b0b0, roughness: 0.4, metalness: 0.9 });
22
+ return { bodyGeo, capGeo, leverGeo, ringGeo, bodyMat, metalMat, pinMat };
23
+ })();
24
+
25
+ function createGrenadeMesh() {
26
+ const g = new THREE.Group();
27
+ const body = new THREE.Mesh(GRENADE.bodyGeo, GRENADE.bodyMat);
28
+ body.castShadow = true; body.receiveShadow = false;
29
+ g.add(body);
30
+ const cap = new THREE.Mesh(GRENADE.capGeo, GRENADE.metalMat);
31
+ cap.position.y = 0.14;
32
+ g.add(cap);
33
+ // Yellow identification stripe
34
+ const stripe = new THREE.Mesh(new THREE.CylinderGeometry(0.125, 0.125, 0.015, 16), new THREE.MeshStandardMaterial({ color: 0xffd84d, emissive: 0x7a6400, emissiveIntensity: 0.25, roughness: 0.6 }));
35
+ stripe.position.y = 0.06;
36
+ g.add(stripe);
37
+ const lever = new THREE.Mesh(GRENADE.leverGeo, GRENADE.metalMat);
38
+ lever.position.set(0.0, 0.18, -0.08);
39
+ lever.rotation.x = -0.3;
40
+ g.add(lever);
41
+ const ring = new THREE.Mesh(GRENADE.ringGeo, GRENADE.pinMat);
42
+ ring.position.set(-0.06, 0.16, -0.02);
43
+ ring.rotation.x = Math.PI / 2;
44
+ g.add(ring);
45
+ return g;
46
+ }
47
+
48
+ function throwOrigin(out) {
49
+ // Start near player's feet for better looking arc
50
+ const gx = G.player.pos.x;
51
+ const gz = G.player.pos.z;
52
+ const gy = getTerrainHeight(gx, gz);
53
+ out.set(gx, gy + 0.6, gz);
54
+ FORWARD.set(0, 0, 0);
55
+ G.camera.getWorldDirection(FORWARD);
56
+ // Nudge forward so it doesn't intersect player capsule
57
+ out.addScaledVector(FORWARD, 0.7);
58
+ return out;
59
+ }
60
+
61
+ function throwVelocity() {
62
+ const dir = new THREE.Vector3();
63
+ G.camera.getWorldDirection(dir);
64
+ dir.normalize();
65
+ const v = dir.clone().multiplyScalar(CFG.grenade.speed);
66
+ // Add a small upward boost to keep a pleasant arc, scaled by horizontal aim amount
67
+ const horiz = Math.min(1, Math.hypot(dir.x, dir.z));
68
+ v.y += (CFG.grenade.yBoost || 0) * horiz;
69
+ // Carry some of the player lateral velocity
70
+ v.x += G.player.vel.x * 0.25;
71
+ v.z += G.player.vel.z * 0.25;
72
+ return v;
73
+ }
74
+
75
+ function ensurePreview() {
76
+ if (G.grenadePreview) return;
77
+ // Line for arc
78
+ const pts = new Float32Array((CFG.grenade.previewSteps) * 3);
79
+ const geo = new THREE.BufferGeometry();
80
+ geo.setAttribute('position', new THREE.BufferAttribute(pts, 3));
81
+ const mat = new THREE.LineBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.9, depthTest: true });
82
+ const line = new THREE.Line(geo, mat);
83
+ line.renderOrder = 10;
84
+ // Landing marker
85
+ const marker = new THREE.Mesh(
86
+ new THREE.TorusGeometry(0.45, 0.06, 10, 24),
87
+ new THREE.MeshBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.75, depthTest: true })
88
+ );
89
+ marker.rotation.x = Math.PI / 2;
90
+ marker.renderOrder = 10;
91
+ G.scene.add(line);
92
+ G.scene.add(marker);
93
+ G.grenadePreview = { line, marker };
94
+ }
95
+
96
+ function clearPreview() {
97
+ if (!G.grenadePreview) return;
98
+ const { line, marker } = G.grenadePreview;
99
+ if (line) {
100
+ G.scene.remove(line);
101
+ line.geometry.dispose();
102
+ if (line.material?.dispose) line.material.dispose();
103
+ }
104
+ if (marker) {
105
+ G.scene.remove(marker);
106
+ marker.geometry.dispose();
107
+ if (marker.material?.dispose) marker.material.dispose();
108
+ }
109
+ G.grenadePreview = null;
110
+ }
111
+
112
+ export function primeGrenade() {
113
+ if (G.state !== 'playing' || !G.player.alive) return;
114
+ if (G.heldGrenade || G.grenadeCount <= 0) return;
115
+ // Consume a grenade and start fuse
116
+ G.grenadeCount -= 1;
117
+ updateHUD();
118
+ G.heldGrenade = { fuseLeft: CFG.grenade.fuse };
119
+ ensurePreview();
120
+ }
121
+
122
+ export function releaseGrenade() {
123
+ if (!G.heldGrenade) return;
124
+ // Spawn a thrown grenade with remaining fuse
125
+ const pos = throwOrigin(new THREE.Vector3());
126
+ const vel = throwVelocity();
127
+ const mesh = createGrenadeMesh();
128
+ mesh.position.copy(pos);
129
+ G.scene.add(mesh);
130
+ const g = {
131
+ pos,
132
+ vel,
133
+ fuseLeft: G.heldGrenade.fuseLeft,
134
+ alive: true,
135
+ grounded: false,
136
+ mesh
137
+ };
138
+ G.grenades.push(g);
139
+ G.heldGrenade = null;
140
+ clearPreview();
141
+ }
142
+
143
+ function explodeAt(position) {
144
+ // Visual + sound
145
+ spawnExplosionAt(position, CFG.grenade.radius);
146
+ playExplosion();
147
+
148
+ // Damage enemies (simple radial falloff)
149
+ for (let i = 0; i < G.enemies.length; i++) {
150
+ const e = G.enemies[i];
151
+ if (!e.alive) continue;
152
+ const d = position.distanceTo(e.pos);
153
+ if (d <= CFG.grenade.radius) {
154
+ const t = Math.max(0, 1 - d / CFG.grenade.radius);
155
+ const dmg = CFG.grenade.maxDamage * (0.3 + 0.7 * t); // keep some damage at edge
156
+ e.hp -= dmg;
157
+ if (e.hp <= 0 && e.alive) {
158
+ e.alive = false;
159
+ e.deathTimer = 0;
160
+ G.waves.aliveCount--;
161
+ // Award score like a body kill
162
+ G.player.score += 10;
163
+ // Modest heals
164
+ spawnHealthOrbs(e.pos, 1 + Math.floor(G.random() * 3));
165
+ }
166
+ }
167
+ }
168
+
169
+ // Damage player
170
+ const pd = position.distanceTo(G.player.pos);
171
+ if (pd <= CFG.grenade.radius) {
172
+ const t = Math.max(0, 1 - pd / CFG.grenade.radius);
173
+ const dmg = CFG.grenade.selfMaxDamage * (0.3 + 0.7 * t);
174
+ G.player.health -= dmg;
175
+ G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP);
176
+ if (G.player.health <= 0 && G.player.alive) {
177
+ G.player.health = 0;
178
+ G.player.alive = false;
179
+ }
180
+ }
181
+ }
182
+
183
+ export function updateGrenades(delta) {
184
+ // Update held grenade: fuse + preview
185
+ if (G.heldGrenade) {
186
+ G.heldGrenade.fuseLeft -= delta;
187
+ if (G.heldGrenade.fuseLeft <= 0) {
188
+ // Explode in hand
189
+ clearPreview();
190
+ explodeAt(G.player.pos.clone());
191
+ G.heldGrenade = null;
192
+ updateHUD();
193
+ } else {
194
+ // Update preview arc
195
+ ensurePreview();
196
+ const origin = throwOrigin(new THREE.Vector3());
197
+ const vel0 = throwVelocity();
198
+ const dt = CFG.grenade.previewDt;
199
+ const n = CFG.grenade.previewSteps;
200
+ const posAttr = G.grenadePreview.line.geometry.getAttribute('position');
201
+ let p = origin.clone();
202
+ let v = vel0.clone();
203
+ let hitPos = null;
204
+ for (let i = 0; i < n; i++) {
205
+ const idx = i * 3;
206
+ posAttr.array[idx] = p.x;
207
+ posAttr.array[idx + 1] = p.y;
208
+ posAttr.array[idx + 2] = p.z;
209
+ // step
210
+ v.y -= CFG.grenade.gravity * dt;
211
+ p.addScaledVector(v, dt);
212
+ const ground = getTerrainHeight(p.x, p.z);
213
+ if (p.y <= ground) {
214
+ p.y = ground;
215
+ hitPos = p.clone();
216
+ // Fill remaining points at landing
217
+ for (let k = i + 1; k < n; k++) {
218
+ const id2 = k * 3;
219
+ posAttr.array[id2] = p.x;
220
+ posAttr.array[id2 + 1] = p.y;
221
+ posAttr.array[id2 + 2] = p.z;
222
+ }
223
+ break;
224
+ }
225
+ }
226
+ posAttr.needsUpdate = true;
227
+ if (G.grenadePreview.marker) {
228
+ G.grenadePreview.marker.position.copy(hitPos || p);
229
+ }
230
+ }
231
+ }
232
+
233
+ // Update thrown grenades
234
+ for (let i = G.grenades.length - 1; i >= 0; i--) {
235
+ const g = G.grenades[i];
236
+ if (!g.alive) { G.grenades.splice(i, 1); continue; }
237
+ g.fuseLeft -= delta;
238
+
239
+ // Physics
240
+ g.vel.y -= CFG.grenade.gravity * delta;
241
+ g.pos.addScaledVector(g.vel, delta);
242
+ const ground = getTerrainHeight(g.pos.x, g.pos.z);
243
+ if (g.pos.y <= ground) {
244
+ g.pos.y = ground;
245
+ // Simple damp when on ground
246
+ g.vel.set(0, 0, 0);
247
+ g.grounded = true;
248
+ }
249
+
250
+ // Sync mesh
251
+ if (g.mesh) { g.mesh.position.copy(g.pos); g.mesh.rotation.y += delta * 2; }
252
+
253
+ if (g.fuseLeft <= 0) {
254
+ explodeAt(g.pos.clone());
255
+ if (g.mesh) { G.scene.remove(g.mesh); g.mesh = null; }
256
+ g.alive = false;
257
+ G.grenades.splice(i, 1);
258
+ updateHUD();
259
+ }
260
+ }
261
+ }
src/hud.js CHANGED
@@ -7,6 +7,7 @@ const HUD = {
7
  scoreEl: /** @type {HTMLElement|null} */(document.getElementById('score')),
8
  enemiesEl: /** @type {HTMLElement|null} */(document.getElementById('enemies')),
9
  ammoEl: /** @type {HTMLElement|null} */(document.getElementById('ammo')),
 
10
  healthText: /** @type {HTMLElement|null} */(document.getElementById('health-text')),
11
  healthFill: /** @type {HTMLElement|null} */(document.getElementById('health-fill')),
12
  ch: {
@@ -25,6 +26,7 @@ const HUD = {
25
  score: -1,
26
  enemies: -1,
27
  ammo: '',
 
28
  }
29
  };
30
 
@@ -59,6 +61,10 @@ export function updateHUD() {
59
  HUD.last.ammo = ammoText;
60
  if (HUD.ammoEl) HUD.ammoEl.textContent = ammoText;
61
  }
 
 
 
 
62
  }
63
 
64
  export function showWaveBanner(text) {
 
7
  scoreEl: /** @type {HTMLElement|null} */(document.getElementById('score')),
8
  enemiesEl: /** @type {HTMLElement|null} */(document.getElementById('enemies')),
9
  ammoEl: /** @type {HTMLElement|null} */(document.getElementById('ammo')),
10
+ grenadesEl: /** @type {HTMLElement|null} */(document.getElementById('grenades')),
11
  healthText: /** @type {HTMLElement|null} */(document.getElementById('health-text')),
12
  healthFill: /** @type {HTMLElement|null} */(document.getElementById('health-fill')),
13
  ch: {
 
26
  score: -1,
27
  enemies: -1,
28
  ammo: '',
29
+ grenades: -1,
30
  }
31
  };
32
 
 
61
  HUD.last.ammo = ammoText;
62
  if (HUD.ammoEl) HUD.ammoEl.textContent = ammoText;
63
  }
64
+ if (G.grenadeCount !== HUD.last.grenades) {
65
+ HUD.last.grenades = G.grenadeCount;
66
+ if (HUD.grenadesEl) HUD.grenadesEl.textContent = String(G.grenadeCount);
67
+ }
68
  }
69
 
70
  export function showWaveBanner(text) {
src/main.js CHANGED
@@ -14,6 +14,7 @@ import { updateHUD, showOverlay, updateDamageEffect, updateHealEffect, updateCro
14
  import { updateFX } from './fx.js';
15
  import { updateCasings } from './casings.js';
16
  import { updateEnemyProjectiles } from './projectiles.js';
 
17
  import { updateDayNight } from './daynight.js';
18
  import { performShooting } from './combat.js';
19
  import { updatePickups } from './pickups.js';
@@ -67,6 +68,7 @@ function init() {
67
  };
68
  G.weapon.ammo = CFG.gun.magSize;
69
  G.weapon.reserve = Infinity;
 
70
 
71
  // Add camera to scene
72
  G.camera.position.copy(G.player.pos);
@@ -105,6 +107,10 @@ function startGame() {
105
  G.camera.position.copy(G.player.pos);
106
  G.damageFlash = 0;
107
  G.healFlash = 0;
 
 
 
 
108
 
109
  // Clear enemies
110
  for (const enemy of G.enemies) {
@@ -195,6 +201,13 @@ function animate() {
195
  updateFX(delta);
196
  updateHelmets(delta);
197
  updateCasings(delta);
 
 
 
 
 
 
 
198
  updateClouds(delta);
199
  updateMountains(delta);
200
  // Update subtle foliage wind sway using elapsedTime (avoid double-advancing clock)
 
14
  import { updateFX } from './fx.js';
15
  import { updateCasings } from './casings.js';
16
  import { updateEnemyProjectiles } from './projectiles.js';
17
+ import { updateGrenades } from './grenades.js';
18
  import { updateDayNight } from './daynight.js';
19
  import { performShooting } from './combat.js';
20
  import { updatePickups } from './pickups.js';
 
68
  };
69
  G.weapon.ammo = CFG.gun.magSize;
70
  G.weapon.reserve = Infinity;
71
+ G.grenadeCount = 0;
72
 
73
  // Add camera to scene
74
  G.camera.position.copy(G.player.pos);
 
107
  G.camera.position.copy(G.player.pos);
108
  G.damageFlash = 0;
109
  G.healFlash = 0;
110
+ // Grenades reset
111
+ G.grenades.length = 0;
112
+ G.heldGrenade = null;
113
+ G.grenadeCount = 0;
114
 
115
  // Clear enemies
116
  for (const enemy of G.enemies) {
 
201
  updateFX(delta);
202
  updateHelmets(delta);
203
  updateCasings(delta);
204
+ // Update grenades and previews last among gameplay
205
+ if (G.state === 'playing') {
206
+ updateGrenades(delta);
207
+ if (!G.player.alive) {
208
+ gameOver();
209
+ }
210
+ }
211
  updateClouds(delta);
212
  updateMountains(delta);
213
  // Update subtle foliage wind sway using elapsedTime (avoid double-advancing clock)
src/waves.js CHANGED
@@ -26,6 +26,9 @@ export function startNextWave() {
26
  G.waves.spawnAnchor = new THREE.Vector3(ax, getTerrainHeight(ax, az), az);
27
 
28
  showWaveBanner(`Wave ${G.waves.current}`);
 
 
 
29
  }
30
 
31
  export function updateWaves(delta) {
 
26
  G.waves.spawnAnchor = new THREE.Vector3(ax, getTerrainHeight(ax, az), az);
27
 
28
  showWaveBanner(`Wave ${G.waves.current}`);
29
+
30
+ // Add 2 grenades each wave start (stacking)
31
+ G.grenadeCount = (G.grenadeCount || 0) + 2;
32
  }
33
 
34
  export function updateWaves(delta) {