Spaces:
Sleeping
Sleeping
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import * as THREE from "three"; | |
| import { Projectile, WeaponType } from "../../types"; | |
| import { LOCAL_FORWARD } from "../math"; | |
| import { VolumetricFire } from "../rendering/VolumetricFire"; | |
| function disposeMaterial(m: THREE.Material | THREE.Material[]) { | |
| if (Array.isArray(m)) { | |
| m.forEach(x => x.dispose()); | |
| } else if (m && typeof m.dispose === "function") { | |
| m.dispose(); | |
| } | |
| } | |
| export interface SmokeParticle { | |
| pos: THREE.Vector3; | |
| vel: THREE.Vector3; | |
| baseScale: THREE.Vector3; | |
| currentScale: number; | |
| scaleSpeed: number; | |
| color: number; | |
| life: number; | |
| maxLife: number; | |
| } | |
| export interface ExplosionBlob { | |
| pos: THREE.Vector3; | |
| vel: THREE.Vector3; | |
| baseScale: THREE.Vector3; | |
| shrinkSpeed: number; | |
| color: number; | |
| life: number; | |
| } | |
| export class ParticleEffectsManager { | |
| private scene: THREE.Scene; | |
| public smokeParticles: SmokeParticle[] = []; | |
| public explosionBlobs: ExplosionBlob[] = []; | |
| public volumeFires: { fire: VolumetricFire, life: number }[] = []; | |
| public listProjectiles: { | |
| bulletId: string; | |
| type: Projectile["type"]; | |
| age: number; | |
| color: number; | |
| isRocket: boolean; | |
| bombMesh?: THREE.Group; | |
| }[] = []; | |
| public bulletInstMesh: THREE.InstancedMesh | null = null; | |
| public rocketInstMesh: THREE.InstancedMesh | null = null; | |
| public smokeInstMesh: THREE.InstancedMesh | null = null; | |
| public explosionInstMesh: THREE.InstancedMesh | null = null; | |
| private readonly MAX_SMOKE = 2000; | |
| private readonly MAX_EXPLOSION = 1000; | |
| private readonly _projDummy = new THREE.Object3D(); | |
| private readonly _projColor = new THREE.Color(); | |
| private readonly _velDir = new THREE.Vector3(); | |
| private readonly _toCamera = new THREE.Vector3(); | |
| private readonly _toCamPerp = new THREE.Vector3(); | |
| private readonly _planeNormal = new THREE.Vector3(); | |
| private readonly _cross = new THREE.Vector3(); | |
| private readonly _billboardQ = new THREE.Quaternion(); | |
| private readonly _baseQ = new THREE.Quaternion(); | |
| constructor(scene: THREE.Scene) { | |
| this.scene = scene; | |
| this.initInstancedMeshes(); | |
| } | |
| private initInstancedMeshes() { | |
| const geo = new THREE.BoxGeometry(1, 1, 1); | |
| // Smoke instanced mesh | |
| const smokeMat = new THREE.MeshBasicMaterial({ | |
| color: 0xffffff, | |
| transparent: true, | |
| opacity: 0.45, | |
| vertexColors: true | |
| }); | |
| this.smokeInstMesh = new THREE.InstancedMesh(geo, smokeMat, this.MAX_SMOKE); | |
| const smokeColors = new Float32Array(this.MAX_SMOKE * 3); | |
| smokeColors.fill(1); | |
| this.smokeInstMesh.instanceColor = new THREE.InstancedBufferAttribute(smokeColors, 3); | |
| this.smokeInstMesh.count = 0; | |
| this.scene.add(this.smokeInstMesh); | |
| // Explosion instanced mesh | |
| const expMat = new THREE.MeshBasicMaterial({ | |
| color: 0xffffff, | |
| transparent: true, | |
| opacity: 0.8, | |
| vertexColors: true | |
| }); | |
| this.explosionInstMesh = new THREE.InstancedMesh(geo.clone(), expMat, this.MAX_EXPLOSION); | |
| const expColors = new Float32Array(this.MAX_EXPLOSION * 3); | |
| expColors.fill(1); | |
| this.explosionInstMesh.instanceColor = new THREE.InstancedBufferAttribute(expColors, 3); | |
| this.explosionInstMesh.count = 0; | |
| this.scene.add(this.explosionInstMesh); | |
| } | |
| public createSmokeTail(x: number, y: number, z: number, colorHex: number = 0x64748b, scale: number = 1.0) { | |
| const rx = 1.2 + Math.random() * 1.2; | |
| const ry = 1.2 + Math.random() * 1.2; | |
| const rz = 1.2 + Math.random() * 1.2; | |
| const baseScale = new THREE.Vector3(rx * scale, ry * scale, rz * scale); | |
| if (this.smokeParticles.length >= this.MAX_SMOKE) { | |
| this.smokeParticles.shift(); // Remove oldest | |
| } | |
| const life = 1.0 + Math.random() * 1.5; | |
| this.smokeParticles.push({ | |
| pos: new THREE.Vector3(x, y, z), | |
| vel: new THREE.Vector3( | |
| (Math.random() - 0.5) * 6, | |
| 3 + Math.random() * 4, | |
| (Math.random() - 0.5) * 6 | |
| ), | |
| baseScale, | |
| currentScale: 1.0, | |
| scaleSpeed: 1.4 * scale, | |
| color: colorHex, | |
| life, | |
| maxLife: life | |
| }); | |
| } | |
| public triggerExplosion(x: number, y: number, z: number, sizeMultiplier: number = 1.0) { | |
| const shardCount = Math.floor(16 * sizeMultiplier); | |
| const colors = [0xef4444, 0xf97316, 0xeab308, 0x475569]; | |
| // Add volumetric fire for the explosion | |
| const volFire = new VolumetricFire(); | |
| volFire.mesh.position.set(x, y, z); | |
| volFire.mesh.scale.set(sizeMultiplier, sizeMultiplier, sizeMultiplier); | |
| this.scene.add(volFire.mesh); | |
| this.volumeFires.push({ fire: volFire, life: 1.5 }); | |
| for (let i = 0; i < shardCount; i++) { | |
| const rx = 1.0 + Math.random() * 2 * sizeMultiplier; | |
| const ry = 1.0 + Math.random() * 2 * sizeMultiplier; | |
| const rz = 1.0 + Math.random() * 2 * sizeMultiplier; | |
| const baseScale = new THREE.Vector3(rx, ry, rz); | |
| if (this.explosionBlobs.length >= this.MAX_EXPLOSION) { | |
| this.explosionBlobs.shift(); // Remove oldest | |
| } | |
| this.explosionBlobs.push({ | |
| pos: new THREE.Vector3(x, y, z), | |
| vel: new THREE.Vector3( | |
| (Math.random() - 0.5) * 50 * sizeMultiplier, | |
| (Math.random() - 0.3) * 40 * sizeMultiplier, | |
| (Math.random() - 0.5) * 50 * sizeMultiplier | |
| ), | |
| baseScale, | |
| shrinkSpeed: 0.8 / sizeMultiplier, | |
| color: colors[Math.floor(Math.random() * colors.length)], | |
| life: 0.6 + Math.random() * 0.9 | |
| }); | |
| } | |
| } | |
| public updateParticles(dt: number) { | |
| // 1. Update smoke particles | |
| for (let i = this.smokeParticles.length - 1; i >= 0; i--) { | |
| const p = this.smokeParticles[i]; | |
| p.life -= dt; | |
| if (p.life <= 0) { | |
| this.smokeParticles.splice(i, 1); | |
| continue; | |
| } | |
| p.pos.addScaledVector(p.vel, dt); | |
| } | |
| // Update Volume Fires | |
| for (let i = this.volumeFires.length - 1; i >= 0; i--) { | |
| const vf = this.volumeFires[i]; | |
| vf.life -= dt; | |
| if (vf.life <= 0) { | |
| this.scene.remove(vf.fire.mesh); | |
| vf.fire.mesh.geometry.dispose(); | |
| (vf.fire.mesh.material as THREE.Material).dispose(); | |
| this.volumeFires.splice(i, 1); | |
| } | |
| } | |
| // 2. Update explosion blobs | |
| for (let i = this.explosionBlobs.length - 1; i >= 0; i--) { | |
| const e = this.explosionBlobs[i]; | |
| e.life -= dt; | |
| e.pos.addScaledVector(e.vel, dt); | |
| const shrink = e.shrinkSpeed * dt; | |
| e.baseScale.x = Math.max(0, e.baseScale.x - shrink); | |
| e.baseScale.y = Math.max(0, e.baseScale.y - shrink); | |
| e.baseScale.z = Math.max(0, e.baseScale.z - shrink); | |
| if (e.baseScale.x <= 0.05 || e.life <= 0) { | |
| this.explosionBlobs.splice(i, 1); | |
| } | |
| } | |
| // 3. Sync matrices and colors for smokeInstMesh | |
| if (this.smokeInstMesh) { | |
| let count = 0; | |
| for (let i = 0; i < this.smokeParticles.length; i++) { | |
| if (count >= this.MAX_SMOKE) break; | |
| const p = this.smokeParticles[i]; | |
| // Grow, then shrink/fade in the last 30% of life | |
| const ageNorm = 1.0 - (p.life / p.maxLife); | |
| let scaleFactor = 1.0; | |
| if (ageNorm < 0.7) { | |
| p.currentScale += p.scaleSpeed * dt; | |
| scaleFactor = p.currentScale; | |
| } else { | |
| const shrinkFactor = (1.0 - ageNorm) / 0.3; | |
| scaleFactor = p.currentScale * Math.max(0, shrinkFactor); | |
| } | |
| this._projDummy.position.copy(p.pos); | |
| this._projDummy.quaternion.set(0, 0, 0, 1); // Identity rotation | |
| this._projDummy.scale.copy(p.baseScale).multiplyScalar(scaleFactor); | |
| this._projDummy.updateMatrix(); | |
| this.smokeInstMesh.setMatrixAt(count, this._projDummy.matrix); | |
| this._projColor.setHex(p.color); | |
| this.smokeInstMesh.setColorAt(count, this._projColor); | |
| count++; | |
| } | |
| this.smokeInstMesh.count = count; | |
| this.smokeInstMesh.instanceMatrix.needsUpdate = true; | |
| if (this.smokeInstMesh.instanceColor) this.smokeInstMesh.instanceColor.needsUpdate = true; | |
| } | |
| // 4. Sync matrices and colors for explosionInstMesh | |
| if (this.explosionInstMesh) { | |
| let count = 0; | |
| for (let i = 0; i < this.explosionBlobs.length; i++) { | |
| if (count >= this.MAX_EXPLOSION) break; | |
| const e = this.explosionBlobs[i]; | |
| this._projDummy.position.copy(e.pos); | |
| this._projDummy.quaternion.set(0, 0, 0, 1); // Identity rotation | |
| this._projDummy.scale.copy(e.baseScale); | |
| this._projDummy.updateMatrix(); | |
| this.explosionInstMesh.setMatrixAt(count, this._projDummy.matrix); | |
| this._projColor.setHex(e.color); | |
| this.explosionInstMesh.setColorAt(count, this._projColor); | |
| count++; | |
| } | |
| this.explosionInstMesh.count = count; | |
| this.explosionInstMesh.instanceMatrix.needsUpdate = true; | |
| if (this.explosionInstMesh.instanceColor) this.explosionInstMesh.instanceColor.needsUpdate = true; | |
| } | |
| } | |
| public syncProjectiles(projectiles: Projectile[], playerPilotId: string, camera: THREE.Camera, dt: number) { | |
| if (!this.bulletInstMesh) { | |
| // PlaneGeometry oriented along Z so the tracer length runs with the velocity vector. | |
| // rotateX(-PI/2) maps the plane's original Y axis (height) to +Z and makes the surface | |
| // normal point along +Y, which the per-bullet billboard rotation then steers toward the camera. | |
| const geo = new THREE.PlaneGeometry(0.52, 16.8); | |
| geo.rotateX(-Math.PI / 2); | |
| const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, vertexColors: true, side: THREE.DoubleSide }); | |
| this.bulletInstMesh = new THREE.InstancedMesh(geo, mat, 2000); | |
| const bulletColors = new Float32Array(2000 * 3); | |
| bulletColors.fill(1); | |
| this.bulletInstMesh.instanceColor = new THREE.InstancedBufferAttribute(bulletColors, 3); | |
| this.bulletInstMesh.frustumCulled = false; | |
| this.scene.add(this.bulletInstMesh); | |
| } | |
| if (!this.rocketInstMesh) { | |
| const geo = new THREE.PlaneGeometry(1.2, 42); | |
| geo.rotateX(-Math.PI / 2); | |
| const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, vertexColors: true, side: THREE.DoubleSide }); | |
| this.rocketInstMesh = new THREE.InstancedMesh(geo, mat, 200); | |
| const rocketColors = new Float32Array(200 * 3); | |
| rocketColors.fill(1); | |
| this.rocketInstMesh.instanceColor = new THREE.InstancedBufferAttribute(rocketColors, 3); | |
| this.rocketInstMesh.frustumCulled = false; | |
| this.scene.add(this.rocketInstMesh); | |
| } | |
| const activeBullets = new Set<string>(); | |
| for (const p of projectiles) { | |
| activeBullets.add(p.id); | |
| let pEntry = this.listProjectiles.find(e => e.bulletId === p.id); | |
| if (!pEntry) { | |
| let color = 0xfffaed; | |
| if (String(p.belt) === "Tracer") color = 0xff3300; | |
| if (String(p.belt) === "Incendiary") color = 0xeab308; | |
| if (String(p.belt) === "Armor-Piercing") color = 0x22c55e; | |
| if (String(p.belt) === "Stealth") color = 0x111827; | |
| if (p.ownerId === playerPilotId) color = 0xffd700; | |
| let bombMesh: THREE.Group | undefined; | |
| if (p.type === WeaponType.BOMB) { | |
| bombMesh = new THREE.Group(); | |
| bombMesh.name = "bomb-projectile"; | |
| const bodyMat = new THREE.MeshLambertMaterial({ color: 0x3f3f32, flatShading: true }); | |
| const bandMat = new THREE.MeshBasicMaterial({ color: 0xd6a11d }); | |
| const finMat = new THREE.MeshLambertMaterial({ color: 0x25251f, flatShading: true }); | |
| const body = new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 1.8, 8), bodyMat); | |
| body.rotation.x = Math.PI / 2; | |
| bombMesh.add(body); | |
| const nose = new THREE.Mesh(new THREE.ConeGeometry(0.38, 0.72, 8), bodyMat); | |
| nose.rotation.x = Math.PI / 2; | |
| nose.position.z = 1.22; | |
| bombMesh.add(nose); | |
| const band = new THREE.Mesh(new THREE.CylinderGeometry(0.41, 0.41, 0.14, 8), bandMat); | |
| band.rotation.x = Math.PI / 2; | |
| band.position.z = 0.35; | |
| bombMesh.add(band); | |
| for (const rz of [0, Math.PI / 2]) { | |
| const fin = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.1, 0.62), finMat); | |
| fin.rotation.z = rz; | |
| fin.position.z = -1.08; | |
| bombMesh.add(fin); | |
| } | |
| this.scene.add(bombMesh); | |
| } | |
| pEntry = { bulletId: p.id, type: p.type, age: 0, color, isRocket: p.isRocket ?? false, bombMesh }; | |
| this.listProjectiles.push(pEntry); | |
| } | |
| pEntry.age += dt; | |
| } | |
| for (let i = this.listProjectiles.length - 1; i >= 0; i--) { | |
| const entry = this.listProjectiles[i]; | |
| if (!activeBullets.has(entry.bulletId)) { | |
| if (entry.bombMesh) { | |
| this.scene.remove(entry.bombMesh); | |
| entry.bombMesh.traverse(child => { | |
| if (!(child instanceof THREE.Mesh)) return; | |
| child.geometry.dispose(); | |
| disposeMaterial(child.material); | |
| }); | |
| } | |
| this.listProjectiles.splice(i, 1); | |
| } | |
| } | |
| let bi = 0, | |
| ri = 0; | |
| for (const p of projectiles) { | |
| const entry = this.listProjectiles.find(e => e.bulletId === p.id); | |
| if (!entry) continue; | |
| this._velDir.set(p.vx, p.vy, p.vz); | |
| const hasVel = this._velDir.lengthSq() > 0; | |
| if (hasVel) this._velDir.normalize(); | |
| if (hasVel) { | |
| this._baseQ.setFromUnitVectors(LOCAL_FORWARD.clone(), this._velDir); | |
| } else { | |
| this._baseQ.identity(); | |
| } | |
| if (entry.bombMesh) { | |
| entry.bombMesh.position.set(p.x, p.y, p.z); | |
| entry.bombMesh.quaternion.copy(this._baseQ); | |
| entry.bombMesh.rotateOnAxis(LOCAL_FORWARD, entry.age * 4.2); | |
| continue; | |
| } | |
| // Billboard rotation: rotate the plane around the velocity axis so its normal | |
| // always faces the camera, making the tracer visible from all angles. | |
| let finalQ = this._baseQ; | |
| if (hasVel) { | |
| this._toCamera.set( | |
| camera.position.x - p.x, | |
| camera.position.y - p.y, | |
| camera.position.z - p.z | |
| ); | |
| const along = this._toCamera.dot(this._velDir); | |
| this._toCamPerp.copy(this._toCamera).addScaledVector(this._velDir, -along); | |
| const perpLen = this._toCamPerp.length(); | |
| if (perpLen > 0.001) { | |
| this._toCamPerp.divideScalar(perpLen); | |
| // After baseQ the plane surface normal is local +Y (see geometry comment above) | |
| this._planeNormal.set(0, 1, 0).applyQuaternion(this._baseQ); | |
| this._cross.crossVectors(this._planeNormal, this._toCamPerp); | |
| const sinAngle = this._cross.dot(this._velDir); | |
| const cosAngle = this._planeNormal.dot(this._toCamPerp); | |
| this._billboardQ.setFromAxisAngle(this._velDir, Math.atan2(sinAngle, cosAngle)); | |
| finalQ = this._billboardQ.multiply(this._baseQ); | |
| } | |
| } | |
| this._projDummy.position.set(p.x, p.y, p.z); | |
| this._projDummy.quaternion.copy(finalQ); | |
| this._projDummy.updateMatrix(); | |
| this._projColor.setHex(entry.color); | |
| if (entry.isRocket && ri < 200) { | |
| this.rocketInstMesh.setMatrixAt(ri, this._projDummy.matrix); | |
| this.rocketInstMesh.setColorAt(ri, this._projColor); | |
| ri++; | |
| // Exhaust smoke emitted every ~3 frames at the rocket tail | |
| if (Math.random() < 0.33 && hasVel) { | |
| const tailX = p.x - this._velDir.x * 2.5; | |
| const tailY = p.y - this._velDir.y * 2.5; | |
| const tailZ = p.z - this._velDir.z * 2.5; | |
| this.createSmokeTail(tailX, tailY, tailZ, 0xb0b8c8, 0.55); | |
| } | |
| } else if (!entry.isRocket && bi < 2000) { | |
| this.bulletInstMesh.setMatrixAt(bi, this._projDummy.matrix); | |
| this.bulletInstMesh.setColorAt(bi, this._projColor); | |
| bi++; | |
| } | |
| } | |
| this.bulletInstMesh.count = bi; | |
| this.bulletInstMesh.instanceMatrix.needsUpdate = true; | |
| if (this.bulletInstMesh.instanceColor) this.bulletInstMesh.instanceColor.needsUpdate = true; | |
| this.rocketInstMesh.count = ri; | |
| this.rocketInstMesh.instanceMatrix.needsUpdate = true; | |
| if (this.rocketInstMesh.instanceColor) this.rocketInstMesh.instanceColor.needsUpdate = true; | |
| } | |
| public dispose() { | |
| for (const entry of this.listProjectiles) { | |
| if (entry.bombMesh) { | |
| this.scene.remove(entry.bombMesh); | |
| entry.bombMesh.traverse(child => { | |
| if (!(child instanceof THREE.Mesh)) return; | |
| child.geometry.dispose(); | |
| disposeMaterial(child.material); | |
| }); | |
| } | |
| } | |
| this.listProjectiles = []; | |
| if (this.bulletInstMesh) { | |
| this.scene.remove(this.bulletInstMesh); | |
| this.bulletInstMesh.geometry.dispose(); | |
| (this.bulletInstMesh.material as THREE.Material).dispose(); | |
| this.bulletInstMesh = null; | |
| } | |
| if (this.rocketInstMesh) { | |
| this.scene.remove(this.rocketInstMesh); | |
| this.rocketInstMesh.geometry.dispose(); | |
| (this.rocketInstMesh.material as THREE.Material).dispose(); | |
| this.rocketInstMesh = null; | |
| } | |
| if (this.smokeInstMesh) { | |
| this.scene.remove(this.smokeInstMesh); | |
| this.smokeInstMesh.geometry.dispose(); | |
| (this.smokeInstMesh.material as THREE.Material).dispose(); | |
| this.smokeInstMesh = null; | |
| } | |
| if (this.explosionInstMesh) { | |
| this.scene.remove(this.explosionInstMesh); | |
| this.explosionInstMesh.geometry.dispose(); | |
| (this.explosionInstMesh.material as THREE.Material).dispose(); | |
| this.explosionInstMesh = null; | |
| } | |
| this.smokeParticles = []; | |
| this.explosionBlobs = []; | |
| } | |
| } | |