Spaces:
Running
Running
| 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); | |
| } | |
| } | |