From 4c31a91bb443ad2474b80b0067b526e4163f81d0 Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Sun, 22 Jun 2025 17:26:10 +0200 Subject: [PATCH] Add modular services for Three.js integration in ParticlesComponent Implemented dedicated Angular services (e.g., RendererService, InputHandlerService, PlayerModelService) to modularize and simplify Three.js integration within the `ParticlesComponent`. Refactored component logic to delegate rendering, input handling, and model creation to respective services. Updated particle data models and removed redundant logic from the component. --- .../app/particles/models/particle.model.ts | 40 ++ .../src/app/particles/particles.component.ts | 506 ++++-------------- .../services/frame-manager.service.ts | 64 +++ .../services/input-handler.service.ts | 105 ++++ .../services/intersection-plane.service.ts | 123 +++++ .../services/particle-manager.service.ts | 185 +++++++ .../services/player-model.service.ts | 71 +++ .../particles/services/renderer.service.ts | 91 ++++ 8 files changed, 787 insertions(+), 398 deletions(-) create mode 100644 frontend/src/app/particles/models/particle.model.ts create mode 100644 frontend/src/app/particles/services/frame-manager.service.ts create mode 100644 frontend/src/app/particles/services/input-handler.service.ts create mode 100644 frontend/src/app/particles/services/intersection-plane.service.ts create mode 100644 frontend/src/app/particles/services/particle-manager.service.ts create mode 100644 frontend/src/app/particles/services/player-model.service.ts create mode 100644 frontend/src/app/particles/services/renderer.service.ts diff --git a/frontend/src/app/particles/models/particle.model.ts b/frontend/src/app/particles/models/particle.model.ts new file mode 100644 index 0000000..4026899 --- /dev/null +++ b/frontend/src/app/particles/models/particle.model.ts @@ -0,0 +1,40 @@ +/** + * Defines the types of particles available in the system + */ +export enum ParticleType { + REDSTONE = 'REDSTONE', + // Other particle types can be added later +} + +/** + * Represents a single particle's information + */ +export interface ParticleInfo { + particle_type: string; + x: number; + y: number; + z: number; + color: string; + extra: number; +} + +/** + * Represents the complete particle data structure + */ +export interface ParticleData { + particle_name: string; + display_name: string; + particle_type: string; + lore: string; + display_item: string; + permission: string; + package_permission: string; + frame_delay: number; + repeat: number; + repeat_delay: number; + random_offset: number; + stationary: boolean; + frames: { + [frameId: string]: ParticleInfo[]; + }; +} diff --git a/frontend/src/app/particles/particles.component.ts b/frontend/src/app/particles/particles.component.ts index 6659d2d..5885c38 100644 --- a/frontend/src/app/particles/particles.component.ts +++ b/frontend/src/app/particles/particles.component.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core'; +import {AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {CommonModule} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; @@ -11,43 +11,17 @@ import {MatTabsModule} from '@angular/material/tabs'; import {MatCardModule} from '@angular/material/card'; import {MatIconModule} from '@angular/material/icon'; import {HeaderComponent} from '../header/header.component'; -import * as THREE from 'three'; -import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'; -// Define particle types -export enum ParticleType { - REDSTONE = 'REDSTONE', - // Other particle types can be added later -} +// Services +import { RendererService } from './services/renderer.service'; +import { PlayerModelService } from './services/player-model.service'; +import { IntersectionPlaneService } from './services/intersection-plane.service'; +import { ParticleManagerService } from './services/particle-manager.service'; +import { InputHandlerService } from './services/input-handler.service'; +import { FrameManagerService } from './services/frame-manager.service'; -// Interface for particle information -interface ParticleInfo { - particle_type: string; - x: number; - y: number; - z: number; - color: string; - extra: number; -} - -// Interface for the complete particle data -interface ParticleData { - particle_name: string; - display_name: string; - particle_type: string; - lore: string; - display_item: string; - permission: string; - package_permission: string; - frame_delay: number; - repeat: number; - repeat_delay: number; - random_offset: number; - stationary: boolean; - frames: { - [frameId: string]: ParticleInfo[]; - }; -} +// Models +import { ParticleType, ParticleData } from './models/particle.model'; @Component({ selector: 'app-particles', @@ -70,425 +44,161 @@ interface ParticleData { templateUrl: './particles.component.html', styleUrl: './particles.component.scss' }) -export class ParticlesComponent implements OnInit, AfterViewInit { +export class ParticlesComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('rendererContainer') rendererContainer!: ElementRef; @ViewChild('planeSlider') planeSlider!: ElementRef; - // Three.js objects - private scene!: THREE.Scene; - private camera!: THREE.PerspectiveCamera; - private renderer!: THREE.WebGLRenderer; - private controls!: OrbitControls; - private playerModel!: THREE.Group; - private intersectionPlane!: THREE.Mesh; - private particles: THREE.Mesh[] = []; - private raycaster = new THREE.Raycaster(); - private mouse = new THREE.Vector2(); - - // Particle data - public particleData: ParticleData = { - particle_name: '', - display_name: '', - particle_type: ParticleType.REDSTONE, - lore: '', - display_item: 'REDSTONE', - permission: '', - package_permission: '', - frame_delay: 1, - repeat: 1, - repeat_delay: 0, - random_offset: 0, - stationary: true, - frames: { - 'frame1': [] - } - }; - // UI state - public currentFrame: string = 'frame1'; - public planePosition: number = 8; // Position in 1/16th of a block - public selectedColor: string = '#ff0000'; public particleTypes = Object.values(ParticleType); - public frames: string[] = ['frame1']; - constructor() { + constructor( + private rendererService: RendererService, + private playerModelService: PlayerModelService, + private intersectionPlaneService: IntersectionPlaneService, + private particleManagerService: ParticleManagerService, + private inputHandlerService: InputHandlerService, + private frameManagerService: FrameManagerService + ) { } + /** + * Initialize component + */ ngOnInit(): void { - // Initialize component + // No initialization needed here } + /** + * Initialize Three.js scene after view is initialized + */ ngAfterViewInit(): void { - this.initThreeJS(); + this.initializeScene(); this.animate(); } - // Initialize Three.js scene - private initThreeJS(): void { - // Create scene - this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0xf0f0f0); + /** + * Clean up resources when component is destroyed + */ + ngOnDestroy(): void { + // Clean up event listeners + if (this.rendererService.renderer) { + this.inputHandlerService.cleanup(this.rendererService.renderer.domElement); + } + } - // Get container dimensions - const containerWidth = this.rendererContainer.nativeElement.clientWidth; - const containerHeight = 400; // Fixed height as defined in CSS + /** + * Initialize the 3D scene and all related components + */ + private initializeScene(): void { + // Initialize renderer + this.rendererService.initializeRenderer(this.rendererContainer); - // Create camera - this.camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000); - this.camera.position.set(0, 1, 3); - this.camera.lookAt(0, 1, 0); - - // Create renderer - this.renderer = new THREE.WebGLRenderer({antialias: true}); - this.renderer.setSize(containerWidth, containerHeight); - - // Center the canvas in the container - this.renderer.domElement.style.display = 'block'; - this.renderer.domElement.style.margin = 'auto'; - - this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); - - // Initialize orbit controls - this.controls = new OrbitControls(this.camera, this.renderer.domElement); - this.controls.enableDamping = true; // Add smooth damping effect - this.controls.dampingFactor = 0.05; - this.controls.minDistance = 2; // Minimum zoom distance - this.controls.maxDistance = 10; // Maximum zoom distance - this.controls.target.set(0, 1, 0); // Set target to player's center - this.controls.update(); - - // Add lights - const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); - this.scene.add(ambientLight); - - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); - directionalLight.position.set(1, 1, 1); - this.scene.add(directionalLight); - - // Create player model (simple representation) - this.createPlayerModel(); + // Create player model + this.playerModelService.createPlayerModel(); // Create intersection plane - this.createIntersectionPlane(); + this.intersectionPlaneService.createIntersectionPlane(); - // Add event listeners - this.renderer.domElement.addEventListener('mousedown', this.onMouseDown.bind(this)); - this.renderer.domElement.addEventListener('mouseup', this.onMouseUp.bind(this)); - this.renderer.domElement.addEventListener('mousemove', this.onMouseMove.bind(this)); - window.addEventListener('resize', this.onWindowResize.bind(this)); + // Initialize input handlers + this.inputHandlerService.initializeInputHandlers(this.rendererService.renderer.domElement); } - // Create a simple player model - private createPlayerModel(): void { - this.playerModel = new THREE.Group(); - - // Head - const headGeometry = new THREE.BoxGeometry(0.5, 0.5, 0.5); - const headMaterial = new THREE.MeshLambertMaterial({color: 0xffccaa}); - const head = new THREE.Mesh(headGeometry, headMaterial); - head.position.y = 1.35; - this.playerModel.add(head); - - // Body - const bodyGeometry = new THREE.BoxGeometry(0.5, 0.7, 0.25); - const bodyMaterial = new THREE.MeshLambertMaterial({color: 0x0000ff}); - const body = new THREE.Mesh(bodyGeometry, bodyMaterial); - body.position.y = 0.75; - this.playerModel.add(body); - - // Arms - const armGeometry = new THREE.BoxGeometry(0.2, 0.7, 0.25); - const armMaterial = new THREE.MeshLambertMaterial({color: 0xffccaa}); - - const leftArm = new THREE.Mesh(armGeometry, armMaterial); - leftArm.position.set(-0.35, 0.75, 0); - this.playerModel.add(leftArm); - - const rightArm = new THREE.Mesh(armGeometry, armMaterial); - rightArm.position.set(0.35, 0.75, 0); - this.playerModel.add(rightArm); - - // Legs - const legGeometry = new THREE.BoxGeometry(0.25, 0.7, 0.25); - const legMaterial = new THREE.MeshLambertMaterial({color: 0x000000}); - - const leftLeg = new THREE.Mesh(legGeometry, legMaterial); - leftLeg.position.set(-0.125, 0.15, 0); - this.playerModel.add(leftLeg); - - const rightLeg = new THREE.Mesh(legGeometry, legMaterial); - rightLeg.position.set(0.125, 0.15, 0); - this.playerModel.add(rightLeg); - - this.scene.add(this.playerModel); - } - - // Create the intersection plane - private createIntersectionPlane(): void { - const planeGeometry = new THREE.PlaneGeometry(3, 3); - const planeMaterial = new THREE.MeshBasicMaterial({ - color: 0x00AA00, - transparent: true, - opacity: 0.05, - side: THREE.DoubleSide - }); - - this.intersectionPlane = new THREE.Mesh(planeGeometry, planeMaterial); - this.intersectionPlane.position.z = 0; - // Center the plane vertically with the player (player is about 2 blocks tall) - this.intersectionPlane.position.y = 1; - this.scene.add(this.intersectionPlane); - } - - // Update plane position based on slider + /** + * Update plane position based on slider + */ public updatePlanePosition(event: Event): void { - // Access the value from the slider element const slider = event.target as HTMLInputElement; - this.planePosition = Number(slider.value); - // Convert from 1/16th block to Three.js units - const position = (this.planePosition / 16) - 0.5; // Center at 0 - - // Apply position based on the plane's current rotation - // This ensures the plane always moves towards/away from the viewer - const rotation = this.intersectionPlane.rotation.y; - - if (Math.abs(rotation) < 0.1 || Math.abs(rotation - Math.PI) < 0.1) { - // Camera in front (0) or behind (PI) - // For camera behind, we need to invert the direction - const direction = Math.abs(rotation) < 0.1 ? 1 : -1; - this.intersectionPlane.position.z = position * direction; - // Reset x position to avoid cumulative changes - this.intersectionPlane.position.x = 0; - } else { - // Camera on right (PI/2) or left (-PI/2) - // For camera on left, we need to invert the direction - const direction = rotation > 0 ? 1 : -1; - this.intersectionPlane.position.x = position * direction; - // Reset z position to avoid cumulative changes - this.intersectionPlane.position.z = 0; - } + const value = Number(slider.value); + this.intersectionPlaneService.updatePlanePosition(value); } - // Track if mouse is being dragged - private isDragging = false; - private mouseDownTime = 0; - - // Handle mouse down event - private onMouseDown(event: MouseEvent): void { - this.isDragging = false; - this.mouseDownTime = Date.now(); + /** + * Get the current plane position + */ + public get planePosition(): number { + return this.intersectionPlaneService.getPlanePosition(); } - // Handle mouse up event - private onMouseUp(event: MouseEvent): void { - // If mouse was down for less than 200ms and didn't move much, consider it a click, not a drag - if (Date.now() - this.mouseDownTime < 200 && !this.isDragging) { - this.handlePlaneClick(event); - } - this.isDragging = false; + /** + * Get the selected color + */ + public get selectedColor(): string { + return this.particleManagerService.getSelectedColor(); } - // Handle mouse move event - private onMouseMove(event: MouseEvent): void { - // If mouse moves while button is pressed, it's a drag - if (event.buttons > 0) { - this.isDragging = true; - } + /** + * Set the selected color + */ + public set selectedColor(color: string) { + this.particleManagerService.setSelectedColor(color); } - // Handle mouse click on the plane - private handlePlaneClick(event: MouseEvent): void { - // Calculate mouse position in normalized device coordinates - const rect = this.renderer.domElement.getBoundingClientRect(); - this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - - // Update the picking ray with the camera and mouse position - this.raycaster.setFromCamera(this.mouse, this.camera); - - // Calculate objects intersecting the picking ray - const intersects = this.raycaster.intersectObject(this.intersectionPlane); - - if (intersects.length > 0) { - const point = intersects[0].point; - this.addParticle(point.x, point.y, point.z); - } + /** + * Get the particle data + */ + public get particleData(): ParticleData { + return this.particleManagerService.getParticleData(); } - // Add a particle at the specified position - private addParticle(x: number, y: number, z: number): void { - // Create a visual representation of the particle - const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16); - const particleMaterial = new THREE.MeshBasicMaterial({color: this.selectedColor}); - const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); - - particleMesh.position.set(x, y, z); - this.scene.add(particleMesh); - this.particles.push(particleMesh); - - // Add to particle data - const hexColor = this.selectedColor.replace('#', ''); - const r = parseInt(hexColor.substring(0, 2), 16) / 255; - const g = parseInt(hexColor.substring(2, 4), 16) / 255; - const b = parseInt(hexColor.substring(4, 6), 16) / 255; - - const particleInfo: ParticleInfo = { - particle_type: ParticleType.REDSTONE, - x: x, - y: y, - z: z, - color: `${r},${g},${b}`, - extra: 1 - }; - - if (!this.particleData.frames[this.currentFrame]) { - this.particleData.frames[this.currentFrame] = []; - } - - this.particleData.frames[this.currentFrame].push(particleInfo); + /** + * Get the current frame + */ + public get currentFrame(): string { + return this.particleManagerService.getCurrentFrame(); } - // Handle window resize - private onWindowResize(): void { - const containerWidth = this.rendererContainer.nativeElement.clientWidth; - const containerHeight = 400; // Fixed height as defined in CSS - - this.camera.aspect = containerWidth / containerHeight; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(containerWidth, containerHeight); + /** + * Get all frames + */ + public get frames(): string[] { + return this.particleManagerService.getFrames(); } - // Animation loop + /** + * Animation loop + */ private animate(): void { requestAnimationFrame(this.animate.bind(this)); - // Update controls - if (this.controls) { - this.controls.update(); - } - // Update plane orientation based on camera position - if (this.intersectionPlane && this.camera) { - // Calculate the angle between camera and player (in the XZ plane) - const cameraAngle = Math.atan2( - this.camera.position.x, - this.camera.position.z - ); + this.intersectionPlaneService.updatePlaneOrientation(this.rendererService.camera); - // Determine which quadrant the camera is in with a 45-degree offset - // Adding Math.PI/4 (45 degrees) to the angle before determining the quadrant - // This shifts the quadrant boundaries by 45 degrees - const quadrant = Math.floor((cameraAngle + Math.PI + Math.PI / 4) / (Math.PI / 2)) % 4; - - // Rotate the plane to face the camera - if (quadrant === 0) { - this.intersectionPlane.rotation.y = 0; // Camera in front - this.updateFrameMaterial(0x00AA00); - } else if (quadrant === 1) { - this.intersectionPlane.rotation.y = Math.PI / 2; // Camera on right - this.updateFrameMaterial(0x0000AA); - } else if (quadrant === 2) { - this.intersectionPlane.rotation.y = Math.PI; // Camera behind - this.updateFrameMaterial(0x00AA00); - } else { - this.intersectionPlane.rotation.y = -Math.PI / 2; // Camera on left - this.updateFrameMaterial(0x0000AA); - } - - } - - this.renderer.render(this.scene, this.camera); + // Render the scene + this.rendererService.render(); } - public updateFrameMaterial(color: number) { - this.intersectionPlane.material = new THREE.MeshBasicMaterial({ - color: color, - transparent: true, - opacity: 0.05, - side: THREE.DoubleSide - }); - } - - // Add a new frame + /** + * Add a new frame + */ public addFrame(): void { - const frameId = `frame${this.frames.length + 1}`; - this.frames.push(frameId); - this.particleData.frames[frameId] = []; - this.currentFrame = frameId; - this.clearParticleVisuals(); + this.frameManagerService.addFrame(); } - // Switch to a different frame + /** + * Switch to a different frame + */ public switchFrame(frameId: string): void { - this.currentFrame = frameId; - this.clearParticleVisuals(); - this.renderFrameParticles(frameId); + this.frameManagerService.switchFrame(frameId); } - // Clear particle visuals from the scene - private clearParticleVisuals(): void { - for (const particle of this.particles) { - this.scene.remove(particle); - } - this.particles = []; - } - - // Render particles for a specific frame - private renderFrameParticles(frameId: string): void { - if (!this.particleData.frames[frameId]) return; - - for (const particleInfo of this.particleData.frames[frameId]) { - const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16); - - // Parse color - const colorParts = particleInfo.color.split(','); - const color = new THREE.Color( - parseFloat(colorParts[0]), - parseFloat(colorParts[1]), - parseFloat(colorParts[2]) - ); - - const particleMaterial = new THREE.MeshBasicMaterial({color}); - const particleMesh = new THREE.Mesh(particleGeometry, particleMaterial); - - particleMesh.position.set(particleInfo.x, particleInfo.y, particleInfo.z); - this.scene.add(particleMesh); - this.particles.push(particleMesh); - } - } - - // Generate JSON output + /** + * Generate JSON output + */ public generateJson(): string { - return JSON.stringify(this.particleData, null, 2); + return this.particleManagerService.generateJson(); } - // Remove a particle + /** + * Remove a particle + */ public 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); - } - } + this.particleManagerService.removeParticle(frameId, index); } - // Remove a frame + /** + * Remove a frame + */ public removeFrame(frameId: string): void { - const index = this.frames.indexOf(frameId); - if (index !== -1) { - this.frames.splice(index, 1); - delete this.particleData.frames[frameId]; - - // Switch to first frame if we removed the current one - if (frameId === this.currentFrame && this.frames.length > 0) { - this.switchFrame(this.frames[0]); - } else if (this.frames.length === 0) { - // If no frames left, add one - this.addFrame(); - } - } + this.frameManagerService.removeFrame(frameId); } } diff --git a/frontend/src/app/particles/services/frame-manager.service.ts b/frontend/src/app/particles/services/frame-manager.service.ts new file mode 100644 index 0000000..e478bcf --- /dev/null +++ b/frontend/src/app/particles/services/frame-manager.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import { ParticleManagerService } from './particle-manager.service'; + +/** + * Service responsible for managing animation frames + */ +@Injectable({ + providedIn: 'root' +}) +export class FrameManagerService { + + constructor(private particleManager: ParticleManagerService) {} + + /** + * Adds a new frame + */ + addFrame(): void { + const frames = this.particleManager.getFrames(); + const frameId = `frame${frames.length + 1}`; + + frames.push(frameId); + this.particleManager.setFrames(frames); + + const particleData = this.particleManager.getParticleData(); + particleData.frames[frameId] = []; + this.particleManager.setParticleData(particleData); + + this.switchFrame(frameId); + } + + /** + * Switches to a different frame + */ + switchFrame(frameId: string): void { + this.particleManager.setCurrentFrame(frameId); + this.particleManager.clearParticleVisuals(); + this.particleManager.renderFrameParticles(frameId); + } + + /** + * Removes a frame + */ + removeFrame(frameId: string): void { + const frames = this.particleManager.getFrames(); + const index = frames.indexOf(frameId); + + if (index !== -1) { + frames.splice(index, 1); + this.particleManager.setFrames(frames); + + const particleData = this.particleManager.getParticleData(); + delete particleData.frames[frameId]; + this.particleManager.setParticleData(particleData); + + // Switch to first frame if we removed the current one + if (frameId === this.particleManager.getCurrentFrame() && frames.length > 0) { + this.switchFrame(frames[0]); + } else if (frames.length === 0) { + // If no frames left, add one + this.addFrame(); + } + } + } +} diff --git a/frontend/src/app/particles/services/input-handler.service.ts b/frontend/src/app/particles/services/input-handler.service.ts new file mode 100644 index 0000000..8d8d331 --- /dev/null +++ b/frontend/src/app/particles/services/input-handler.service.ts @@ -0,0 +1,105 @@ +import { Injectable, ElementRef } from '@angular/core'; +import * as THREE from 'three'; +import { RendererService } from './renderer.service'; +import { IntersectionPlaneService } from './intersection-plane.service'; +import { ParticleManagerService } from './particle-manager.service'; + +/** + * Service responsible for handling user input interactions + */ +@Injectable({ + providedIn: 'root' +}) +export class InputHandlerService { + private raycaster = new THREE.Raycaster(); + private mouse = new THREE.Vector2(); + private isDragging = false; + private mouseDownTime = 0; + + constructor( + private rendererService: RendererService, + private intersectionPlaneService: IntersectionPlaneService, + private particleManagerService: ParticleManagerService + ) {} + + /** + * Initializes input event listeners + */ + initializeInputHandlers(rendererElement: HTMLElement): void { + rendererElement.addEventListener('mousedown', this.onMouseDown.bind(this)); + rendererElement.addEventListener('mouseup', this.onMouseUp.bind(this)); + rendererElement.addEventListener('mousemove', this.onMouseMove.bind(this)); + window.addEventListener('resize', this.onWindowResize.bind(this)); + } + + /** + * Handles mouse down event + */ + private onMouseDown(event: MouseEvent): void { + this.isDragging = false; + this.mouseDownTime = Date.now(); + } + + /** + * Handles mouse up event + */ + private onMouseUp(event: MouseEvent): void { + // If mouse was down for less than 200ms and didn't move much, consider it a click, not a drag + if (Date.now() - this.mouseDownTime < 200 && !this.isDragging) { + this.handlePlaneClick(event); + } + this.isDragging = false; + } + + /** + * Handles mouse move event + */ + private onMouseMove(event: MouseEvent): void { + // If mouse moves while button is pressed, it's a drag + if (event.buttons > 0) { + this.isDragging = true; + } + } + + /** + * Handles mouse click on the plane + */ + private handlePlaneClick(event: MouseEvent): void { + // Calculate mouse position in normalized device coordinates + const rect = this.rendererService.renderer.domElement.getBoundingClientRect(); + this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + // Update the picking ray with the camera and mouse position + this.raycaster.setFromCamera(this.mouse, this.rendererService.camera); + + // Calculate objects intersecting the picking ray + const intersects = this.raycaster.intersectObject(this.intersectionPlaneService.getIntersectionPlane()); + + if (intersects.length > 0) { + const point = intersects[0].point; + this.particleManagerService.addParticle(point.x, point.y, point.z); + } + } + + /** + * Handles window resize event + */ + private onWindowResize(): void { + // This is delegated to the renderer service + const container = this.rendererService.renderer.domElement.parentElement; + if (container) { + this.rendererService.onWindowResize(new ElementRef(container)); + } + } + + /** + * Removes event listeners + */ + cleanup(rendererElement: HTMLElement): void { + rendererElement.removeEventListener('mousedown', this.onMouseDown.bind(this)); + rendererElement.removeEventListener('mouseup', this.onMouseUp.bind(this)); + rendererElement.removeEventListener('mousemove', this.onMouseMove.bind(this)); + window.removeEventListener('resize', this.onWindowResize.bind(this)); + } +} diff --git a/frontend/src/app/particles/services/intersection-plane.service.ts b/frontend/src/app/particles/services/intersection-plane.service.ts new file mode 100644 index 0000000..6a16b44 --- /dev/null +++ b/frontend/src/app/particles/services/intersection-plane.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@angular/core'; +import * as THREE from 'three'; +import { RendererService } from './renderer.service'; + +/** + * Service responsible for managing the intersection plane + */ +@Injectable({ + providedIn: 'root' +}) +export class IntersectionPlaneService { + private intersectionPlane!: THREE.Mesh; + private planePosition: number = 8; // Position in 1/16th of a block + + constructor(private rendererService: RendererService) {} + + /** + * Creates the intersection plane and adds it to the scene + */ + createIntersectionPlane(): THREE.Mesh { + const planeGeometry = new THREE.PlaneGeometry(3, 3); + const planeMaterial = new THREE.MeshBasicMaterial({ + color: 0x00AA00, + transparent: true, + opacity: 0.05, + side: THREE.DoubleSide + }); + + this.intersectionPlane = new THREE.Mesh(planeGeometry, planeMaterial); + this.intersectionPlane.position.z = 0; + // Center the plane vertically with the player (player is about 2 blocks tall) + this.intersectionPlane.position.y = 1; + this.rendererService.scene.add(this.intersectionPlane); + + return this.intersectionPlane; + } + + /** + * Updates the plane position based on slider value + */ + updatePlanePosition(value: number): void { + this.planePosition = value; + // Convert from 1/16th block to Three.js units + const position = (this.planePosition / 16) - 0.5; // Center at 0 + + // Apply position based on the plane's current rotation + const rotation = this.intersectionPlane.rotation.y; + + if (Math.abs(rotation) < 0.1 || Math.abs(rotation - Math.PI) < 0.1) { + // Camera in front (0) or behind (PI) + const direction = Math.abs(rotation) < 0.1 ? 1 : -1; + this.intersectionPlane.position.z = position * direction; + // Reset x position to avoid cumulative changes + this.intersectionPlane.position.x = 0; + } else { + // Camera on right (PI/2) or left (-PI/2) + const direction = rotation > 0 ? 1 : -1; + this.intersectionPlane.position.x = position * direction; + // Reset z position to avoid cumulative changes + this.intersectionPlane.position.z = 0; + } + } + + /** + * Updates the plane orientation based on camera position + */ + updatePlaneOrientation(camera: THREE.Camera): void { + if (!this.intersectionPlane) return; + + // Calculate the angle between camera and player (in the XZ plane) + const cameraAngle = Math.atan2( + camera.position.x, + camera.position.z + ); + + // Determine which quadrant the camera is in with a 45-degree offset + const quadrant = Math.floor((cameraAngle + Math.PI + Math.PI / 4) / (Math.PI / 2)) % 4; + + // Rotate the plane to face the camera + if (quadrant === 0) { + this.intersectionPlane.rotation.y = 0; // Camera in front + this.updatePlaneMaterial(0x00AA00); + } else if (quadrant === 1) { + this.intersectionPlane.rotation.y = Math.PI / 2; // Camera on right + this.updatePlaneMaterial(0x0000AA); + } else if (quadrant === 2) { + this.intersectionPlane.rotation.y = Math.PI; // Camera behind + this.updatePlaneMaterial(0x00AA00); + } else { + this.intersectionPlane.rotation.y = -Math.PI / 2; // Camera on left + this.updatePlaneMaterial(0x0000AA); + } + + // Update position after rotation change + this.updatePlanePosition(this.planePosition); + } + + /** + * Updates the plane material color + */ + private updatePlaneMaterial(color: number): void { + this.intersectionPlane.material = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0.05, + side: THREE.DoubleSide + }); + } + + /** + * Gets the intersection plane + */ + getIntersectionPlane(): THREE.Mesh { + return this.intersectionPlane; + } + + /** + * Gets the current plane position + */ + getPlanePosition(): number { + return this.planePosition; + } +} diff --git a/frontend/src/app/particles/services/particle-manager.service.ts b/frontend/src/app/particles/services/particle-manager.service.ts new file mode 100644 index 0000000..2afabb7 --- /dev/null +++ b/frontend/src/app/particles/services/particle-manager.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@angular/core'; +import * as THREE from 'three'; +import { RendererService } from './renderer.service'; +import { ParticleData, ParticleInfo, ParticleType } from '../models/particle.model'; + +/** + * 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.REDSTONE, + lore: '', + display_item: 'REDSTONE', + permission: '', + package_permission: '', + frame_delay: 1, + repeat: 1, + repeat_delay: 0, + random_offset: 0, + stationary: true, + frames: { + 'frame1': [] + } + }; + private currentFrame: string = 'frame1'; + private frames: string[] = ['frame1']; + private selectedColor: string = '#ff0000'; + + constructor(private rendererService: RendererService) {} + + /** + * Adds a particle at the specified position + */ + addParticle(x: number, y: number, z: number): void { + // Create a visual representation of the particle + const particleGeometry = new THREE.SphereGeometry(0.03, 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('#', ''); + const r = parseInt(hexColor.substring(0, 2), 16) / 255; + const g = parseInt(hexColor.substring(2, 4), 16) / 255; + const b = parseInt(hexColor.substring(4, 6), 16) / 255; + + const particleInfo: ParticleInfo = { + particle_type: ParticleType.REDSTONE, + x: x, + y: y, + z: z, + color: `${r},${g},${b}`, + extra: 1 + }; + + 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; + + for (const particleInfo of this.particleData.frames[frameId]) { + const particleGeometry = new THREE.SphereGeometry(0.03, 16, 16); + + // Parse color + const colorParts = particleInfo.color.split(','); + const color = new THREE.Color( + parseFloat(colorParts[0]), + parseFloat(colorParts[1]), + parseFloat(colorParts[2]) + ); + + 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); + } + } + } + + /** + * Sets the selected color for new particles + */ + setSelectedColor(color: string): void { + this.selectedColor = color; + } + + /** + * Gets the selected color + */ + getSelectedColor(): string { + return this.selectedColor; + } + + /** + * 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 { + return JSON.stringify(this.particleData, null, 2); + } +} diff --git a/frontend/src/app/particles/services/player-model.service.ts b/frontend/src/app/particles/services/player-model.service.ts new file mode 100644 index 0000000..d50112a --- /dev/null +++ b/frontend/src/app/particles/services/player-model.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import * as THREE from 'three'; +import { RendererService } from './renderer.service'; + +/** + * Service responsible for creating and managing the player model + */ +@Injectable({ + providedIn: 'root' +}) +export class PlayerModelService { + private playerModel!: THREE.Group; + + constructor(private rendererService: RendererService) {} + + /** + * Creates a simple player model and adds it to the scene + */ + createPlayerModel(): THREE.Group { + this.playerModel = new THREE.Group(); + + // Head + const headGeometry = new THREE.BoxGeometry(0.5, 0.5, 0.5); + const headMaterial = new THREE.MeshLambertMaterial({color: 0xffccaa}); + const head = new THREE.Mesh(headGeometry, headMaterial); + head.position.y = 1.35; + this.playerModel.add(head); + + // Body + const bodyGeometry = new THREE.BoxGeometry(0.5, 0.7, 0.25); + const bodyMaterial = new THREE.MeshLambertMaterial({color: 0x0000ff}); + const body = new THREE.Mesh(bodyGeometry, bodyMaterial); + body.position.y = 0.75; + this.playerModel.add(body); + + // Arms + const armGeometry = new THREE.BoxGeometry(0.2, 0.7, 0.25); + const armMaterial = new THREE.MeshLambertMaterial({color: 0xffccaa}); + + const leftArm = new THREE.Mesh(armGeometry, armMaterial); + leftArm.position.set(-0.35, 0.75, 0); + this.playerModel.add(leftArm); + + const rightArm = new THREE.Mesh(armGeometry, armMaterial); + rightArm.position.set(0.35, 0.75, 0); + this.playerModel.add(rightArm); + + // Legs + const legGeometry = new THREE.BoxGeometry(0.25, 0.7, 0.25); + const legMaterial = new THREE.MeshLambertMaterial({color: 0x000000}); + + const leftLeg = new THREE.Mesh(legGeometry, legMaterial); + leftLeg.position.set(-0.125, 0.15, 0); + this.playerModel.add(leftLeg); + + const rightLeg = new THREE.Mesh(legGeometry, legMaterial); + rightLeg.position.set(0.125, 0.15, 0); + this.playerModel.add(rightLeg); + + this.rendererService.scene.add(this.playerModel); + + return this.playerModel; + } + + /** + * Gets the player model + */ + getPlayerModel(): THREE.Group { + return this.playerModel; + } +} diff --git a/frontend/src/app/particles/services/renderer.service.ts b/frontend/src/app/particles/services/renderer.service.ts new file mode 100644 index 0000000..3e54c94 --- /dev/null +++ b/frontend/src/app/particles/services/renderer.service.ts @@ -0,0 +1,91 @@ +import { ElementRef, Injectable } from '@angular/core'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +/** + * Service responsible for managing the Three.js rendering environment + */ +@Injectable({ + providedIn: 'root' +}) +export class RendererService { + scene!: THREE.Scene; + camera!: THREE.PerspectiveCamera; + renderer!: THREE.WebGLRenderer; + controls!: OrbitControls; + + /** + * Initializes the Three.js scene, camera, renderer, and controls + */ + initializeRenderer(container: ElementRef): void { + // Create scene + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0xf0f0f0); + + // Get container dimensions + const containerWidth = container.nativeElement.clientWidth; + const containerHeight = 400; // Fixed height as defined in CSS + + // Create camera + this.camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000); + this.camera.position.set(0, 1, 3); + this.camera.lookAt(0, 1, 0); + + // Create renderer + this.renderer = new THREE.WebGLRenderer({antialias: true}); + this.renderer.setSize(containerWidth, containerHeight); + + // Center the canvas in the container + this.renderer.domElement.style.display = 'block'; + this.renderer.domElement.style.margin = 'auto'; + + container.nativeElement.appendChild(this.renderer.domElement); + + // Initialize orbit controls + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.05; + this.controls.minDistance = 2; + this.controls.maxDistance = 10; + this.controls.target.set(0, 1, 0); + this.controls.update(); + + // Add lights + this.addLights(); + } + + /** + * Adds lighting to the scene + */ + private addLights(): void { + const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); + this.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(1, 1, 1); + this.scene.add(directionalLight); + } + + /** + * Handles window resize events + */ + onWindowResize(container: ElementRef): void { + const containerWidth = container.nativeElement.clientWidth; + const containerHeight = 400; // Fixed height as defined in CSS + + this.camera.aspect = containerWidth / containerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(containerWidth, containerHeight); + } + + /** + * Renders the scene + */ + render(): void { + if (this.controls) { + this.controls.update(); + } + + this.renderer.render(this.scene, this.camera); + } +}