Rework folder structure in frontend
Pages are now grouped per group they appear in on in the header (where possible) Utilities used by multiple pages in the project are grouped in folders such as services/pipes/etc
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<div class="card-div">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Frames</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="frames-container">
|
||||
<mat-tab-group [selectedIndex]="frames.indexOf(currentFrame)"
|
||||
(selectedIndexChange)="switchFrame(frames[$event])">
|
||||
<mat-tab *ngFor="let frameId of frames" [label]="frameId">
|
||||
<div class="frame-content">
|
||||
<h3>Particles in {{ frameId }}</h3>
|
||||
<div class="particles-list">
|
||||
<div *ngFor="let particle of particleData.frames[frameId]; let i = index" class="particle-item">
|
||||
<span class="particle-item-text">
|
||||
Particle {{ i + 1 }}: ({{ particle.x.toFixed(2) }}, {{ particle.y.toFixed(2) }}
|
||||
, {{ particle.z.toFixed(2) }})
|
||||
</span>
|
||||
<button mat-icon-button (click)="removeParticle(frameId, i)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="highlightParticle(frameId, i)">
|
||||
<mat-icon>lightbulb</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="!particleData.frames[frameId] || particleData.frames[frameId].length === 0"
|
||||
class="no-particles">
|
||||
No particles in this frame. Click on the plane to add particles.
|
||||
</div>
|
||||
</div>
|
||||
<div class="frame-actions">
|
||||
<button mat-raised-button color="warn" (click)="removeFrame(frameId)"
|
||||
[disabled]="frames.length <= 1">
|
||||
Remove Frame
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
<div class="add-frame">
|
||||
<button mat-raised-button color="primary" (click)="addFrame()">
|
||||
Add New Frame
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
.frames-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.frame-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.particles-list {
|
||||
height: 550px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.particle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.particle-item-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.particle-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.no-particles {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--color-primairy);
|
||||
}
|
||||
|
||||
.frame-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.add-frame {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-div {
|
||||
mat-card {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--font-color);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FramesComponent } from './frames.component';
|
||||
|
||||
describe('FramesComponent', () => {
|
||||
let component: FramesComponent;
|
||||
let fixture: ComponentFixture<FramesComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FramesComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FramesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {MatButton, MatIconButton} from "@angular/material/button";
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||
import {MatTab, MatTabGroup} from "@angular/material/tabs";
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {ParticleData} from '../../models/particle.model';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {ParticleManagerService} from '../../services/particle-manager.service';
|
||||
import {FrameManagerService} from '../../services/frame-manager.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-frames',
|
||||
imports: [
|
||||
MatButton,
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTab,
|
||||
MatTabGroup,
|
||||
NgForOf,
|
||||
NgIf
|
||||
],
|
||||
templateUrl: './frames.component.html',
|
||||
styleUrl: './frames.component.scss'
|
||||
})
|
||||
export class FramesComponent {
|
||||
|
||||
constructor(
|
||||
private particleManagerService: ParticleManagerService,
|
||||
private frameManagerService: FrameManagerService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the particle data
|
||||
*/
|
||||
public get particleData(): ParticleData {
|
||||
return this.particleManagerService.getParticleData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current frame
|
||||
*/
|
||||
public get currentFrame(): string {
|
||||
return this.particleManagerService.getCurrentFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all frames
|
||||
*/
|
||||
public get frames(): string[] {
|
||||
return this.particleManagerService.getFrames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new frame
|
||||
*/
|
||||
public addFrame(): void {
|
||||
this.frameManagerService.addFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different frame
|
||||
*/
|
||||
public switchFrame(frameId: string): void {
|
||||
this.frameManagerService.switchFrame(frameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a particle
|
||||
*/
|
||||
public removeParticle(frameId: string, index: number): void {
|
||||
this.particleManagerService.removeParticle(frameId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a frame
|
||||
*/
|
||||
public removeFrame(frameId: string): void {
|
||||
this.frameManagerService.removeFrame(frameId);
|
||||
}
|
||||
|
||||
|
||||
public highlightParticle(frameId: string, i: number) {
|
||||
this.particleManagerService.highlightParticle(frameId, i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="card-div">
|
||||
<mat-card class="particle-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Particle Properties</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="particle-properties">
|
||||
<div class="property-row">
|
||||
<div class="color-picker">
|
||||
<input type="color" [(ngModel)]="selectedColor">
|
||||
<span>Current color: {{ selectedColor }}</span>
|
||||
</div>
|
||||
<mat-form-field appearance="fill" class="type-field">
|
||||
<mat-label>Select Particle Type</mat-label>
|
||||
<input type="text"
|
||||
placeholder="Search for a particle type"
|
||||
matInput
|
||||
[formControl]="particleTypeControl"
|
||||
[matAutocomplete]="auto">
|
||||
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
|
||||
<mat-option *ngFor="let type of filteredParticleTypes | async" [value]="type">
|
||||
{{ type }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="size-slider">
|
||||
<mat-slider min="0.1" max="4" step="0.1" class="full-width">
|
||||
<input matSliderThumb [(ngModel)]="selectedSize">
|
||||
</mat-slider>
|
||||
<span>Size: {{ selectedSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
.particle-card {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.particle-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.type-field {
|
||||
flex: 1;
|
||||
min-width: 20ch;
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.color-picker input[type="color"] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-div {
|
||||
mat-card {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.size-slider {
|
||||
width: 95%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--font-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ParticleComponent} from './particle.component';
|
||||
|
||||
describe('ParticleComponent', () => {
|
||||
let component: ParticleComponent;
|
||||
let fixture: ComponentFixture<ParticleComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ParticleComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ParticleComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {ParticleManagerService} from '../../services/particle-manager.service';
|
||||
import {Particle} from '../../models/particle.model';
|
||||
import {MatSliderModule} from '@angular/material/slider';
|
||||
import {MatSelectModule} from '@angular/material/select';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatAutocompleteModule} from '@angular/material/autocomplete';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map, startWith} from 'rxjs/operators';
|
||||
import {AsyncPipe, NgForOf} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-particle',
|
||||
imports: [
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MatSliderModule,
|
||||
MatSelectModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatAutocompleteModule,
|
||||
NgForOf,
|
||||
AsyncPipe
|
||||
],
|
||||
templateUrl: './particle.component.html',
|
||||
styleUrl: './particle.component.scss'
|
||||
})
|
||||
export class ParticleComponent implements OnInit {
|
||||
// Available particle types from the enum
|
||||
particleTypes = Object.values(Particle);
|
||||
|
||||
// Form control for the particle type dropdown with filtering
|
||||
particleTypeControl = new FormControl();
|
||||
filteredParticleTypes: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
private particleManagerService: ParticleManagerService,
|
||||
) {
|
||||
this.filteredParticleTypes = this.particleTypeControl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
map(value => this._filterParticleTypes(value || ''))
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize the particle type control with the current value
|
||||
this.particleTypeControl.setValue(this.selectedParticle);
|
||||
|
||||
// Update the selected particle when the control value changes
|
||||
this.particleTypeControl.valueChanges.subscribe(value => {
|
||||
if (value && Object.values(Particle).includes(value)) {
|
||||
this.selectedParticle = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _filterParticleTypes(value: string): string[] {
|
||||
const filterValue = value.toLowerCase();
|
||||
return this.particleTypes.filter(type => type.toLowerCase().includes(filterValue));
|
||||
}
|
||||
|
||||
// Display function for the autocomplete
|
||||
displayFn(particle: string): string {
|
||||
return particle ? particle : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected color
|
||||
*/
|
||||
public get selectedColor(): string {
|
||||
return this.particleManagerService.getSelectedColor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected color
|
||||
*/
|
||||
public set selectedColor(color: string) {
|
||||
this.particleManagerService.setSelectedColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected particle type
|
||||
*/
|
||||
public get selectedParticle(): Particle {
|
||||
return this.particleManagerService.particle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected particle type
|
||||
*/
|
||||
public set selectedParticle(particle: Particle) {
|
||||
this.particleManagerService.particle = particle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected particle size
|
||||
*/
|
||||
public get selectedSize(): number {
|
||||
return this.particleManagerService.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected particle size
|
||||
*/
|
||||
public set selectedSize(size: number) {
|
||||
this.particleManagerService.size = size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="card-div">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Particle Properties</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Particle Name</mat-label>
|
||||
<input matInput [(ngModel)]="particleData.particle_name" placeholder="Enter particle name">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Display Name</mat-label>
|
||||
<input matInput [(ngModel)]="particleData.display_name" placeholder="Enter display name">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Particle Type</mat-label>
|
||||
<mat-select [(ngModel)]="particleData.particle_type">
|
||||
<mat-option *ngFor="let type of particleTypes" [value]="type">{{ type }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Lore</mat-label>
|
||||
<textarea matInput [(ngModel)]="particleData.lore" placeholder="Enter lore"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Display Item</mat-label>
|
||||
<input matInput [(ngModel)]="particleData.display_item" placeholder="Enter display item">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Permission</mat-label>
|
||||
<input matInput [(ngModel)]="particleData.permission" placeholder="Enter permission">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Package Permission</mat-label>
|
||||
<input matInput [(ngModel)]="particleData.package_permission" placeholder="Enter package permission">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Frame Delay</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="particleData.frame_delay" placeholder="Enter frame delay">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Repeat</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="particleData.repeat" placeholder="Enter repeat count">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Repeat Delay</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="particleData.repeat_delay"
|
||||
placeholder="Enter repeat delay">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Random Offset</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="particleData.random_offset"
|
||||
placeholder="Enter random offset">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-checkbox [(ngModel)]="particleData.stationary"><span>Stationary</span></mat-checkbox>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
.form-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-div {
|
||||
mat-card {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--font-color);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PropertiesComponent } from './properties.component';
|
||||
|
||||
describe('PropertiesComponent', () => {
|
||||
let component: PropertiesComponent;
|
||||
let fixture: ComponentFixture<PropertiesComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PropertiesComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PropertiesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||
import {MatCheckbox} from "@angular/material/checkbox";
|
||||
import {MatFormField, MatInput, MatLabel} from "@angular/material/input";
|
||||
import {NgForOf} from "@angular/common";
|
||||
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {ParticleData, ParticleType} from '../../models/particle.model';
|
||||
import {MatSelect} from '@angular/material/select';
|
||||
import {MatOption} from '@angular/material/core';
|
||||
import {ParticleManagerService} from '../../services/particle-manager.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-particle-properties',
|
||||
imports: [
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatCardHeader,
|
||||
MatCardTitle,
|
||||
MatCheckbox,
|
||||
MatFormField,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatOption,
|
||||
MatSelect,
|
||||
NgForOf,
|
||||
ReactiveFormsModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './properties.component.html',
|
||||
styleUrl: './properties.component.scss'
|
||||
})
|
||||
export class PropertiesComponent {
|
||||
public particleTypes = Object.values(ParticleType);
|
||||
|
||||
constructor(
|
||||
private particleManagerService: ParticleManagerService,
|
||||
) {
|
||||
}
|
||||
|
||||
public get particleData(): ParticleData {
|
||||
return this.particleManagerService.getParticleData();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<div #rendererContainer class="renderer-container">
|
||||
<div class="plane-controls-overlay">
|
||||
<div>
|
||||
<mat-form-field appearance="outline" style="width: 10ch">
|
||||
<mat-label>Opacity</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="opacity" min="0" max="1" step="0.01" placeholder="">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button mat-mini-fab color="primary" (click)="resetCamera()"
|
||||
matTooltip="Reset camera">
|
||||
<mat-icon>location_searching</mat-icon>
|
||||
</button>
|
||||
|
||||
<button mat-mini-fab color="primary" (click)="togglePlaneLock()"
|
||||
[matTooltip]="isPlaneLocked ? 'Unlock Plane' : 'Lock Plane'">
|
||||
<mat-icon>{{ isPlaneLocked ? 'lock' : 'lock_open' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isPlaneLocked" class="plane-orientation-buttons">
|
||||
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_ABOVE)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_ABOVE"
|
||||
matTooltip="Vertical Above">
|
||||
<mat-icon>arrow_upward</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="warn" (click)="setPlaneOrientation(planeOrientations.VERTICAL_BELOW)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.VERTICAL_BELOW"
|
||||
matTooltip="Vertical Below">
|
||||
<mat-icon>arrow_downward</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_FRONT)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_FRONT"
|
||||
matTooltip="Horizontal Front">
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="primary" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_BEHIND)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_BEHIND"
|
||||
matTooltip="Horizontal Behind">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_RIGHT)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_RIGHT"
|
||||
matTooltip="Horizontal Right">
|
||||
<mat-icon>arrow_right</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="accent" (click)="setPlaneOrientation(planeOrientations.HORIZONTAL_LEFT)"
|
||||
[class.active]="currentPlaneOrientation === planeOrientations.HORIZONTAL_LEFT"
|
||||
matTooltip="Horizontal Left">
|
||||
<mat-icon>arrow_left</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
.renderer-container {
|
||||
height: 1000px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-primairy);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative; /* Added for absolute positioning of overlay */
|
||||
}
|
||||
|
||||
.plane-controls-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.plane-orientation-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.plane-orientation-buttons button {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.plane-orientation-buttons button:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.plane-orientation-buttons button.active {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px var(--color-tertiary);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RenderContainerComponent } from './render-container.component';
|
||||
|
||||
describe('RenderContainerComponent', () => {
|
||||
let component: RenderContainerComponent;
|
||||
let fixture: ComponentFixture<RenderContainerComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RenderContainerComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RenderContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild} from '@angular/core';
|
||||
import {MatMiniFabButton} from '@angular/material/button';
|
||||
import {NgIf} from '@angular/common';
|
||||
import {IntersectionPlaneService, PlaneOrientation} from '../../services/intersection-plane.service';
|
||||
import {MatIcon} from '@angular/material/icon';
|
||||
import {MatTooltip} from '@angular/material/tooltip';
|
||||
import {RendererService} from '../../services/renderer.service';
|
||||
import {PlayerModelService} from '../../services/player-model.service';
|
||||
import {InputHandlerService} from '../../services/input-handler.service';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||
|
||||
@Component({
|
||||
selector: 'app-render-container',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatMiniFabButton,
|
||||
MatTooltip,
|
||||
NgIf,
|
||||
FormsModule,
|
||||
MatInput,
|
||||
MatFormField,
|
||||
MatLabel
|
||||
],
|
||||
templateUrl: './render-container.component.html',
|
||||
styleUrl: './render-container.component.scss'
|
||||
})
|
||||
export class RenderContainerComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('rendererContainer') rendererContainer!: ElementRef;
|
||||
|
||||
constructor(
|
||||
private intersectionPlaneService: IntersectionPlaneService,
|
||||
private playerModelService: PlayerModelService,
|
||||
private inputHandlerService: InputHandlerService,
|
||||
private rendererService: RendererService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initializeScene();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when component is destroyed
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.rendererService.renderer) {
|
||||
this.inputHandlerService.cleanup(this.rendererService.renderer.domElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the 3D scene and all related components
|
||||
*/
|
||||
private initializeScene(): void {
|
||||
this.rendererService.initializeRenderer(this.rendererContainer);
|
||||
this.playerModelService.loadSkinTexture('/public/img/skins/steve.png')
|
||||
.then(() => {
|
||||
// Then create the player model with the texture applied
|
||||
this.playerModelService.createPlayerModel();
|
||||
});
|
||||
this.intersectionPlaneService.createIntersectionPlane();
|
||||
this.inputHandlerService.initializeInputHandlers(this.rendererService.renderer.domElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation loop
|
||||
*/
|
||||
private animate(): void {
|
||||
requestAnimationFrame(this.animate.bind(this));
|
||||
this.intersectionPlaneService.updatePlaneOrientation(this.rendererService.camera);
|
||||
this.rendererService.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the plane is locked
|
||||
*/
|
||||
public get isPlaneLocked(): boolean {
|
||||
return this.intersectionPlaneService.isPlaneLocked();
|
||||
}
|
||||
|
||||
public set opacity(opacity: number) {
|
||||
this.intersectionPlaneService.currentOpacity = opacity;
|
||||
}
|
||||
|
||||
public get opacity(): number {
|
||||
return this.intersectionPlaneService.currentOpacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the plane locked state
|
||||
*/
|
||||
public togglePlaneLock(): void {
|
||||
const newLockedState = !this.isPlaneLocked;
|
||||
this.intersectionPlaneService.setPlaneLocked(newLockedState);
|
||||
}
|
||||
|
||||
public resetCamera(): void {
|
||||
this.rendererService.resetCamera();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plane orientation
|
||||
*/
|
||||
public get currentPlaneOrientation(): PlaneOrientation {
|
||||
return this.intersectionPlaneService.getCurrentOrientation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the plane orientation
|
||||
*/
|
||||
public setPlaneOrientation(orientation: PlaneOrientation): void {
|
||||
this.intersectionPlaneService.setPlaneOrientation(orientation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available plane orientations
|
||||
*/
|
||||
public get planeOrientations(): typeof PlaneOrientation {
|
||||
return PlaneOrientation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Defines the types of particles available in the system
|
||||
*/
|
||||
export enum Particle {
|
||||
DUST = 'DUST',
|
||||
DUST_COLOR_TRANSITION = 'DUST_COLOR_TRANSITION',
|
||||
TINTED_LEAVES = 'TINTED_LEAVES'
|
||||
// Other particle types can be added later
|
||||
}
|
||||
|
||||
export enum ParticleType {
|
||||
HEAD = 'HEAD',
|
||||
TRAIL = 'TRAIL',
|
||||
BREAK_PLACE_BLOCK = 'BREAK_PLACE_BLOCK',
|
||||
DEATH = 'DEATH',
|
||||
KILL = 'KILL',
|
||||
CLICK_BLOCK = 'CLICK_BLOCK',
|
||||
TELEPORT_ARRIVE = 'TELEPORT',
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single particle's information
|
||||
*/
|
||||
export interface ParticleInfo {
|
||||
particle_type: Particle;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
extra?: number;
|
||||
color?: string;
|
||||
color_gradient_end?: string;
|
||||
size?: 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[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<app-header current_page="particles" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>Particle Creator</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<section class="column">
|
||||
<div class="renderer-section column">
|
||||
<div class="flex row">
|
||||
<div class="flex side-column">
|
||||
<app-particle-properties></app-particle-properties>
|
||||
</div>
|
||||
<div class="flex middle-column">
|
||||
<app-render-container></app-render-container>
|
||||
<div class="plane-controls">
|
||||
<label>Plane Position (Z-axis):</label>
|
||||
<mat-slider [min]="minOffset" [max]="maxOffset" step="1" #planeSlider>
|
||||
<input matSliderThumb [(ngModel)]="planePosition" (input)="updatePlanePosition($event)">
|
||||
</mat-slider>
|
||||
<span>{{ planePosition }} offset from center</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex side-column">
|
||||
<app-particle></app-particle>
|
||||
<app-frames></app-frames>
|
||||
<div>
|
||||
<button mat-fab extended (click)="copyJson()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy JSON to clipboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,125 @@
|
||||
.renderer-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.side-column {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.middle-column {
|
||||
flex: 2;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
label, span {
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.plane-controls {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: var(--color-primairy);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plane-controls mat-slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.mdc-text-field--outlined {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mdc-text-field--outlined .mdc-floating-label,
|
||||
.mdc-text-field--outlined .mdc-text-field__input,
|
||||
.mat-mdc-form-field-label,
|
||||
.mat-mdc-select-value-text,
|
||||
.mat-mdc-select-arrow,
|
||||
.mat-mdc-checkbox-label,
|
||||
.mat-mdc-card-header,
|
||||
.mat-mdc-card-title,
|
||||
.mat-mdc-card-content {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading,
|
||||
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__notch,
|
||||
.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__trailing {
|
||||
border-color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
// Fix for dropdown menu
|
||||
.mat-mdc-select-panel {
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option:hover:not(.mdc-list-item--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option.mat-mdc-option-active {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-panel {
|
||||
background-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option:hover:not(.mdc-list-item--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-option.mat-mdc-option-active {
|
||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-header {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label-container {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mdc-tab__content .mdc-tab__text-label {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label, .mat-mdc-tab-link {
|
||||
color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-group.mat-primary .mat-mdc-tab:not(.mat-mdc-tab-disabled) .mdc-tab-indicator__content--underline {
|
||||
border-color: var(--font-color) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-content {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ParticlesComponent } from './particles.component';
|
||||
|
||||
describe('ParticlesComponent', () => {
|
||||
let component: ParticlesComponent;
|
||||
let fixture: ComponentFixture<ParticlesComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ParticlesComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ParticlesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Component, ElementRef, 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';
|
||||
|
||||
// Services
|
||||
import {IntersectionPlaneService} from './services/intersection-plane.service';
|
||||
import {ParticleManagerService} from './services/particle-manager.service';
|
||||
|
||||
// Models
|
||||
import {PropertiesComponent} from './components/properties/properties.component';
|
||||
import {ParticleComponent} from './components/particle/particle.component';
|
||||
import {FramesComponent} from './components/frames/frames.component';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {RenderContainerComponent} from './components/render-container/render-container.component';
|
||||
import {ParticlesService} from '@api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-particles',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatSliderModule,
|
||||
MatCheckboxModule,
|
||||
MatTabsModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
HeaderComponent,
|
||||
PropertiesComponent,
|
||||
ParticleComponent,
|
||||
FramesComponent,
|
||||
RenderContainerComponent,
|
||||
],
|
||||
templateUrl: './particles.component.html',
|
||||
styleUrl: './particles.component.scss'
|
||||
})
|
||||
export class ParticlesComponent {
|
||||
@ViewChild('planeSlider') planeSlider!: ElementRef;
|
||||
|
||||
constructor(
|
||||
private intersectionPlaneService: IntersectionPlaneService,
|
||||
private particleManagerService: ParticleManagerService,
|
||||
private matSnackBar: MatSnackBar,
|
||||
private particlesService: ParticlesService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plane position based on slider
|
||||
*/
|
||||
public updatePlanePosition(event: Event): void {
|
||||
const slider = event.target as HTMLInputElement;
|
||||
const value = Number(slider.value);
|
||||
this.intersectionPlaneService.updatePlanePosition(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plane position
|
||||
*/
|
||||
public get planePosition(): number {
|
||||
return this.intersectionPlaneService.getPlanePosition();
|
||||
}
|
||||
|
||||
public set planePosition(newPlanePosition: number) {
|
||||
this.intersectionPlaneService.updatePlanePosition(newPlanePosition);
|
||||
}
|
||||
|
||||
public get maxOffset(): number {
|
||||
return this.intersectionPlaneService.getMaxOffset();
|
||||
}
|
||||
|
||||
public get minOffset(): number {
|
||||
return this.intersectionPlaneService.getMinOffset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON output
|
||||
*/
|
||||
public generateJson(): string {
|
||||
return this.particleManagerService.generateJson();
|
||||
}
|
||||
|
||||
public getJsonFile(): Blob {
|
||||
const jsonContent = this.generateJson();
|
||||
return new Blob([jsonContent], {type: 'application/json'});
|
||||
}
|
||||
|
||||
public saveJsonToFile(): void {
|
||||
const jsonContent = this.generateJson();
|
||||
const blob = new Blob([jsonContent], {type: 'application/json'});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary link element
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'particle-data.json';
|
||||
|
||||
// Append to the document, click it, and remove it
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
this.matSnackBar.open('JSON file downloaded', '', {duration: 2000});
|
||||
}
|
||||
|
||||
public copyJson() {
|
||||
navigator.clipboard.writeText(this.generateJson()).then(() => {
|
||||
this.matSnackBar.open('Copied to clipboard', '', {duration: 2000})
|
||||
});
|
||||
//TODO validation
|
||||
this.particlesService.saveFile(this.particleManagerService.getParticleData().particle_name, this.getJsonFile());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import * as THREE from 'three';
|
||||
import {RendererService} from './renderer.service';
|
||||
|
||||
/**
|
||||
* Represents the possible orientations of the intersection plane
|
||||
*/
|
||||
export enum PlaneOrientation {
|
||||
VERTICAL_ABOVE,
|
||||
VERTICAL_BELOW,
|
||||
HORIZONTAL_FRONT,
|
||||
HORIZONTAL_BEHIND,
|
||||
HORIZONTAL_RIGHT,
|
||||
HORIZONTAL_LEFT
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for managing the intersection plane
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class IntersectionPlaneService {
|
||||
private intersectionPlane!: THREE.Mesh;
|
||||
private planePosition: number = 0; // Position in 1/16th of a block
|
||||
private currentOrientation: PlaneOrientation = PlaneOrientation.HORIZONTAL_FRONT;
|
||||
private planeLocked: boolean = false;
|
||||
private opacity: number = 0.05;
|
||||
|
||||
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);
|
||||
this.intersectionPlane.renderOrder = 1;
|
||||
|
||||
return this.intersectionPlane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the plane orientation based on camera position
|
||||
*/
|
||||
private determinePlaneOrientation(camera: THREE.Camera): PlaneOrientation {
|
||||
// Check if camera is looking from above or below first
|
||||
const verticalAngle = Math.atan2(
|
||||
camera.position.y,
|
||||
Math.sqrt(camera.position.x * camera.position.x + camera.position.z * camera.position.z)
|
||||
);
|
||||
|
||||
// Threshold angle for considering the camera to be above/below (about 45 degrees)
|
||||
const verticalThreshold = Math.PI / 4;
|
||||
|
||||
if (verticalAngle > verticalThreshold) {
|
||||
return PlaneOrientation.VERTICAL_ABOVE;
|
||||
} else if (verticalAngle < -verticalThreshold) {
|
||||
return PlaneOrientation.VERTICAL_BELOW;
|
||||
} else {
|
||||
// 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;
|
||||
|
||||
switch (quadrant) {
|
||||
case 0:
|
||||
return PlaneOrientation.HORIZONTAL_FRONT;
|
||||
case 1:
|
||||
return PlaneOrientation.HORIZONTAL_RIGHT;
|
||||
case 2:
|
||||
return PlaneOrientation.HORIZONTAL_BEHIND;
|
||||
case 3:
|
||||
return PlaneOrientation.HORIZONTAL_LEFT;
|
||||
default:
|
||||
return PlaneOrientation.HORIZONTAL_FRONT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the plane orientation based on camera position
|
||||
*/
|
||||
public updatePlaneOrientation(camera: THREE.Camera): void {
|
||||
if (!this.intersectionPlane) return;
|
||||
|
||||
if (!this.planeLocked) {
|
||||
this.currentOrientation = this.determinePlaneOrientation(camera);
|
||||
}
|
||||
|
||||
this.updateIntersectionPlaneOrientation()
|
||||
|
||||
//Restrict plane position to the new bounds and update it
|
||||
this.planePosition = Math.max(this.getMinOffset(), Math.min(this.getMaxOffset(), this.planePosition));
|
||||
this.updatePlanePosition(this.planePosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the plane position based on slider value
|
||||
*/
|
||||
public updatePlanePosition(value: number): void {
|
||||
this.planePosition = value;
|
||||
// Convert from 1/16th block to Three.js units
|
||||
const position = (this.planePosition / 16)
|
||||
|
||||
this.intersectionPlane.position.y = 0.8;
|
||||
this.intersectionPlane.position.x = 0;
|
||||
this.intersectionPlane.position.z = 0;
|
||||
|
||||
// Position based on the current orientation
|
||||
switch (this.currentOrientation) {
|
||||
case PlaneOrientation.VERTICAL_ABOVE:
|
||||
this.intersectionPlane.position.y = 0.8 - position;
|
||||
break;
|
||||
case PlaneOrientation.VERTICAL_BELOW:
|
||||
this.intersectionPlane.position.y = 0.8 + position;
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_FRONT:
|
||||
this.intersectionPlane.position.z = position;
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_BEHIND:
|
||||
this.intersectionPlane.position.z = -position;
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_RIGHT:
|
||||
this.intersectionPlane.position.x = position;
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_LEFT:
|
||||
this.intersectionPlane.position.x = -position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the plane material color
|
||||
*/
|
||||
private updatePlaneMaterial(color: number): void {
|
||||
this.intersectionPlane.material = new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: this.opacity,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
}
|
||||
|
||||
public set currentOpacity(opacity: number) {
|
||||
this.opacity = opacity;
|
||||
}
|
||||
|
||||
public get currentOpacity(): number {
|
||||
return this.opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the intersection plane
|
||||
*/
|
||||
public getIntersectionPlane(): THREE.Mesh {
|
||||
return this.intersectionPlane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current plane position
|
||||
*/
|
||||
public getPlanePosition(): number {
|
||||
return this.planePosition;
|
||||
}
|
||||
|
||||
public getMaxOffset(): number {
|
||||
switch (this.currentOrientation) {
|
||||
case PlaneOrientation.VERTICAL_ABOVE:
|
||||
case PlaneOrientation.VERTICAL_BELOW:
|
||||
return 16;
|
||||
case PlaneOrientation.HORIZONTAL_FRONT:
|
||||
case PlaneOrientation.HORIZONTAL_BEHIND:
|
||||
return 8;
|
||||
case PlaneOrientation.HORIZONTAL_RIGHT:
|
||||
case PlaneOrientation.HORIZONTAL_LEFT:
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
|
||||
public getMinOffset(): number {
|
||||
return this.getMaxOffset() * -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current plane orientation
|
||||
*/
|
||||
public getCurrentOrientation(): PlaneOrientation {
|
||||
return this.currentOrientation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the plane orientation manually
|
||||
*/
|
||||
public setPlaneOrientation(orientation: PlaneOrientation): void {
|
||||
this.currentOrientation = orientation;
|
||||
this.updateIntersectionPlaneOrientation();
|
||||
this.updatePlanePosition(this.planePosition);
|
||||
}
|
||||
|
||||
private updateIntersectionPlaneOrientation() {
|
||||
switch (this.currentOrientation) {
|
||||
case PlaneOrientation.VERTICAL_ABOVE:
|
||||
this.intersectionPlane.rotation.x = -Math.PI / 2;
|
||||
this.intersectionPlane.rotation.y = 0;
|
||||
this.updatePlaneMaterial(0xAA0000);
|
||||
break;
|
||||
case PlaneOrientation.VERTICAL_BELOW:
|
||||
this.intersectionPlane.rotation.x = Math.PI / 2;
|
||||
this.intersectionPlane.rotation.y = 0;
|
||||
this.updatePlaneMaterial(0xAA0000);
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_FRONT:
|
||||
this.intersectionPlane.rotation.x = 0;
|
||||
this.intersectionPlane.rotation.y = 0;
|
||||
this.updatePlaneMaterial(0x00AA00);
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_BEHIND:
|
||||
this.intersectionPlane.rotation.x = 0;
|
||||
this.intersectionPlane.rotation.y = Math.PI;
|
||||
this.updatePlaneMaterial(0x00AA00);
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_RIGHT:
|
||||
this.intersectionPlane.rotation.x = 0;
|
||||
this.intersectionPlane.rotation.y = Math.PI / 2;
|
||||
this.updatePlaneMaterial(0x0000AA);
|
||||
break;
|
||||
case PlaneOrientation.HORIZONTAL_LEFT:
|
||||
this.intersectionPlane.rotation.x = 0;
|
||||
this.intersectionPlane.rotation.y = -Math.PI / 2;
|
||||
this.updatePlaneMaterial(0x0000AA);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether the plane orientation is locked
|
||||
*/
|
||||
public isPlaneLocked(): boolean {
|
||||
return this.planeLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the plane orientation is locked
|
||||
*/
|
||||
public setPlaneLocked(locked: boolean): void {
|
||||
this.planeLocked = locked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import * as THREE from 'three';
|
||||
import {RendererService} from './renderer.service';
|
||||
import {Particle, 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.TRAIL,
|
||||
lore: '',
|
||||
display_item: 'DIRT',
|
||||
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';
|
||||
private selectedParticle: Particle = Particle.DUST;
|
||||
private selectedSize: number = 1;
|
||||
|
||||
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 * 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: 1,
|
||||
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;
|
||||
|
||||
for (const particleInfo of this.particleData.frames[frameId]) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the selected color
|
||||
*/
|
||||
getSelectedColor(): string {
|
||||
return this.selectedColor;
|
||||
}
|
||||
|
||||
public get particle(): Particle {
|
||||
return this.selectedParticle;
|
||||
}
|
||||
|
||||
public set particle(selectedParticle: Particle) {
|
||||
this.selectedParticle = selectedParticle;
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.selectedSize;
|
||||
}
|
||||
|
||||
public set size(selectedSize: number) {
|
||||
this.selectedSize = selectedSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import * as THREE from 'three';
|
||||
import {RendererService} from './renderer.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PlayerModelService {
|
||||
private playerModel!: THREE.Group;
|
||||
private skinTexture!: THREE.Texture;
|
||||
private textureLoaded = false;
|
||||
|
||||
constructor(private rendererService: RendererService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Minecraft skin texture from a URL
|
||||
* @param textureUrl The URL of the skin texture to load
|
||||
* @returns A promise that resolves when the texture is loaded
|
||||
*/
|
||||
loadSkinTexture(textureUrl: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.load(textureUrl, (texture) => {
|
||||
// Set texture parameters
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
this.skinTexture = texture;
|
||||
this.textureLoaded = true;
|
||||
|
||||
// If the player model already exists, rebuild it with textures
|
||||
if (this.playerModel) {
|
||||
// Remove old model
|
||||
this.rendererService.scene.remove(this.playerModel);
|
||||
// Create new model with textures
|
||||
this.createPlayerModel();
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a player model with Minecraft-style textures and adds it to the scene
|
||||
*/
|
||||
createPlayerModel(): THREE.Group {
|
||||
this.playerModel = new THREE.Group();
|
||||
|
||||
if (this.textureLoaded) {
|
||||
// Create textured model if texture is loaded
|
||||
this.createTexturedPlayerModel();
|
||||
} else {
|
||||
// Create simple colored model if no texture is loaded
|
||||
this.createSimplePlayerModel();
|
||||
}
|
||||
|
||||
this.playerModel.renderOrder = 0;
|
||||
this.rendererService.scene.add(this.playerModel);
|
||||
return this.playerModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple colored player model (without textures)
|
||||
*/
|
||||
private createSimplePlayerModel(): void {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a textured player model using the Minecraft skin
|
||||
*/
|
||||
private createTexturedPlayerModel(): void {
|
||||
// Create the player with properly mapped textures
|
||||
|
||||
// Head - 8x8x8 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.5, 0.5, 0.5, // width, height, depth
|
||||
[
|
||||
{x: 8, y: 0, w: 8, h: 8}, // top
|
||||
{x: 16, y: 0, w: 8, h: 8}, // bottom
|
||||
{x: 16, y: 8, w: 8, h: 8}, // right
|
||||
{x: 0, y: 8, w: 8, h: 8}, // left
|
||||
{x: 8, y: 8, w: 8, h: 8}, // front
|
||||
{x: 24, y: 8, w: 8, h: 8} // back
|
||||
],
|
||||
{x: 0, y: 1.35, z: 0} // position
|
||||
));
|
||||
|
||||
// Body - 8x12x4 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.5, 0.7, 0.25, // width, height, depth
|
||||
[
|
||||
{x: 20, y: 16, w: 8, h: 4}, // top
|
||||
{x: 28, y: 16, w: 8, h: 4}, // bottom
|
||||
{x: 28, y: 20, w: 4, h: 12}, // right
|
||||
{x: 16, y: 20, w: 4, h: 12}, // left
|
||||
{x: 20, y: 20, w: 8, h: 12}, // front
|
||||
{x: 32, y: 20, w: 8, h: 12} // back
|
||||
],
|
||||
{x: 0, y: 0.75, z: 0} // position
|
||||
));
|
||||
|
||||
// Left Arm - 4x12x4 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.2, 0.7, 0.25, // width, height, depth
|
||||
[
|
||||
{x: 44, y: 16, w: 4, h: 4}, // top
|
||||
{x: 48, y: 16, w: 4, h: 4}, // bottom
|
||||
{x: 48, y: 20, w: 4, h: 12}, // right
|
||||
{x: 40, y: 20, w: 4, h: 12}, // left
|
||||
{x: 44, y: 20, w: 4, h: 12}, // front
|
||||
{x: 52, y: 20, w: 4, h: 12} // back
|
||||
],
|
||||
{x: -0.35, y: 0.75, z: 0} // position
|
||||
));
|
||||
|
||||
// Right Arm - 4x12x4 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.2, 0.7, 0.25, // width, height, depth
|
||||
[
|
||||
{x: 44, y: 16, w: 4, h: 4}, // top - mirror of left arm
|
||||
{x: 48, y: 16, w: 4, h: 4}, // bottom - mirror of left arm
|
||||
{x: 40, y: 20, w: 4, h: 12}, // right - mirror of left arm's left
|
||||
{x: 48, y: 20, w: 4, h: 12}, // left - mirror of left arm's right
|
||||
{x: 44, y: 20, w: 4, h: 12}, // front - same as left arm
|
||||
{x: 52, y: 20, w: 4, h: 12} // back - same as left arm
|
||||
],
|
||||
{x: 0.35, y: 0.75, z: 0} // position
|
||||
));
|
||||
|
||||
// Left Leg - 4x12x4 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.26, 0.7, 0.26, // width, height, depth
|
||||
[
|
||||
{x: 4, y: 16, w: 4, h: 4}, // top
|
||||
{x: 8, y: 16, w: 4, h: 4}, // bottom
|
||||
{x: 8, y: 20, w: 4, h: 12}, // right
|
||||
{x: 0, y: 20, w: 4, h: 12}, // left
|
||||
{x: 4, y: 20, w: 4, h: 12}, // front
|
||||
{x: 12, y: 20, w: 4, h: 12} // back
|
||||
],
|
||||
{x: -0.125, y: 0.15, z: 0} // position
|
||||
));
|
||||
|
||||
// Right Leg - 4x12x4 pixels in the texture
|
||||
this.playerModel.add(this.createBoxWithUvMapping(
|
||||
0.26, 0.7, 0.26, // width, height, depth
|
||||
[
|
||||
{x: 4, y: 16, w: 4, h: 4}, // top - mirror of left leg
|
||||
{x: 8, y: 16, w: 4, h: 4}, // bottom - mirror of left leg
|
||||
{x: 0, y: 20, w: 4, h: 12}, // right - mirror of left leg's left
|
||||
{x: 8, y: 20, w: 4, h: 12}, // left - mirror of left leg's right
|
||||
{x: 4, y: 20, w: 4, h: 12}, // front - same as left leg
|
||||
{x: 12, y: 20, w: 4, h: 12} // back - same as left leg
|
||||
],
|
||||
{x: 0.125, y: 0.15, z: 0} // position
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a box with proper UV mapping for a Minecraft character part
|
||||
* @param width Width of the box
|
||||
* @param height Height of the box
|
||||
* @param depth Depth of the box
|
||||
* @param uvMapping Array of UV coordinates for each face (top, bottom, right, left, front, back)
|
||||
* @param position Position of the box
|
||||
* @returns THREE.Mesh with properly mapped textures
|
||||
*/
|
||||
private createBoxWithUvMapping(
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
uvMapping: Array<{ x: number, y: number, w: number, h: number }>,
|
||||
position: { x: number, y: number, z: number }
|
||||
): THREE.Mesh {
|
||||
const geometry = new THREE.BoxGeometry(width, height, depth);
|
||||
|
||||
// Remap the custom face order to BoxGeometry face order: px, nx, py, ny, pz, nz
|
||||
const faceOrder = [2, 3, 0, 1, 4, 5]; // right, left, top, bottom, front, back
|
||||
const textureWidth = 64;
|
||||
const textureHeight = 64;
|
||||
const uv = geometry.attributes['uv'];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const face = uvMapping[faceOrder[i]];
|
||||
const x1 = face.x / textureWidth;
|
||||
const y1 = 1 - face.y / textureHeight;
|
||||
const x2 = (face.x + face.w) / textureWidth;
|
||||
const y2 = 1 - (face.y + face.h) / textureHeight;
|
||||
|
||||
let uvs: [number, number][] = [
|
||||
[x1, y1], // top-left
|
||||
[x2, y1], // top-right
|
||||
[x1, y2], // bottom-left
|
||||
[x2, y2] // bottom-right
|
||||
];
|
||||
|
||||
const uvOffset = i * 8;
|
||||
for (let j = 0; j < 4; j++) {
|
||||
uv.array[uvOffset + j * 2] = uvs[j][0];
|
||||
uv.array[uvOffset + j * 2 + 1] = uvs[j][1];
|
||||
}
|
||||
}
|
||||
|
||||
uv.needsUpdate = true;
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: this.skinTexture,
|
||||
transparent: true,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(position.x, position.y, position.z);
|
||||
|
||||
return mesh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {ElementRef, Injectable} from '@angular/core';
|
||||
import * as THREE from 'three';
|
||||
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import {ThemeService} from '@shared-components/theme/theme.service';
|
||||
import {THEME_MODE} from '@custom-types/constant';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
private currentTheme: THEME_MODE = THEME_MODE.LIGHT;
|
||||
|
||||
constructor(private themeService: ThemeService) {
|
||||
this.themeService.theme$.subscribe(theme => {
|
||||
this.currentTheme = theme;
|
||||
if (this.scene) {
|
||||
this.setBackgroundColor(theme);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Three.js scene, camera, renderer, and controls
|
||||
*/
|
||||
initializeRenderer(container: ElementRef): void {
|
||||
// Create scene
|
||||
this.scene = new THREE.Scene();
|
||||
this.setBackgroundColor(this.currentTheme);
|
||||
|
||||
// Get container dimensions
|
||||
const containerWidth = container.nativeElement.clientWidth;
|
||||
const containerHeight = container.nativeElement.clientHeight;
|
||||
|
||||
// Create camera
|
||||
this.camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 1000);
|
||||
this.camera.position.set(-1, 2, 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 = 0.5;
|
||||
this.controls.maxDistance = 4;
|
||||
this.controls.target.set(0, 1, 0);
|
||||
this.controls.update();
|
||||
|
||||
// Add lights
|
||||
this.addLights();
|
||||
}
|
||||
|
||||
private setBackgroundColor(theme: THEME_MODE) {
|
||||
this.scene.background = new THREE.Color(this.currentTheme === THEME_MODE.DARK ? 0x242526 : 0xFBFBFE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the camera to its default position and orientation.
|
||||
*/
|
||||
public resetCamera(): void {
|
||||
this.camera.position.set(-1, 2, 3);
|
||||
this.camera.lookAt(0, 1, 0);
|
||||
this.controls.target.set(0, 1, 0);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user