player.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. "use client";
  2. import { useEffect, useRef, useState, Suspense } from "react";
  3. import { useFrame, useThree } from "@react-three/fiber";
  4. import { PointerLockControls, useGLTF } from "@react-three/drei";
  5. import { RigidBody, CapsuleCollider, useRapier } from "@react-three/rapier";
  6. import type { RapierRigidBody } from "@react-three/rapier";
  7. import * as THREE from "three";
  8. import { useEditorStore } from "@/lib/store";
  9. import { useIsMobile } from "@/lib/use-mobile";
  10. import { touchMoveState } from "@/lib/touch-state";
  11. import { resetBoneScales } from "@/lib/bone-utils";
  12. interface PlayerProps {
  13. position?: [number, number, number] | null;
  14. rotation?: [number, number, number] | null;
  15. scale?: [number, number, number] | null;
  16. objectId?: string | null;
  17. isPlayer?: boolean | null;
  18. }
  19. export function Player({ position }: PlayerProps) {
  20. const isPlaying = useEditorStore((s) => s.isPlaying);
  21. const viewMode = useEditorStore((s) => s.viewMode);
  22. const health = useEditorStore((s) => s.health);
  23. const isPromptOpen = useEditorStore((s) => s.isPromptOpen);
  24. if (!isPlaying) {
  25. const pos = position ?? [0, 0, 0];
  26. return (
  27. <mesh position={pos as [number, number, number]}>
  28. <capsuleGeometry args={[0.3, 0.8, 8, 16]} />
  29. <meshStandardMaterial color="#3498db" transparent opacity={0.5} />
  30. </mesh>
  31. );
  32. }
  33. if (viewMode === "first-person") {
  34. return (
  35. <FirstPersonController
  36. initialPosition={position ?? [0, 0, 0]}
  37. health={health}
  38. isPromptOpen={isPromptOpen}
  39. />
  40. );
  41. }
  42. return (
  43. <ThirdPersonController
  44. initialPosition={position ?? [0, 0, 0]}
  45. health={health}
  46. isPromptOpen={isPromptOpen}
  47. />
  48. );
  49. }
  50. function FirstPersonController({
  51. initialPosition,
  52. health,
  53. isPromptOpen,
  54. }: {
  55. initialPosition: [number, number, number];
  56. health: number;
  57. isPromptOpen: boolean;
  58. }) {
  59. const { camera } = useThree();
  60. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  61. const controlsRef = useRef<any>(null);
  62. const playerRef = useRef<RapierRigidBody>(null);
  63. const isMobile = useIsMobile();
  64. const moveState = useRef({
  65. forward: false,
  66. backward: false,
  67. left: false,
  68. right: false,
  69. jump: false,
  70. sprint: false,
  71. });
  72. const { rapier, world } = useRapier();
  73. const playerDirection = useRef(new THREE.Vector3());
  74. const isOnGround = useRef(true);
  75. const jumpCooldown = useRef(0);
  76. const touchYaw = useRef(0);
  77. const touchPitch = useRef(0);
  78. const speed = 5;
  79. const jumpForce = 5;
  80. const groundCastDist = 0.85;
  81. const isGameOver = health <= 0;
  82. useEffect(() => {
  83. if (isMobile) {
  84. camera.rotation.order = "YXZ";
  85. camera.rotation.set(0, 0, 0);
  86. touchYaw.current = 0;
  87. touchPitch.current = 0;
  88. }
  89. }, [isMobile, camera]);
  90. useEffect(() => {
  91. if (isMobile) return;
  92. const handleKeyDown = (e: KeyboardEvent) => {
  93. if (isPromptOpen || isGameOver) return;
  94. switch (e.code) {
  95. case "KeyW":
  96. case "ArrowUp":
  97. moveState.current.forward = true;
  98. break;
  99. case "KeyS":
  100. case "ArrowDown":
  101. moveState.current.backward = true;
  102. break;
  103. case "KeyA":
  104. case "ArrowLeft":
  105. moveState.current.left = true;
  106. break;
  107. case "KeyD":
  108. case "ArrowRight":
  109. moveState.current.right = true;
  110. break;
  111. case "Space":
  112. moveState.current.jump = true;
  113. break;
  114. case "ShiftLeft":
  115. case "ShiftRight":
  116. moveState.current.sprint = true;
  117. break;
  118. }
  119. };
  120. const handleKeyUp = (e: KeyboardEvent) => {
  121. switch (e.code) {
  122. case "KeyW":
  123. case "ArrowUp":
  124. moveState.current.forward = false;
  125. break;
  126. case "KeyS":
  127. case "ArrowDown":
  128. moveState.current.backward = false;
  129. break;
  130. case "KeyA":
  131. case "ArrowLeft":
  132. moveState.current.left = false;
  133. break;
  134. case "KeyD":
  135. case "ArrowRight":
  136. moveState.current.right = false;
  137. break;
  138. case "Space":
  139. moveState.current.jump = false;
  140. break;
  141. case "ShiftLeft":
  142. case "ShiftRight":
  143. moveState.current.sprint = false;
  144. break;
  145. }
  146. };
  147. window.addEventListener("keydown", handleKeyDown);
  148. window.addEventListener("keyup", handleKeyUp);
  149. return () => {
  150. window.removeEventListener("keydown", handleKeyDown);
  151. window.removeEventListener("keyup", handleKeyUp);
  152. };
  153. }, [isPromptOpen, isGameOver, isMobile]);
  154. useFrame((_, delta) => {
  155. if (!playerRef.current || isGameOver) return;
  156. const body = playerRef.current;
  157. const vel = body.linvel();
  158. let forward: boolean;
  159. let backward: boolean;
  160. let left: boolean;
  161. let right: boolean;
  162. let jump: boolean;
  163. let sprint: boolean;
  164. if (isMobile) {
  165. forward = touchMoveState.forward > 0.15;
  166. backward = touchMoveState.forward < -0.15;
  167. left = touchMoveState.right < -0.15;
  168. right = touchMoveState.right > 0.15;
  169. jump = touchMoveState.jump;
  170. sprint = touchMoveState.sprint;
  171. if (touchMoveState.lookDeltaX !== 0 || touchMoveState.lookDeltaY !== 0) {
  172. touchYaw.current -= touchMoveState.lookDeltaX;
  173. touchPitch.current -= touchMoveState.lookDeltaY;
  174. touchPitch.current = Math.max(
  175. -Math.PI / 2 + 0.01,
  176. Math.min(Math.PI / 2 - 0.01, touchPitch.current),
  177. );
  178. camera.rotation.order = "YXZ";
  179. camera.rotation.y = touchYaw.current;
  180. camera.rotation.x = touchPitch.current;
  181. touchMoveState.lookDeltaX = 0;
  182. touchMoveState.lookDeltaY = 0;
  183. }
  184. } else {
  185. ({ forward, backward, left, right, jump, sprint } = moveState.current);
  186. }
  187. playerDirection.current.set(0, 0, 0);
  188. const frontVector = new THREE.Vector3(
  189. 0,
  190. 0,
  191. (backward ? 1 : 0) - (forward ? 1 : 0),
  192. );
  193. const sideVector = new THREE.Vector3(
  194. (right ? 1 : 0) - (left ? 1 : 0),
  195. 0,
  196. 0,
  197. );
  198. playerDirection.current
  199. .addVectors(frontVector, sideVector)
  200. .normalize()
  201. .multiplyScalar(speed * (sprint ? 1.8 : 1))
  202. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
  203. .applyEuler(camera.rotation as any);
  204. body.setLinvel(
  205. { x: playerDirection.current.x, y: vel.y, z: playerDirection.current.z },
  206. true,
  207. );
  208. if (jump && isOnGround.current && jumpCooldown.current <= 0) {
  209. body.setLinvel({ x: vel.x, y: jumpForce, z: vel.z }, true);
  210. isOnGround.current = false;
  211. jumpCooldown.current = 0.3;
  212. }
  213. if (jumpCooldown.current > 0) jumpCooldown.current -= delta;
  214. const pos = body.translation();
  215. const ray = new rapier.Ray(
  216. { x: pos.x, y: pos.y, z: pos.z },
  217. { x: 0, y: -1, z: 0 },
  218. );
  219. const hit = world.castRay(ray, groundCastDist, false);
  220. isOnGround.current = hit !== null;
  221. camera.position.set(pos.x, pos.y + 0.85, pos.z);
  222. });
  223. return (
  224. <>
  225. {!isMobile && <PointerLockControls ref={controlsRef} />}
  226. <RigidBody
  227. ref={playerRef}
  228. position={[
  229. initialPosition[0],
  230. initialPosition[1] + 2,
  231. initialPosition[2],
  232. ]}
  233. enabledRotations={[false, false, false]}
  234. mass={1}
  235. linearDamping={0.5}
  236. type="dynamic"
  237. colliders={false}
  238. lockRotations
  239. >
  240. <CapsuleCollider args={[0.35, 0.3]} />
  241. </RigidBody>
  242. </>
  243. );
  244. }
  245. function AnimatedCharacterModelInner({
  246. characterRef,
  247. isMoving,
  248. }: {
  249. characterRef: React.RefObject<THREE.Group | null>;
  250. isMoving: boolean;
  251. }) {
  252. const characterGltf = useGLTF("/models/bot.glb");
  253. const walkGltf = useGLTF("/models/walking.glb");
  254. const idleGltf = useGLTF("/models/idle.glb");
  255. const mixerRef = useRef<THREE.AnimationMixer | null>(null);
  256. const actionsRef = useRef<Record<string, THREE.AnimationAction>>({});
  257. const [ready, setReady] = useState(false);
  258. useEffect(() => {
  259. if (!characterRef.current || !characterGltf || !walkGltf || !idleGltf)
  260. return;
  261. try {
  262. characterGltf.scene.scale.set(1, 1, 1);
  263. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
  264. resetBoneScales(characterGltf.scene as any);
  265. characterRef.current.clear();
  266. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch between drei and three
  267. characterRef.current.add(characterGltf.scene.clone() as any);
  268. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
  269. const mixer = new THREE.AnimationMixer(characterRef.current as any);
  270. mixerRef.current = mixer;
  271. if (idleGltf.animations[0] && walkGltf.animations[0]) {
  272. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
  273. const idleAction = mixer.clipAction(idleGltf.animations[0] as any);
  274. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
  275. const walkAction = mixer.clipAction(walkGltf.animations[0] as any);
  276. actionsRef.current = { idle: idleAction, walk: walkAction };
  277. idleAction.play();
  278. setReady(true);
  279. }
  280. } catch {
  281. // Model loading failed
  282. }
  283. }, [characterRef, characterGltf, walkGltf, idleGltf]);
  284. useEffect(() => {
  285. if (!ready) return;
  286. const { idle, walk } = actionsRef.current;
  287. if (!idle || !walk) return;
  288. if (isMoving) {
  289. idle.fadeOut(0.2);
  290. walk.reset().fadeIn(0.2).play();
  291. } else {
  292. walk.fadeOut(0.2);
  293. idle.reset().fadeIn(0.2).play();
  294. }
  295. }, [isMoving, ready]);
  296. useFrame((_, delta) => {
  297. mixerRef.current?.update(delta);
  298. });
  299. return null;
  300. }
  301. function ThirdPersonController({
  302. initialPosition,
  303. health,
  304. isPromptOpen,
  305. }: {
  306. initialPosition: [number, number, number];
  307. health: number;
  308. isPromptOpen: boolean;
  309. }) {
  310. const { camera } = useThree();
  311. const playerRef = useRef<RapierRigidBody>(null);
  312. const characterRef = useRef<THREE.Group>(null);
  313. const cameraAngle = useRef(0);
  314. const [isMoving, setIsMoving] = useState(false);
  315. const isMobile = useIsMobile();
  316. const moveState = useRef({
  317. forward: false,
  318. backward: false,
  319. left: false,
  320. right: false,
  321. sprint: false,
  322. });
  323. const speed = 5;
  324. const isGameOver = health <= 0;
  325. useEffect(() => {
  326. if (isMobile) {
  327. cameraAngle.current = 0;
  328. }
  329. }, [isMobile]);
  330. useEffect(() => {
  331. if (isMobile) return;
  332. const handleKeyDown = (e: KeyboardEvent) => {
  333. if (isPromptOpen || isGameOver) return;
  334. switch (e.code) {
  335. case "KeyW":
  336. case "ArrowUp":
  337. moveState.current.forward = true;
  338. break;
  339. case "KeyS":
  340. case "ArrowDown":
  341. moveState.current.backward = true;
  342. break;
  343. case "KeyA":
  344. case "ArrowLeft":
  345. moveState.current.left = true;
  346. break;
  347. case "KeyD":
  348. case "ArrowRight":
  349. moveState.current.right = true;
  350. break;
  351. case "ShiftLeft":
  352. case "ShiftRight":
  353. moveState.current.sprint = true;
  354. break;
  355. }
  356. };
  357. const handleKeyUp = (e: KeyboardEvent) => {
  358. switch (e.code) {
  359. case "KeyW":
  360. case "ArrowUp":
  361. moveState.current.forward = false;
  362. break;
  363. case "KeyS":
  364. case "ArrowDown":
  365. moveState.current.backward = false;
  366. break;
  367. case "KeyA":
  368. case "ArrowLeft":
  369. moveState.current.left = false;
  370. break;
  371. case "KeyD":
  372. case "ArrowRight":
  373. moveState.current.right = false;
  374. break;
  375. case "ShiftLeft":
  376. case "ShiftRight":
  377. moveState.current.sprint = false;
  378. break;
  379. }
  380. };
  381. const handleMouseMove = (e: MouseEvent) => {
  382. cameraAngle.current -= e.movementX * 0.003;
  383. };
  384. window.addEventListener("keydown", handleKeyDown);
  385. window.addEventListener("keyup", handleKeyUp);
  386. window.addEventListener("mousemove", handleMouseMove);
  387. return () => {
  388. window.removeEventListener("keydown", handleKeyDown);
  389. window.removeEventListener("keyup", handleKeyUp);
  390. window.removeEventListener("mousemove", handleMouseMove);
  391. };
  392. }, [isPromptOpen, isGameOver, isMobile]);
  393. useFrame(() => {
  394. if (!playerRef.current || isGameOver) return;
  395. const body = playerRef.current;
  396. const vel = body.linvel();
  397. let forward: boolean;
  398. let backward: boolean;
  399. let left: boolean;
  400. let right: boolean;
  401. let sprint: boolean;
  402. if (isMobile) {
  403. forward = touchMoveState.forward > 0.15;
  404. backward = touchMoveState.forward < -0.15;
  405. left = touchMoveState.right < -0.15;
  406. right = touchMoveState.right > 0.15;
  407. sprint = touchMoveState.sprint;
  408. if (touchMoveState.lookDeltaX !== 0) {
  409. cameraAngle.current -= touchMoveState.lookDeltaX;
  410. touchMoveState.lookDeltaX = 0;
  411. touchMoveState.lookDeltaY = 0;
  412. }
  413. } else {
  414. ({ forward, backward, left, right, sprint } = moveState.current);
  415. }
  416. const dir = new THREE.Vector3(0, 0, 0);
  417. if (forward) dir.z -= 1;
  418. if (backward) dir.z += 1;
  419. if (left) dir.x -= 1;
  420. if (right) dir.x += 1;
  421. dir.normalize().multiplyScalar(speed * (sprint ? 1.8 : 1));
  422. dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), cameraAngle.current);
  423. body.setLinvel({ x: dir.x, y: vel.y, z: dir.z }, true);
  424. const moving = dir.length() > 0.1;
  425. setIsMoving(moving);
  426. const pos = body.translation();
  427. if (characterRef.current) {
  428. characterRef.current.position.set(pos.x, pos.y, pos.z);
  429. if (moving) {
  430. const targetAngle = Math.atan2(dir.x, dir.z);
  431. characterRef.current.rotation.y = targetAngle;
  432. }
  433. }
  434. const camDist = 6;
  435. const camHeight = 3;
  436. camera.position.set(
  437. pos.x + Math.sin(cameraAngle.current) * camDist,
  438. pos.y + camHeight,
  439. pos.z + Math.cos(cameraAngle.current) * camDist,
  440. );
  441. camera.lookAt(pos.x, pos.y + 1, pos.z);
  442. });
  443. return (
  444. <>
  445. <RigidBody
  446. ref={playerRef}
  447. position={[
  448. initialPosition[0],
  449. initialPosition[1] + 2,
  450. initialPosition[2],
  451. ]}
  452. enabledRotations={[false, false, false]}
  453. mass={1}
  454. linearDamping={0.5}
  455. type="dynamic"
  456. colliders={false}
  457. lockRotations
  458. >
  459. <CapsuleCollider args={[0.35, 0.3]} />
  460. </RigidBody>
  461. <group ref={characterRef} position={initialPosition}>
  462. <Suspense fallback={null}>
  463. <AnimatedCharacterModelInner
  464. characterRef={characterRef}
  465. isMoving={isMoving}
  466. />
  467. </Suspense>
  468. {/* Fallback capsule mesh visible until models load */}
  469. <mesh castShadow>
  470. <capsuleGeometry args={[0.3, 0.8, 8, 16]} />
  471. <meshStandardMaterial color="#3498db" />
  472. </mesh>
  473. </group>
  474. </>
  475. );
  476. }