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; | |
| } | |
| } | |
| } | |