orcs-in-the-forest / src /enemies.js
Codex CLI
feat: Implement dynamic fire-rate powerups and enemy behavior updates
5cf582c
import * as THREE from 'three';
import { CFG } from './config.js';
import { G } from './globals.js';
import { getTerrainHeight, getNearbyTrees, hasLineOfSight } from './world.js';
import { spawnMuzzleFlashAt, spawnDustAt, spawnPortalAt } from './fx.js';
import { spawnEnemyArrow, spawnEnemyFireball, spawnEnemyRock } from './projectiles.js';
// Reusable temps
const TMPv1 = new THREE.Vector3();
const TMPv2 = new THREE.Vector3();
const TMPv3 = new THREE.Vector3();
const ORIGIN = new THREE.Vector3();
const TO_PLAYER = new THREE.Vector3();
const START = new THREE.Vector3();
const TARGET = new THREE.Vector3();
// Shared materials for enemies
const MAT = {
skin: new THREE.MeshStandardMaterial({ color: 0x5a8f3a, roughness: 0.9 }),
tunic: new THREE.MeshStandardMaterial({ color: 0x3b2f1c, roughness: 0.85 }),
pants: new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.9 }),
metal: new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.35, roughness: 0.4 }),
leather: new THREE.MeshStandardMaterial({ color: 0x6a3e1b, roughness: 0.8 }),
silver: new THREE.MeshStandardMaterial({ color: 0xcfd3d6, metalness: 0.95, roughness: 0.25 })
};
// Also share per-enemy odds-and-ends that were previously recreated
const RIVET_MAT = new THREE.MeshStandardMaterial({ color: 0xb0b4b8, metalness: 0.8, roughness: 0.3 });
const STRING_MAT = new THREE.LineBasicMaterial({ color: 0xffffff });
// Invisible low-poly hit proxies to reduce raycast CPU on shots
const PROXY_MAT = new THREE.MeshBasicMaterial({ visible: false });
const PROXY_HEAD = new THREE.SphereGeometry(0.32, 10, 8);
const PROXY_BODY = new THREE.CapsuleGeometry(0.45, 0.6, 6, 8);
function ballisticVelocity(start, target, speed, gravity, preferHigh = false) {
const disp = TMPv1.subVectors(target, start);
const dxz = Math.hypot(disp.x, disp.z);
if (dxz < 0.0001) return null;
const v2 = speed * speed;
const g = gravity;
const y = disp.y;
const under = v2 * v2 - g * (g * dxz * dxz + 2 * y * v2);
if (under < 0) return null; // no ballistic solution at this speed
const root = Math.sqrt(under);
const tan1 = (v2 + (preferHigh ? +root : -root)) / (g * dxz);
const cos = 1 / Math.sqrt(1 + tan1 * tan1);
const sin = tan1 * cos;
const vxz = speed * cos;
const vy = speed * sin;
const hdir = TMPv2.set(disp.x, 0, disp.z).normalize();
return hdir.multiplyScalar(vxz).add(TMPv3.set(0, vy, 0));
}
// --- Per-type behavior updaters (refactor) ---
// These functions encapsulate attack logic and animations per enemy type.
// They are invoked from the main update loop based on enemy.type.
function updateOrc(enemy, delta, dist, onPlayerDeath) {
// Ranged archer logic + contact damage
enemy.shootCooldown -= delta;
const rangedRange = CFG.enemy.range;
if (dist < rangedRange && enemy.alive) {
// Throttled line-of-sight
enemy.losTimer -= delta;
if (enemy.losTimer <= 0) {
ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
enemy.hasLOS = hasLineOfSight(ORIGIN, G.player.pos);
enemy.losTimer = 0.28 + G.random() * 0.18;
}
if (enemy.hasLOS && enemy.shootCooldown <= 0) {
// Ballistic arrow with slight bloom
const spread = CFG.enemy.bloom;
enemy.mesh.updateMatrixWorld(true);
if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
TARGET.set(G.player.pos.x, G.player.pos.y - 0.2, G.player.pos.z);
TARGET.x += (G.random() - 0.5) * spread * 20;
TARGET.y += (G.random() - 0.5) * spread * 8;
TARGET.z += (G.random() - 0.5) * spread * 20;
const dxz = Math.hypot(TARGET.x - START.x, TARGET.z - START.z);
const maxRangeFlat = (CFG.enemy.arrowSpeed * CFG.enemy.arrowSpeed) / CFG.enemy.arrowGravity;
if (dxz <= maxRangeFlat * 0.98) {
const vel = ballisticVelocity(START, TARGET, CFG.enemy.arrowSpeed, CFG.enemy.arrowGravity, false);
if (vel) {
enemy.shootCooldown = 1 / CFG.enemy.rof;
spawnEnemyArrow(START, vel, true);
spawnMuzzleFlashAt(START, 0xffc080);
}
}
}
}
// Contact DPS when very close
if (dist < G.player.radius + enemy.radius) {
const dmg = enemy.damagePerSecond * delta * 0.5;
G.player.health -= dmg;
G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP);
if (G.player.health <= 0) {
G.player.health = 0;
G.player.alive = false;
if (onPlayerDeath) onPlayerDeath();
}
}
}
function updateShaman(enemy, delta, dist, onPlayerDeath) {
// Fireball caster + periodic teleport + contact DPS fallback
enemy.shootCooldown -= delta;
const rangedRange = CFG.enemy.range;
if (dist < rangedRange && enemy.alive) {
enemy.losTimer -= delta;
if (enemy.losTimer <= 0) {
ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
enemy.hasLOS = hasLineOfSight(ORIGIN, G.player.pos);
enemy.losTimer = 0.28 + G.random() * 0.18;
}
if (enemy.hasLOS && enemy.shootCooldown <= 0) {
enemy.mesh.updateMatrixWorld(true);
if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
TARGET.copy(G.camera.position);
const dir = TMPv2.subVectors(TARGET, START).normalize();
enemy.shootCooldown = 1 / CFG.enemy.rof;
spawnEnemyFireball(START, dir, false);
spawnMuzzleFlashAt(START, 0xff5a22);
}
}
// Teleport ability (periodic)
enemy.teleTimer -= delta;
if (enemy.teleTimer <= 0) {
enemy.teleTimer = CFG.shaman.teleportCooldown * (0.85 + G.random() * 0.3);
const dirTo = TO_PLAYER.copy(G.player.pos).sub(enemy.pos);
dirTo.y = 0;
const dlen = dirTo.length();
if (dlen > 1) {
dirTo.normalize();
let tdist = CFG.shaman.teleportDistance;
if (dlen - tdist < (G.player.radius + enemy.radius + 2)) {
tdist = Math.max(0, dlen - (G.player.radius + enemy.radius + 2));
}
const nx = enemy.pos.x + dirTo.x * tdist;
const nz = enemy.pos.z + dirTo.z * tdist;
const trees = getNearbyTrees(nx, nz, 3);
let blocked = false;
for (let ti = 0; ti < trees.length; ti++) {
const tr = trees[ti];
const dx = nx - tr.x; const dz = nz - tr.z;
const rr = tr.radius + enemy.radius * 0.8;
if (dx * dx + dz * dz < rr * rr) { blocked = true; break; }
}
if (!blocked) {
spawnPortalAt(enemy.pos, 0xff5522, 1.0, 0.32);
spawnDustAt(enemy.pos, 0x9c3322, 0.7, 0.18);
enemy.pos.set(nx, getTerrainHeight(nx, nz), nz);
spawnPortalAt(enemy.pos, 0xff5522, 1.1, 0.36);
spawnDustAt(enemy.pos, 0xcc4422, 0.9, 0.22);
}
}
}
// Contact DPS when very close
if (dist < G.player.radius + enemy.radius) {
const dmg = enemy.damagePerSecond * delta * 0.5;
G.player.health -= dmg;
G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP);
if (G.player.health <= 0) {
G.player.health = 0;
G.player.alive = false;
if (onPlayerDeath) onPlayerDeath();
}
}
}
function updateWolf(enemy, delta, dist, onPlayerDeath) {
// Bite-based melee and animations
enemy.biteCooldown = Math.max(0, (enemy.biteCooldown || 0) - delta);
const biteRange = CFG.wolf.biteRange;
const biteWindup = CFG.wolf.biteWindup;
const biteInterval = CFG.wolf.biteInterval;
const biteDuration = Math.max(0.42, biteWindup + 0.18);
if (dist < biteRange && enemy.biteCooldown <= 0 && !enemy.biting && G.player.alive) {
enemy.biting = true;
enemy.biteTimer = 0;
enemy.biteApplied = false;
enemy.biteCooldown = biteInterval;
}
if (enemy.biting) {
enemy.biteTimer += delta;
if (!enemy.biteApplied && enemy.biteTimer >= biteWindup && dist < biteRange + 0.2) {
const dmg = CFG.wolf.biteDamage;
G.player.health -= dmg;
G.damageFlash = Math.min(1, G.damageFlash + (CFG.hud.damagePulsePerHit || 0.5) + dmg * (CFG.hud.damagePulsePerHP || 0.01));
enemy.biteApplied = true;
if (G.player.health <= 0) {
G.player.health = 0;
G.player.alive = false;
if (onPlayerDeath) onPlayerDeath();
}
}
if (enemy.biteTimer >= biteDuration) {
enemy.biting = false;
enemy.biteTimer = 0;
}
}
// Animations
enemy.animT += delta * Math.max(1.0, enemy.baseSpeed * 0.9);
const t = enemy.animT;
const runAmp = 0.6;
const cycle = Math.sin(t * 6.0);
const oc = Math.sin(t * 6.0 + Math.PI);
if (enemy.legs) {
if (enemy.legs.FL) enemy.legs.FL.rotation.x = cycle * runAmp;
if (enemy.legs.RR) enemy.legs.RR.rotation.x = cycle * runAmp * 0.9;
if (enemy.legs.FR) enemy.legs.FR.rotation.x = oc * runAmp;
if (enemy.legs.RL) enemy.legs.RL.rotation.x = oc * runAmp * 0.9;
}
if (enemy.tailPivot) enemy.tailPivot.rotation.x = Math.PI * 0.30 + Math.sin(t * 8.0) * 0.2;
if (enemy.headPivot) {
enemy.headPivot.rotation.y = Math.sin(t * 1.5) * 0.12;
enemy.headPivot.rotation.x = Math.sin(t * 1.3) * 0.06;
}
if (enemy.biting && enemy.muzzlePivot && enemy.headPivot) {
const u = Math.min(1, enemy.biteTimer / Math.max(0.01, CFG.wolf.biteWindup));
const fwd = u < 1 ? (u * (2 - u)) : 1;
enemy.muzzlePivot.position.z = (enemy.muzzleBase || 1.5) + fwd * 0.35;
enemy.headPivot.rotation.x = -0.25 - fwd * 0.25;
} else if (enemy.muzzlePivot) {
enemy.muzzlePivot.position.z = enemy.muzzleBase || 1.5;
}
}
function updateGolem(enemy, delta, dist, onPlayerDeath) {
// Throw rocks with windup, heavy gait, contact DPS fallback
enemy.shootCooldown -= delta;
const rangedRange = (CFG.golem?.range ?? CFG.enemy.range);
if (dist < rangedRange && enemy.alive) {
enemy.losTimer -= delta;
if (enemy.losTimer <= 0) {
ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
enemy.hasLOS = hasLineOfSight(ORIGIN, G.player.pos);
enemy.losTimer = 0.28 + G.random() * 0.18;
}
if (enemy.hasLOS && enemy.shootCooldown <= 0) {
// Begin throw sequence; actual spawn handled below
enemy.throwing = true;
enemy.throwTimer = 0;
enemy.throwSpawned = false;
enemy.shootCooldown = (CFG.golem?.throwInterval ?? 1.6);
}
}
// Animations and throw
enemy.animT += delta * Math.max(0.6, enemy.baseSpeed * 0.6);
const t = enemy.animT;
const swing = Math.sin(t * 1.2) * 0.32;
const counter = Math.sin(t * 1.2 + Math.PI) * 0.32;
if (!enemy.throwing) {
if (enemy.armL) enemy.armL.rotation.x = swing * 0.9;
if (enemy.armR) enemy.armR.rotation.x = counter * 0.9;
}
if (enemy.legL) enemy.legL.rotation.x = counter * 0.5;
if (enemy.legR) enemy.legR.rotation.x = swing * 0.5;
if (enemy.headPivot) enemy.headPivot.rotation.x = Math.sin(t * 2.0) * 0.05;
if (enemy.throwing && enemy.armR) {
const wind = CFG.golem?.throwWindup ?? 0.4;
enemy.throwTimer += delta;
const u = Math.min(1, enemy.throwTimer / Math.max(0.001, wind));
const back = -1.3;
const fwd = 0.6;
if (enemy.throwTimer < wind) {
const e = u * (2 - u);
enemy.armR.rotation.x = back * e;
} else {
const k = Math.min(1, (enemy.throwTimer - wind) / 0.18);
enemy.armR.rotation.x = back + (fwd - back) * (k * (2 - k));
}
if (!enemy.throwSpawned && enemy.throwTimer >= wind) {
const spread = (CFG.golem?.bloom ?? 0.01);
enemy.mesh.updateMatrixWorld(true);
if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.set(enemy.pos.x, enemy.pos.y + 2.2, enemy.pos.z);
TARGET.set(G.player.pos.x, G.player.pos.y + 0.2, G.player.pos.z);
TARGET.x += (G.random() - 0.5) * spread * 20;
TARGET.y += (G.random() - 0.5) * spread * 8;
TARGET.z += (G.random() - 0.5) * spread * 20;
const speed = CFG.golem?.rockSpeed ?? CFG.enemy.arrowSpeed;
const grav = CFG.golem?.rockGravity ?? CFG.enemy.arrowGravity;
const vel = ballisticVelocity(START, TARGET, speed, grav, false);
if (vel) {
spawnEnemyRock(START, vel, true);
spawnMuzzleFlashAt(START, 0xb0b0b0);
}
enemy.throwSpawned = true;
}
if (enemy.throwTimer >= wind + 0.32) {
enemy.throwing = false;
enemy.throwTimer = 0;
enemy.throwSpawned = false;
}
}
// Contact DPS when very close
if (dist < G.player.radius + enemy.radius) {
const dmg = enemy.damagePerSecond * delta * 0.5;
G.player.health -= dmg;
G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP);
if (G.player.health <= 0) {
G.player.health = 0;
G.player.alive = false;
if (onPlayerDeath) onPlayerDeath();
}
}
}
const ENEMY_UPDATERS = {
orc: updateOrc,
shaman: updateShaman,
wolf: updateWolf,
golem: updateGolem,
};
export function spawnEnemy(type = 'orc') {
// Spawn near a single wave anchor, not around center
const halfSize = CFG.forestSize / 2;
const anchor = G.waves.spawnAnchor || new THREE.Vector3(
(G.random() - 0.5) * (CFG.forestSize - 40), 0, (G.random() - 0.5) * (CFG.forestSize - 40)
);
// jitter around anchor (avoid exact center of map)
let x = 0, z = 0;
{
let tries = 0;
while (tries++ < 6) {
const r = 8 + G.random() * 14;
const t = G.random() * Math.PI * 2;
x = anchor.x + Math.cos(t) * r;
z = anchor.z + Math.sin(t) * r;
if (Math.abs(x) <= halfSize && Math.abs(z) <= halfSize) break;
}
if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return;
}
if (type === 'shaman') {
// Create shaman: red with a cape, fires fireballs and teleports
const enemyGroup = new THREE.Group();
const skin = MAT.skin;
const robe = new THREE.MeshStandardMaterial({ color: 0x6f0c0c, roughness: 0.9 });
const capeMat = new THREE.MeshStandardMaterial({ color: 0xcc2222, roughness: 0.95 });
// Torso and head
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.75, 1.15, 0.45), robe);
torso.position.set(0, 1.3, 0);
torso.castShadow = false; torso.receiveShadow = true;
enemyGroup.add(torso);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
head.position.set(0, 1.95, 0);
head.castShadow = false; head.receiveShadow = true;
enemyGroup.add(head);
// Hood (larger dome over head, dark red)
const hoodMat = new THREE.MeshStandardMaterial({ color: 0x4d0909, roughness: 0.95 });
const hood = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), hoodMat);
hood.scale.y = 0.7; hood.position.set(0, 2.03, 0);
hood.castShadow = false; hood.receiveShadow = true;
enemyGroup.add(hood);
// Glowing eyes
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x550000, emissive: 0xff2200, emissiveIntensity: 1.2 });
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), eyeMat);
eyeL.position.set(-0.1, 1.98, -0.25);
const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), eyeMat);
eyeR.position.set(0.1, 1.98, -0.25);
enemyGroup.add(eyeL); enemyGroup.add(eyeR);
// Simple arms
const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), robe);
armL.position.set(-0.5, 1.35, 0);
armL.castShadow = false; enemyGroup.add(armL);
const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), robe);
armR.position.set(0.5, 1.35, 0);
armR.castShadow = false; enemyGroup.add(armR);
// Legs
const legL = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.75, 0.24), robe);
legL.position.set(-0.22, 0.5, 0);
legL.castShadow = false; enemyGroup.add(legL);
const legR = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.75, 0.24), robe);
legR.position.set(0.22, 0.5, 0);
legR.castShadow = false; enemyGroup.add(legR);
// Cape (thin panel on back)
const cape = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.3, 0.04), capeMat);
cape.position.set(0, 1.18, 0.30);
cape.castShadow = false; cape.receiveShadow = true;
enemyGroup.add(cape);
// Staff held forward in right hand with glowing tip
const staffGroup = new THREE.Group();
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 1.6, 8), new THREE.MeshStandardMaterial({ color: 0x3b2b1b, roughness: 0.9 }));
staff.position.set(0, 0.8, 0);
const orb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 10), new THREE.MeshStandardMaterial({ color: 0xff4a1d, emissive: 0xff2200, emissiveIntensity: 1.5 }));
orb.position.set(0, 1.6 * 0.5 + 0.16, 0);
staffGroup.add(staff);
staffGroup.add(orb);
staffGroup.position.set(0.42, 1.2, -0.25);
staffGroup.rotation.x = -0.3; staffGroup.rotation.y = 0.1;
enemyGroup.add(staffGroup);
// Fireball spawn point at the orb tip
const projectileSpawn = new THREE.Object3D();
projectileSpawn.position.set(0, 1.6 * 0.5 + 0.16, 0);
staffGroup.add(projectileSpawn);
enemyGroup.position.set(x, getTerrainHeight(x, z), z);
G.scene.add(enemyGroup);
// Invisible hit proxies
const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
proxyHead.position.set(0, 1.95, 0);
proxyHead.userData = { enemy: null, hitZone: 'head' };
enemyGroup.add(proxyHead);
const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
proxyBody.position.set(0, 1.3, 0);
proxyBody.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(proxyBody);
// Tag (only proxies are raycasted)
torso.userData = { enemy: null, hitZone: 'body' };
head.userData = { enemy: null, hitZone: 'head' };
armL.userData = { enemy: null, hitZone: 'limb' };
armR.userData = { enemy: null, hitZone: 'limb' };
legL.userData = { enemy: null, hitZone: 'limb' };
legR.userData = { enemy: null, hitZone: 'limb' };
const enemy = {
type: 'shaman',
mesh: enemyGroup,
body: torso,
pos: enemyGroup.position,
radius: CFG.enemy.radius,
hp: CFG.enemy.hp,
baseSpeed: CFG.enemy.baseSpeed + CFG.enemy.speedPerWave * (G.waves.current - 1),
damagePerSecond: CFG.enemy.dps,
alive: true,
deathTimer: 0,
projectileSpawn,
shootCooldown: 0,
helmet: null,
helmetAttached: false,
// LOS throttling
losTimer: 0,
hasLOS: true,
hitProxies: [],
// Teleport ability
teleTimer: CFG.shaman.teleportCooldown * (0.6 + G.random() * 0.8)
};
enemyGroup.userData = { enemy };
torso.userData.enemy = enemy;
head.userData.enemy = enemy;
armL.userData.enemy = enemy;
armR.userData.enemy = enemy;
legL.userData.enemy = enemy;
legR.userData.enemy = enemy;
proxyHead.userData.enemy = enemy;
proxyBody.userData.enemy = enemy;
enemy.hitProxies.push(proxyBody, proxyHead);
G.enemies.push(enemy);
G.waves.aliveCount++;
return;
}
if (type === 'wolf') {
// Create a Minecraft-style wolf from boxes (lightweight), scaled to player height
const wolfGroup = new THREE.Group();
// Simple palette
const palette = {
furLight: 0xdddddd,
fur: 0xbdbdbd,
furDark: 0x8e8e8e,
muzzle: 0xd6c3a1,
nose: 0x222222,
eyes: 0x101010,
collar: 0xc43b3b
};
const makeMat = (color) => new THREE.MeshStandardMaterial({ color, roughness: 1, metalness: 0, flatShading: true });
function makeBox(w, h, d, color) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), makeMat(color));
mesh.castShadow = false; mesh.receiveShadow = true;
return mesh;
}
// Dimensions
const LEG_H = 2.0, LEG_W = 1.0, LEG_D = 1.0;
const BODY_W = 4.0, BODY_H = 2.5, BODY_D = 7.0;
const HEAD_W = 3.0, HEAD_H = 2.6, HEAD_D = 3.0;
const MUZZ_W = 1.6, MUZZ_H = 1.4, MUZZ_D = 2.0;
const EAR_W = 0.6, EAR_H = 0.7, EAR_D = 0.4;
const TAIL_W = 1.0, TAIL_H = 1.0, TAIL_D = 4.0;
const bodyCenterY = LEG_H + BODY_H/2;
// Body
const body = makeBox(BODY_W, BODY_H, BODY_D, palette.fur);
body.position.set(0, bodyCenterY, 0);
wolfGroup.add(body);
const bodyTop = makeBox(BODY_W - 0.2, 0.4, BODY_D - 0.2, palette.furLight);
bodyTop.position.set(0, bodyCenterY + BODY_H/2 - 0.2, 0);
wolfGroup.add(bodyTop);
// Head + muzzle pivots
const headPivot = new THREE.Group();
headPivot.position.set(0, bodyCenterY + 0.1, BODY_D/2 + HEAD_D/2 - 0.1);
wolfGroup.add(headPivot);
const head = makeBox(HEAD_W, HEAD_H, HEAD_D, palette.furLight);
headPivot.add(head);
const muzzlePivot = new THREE.Group();
muzzlePivot.position.set(0, -0.2, HEAD_D/2);
headPivot.add(muzzlePivot);
const muzzle = makeBox(MUZZ_W, MUZZ_H, MUZZ_D, palette.muzzle);
muzzle.position.set(0, 0, MUZZ_D/2);
muzzlePivot.add(muzzle);
const nose = makeBox(0.6, 0.4, 0.6, palette.nose);
nose.position.set(0, MUZZ_H/2 - 0.2, MUZZ_D + 0.3);
muzzlePivot.add(nose);
const eyeL = makeBox(0.2, 0.4, 0.2, palette.eyes);
const eyeR = eyeL.clone();
eyeL.position.set(-HEAD_W/2 + 0.35, 0.3, HEAD_D/2 - 0.2);
eyeR.position.set( HEAD_W/2 - 0.35, 0.3, HEAD_D/2 - 0.2);
headPivot.add(eyeL, eyeR);
const earL = makeBox(EAR_W, EAR_H, EAR_D, palette.furDark);
const earR = earL.clone();
const earY = HEAD_H/2 + EAR_H/2 - 0.02;
const earX = HEAD_W/2 - EAR_W/2 - 0.02;
earL.position.set(-earX, earY, -0.2);
earR.position.set( earX, earY, -0.2);
headPivot.add(earL, earR);
const collar = makeBox(HEAD_W + 0.2, 0.4, 1.0, palette.collar);
collar.position.set(0, -HEAD_H/2 + 0.45, -HEAD_D/2 + 0.3);
headPivot.add(collar);
// Tail
const tailPivot = new THREE.Group();
tailPivot.position.set(0, bodyCenterY, -BODY_D/2 + 0.05);
wolfGroup.add(tailPivot);
const tail = makeBox(TAIL_W, TAIL_H, TAIL_D, palette.furDark);
tail.position.set(0, 0, -TAIL_D/2);
tailPivot.add(tail);
tailPivot.rotation.x = Math.PI * 0.30;
// Leg pivots (for simple run animation)
function makeLeg(x, z) {
const pivot = new THREE.Group();
pivot.position.set(x, LEG_H, z);
const upper = makeBox(LEG_W, LEG_H * 0.65, LEG_D, palette.furDark);
upper.position.set(0, -LEG_H * 0.325, 0);
const paw = makeBox(LEG_W, LEG_H * 0.35, LEG_D, palette.furLight);
paw.position.set(0, -LEG_H * 0.775, 0);
pivot.add(upper);
pivot.add(paw);
wolfGroup.add(pivot);
return pivot;
}
const legPos = [
[-(BODY_W/2 - LEG_W/2), (BODY_D/2 - LEG_D/2)],
[ (BODY_W/2 - LEG_W/2), (BODY_D/2 - LEG_D/2)],
[-(BODY_W/2 - LEG_W/2), -(BODY_D/2 - LEG_D/2)],
[ (BODY_W/2 - LEG_W/2), -(BODY_D/2 - LEG_D/2)]
];
const legFL = makeLeg(legPos[0][0], legPos[0][1]);
const legFR = makeLeg(legPos[1][0], legPos[1][1]);
const legRL = makeLeg(legPos[2][0], legPos[2][1]);
const legRR = makeLeg(legPos[3][0], legPos[3][1]);
// Scale down to match game scale (halve again per feedback)
wolfGroup.scale.setScalar(0.25);
// Initial placement
wolfGroup.position.set(x, getTerrainHeight(x, z), z);
G.scene.add(wolfGroup);
// Invisible hit proxies (head + body) for hitscan
const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
proxyHead.userData = { enemy: null, hitZone: 'head' };
// Attach to head pivot so it tracks bite/head motion and height
headPivot.add(proxyHead);
proxyHead.position.set(0, 0, 0);
const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
proxyBody.userData = { enemy: null, hitZone: 'body' };
// Attach to torso/body for reliable center-of-mass hits
body.add(proxyBody);
proxyBody.position.set(0, 0, 0);
// Counteract group scale so proxies remain reasonably hittable
const inv = 1 / wolfGroup.scale.x;
const proxyScale = inv * 1.0; // keep proxies around human size in world space
proxyHead.scale.setScalar(proxyScale);
proxyBody.scale.setScalar(proxyScale);
const enemy = {
type: 'wolf',
mesh: wolfGroup,
body: body,
pos: wolfGroup.position,
radius: CFG.wolf.radius,
hp: CFG.enemy.hp,
baseSpeed: (CFG.enemy.baseSpeed + CFG.enemy.speedPerWave * (G.waves.current - 1)) * CFG.wolf.speedMult,
damagePerSecond: 0, // not used for wolves; they bite instead
alive: true,
deathTimer: 0,
shootCooldown: 0,
helmet: null,
helmetAttached: false,
// Wolf-specific anim and attack state
animT: 0,
runPhase: 0,
biteTimer: 0,
biteCooldown: 0,
biting: false,
biteApplied: false,
// Pivots for anims
headPivot,
muzzlePivot,
muzzleBase: HEAD_D/2,
tailPivot,
legs: { FL: legFL, FR: legFR, RL: legRL, RR: legRR },
hitProxies: []
};
wolfGroup.userData = { enemy };
body.userData = { enemy, hitZone: 'body' };
head.userData = { enemy, hitZone: 'head' };
muzzle.userData = { enemy, hitZone: 'head' };
proxyHead.userData.enemy = enemy;
proxyBody.userData.enemy = enemy;
enemy.hitProxies.push(proxyBody, proxyHead);
G.enemies.push(enemy);
G.waves.aliveCount++;
return;
}
if (type === 'golem') {
// Blocky golem from simple boxes, with arm/leg pivots and a throw anchor
const golem = new THREE.Group();
// Materials inspired by the provided model
const iron = new THREE.MeshStandardMaterial({ color: 0xE0E0E0, roughness: 0.95, metalness: 0.05, flatShading: true });
const ironDark = new THREE.MeshStandardMaterial({ color: 0xCFCFCF, roughness: 0.95, metalness: 0.05, flatShading: true });
const woodish = new THREE.MeshStandardMaterial({ color: 0x8A6B58, roughness: 1.0, metalness: 0.0, flatShading: true });
const vine = new THREE.MeshStandardMaterial({ color: 0x2f8f38, roughness: 0.9, metalness: 0.0, flatShading: true });
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x661c1c, emissive: 0x5b0a0a, emissiveIntensity: 0.9, roughness: 1.0, flatShading: true });
// Dimensions (scaled down later to match world scale)
const bodyW = 4.0, bodyH = 4.6, bodyD = 2.2;
const headW = 2.2, headH = 2.2, headD = 2.0;
const armW = 1.3, armD = 1.3, armL = 5.0;
const handW = 1.5, handH = 0.9, handD = 1.5;
const legW = 1.2, legD = 1.2, legH = 3.2;
const footW = 1.6, footD = 1.8, footH = 0.8;
const legGap = 0.6;
const legX = (legW + legGap) * 0.5;
const hipsY = footH + legH;
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(bodyW, bodyH, bodyD), iron);
torso.position.y = hipsY + bodyH * 0.5;
torso.castShadow = false; torso.receiveShadow = true;
golem.add(torso);
// Head pivot
const headPivot = new THREE.Group();
headPivot.position.set(0, hipsY + bodyH, 0);
golem.add(headPivot);
const head = new THREE.Mesh(new THREE.BoxGeometry(headW, headH, headD), ironDark);
head.position.y = headH * 0.5;
head.castShadow = false; head.receiveShadow = true;
headPivot.add(head);
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 1.0), woodish);
nose.position.set(0, 0.2, headD * 0.5 + 0.5);
head.add(nose);
const eyeGeom = new THREE.BoxGeometry(0.35, 0.35, 0.12);
const leftEye = new THREE.Mesh(eyeGeom, eyeMat);
const rightEye = new THREE.Mesh(eyeGeom, eyeMat);
leftEye.position.set(-0.45, 0.55, headD * 0.5 + 0.07);
rightEye.position.set(0.45, 0.55, headD * 0.5 + 0.07);
head.add(leftEye, rightEye);
// Shoulders
const shoulderY = hipsY + bodyH - 0.2;
const shoulderX = bodyW * 0.5 + armW * 0.5 - 0.1;
function buildArm(sign = 1) {
const pivot = new THREE.Group();
pivot.position.set(sign * shoulderX, shoulderY, 0);
golem.add(pivot);
const arm = new THREE.Mesh(new THREE.BoxGeometry(armW, armL, armD), iron);
arm.position.y = -armL * 0.5;
arm.castShadow = false; arm.receiveShadow = true;
pivot.add(arm);
const hand = new THREE.Mesh(new THREE.BoxGeometry(handW, handH, handD), ironDark);
hand.position.y = -armL * 0.5 - handH * 0.5;
arm.add(hand);
const creeper1 = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.3, 0.10), vine);
creeper1.position.set(sign * 0.35, -0.8, armD * 0.5 + 0.06);
arm.add(creeper1);
// Return pivots plus a handle to hand for projectile spawn anchor
return { pivot, arm, hand };
}
const leftArm = buildArm(-1);
const rightArm = buildArm(1);
// Rock spawn anchor at right hand
const projectileSpawn = new THREE.Object3D();
projectileSpawn.position.set(0, -armL * 0.5 - handH, 0);
rightArm.arm.add(projectileSpawn);
function buildLeg(sign = 1) {
const pivot = new THREE.Group();
pivot.position.set(sign * legX, hipsY, 0);
golem.add(pivot);
const leg = new THREE.Mesh(new THREE.BoxGeometry(legW, legH, legD), iron);
leg.position.y = -legH * 0.5;
leg.castShadow = false; leg.receiveShadow = true;
pivot.add(leg);
const foot = new THREE.Mesh(new THREE.BoxGeometry(footW, footH, footD), ironDark);
foot.position.y = -legH * 0.5 - footH * 0.5;
leg.add(foot);
return { pivot, leg };
}
const leftLeg = buildLeg(-1);
const rightLeg = buildLeg(1);
// A few vines on torso & leg
function addVine(target, x, y, z, h) {
const strip = new THREE.Mesh(new THREE.BoxGeometry(0.14, h, 0.12), vine);
strip.position.set(x, y, z);
strip.castShadow = false; strip.receiveShadow = true;
target.add(strip);
}
addVine(torso, 0.9, 0.3, bodyD * 0.5 + 0.07, 2.2);
addVine(torso, -0.2, -0.7, bodyD * 0.5 + 0.07, 1.6);
addVine(leftLeg.leg, -0.3, -0.2, legD * 0.5 + 0.07, 1.8);
// Scale to world; triple the previous size
const scale = 0.66; // 3x bigger than before
golem.scale.setScalar(scale);
// Place in world
golem.position.set(x, getTerrainHeight(x, z), z);
G.scene.add(golem);
// Hit proxies: scale-compensated so they remain hittable in world units
const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
headPivot.add(proxyHead);
torso.add(proxyBody);
const inv = 1 / scale;
// Scale proxies up so hits match the larger body
const proxyScale = inv * 3.0;
proxyHead.scale.setScalar(proxyScale);
proxyBody.scale.setScalar(proxyScale);
proxyHead.userData = { enemy: null, hitZone: 'head' };
proxyBody.userData = { enemy: null, hitZone: 'body' };
const enemy = {
type: 'golem',
mesh: golem,
body: torso,
pos: golem.position,
radius: CFG.golem?.radius ?? 1.2,
hp: (CFG.golem?.hp ?? 260),
baseSpeed: (CFG.golem?.baseSpeed ?? 1.7) + (CFG.golem?.speedPerWave ?? 0.12) * (G.waves.current - 1),
damagePerSecond: CFG.golem?.dps ?? CFG.enemy.dps,
alive: true,
deathTimer: 0,
projectileSpawn,
shootCooldown: 0,
helmet: null,
helmetAttached: false,
// LOS throttling
losTimer: 0,
hasLOS: true,
hitProxies: [],
// Anim state
animT: 0,
headPivot,
armL: leftArm?.pivot,
armR: rightArm?.pivot,
legL: leftLeg?.pivot,
legR: rightLeg?.pivot,
// Throw state
throwing: false,
throwTimer: 0,
throwSpawned: false
};
golem.userData = { enemy };
torso.userData = { enemy, hitZone: 'body' };
head.userData = { enemy, hitZone: 'head' };
proxyHead.userData.enemy = enemy;
proxyBody.userData.enemy = enemy;
enemy.hitProxies.push(proxyBody, proxyHead);
G.enemies.push(enemy);
G.waves.aliveCount++;
return;
}
// Create enemy mesh (orc with cute helmet + bow)
const enemyGroup = new THREE.Group();
const skin = MAT.skin;
const tunic = MAT.tunic;
const pants = MAT.pants;
const metal = MAT.metal;
const leather = MAT.leather;
const silver = MAT.silver;
// Torso (bulky base under armor)
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.1, 0.4), tunic);
torso.position.set(0, 1.3, 0);
torso.castShadow = false; torso.receiveShadow = true;
enemyGroup.add(torso);
// Silver armor: keep back plate + pauldrons; remove front plate for subtler look
const armorPieces = [];
{
const backPlate = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.58, 0.10), metal);
backPlate.position.set(0, 1.34, 0.26);
backPlate.castShadow = false; backPlate.receiveShadow = true;
backPlate.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(backPlate); armorPieces.push(backPlate);
// Shoulder pauldrons (simple domes)
const pauldronGeo = new THREE.SphereGeometry(0.27, 12, 10);
const pL = new THREE.Mesh(pauldronGeo, silver);
pL.scale.y = 0.6; pL.position.set(-0.52, 1.62, -0.02);
pL.castShadow = false; pL.receiveShadow = true; pL.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(pL); armorPieces.push(pL);
const pR = new THREE.Mesh(pauldronGeo, silver);
pR.scale.y = 0.6; pR.position.set(0.52, 1.62, -0.02);
pR.castShadow = false; pR.receiveShadow = true; pR.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(pR); armorPieces.push(pR);
// Leather belt with a simple metal buckle
const belt = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.04, 8, 20), leather);
belt.rotation.x = Math.PI / 2; belt.position.set(0, 1.0, 0);
belt.castShadow = false; belt.receiveShadow = true; belt.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(belt); armorPieces.push(belt);
const buckle = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.12, 0.03), metal);
buckle.position.set(0, 1.0, -0.34);
buckle.castShadow = false; buckle.receiveShadow = true; buckle.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(buckle); armorPieces.push(buckle);
// Small rivets on back plate corners only
const rivetGeo = new THREE.SphereGeometry(0.04, 8, 6);
const rivets = [
new THREE.Vector3(-0.34, 1.64, 0.26), new THREE.Vector3(0.34, 1.64, 0.26),
new THREE.Vector3(-0.34, 1.06, 0.26), new THREE.Vector3(0.34, 1.06, 0.26)
];
for (const pos of rivets) {
const r = new THREE.Mesh(rivetGeo, RIVET_MAT);
r.position.copy(pos);
r.castShadow = false; r.receiveShadow = true; r.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(r); armorPieces.push(r);
}
}
// Head (slightly larger) + cute helmet (small dome)
const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
head.position.set(0, 1.95, 0);
head.castShadow = false; head.receiveShadow = true;
enemyGroup.add(head);
const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.33, 12, 10), metal);
helmet.scale.y = 0.7;
helmet.position.set(0, 2.07, 0);
helmet.castShadow = false; helmet.receiveShadow = true;
// Tag helmet as a head hit zone so headshots register even when helmet is hit
helmet.userData = { enemy: null, hitZone: 'head', isHelmet: true };
enemyGroup.add(helmet);
// Arms
const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
armL.position.set(-0.5, 1.35, 0);
armL.castShadow = false; enemyGroup.add(armL);
const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
armR.position.set(0.5, 1.35, 0);
armR.castShadow = false; enemyGroup.add(armR);
// Legs
const legL = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
legL.position.set(-0.22, 0.5, 0);
legL.castShadow = false; enemyGroup.add(legL);
const legR = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
legR.position.set(0.22, 0.5, 0);
legR.castShadow = false; enemyGroup.add(legR);
// Bow (curved torus segment + string) held slightly forward on left side
const bowGroup = new THREE.Group();
const bow = new THREE.Mesh(
new THREE.TorusGeometry(0.45, 0.03, 8, 24, Math.PI * 0.9),
leather
);
bow.rotation.z = Math.PI / 2; // orient curve
bowGroup.add(bow);
const stringGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -0.42, 0), new THREE.Vector3(0, 0.42, 0)
]);
const string = new THREE.Line(stringGeo, STRING_MAT);
bowGroup.add(string);
bowGroup.position.set(-0.32, 1.35, -0.35);
enemyGroup.add(bowGroup);
// Arrow spawn point near top-tip of bow, facing forwards
const projectileSpawn = new THREE.Object3D();
projectileSpawn.position.set(-0.32, 1.35, -0.55);
enemyGroup.add(projectileSpawn);
enemyGroup.position.set(x, getTerrainHeight(x, z), z);
G.scene.add(enemyGroup);
// Add invisible hit proxies (head + body) for fast ray hits
const proxyHead = new THREE.Mesh(PROXY_HEAD, PROXY_MAT);
proxyHead.position.set(0, 1.95, 0);
proxyHead.userData = { enemy: null, hitZone: 'head' };
enemyGroup.add(proxyHead);
const proxyBody = new THREE.Mesh(PROXY_BODY, PROXY_MAT);
proxyBody.position.set(0, 1.3, 0);
proxyBody.userData = { enemy: null, hitZone: 'body' };
enemyGroup.add(proxyBody);
// Tag hit zones for headshot logic
torso.userData = { enemy: null, hitZone: 'body' };
head.userData = { enemy: null, hitZone: 'head' };
armL.userData = { enemy: null, hitZone: 'limb' };
armR.userData = { enemy: null, hitZone: 'limb' };
legL.userData = { enemy: null, hitZone: 'limb' };
legR.userData = { enemy: null, hitZone: 'limb' };
bow.userData = { enemy: null, hitZone: 'gear' };
proxyHead.userData.enemy = null; // will assign below
proxyBody.userData.enemy = null;
const enemy = {
type: 'orc',
mesh: enemyGroup,
body: torso,
pos: enemyGroup.position,
radius: CFG.enemy.radius,
hp: CFG.enemy.hp,
baseSpeed: CFG.enemy.baseSpeed + CFG.enemy.speedPerWave * (G.waves.current - 1),
damagePerSecond: CFG.enemy.dps,
alive: true,
deathTimer: 0,
projectileSpawn,
shootCooldown: 0,
helmet,
helmetAttached: true,
// LOS throttling to avoid per-frame raycasting
losTimer: 0,
hasLOS: true,
hitProxies: []
};
enemyGroup.userData = { enemy };
torso.userData.enemy = enemy;
head.userData.enemy = enemy;
armL.userData.enemy = enemy;
armR.userData.enemy = enemy;
legL.userData.enemy = enemy;
legR.userData.enemy = enemy;
bow.userData.enemy = enemy;
helmet.userData.enemy = enemy;
proxyHead.userData.enemy = enemy;
proxyBody.userData.enemy = enemy;
enemy.hitProxies.push(proxyBody, proxyHead);
// Assign enemy to armor pieces so they count as body hits
for (const part of armorPieces) {
if (part && part.userData) part.userData.enemy = enemy;
}
G.enemies.push(enemy);
G.waves.aliveCount++;
}
export function updateEnemies(delta, onPlayerDeath) {
for (let i = G.enemies.length - 1; i >= 0; i--) {
const enemy = G.enemies[i];
if (!enemy.alive) {
// Death handling and cleanup
spawnDustAt(enemy.pos);
enemy.mesh.traverse((obj) => {
if (obj.isMesh && obj.geometry) {
G.disposeQueue.push(obj.geometry);
obj.geometry = null;
}
});
G.scene.remove(enemy.mesh);
G.enemies.splice(i, 1);
continue;
}
// Move towards player with simple avoidance
const dir = G.tmpV1.copy(G.player.pos).sub(enemy.pos);
dir.y = 0;
const dist = dir.length();
if (dist > 0) {
dir.normalize();
const moveSpeed = enemy.baseSpeed * delta;
enemy.pos.add(dir.multiplyScalar(moveSpeed));
const nearby = getNearbyTrees(enemy.pos.x, enemy.pos.z, 4);
for (let ti = 0; ti < nearby.length; ti++) {
const tree = nearby[ti];
const dx = enemy.pos.x - tree.x;
const dz = enemy.pos.z - tree.z;
const treeDist = Math.sqrt(dx * dx + dz * dz);
const minDist = enemy.radius + tree.radius;
if (treeDist < minDist && treeDist > 0) {
const pushX = (dx / treeDist) * (minDist - treeDist);
const pushZ = (dz / treeDist) * (minDist - treeDist);
enemy.pos.x += pushX;
enemy.pos.z += pushZ;
}
}
}
// Clamp to terrain and face player
enemy.pos.y = getTerrainHeight(enemy.pos.x, enemy.pos.z);
enemy.mesh.lookAt(G.player.pos.x, enemy.pos.y + 1.4, G.player.pos.z);
// Dispatch to type-specific behavior
const updater = ENEMY_UPDATERS[enemy.type];
if (updater) updater(enemy, delta, dist, onPlayerDeath);
}
}