Spaces:
Running
Running
import * as THREE from 'three'; | |
import { G } from './globals.js'; | |
import { CFG } from './config.js'; | |
import { getTerrainHeight } from './world.js'; | |
import { playPowerupPickup } from './audio.js'; | |
// Share orb material/geometry to avoid per-orb allocations | |
// Switch to unlit Basic material so orbs glow without extra scene lights | |
const ORB_MAT = new THREE.MeshBasicMaterial({ | |
color: 0x5cff9a | |
}); | |
const ORB_GEO = new THREE.SphereGeometry(0.12, 14, 12); | |
// Accelerator (ROF boost) shared resources | |
// Battery aesthetic with yellow accent/glow | |
const ACCEL_COLOR = 0xffd84d; // warm yellow | |
const ACCEL_MAT = new THREE.MeshBasicMaterial({ color: ACCEL_COLOR, fog: false }); | |
const ACCEL_SEG_GEO = new THREE.BoxGeometry(0.12, 0.42, 0.06); // repurposed for plus symbol arms | |
const GLOW_TEX = makeGlowTexture(128, 1, 0); | |
function makeGlowTexture(size = 128, inner = 1, outer = 0) { | |
const canvas = document.createElement('canvas'); | |
canvas.width = canvas.height = size; | |
const ctx = canvas.getContext('2d'); | |
const r = size / 2; | |
const grad = ctx.createRadialGradient(r, r, 0, r, r, r); | |
grad.addColorStop(0, `rgba(255,255,255,${inner})`); | |
grad.addColorStop(1, `rgba(255,255,255,${outer})`); | |
ctx.fillStyle = grad; | |
ctx.beginPath(); | |
ctx.arc(r, r, r, 0, Math.PI * 2); | |
ctx.fill(); | |
const tex = new THREE.CanvasTexture(canvas); | |
tex.generateMipmaps = false; | |
tex.minFilter = THREE.LinearFilter; | |
tex.magFilter = THREE.LinearFilter; | |
return tex; | |
} | |
function makeAcceleratorMesh() { | |
const g = new THREE.Group(); | |
// --- Battery body --- | |
const bodyMat = new THREE.MeshBasicMaterial({ color: 0x1c1f24, fog: false }); // dark shell | |
const bodyGeo = new THREE.CylinderGeometry(0.22, 0.22, 0.72, 18, 1); | |
const body = new THREE.Mesh(bodyGeo, bodyMat); | |
body.position.set(0, 0, 0); | |
g.add(body); | |
// Metallic positive terminal cap (+) on top | |
const capMat = new THREE.MeshBasicMaterial({ color: 0xcfd3d6, fog: false }); | |
const capGeo = new THREE.CylinderGeometry(0.13, 0.13, 0.08, 18, 1); | |
const cap = new THREE.Mesh(capGeo, capMat); | |
cap.position.set(0, 0.40, 0); | |
g.add(cap); | |
// Accent stripe around the body (green energy band) | |
const stripeGeo = new THREE.CylinderGeometry(0.225, 0.225, 0.18, 18, 1); | |
const stripe = new THREE.Mesh(stripeGeo, ACCEL_MAT); | |
stripe.position.set(0, 0.0, 0); | |
g.add(stripe); | |
// Embossed plus symbol on the front face | |
const plusMat = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false }); | |
const plusH = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.04, 0.02), plusMat); | |
const plusV = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.02), plusMat); | |
// Place slightly off the surface to avoid z-fighting, near the upper third | |
plusH.position.set(0, 0.18, 0.205); | |
plusV.position.set(0, 0.18, 0.205); | |
g.add(plusH); | |
g.add(plusV); | |
// --- Glow sprites (inner core + outer aura) --- | |
const innerMat = new THREE.SpriteMaterial({ | |
map: GLOW_TEX, | |
color: ACCEL_COLOR, | |
transparent: true, | |
opacity: 0.45, | |
depthWrite: false, | |
depthTest: true, | |
blending: THREE.NormalBlending, | |
fog: false | |
}); | |
const outerMat = new THREE.SpriteMaterial({ | |
map: GLOW_TEX, | |
color: ACCEL_COLOR, | |
transparent: true, | |
opacity: 0.45, | |
depthWrite: false, | |
depthTest: true, | |
blending: THREE.AdditiveBlending, | |
fog: false | |
}); | |
const inner = new THREE.Sprite(innerMat); | |
const outer = new THREE.Sprite(outerMat); | |
inner.scale.set(0.7, 0.7, 1); | |
outer.scale.set(1.7, 1.7, 1); | |
g.add(outer); | |
g.add(inner); | |
g.castShadow = false; | |
g.receiveShadow = false; | |
// Expose for animation | |
g.userData.glowInner = inner; | |
g.userData.glowOuter = outer; | |
return g; | |
} | |
// Infinite ammo (indigo bullet) shared resources | |
const INF_COLOR = 0x6366f1; // brighter indigo | |
function makeInfiniteAmmoMesh() { | |
const g = new THREE.Group(); | |
// Bullet body: cylinder + conical tip | |
const bodyMat = new THREE.MeshBasicMaterial({ color: INF_COLOR, fog: false }); | |
const bodyGeo = new THREE.CylinderGeometry(0.10, 0.10, 0.52, 16, 1); | |
const body = new THREE.Mesh(bodyGeo, bodyMat); | |
body.position.set(0, 0.0, 0); | |
g.add(body); | |
const tipGeo = new THREE.ConeGeometry(0.10, 0.20, 16); | |
const tip = new THREE.Mesh(tipGeo, bodyMat); | |
tip.position.set(0, 0.36, 0); | |
g.add(tip); | |
// Base cap | |
const baseMat = new THREE.MeshBasicMaterial({ color: 0x221133, fog: false }); | |
const baseGeo = new THREE.CylinderGeometry(0.11, 0.11, 0.06, 16, 1); | |
const base = new THREE.Mesh(baseGeo, baseMat); | |
base.position.set(0, -0.29, 0); | |
g.add(base); | |
// Glow sprites similar to accelerator | |
const innerMat = new THREE.SpriteMaterial({ | |
map: GLOW_TEX, | |
color: INF_COLOR, | |
transparent: true, | |
opacity: 0.45, | |
depthWrite: false, | |
depthTest: true, | |
blending: THREE.NormalBlending, | |
fog: false | |
}); | |
const outerMat = new THREE.SpriteMaterial({ | |
map: GLOW_TEX, | |
color: INF_COLOR, | |
transparent: true, | |
opacity: 0.45, | |
depthWrite: false, | |
depthTest: true, | |
blending: THREE.AdditiveBlending, | |
fog: false | |
}); | |
const inner = new THREE.Sprite(innerMat); | |
const outer = new THREE.Sprite(outerMat); | |
inner.scale.set(0.7, 0.7, 1); | |
outer.scale.set(1.7, 1.7, 1); | |
g.add(outer); | |
g.add(inner); | |
g.castShadow = false; | |
g.receiveShadow = false; | |
g.userData.glowInner = inner; | |
g.userData.glowOuter = outer; | |
return g; | |
} | |
// Spawn N accelerator powerups at random world locations | |
// They float near ground and rotate, granting x2 ROF for 20s on pickup | |
export function spawnAccelerators(count) { | |
const n = Math.max(0, Math.min(6, Math.floor(count))); | |
const half = CFG.forestSize / 2; | |
const margin = 12; | |
function sampleAroundAnchor() { | |
const a = G.waves?.spawnAnchor; | |
if (!a) return null; | |
// Uniform-in-area annulus around wave spawn anchor | |
const Rmin = 8; | |
const Rmax = 26; | |
const u = G.random(); | |
const r = Math.sqrt(u * (Rmax * Rmax - Rmin * Rmin) + Rmin * Rmin); | |
const t = G.random() * Math.PI * 2; | |
let x = a.x + Math.cos(t) * r; | |
let z = a.z + Math.sin(t) * r; | |
// Clamp to playable bounds | |
x = Math.max(-half + margin, Math.min(half - margin, x)); | |
z = Math.max(-half + margin, Math.min(half - margin, z)); | |
return { x, z }; | |
} | |
function sampleGlobal() { | |
// Fallback: uniform across map square, reject inside clearing | |
const clear = (CFG.clearRadius || 12) + 4; | |
for (let tries = 0; tries < 10; tries++) { | |
const x = (G.random() * 2 - 1) * (half - margin); | |
const z = (G.random() * 2 - 1) * (half - margin); | |
if (Math.hypot(x, z) < clear) continue; | |
return { x, z }; | |
} | |
return { x: (G.random() * 2 - 1) * (half - margin), z: (G.random() * 2 - 1) * (half - margin) }; | |
} | |
for (let i = 0; i < n; i++) { | |
const pt = sampleAroundAnchor() || sampleGlobal(); | |
const gy = getTerrainHeight(pt.x, pt.z); | |
const group = makeAcceleratorMesh(); | |
const baseY = gy + 0.60; // raise so bolt never clips ground | |
group.position.set(pt.x, baseY + 0.14, pt.z); | |
group.scale.setScalar(1.5); // 50% bigger | |
G.scene.add(group); | |
const p = { | |
type: 'accelerator', | |
mesh: group, | |
pos: group.position, | |
baseY, | |
bobT: G.random() * Math.PI * 2, | |
rotSpeed: 2.6 + G.random() * 1.2, | |
glowInner: group.userData.glowInner, | |
glowOuter: group.userData.glowOuter, | |
glowT: G.random() * Math.PI * 2 | |
}; | |
G.powerups.push(p); | |
} | |
} | |
// Spawn N infinite-ammo powerups (indigo bullet) scattered around waves anchor | |
export function spawnInfiniteAmmo(count) { | |
const n = Math.max(0, Math.min(3, Math.floor(count))); | |
const half = CFG.forestSize / 2; | |
const margin = 12; | |
function sampleAroundAnchor() { | |
const a = G.waves?.spawnAnchor; | |
if (!a) return null; | |
const Rmin = 8; | |
const Rmax = 26; | |
const u = G.random(); | |
const r = Math.sqrt(u * (Rmax * Rmax - Rmin * Rmin) + Rmin * Rmin); | |
const t = G.random() * Math.PI * 2; | |
let x = a.x + Math.cos(t) * r; | |
let z = a.z + Math.sin(t) * r; | |
x = Math.max(-half + margin, Math.min(half - margin, x)); | |
z = Math.max(-half + margin, Math.min(half - margin, z)); | |
return { x, z }; | |
} | |
function sampleGlobal() { | |
const clear = (CFG.clearRadius || 12) + 4; | |
for (let tries = 0; tries < 10; tries++) { | |
const x = (G.random() * 2 - 1) * (half - margin); | |
const z = (G.random() * 2 - 1) * (half - margin); | |
if (Math.hypot(x, z) < clear) continue; | |
return { x, z }; | |
} | |
return { x: (G.random() * 2 - 1) * (half - margin), z: (G.random() * 2 - 1) * (half - margin) }; | |
} | |
for (let i = 0; i < n; i++) { | |
const pt = sampleAroundAnchor() || sampleGlobal(); | |
const gy = getTerrainHeight(pt.x, pt.z); | |
const group = makeInfiniteAmmoMesh(); | |
const baseY = gy + 0.60; | |
group.position.set(pt.x, baseY + 0.14, pt.z); | |
group.scale.setScalar(1.5); // match accelerator size | |
G.scene.add(group); | |
const p = { | |
type: 'infiniteAmmo', | |
mesh: group, | |
pos: group.position, | |
baseY, | |
bobT: G.random() * Math.PI * 2, | |
rotSpeed: 2.8 + G.random() * 1.2, | |
glowInner: group.userData.glowInner, | |
glowOuter: group.userData.glowOuter, | |
glowT: G.random() * Math.PI * 2 | |
}; | |
G.powerups.push(p); | |
} | |
} | |
// Spawns N small glowing green health orbs around a position | |
export function spawnHealthOrbs(center, count) { | |
// Allow larger drops (e.g., golem 15–20); cap to keep it reasonable | |
const n = Math.max(1, Math.min(30, Math.floor(count))); | |
for (let i = 0; i < n; i++) { | |
const group = new THREE.Group(); | |
// Slight radial scatter around center (tighter grouping) | |
const r = 0.12 + G.random() * 0.48; // was up to ~1.4 | |
const t = G.random() * Math.PI * 2; | |
const startY = 0.9 + G.random() * 0.8; // spawn a bit in the air | |
group.position.set( | |
center.x + Math.cos(t) * r, | |
startY, | |
center.z + Math.sin(t) * r | |
); | |
const sphere = new THREE.Mesh(ORB_GEO, ORB_MAT); | |
sphere.castShadow = false; | |
sphere.receiveShadow = false; | |
group.add(sphere); | |
G.scene.add(group); | |
// Initial outward + upward velocity (reduced to keep grouping tighter) | |
const dir = new THREE.Vector3(Math.cos(t), 0, Math.sin(t)); | |
const speed = 0.8 + G.random() * 1.4; // was up to ~4.8 | |
const vel = dir.multiplyScalar(speed); | |
vel.y = 2.2 + G.random() * 1.6; // was up to ~5.5 | |
const orb = { | |
mesh: group, | |
light: null, | |
pos: group.position, | |
radius: 0.7, // legacy; pickup now uses absolute distance | |
heal: 1, | |
bobT: G.random() * Math.PI * 2, | |
vel, | |
state: 'air', // 'air' | 'settled' | |
settleTimer: 0, | |
baseY: 0.2, | |
magnet: false | |
}; | |
G.orbs.push(orb); | |
} | |
} | |
export function updatePickups(delta) { | |
// Attraction/pickup thresholds (meters) | |
const ATTRACT_RADIUS = 5.0; // start pulling from farther away | |
const PICKUP_DIST = 3.0; // auto-collect distance | |
for (let i = G.orbs.length - 1; i >= 0; i--) { | |
const o = G.orbs[i]; | |
// Simple physics: integrate while in air, bounce on ground, then settle to bob | |
if (o.state !== 'settled') { | |
// Gravity | |
if (o.vel) o.vel.y -= 18 * delta; | |
// Integrate | |
if (o.vel) o.pos.addScaledVector(o.vel, delta); | |
// Ground collision (sphere radius ~0.12) | |
const ground = getTerrainHeight(o.pos.x, o.pos.z); | |
const floor = ground + 0.12; | |
if (o.pos.y <= floor) { | |
o.pos.y = floor; | |
if (o.vel) { | |
const bounce = 0.35; | |
const friction = 10.0; // stronger horizontal damping for tighter spread | |
if (Math.abs(o.vel.y) > 0.6) { | |
o.vel.y = -o.vel.y * bounce; | |
} else { | |
o.vel.y = 0; | |
} | |
// Horizontal friction | |
const fr = Math.max(0, 1 - friction * delta); | |
o.vel.x *= fr; | |
o.vel.z *= fr; | |
// Settle detection | |
const horizSpeed = Math.hypot(o.vel.x, o.vel.z); | |
if (horizSpeed < 0.15 && Math.abs(o.vel.y) < 0.05) { | |
o.settleTimer += delta; | |
if (o.settleTimer > 0.15) { | |
o.state = 'settled'; | |
o.baseY = ground + 0.2; | |
// Snap to a clean base height | |
o.pos.y = o.baseY; | |
} | |
} else { | |
o.settleTimer = 0; | |
} | |
} | |
} | |
} | |
// Visuals: rotation and glow pulse | |
o.bobT += delta * 2.0; | |
// Speed up spin slightly when magnetized | |
const spin = o.magnet ? 4.0 : 1.5; | |
o.mesh.rotation.y += delta * spin; | |
// No dynamic light; keep unlit glow cheap | |
// If settled and not magnetized, apply gentle bob around baseY | |
if (o.state === 'settled' && !o.magnet) { | |
o.pos.y = o.baseY + Math.sin(o.bobT) * 0.06; | |
} | |
// Distance on ground plane (for feel); magnet + pickup thresholds | |
const dx = o.pos.x - G.player.pos.x; | |
const dz = o.pos.z - G.player.pos.z; | |
const dist = Math.hypot(dx, dz); | |
// Begin attraction when close enough | |
if (!o.magnet && dist <= ATTRACT_RADIUS) { | |
o.magnet = true; | |
} | |
// Attraction animation: pull toward player smoothly | |
if (o.magnet) { | |
// Disable physics while magnetized for a clean pull | |
if (o.vel) { o.vel.set(0, 0, 0); } | |
const t = Math.max(0, Math.min(1, 1 - dist / ATTRACT_RADIUS)); | |
// Non-linear catch-up factor for a snappy feel | |
const alpha = 1 - Math.pow(1 - Math.min(0.95, 0.15 + t * 0.8), Math.max(1, delta * 60)); | |
// Target toward player's position (eye-level), looks good with vertical glide | |
o.pos.lerp(G.player.pos, alpha); | |
// Scale up slightly as it gets closer | |
const s = 1 + 0.35 * t; | |
o.mesh.scale.setScalar(s); | |
} else { | |
// Reset scale when not magnetized | |
o.mesh.scale.setScalar(1); | |
} | |
// Pickup check using absolute distance threshold (meters) | |
if (dist <= PICKUP_DIST && G.player.alive && G.state === 'playing') { | |
G.player.health = Math.min(CFG.player.health, G.player.health + o.heal); | |
// Pulse green heal overlay | |
G.healFlash = Math.min(1, G.healFlash + CFG.hud.healPulsePerPickup + o.heal * CFG.hud.healPulsePerHP); | |
// Dispose unique geometries (materials are shared) | |
o.mesh.traverse((obj) => { if (obj.isMesh && obj.geometry?.dispose) obj.geometry.dispose(); }); | |
G.scene.remove(o.mesh); | |
G.orbs.splice(i, 1); | |
} | |
} | |
// Update ephemeral powerups (non-magnetized; float + rotate) | |
for (let i = G.powerups.length - 1; i >= 0; i--) { | |
const p = G.powerups[i]; | |
// Bob and spin | |
p.bobT += delta * 2.0; | |
p.pos.y = p.baseY + Math.sin(p.bobT) * 0.12 + 0.14; | |
p.mesh.rotation.y += delta * p.rotSpeed; | |
// Stronger glow pulse | |
p.glowT += delta * 3.2; | |
const glowPulse = 0.7 + Math.sin(p.glowT) * 0.3; // 0.4..1.0 | |
if (p.glowInner && p.glowInner.material) { | |
// Much softer core | |
p.glowInner.material.opacity = 0.30 + glowPulse * 0.20; // ~0.38..0.50 | |
p.glowInner.scale.set(0.70 + glowPulse * 0.12, 0.70 + glowPulse * 0.12, 1); | |
} | |
if (p.glowOuter && p.glowOuter.material) { | |
// Keep aura noticeable but not blown out | |
p.glowOuter.material.opacity = 0.35 + glowPulse * 0.40; // ~0.51..0.75 | |
p.glowOuter.scale.set(1.60 + glowPulse * 0.50, 1.60 + glowPulse * 0.50, 1); | |
} | |
// Pickup check | |
const dx = p.pos.x - G.player.pos.x; | |
const dz = p.pos.z - G.player.pos.z; | |
const dist = Math.hypot(dx, dz); | |
if (dist <= 2.4 && G.player.alive && G.state === 'playing') { | |
if (p.type === 'accelerator') { | |
// Apply/refresh ROF buff: x2 for 20s | |
G.weapon.rofMult = 2; | |
G.weapon.rofBuffTimer = 20; | |
G.weapon.rofBuffTotal = 20; | |
// Movement speed buff: +50% for 20s | |
G.movementMult = 1.5; | |
G.movementBuffTimer = 20; | |
// Audio cue for powerup pickup | |
try { playPowerupPickup(); } catch {} | |
} else if (p.type === 'infiniteAmmo') { | |
// Apply/refresh infinite ammo for 12s | |
if (G.weapon.infiniteAmmoTimer <= 0) { | |
G.weapon.ammoBeforeInf = G.weapon.ammo; | |
G.weapon.reserveBeforeInf = G.weapon.reserve; | |
} | |
G.weapon.infiniteAmmoTimer = 12; | |
G.weapon.infiniteAmmoTotal = 12; | |
// Cancel any reload in progress | |
G.weapon.reloading = false; | |
G.weapon.reloadTimer = 0; | |
// Audio cue for powerup pickup | |
try { playPowerupPickup(); } catch {} | |
} | |
G.scene.remove(p.mesh); | |
G.powerups.splice(i, 1); | |
} | |
} | |
} | |