orcs-in-the-forest / src /projectiles.js
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;
}
}
}