refactor: tighten project structure and strengthen tooling

This commit is contained in:
2026-04-16 10:45:05 +02:00
parent 3506858c96
commit 7769959135
57 changed files with 362 additions and 519 deletions
-1
View File
@@ -1 +0,0 @@
// src/utils/Dispose.ts
+54 -1
View File
@@ -1 +1,54 @@
// src/utils/EventEmitter.ts
type Listener<TPayload> = (payload: TPayload) => void;
export class EventEmitter<TEvents extends Record<string, unknown>> {
private readonly listeners = new Map<
keyof TEvents,
Set<Listener<TEvents[keyof TEvents]>>
>();
on<TKey extends keyof TEvents>(
event: TKey,
listener: Listener<TEvents[TKey]>,
): () => void {
const currentListeners = this.listeners.get(event) ?? new Set();
currentListeners.add(listener as Listener<TEvents[keyof TEvents]>);
this.listeners.set(event, currentListeners);
return () => {
this.off(event, listener);
};
}
off<TKey extends keyof TEvents>(
event: TKey,
listener: Listener<TEvents[TKey]>,
): void {
const currentListeners = this.listeners.get(event);
if (!currentListeners) {
return;
}
currentListeners.delete(listener as Listener<TEvents[keyof TEvents]>);
if (currentListeners.size === 0) {
this.listeners.delete(event);
}
}
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const currentListeners = this.listeners.get(event);
if (!currentListeners) {
return;
}
currentListeners.forEach((listener) => {
listener(payload as TEvents[keyof TEvents]);
});
}
clear(): void {
this.listeners.clear();
}
}
+50 -1
View File
@@ -1 +1,50 @@
// src/utils/Sizes.ts
type SizeSnapshot = {
width: number;
height: number;
pixelRatio: number;
};
type SizeListener = (snapshot: SizeSnapshot) => void;
export class Sizes {
private snapshot: SizeSnapshot;
private readonly listeners = new Set<SizeListener>();
private readonly handleResize = (): void => {
this.snapshot = Sizes.readWindow();
this.emit();
};
constructor() {
this.snapshot = Sizes.readWindow();
window.addEventListener("resize", this.handleResize);
}
subscribe(listener: SizeListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getSnapshot(): SizeSnapshot {
return this.snapshot;
}
destroy(): void {
window.removeEventListener("resize", this.handleResize);
this.listeners.clear();
}
private emit(): void {
this.listeners.forEach((listener) => listener(this.snapshot));
}
private static readWindow(): SizeSnapshot {
return {
width: window.innerWidth,
height: window.innerHeight,
pixelRatio: Math.min(window.devicePixelRatio, 2),
};
}
}
+42 -1
View File
@@ -1 +1,42 @@
// src/utils/Time.ts
type TickListener = (delta: number, elapsed: number) => void;
export class Time {
private readonly listeners = new Set<TickListener>();
private animationFrameId = 0;
private lastTick = performance.now();
private elapsed = 0;
constructor() {
this.tick = this.tick.bind(this);
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
subscribe(listener: TickListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getElapsed(): number {
return this.elapsed;
}
destroy(): void {
window.cancelAnimationFrame(this.animationFrameId);
this.listeners.clear();
}
private tick(now: number): void {
const delta = (now - this.lastTick) / 1000;
this.lastTick = now;
this.elapsed += delta;
this.listeners.forEach((listener) => {
listener(delta, this.elapsed);
});
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
}
+78
View File
@@ -0,0 +1,78 @@
import GUI from "lil-gui";
import type { CameraMode } from "@/types/debug";
export class Debug {
private static instance: Debug | null = null;
public readonly active: boolean;
private readonly gui: GUI | null;
private readonly folders = new Map<string, GUI>();
private readonly listeners = new Set<() => void>();
private readonly controls: { cameraMode: CameraMode } = {
cameraMode: "player",
};
static getInstance(): Debug {
if (!Debug.instance) {
Debug.instance = new Debug();
}
return Debug.instance;
}
private constructor() {
this.active = new URLSearchParams(window.location.search).has("debug");
this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null;
if (this.gui) {
const folder = this.createFolder("Debug");
if (!folder) {
return;
}
folder
.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" })
.name("Camera Mode")
.onChange((value: CameraMode) => {
this.controls.cameraMode = value;
this.emit();
});
}
}
createFolder(name: string): GUI;
createFolder(name: string): GUI | null;
createFolder(name: string): GUI | null {
if (!this.gui) {
return null;
}
const existingFolder = this.folders.get(name);
if (existingFolder) {
return existingFolder;
}
const folder = this.gui.addFolder(name);
this.folders.set(name, folder);
return folder;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getCameraMode(): CameraMode {
return this.controls.cameraMode;
}
private emit(): void {
this.listeners.forEach((listener) => listener());
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Suspense, lazy } from "react";
import { Debug } from "@/utils/debug/Debug";
const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf })));
export function DebugPerf(): React.JSX.Element | null {
const debug = Debug.getInstance();
if (!debug.active) {
return null;
}
return (
<Suspense fallback={null}>
<Perf position="bottom-right" />
</Suspense>
);
}
@@ -0,0 +1,13 @@
import { OrbitControls } from "@react-three/drei";
export function DebugCameraControls(): React.JSX.Element {
return (
<OrbitControls
enableDamping
dampingFactor={0.05}
minDistance={100}
maxDistance={1000}
target={[0, 1.75, 0]}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Debug } from "@/utils/debug/Debug";
export function DebugHelpers(): React.JSX.Element | null {
const debug = Debug.getInstance();
if (!debug.active) {
return null;
}
return (
<>
<gridHelper
args={[180, 36, "#1d4ed8", "#1e293b"]}
position={[0, 0.01, 0]}
/>
<axesHelper args={[10]} />
</>
);
}