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