diff --git a/cspell.json b/cspell.json
index b922dff13..3a671e892 100644
--- a/cspell.json
+++ b/cspell.json
@@ -14,7 +14,10 @@
"RMAO",
"Verts",
"drei",
- "frameloop"
+ "frameloop",
+ "pointermove",
+ "pointerup",
+ "pointerdown"
],
"userWords": [
"WOTB",
diff --git a/packages/website-ue/src/components/TankopediaControls.tsx b/packages/website-ue/src/components/TankopediaControls.tsx
new file mode 100644
index 000000000..4d8a7fbfd
--- /dev/null
+++ b/packages/website-ue/src/components/TankopediaControls.tsx
@@ -0,0 +1,70 @@
+import { J_HAT, K_HAT } from "@blitzkit/core";
+import { invalidate, useThree } from "@react-three/fiber";
+import { useEffect } from "react";
+import { Vector2, Vector3 } from "three";
+import { degToRad } from "three/src/math/MathUtils.js";
+
+const initialDistance = 8;
+const initialRotation = new Vector2(degToRad(45), degToRad(45));
+
+const clientPosition = new Vector2();
+const workingVector2 = new Vector2();
+const workingVector3 = new Vector3();
+
+const cameraCenter = new Vector3(0, 1, 0);
+
+const cameraRotation = initialRotation.clone();
+let cameraDistance = initialDistance;
+
+export function TankopediaControls() {
+ const canvas = useThree((state) => state.gl.domElement);
+ const camera = useThree((state) => state.camera);
+
+ useEffect(() => {
+ function handlePointerDown(event: PointerEvent) {
+ clientPosition.set(event.clientX, event.clientY);
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ }
+
+ function handlePointerMove(event: PointerEvent) {
+ workingVector2.set(event.clientX, event.clientY).sub(clientPosition);
+
+ workingVector2.x *= -(2 * Math.PI) / canvas.width;
+ workingVector2.y *= (2 * Math.PI) / canvas.height;
+
+ cameraRotation.x += workingVector2.x;
+
+ if (cameraRotation.y + workingVector2.y < Math.PI / 2) {
+ cameraRotation.y += workingVector2.y;
+ }
+
+ clientPosition.set(event.clientX, event.clientY);
+
+ updateCameraPosition();
+ }
+
+ function handlePointerUp() {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ }
+
+ function updateCameraPosition() {
+ camera.position
+ .set(cameraDistance, 0, 0)
+ .applyAxisAngle(J_HAT, cameraRotation.x)
+ .applyAxisAngle(K_HAT, cameraRotation.y)
+ .add(cameraCenter);
+ camera.lookAt(cameraCenter);
+
+ invalidate();
+ }
+
+ canvas.addEventListener("pointerdown", handlePointerDown);
+
+ return () => {
+ canvas.removeEventListener("pointerdown", handlePointerDown);
+ };
+ }, []);
+}
diff --git a/packages/website-ue/src/components/TankopediaSandbox/index.tsx b/packages/website-ue/src/components/TankopediaSandbox/index.tsx
index cfde0340a..ca0df20ae 100644
--- a/packages/website-ue/src/components/TankopediaSandbox/index.tsx
+++ b/packages/website-ue/src/components/TankopediaSandbox/index.tsx
@@ -1,10 +1,10 @@
import { type StageParameters } from "@protos/blitz_static_tank_upgrade_single_stage";
-import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { useTankChassisModel } from "../../hooks/useTankChassisModel";
import { useTankGunModel } from "../../hooks/useTankGunModel";
import { useTankHullModel } from "../../hooks/useTankHullModel";
import { useTankTurretModel } from "../../hooks/useTankTurretModel";
+import { TankopediaControls } from "../TankopediaControls";
import { TankopediaLighting } from "../TankopediaLighting";
import { SceneProps } from "../TankopediaSceneProps";
import styles from "./index.module.css";
@@ -53,8 +53,7 @@ function Content({ parameters }: TankopediaSandboxProps) {
-
-
+
>
);
}