diff --git a/.agent/skills/debug.md b/.agent/skills/debug.md
index 0e44edd..f000409 100644
--- a/.agent/skills/debug.md
+++ b/.agent/skills/debug.md
@@ -8,6 +8,8 @@ Append `?debug` to the URL:
http://localhost:5173?debug
```
+The free debug camera is toggled from the debug panel, not mounted permanently.
+
## Debug singleton
```ts
diff --git a/README.md b/README.md
index 9c96f46..96487db 100644
--- a/README.md
+++ b/README.md
@@ -123,7 +123,7 @@ npm run dev
```
Open `http://localhost:5173` — standard experience.
-Open `http://localhost:5173?debug` — debug panel + r3f-perf overlay + free debug camera.
+Open `http://localhost:5173?debug` — debug panel + r3f-perf overlay. The free debug camera is enabled from the debug panel.
## 🧭 Conventions
diff --git a/src/App.tsx b/src/App.tsx
index dcc621e..6b30f8e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,13 +1,17 @@
import { Canvas } from "@react-three/fiber";
+import { Crosshair } from "@/components/ui/Crosshair";
import { DebugPerf } from "@/debug/DebugPerf";
import { World } from "@/world/World";
function App(): React.JSX.Element {
return (
-
+ <>
+
+
+ >
);
}
diff --git a/src/components/ui/Crosshair.tsx b/src/components/ui/Crosshair.tsx
new file mode 100644
index 0000000..e43affd
--- /dev/null
+++ b/src/components/ui/Crosshair.tsx
@@ -0,0 +1,11 @@
+import { useCameraMode } from "@/debug/useCameraMode";
+
+export function Crosshair(): React.JSX.Element | null {
+ const cameraMode = useCameraMode();
+
+ if (cameraMode !== "player") {
+ return null;
+ }
+
+ return
;
+}
diff --git a/src/debug/Debug.ts b/src/debug/Debug.ts
index 0951f79..66d4596 100644
--- a/src/debug/Debug.ts
+++ b/src/debug/Debug.ts
@@ -1,25 +1,40 @@
import GUI from "lil-gui";
+export type CameraMode = "player" | "debug";
+
export class Debug {
private static instance: Debug | null = null;
public readonly active: boolean;
private readonly gui: GUI | null;
private readonly folders = new Map();
+ private readonly listeners = new Set<() => void>();
+ private readonly controls = { cameraMode: "player" as CameraMode };
+ private 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");
- if (this.active) {
- this.gui = new GUI({ title: "La-Fabrik Debug" });
- } else {
- this.gui = null;
+ this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null;
+
+ if (this.gui) {
+ const folder = this.createFolder("Debug");
+
+ folder
+ ?.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" })
+ .name("Camera Mode")
+ .onChange((value: CameraMode) => {
+ this.controls.cameraMode = value;
+ this.cameraMode = value;
+ this.emit();
+ });
}
}
@@ -40,9 +55,30 @@ export class Debug {
return folder;
}
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ isDebugCameraEnabled(): boolean {
+ return this.cameraMode === "debug";
+ }
+
+ getCameraMode(): CameraMode {
+ return this.cameraMode;
+ }
+
destroy(): void {
+ this.listeners.clear();
this.folders.clear();
this.gui?.destroy();
Debug.instance = null;
}
+
+ private emit(): void {
+ this.listeners.forEach((listener) => listener());
+ }
}
diff --git a/src/debug/scene/DebugCameraControls.tsx b/src/debug/scene/DebugCameraControls.tsx
index f3fcaa0..85977a0 100644
--- a/src/debug/scene/DebugCameraControls.tsx
+++ b/src/debug/scene/DebugCameraControls.tsx
@@ -1,68 +1,13 @@
-import { useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei";
-import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
-import { Debug } from "@/debug/Debug";
export function DebugCameraControls(): React.JSX.Element {
- const controls = useRef(null);
-
- useEffect(() => {
- const debug = Debug.getInstance();
-
- if (!debug.active || !controls.current) {
- return undefined;
- }
-
- const folder = debug.createFolder("Camera");
-
- if (!folder) {
- return undefined;
- }
-
- const target = controls.current.target;
- const cameraState = {
- targetX: target.x,
- targetY: target.y,
- targetZ: target.z,
- };
-
- const syncTarget = (): void => {
- if (!controls.current) {
- return;
- }
-
- controls.current.target.set(
- cameraState.targetX,
- cameraState.targetY,
- cameraState.targetZ,
- );
- controls.current.update();
- };
-
- folder
- .add(cameraState, "targetX", -100, 100, 0.1)
- .name("Target X")
- .onChange(syncTarget);
- folder
- .add(cameraState, "targetY", -20, 50, 0.1)
- .name("Target Y")
- .onChange(syncTarget);
- folder
- .add(cameraState, "targetZ", -100, 100, 0.1)
- .name("Target Z")
- .onChange(syncTarget);
-
- return undefined;
- }, []);
-
return (
);
}
diff --git a/src/debug/useCameraMode.ts b/src/debug/useCameraMode.ts
new file mode 100644
index 0000000..3ef7d5a
--- /dev/null
+++ b/src/debug/useCameraMode.ts
@@ -0,0 +1,13 @@
+import { useSyncExternalStore } from "react";
+import type { CameraMode } from "@/debug/Debug";
+import { Debug } from "@/debug/Debug";
+
+export function useCameraMode(): CameraMode {
+ const debug = Debug.getInstance();
+
+ return useSyncExternalStore(
+ (listener) => debug.subscribe(listener),
+ () => debug.getCameraMode(),
+ () => debug.getCameraMode(),
+ );
+}
diff --git a/src/index.css b/src/index.css
index faca6d2..f0b4d3a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -13,6 +13,7 @@ body,
body {
overflow: hidden;
+ background: #04070d;
}
button,
@@ -25,3 +26,17 @@ select {
canvas {
display: block;
}
+
+.crosshair {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(255, 255, 255, 0.92);
+ border-radius: 999px;
+ transform: translate(-50%, -50%);
+ box-sizing: border-box;
+ pointer-events: none;
+ z-index: 10;
+}
diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx
index 2870010..ed42b98 100644
--- a/src/world/Lighting.tsx
+++ b/src/world/Lighting.tsx
@@ -1,9 +1,80 @@
+import { useEffect, useRef } from "react";
+import { useFrame } from "@react-three/fiber";
+import type { AmbientLight, DirectionalLight } from "three";
+import { Debug } from "@/debug/Debug";
+
+type LightingState = {
+ ambientIntensity: number;
+ sunIntensity: number;
+ sunX: number;
+ sunY: number;
+ sunZ: number;
+};
+
+const LIGHTING_STATE: LightingState = {
+ ambientIntensity: 1.8,
+ sunIntensity: 2.8,
+ sunX: 60,
+ sunY: 80,
+ sunZ: 30,
+};
+
export function Lighting(): React.JSX.Element {
+ const ambient = useRef(null);
+ const sun = useRef(null);
+
+ useEffect(() => {
+ const debug = Debug.getInstance();
+
+ if (!debug.active) {
+ return undefined;
+ }
+
+ const folder = debug.createFolder("Lighting");
+
+ if (!folder) {
+ return undefined;
+ }
+
+ folder.add(LIGHTING_STATE, "ambientIntensity", 0, 5, 0.1).name("Ambient");
+ folder.add(LIGHTING_STATE, "sunIntensity", 0, 8, 0.1).name("Sun Intensity");
+ folder.add(LIGHTING_STATE, "sunX", -100, 100, 1).name("Sun X");
+ folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y");
+ folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z");
+
+ return undefined;
+ }, []);
+
+ useFrame(() => {
+ if (ambient.current) {
+ ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
+ }
+
+ if (sun.current) {
+ sun.current.position.set(
+ LIGHTING_STATE.sunX,
+ LIGHTING_STATE.sunY,
+ LIGHTING_STATE.sunZ,
+ );
+ sun.current.intensity = LIGHTING_STATE.sunIntensity;
+ }
+ });
+
return (
<>
+
diff --git a/src/world/Map.tsx b/src/world/Map.tsx
index 773c9cf..043181c 100644
--- a/src/world/Map.tsx
+++ b/src/world/Map.tsx
@@ -1,28 +1,14 @@
-// # route path src/world/Map.tsx
-import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
-import { useFrame } from "@react-three/fiber";
+import { useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
-import { Debug } from "@/debug/Debug";
const MODEL_PATH = "/models/map/blocking/model.glb";
-type MapDebugState = {
- positionX: number;
- positionY: number;
- positionZ: number;
- rotationY: number;
+type CenteredModel = {
+ object: THREE.Object3D;
scale: number;
};
-const DEFAULT_DEBUG_STATE: MapDebugState = {
- positionX: 0,
- positionY: 0,
- positionZ: 0,
- rotationY: 0,
- scale: 1,
-};
-
function centerModel(model: THREE.Object3D): number {
model.updateMatrixWorld(true);
@@ -36,64 +22,17 @@ function centerModel(model: THREE.Object3D): number {
}
export function Map(): React.JSX.Element {
- const root = useRef(null);
- const debugState = useRef({ ...DEFAULT_DEBUG_STATE });
const { scene } = useGLTF(MODEL_PATH);
- const model = useMemo(() => scene.clone(true), [scene]);
+ const centeredModel = useMemo(() => {
+ const object = scene.clone(true);
+ const scale = centerModel(object);
- useLayoutEffect(() => {
- debugState.current.scale = centerModel(model);
- }, [model]);
-
- useEffect(() => {
- const debug = Debug.getInstance();
-
- if (!debug.active) {
- return undefined;
- }
-
- const folder = debug.createFolder("Map");
-
- if (!folder) {
- return undefined;
- }
-
- folder
- .add(debugState.current, "positionX", -100, 100, 0.1)
- .name("Position X");
- folder
- .add(debugState.current, "positionY", -20, 50, 0.1)
- .name("Position Y");
- folder
- .add(debugState.current, "positionZ", -100, 100, 0.1)
- .name("Position Z");
- folder
- .add(debugState.current, "rotationY", -Math.PI, Math.PI, 0.01)
- .name("Rotation Y");
- folder.add(debugState.current, "scale", 0.1, 10, 0.01).name("Scale");
-
- return undefined;
- }, []);
-
- useFrame(() => {
- const currentRoot = root.current;
-
- if (!currentRoot) {
- return;
- }
-
- currentRoot.position.set(
- debugState.current.positionX,
- debugState.current.positionY,
- debugState.current.positionZ,
- );
- currentRoot.rotation.y = debugState.current.rotationY;
- currentRoot.scale.setScalar(debugState.current.scale);
- });
+ return { object, scale };
+ }, [scene]);
return (
-
-
+
+
);
}
diff --git a/src/world/World.tsx b/src/world/World.tsx
index 78e2d58..48764da 100644
--- a/src/world/World.tsx
+++ b/src/world/World.tsx
@@ -1,17 +1,21 @@
import { Suspense } from "react";
import { DebugCameraControls } from "@/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/debug/scene/DebugHelpers";
+import { useCameraMode } from "@/debug/useCameraMode";
import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting";
import { Map } from "@/world/Map";
+import { FPSController } from "@/world/player/FPSController";
export function World(): React.JSX.Element {
+ const cameraMode = useCameraMode();
+
return (
<>
-
+ {cameraMode === "debug" ? : }
diff --git a/src/world/player/FPSController.tsx b/src/world/player/FPSController.tsx
index 48de893..62c9eff 100644
--- a/src/world/player/FPSController.tsx
+++ b/src/world/player/FPSController.tsx
@@ -1 +1,124 @@
-// src/world/player/FPSController.tsx
+import { useEffect, useMemo, useRef } from "react";
+import { PointerLockControls } from "@react-three/drei";
+import { useFrame, useThree } from "@react-three/fiber";
+import * as THREE from "three";
+
+const PLAYER_EYE_HEIGHT = 1.75;
+const PLAYER_SPAWN_POSITION = new THREE.Vector3(0, PLAYER_EYE_HEIGHT, 6);
+const PLAYER_LOOK_AT = new THREE.Vector3(0, PLAYER_EYE_HEIGHT, 0);
+const MOVE_SPEED = 5;
+
+type PlayerKeys = {
+ forward: boolean;
+ backward: boolean;
+ left: boolean;
+ right: boolean;
+};
+
+const DEFAULT_KEYS: PlayerKeys = {
+ forward: false,
+ backward: false,
+ left: false,
+ right: false,
+};
+
+export function FPSController(): React.JSX.Element {
+ const camera = useThree((state) => state.camera);
+ const keys = useRef({ ...DEFAULT_KEYS });
+ const interact = useRef<() => void>(() => {});
+ const forward = useMemo(() => new THREE.Vector3(), []);
+ const right = useMemo(() => new THREE.Vector3(), []);
+ const movement = useMemo(() => new THREE.Vector3(), []);
+ const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
+
+ useEffect(() => {
+ camera.position.copy(PLAYER_SPAWN_POSITION);
+ camera.lookAt(PLAYER_LOOK_AT);
+ camera.updateProjectionMatrix();
+
+ return () => {
+ document.exitPointerLock?.();
+ };
+ }, [camera]);
+
+ useEffect(() => {
+ const handleKeyChange =
+ (pressed: boolean) =>
+ (event: KeyboardEvent): void => {
+ switch (event.code) {
+ case "KeyZ":
+ keys.current.forward = pressed;
+ break;
+ case "KeyS":
+ keys.current.backward = pressed;
+ break;
+ case "KeyQ":
+ keys.current.left = pressed;
+ break;
+ case "KeyD":
+ keys.current.right = pressed;
+ break;
+ case "KeyE":
+ if (pressed) {
+ interact.current();
+ }
+ break;
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ };
+
+ const handleKeyDown = handleKeyChange(true);
+ const handleKeyUp = handleKeyChange(false);
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ keys.current = { ...DEFAULT_KEYS };
+ };
+ }, []);
+
+ useFrame((_, delta) => {
+ movement.set(0, 0, 0);
+
+ camera.getWorldDirection(forward);
+ forward.y = 0;
+
+ if (forward.lengthSq() > 0) {
+ forward.normalize();
+ right.crossVectors(forward, up).normalize();
+ }
+
+ if (keys.current.forward) {
+ movement.add(forward);
+ }
+
+ if (keys.current.backward) {
+ movement.sub(forward);
+ }
+
+ if (keys.current.left) {
+ movement.sub(right);
+ }
+
+ if (keys.current.right) {
+ movement.add(right);
+ }
+
+ if (movement.lengthSq() > 0) {
+ movement.normalize().multiplyScalar(MOVE_SPEED * delta);
+ camera.position.add(movement);
+ }
+
+ if (camera.position.y < PLAYER_EYE_HEIGHT) {
+ camera.position.y = PLAYER_EYE_HEIGHT;
+ }
+ });
+
+ return ;
+}