Spaces:
Running
Running
import * as THREE from 'three'; | |
import { CFG } from './config.js'; | |
import { G } from './globals.js'; | |
import { updateHUD } from './hud.js'; | |
import { spawnTracer, spawnImpact, spawnMuzzleFlash } from './fx.js'; | |
import { getTreesInAABB, getTerrainHeight } from './world.js'; | |
import { spawnShellCasing } from './casings.js'; | |
import { popHelmet } from './helmets.js'; | |
import { beginReload } from './weapon.js'; | |
import { spawnHealthOrbs } from './pickups.js'; | |
import { playGunshot, playHeadshot } from './audio.js'; | |
// Reusable temps to reduce GC | |
const TMP2 = new THREE.Vector2(); | |
const TMPv1 = new THREE.Vector3(); | |
const TMPv2 = new THREE.Vector3(); | |
const TMPn = new THREE.Vector3(); | |
const HIT_OBJECTS = []; | |
const UP = new THREE.Vector3(0, 1, 0); | |
export function performShooting(delta) { | |
G.shootCooldown -= delta; | |
if (G.input.shoot && G.shootCooldown <= 0 && G.state === 'playing') { | |
if (G.weapon.reloading) return; | |
const infinite = G.weapon.infiniteAmmoTimer > 0; | |
if (!infinite && G.weapon.ammo <= 0) { | |
G.shootCooldown = 0.2; | |
G.weapon.recoil += CFG.gun.recoilKick * 0.25; | |
return; | |
} | |
G.shootCooldown = 1 / (CFG.gun.rof * (G.weapon.rofMult || 1)); | |
if (!infinite) { | |
G.weapon.ammo--; | |
} | |
G.weapon.recoil += CFG.gun.recoilKick; | |
updateHUD(); | |
// Increase dynamic spread per shot (clamped) | |
const inc = CFG.gun.spreadShotIncrease || 0; | |
const maxS = CFG.gun.spreadMax || 0.02; | |
G.weapon.spread = Math.min(maxS, G.weapon.spread + inc); | |
// View recoil: add a pitch up and small random yaw | |
const pitchKick = THREE.MathUtils.degToRad(CFG.gun.viewKickPitchDeg || 0); | |
const yawKick = THREE.MathUtils.degToRad((CFG.gun.viewKickYawDeg || 0) * (G.random() * 2 - 1)); | |
G.weapon.viewPitch += pitchKick; | |
G.weapon.viewYaw += yawKick; | |
const spread = G.weapon.spread || (CFG.gun.bloom || 0); | |
const nx = (G.random() - 0.5) * spread * 2; | |
const ny = (G.random() - 0.5) * spread * 2; | |
TMP2.set(nx, ny); | |
G.raycaster.setFromCamera(TMP2, G.camera); | |
G.raycaster.far = CFG.gun.range; | |
// Build hit list using lightweight proxies and trunk-only blockers | |
HIT_OBJECTS.length = 0; | |
for (let i = 0; i < G.enemies.length; i++) { | |
const e = G.enemies[i]; | |
if (!e.alive || !e.hitProxies) continue; | |
// push proxies directly; no recursion needed | |
for (let k = 0; k < e.hitProxies.length; k++) HIT_OBJECTS.push(e.hitProxies[k]); | |
} | |
for (let i = 0; i < G.blockers.length; i++) HIT_OBJECTS.push(G.blockers[i]); | |
const hits = G.raycaster.intersectObjects(HIT_OBJECTS, false); | |
G.weapon.muzzle.getWorldPosition(TMPv1); | |
G.camera.getWorldDirection(TMPv2); | |
let end = TMPv2.clone().multiplyScalar(CFG.gun.range).add(G.camera.position); | |
let firstHit = null; | |
// Choose nearest of raycast hit (enemy/ground) and tree trunk collision | |
const origin = G.camera.position; | |
const rayFirst = hits.length > 0 ? hits[0] : null; | |
const rayDist = rayFirst ? origin.distanceTo(rayFirst.point) : Infinity; | |
// Candidate tree hit along the segment (origin -> max range) | |
let treeHitU = Infinity; | |
{ | |
const minX = Math.min(origin.x, end.x) - 2.0; | |
const maxX = Math.max(origin.x, end.x) + 2.0; | |
const minZ = Math.min(origin.z, end.z) - 2.0; | |
const maxZ = Math.max(origin.z, end.z) + 2.0; | |
const cands = getTreesInAABB(minX, minZ, maxX, maxZ); | |
const ox = origin.x, oz = origin.z; | |
const ex = end.x, ez = end.z; | |
const vx = ex - ox, vz = ez - oz; | |
const vv = vx * vx + vz * vz || 1; | |
for (let i = 0; i < cands.length; i++) { | |
const t = cands[i]; | |
const wx = t.x - ox, wz = t.z - oz; | |
let u = (wx * vx + wz * vz) / vv; | |
if (u < 0) u = 0; else if (u > 1) u = 1; | |
const px = ox + u * vx, pz = oz + u * vz; | |
const dx = t.x - px, dz = t.z - pz; | |
const rr = (t.radius + 0.2) * (t.radius + 0.2); | |
if (dx * dx + dz * dz <= rr) { | |
const yAt = origin.y + (end.y - origin.y) * u; | |
if (yAt < 8 && u < treeHitU) treeHitU = u; | |
} | |
} | |
} | |
if (treeHitU !== Infinity && (treeHitU * origin.distanceTo(end)) < rayDist) { | |
// Trunk is the nearest hit | |
const hitPos = new THREE.Vector3( | |
origin.x + (end.x - origin.x) * treeHitU, | |
origin.y + (end.y - origin.y) * treeHitU, | |
origin.z + (end.z - origin.z) * treeHitU | |
); | |
const gy = getTerrainHeight(hitPos.x, hitPos.z) + 0.02; | |
if (hitPos.y < gy) hitPos.y = gy; | |
end.copy(hitPos); | |
firstHit = { point: hitPos, object: null, face: null }; | |
} else if (rayFirst) { | |
firstHit = rayFirst; | |
end.copy(rayFirst.point); | |
} | |
if (firstHit && firstHit.object) { | |
// Find enemy and hit zone by traversing up the hierarchy | |
function findEnemyAndZone(obj) { | |
let cur = obj; | |
while (cur) { | |
if (cur.userData && cur.userData.enemy) { | |
return { enemy: cur.userData.enemy, zone: cur.userData.hitZone || 'body' }; | |
} | |
cur = cur.parent; | |
} | |
return { enemy: null, zone: null }; | |
} | |
const { enemy, zone } = findEnemyAndZone(firstHit.object); | |
if (enemy && enemy.alive) { | |
// Trigger hitmarker HUD flash | |
G.hitFlash = Math.min(1, (G.hitFlash || 0) + 1); | |
const isHead = zone === 'head'; | |
const dmg = CFG.gun.damage * (isHead ? CFG.gun.headshotMult : 1); | |
enemy.hp -= dmg; | |
if (isHead) playHeadshot(); | |
// If headshot, pop the helmet off with a kick in shot direction | |
if (isHead && enemy.helmetAttached) { | |
const shotDir = new THREE.Vector3(); | |
G.camera.getWorldDirection(shotDir); | |
popHelmet(enemy, shotDir, firstHit.point); | |
} | |
if (enemy.hp <= 0) { | |
enemy.alive = false; | |
enemy.deathTimer = 0; | |
G.waves.aliveCount--; | |
G.player.score += isHead ? 15 : 10; | |
// Drop more orbs for special enemies | |
if (enemy.type === 'golem') { | |
const cnt = 15 + Math.floor(G.random() * 6); // 15..20 | |
spawnHealthOrbs(enemy.pos, cnt); | |
} else { | |
const cnt = 1 + Math.floor(G.random() * 5); | |
spawnHealthOrbs(enemy.pos, cnt); | |
} | |
} | |
} else if (firstHit.object !== G.ground) { | |
const n = firstHit.face?.normal | |
? TMPn.copy(firstHit.face.normal).transformDirection(firstHit.object.matrixWorld) | |
: UP; | |
spawnImpact(firstHit.point, n); | |
} | |
} else if (firstHit && !firstHit.object) { | |
// Blocked by a tree trunk approximation | |
spawnImpact(firstHit.point, UP); | |
} | |
spawnTracer(TMPv1, end); | |
spawnMuzzleFlash(); | |
spawnShellCasing(); | |
playGunshot(); | |
} | |
if ( | |
!G.weapon.reloading && | |
G.weapon.infiniteAmmoTimer <= 0 && | |
G.weapon.ammo === 0 && | |
(G.weapon.reserve > 0 || G.weapon.reserve === Infinity) | |
) { | |
beginReload(); | |
} | |
} | |