diff --git a/frontend/package.json b/frontend/package.json index 618ec7c..a978a67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,8 +22,10 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "@types/three": "^0.177.0", "ngx-cookie-service": "^19.1.2", "rxjs": "~7.8.0", + "three": "^0.177.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" }, diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 1ac56b8..1f041ca 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -5,6 +5,10 @@ export const routes: Routes = [ path: '', loadComponent: () => import('./home/home.component').then(m => m.HomeComponent) }, + { + path: 'particles', + loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent) + }, { path: 'map', loadComponent: () => import('./map/map.component').then(m => m.MapComponent) @@ -105,6 +109,8 @@ export const routes: Routes = [ path: 'forms', loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent) }, + { + path: 'particles', + loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent) + }, ]; - - diff --git a/frontend/src/app/particles/particles.component.html b/frontend/src/app/particles/particles.component.html new file mode 100644 index 0000000..09a05ff --- /dev/null +++ b/frontend/src/app/particles/particles.component.html @@ -0,0 +1,189 @@ + +
+

Particle Creator

+
+
+ +
+
+
+
+
+
+ + + + + {{ planePosition }}/16 blocks +
+
+
+
+ + + Particle Properties + + +
+ + Particle Name + + +
+ +
+ + Display Name + + +
+ +
+ + Particle Type + + {{ type }} + + +
+ +
+ + Lore + + +
+ +
+ + Display Item + + +
+ +
+ + Permission + + +
+ +
+ + Package Permission + + +
+ +
+ + Frame Delay + + +
+ +
+ + Repeat + + +
+ +
+ + Repeat Delay + + +
+ +
+ + Random Offset + + +
+ +
+ Stationary +
+
+
+
+ +
+ + + Particle Color + + +
+ + Selected Color: {{ selectedColor }} +
+
+
+
+ +
+ + + Frames + + +
+ + +
+

Particles in {{ frameId }}

+
+
+ Particle {{ i + 1 }}: ({{ particle.x.toFixed(2) }}, {{ particle.y.toFixed(2) }} + , {{ particle.z.toFixed(2) }}) + +
+
+ No particles in this frame. Click on the plane to add particles. +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+ + + + + JSON Output + + +
{{ generateJson() }}
+
+
+
+
+
+
+
diff --git a/frontend/src/app/particles/particles.component.scss b/frontend/src/app/particles/particles.component.scss new file mode 100644 index 0000000..2674064 --- /dev/null +++ b/frontend/src/app/particles/particles.component.scss @@ -0,0 +1,125 @@ +.renderer-section { + flex: 1; + min-width: 300px; + display: flex; +} + +.renderer-container { + width: 100%; + height: 400px; + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; + background-color: #f0f0f0; +} + +.plane-controls { + margin-top: 10px; + padding: 10px; + background-color: #f5f5f5; + border-radius: 4px; + display: flex; + align-items: center; + gap: 10px; +} + +.plane-controls mat-slider { + flex: 1; +} + +.controls-section { + flex: 1; + min-width: 300px; + gap: 20px; +} + +.form-row { + margin-bottom: 15px; +} + +mat-form-field { + width: 100%; +} + +.color-picker-card { + margin-top: 20px; +} + +.color-picker { + display: flex; + align-items: center; + gap: 15px; +} + +.color-picker input[type="color"] { + width: 50px; + height: 50px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.frames-section { + margin-bottom: 20px; +} + +.frames-container { + margin-top: 10px; +} + +.frame-content { + padding: 15px; +} + +.particles-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; +} + +.particle-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-bottom: 1px solid #eee; +} + +.particle-item:last-child { + border-bottom: none; +} + +.no-particles { + padding: 20px; + text-align: center; + color: #888; +} + +.frame-actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; +} + +.add-frame { + margin-top: 15px; + display: flex; + justify-content: center; +} + +.json-output { + margin-top: 20px; +} + +.json-output pre { + background-color: #f5f5f5; + padding: 15px; + border-radius: 4px; + overflow-x: auto; + max-height: 300px; + font-family: monospace; + white-space: pre-wrap; +} diff --git a/frontend/src/app/particles/particles.component.spec.ts b/frontend/src/app/particles/particles.component.spec.ts new file mode 100644 index 0000000..5566cab --- /dev/null +++ b/frontend/src/app/particles/particles.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ParticlesComponent } from './particles.component'; + +describe('ParticlesComponent', () => { + let component: ParticlesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ParticlesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ParticlesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/particles/particles.component.ts b/frontend/src/app/particles/particles.component.ts new file mode 100644 index 0000000..80b9c0f --- /dev/null +++ b/frontend/src/app/particles/particles.component.ts @@ -0,0 +1,382 @@ +import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatInputModule} from '@angular/material/input'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSliderModule} from '@angular/material/slider'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +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'; + +// Define particle types +export enum ParticleType { + REDSTONE = 'REDSTONE', + // Other particle types can be added later +} + +// 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[]; + }; +} + +@Component({ + selector: 'app-particles', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatSliderModule, + MatCheckboxModule, + MatTabsModule, + MatCardModule, + MatIconModule, + HeaderComponent + ], + templateUrl: './particles.component.html', + styleUrl: './particles.component.scss' +}) +export class ParticlesComponent implements OnInit, AfterViewInit { + @ViewChild('rendererContainer') rendererContainer!: ElementRef; + @ViewChild('planeSlider') planeSlider!: ElementRef; + + // Three.js objects + private scene!: THREE.Scene; + private camera!: THREE.PerspectiveCamera; + private renderer!: THREE.WebGLRenderer; + 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() { + } + + ngOnInit(): void { + // Initialize component + } + + ngAfterViewInit(): void { + this.initThreeJS(); + this.animate(); + } + + // Initialize Three.js scene + private initThreeJS(): void { + // Create scene + this.scene = new THREE.Scene(); + this.scene.background = new THREE.Color(0xf0f0f0); + + // Create camera + this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 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(window.innerWidth * 0.6, window.innerHeight * 0.6); + this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); + + // 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 intersection plane + this.createIntersectionPlane(); + + // Add event listeners + this.renderer.domElement.addEventListener('click', this.onMouseClick.bind(this)); + window.addEventListener('resize', this.onWindowResize.bind(this)); + } + + // 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(2, 2); + const planeMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + transparent: true, + opacity: 0.2, + side: THREE.DoubleSide + }); + + this.intersectionPlane = new THREE.Mesh(planeGeometry, planeMaterial); + this.intersectionPlane.position.z = 0; + this.scene.add(this.intersectionPlane); + } + + // 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 zPosition = (this.planePosition / 16) - 0.5; // Center at 0 + this.intersectionPlane.position.z = zPosition; + } + + // Handle mouse click on the plane + private onMouseClick(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); + } + } + + // 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); + } + + // Handle window resize + private onWindowResize(): void { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth * 0.6, window.innerHeight * 0.6); + } + + // Animation loop + private animate(): void { + requestAnimationFrame(this.animate.bind(this)); + + // Rotate player model slightly for better view + if (this.playerModel) { + this.playerModel.rotation.y += 0.005; + } + + this.renderer.render(this.scene, this.camera); + } + + // 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(); + } + + // Switch to a different frame + public switchFrame(frameId: string): void { + this.currentFrame = frameId; + this.clearParticleVisuals(); + this.renderFrameParticles(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 + public generateJson(): string { + return JSON.stringify(this.particleData, null, 2); + } + + // 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); + } + } + } + + // 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(); + } + } + } +}