Compare commits
36
Commits
bans
..
213f9987d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213f9987d9 | ||
|
|
48cac607de | ||
|
|
6ed2e15017 | ||
|
|
7fc25f46f3 | ||
|
|
c72703ea32 | ||
|
|
e837a9216d | ||
|
|
d4363b3a8a | ||
|
|
1e5862bae6 | ||
|
|
daf88ea437 | ||
|
|
9abd570b87 | ||
|
|
5284d498f3 | ||
|
|
c3a7be82e9 | ||
|
|
fdb57289f8 | ||
|
|
60c1329163 | ||
|
|
0e71c0f581 | ||
|
|
eb67a33331 | ||
|
|
39f20796ce | ||
|
|
e00165c56f | ||
|
|
02c6497700 | ||
|
|
0efd476676 | ||
|
|
237518638c | ||
|
|
fea1a98cea | ||
|
|
ecd9b3d824 | ||
|
|
9808b5d63d | ||
|
|
c13b7077a7 | ||
|
|
023ae809ef | ||
|
|
3a6f137c9a | ||
|
|
cb8447a096 | ||
|
|
3e98e1a498 | ||
|
|
4c31a91bb4 | ||
|
|
d6faaba01c | ||
|
|
56175e62d6 | ||
|
|
283838f444 | ||
|
|
49fe335c73 | ||
|
|
1875f050c6 | ||
|
|
52d8658be3 |
@@ -36,11 +36,11 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
return http
|
return http
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
|
|
||||||
.requestMatchers("/team/**", "/history/**").permitAll()
|
|
||||||
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
|
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
|
||||||
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.anyRequest().authenticated()
|
// .requestMatchers("/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
.requestMatchers("/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
|||||||
@@ -83,8 +83,6 @@ public class LoginController implements LoginApi {
|
|||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<String> login(String code) {
|
public ResponseEntity<String> login(String code) {
|
||||||
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
|
|
||||||
cache.put("23232323", cacheEntry1);
|
|
||||||
if (code == null) {
|
if (code == null) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -134,12 +132,12 @@ public class LoginController implements LoginApi {
|
|||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
|
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
|
||||||
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
|
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
|
||||||
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
|
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||||
List<PermissionClaimDto> claimList = new ArrayList<>();
|
List<PermissionClaimDto> claimList = new ArrayList<>();
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
Connection.getConnection(Databases.DEFAULT)
|
||||||
.runQuery(sqlSession -> {
|
.runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
|
Optional<PrivilegedUser> privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
|
||||||
.getUserByUuid(uuid.toString());
|
.getUserByUuid(uuid.toString());
|
||||||
|
|
||||||
privilegedUserCompletableFuture.complete(privilegedUser);
|
privilegedUserCompletableFuture.complete(privilegedUser);
|
||||||
@@ -148,17 +146,15 @@ public class LoginController implements LoginApi {
|
|||||||
privilegedUserCompletableFuture.completeExceptionally(e);
|
privilegedUserCompletableFuture.completeExceptionally(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
|
Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
|
||||||
claimList.add(PermissionClaimDto.USER);
|
claimList.add(PermissionClaimDto.USER);
|
||||||
if (privilegedUser != null) {
|
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
|
||||||
privilegedUser.getPermissions().forEach(permission -> {
|
|
||||||
try {
|
try {
|
||||||
claimList.add(PermissionClaimDto.valueOf(permission));
|
claimList.add(PermissionClaimDto.valueOf(permission));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
log.warn("Received invalid permission claim: {}", permission);
|
log.warn("Received invalid permission claim: {}", permission);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
.issuer("altitudeweb")
|
.issuer("altitudeweb")
|
||||||
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
|
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
package com.alttd.altitudeweb.controllers.particles;
|
||||||
|
|
||||||
|
import com.alttd.altitudeweb.api.ParticlesApi;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
public class ParticleController implements ParticlesApi {
|
||||||
|
|
||||||
|
@Value("${login.secret:#{null}}")
|
||||||
|
private String loginSecret;
|
||||||
|
|
||||||
|
@Value("${particles.file_path}")
|
||||||
|
private String particlesFilePath;
|
||||||
|
|
||||||
|
@Value("${notification.server.url:http://localhost:8080}")
|
||||||
|
private String notificationServerUrl;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
|
||||||
|
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
|
File file = new File(particlesFilePath);
|
||||||
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
|
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
File targetFile = new File(file, filename);
|
||||||
|
return getFileForDownload(targetFile, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
|
||||||
|
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
|
File file = new File(particlesFilePath);
|
||||||
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
|
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
File targetDir = new File(file, uuid);
|
||||||
|
if (targetDir.exists()) {
|
||||||
|
return getFileForDownload(targetDir, filename);
|
||||||
|
} else {
|
||||||
|
log.warn("User {} does not have a directory for particles files", uuid);
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
||||||
|
File targetFile = new File(file, filename);
|
||||||
|
if (!targetFile.exists()) {
|
||||||
|
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetFile.isFile()) {
|
||||||
|
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path path = targetFile.toPath();
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentLength(targetFile.length())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.body(resource);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
|
||||||
|
File file = new File(particlesFilePath);
|
||||||
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
|
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||||
|
notifyServerOfFileUpload(filename);
|
||||||
|
return voidResponseEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
|
||||||
|
File file = new File(particlesFilePath);
|
||||||
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
|
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
File targetDir = new File(file, uuid);
|
||||||
|
if (!file.exists()) {
|
||||||
|
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
|
||||||
|
if (targetDir.mkdirs()) {
|
||||||
|
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||||
|
notifyServerOfFileUpload(uuid, filename);
|
||||||
|
return voidResponseEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyServerOfFileUpload(String filename) {
|
||||||
|
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
|
||||||
|
sendNotification(notificationUrl, String.format("file upload: %s", filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyServerOfFileUpload(String uuid, String filename) {
|
||||||
|
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
|
||||||
|
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendNotification(String notificationUrl, String logDescription) {
|
||||||
|
try {
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
|
||||||
|
|
||||||
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
log.info("Successfully notified server of {}", logDescription);
|
||||||
|
} else {
|
||||||
|
log.warn("Failed to notify server of {}, status: {}",
|
||||||
|
logDescription, response.getStatusCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error notifying server of {}", logDescription, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
||||||
|
File targetFile = new File(dir, filename);
|
||||||
|
if (!Files.isWritable(targetFile.toPath())) {
|
||||||
|
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFile.exists()) {
|
||||||
|
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
content.transferTo(targetFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,6 @@ database.user=${DB_USER:root}
|
|||||||
database.password=${DB_PASSWORD:root}
|
database.password=${DB_PASSWORD:root}
|
||||||
cors.allowed-origins=${CORS:https://alttd.com}
|
cors.allowed-origins=${CORS:https://alttd.com}
|
||||||
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
||||||
|
particles.file_path=${user.home}/.altitudeweb/particles
|
||||||
|
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
||||||
logging.level.com.alttd.altitudeweb=INFO
|
logging.level.com.alttd.altitudeweb=INFO
|
||||||
|
|||||||
+3
-1
@@ -1,8 +1,10 @@
|
|||||||
package com.alttd.altitudeweb.database.web_db;
|
package com.alttd.altitudeweb.database.web_db;
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface PrivilegedUserMapper {
|
public interface PrivilegedUserMapper {
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
|
|||||||
@Result(property = "permissions", column = "id", javaType = List.class,
|
@Result(property = "permissions", column = "id", javaType = List.class,
|
||||||
many = @Many(select = "getPermissionsForUser"))
|
many = @Many(select = "getPermissionsForUser"))
|
||||||
})
|
})
|
||||||
PrivilegedUser getUserByUuid(@Param("uuid") String uuid);
|
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") String uuid);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all privileged users with their permissions
|
* Retrieves all privileged users with their permissions
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
"@angular/platform-browser": "^19.2.0",
|
"@angular/platform-browser": "^19.2.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
"@angular/router": "^19.2.0",
|
"@angular/router": "^19.2.0",
|
||||||
|
"@types/three": "^0.177.0",
|
||||||
"ngx-cookie-service": "^19.1.2",
|
"ngx-cookie-service": "^19.1.2",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"three": "^0.177.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -2,15 +2,13 @@ import {Component} from '@angular/core';
|
|||||||
import {ScrollService} from '../scroll/scroll.service';
|
import {ScrollService} from '../scroll/scroll.service';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {HeaderComponent} from '../header/header.component';
|
import {HeaderComponent} from '../header/header.component';
|
||||||
import {RemoveTrailingPeriodPipe} from "../util/RemoveTrailingPeriodPipe";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about',
|
selector: 'app-about',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
HeaderComponent,
|
HeaderComponent
|
||||||
RemoveTrailingPeriodPipe
|
|
||||||
],
|
],
|
||||||
templateUrl: './about.component.html',
|
templateUrl: './about.component.html',
|
||||||
styleUrl: './about.component.scss'
|
styleUrl: './about.component.scss'
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export const routes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'particles',
|
||||||
|
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'map',
|
path: 'map',
|
||||||
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
|
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
|
||||||
@@ -105,6 +109,8 @@ export const routes: Routes = [
|
|||||||
path: 'forms',
|
path: 'forms',
|
||||||
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () => import('./login/login.component').then(m => m.LoginDialogComponent)
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
<ng-container>
|
|
||||||
<app-header [current_page]="'community'" height="460px" background_image="/public/img/backgrounds/community.jpg"
|
|
||||||
[overlay_gradient]="0.5">
|
|
||||||
<div class="title" header-content>
|
|
||||||
<h1>Community</h1>
|
|
||||||
<h2>Talented people who help Altitude in more than one way.</h2>
|
|
||||||
</div>
|
|
||||||
</app-header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="darkmodeSection">
|
|
||||||
<div class="customContainer">
|
|
||||||
<h2>Current Nitro Boosters</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="social" class="darkmodeSectionThree">
|
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
|
||||||
<h2 class="sectionTitle">Social Media</h2>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
|
||||||
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="crateTeam" class="darkmodeSection">
|
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
|
||||||
<h2 class="sectionTitle">Crate Team</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="darkmodeSectionThree">
|
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
|
||||||
<h2 class="sectionTitle">Event Leaders</h2>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
|
||||||
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="darkmodeSection">
|
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
|
||||||
<h2 class="sectionTitle">Event Team</h2>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
|
||||||
<div style="flex-direction: column;">
|
|
||||||
<p style="text-align: center;">We occasionally open applications for the event team.</p>
|
|
||||||
<p style="text-align: center;">If you're interested in joining you simply need to keep an eye on the Discord
|
|
||||||
announcements.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="darkmodeSectionThree">
|
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
|
||||||
<h2 class="sectionTitle">YouTubers & Streamers</h2>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
|
||||||
<div style="flex-direction: column;">
|
|
||||||
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
|
|
||||||
<div style="flex-direction: column; justify-content: center; max-width: 800px;">
|
|
||||||
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
|
|
||||||
</p>
|
|
||||||
<p style="text-align: center;">You need to have at least one recent stream/video on Altitude which we can use
|
|
||||||
to gauge if your audience enjoys your content on Altitude.</p>
|
|
||||||
<br>
|
|
||||||
<p style="text-align: center;">Twitch: You need to be affiliate and get at least 5 viewers on average while
|
|
||||||
streaming on Altitude.</p>
|
|
||||||
<p style="text-align: center;">YouTube videos: You need at least 500 subs and have at least 200 views per
|
|
||||||
video within a week on average for Altitude content.</p>
|
|
||||||
<p style="text-align: center;">YouTube streamers: You need at least 500 subs and have at least 5 viewers on
|
|
||||||
average while streaming on Altitude.</p>
|
|
||||||
<br>
|
|
||||||
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Note:</span> Before
|
|
||||||
accepting or denying you we will watch your latest video/stream on Altitude (so keep your broadcasts public
|
|
||||||
on twitch).</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</ng-container>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
.customContainer {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 1020px;
|
|
||||||
margin: auto;
|
|
||||||
padding: 80px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import {Component} from '@angular/core';
|
|
||||||
import {HeaderComponent} from "../header/header.component";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-community',
|
|
||||||
imports: [
|
|
||||||
HeaderComponent
|
|
||||||
],
|
|
||||||
templateUrl: './community.component.html',
|
|
||||||
styleUrl: './community.component.scss'
|
|
||||||
})
|
|
||||||
export class CommunityComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<ng-container>
|
|
||||||
<app-header [current_page]="'nickgenerator'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
|
|
||||||
[overlay_gradient]="0.5">
|
|
||||||
<div class="title" header-content>
|
|
||||||
<h1>Nickname Generator</h1>
|
|
||||||
<h2>Customize your in-game nickname</h2>
|
|
||||||
<h3 style="font-family: 'minecraft-text', sans-serif; font-size: 0.8rem; margin-top: 10px;">Made by TheParm</h3>
|
|
||||||
</div>
|
|
||||||
</app-header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<!-- <section class="darkmodeSection">
|
|
||||||
<div class="container containerNick">
|
|
||||||
<div style="padding: 0 5% 0 5%;">
|
|
||||||
<div id="parts" class="previewNickDiv">
|
|
||||||
</div>
|
|
||||||
<div class="previewNickDiv">
|
|
||||||
<input type="button" class="button" value="Add Part" onclick="addPart()"/>
|
|
||||||
<input type="button" class="button" value="Remove Part" onclick="deletePart()"/>
|
|
||||||
</div>
|
|
||||||
<br><br><br><br>
|
|
||||||
<div id="commandTry" class="previewNickDiv">
|
|
||||||
<div id="try" class="command darkBg"></div>
|
|
||||||
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
|
|
||||||
</div>
|
|
||||||
<div id="commandRequest" class="previewNickDiv">
|
|
||||||
<div id="request" class="command darkBg"></div>
|
|
||||||
<input type="button" class="button copy" value="Copy" onclick="copy(this)"/>
|
|
||||||
</div>
|
|
||||||
<div id="preview" class="preview darkBg previewNickDiv">
|
|
||||||
</div>
|
|
||||||
<div id="template" class='part' style="display: none">
|
|
||||||
<p style="font-family: 'minecraft-text', sans-serif">
|
|
||||||
Text: <input type="text" id="text" class="textPart" size=18 oninput="inputChanged()"/>
|
|
||||||
Gradient: <input type="checkbox" id="grad" class="gradPart" oninput="onGradient(this)"/>
|
|
||||||
<input id="colorA" type="text" class="coloris colorAPart color" value="#ffffff" oninput="inputChanged()"/>
|
|
||||||
<input id="colorB" type="text" class="coloris colorBPart color" value="#ffffff" oninput="inputChanged()"/>
|
|
||||||
Continuation: <input type="checkbox" id="cont" class="contPart" disabled oninput="onContinuation(this)"/>
|
|
||||||
<span id="invalid" class="invalidPart" style="display: none">(min 1 - max 16 chars)</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 20px; text-align: center;">
|
|
||||||
<p style="font-family: 'minecraft-text', sans-serif">
|
|
||||||
Usage: Add as many parts as you wish, then apply the color and/or gradient, and copy/paste the command
|
|
||||||
into the minecraft chat. The total length of the nickname should be between 3 and 16 characters. Use the
|
|
||||||
continuation checkbox to continue the gradient from the last gradient color.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section> -->
|
|
||||||
</main>
|
|
||||||
</ng-container>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import {Component} from '@angular/core';
|
|
||||||
import {HeaderComponent} from "../header/header.component";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-nickgenerator',
|
|
||||||
imports: [
|
|
||||||
HeaderComponent
|
|
||||||
],
|
|
||||||
templateUrl: './nickgenerator.component.html',
|
|
||||||
styleUrl: './nickgenerator.component.scss'
|
|
||||||
})
|
|
||||||
export class NickgeneratorComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<ng-container>
|
|
||||||
<app-header [current_page]="'nicknames'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
|
|
||||||
[overlay_gradient]="0.5">
|
|
||||||
<div class="title" header-content>
|
|
||||||
<h1>How To Get A Nickname</h1>
|
|
||||||
<h2>Personalize your writing and nickname in-game by choosing some of the millions of custom colors that we have
|
|
||||||
to offer!</h2>
|
|
||||||
</div>
|
|
||||||
</app-header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="darkmodeSection">
|
|
||||||
<section class="columnSection">
|
|
||||||
<div class="columnContainer">
|
|
||||||
<div class="columnParagraph">
|
|
||||||
<h2>Creating a Nickname</h2>
|
|
||||||
<p>Donors that have the Duke or Archduke rank have the ability to create custom nicknames for themselves. A
|
|
||||||
nickname should be similar enough to the player’s username for that player to be identifiable, and it
|
|
||||||
<span style="font-family: 'opensans-bold', sans-serif;">must not exceed 16 characters in length</span>. In
|
|
||||||
general, a nickname should include the main part of a player’s actual username to avoid confusion.</p>
|
|
||||||
</div>
|
|
||||||
<div class="columnParagraph">
|
|
||||||
<h2>Make it Your Own</h2>
|
|
||||||
<p>Altitude now supports the full range of RGB colors, which includes more than 16 million different hues.
|
|
||||||
In addition, the previous basic color codes still exist and are able to be used in combination with the
|
|
||||||
RGB colors. You can still use <span style="font-family: 'opensans-bold', sans-serif;">/colors</span> to
|
|
||||||
see the basic color codes that are available for use. You can also make use of <span
|
|
||||||
style="font-family: 'opensans-bold', sans-serif;">/colorsextra</span> which shows many different RGB
|
|
||||||
color options. These colors will be in the form of <span
|
|
||||||
style="font-family: 'opensans-bold', sans-serif;">#XXXXXX</span>; write down the colors that you like so
|
|
||||||
that you can easily use them later.</p>
|
|
||||||
<img ngSrc="/public/img/random/colors.png" alt="RGB colors" style="width: 100%; padding:0;" height="130"
|
|
||||||
width="480">
|
|
||||||
<p>Output of /colorsextra</p>
|
|
||||||
<p>Furthermore, you can also use the <a [routerLink]="['/nickgenerator']">nickname generation tool</a> to
|
|
||||||
make and preview potential nicknames - as well as copying the commands to use in-game.</p>
|
|
||||||
<p>Players are encouraged to play around and personalize their nicknames, as long as they follow the rules
|
|
||||||
and are legible! There are endless possibilities beyond simple gradients as well; players can create any
|
|
||||||
combination of standard colors and gradients that they choose.</p>
|
|
||||||
<p>Even if you aren’t a Duke or Archduke, you can try out colors like this on signs in-game.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="columnContainer">
|
|
||||||
<div class="columnParagraph">
|
|
||||||
<h2>Request Your Nickname</h2>
|
|
||||||
<p>Players can only submit a new nickname request using <span
|
|
||||||
style="font-family: 'opensans-bold', sans-serif;">/nick request <name></span> once per day. When a
|
|
||||||
nickname is requested, it will notify staff for near-instant approval. Creating custom nicknames using RGB
|
|
||||||
can be difficult, so we have implemented <span style="font-family: 'opensans-bold', sans-serif;">/nick try <name></span>
|
|
||||||
so that you can experiment with your nicknames as many times as you want. It is highly encouraged to use
|
|
||||||
this command before submitting a formal request to staff. You can select an RGB color by putting the <span
|
|
||||||
style="font-family: 'opensans-bold', sans-serif;">#XXXXXX</span> code into curly braces before your
|
|
||||||
text.</p>
|
|
||||||
<p>We also support gradients in nicknames. You can specify the start and finish color and your nickname will
|
|
||||||
automatically find a gradient that goes between the two endpoints. <span
|
|
||||||
style="font-family: 'opensans-bold', sans-serif;">The symbol > is used to start a gradient, and the symbol < is used to close a gradient.</span>
|
|
||||||
You can have as many gradients as you want in your nickname, as long as it is readable.</p>
|
|
||||||
<p><span style="font-family: 'opensans-bold', sans-serif;">/nick try {#003380}Player</span>
|
|
||||||
would result in a blue name that read as “Player”.</p>
|
|
||||||
<p><span style="font-family: 'opensans-bold', sans-serif;">/nick try {#003380>}Player{#0000FF<}</span>
|
|
||||||
would result in the nickname reading as “Player” with a blue gradient fading across the letters in the
|
|
||||||
name.</p>
|
|
||||||
</div>
|
|
||||||
<div class="columnParagraph">
|
|
||||||
<h2>Useful Commands</h2>
|
|
||||||
<ul>
|
|
||||||
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick help</span> - Shows a list of all useful
|
|
||||||
nickname commands and how to use them.
|
|
||||||
</li>
|
|
||||||
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick try name</span> - Allows players to see
|
|
||||||
how a nickname would look as many times as you like.
|
|
||||||
</li>
|
|
||||||
<li><span style="font-family: 'opensans-bold', sans-serif;">/nick request name</span> - Allows players to
|
|
||||||
submit a new nickname to staff for approval. This function can only be used once per day.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</ng-container>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import {Component} from '@angular/core';
|
|
||||||
import {HeaderComponent} from "../header/header.component";
|
|
||||||
import {NgOptimizedImage} from '@angular/common';
|
|
||||||
import {RouterLink} from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-nicknames',
|
|
||||||
imports: [
|
|
||||||
HeaderComponent,
|
|
||||||
NgOptimizedImage,
|
|
||||||
RouterLink
|
|
||||||
],
|
|
||||||
templateUrl: './nicknames.component.html',
|
|
||||||
styleUrl: './nicknames.component.scss'
|
|
||||||
})
|
|
||||||
export class NicknamesComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+6
-6
@@ -1,18 +1,18 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { NicknamesComponent } from './nicknames.component';
|
import { FramesComponent } from './frames.component';
|
||||||
|
|
||||||
describe('NicknamesComponent', () => {
|
describe('FramesComponent', () => {
|
||||||
let component: NicknamesComponent;
|
let component: FramesComponent;
|
||||||
let fixture: ComponentFixture<NicknamesComponent>;
|
let fixture: ComponentFixture<FramesComponent>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [NicknamesComponent]
|
imports: [FramesComponent]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(NicknamesComponent);
|
fixture = TestBed.createComponent(FramesComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+6
-6
@@ -1,18 +1,18 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { NickgeneratorComponent } from './nickgenerator.component';
|
import { PropertiesComponent } from './properties.component';
|
||||||
|
|
||||||
describe('NickgeneratorComponent', () => {
|
describe('PropertiesComponent', () => {
|
||||||
let component: NickgeneratorComponent;
|
let component: PropertiesComponent;
|
||||||
let fixture: ComponentFixture<NickgeneratorComponent>;
|
let fixture: ComponentFixture<PropertiesComponent>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [NickgeneratorComponent]
|
imports: [PropertiesComponent]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(NickgeneratorComponent);
|
fixture = TestBed.createComponent(PropertiesComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+6
-6
@@ -1,18 +1,18 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { CommunityComponent } from './community.component';
|
import { ParticlesComponent } from './particles.component';
|
||||||
|
|
||||||
describe('CommunityComponent', () => {
|
describe('ParticlesComponent', () => {
|
||||||
let component: CommunityComponent;
|
let component: ParticlesComponent;
|
||||||
let fixture: ComponentFixture<CommunityComponent>;
|
let fixture: ComponentFixture<ParticlesComponent>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CommunityComponent]
|
imports: [ParticlesComponent]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(CommunityComponent);
|
fixture = TestBed.createComponent(ParticlesComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@@ -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 '../../theme/theme.service';
|
||||||
|
import {THEME_MODE} from '../../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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import '@angular/material/prebuilt-themes/azure-blue.css';
|
@import '@angular/material/prebuilt-themes/azure-blue.css';
|
||||||
|
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--white: #FFFFFF;
|
--white: #FFFFFF;
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ components:
|
|||||||
schemas:
|
schemas:
|
||||||
PermissionClaim:
|
PermissionClaim:
|
||||||
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
|
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
tags:
|
tags:
|
||||||
- name: history
|
- name: history
|
||||||
description: Retrieves punishment history
|
description: Retrieves punishment history
|
||||||
- name: team
|
- name: team
|
||||||
description: Retrieves information about the staff team
|
description: Retrieves information about the staff team
|
||||||
|
- name: particles
|
||||||
|
description: All actions related to particles
|
||||||
paths:
|
paths:
|
||||||
/team/{team}:
|
/team/{team}:
|
||||||
$ref: './schemas/team/team.yml#/getTeam'
|
$ref: './schemas/team/team.yml#/getTeam'
|
||||||
@@ -46,3 +53,11 @@ paths:
|
|||||||
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
|
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
|
||||||
/login/userLogin/{code}:
|
/login/userLogin/{code}:
|
||||||
$ref: './schemas/login/login.yml#/UserLogin'
|
$ref: './schemas/login/login.yml#/UserLogin'
|
||||||
|
/files/save/{filename}:
|
||||||
|
$ref: './schemas/particles/particles.yml#/SaveFile'
|
||||||
|
/files/save/{uuid}/{filename}:
|
||||||
|
$ref: './schemas/particles/particles.yml#/SaveFileForUser'
|
||||||
|
/files/download/{filename}/{secret}:
|
||||||
|
$ref: './schemas/particles/particles.yml#/DownloadFile'
|
||||||
|
/files/download/{uuid}/{filename}:
|
||||||
|
$ref: './schemas/particles/particles.yml#/DownloadFileForUser'
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Filename:
|
||||||
|
name: filename
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: The name of the file
|
||||||
|
Secret:
|
||||||
|
name: Authorization
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Secret
|
||||||
|
schemas:
|
||||||
|
FileData:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- content
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: The content of the file
|
||||||
|
|
||||||
|
SaveFile:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- particles
|
||||||
|
summary: Save a file
|
||||||
|
description: Save a file to the server (requires authorization)
|
||||||
|
operationId: saveFile
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Filename'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FileData'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: File saved successfully
|
||||||
|
'401':
|
||||||
|
description: Unauthorized - Invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
'403':
|
||||||
|
description: Forbidden - Insufficient permissions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
SaveFileForUser:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- particles
|
||||||
|
summary: Save a file for a specific user
|
||||||
|
description: Save a file to the server for a specific user (requires head_mod permission)
|
||||||
|
operationId: saveFileForUser
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
|
||||||
|
- $ref: '#/components/parameters/Filename'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FileData'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: File saved successfully
|
||||||
|
'401':
|
||||||
|
description: Unauthorized - Invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
'403':
|
||||||
|
description: Forbidden - Insufficient permissions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
DownloadFile:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- particles
|
||||||
|
summary: Download a file
|
||||||
|
description: Download a file from the server using a secret key
|
||||||
|
operationId: downloadFile
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Secret'
|
||||||
|
- $ref: '#/components/parameters/Filename'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: File downloaded successfully
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'404':
|
||||||
|
description: File not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
|
||||||
|
DownloadFileForUser:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- particles
|
||||||
|
summary: Download a file for a specific user
|
||||||
|
description: Download a file from the server for a specific user (requires authorization)
|
||||||
|
operationId: downloadFileForUser
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Secret'
|
||||||
|
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
|
||||||
|
- $ref: '#/components/parameters/Filename'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: File downloaded successfully
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'401':
|
||||||
|
description: Unauthorized - Invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
'404':
|
||||||
|
description: File not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
Reference in New Issue
Block a user