AltitudeWeb/frontend/src/app/pages/particles/services/particle-manager.service.ts

349 lines
10 KiB
TypeScript

import {inject, Injectable} from '@angular/core';
import * as THREE from 'three';
import {RendererService} from './renderer.service';
import {
getDefaultParticleColor,
Particle,
ParticleData,
ParticleInfo,
ParticleType,
supportsColors
} from '../models/particle.model';
import {IntersectionPlaneService, PlaneOrientation} from './intersection-plane.service';
import {deepCopy} from '../../../util/deep-copy.util';
/**
* Service responsible for managing particles in the scene
*/
@Injectable({
providedIn: 'root'
})
export class ParticleManagerService {
private particles: THREE.Mesh[] = [];
private particleData: ParticleData = {
particle_name: '',
display_name: '',
particle_type: ParticleType.TRAIL,
lore: '',
display_item: 'DIRT',
permission: '',
package_permission: '',
frame_delay: 1,
repeat: 1,
repeat_delay: 0,
random_offset: 0,
stationary: true,
frames: {
'frame-0': []
}
};
private currentFrame: string = 'frame-0';
private frames: string[] = ['frame-0'];
private selectedColor: string = '#ff0000';
private selectedParticle: Particle = Particle.DUST;
private supports_color: boolean = true;
private selectedVelocity: number = 1;
private selectedSize: number = 1;
private onlyIntersecting: boolean = false;
private readonly rendererService = inject(RendererService);
private readonly intersectionPlaneService = inject(IntersectionPlaneService);
constructor() {
this.intersectionPlaneService.planeChanged$.subscribe(() => {
if (this.onlyIntersecting) {
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
}
});
}
/**
* Adds a particle at the specified position
*/
addParticle(x: number, y: number, z: number): void {
const planeSize = this.intersectionPlaneService.stepSize;
const divisions = Math.max(1, Math.floor(planeSize * this.intersectionPlaneService.getGridDensity()));
const gridStepPlane = planeSize / divisions;
if (this.intersectionPlaneService.getGridSnapEnabled()) {
x = Math.round(x / gridStepPlane) * gridStepPlane;
y = Math.round(y / gridStepPlane) * gridStepPlane;
z = Math.round(z / gridStepPlane) * gridStepPlane;
}
// Create a visual representation of the particle
const particleGeometry = new THREE.SphereGeometry(0.03 * this.selectedSize, 16, 16);
const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
particleMesh.position.set(x, y, z);
this.rendererService.scene.add(particleMesh);
this.particles.push(particleMesh);
// Add to particle data
const hexColor = this.selectedColor.replace('#', '');
//TODO make this work for more than just type DUST
const particleInfo: ParticleInfo = {
particle_type: this.selectedParticle,
x: x,
y: y,
z: z,
color: hexColor,
// color_gradient_end: hexColor2,
extra: this.selectedVelocity,
size: this.selectedSize
};
if (!this.particleData.frames[this.currentFrame]) {
this.particleData.frames[this.currentFrame] = [];
}
this.particleData.frames[this.currentFrame].push(particleInfo);
}
/**
* Clears all particle visuals from the scene
*/
clearParticleVisuals(): void {
for (const particle of this.particles) {
this.rendererService.scene.remove(particle);
}
this.particles = [];
}
/**
* Renders particles for a specific frame
*/
renderFrameParticles(frameId: string): void {
if (!this.particleData.frames[frameId]) return;
const filter = this.onlyIntersecting;
const orientation = this.intersectionPlaneService.getCurrentOrientation();
const offset16 = this.intersectionPlaneService.getPlanePosition();
const planePos = offset16 / 16; // convert from 1/16th units to world units
const epsilon = 0.02; // tolerance for intersection
const isOnPlane = (p: ParticleInfo) => {
if (!filter) return true;
switch (orientation) {
case PlaneOrientation.VERTICAL_ABOVE:
case PlaneOrientation.VERTICAL_BELOW:
// Horizontal plane at y = 0.8 +/- planePos
return Math.abs(p.y - (0.8 + (orientation === PlaneOrientation.VERTICAL_BELOW ? planePos : -planePos))) <= epsilon;
case PlaneOrientation.HORIZONTAL_FRONT:
return Math.abs(p.z - planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_BEHIND:
return Math.abs(p.z + planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_RIGHT:
return Math.abs(p.x - planePos) <= epsilon;
case PlaneOrientation.HORIZONTAL_LEFT:
return Math.abs(p.x + planePos) <= epsilon;
}
};
for (const particleInfo of this.particleData.frames[frameId]) {
if (!isOnPlane(particleInfo)) {
continue;
}
const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const color = this.getColor(particleInfo);
const particleMaterial = new THREE.MeshBasicMaterial({color});
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z);
this.rendererService.scene.add(particleMesh);
this.particles.push(particleMesh);
}
}
/**
* Removes a particle from a specific frame
*/
removeParticle(frameId: string, index: number): void {
if (this.particleData.frames[frameId] && this.particleData.frames[frameId].length > index) {
this.particleData.frames[frameId].splice(index, 1);
// Update visuals if this is the current frame
if (frameId === this.currentFrame) {
this.clearParticleVisuals();
this.renderFrameParticles(frameId);
}
}
}
highlightParticle(frameId: string, index: number): void {
if (!(this.particleData.frames[frameId] && this.particleData.frames[frameId].length > index)) {
return;
}
const particleInfo = this.particleData.frames[frameId][index];
const color = this.getColor(particleInfo);
const particleMaterial = new THREE.MeshBasicMaterial({color});
const particleGeometry = new THREE.SphereGeometry(0.03 * (particleInfo.size ?? 1), 16, 16);
const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial);
particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z);
this.rendererService.scene.add(particleMesh);
this.particles.push(particleMesh);
this.animatePulse(particleMesh, 3, () => {
this.rendererService.scene.remove(particleMesh);
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
});
}
private animatePulse(mesh: THREE.Mesh, cycles: number, onComplete: () => void): void {
const duration = 300;
const maxScale = 0.08 / 0.03;
const startTime = performance.now();
const animate = (time: number) => {
const elapsed = (time - startTime) % duration;
const t = elapsed / (duration / 2);
const scaleFactor = t <= 1 ? 1 + (maxScale - 1) * t : maxScale - (maxScale - 1) * (t - 1);
mesh.scale.setScalar(scaleFactor);
if (time - startTime >= duration * cycles) {
onComplete();
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
private getColor(particleInfo: ParticleInfo) {
if (particleInfo.color) {
const r = parseInt(particleInfo.color.substring(0, 2), 16) / 255;
const g = parseInt(particleInfo.color.substring(2, 4), 16) / 255;
const b = parseInt(particleInfo.color.substring(4, 6), 16) / 255;
return new THREE.Color(r, g, b);
} else {
return new THREE.Color(255, 0, 0);
}
}
/**
* Sets the selected color for new particles
*/
setSelectedColor(color: string): void {
this.selectedColor = color;
}
public get supportsColor(): boolean {
return this.supports_color;
}
/**
* Gets the selected color
*/
getSelectedColor(): string {
return this.selectedColor;
}
public get particle(): Particle {
return this.selectedParticle;
}
public set particle(selectedParticle: Particle) {
this.selectedParticle = selectedParticle;
this.supports_color = supportsColors(selectedParticle);
if (!this.supports_color) {
const defaultParticleColor = getDefaultParticleColor(selectedParticle);
if (defaultParticleColor) {
this.selectedColor = defaultParticleColor;
}
}
}
public get size(): number {
return this.selectedSize;
}
public set size(selectedSize: number) {
this.selectedSize = selectedSize;
}
public get velocity(): number {
return this.selectedVelocity;
}
public set velocity(velocity: number) {
this.selectedVelocity = velocity;
}
/**
* Gets the particle data
*/
getParticleData(): ParticleData {
return this.particleData;
}
/**
* Sets the particle data
*/
setParticleData(data: ParticleData): void {
this.particleData = data;
}
/**
* Gets the current frame
*/
getCurrentFrame(): string {
return this.currentFrame;
}
/**
* Sets the current frame
*/
setCurrentFrame(frameId: string): void {
this.currentFrame = frameId;
}
/**
* Gets all frames
*/
getFrames(): string[] {
return this.frames;
}
/**
* Sets all frames
*/
setFrames(frames: string[]): void {
this.frames = frames;
}
/**
* Generates JSON output of the particle data
*/
generateJson(): string {
const particleData = deepCopy(this.particleData)
if (this.particleData.package_permission) {
particleData.package_permission = 'apart.set.' + this.particleData.package_permission.toLowerCase().replace(' ', '-');
} else {
particleData.package_permission = 'apart.set.none';
}
particleData.permission = 'apart.particle.' + this.particleData.permission.toLowerCase().replace(' ', '-');
return JSON.stringify(this.particleData, null, 2);
}
public get onlyIntersectingParticles(): boolean {
return this.onlyIntersecting;
}
public set onlyIntersectingParticles(value: boolean) {
this.onlyIntersecting = value;
this.clearParticleVisuals();
this.renderFrameParticles(this.currentFrame);
}
public loadParticleData(data: string): void {
this.particleData = JSON.parse(data);
this.setCurrentFrame('frame-0');
this.frames = Object.keys(this.particleData.frames);
}
}