import * as THREE from 'three'; import { CFG } from './config.js'; import { G } from './globals.js'; import { getTerrainHeight } from './world.js'; import { updateHUD } from './hud.js'; import { spawnExplosionAt } from './fx.js'; import { playExplosion } from './audio.js'; import { spawnHealthOrbs } from './pickups.js'; const TMPv = new THREE.Vector3(); const FORWARD = new THREE.Vector3(); // Shared grenade mesh resources const GRENADE = (() => { // Shared resources for a more realistic grenade const bodyGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.22, 12); const capGeo = new THREE.CylinderGeometry(0.09, 0.09, 0.05, 12); const leverGeo = new THREE.BoxGeometry(0.16, 0.03, 0.04); const ringGeo = new THREE.TorusGeometry(0.06, 0.01, 8, 20); const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2b4c2b, roughness: 0.8, metalness: 0.1 }); // olive green const metalMat = new THREE.MeshStandardMaterial({ color: 0x777777, roughness: 0.5, metalness: 0.6 }); const pinMat = new THREE.MeshStandardMaterial({ color: 0xb0b0b0, roughness: 0.4, metalness: 0.9 }); return { bodyGeo, capGeo, leverGeo, ringGeo, bodyMat, metalMat, pinMat }; })(); function createGrenadeMesh() { const g = new THREE.Group(); const body = new THREE.Mesh(GRENADE.bodyGeo, GRENADE.bodyMat); body.castShadow = true; body.receiveShadow = false; g.add(body); const cap = new THREE.Mesh(GRENADE.capGeo, GRENADE.metalMat); cap.position.y = 0.14; g.add(cap); // Yellow identification stripe 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 })); stripe.position.y = 0.06; g.add(stripe); const lever = new THREE.Mesh(GRENADE.leverGeo, GRENADE.metalMat); lever.position.set(0.0, 0.18, -0.08); lever.rotation.x = -0.3; g.add(lever); const ring = new THREE.Mesh(GRENADE.ringGeo, GRENADE.pinMat); ring.position.set(-0.06, 0.16, -0.02); ring.rotation.x = Math.PI / 2; g.add(ring); return g; } function throwOrigin(out) { // Start near player's feet for better looking arc const gx = G.player.pos.x; const gz = G.player.pos.z; const gy = getTerrainHeight(gx, gz); out.set(gx, gy + 0.6, gz); FORWARD.set(0, 0, 0); G.camera.getWorldDirection(FORWARD); // Nudge forward so it doesn't intersect player capsule out.addScaledVector(FORWARD, 0.7); return out; } function throwVelocity() { const dir = new THREE.Vector3(); G.camera.getWorldDirection(dir); dir.normalize(); const v = dir.clone().multiplyScalar(CFG.grenade.speed); // Add a small upward boost to keep a pleasant arc, scaled by horizontal aim amount const horiz = Math.min(1, Math.hypot(dir.x, dir.z)); v.y += (CFG.grenade.yBoost || 0) * horiz; // Carry some of the player lateral velocity v.x += G.player.vel.x * 0.25; v.z += G.player.vel.z * 0.25; return v; } function ensurePreview() { if (G.grenadePreview) return; // Line for arc const pts = new Float32Array((CFG.grenade.previewSteps) * 3); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pts, 3)); const mat = new THREE.LineBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.9, depthTest: true }); const line = new THREE.Line(geo, mat); line.renderOrder = 10; // Landing marker const marker = new THREE.Mesh( new THREE.TorusGeometry(0.45, 0.06, 10, 24), new THREE.MeshBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.75, depthTest: true }) ); marker.rotation.x = Math.PI / 2; marker.renderOrder = 10; G.scene.add(line); G.scene.add(marker); G.grenadePreview = { line, marker }; } function clearPreview() { if (!G.grenadePreview) return; const { line, marker } = G.grenadePreview; if (line) { G.scene.remove(line); line.geometry.dispose(); if (line.material?.dispose) line.material.dispose(); } if (marker) { G.scene.remove(marker); marker.geometry.dispose(); if (marker.material?.dispose) marker.material.dispose(); } G.grenadePreview = null; } export function primeGrenade() { if (G.state !== 'playing' || !G.player.alive) return; if (G.heldGrenade || G.grenadeCount <= 0) return; // Consume a grenade and start fuse G.grenadeCount -= 1; updateHUD(); G.heldGrenade = { fuseLeft: CFG.grenade.fuse }; ensurePreview(); } export function releaseGrenade() { if (!G.heldGrenade) return; // Spawn a thrown grenade with remaining fuse const pos = throwOrigin(new THREE.Vector3()); const vel = throwVelocity(); const mesh = createGrenadeMesh(); mesh.position.copy(pos); G.scene.add(mesh); const g = { pos, vel, fuseLeft: G.heldGrenade.fuseLeft, alive: true, grounded: false, mesh }; G.grenades.push(g); G.heldGrenade = null; clearPreview(); } function explodeAt(position) { // Visual + sound spawnExplosionAt(position, CFG.grenade.radius); playExplosion(); // Damage enemies (simple radial falloff) for (let i = 0; i < G.enemies.length; i++) { const e = G.enemies[i]; if (!e.alive) continue; const d = position.distanceTo(e.pos); if (d <= CFG.grenade.radius) { const t = Math.max(0, 1 - d / CFG.grenade.radius); const dmg = CFG.grenade.maxDamage * (0.3 + 0.7 * t); // keep some damage at edge e.hp -= dmg; if (e.hp <= 0 && e.alive) { e.alive = false; e.deathTimer = 0; G.waves.aliveCount--; // Award score like a body kill G.player.score += 10; // Heals: larger drop for golems if (e.type === 'golem') { spawnHealthOrbs(e.pos, 15 + Math.floor(G.random() * 6)); // 15..20 } else { spawnHealthOrbs(e.pos, 1 + Math.floor(G.random() * 3)); } } } } // Damage player const pd = position.distanceTo(G.player.pos); if (pd <= CFG.grenade.radius) { const t = Math.max(0, 1 - pd / CFG.grenade.radius); const dmg = CFG.grenade.selfMaxDamage * (0.3 + 0.7 * t); G.player.health -= dmg; G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP); if (G.player.health <= 0 && G.player.alive) { G.player.health = 0; G.player.alive = false; } } } export function updateGrenades(delta) { // Update held grenade: fuse + preview if (G.heldGrenade) { G.heldGrenade.fuseLeft -= delta; if (G.heldGrenade.fuseLeft <= 0) { // Explode in hand clearPreview(); explodeAt(G.player.pos.clone()); G.heldGrenade = null; updateHUD(); } else { // Update preview arc ensurePreview(); const origin = throwOrigin(new THREE.Vector3()); const vel0 = throwVelocity(); const dt = CFG.grenade.previewDt; const n = CFG.grenade.previewSteps; const posAttr = G.grenadePreview.line.geometry.getAttribute('position'); let p = origin.clone(); let v = vel0.clone(); let hitPos = null; for (let i = 0; i < n; i++) { const idx = i * 3; posAttr.array[idx] = p.x; posAttr.array[idx + 1] = p.y; posAttr.array[idx + 2] = p.z; // step v.y -= CFG.grenade.gravity * dt; p.addScaledVector(v, dt); const ground = getTerrainHeight(p.x, p.z); if (p.y <= ground) { p.y = ground; hitPos = p.clone(); // Fill remaining points at landing for (let k = i + 1; k < n; k++) { const id2 = k * 3; posAttr.array[id2] = p.x; posAttr.array[id2 + 1] = p.y; posAttr.array[id2 + 2] = p.z; } break; } } posAttr.needsUpdate = true; if (G.grenadePreview.marker) { G.grenadePreview.marker.position.copy(hitPos || p); } } } // Update thrown grenades for (let i = G.grenades.length - 1; i >= 0; i--) { const g = G.grenades[i]; if (!g.alive) { G.grenades.splice(i, 1); continue; } g.fuseLeft -= delta; // Physics g.vel.y -= CFG.grenade.gravity * delta; g.pos.addScaledVector(g.vel, delta); const ground = getTerrainHeight(g.pos.x, g.pos.z); if (g.pos.y <= ground) { g.pos.y = ground; // Simple damp when on ground g.vel.set(0, 0, 0); g.grounded = true; } // Sync mesh if (g.mesh) { g.mesh.position.copy(g.pos); g.mesh.rotation.y += delta * 2; } if (g.fuseLeft <= 0) { explodeAt(g.pos.clone()); if (g.mesh) { G.scene.remove(g.mesh); g.mesh = null; } g.alive = false; G.grenades.splice(i, 1); updateHUD(); } } }