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, getNearbyTrees } from './world.js'; | |
import { spawnImpact, spawnMuzzleFlashAt } from './fx.js'; | |
// Shared arrow geometry/material to avoid per-shot allocations | |
const ARROW = (() => { | |
const shaftGeo = new THREE.CylinderGeometry(0.03, 0.03, 0.9, 6); | |
const headGeo = new THREE.ConeGeometry(0.08, 0.2, 8); | |
const shaftMat = new THREE.MeshStandardMaterial({ color: 0xdeb887, roughness: 0.8 }); | |
const headMat = new THREE.MeshStandardMaterial({ color: 0x9e9e9e, metalness: 0.2, roughness: 0.5 }); | |
return { shaftGeo, headGeo, shaftMat, headMat }; | |
})(); | |
// Shared fireball geometry/material (core + outer glow) | |
const FIREBALL = (() => { | |
const coreGeo = new THREE.SphereGeometry(0.22, 16, 12); | |
const coreMat = new THREE.MeshStandardMaterial({ color: 0xff3b1d, emissive: 0xff2200, emissiveIntensity: 1.6, roughness: 0.55 }); | |
const glowGeo = new THREE.SphereGeometry(0.36, 14, 12); | |
const glowMat = new THREE.MeshBasicMaterial({ color: 0xff6622, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }); | |
const ringGeo = new THREE.TorusGeometry(0.28, 0.04, 10, 24); | |
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffaa55, transparent: true, opacity: 0.7, blending: THREE.AdditiveBlending, depthWrite: false }); | |
return { coreGeo, coreMat, glowGeo, glowMat, ringGeo, ringMat }; | |
})(); | |
const UP = new THREE.Vector3(0, 1, 0); | |
const TMPv = new THREE.Vector3(); | |
const TMPq = new THREE.Quaternion(); | |
// Shared jagged rock geometry/material | |
const ROCK = (() => { | |
const geo = new THREE.DodecahedronGeometry(0.6, 0); | |
const mat = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 1.0, metalness: 0.0 }); | |
return { geo, mat }; | |
})(); | |
// Spawns a visible enemy arrow projectile | |
export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) { | |
const speed = CFG.enemy.arrowSpeed; | |
// Arrow visual: shaft + head as a small cone (shared geos/materials) | |
const group = new THREE.Group(); | |
const shaft = new THREE.Mesh(ARROW.shaftGeo, ARROW.shaftMat); | |
shaft.position.y = 0; // centered | |
shaft.castShadow = true; shaft.receiveShadow = true; | |
group.add(shaft); | |
const head = new THREE.Mesh(ARROW.headGeo, ARROW.headMat); | |
head.position.y = 0.55; | |
head.castShadow = true; head.receiveShadow = true; | |
group.add(head); | |
// Orient to direction (geometry points +Y by default) | |
const dir = TMPv.copy(dirOrVel).normalize(); | |
TMPq.setFromUnitVectors(UP, dir); | |
group.quaternion.copy(TMPq); | |
group.position.copy(start); | |
G.scene.add(group); | |
const vel = asVelocity | |
? dirOrVel.clone() | |
: TMPv.copy(dirOrVel).normalize().multiplyScalar(speed); | |
const projectile = { | |
kind: 'arrow', | |
mesh: group, | |
pos: group.position, | |
vel, | |
life: CFG.enemy.arrowLife | |
}; | |
G.enemyProjectiles.push(projectile); | |
} | |
// Spawns a straight-traveling shaman fireball | |
export function spawnEnemyFireball(start, dirOrVel, asVelocity = false) { | |
const speed = CFG.shaman.fireballSpeed; | |
// Visual group: core + additive glow + fiery ring + light | |
const group = new THREE.Group(); | |
const core = new THREE.Mesh(FIREBALL.coreGeo, FIREBALL.coreMat); | |
const glow = new THREE.Mesh(FIREBALL.glowGeo, FIREBALL.glowMat); | |
const ring = new THREE.Mesh(FIREBALL.ringGeo, FIREBALL.ringMat); | |
ring.rotation.x = Math.PI / 2; | |
core.castShadow = true; core.receiveShadow = false; | |
glow.castShadow = false; glow.receiveShadow = false; | |
group.add(core); | |
group.add(glow); | |
group.add(ring); | |
// Avoid dynamic lights to keep shaders stable; rely on glow meshes | |
group.position.copy(start); | |
G.scene.add(group); | |
// Compute velocity without aliasing TMP vectors | |
const velocity = asVelocity ? dirOrVel.clone() : dirOrVel.clone().normalize().multiplyScalar(speed); | |
// Safety: if somehow pointing away from camera, flip (prevents “opposite” shots) | |
const toCam = new THREE.Vector3().subVectors(G.camera.position, group.position).normalize(); | |
if (velocity.clone().normalize().dot(toCam) < 0) velocity.multiplyScalar(-1); | |
// Orient to direction for consistency | |
const nd = velocity.clone().normalize(); | |
TMPq.setFromUnitVectors(UP, nd); | |
group.quaternion.copy(TMPq); | |
const projectile = { | |
kind: 'fireball', | |
mesh: group, | |
pos: group.position, | |
vel: velocity, | |
life: CFG.shaman.fireballLife, | |
core, | |
glow, | |
ring, | |
light: null, | |
osc: Math.random() * Math.PI * 2 | |
}; | |
G.enemyProjectiles.push(projectile); | |
} | |
// Spawns a heavy arcing rock projectile | |
export function spawnEnemyRock(start, dirOrVel, asVelocity = false) { | |
const speed = CFG.golem?.rockSpeed ?? 30; | |
const group = new THREE.Group(); | |
const rock = new THREE.Mesh(ROCK.geo, ROCK.mat); | |
rock.castShadow = true; rock.receiveShadow = true; | |
group.add(rock); | |
// Orientation based on throw direction | |
const nd = dirOrVel.clone(); | |
if (!asVelocity) nd.normalize(); | |
TMPq.setFromUnitVectors(UP, nd.clone().normalize()); | |
group.quaternion.copy(TMPq); | |
group.position.copy(start); | |
G.scene.add(group); | |
const vel = asVelocity | |
? dirOrVel.clone() | |
: nd.normalize().multiplyScalar(speed); | |
const projectile = { | |
kind: 'rock', | |
mesh: group, | |
pos: group.position, | |
vel, | |
life: CFG.golem?.rockLife ?? 6 | |
}; | |
G.enemyProjectiles.push(projectile); | |
} | |
export function updateEnemyProjectiles(delta, onPlayerDeath) { | |
const gravity = CFG.enemy.arrowGravity; | |
for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) { | |
const p = G.enemyProjectiles[i]; | |
// Integrate gravity by projectile kind | |
if (p.kind === 'arrow') p.vel.y -= gravity * delta; | |
else if (p.kind === 'rock') p.vel.y -= (CFG.golem?.rockGravity ?? gravity) * delta; | |
p.pos.addScaledVector(p.vel, delta); | |
// Re-orient to velocity | |
const vdir = TMPv.copy(p.vel).normalize(); | |
TMPq.setFromUnitVectors(UP, vdir); | |
p.mesh.quaternion.copy(TMPq); | |
// Fireball visual flicker | |
if (p.kind === 'fireball') { | |
p.osc += delta * 14; | |
const pulse = 1 + Math.sin(p.osc) * 0.18 + (Math.random() - 0.5) * 0.06; | |
p.mesh.scale.setScalar(pulse); | |
if (p.ring) p.ring.rotation.z += delta * 3; | |
if (p.glow) p.glow.material.opacity = 0.6 + Math.abs(Math.sin(p.osc * 1.3)) * 0.5; | |
// no dynamic light | |
} | |
p.life -= delta; | |
// Ground hit against terrain (fireballs usually won't arc down) | |
const gy = getTerrainHeight(p.pos.x, p.pos.z); | |
if (p.pos.y <= gy) { | |
TMPv.set(p.pos.x, gy + 0.02, p.pos.z); | |
spawnImpact(TMPv, UP); | |
if (p.kind === 'fireball') spawnMuzzleFlashAt(TMPv, 0xff5522); | |
if (p.kind === 'rock') spawnMuzzleFlashAt(TMPv, 0xb0b0b0); | |
G.scene.remove(p.mesh); | |
G.enemyProjectiles.splice(i, 1); | |
continue; | |
} | |
// Tree collision (2D cylinder test using spatial grid) | |
const nearTrees = getNearbyTrees(p.pos.x, p.pos.z, 3.5); | |
for (let ti = 0; ti < nearTrees.length; ti++) { | |
const tree = nearTrees[ti]; | |
const dx = p.pos.x - tree.x; | |
const dz = p.pos.z - tree.z; | |
const dist2 = dx * dx + dz * dz; | |
const pad = p.kind === 'rock' ? 0.6 : 0.2; // rocks are bulkier | |
const r = tree.radius + pad; | |
if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish | |
spawnImpact(p.pos, UP); | |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522); | |
if (p.kind === 'rock') spawnMuzzleFlashAt(p.pos, 0xb0b0b0); | |
G.scene.remove(p.mesh); | |
G.enemyProjectiles.splice(i, 1); | |
continue; | |
} | |
} | |
// Player collision (sphere) | |
const hitR = ( | |
p.kind === 'arrow' ? CFG.enemy.arrowHitRadius : | |
p.kind === 'fireball' ? CFG.shaman.fireballHitRadius : | |
CFG.golem?.rockHitRadius ?? 0.9 | |
); | |
const pr = hitR + G.player.radius * 0.6; // slightly generous | |
if (p.pos.distanceTo(G.player.pos) < pr) { | |
const dmg = ( | |
p.kind === 'arrow' ? CFG.enemy.arrowDamage : | |
p.kind === 'fireball' ? CFG.shaman.fireballDamage : | |
CFG.golem?.rockDamage ?? 40 | |
); | |
G.player.health -= dmg; | |
G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP); | |
if (G.player.health <= 0 && G.player.alive) { | |
G.player.health = 0; | |
G.player.alive = false; | |
if (onPlayerDeath) onPlayerDeath(); | |
} | |
spawnImpact(p.pos, UP); | |
if (p.kind === 'fireball') spawnMuzzleFlashAt(p.pos, 0xff5522); | |
if (p.kind === 'rock') spawnMuzzleFlashAt(p.pos, 0xb0b0b0); | |
G.scene.remove(p.mesh); | |
G.enemyProjectiles.splice(i, 1); | |
continue; | |
} | |
// Timeout | |
if (p.life <= 0) { | |
G.scene.remove(p.mesh); | |
G.enemyProjectiles.splice(i, 1); | |
continue; | |
} | |
} | |
} | |