Codex CLI
feat(powerups): add infinite ammo powerup mechanics and UI integration
a646668
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();
}
}