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