feat: rework entire project structure

This commit is contained in:
2026-03-23 00:04:04 +01:00
parent 6282951b88
commit 8185757933
26 changed files with 1414 additions and 86 deletions

92
app/three/animQueue.ts Normal file
View File

@@ -0,0 +1,92 @@
import * as THREE from "three";
import Animation from "./animation";
import assets from "@app/data/assets.json"
const ANIMATION_ASSETS = assets.animations;
export default class AnimationQueue {
private animation: Animation;
private queue: THREE.AnimationAction[] = [];
private currentAction: THREE.AnimationAction | null = null;
private mixer: THREE.AnimationMixer;
private randomIntervalId: number | null = null;
constructor(animation: Animation) {
this.mixer = animation.getMixer();
this.animation = animation;
}
public onqueue(action: THREE.AnimationAction) {
this.queue.push(action);
this.tryPlayNext();
}
public replaceFirst(action: THREE.AnimationAction) {
this.queue.unshift(action);
this.tryPlayNext(true);
}
public async tryPlayNext(force: boolean = false) {
if (this.currentAction) {
if (force) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
if ((this.currentAction as any).isBasicClone) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
return;
}
}
}
if (!this.queue.length) this.queue.push(this.animation.getBasicAction());
const NEXTACTION = this.queue.shift()!;
NEXTACTION.reset();
NEXTACTION.setLoop(THREE.LoopOnce, 1);
NEXTACTION.clampWhenFinished = true;
NEXTACTION.fadeIn(this.animation.getFadein()).play();
const onFinish = (e: any) => {
if (e.action === this.currentAction) {
if (this.currentAction) this.currentAction.fadeOut(this.animation.getFadeout());
this.mixer.removeEventListener("finished", onFinish);
this.currentAction = null;
return this.tryPlayNext();
}
};
this.currentAction = NEXTACTION;
this.mixer.addEventListener("finished", onFinish);
}
public startRandom() {
if (this.randomIntervalId !== null) return;
this.randomIntervalId = window.setInterval(async () => {
if (!this.mixer) return;
const RANDOMINDEX = Math.floor(Math.random() * ANIMATION_ASSETS.length);
this.onqueue(await this.animation.loadAnimation(ANIMATION_ASSETS[RANDOMINDEX]));
}, 30_000);
}
public stopRandom() {
if (this.randomIntervalId !== null) {
clearInterval(this.randomIntervalId);
this.randomIntervalId = null;
}
}
public clearQueue() {
this.queue = [];
}
public stop() {
this.clearQueue();
this.currentAction?.fadeOut(this.animation.getFadeout());
this.currentAction = null;
this.stopRandom();
}
}

81
app/three/animation.ts Normal file
View File

@@ -0,0 +1,81 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Animation {
private mixer: THREE.AnimationMixer;
private loader: GLTFLoader;
private actions: Map<string, THREE.AnimationAction> = new Map();
private basicAction!: THREE.AnimationAction;
private fadein: number;
private fadeout: number;
constructor(
model: THREE.Object3D,
loader: GLTFLoader,
basicAction_url: string,
fadein: number = 0.5, fadeout: number = 0.8) {
this.mixer = new THREE.AnimationMixer(model);
this.loader = loader;
this.fadein = fadein;
this.fadeout = fadeout;
this.setBasicAction(basicAction_url);
}
public loadAnimation(url: string): Promise<THREE.AnimationAction> {
url = `https://web-bucket.s3.fr-par.scw.cloud/${url}`;
return new Promise((resolve, reject) => {
if (this.actions.has(url)) return resolve(this.actions.get(url)!);
this.loader.load(
url,
(gltf: GLTF) => {
if (!gltf.animations.length) return reject(new Error(`${url} has no animations`));
let clip = gltf.animations[0];
if (clip.tracks.some(track => track.name.endsWith('.position'))) {
clip = clip.clone();
clip.tracks = clip.tracks.filter(track => !track.name.endsWith('.position'));
}
const action = this.mixer.clipAction(clip);
action.stop();
this.actions.set(url, action);
resolve(action);
},
undefined,
reject
);
});
}
public setBasicAction(url: string) {
this.loadAnimation(url).then(action => {
this.basicAction = action;
});
}
public getBasicAction(): THREE.AnimationAction {
const clipClone = this.basicAction.getClip().clone();
const action = this.mixer.clipAction(clipClone);
(action as any).isBasicClone = true;
return action;
}
public getFadein(): number { return this.fadein; }
public getFadeout(): number { return this.fadeout; }
public update(delta: number) {
this.mixer?.update(delta);
}
public getMixer(): THREE.AnimationMixer {
if (!this.mixer) throw new Error("Mixer not initialized yet!");
return this.mixer
}
}

34
app/three/camera.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as THREE from "three";
export default class Camera {
private camera: THREE.PerspectiveCamera;
private box = new THREE.Box3();
private size = new THREE.Vector3();
private center = new THREE.Vector3();
constructor(camera: THREE.PerspectiveCamera, model: THREE.Object3D) {
this.camera = camera;
this.box = this.box.setFromObject(model);
}
public centerCamera(
mouvement: (box: THREE.Box3, size: THREE.Vector3, center: THREE.Vector3) => void
) {
this.box.getSize(this.size);
this.box.getCenter(this.center);
mouvement(this.box, this.size, this.center);
}
public positionCamera() {
const fov = this.camera.fov * (Math.PI / 180);
const distance = this.size.y / (2 * Math.tan(fov / 2));
this.camera.position.set(0, 0, distance * 1.2);
this.camera.lookAt(0, 0, 0);
}
public getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
}

109
app/three/main.ts Normal file
View File

@@ -0,0 +1,109 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Model from "./model";
import Camera from "./camera";
import Animation from "./animation";
import assets from "@app/data/assets.json"
import AnimationQueue from "./animQueue";
export default class Main {
private element!: HTMLElement;
private loadingManager?: THREE.LoadingManager;
private scene!: THREE.Scene;
private renderer!: THREE.WebGLRenderer;
private camera!: THREE.PerspectiveCamera;
private controls!: OrbitControls;
private loader!: GLTFLoader;
private clock: THREE.Clock;
private animation!: Animation;
constructor(htmlelement: HTMLElement, loadingManager?: THREE.LoadingManager) {
this.element = htmlelement;
this.loadingManager = loadingManager;
this.clock = new THREE.Clock();
this.init();
}
private async init() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75, // fov
this.element.clientWidth / this.element.clientHeight, // aspect
0.1, 1000 // near, far
);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this.renderer.setSize(
this.element.clientWidth,
this.element.clientHeight
);
this.element.appendChild(this.renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
this.controls = new OrbitControls(this.camera, this.element);
this.controls.minPolarAngle = Math.PI / 2;
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.enableZoom = false;
this.controls.enablePan = false;
this.renderer.domElement.style.touchAction = 'pan-y';
this.loader = new GLTFLoader(this.loadingManager);
window.addEventListener("resize", () => {
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;
this.camera.aspect = WIDTH / HEIGHT;
this.camera.updateProjectionMatrix();
this.renderer.setSize(WIDTH, HEIGHT);
});
const MODEL = await (new Model(this.loader)).init(assets.model, this.scene);
const CAMERA = new Camera(this.camera, MODEL);
CAMERA.centerCamera((_box: any, size: any, center: any) => {
MODEL.position.sub(center);
MODEL.position.y -= size.y * 0.3;
});
CAMERA.positionCamera();
// Animation
this.animation = new Animation(MODEL, this.loader, assets.idle_animation);
const ANIMATION_QUEUE = new AnimationQueue(this.animation);
ANIMATION_QUEUE.onqueue(await this.animation.loadAnimation(assets.welcome_animation));
ANIMATION_QUEUE.startRandom();
this.animate();
}
// animate must be an arrow key so that "this"
// can be pointed to the Main instance
private animate = () => {
requestAnimationFrame(this.animate);
const delta = this.clock.getDelta();
if (this.animation) this.animation.update(delta);
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}

29
app/three/model.ts Normal file
View File

@@ -0,0 +1,29 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Model {
private loader: GLTFLoader;
constructor(loader: GLTFLoader) {
this.loader = loader;
}
public async init(model_url: string, scene: THREE.Scene, isVisible: boolean = true): Promise<THREE.Object3D> {
const MODEL = await this.loadModel(`https://web-bucket.s3.fr-par.scw.cloud/${model_url}`);
MODEL.visible = isVisible;
scene.add(MODEL);
return MODEL;
}
public loadModel(model_url: string): Promise<THREE.Object3D> {
return new Promise((resolve, reject) => {
this.loader.setCrossOrigin('anonymous');
this.loader.load(
model_url,
(gltf: GLTF) => resolve(gltf.scene),
undefined,
reject
);
});
}
}