Spaces:
Running
Running
Codex CLI
commited on
Commit
·
874a51f
1
Parent(s):
fd12cd0
feat(grenades): implement grenade mechanics including throwing, explosion effects, and HUD integration
Browse files- index.html +5 -1
- src/audio.js +36 -0
- src/config.js +13 -0
- src/events.js +13 -0
- src/fx.js +45 -0
- src/globals.js +9 -1
- src/grenades.js +261 -0
- src/hud.js +6 -0
- src/main.js +13 -0
- 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) {
|