Spaces:
Running
Running
Codex CLI
feat(enemies, waves): add golem enemy with specific behaviors, health orbs, and projectile mechanics
1f43352
| 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(); | |
| } | |
| } | |
| } | |