├── .DS_Store ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── example ├── .DS_Store ├── CharacterAnimation.png ├── CharacterModel.tsx ├── DynamicPlatforms.tsx ├── EcctrlFixedCamera.png ├── Experience.tsx ├── FloatingCharacterControl.png ├── FloatingPlatform.tsx ├── Floor.tsx ├── Lights.tsx ├── PmndrsEcctrl.png ├── RigidObjects.tsx ├── RoughPlane.tsx ├── ShotCube.tsx ├── Slopes.tsx ├── Steps.tsx ├── UnclePetePhysicsEnhance.png ├── ecctrlClickToMove.png ├── ecctrlJoystick.png ├── index.html ├── index.tsx └── style.css ├── featurelog.md ├── package-lock.json ├── package.json ├── public ├── .DS_Store ├── Floating Character.glb ├── keyControls.png ├── punchEffect.png ├── roughPlane.glb ├── slopes.glb └── textures │ ├── .DS_Store │ ├── 3.jpg │ └── 5.jpg ├── readme.md ├── src ├── Ecctrl.tsx ├── EcctrlAnimation.tsx ├── EcctrlJoystick.tsx ├── hooks │ └── useFollowCam.tsx └── stores │ ├── useGame.ts │ └── useJoystickControls.ts ├── tsconfig.json ├── vercelVite.config.js └── vite.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/.DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ErdongChen-Andrew] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | open_collective: # Replace with a single Open Collective username 5 | ko_fi: # Replace with a single Ko-fi username 6 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 7 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 8 | liberapay: # Replace with a single Liberapay username 9 | issuehunt: # Replace with a single IssueHunt username 10 | otechie: # Replace with a single Otechie username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | custom: ["https://www.paypal.me/andrewchenerdong"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | .vscode 5 | exampleDist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Erdong Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/.DS_Store -------------------------------------------------------------------------------- /example/CharacterAnimation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/CharacterAnimation.png -------------------------------------------------------------------------------- /example/CharacterModel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAnimations, 3 | useGLTF, 4 | useTexture, 5 | Trail, 6 | SpriteAnimator, 7 | } from "@react-three/drei"; 8 | import { useControls } from "leva"; 9 | import { Suspense, useEffect, useRef, useMemo, useState } from "react"; 10 | import * as THREE from "three"; 11 | import { useGame } from "../src/stores/useGame"; 12 | import { BallCollider, RapierCollider, vec3 } from "@react-three/rapier"; 13 | import { useFrame } from "@react-three/fiber"; 14 | import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; 15 | 16 | export default function CharacterModel(props: CharacterModelProps) { 17 | // Change the character src to yours 18 | const group = useRef(); 19 | const { nodes, animations } = useGLTF("/Floating Character.glb") as GLTF & { 20 | nodes: any; 21 | }; 22 | const { actions } = useAnimations(animations, group); 23 | // gradientMapTexture for MeshToonMaterial 24 | const gradientMapTexture = useTexture("./textures/3.jpg"); 25 | gradientMapTexture.minFilter = THREE.NearestFilter; 26 | gradientMapTexture.magFilter = THREE.NearestFilter; 27 | gradientMapTexture.generateMipmaps = false; 28 | 29 | /** 30 | * Prepare hands ref for attack action 31 | */ 32 | const rightHandRef = useRef(); 33 | const rightHandColliderRef = useRef(); 34 | const leftHandRef = useRef(); 35 | const leftHandColliderRef = useRef(); 36 | const rightHandPos = useMemo(() => new THREE.Vector3(), []); 37 | const leftHandPos = useMemo(() => new THREE.Vector3(), []); 38 | const bodyPos = useMemo(() => new THREE.Vector3(), []); 39 | const bodyRot = useMemo(() => new THREE.Quaternion(), []); 40 | let rightHand: THREE.Object3D = null; 41 | let leftHand: THREE.Object3D = null; 42 | let mugModel: THREE.Object3D = null; 43 | 44 | /** 45 | * Prepare punch effect sprite 46 | */ 47 | const [punchEffectProps, setPunchEffectProp] = useState({ 48 | visible: false, 49 | scale: [1, 1, 1], 50 | play: false, 51 | position: [-0.2, -0.2, 0.5], 52 | startFrame: 0, 53 | }); 54 | 55 | /** 56 | * Debug settings 57 | */ 58 | const { mainColor, outlineColor, trailColor } = useControls( 59 | "Character Model", 60 | { 61 | mainColor: "mediumslateblue", 62 | outlineColor: "black", 63 | trailColor: "violet", 64 | } 65 | ); 66 | 67 | /** 68 | * Prepare replacing materials 69 | */ 70 | const outlineMaterial = useMemo( 71 | () => 72 | new THREE.MeshBasicMaterial({ 73 | color: outlineColor, 74 | transparent: true, 75 | }), 76 | [outlineColor] 77 | ); 78 | const meshToonMaterial = useMemo( 79 | () => 80 | new THREE.MeshToonMaterial({ 81 | color: mainColor, 82 | gradientMap: gradientMapTexture, 83 | transparent: true, 84 | }), 85 | [mainColor] 86 | ); 87 | 88 | /** 89 | * Character animations setup 90 | */ 91 | const curAnimation = useGame((state) => state.curAnimation); 92 | const resetAnimation = useGame((state) => state.reset); 93 | const initializeAnimationSet = useGame( 94 | (state) => state.initializeAnimationSet 95 | ); 96 | 97 | // Rename your character animations here 98 | const animationSet = { 99 | idle: "Idle", 100 | walk: "Walk", 101 | run: "Run", 102 | jump: "Jump_Start", 103 | jumpIdle: "Jump_Idle", 104 | jumpLand: "Jump_Land", 105 | fall: "Climbing", // This is for falling from high sky 106 | action1: "Wave", 107 | action2: "Dance", 108 | action3: "Cheer", 109 | action4: "Attack(1h)", 110 | }; 111 | 112 | useEffect(() => { 113 | // Initialize animation set 114 | initializeAnimationSet(animationSet); 115 | }, []); 116 | 117 | useEffect(() => { 118 | group.current.traverse((obj) => { 119 | // Prepare both hands bone object 120 | if (obj instanceof THREE.Bone) { 121 | if (obj.name === "handSlotRight") rightHand = obj; 122 | if (obj.name === "handSlotLeft") leftHand = obj; 123 | } 124 | // Prepare mug model for cheer action 125 | if (obj.name === "mug") { 126 | mugModel = obj; 127 | mugModel.visible = false; 128 | } 129 | }); 130 | }); 131 | 132 | useFrame(() => { 133 | if (curAnimation === animationSet.action4) { 134 | if (rightHand) { 135 | rightHand.getWorldPosition(rightHandPos); 136 | group.current.getWorldPosition(bodyPos); 137 | group.current.getWorldQuaternion(bodyRot); 138 | } 139 | 140 | // Apply hands position to hand colliders 141 | if (rightHandColliderRef.current) { 142 | // check if parent group autobalance is on or off 143 | if (group.current.parent.quaternion.y === 0 && group.current.parent.quaternion.w === 1) { 144 | rightHandRef.current.position.copy(rightHandPos).sub(bodyPos).applyQuaternion(bodyRot.conjugate()); 145 | } else { 146 | rightHandRef.current.position.copy(rightHandPos).sub(bodyPos); 147 | } 148 | rightHandColliderRef.current.setTranslationWrtParent( 149 | rightHandRef.current.position 150 | ); 151 | } 152 | } 153 | }); 154 | 155 | useEffect(() => { 156 | // Play animation 157 | const action = actions[curAnimation ? curAnimation : animationSet.jumpIdle]; 158 | 159 | // For jump and jump land animation, only play once and clamp when finish 160 | if ( 161 | curAnimation === animationSet.jump || 162 | curAnimation === animationSet.jumpLand || 163 | curAnimation === animationSet.action1 || 164 | curAnimation === animationSet.action2 || 165 | curAnimation === animationSet.action3 || 166 | curAnimation === animationSet.action4 167 | ) { 168 | action 169 | .reset() 170 | .fadeIn(0.2) 171 | .setLoop(THREE.LoopOnce, undefined as number) 172 | .play(); 173 | action.clampWhenFinished = true; 174 | // Only show mug during cheer action 175 | if (curAnimation === animationSet.action3) { 176 | mugModel.visible = true; 177 | } else { 178 | mugModel.visible = false; 179 | } 180 | } else { 181 | action.reset().fadeIn(0.2).play(); 182 | mugModel.visible = false; 183 | } 184 | 185 | // When any action is clamp and finished reset animation 186 | (action as any)._mixer.addEventListener("finished", () => resetAnimation()); 187 | 188 | return () => { 189 | // Fade out previous action 190 | action.fadeOut(0.2); 191 | 192 | // Clean up mixer listener, and empty the _listeners array 193 | (action as any)._mixer.removeEventListener("finished", () => 194 | resetAnimation() 195 | ); 196 | (action as any)._mixer._listeners = []; 197 | 198 | // Move hand collider back to initial position after action 199 | if (curAnimation === animationSet.action4) { 200 | if (rightHandColliderRef.current) { 201 | rightHandColliderRef.current.setTranslationWrtParent(vec3({ x: 0, y: 0, z: 0 })) 202 | } 203 | } 204 | }; 205 | }, [curAnimation]); 206 | 207 | return ( 208 | }> 209 | {/* Default capsule modle */} 210 | {/* 211 | 212 | 213 | 214 | 215 | 216 | 217 | */} 218 | 219 | {/* Replace yours model here */} 220 | {/* Head collider */} 221 | 222 | {/* Right hand collider */} 223 | 224 | { 228 | if (curAnimation === animationSet.action4) { 229 | // Play punch effect 230 | setPunchEffectProp((prev) => ({ 231 | ...prev, 232 | visible: true, 233 | play: true, 234 | })); 235 | } 236 | }} 237 | /> 238 | 239 | {/* Left hand collider */} 240 | 241 | 242 | {/* Character model */} 243 | 248 | 249 | 250 | 256 | 264 | width} 269 | > 270 | 271 | 272 | 273 | 274 | { 281 | setPunchEffectProp((prev) => ({ 282 | ...prev, 283 | visible: false, 284 | play: false, 285 | })); 286 | }} 287 | play={punchEffectProps.play} 288 | numberOfFrames={7} 289 | alphaTest={0.01} 290 | textureImageURL={"./punchEffect.png"} 291 | /> 292 | 293 | 294 | ); 295 | } 296 | 297 | export type CharacterModelProps = JSX.IntrinsicElements["group"]; 298 | 299 | // Change the character src to yours 300 | useGLTF.preload("/Floating Character.glb"); 301 | -------------------------------------------------------------------------------- /example/DynamicPlatforms.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@react-three/drei"; 2 | import { useFrame } from "@react-three/fiber"; 3 | import { 4 | CuboidCollider, 5 | CylinderCollider, 6 | RapierRigidBody, 7 | RigidBody, 8 | } from "@react-three/rapier"; 9 | import { useRef, useMemo } from "react"; 10 | import * as THREE from "three"; 11 | 12 | export default function DynamicPlatforms() { 13 | const sideMovePlatformRef = useRef(); 14 | const verticalMovePlatformRef = useRef(); 15 | const rotatePlatformRef = useRef(); 16 | const rotationDrumRef = useRef(); 17 | 18 | // Initializ animation settings 19 | let time = null; 20 | const xRotationAxies = new THREE.Vector3(1, 0, 0); 21 | const yRotationAxies = new THREE.Vector3(0, 1, 0); 22 | 23 | const quaternionRotation = useMemo(() => new THREE.Quaternion(), []); 24 | 25 | useFrame((state) => { 26 | time = state.clock.elapsedTime; 27 | 28 | // Move platform 29 | sideMovePlatformRef.current?.setNextKinematicTranslation({ 30 | x: 5 * Math.sin(time / 2) - 12, 31 | y: -0.5, 32 | z: -10, 33 | }); 34 | 35 | // Elevate platform 36 | verticalMovePlatformRef.current?.setNextKinematicTranslation({ 37 | x: -25, 38 | y: 2 * Math.sin(time / 2) + 2, 39 | z: 0, 40 | }); 41 | verticalMovePlatformRef.current?.setNextKinematicRotation( 42 | quaternionRotation.setFromAxisAngle(yRotationAxies, time * 0.5) 43 | ); 44 | 45 | // Rotate platform 46 | rotatePlatformRef.current?.setNextKinematicRotation( 47 | quaternionRotation.setFromAxisAngle(yRotationAxies, time * 0.5) 48 | ); 49 | 50 | // Rotate drum 51 | rotationDrumRef.current?.setNextKinematicRotation( 52 | quaternionRotation.setFromAxisAngle(xRotationAxies, time * 0.5) 53 | ); 54 | }); 55 | 56 | return ( 57 | <> 58 | {/* Moving platform */} 59 | 64 | 71 | Kinematic Moving Platform 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | {/* Elevating platform */} 81 | 87 | 95 | Kinematic Elevating Platform 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | {/* Rotating Platform */} 105 | 111 | 118 | Kinematic Rotating Platform 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | {/* Rotating drum */} 128 | 135 | Kinematic Rotating Drum 136 | 137 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /example/EcctrlFixedCamera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/EcctrlFixedCamera.png -------------------------------------------------------------------------------- /example/Experience.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, KeyboardControls } from "@react-three/drei"; 2 | import { Perf } from "r3f-perf"; 3 | import { Physics } from "@react-three/rapier"; 4 | import Ecctrl from "../src/Ecctrl"; 5 | import Floor from "./Floor"; 6 | import Lights from "./Lights"; 7 | import Steps from "./Steps"; 8 | import Slopes from "./Slopes"; 9 | import RoughPlane from "./RoughPlane"; 10 | import RigidObjects from "./RigidObjects"; 11 | import FloatingPlatform from "./FloatingPlatform"; 12 | import DynamicPlatforms from "./DynamicPlatforms"; 13 | import ShotCube from "./ShotCube"; 14 | import { useControls } from "leva"; 15 | import CharacterModel from "./CharacterModel"; 16 | import React, { useEffect, useState } from "react"; 17 | 18 | export default function Experience() { 19 | /** 20 | * Delay physics activate 21 | */ 22 | const [pausedPhysics, setPausedPhysics] = useState(true); 23 | useEffect(() => { 24 | const timeout = setTimeout(() => { 25 | setPausedPhysics(false); 26 | }, 500); 27 | 28 | return () => clearTimeout(timeout); 29 | }, []); 30 | 31 | /** 32 | * Debug settings 33 | */ 34 | const { physics, disableControl, disableFollowCam } = useControls("World Settings", { 35 | physics: false, 36 | disableControl: false, 37 | disableFollowCam: false, 38 | }); 39 | 40 | /** 41 | * Keyboard control preset 42 | */ 43 | const keyboardMap = [ 44 | { name: "forward", keys: ["ArrowUp", "KeyW"] }, 45 | { name: "backward", keys: ["ArrowDown", "KeyS"] }, 46 | { name: "leftward", keys: ["ArrowLeft", "KeyA"] }, 47 | { name: "rightward", keys: ["ArrowRight", "KeyD"] }, 48 | { name: "jump", keys: ["Space"] }, 49 | { name: "run", keys: ["Shift"] }, 50 | { name: "action1", keys: ["1"] }, 51 | { name: "action2", keys: ["2"] }, 52 | { name: "action3", keys: ["3"] }, 53 | { name: "action4", keys: ["KeyF"] }, 54 | ]; 55 | 56 | return ( 57 | <> 58 | 59 | 60 | 67 | 68 | 69 | 70 | 71 | {/* Keyboard preset */} 72 | 73 | {/* Character Control */} 74 | 87 | {/* Replace your model here */} 88 | 89 | 90 | 91 | 92 | {/* Rough plan */} 93 | 94 | 95 | {/* Slopes and stairs */} 96 | 97 | 98 | {/* Small steps */} 99 | 100 | 101 | {/* Rigid body objects */} 102 | 103 | 104 | {/* Floating platform */} 105 | 106 | 107 | {/* Dynamic platforms */} 108 | 109 | 110 | {/* Floor */} 111 | 112 | 113 | {/* Shoting cubes */} 114 | 115 | 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /example/FloatingCharacterControl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/FloatingCharacterControl.png -------------------------------------------------------------------------------- /example/FloatingPlatform.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CuboidCollider, 3 | RigidBody, 4 | useRapier, 5 | } from "@react-three/rapier"; 6 | import { useEffect, useRef, useMemo } from "react"; 7 | import * as THREE from "three"; 8 | import { useFrame } from "@react-three/fiber"; 9 | import { Text } from "@react-three/drei"; 10 | import type { RayColliderHit } from "@dimforge/rapier3d-compat"; 11 | 12 | export default function FloatingPlatform() { 13 | // Preset 14 | // couldn't find the correct type 15 | const floatingPlateRef = useRef(); 16 | const floatingPlateRef2 = useRef(); 17 | const floatingMovingPlateRef = useRef(); 18 | const { rapier, world } = useRapier(); 19 | 20 | /** 21 | * Ray setup 22 | */ 23 | // Platform 1 24 | const rayLength = 0.8; 25 | const rayDir = { x: 0, y: -1, z: 0 }; 26 | const springDirVec = useMemo(() => new THREE.Vector3(), []); 27 | const origin = useMemo(() => new THREE.Vector3(), []); 28 | const rayCast = new rapier.Ray(origin, rayDir); 29 | let rayHit: RayColliderHit | null = null; 30 | const floatingDis = 0.8; 31 | const springK = 2.5; 32 | const dampingC = 0.15; 33 | // Platform 2 34 | const springDirVec2 = useMemo(() => new THREE.Vector3(), []); 35 | const origin2 = useMemo(() => new THREE.Vector3(), []); 36 | const rayCast2 = new rapier.Ray(origin2, rayDir); 37 | let rayHit2: RayColliderHit | null = null; 38 | // Moving Platform 39 | const springDirVecMove = useMemo(() => new THREE.Vector3(), []); 40 | const originMove = useMemo(() => new THREE.Vector3(), []); 41 | const rayCastMove = new rapier.Ray(originMove, rayDir); 42 | const movingVel = useMemo(() => new THREE.Vector3(), []); 43 | let movingDir = 1; 44 | let rayHitMove: RayColliderHit | null = null; 45 | 46 | useEffect(() => { 47 | // Loack platform 1 rotation 48 | floatingPlateRef.current.lockRotations(true); 49 | 50 | // Loack platform 2 translation 51 | floatingPlateRef2.current.lockRotations(true); 52 | floatingPlateRef2.current.lockTranslations(true); 53 | floatingPlateRef2.current.setEnabledRotations(false, true, false); 54 | floatingPlateRef2.current.setEnabledTranslations(false, true, false); 55 | 56 | // Loack moving platform rotation 57 | floatingMovingPlateRef.current.setEnabledRotations(false, true, false); 58 | floatingMovingPlateRef.current.setEnabledTranslations(true, true, false); 59 | }, []); 60 | 61 | useFrame(() => { 62 | /** 63 | * Ray casting detect if on ground 64 | */ 65 | // Ray cast for platform 1 66 | if (floatingPlateRef.current) { 67 | origin.set( 68 | floatingPlateRef.current.translation().x, 69 | floatingPlateRef.current.translation().y, 70 | floatingPlateRef.current.translation().z 71 | ); 72 | rayHit = world.castRay( 73 | rayCast, 74 | rayLength, 75 | false, 76 | undefined, 77 | undefined, 78 | floatingPlateRef.current, 79 | floatingPlateRef.current 80 | ); 81 | } 82 | // Ray cast for platform 2 83 | if (floatingPlateRef2.current) { 84 | origin2.set( 85 | floatingPlateRef2.current.translation().x, 86 | floatingPlateRef2.current.translation().y, 87 | floatingPlateRef2.current.translation().z 88 | ); 89 | rayHit2 = world.castRay( 90 | rayCast2, 91 | rayLength, 92 | false, 93 | undefined, 94 | undefined, 95 | floatingPlateRef2.current, 96 | floatingPlateRef2.current 97 | ); 98 | } 99 | // Ray cast for moving platform 100 | if (floatingMovingPlateRef.current) { 101 | originMove.set( 102 | floatingMovingPlateRef.current.translation().x, 103 | floatingMovingPlateRef.current.translation().y, 104 | floatingMovingPlateRef.current.translation().z 105 | ); 106 | rayHitMove = world.castRay( 107 | rayCastMove, 108 | rayLength, 109 | false, 110 | undefined, 111 | undefined, 112 | floatingMovingPlateRef.current, 113 | floatingMovingPlateRef.current 114 | ); 115 | // Apply moving velocity to the platform 116 | if (floatingMovingPlateRef.current.translation().x > 10) { 117 | movingDir = -1; 118 | } else if (floatingMovingPlateRef.current.translation().x < -5) { 119 | movingDir = 1; 120 | } 121 | 122 | if (movingDir > 0) { 123 | floatingMovingPlateRef.current.setLinvel( 124 | movingVel.set(2, floatingMovingPlateRef.current.linvel().y, 0) 125 | ); 126 | } else { 127 | floatingMovingPlateRef.current.setLinvel( 128 | movingVel.set(-2, floatingMovingPlateRef.current.linvel().y, 0) 129 | ); 130 | } 131 | } 132 | 133 | /** 134 | * Apply floating force 135 | */ 136 | // Ray for platform 1 137 | if (rayHit) { 138 | if (rayHit.collider.parent()) { 139 | const floatingForce = 140 | springK * (floatingDis - rayHit.timeOfImpact) - 141 | floatingPlateRef.current.linvel().y * dampingC; 142 | floatingPlateRef.current.applyImpulse( 143 | springDirVec.set(0, floatingForce, 0), 144 | true 145 | ); 146 | } 147 | } 148 | 149 | // Ray for platform 2 150 | if (rayHit2) { 151 | if (rayHit2.collider.parent()) { 152 | const floatingForce2 = 153 | springK * (floatingDis - rayHit2.timeOfImpact) - 154 | floatingPlateRef2.current.linvel().y * dampingC; 155 | floatingPlateRef2.current.applyImpulse( 156 | springDirVec2.set(0, floatingForce2, 0), 157 | true 158 | ); 159 | } 160 | } 161 | 162 | // Ray for moving platform 163 | if (rayHitMove) { 164 | if (rayHitMove.collider.parent()) { 165 | const floatingForceMove = 166 | springK * (floatingDis - rayHitMove.timeOfImpact) - 167 | floatingMovingPlateRef.current.linvel().y * dampingC; 168 | floatingMovingPlateRef.current.applyImpulse( 169 | springDirVecMove.set(0, floatingForceMove, 0), 170 | true 171 | ); 172 | } 173 | } 174 | }); 175 | 176 | return ( 177 | <> 178 | {/* Platform 1 */} 179 | 185 | 192 | Floating Platform push to move 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | {/* Platform 2 */} 202 | 208 | 215 | Floating Platform push to rotate 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | {/* Floating moving Platform test */} 225 | 231 | 238 | Floating & Moving Platform (rigidbody) 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | ); 248 | } 249 | -------------------------------------------------------------------------------- /example/Floor.tsx: -------------------------------------------------------------------------------- 1 | import { RigidBody } from "@react-three/rapier"; 2 | 3 | export default function Floor() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /example/Lights.tsx: -------------------------------------------------------------------------------- 1 | import { useHelper } from "@react-three/drei"; 2 | import { useRef } from "react"; 3 | import * as THREE from "three"; 4 | 5 | export default function Lights() { 6 | const directionalLightRef = useRef(); 7 | 8 | // useHelper(directionalLightRef, THREE.DirectionalLightHelper, 1); 9 | 10 | return ( 11 | <> 12 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /example/PmndrsEcctrl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/PmndrsEcctrl.png -------------------------------------------------------------------------------- /example/RigidObjects.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@react-three/drei"; 2 | import { 3 | BallCollider, 4 | CuboidCollider, 5 | CylinderCollider, 6 | RigidBody, 7 | } from "@react-three/rapier"; 8 | 9 | export default function RigidObjects() { 10 | return ( 11 | <> 12 | {/* Rigid body boxes */} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 34 | mass: 1 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 51 | mass: 3.375 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 68 | mass: 8 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {/* Fun toy */} 78 | 79 | 87 | mass: 1.24 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /example/RoughPlane.tsx: -------------------------------------------------------------------------------- 1 | import { RigidBody } from "@react-three/rapier"; 2 | import { useGLTF } from "@react-three/drei"; 3 | import { useEffect } from "react"; 4 | import * as THREE from "three"; 5 | 6 | export default function RoughPlane() { 7 | // Load models 8 | const roughPlane = useGLTF("./roughPlane.glb"); 9 | 10 | useEffect(() => { 11 | // Receive Shadows 12 | roughPlane.scene.traverse((child) => { 13 | if ( 14 | child instanceof THREE.Mesh && 15 | child.material instanceof THREE.MeshStandardMaterial 16 | ) { 17 | child.receiveShadow = true; 18 | } 19 | }); 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /example/ShotCube.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { useThree } from "@react-three/fiber"; 3 | import { RapierRigidBody, RigidBody } from "@react-three/rapier"; 4 | import { useRef, useMemo, useState, useEffect } from "react"; 5 | 6 | export default function ShotCube() { 7 | const { camera } = useThree(); 8 | const [cubeMesh, setCubeMesh] = useState([]); 9 | const cubeRef = useRef(); 10 | 11 | const direction = useMemo(() => new THREE.Vector3(), []); 12 | 13 | const clickToCreateBox = () => { 14 | if (document.pointerLockElement) { 15 | const newMesh = ( 16 | 21 | 22 | 23 | 24 | ); 25 | setCubeMesh((prevMeshes) => [...prevMeshes, newMesh]); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | camera.getWorldDirection(direction); 31 | if (cubeMesh.length > 0) { 32 | cubeRef.current?.setLinvel( 33 | new THREE.Vector3( 34 | direction.x * 20, 35 | direction.y * 20 + 2, 36 | direction.z * 20 37 | ), 38 | false 39 | ); 40 | } 41 | }, [cubeMesh]); 42 | 43 | useEffect(() => { 44 | window.addEventListener("click", () => clickToCreateBox()); 45 | 46 | return () => { 47 | window.removeEventListener("click", () => clickToCreateBox()); 48 | }; 49 | }, []); 50 | 51 | return ( 52 | <> 53 | {cubeMesh.map((item, i) => { 54 | return ( 55 | 56 | {item} 57 | 58 | ); 59 | })} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /example/Slopes.tsx: -------------------------------------------------------------------------------- 1 | import { RigidBody } from "@react-three/rapier"; 2 | import { useGLTF, Text } from "@react-three/drei"; 3 | import { useEffect } from "react"; 4 | import * as THREE from "three"; 5 | 6 | export default function Slopes() { 7 | // Load models 8 | const slopes = useGLTF("./slopes.glb"); 9 | 10 | useEffect(() => { 11 | // Receive Shadows 12 | slopes.scene.traverse((child) => { 13 | if ( 14 | child instanceof THREE.Mesh && 15 | child.material instanceof THREE.MeshStandardMaterial 16 | ) { 17 | child.receiveShadow = true; 18 | } 19 | }); 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 33 | 23.5 Deg 34 | 35 | 41 | 43.1 Deg 42 | 43 | 49 | 62.7 Deg 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /example/Steps.tsx: -------------------------------------------------------------------------------- 1 | import { RigidBody } from "@react-three/rapier"; 2 | 3 | export default function Steps() { 4 | return ( 5 | <> 6 | {/* Small steps */} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /example/UnclePetePhysicsEnhance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/UnclePetePhysicsEnhance.png -------------------------------------------------------------------------------- /example/ecctrlClickToMove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/ecctrlClickToMove.png -------------------------------------------------------------------------------- /example/ecctrlJoystick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/example/ecctrlJoystick.png -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Character Control 9 | 10 | 11 | 12 |
13 | control keys 14 |

15 | Ecctrl 16 | Floating Character Controller Demo by 17 | Andrew Chen 18 |
19 | Credit for 20 | Animated Uncle Pete 21 | by 22 | @KayLousberg 23 |

24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | import ReactDOM from "react-dom/client"; 3 | import { Canvas } from "@react-three/fiber"; 4 | import Experience from "../example/Experience"; 5 | import { Leva } from "leva"; 6 | import { EcctrlJoystick } from "../src/EcctrlJoystick"; 7 | import { Suspense, useEffect, useState } from "react"; 8 | import { Bvh } from "@react-three/drei"; 9 | 10 | const root = ReactDOM.createRoot(document.querySelector("#root")); 11 | 12 | const EcctrlJoystickControls = () => { 13 | const [isTouchScreen, setIsTouchScreen] = useState(false) 14 | useEffect(() => { 15 | // Check if using a touch control device, show/hide joystick 16 | if (('ontouchstart' in window) || 17 | (navigator.maxTouchPoints > 0)) { 18 | setIsTouchScreen(true) 19 | } else { 20 | setIsTouchScreen(false) 21 | } 22 | }, []) 23 | return ( 24 | <> 25 | {isTouchScreen && } 26 | 27 | ) 28 | } 29 | 30 | root.render( 31 | <> 32 | 33 | 34 | { 42 | if (e.pointerType === 'mouse') { 43 | (e.target as HTMLCanvasElement).requestPointerLock() 44 | } 45 | }} 46 | > 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | background: rgb(253, 228, 255); 10 | background: linear-gradient( 11 | 0deg, 12 | rgba(253, 228, 255, 1) 0%, 13 | rgba(124, 156, 254, 1) 100% 14 | ); 15 | user-select: none; 16 | -moz-user-select: none; 17 | -webkit-user-drag: none; 18 | -webkit-user-select: none; 19 | -ms-user-select: none; 20 | touch-action: none; 21 | } 22 | 23 | .controlKeys { 24 | position: absolute; 25 | width: 20rem; 26 | left: 50%; 27 | margin-left: -10rem; 28 | bottom: 13%; 29 | user-select: none; 30 | -moz-user-select: none; 31 | -webkit-user-drag: none; 32 | -webkit-user-select: none; 33 | -ms-user-select: none; 34 | } 35 | 36 | .title { 37 | font-size: 0.8rem; 38 | color: darkgray; 39 | position: absolute; 40 | width: 100%; 41 | bottom: 2%; 42 | text-align: center; 43 | } 44 | 45 | .title > a { 46 | color: rgb(79, 189, 249); 47 | } 48 | 49 | @media (max-width: 768px) { 50 | .controlKeys { 51 | display: none; 52 | } 53 | .title { 54 | display: none; 55 | } 56 | } 57 | 58 | @media (max-height: 450px) { 59 | .controlKeys { 60 | display: none; 61 | } 62 | .title { 63 | display: none; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /featurelog.md: -------------------------------------------------------------------------------- 1 | ## New Features 2 | 3 | ### (2024-6-24) FixedCamera Mode: 4 | 5 | - The “FixedCamera” mode automatically rotates the camera as the character turns (similar to the controls in Coastal World). You can activate it with the following code: 6 | 7 | `` 8 | 9 | [![screenshot](example/EcctrlFixedCamera.png)] 10 | 11 | ### (2024-1-1) EcctrlMode: 12 | 13 | - Now you can seamlessly switch between different modes by adding "mode" inside Ecctrl. 14 | 15 | `` 16 | 17 | - "PointToMove" mode is designed for click-to-move or path following features. (no needs for keyboard controls) 18 | 19 | ```js 20 | import { useGame } from 'ecctrl' 21 | // ... 22 | const setMoveToPoint = useGame((state) => state.setMoveToPoint) 23 | // ... 24 | // call function setMoveToPoint(), whenever character needs to move 25 | setMoveToPoint(point) // "point" is a vec3 value 26 | ``` 27 | 28 | - Here is a simple click-to-move example: [Ecctrl CodeSandbox](https://codesandbox.io/p/sandbox/ecctrl-pointtomove-m9z6xh) 29 | 30 | [![screenshot](example/ecctrlClickToMove.png)](https://codesandbox.io/p/sandbox/ecctrl-pointtomove-m9z6xh) 31 | 32 | ### (2023-11-18) EcctrlJoystick: 33 | 34 | - Ecctrl now supports touch screen control! 35 | 36 | - You can easily import and use the built-in 3D joystick. 37 | (note: place EcctrlJoystick outside of the canvas component) 38 | 39 | ```js 40 | import Ecctrl, {EcctrlJoystick} from 'ecctrl' 41 | //... 42 | 43 | 44 | {/* ... */} 45 | 46 | //... 47 | ``` 48 | 49 | - For more detailed settings, including lights, materials, and textures, please refer to the following sections. 50 | 51 | - Additionally, I've prepared a simple [Ecctrl CodeSandbox](https://codesandbox.io/s/ecctrl-w-o-animations-3k3zxt) for online testing and demostration. 52 | 53 | - Also, here is another [Ecctrl CodeSandbox](https://codesandbox.io/s/ecctrl-with-animations-nr4493) showcasing character animation functionality. 54 | 55 | [![screenshot](example/ecctrlJoystick.png)](https://codesandbox.io/s/ecctrl-w-o-animations-3k3zxt) 56 | 57 | ### (2023-10-02) Pmndrs/ecctrl & npm package: 58 | 59 | - The character controller now integrated with [pmndrs/ecctrl](https://github.com/pmndrs/ecctrl) 60 | 61 | - You can easily install the npm package using the following command: 62 | 63 | ```bash 64 | npm install ecctrl 65 | ``` 66 | 67 | To get started, import `Ecctrl` and `EcctrlAnimation`, then wrap your character model within ``: 68 | 69 | ```js 70 | import Ecctrl, {EcctrlAnimation} from 'ecctrl' 71 | ... 72 | 73 | 74 | 75 | ... 76 | ``` 77 | 78 | - Additionally, I've prepared a simple [Ecctrl CodeSandbox](https://codesandbox.io/s/ecctrl-w-o-animations-3k3zxt) for online testing and demostration. 79 | 80 | - Also, here is another [Ecctrl CodeSandbox](https://codesandbox.io/s/ecctrl-with-animations-nr4493) showcasing character animation functionality. 81 | 82 | [![screenshot](example/PmndrsEcctrl.png)](https://codesandbox.io/s/ecctrl-w-o-animations-3k3zxt) 83 | 84 | ### (2023-09-13) New Character & Physics Enhancements: 85 | 86 | - Incorporate 11 dynamic animations with new floating character, Uncle Pete 87 | - Implement action and reaction forces on frictionless floating platforms: 88 | - Platforms now move opposite to the character's moving direction (Having less impact on havier platforms) 89 | - Character also applies drag force (friction) to the standing platform 90 | - Character's free fall height now impacts on platform reaction forces 91 | - Add extra downward force upon character jumps for more realistic physics 92 | 93 | [![screenshot](example/UnclePetePhysicsEnhance.png)](https://github.com/erdongchen-andrew/CharacterControl/tree/main/example) 94 | 95 | ### (2023-08-28) Character Animations: 96 | 97 | - Incorporate 8 built-in dynamic animations (including 3 for jump actions) 98 | - Flexibility to add and personalize additional animations 99 | - Fine-tune slope angle's impact on jump direction (fully customizable) 100 | - Tailor the rejection velocity for sudden changes in movement direction (fully customizable) 101 | 102 | [![screenshot](example/CharacterAnimation.png)](https://github.com/erdongchen-andrew/CharacterControl/tree/main/example) 103 | 104 | ### (2023-08-10) Camera Enhancement: 105 | 106 | - Collision detection 107 | - Zoom in/out capability 108 | - Expanded movement range 109 | - Improved tracking smoothness 110 | 111 | ### (2023-07-27) Character Auto Balance: 112 | 113 | - Character tilts forward/backward while in motion 114 | - Automatically returns to upright position after a hit or attack 115 | - Stability customization: Users can fine-tune the balance sensitivity to match their gameplay style -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecctrl", 3 | "version": "1.0.92", 4 | "author": "Erdong Chen", 5 | "license": "MIT", 6 | "description": "A floating rigibody character controller for R3F", 7 | "keywords": [ 8 | "react", 9 | "three", 10 | "threejs", 11 | "react-three-fiber", 12 | "control", 13 | "character-control" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/pmndrs/ecctrl.git" 18 | }, 19 | "files": [ 20 | "dist/*", 21 | "src/*" 22 | ], 23 | "type": "module", 24 | "types": "./dist/Ecctrl.d.ts", 25 | "main": "./dist/Ecctrl.cjs", 26 | "module": "./dist/Ecctrl.js", 27 | "exports": { 28 | "types": "./dist/Ecctrl.d.ts", 29 | "require": "./dist/Ecctrl.cjs", 30 | "import": "./dist/Ecctrl.js" 31 | }, 32 | "sideEffects": false, 33 | "devDependencies": { 34 | "@react-three/drei": "^9.117.3", 35 | "@react-three/fiber": "^8.17.10", 36 | "@types/react": "^18.3.12", 37 | "@types/react-dom": "^18.3.1", 38 | "@vitejs/plugin-react": "^4.3.3", 39 | "r3f-perf": "^7.2.3", 40 | "typescript": "^5.7.2", 41 | "vite": "^5.4.11" 42 | }, 43 | "dependencies": { 44 | "@react-spring/three": "^9.7.5", 45 | "@types/three": "^0.170.0", 46 | "leva": "^0.9.35", 47 | "zustand": "^5.0.1" 48 | }, 49 | "peerDependencies": { 50 | "@react-three/drei": ">=9.0", 51 | "@react-three/fiber": ">=8.0", 52 | "@react-three/rapier": ">=1.5.0", 53 | "react": ">=18", 54 | "react-dom": ">=18.0", 55 | "three": ">=0.170.0" 56 | }, 57 | "scripts": { 58 | "dev": "vite", 59 | "build": "vite build && tsc", 60 | "preview": "vite preview" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/.DS_Store -------------------------------------------------------------------------------- /public/Floating Character.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/Floating Character.glb -------------------------------------------------------------------------------- /public/keyControls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/keyControls.png -------------------------------------------------------------------------------- /public/punchEffect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/punchEffect.png -------------------------------------------------------------------------------- /public/roughPlane.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/roughPlane.glb -------------------------------------------------------------------------------- /public/slopes.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/slopes.glb -------------------------------------------------------------------------------- /public/textures/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/textures/.DS_Store -------------------------------------------------------------------------------- /public/textures/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/textures/3.jpg -------------------------------------------------------------------------------- /public/textures/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErdongChen-Andrew/CharacterControl/91d15608a834f541e5e26924ad7e7249066ee1fb/public/textures/5.jpg -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Ecctrl Floating Capsule Character Controller 2 | 3 | [![screenshot](example/FloatingCharacterControl.png)](https://character-control.vercel.app/) 4 | 5 | [Pmndrs/ecctrl](https://github.com/pmndrs/ecctrl) is a simple web based character controller build on [react-three-fiber](https://github.com/pmndrs/react-three-fiber) and [react-three-rapier](https://github.com/pmndrs/react-three-rapier). It provides a playground demo where you can experience the following features: 6 | 7 | 1. Seamless movement over small obstacles 8 | 2. Enhanced control with floating force incorporating spring and damping forces 9 | 3. Rigidbody character functionality for interaction with the game environment 10 | 4. Customizable ground friction for tailored control 11 | 5. Realistic simulation with applied mass on supporting surfaces 12 | 6. Smooth integration with moving and rotating platforms 13 | 14 | ## New Features 15 | 16 | ### (2024-6-24) FixedCamera Mode: 17 | 18 | - The “FixedCamera” mode automatically rotates the camera as the character turns (similar to the controls in Coastal World). You can activate it with the following code: 19 | 20 | `` 21 | 22 | ![screenshot](example/EcctrlFixedCamera.png) 23 | 24 | Check out the [featurelog.md](/featurelog.md) for details on previous updates and features. 25 | 26 | ## Project Link 27 | 28 | Live Demo: [Floating Capsule Character Controller](https://character-control.vercel.app/) 29 | 30 | ## Local Setup 31 | 32 | Download [Node.js](https://nodejs.org/en/download). Run this followed commands: 33 | 34 | ```bash 35 | # Install dependencies (only the first time) 36 | npm install 37 | 38 | # Run the local server at localhost:5173 39 | npm run dev 40 | 41 | # Build for production in the example/exampleDist/ directory 42 | vite build -c vercelVite.config.js 43 | ``` 44 | 45 | ## How To Use 46 | 47 | ### Basic Controls ([CodeSandbox Demo](https://codesandbox.io/s/ecctrl-w-o-animations-3k3zxt)) 48 | 49 | ```bash 50 | npm install ecctrl 51 | ``` 52 | 53 | ```js 54 | import Ecctrl, { EcctrlAnimation } from "ecctrl"; 55 | ``` 56 | 57 | To get started, set up your keyboard map using [KeyboardControls](https://github.com/pmndrs/drei#keyboardcontrols). Then, wrap your character model within ``: 58 | 59 | ```js 60 | /** 61 | * Keyboard control preset 62 | */ 63 | const keyboardMap = [ 64 | { name: "forward", keys: ["ArrowUp", "KeyW"] }, 65 | { name: "backward", keys: ["ArrowDown", "KeyS"] }, 66 | { name: "leftward", keys: ["ArrowLeft", "KeyA"] }, 67 | { name: "rightward", keys: ["ArrowRight", "KeyD"] }, 68 | { name: "jump", keys: ["Space"] }, 69 | { name: "run", keys: ["Shift"] }, 70 | // Optional animation key map 71 | { name: "action1", keys: ["1"] }, 72 | { name: "action2", keys: ["2"] }, 73 | { name: "action3", keys: ["3"] }, 74 | { name: "action4", keys: ["KeyF"] }, 75 | ]; 76 | 77 | return ( 78 | <> 79 | ... 80 | 81 | {/* Keyboard preset */} 82 | 83 | {/* Character Control */} 84 | 85 | {/* Replace your model here */} 86 | 87 | 88 | 89 | ... 90 | 91 | 92 | ); 93 | ``` 94 | 95 | Here are all the default properties you can play with for ``: 96 | 97 | ```js 98 | // Default properties for Ecctrl 99 | EcctrlProps: { 100 | children, // ReactNode 101 | debug: false, // Enable debug mode (require leva package) 102 | capsuleHalfHeight: 0.35, // Half-height of the character capsule 103 | capsuleRadius: 0.3, // Radius of the character capsule 104 | floatHeight: 0.3, // Height of the character when floating 105 | characterInitDir: 0, // Character initial facing direction (in rad) 106 | followLight: false, // Enable follow light mode (name your light "followLight" before turn this on) 107 | disableControl: false, // Disable the ecctrl control feature 108 | disableFollowCam: false, // Disable follow camera feature 109 | disableFollowCamPos: { x: 0, y: 0, z: -5 }, // Camera position when the follow camera feature is disabled 110 | disableFollowCamTarget: { x: 0, y: 0, z: 0 }, // Camera lookAt target when the follow camera feature is disabled 111 | // Follow camera setups 112 | camInitDis: -5, // Initial camera distance 113 | camMaxDis: -7, // Maximum camera distance 114 | camMinDis: -0.7, // Minimum camera distance 115 | camUpLimit: 1.5, // Camera upward limit (in rad) 116 | camLowLimit: -1.3, // Camera loward limit (in rad) 117 | camInitDir: { x: 0, y: 0 }, // Camera initial rotation direction (in rad) 118 | camTargetPos: { x: 0, y: 0, z: 0 }, // Camera target position 119 | camMoveSpeed: 1, // Camera moving speed multiplier 120 | camZoomSpeed: 1, // Camera zooming speed multiplier 121 | camCollision: true, // Camera collision active/deactive 122 | camCollisionOffset: 0.7, // Camera collision offset 123 | camCollisionSpeedMult: 4, // Camera collision lerping speed multiplier 124 | fixedCamRotMult: 1, // Camera rotate speed multiplier (FixedCamera mode) 125 | camListenerTarget: "domElement", // Camera listener target ("domElement" | "document") 126 | // Follow light setups 127 | followLightPos: { x: 20, y: 30, z: 10 }, // Follow light position 128 | // Base control setups 129 | maxVelLimit: 2.5, // Maximum velocity limit 130 | turnVelMultiplier: 0.2, // Turn velocity multiplier 131 | turnSpeed: 15, // Turn speed 132 | sprintMult: 2, // Sprint speed multiplier 133 | jumpVel: 4, // Jump velocity 134 | jumpForceToGroundMult: 5, // Jump force to ground object multiplier 135 | slopJumpMult: 0.25, // Slope jump affect multiplier 136 | sprintJumpMult: 1.2, // Sprint jump multiplier 137 | airDragMultiplier: 0.2, // Air drag multiplier 138 | dragDampingC: 0.15, // Drag damping coefficient 139 | accDeltaTime: 8, // Acceleration delta time 140 | rejectVelMult: 4, // Reject velocity multiplier 141 | moveImpulsePointY: 0.5, // Move impulse point Y offset 142 | camFollowMult: 11, // Camera follow target speed multiplier 143 | camLerpMult: 25, // Camera lerp to position speed multiplier 144 | fallingGravityScale: 2.5, // Character is falling, apply higher gravity 145 | fallingMaxVel: -20, // Limit character max falling velocity 146 | wakeUpDelay: 200, // Wake up character delay time after window visibility change to visible (in ms) 147 | // Floating Ray setups 148 | rayOriginOffest: { x: 0, y: -capsuleHalfHeight, z: 0 }, // Ray origin offset 149 | rayHitForgiveness: 0.1, // Ray hit forgiveness 150 | rayLength: capsuleRadius + 2, // Ray length 151 | rayDir: { x: 0, y: -1, z: 0 }, // Ray direction 152 | floatingDis: capsuleRadius + floatHeight, // Floating distance 153 | springK: 1.2, // Spring constant 154 | dampingC: 0.08, // Damping coefficient 155 | // Slope Ray setups 156 | showSlopeRayOrigin: false, // Show slope ray origin 157 | slopeMaxAngle: 1, // in rad, the max walkable slope angle 158 | slopeRayOriginOffest: capsuleRadius - 0.03, // Slope ray origin offset 159 | slopeRayLength: capsuleRadius + 3, // Slope ray length 160 | slopeRayDir: { x: 0, y: -1, z: 0 }, // Slope ray direction 161 | slopeUpExtraForce: 0.1, // Slope up extra force 162 | slopeDownExtraForce: 0.2, // Slope down extra force 163 | // AutoBalance Force setups 164 | autoBalance: true, // Enable auto-balance 165 | autoBalanceSpringK: 0.3, // Auto-balance spring constant 166 | autoBalanceDampingC: 0.03, // Auto-balance damping coefficient 167 | autoBalanceSpringOnY: 0.5, // Auto-balance spring on Y-axis 168 | autoBalanceDampingOnY: 0.015, // Auto-balance damping on Y-axis 169 | // Animation temporary setups 170 | animated: false, // Enable animation 171 | // Mode setups 172 | mode: null, // Activate different ecctrl modes ("CameraBasedMovement" | "FixedCamera" | "PointToMove") 173 | // Customizable controller key setups 174 | controllerKeys: { forward: 12, backward: 13, leftward: 14, rightward: 15, jump: 2, action1: 11, action2: 3, action3: 1, action4: 0 }, 175 | // Point-to-move setups 176 | bodySensorSize: [capsuleHalfHeight / 2, capsuleRadius], // cylinder body sensor [halfHeight, radius] 177 | bodySensorPosition: { x: 0, y: 0, z: capsuleRadius / 2 }, 178 | // Other rigibody props from parent 179 | // Rigidbody props can be used here, 180 | // such as position, friction, gravityScale, etc. 181 | ...props 182 | } 183 | 184 | // Simply change the value by doing this 185 | 186 | 187 | 188 | ``` 189 | 190 | ### Apply Character Animations ([CodeSandbox Demo](https://codesandbox.io/s/ecctrl-with-animations-nr4493)) 191 | 192 | If you want to apply character animations, prepare the character url and customize the `animationSet` with your own animation names. Change the `Ecctrl` property `animated` to true and wrap your character model inside `` tag: 193 | 194 | ```js 195 | // Prepare character model url 196 | const characterURL = "./ReplaceWithYourCharacterURL"; 197 | 198 | // Prepare and rename your character animations here 199 | // Note: idle, walk, run, jump, jumpIdle, jumpLand and fall names are essential 200 | // Missing any of these names might result in an error: "cannot read properties of undifined (reading 'reset')" 201 | const animationSet = { 202 | idle: "Idle", 203 | walk: "Walk", 204 | run: "Run", 205 | jump: "Jump_Start", 206 | jumpIdle: "Jump_Idle", 207 | jumpLand: "Jump_Land", 208 | fall: "Climbing", // This is for falling from high sky 209 | // Currently support four additional animations 210 | action1: "Wave", 211 | action2: "Dance", 212 | action3: "Cheer", 213 | action4: "Attack(1h)", // This is special action which can be trigger while walking or running 214 | }; 215 | 216 | return ( 217 | <> 218 | ... 219 | 220 | {/* Keyboard preset */} 221 | 222 | {/* Character Control */} 223 | 224 | {/* Character Animations */} 225 | 229 | {/* Replace your model here */} 230 | 231 | 232 | 233 | 234 | ... 235 | 236 | 237 | ); 238 | ``` 239 | 240 | ### (Advanced) Add and Personalize Additional Animations 241 | 242 | For advanced animation setups, download all files and follow these steps: 243 | 244 | 1. In `CharacterModel.jsx`, expand the `animationSet` with additional animations: 245 | 246 | ```js 247 | // Rename your character animations here 248 | const animationSet = { 249 | idle: "Idle", 250 | walk: "Walk", 251 | run: "Run", 252 | jump: "Jump_Start", 253 | jumpIdle: "Jump_Idle", 254 | jumpLand: "Jump_Land", 255 | fall: "Climbing", 256 | action1: "Wave", 257 | action2: "Dance", 258 | action3: "Cheer", 259 | action4: "Attack(1h)", // This is special action which can be trigger while walking or running 260 | //additinalAnimation: "additinalAnimationName", 261 | }; 262 | ``` 263 | 264 | 2. In `useGame.jsx`, create a trigger function for the new animation: 265 | 266 | ```js 267 | return { 268 | /** 269 | * Character animations state manegement 270 | */ 271 | // Initial animation 272 | curAnimation: null, 273 | animationSet: {}, 274 | 275 | ... 276 | 277 | action1: () => { 278 | set((state) => { 279 | if (state.curAnimation === state.animationSet.idle) { 280 | return { curAnimation: state.animationSet.action1 }; 281 | } 282 | return {}; 283 | }); 284 | }, 285 | 286 | /** 287 | * Additional animations 288 | */ 289 | // triggerFunction: ()=>{ 290 | // set((state) => { 291 | // return { curAnimation: state.animationSet.additionalAnimation }; 292 | // }); 293 | // } 294 | }; 295 | ``` 296 | 297 | 3. In `CharacterController.jsx`, initialize the trigger function and call it when needed: 298 | 299 | ```js 300 | // Animation change functions 301 | const idleAnimation = useGame((state) => state.idle); 302 | const walkAnimation = useGame((state) => state.walk); 303 | const runAnimation = useGame((state) => state.run); 304 | const jumpAnimation = useGame((state) => state.jump); 305 | const jumpIdleAnimation = useGame((state) => state.jumpIdle); 306 | const jumpLandAnimation = useGame((state) => state.jumpLand); 307 | const fallAnimation = useGame((state) => state.fall); 308 | const action1Animation = useGame((state) => state.action1); 309 | const action2Animation = useGame((state) => state.action2); 310 | const action3Animation = useGame((state) => state.action3); 311 | const action4Animation = useGame((state) => state.action4); 312 | //const additionalAnimation = useGame((state) => state.triggerFunction); 313 | ``` 314 | 315 | ### EcctrlJoystick and Touch buttons 316 | 317 | To get start, simply import `EcctrlJoystick` from `ecctrl` 318 | 319 | ```js 320 | import { EcctrlJoystick } from "ecctrl"; 321 | ``` 322 | 323 | Place `` outside of your canvas component, and you're done! 324 | 325 | ```js 326 | //... 327 | 328 | 329 | {/* ... */} 330 | 331 | //... 332 | ``` 333 | 334 | You can also add lights or additional meshs like so (note: this will create components twice, once inside the joystick's scene, another inside the buttons' scene, so keep an eye on performance): 335 | 336 | ```js 337 | //... 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | {/* ... */} 346 | 347 | //... 348 | ``` 349 | 350 | Additionally, you can change components' material, geometry, or texture as you like: 351 | 352 | ```js 353 | //... 354 | 360 | 361 | {/* ... */} 362 | 363 | //... 364 | ``` 365 | 366 | Here are all the properties you can play with for ``: 367 | 368 | ```js 369 | EcctrlJoystickProps: { 370 | // Joystick props 371 | children?: ReactNode; 372 | joystickRunSensitivity?: number; // Sensitivity for transitioning to the running state. The default value is 0.9 (valid range: 0 < joystickRunSensitivity < 1) 373 | joystickPositionLeft?: number; // joystick div container position left 374 | joystickPositionBottom?: number; // joystick div container position bottom 375 | joystickHeightAndWidth?: number; // joystick div container height and width 376 | joystickCamZoom?: number; // camera zoom level for the joystick 377 | joystickCamPosition?: [x: number, y: number, z: number]; // camera position for the joystick 378 | joystickBaseProps?: ThreeElements['mesh']; // custom properties for the joystick's base mesh 379 | joystickStickProps?: ThreeElements['mesh']; // custom properties for the joystick's stick mesh 380 | joystickHandleProps?: ThreeElements['mesh']; // custom properties for the joystick's handle mesh 381 | 382 | // Touch buttons props 383 | buttonNumber?: number; // Number of buttons (max 5) 384 | buttonPositionRight?: number; // buttons div container position right 385 | buttonPositionBottom?: number; // buttons div container position bottom 386 | buttonHeightAndWidth?: number; // buttons div container height and width 387 | buttonCamZoom?: number; // camera zoom level for the buttons 388 | buttonCamPosition?: [x: number, y: number, z: number]; // camera position for the buttons 389 | buttonGroup1Position?: [x: number, y: number, z: number]; // button 1 posiiton in 3D scene 390 | buttonGroup2Position?: [x: number, y: number, z: number]; // button 2 posiiton in 3D scene 391 | buttonGroup3Position?: [x: number, y: number, z: number]; // button 3 posiiton in 3D scene 392 | buttonGroup4Position?: [x: number, y: number, z: number]; // button 4 posiiton in 3D scene 393 | buttonGroup5Position?: [x: number, y: number, z: number]; // button 5 posiiton in 3D scene 394 | buttonLargeBaseProps?: ThreeElements['mesh']; // custom properties for the buttons' large base mesh 395 | buttonSmallBaseProps?: ThreeElements['mesh']; // custom properties for the buttons' small base mesh 396 | buttonTop1Props?: ThreeElements['mesh']; // custom properties for the button 1 top mesh (large button) 397 | buttonTop2Props?: ThreeElements['mesh']; // custom properties for the button 2 top mesh (large button) 398 | buttonTop3Props?: ThreeElements['mesh']; // custom properties for the button 3 top mesh (small button) 399 | buttonTop4Props?: ThreeElements['mesh']; // custom properties for the button 4 top mesh (small button) 400 | buttonTop5Props?: ThreeElements['mesh']; // custom properties for the button 5 top mesh (small button) 401 | }; 402 | ``` 403 | 404 | ### Using your own joystick or buttons 405 | 406 | If you prefer to use your custom joystick or buttons, you can leverage the `useJoystickControls` hook from `ecctrl`. Import the hook and call the appropriate functions:: 407 | 408 | ```js 409 | import { useJoystickControls } from "ecctrl"; 410 | //... 411 | const setJoystick = useJoystickControls((state) => state.setJoystick); 412 | const resetJoystick = useJoystickControls((state) => state.resetJoystick); 413 | const pressButton1 = useJoystickControls((state) => state.pressButton1); 414 | const releaseAllButtons = useJoystickControls( 415 | (state) => state.releaseAllButtons 416 | ); 417 | //... 418 | // call the proper fuctions 419 | setJoystick(joystickDis, joystickAng, runState); 420 | // or 421 | pressButton1(); 422 | ``` 423 | 424 | ### Ecctrl Mode 425 | 426 | Activate different modes in Ecctrl by including the desired mode inside Ecctrl component: 427 | ``. 428 | 429 | #### 1. "PointToMove" Mode ([CodeSandbox Demo](https://codesandbox.io/p/sandbox/ecctrl-pointtomove-m9z6xh?file=%2Fsrc%2FMap.js%3A46%2C19)) 430 | 431 | This mode doesn't require keyboard controls and is designed for click-to-move or path-following features. 432 | 433 | ```js 434 | import { useGame } from "ecctrl"; 435 | // ... 436 | const setMoveToPoint = useGame((state) => state.setMoveToPoint); 437 | // ... 438 | // call function setMoveToPoint(), whenever character needs to move 439 | setMoveToPoint(point); // "point" is a vec3 value 440 | ``` 441 | 442 | ### (Optional) First-person view setup 443 | 444 | If you would like to quickly set up a first-person mode, you can modify these props to achieve that: 445 | 446 | ```js 447 | 457 | ``` 458 | 459 | ## Contributions 460 | 461 | I appreciate your interest in this project! If you have any feedback, suggestions, or resources related to the controller, please feel free to share. 462 | 463 | Thank you! 464 | -------------------------------------------------------------------------------- /src/Ecctrl.tsx: -------------------------------------------------------------------------------- 1 | import { useKeyboardControls } from "@react-three/drei"; 2 | import { useFrame } from "@react-three/fiber"; 3 | import { 4 | quat, 5 | RigidBody, 6 | CapsuleCollider, 7 | useRapier, 8 | RapierRigidBody, 9 | type RigidBodyProps, 10 | CylinderCollider, 11 | } from "@react-three/rapier"; 12 | import { useEffect, useRef, useMemo, useState, useImperativeHandle, forwardRef, type ReactNode, type ForwardRefRenderFunction } from "react"; 13 | import * as THREE from "three"; 14 | import { useControls } from "leva"; 15 | import { useFollowCam } from "./hooks/useFollowCam"; 16 | import { useGame } from "./stores/useGame"; 17 | import { useJoystickControls } from "./stores/useJoystickControls"; 18 | import { QueryFilterFlags } from "@dimforge/rapier3d-compat"; 19 | import type { 20 | Collider, 21 | RayColliderHit, 22 | Vector, 23 | } from "@dimforge/rapier3d-compat"; 24 | import React from "react"; 25 | 26 | export { EcctrlAnimation } from "./EcctrlAnimation"; 27 | export { useFollowCam } from "./hooks/useFollowCam"; 28 | export { useGame } from "./stores/useGame"; 29 | export { EcctrlJoystick } from "../src/EcctrlJoystick"; 30 | export { useJoystickControls } from "./stores/useJoystickControls"; 31 | 32 | // Retrieve current moving direction of the character 33 | const getMovingDirection = (forward: boolean, 34 | backward: boolean, 35 | leftward: boolean, 36 | rightward: boolean, 37 | pivot: THREE.Object3D) 38 | : number | null => { 39 | if (!forward && !backward && !leftward && !rightward) return null; 40 | if (forward && leftward) return pivot.rotation.y + Math.PI / 4; 41 | if (forward && rightward) return pivot.rotation.y - Math.PI / 4; 42 | if (backward && leftward) return pivot.rotation.y - Math.PI / 4 + Math.PI; 43 | if (backward && rightward) return pivot.rotation.y + Math.PI / 4 + Math.PI; 44 | if (backward) return pivot.rotation.y + Math.PI; 45 | if (leftward) return pivot.rotation.y + Math.PI / 2; 46 | if (rightward) return pivot.rotation.y - Math.PI / 2; 47 | if (forward) return pivot.rotation.y; 48 | }; 49 | 50 | const Ecctrl: ForwardRefRenderFunction = ({ 51 | children, 52 | debug = false, 53 | capsuleHalfHeight = 0.35, 54 | capsuleRadius = 0.3, 55 | floatHeight = 0.3, 56 | characterInitDir = 0, // in rad 57 | followLight = false, 58 | disableControl = false, 59 | disableFollowCam = false, 60 | disableFollowCamPos = null, 61 | disableFollowCamTarget = null, 62 | // Follow camera setups 63 | camInitDis = -5, 64 | camMaxDis = -7, 65 | camMinDis = -0.7, 66 | camUpLimit = 1.5, // in rad 67 | camLowLimit = -1.3, // in rad 68 | camInitDir = { x: 0, y: 0 }, // in rad 69 | camTargetPos = { x: 0, y: 0, z: 0 }, 70 | camMoveSpeed = 1, 71 | camZoomSpeed = 1, 72 | camCollision = true, 73 | camCollisionOffset = 0.7, 74 | camCollisionSpeedMult = 4, 75 | fixedCamRotMult = 1, 76 | camListenerTarget = "domElement", // document or domElement 77 | // Follow light setups 78 | followLightPos = { x: 20, y: 30, z: 10 }, 79 | // Base control setups 80 | maxVelLimit = 2.5, 81 | turnVelMultiplier = 0.2, 82 | turnSpeed = 15, 83 | sprintMult = 2, 84 | jumpVel = 4, 85 | jumpForceToGroundMult = 5, 86 | slopJumpMult = 0.25, 87 | sprintJumpMult = 1.2, 88 | airDragMultiplier = 0.2, 89 | dragDampingC = 0.15, 90 | accDeltaTime = 8, 91 | rejectVelMult = 4, 92 | moveImpulsePointY = 0.5, 93 | camFollowMult = 11, 94 | camLerpMult = 25, 95 | fallingGravityScale = 2.5, 96 | fallingMaxVel = -20, 97 | wakeUpDelay = 200, 98 | // Floating Ray setups 99 | rayOriginOffest = { x: 0, y: -capsuleHalfHeight, z: 0 }, 100 | rayHitForgiveness = 0.1, 101 | rayLength = capsuleRadius + 2, 102 | rayDir = { x: 0, y: -1, z: 0 }, 103 | floatingDis = capsuleRadius + floatHeight, 104 | springK = 1.2, 105 | dampingC = 0.08, 106 | // Slope Ray setups 107 | showSlopeRayOrigin = false, 108 | slopeMaxAngle = 1, // in rad 109 | slopeRayOriginOffest = capsuleRadius - 0.03, 110 | slopeRayLength = capsuleRadius + 3, 111 | slopeRayDir = { x: 0, y: -1, z: 0 }, 112 | slopeUpExtraForce = 0.1, 113 | slopeDownExtraForce = 0.2, 114 | // AutoBalance Force setups 115 | autoBalance = true, 116 | autoBalanceSpringK = 0.3, 117 | autoBalanceDampingC = 0.03, 118 | autoBalanceSpringOnY = 0.5, 119 | autoBalanceDampingOnY = 0.015, 120 | // Animation temporary setups 121 | animated = false, 122 | // Mode setups 123 | mode = null, 124 | // Controller setups 125 | controllerKeys = { forward: 12, backward: 13, leftward: 14, rightward: 15, jump: 2, action1: 11, action2: 3, action3: 1, action4: 0 }, 126 | // Point-to-move setups 127 | bodySensorSize = [capsuleHalfHeight / 2, capsuleRadius], 128 | bodySensorPosition = { x: 0, y: 0, z: capsuleRadius / 2 }, 129 | // Other rigibody props from parent 130 | ...props 131 | }: EcctrlProps, ref) => { 132 | const characterRef = useRef(null) 133 | // const characterRef = ref as RefObject || useRef() 134 | const characterModelRef = useRef(); 135 | const characterModelIndicator: THREE.Object3D = useMemo(() => new THREE.Object3D(), []) 136 | const defaultControllerKeys = { forward: 12, backward: 13, leftward: 14, rightward: 15, jump: 2, action1: 11, action2: 3, action3: 1, action4: 0 } 137 | useImperativeHandle(ref, () => { 138 | if (characterRef.current) { 139 | characterRef.current.rotateCamera = rotateCamera; 140 | characterRef.current.rotateCharacterOnY = rotateCharacterOnY; 141 | return characterRef.current!; 142 | } 143 | return null; 144 | }, [characterRef.current]); 145 | 146 | /** 147 | * Mode setup 148 | */ 149 | let isModePointToMove: boolean = false 150 | let functionKeyDown: boolean = false 151 | let isModeFixedCamera: boolean = false 152 | let isModeCameraBased: boolean = false 153 | const setMoveToPoint = useGame((state) => state.setMoveToPoint) 154 | const findMode = (mode: string, modes: string) => modes.split(" ").some(m => m === mode) 155 | if (mode) { 156 | if (findMode("PointToMove", mode)) isModePointToMove = true 157 | if (findMode("FixedCamera", mode)) isModeFixedCamera = true 158 | if (findMode("CameraBasedMovement", mode)) isModeCameraBased = true 159 | } 160 | 161 | /** 162 | * Body collider setup 163 | */ 164 | const modelFacingVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 165 | const bodyFacingVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 166 | const bodyBalanceVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 167 | const bodyBalanceVecOnX: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 168 | const bodyFacingVecOnY: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 169 | const bodyBalanceVecOnZ: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 170 | const vectorY: THREE.Vector3 = useMemo(() => new THREE.Vector3(0, 1, 0), []); 171 | const vectorZ: THREE.Vector3 = useMemo(() => new THREE.Vector3(0, 0, 1), []); 172 | const crossVecOnX: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 173 | const crossVecOnY: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 174 | const crossVecOnZ: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 175 | const bodyContactForce: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 176 | const slopeRayOriginUpdatePosition: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 177 | const camBasedMoveCrossVecOnY: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 178 | 179 | // Animation change functions 180 | const idleAnimation = !animated ? null : useGame((state) => state.idle); 181 | const walkAnimation = !animated ? null : useGame((state) => state.walk); 182 | const runAnimation = !animated ? null : useGame((state) => state.run); 183 | const jumpAnimation = !animated ? null : useGame((state) => state.jump); 184 | const jumpIdleAnimation = !animated 185 | ? null 186 | : useGame((state) => state.jumpIdle); 187 | const fallAnimation = !animated ? null : useGame((state) => state.fall); 188 | const action1Animation = !animated ? null : useGame((state) => state.action1); 189 | const action2Animation = !animated ? null : useGame((state) => state.action2); 190 | const action3Animation = !animated ? null : useGame((state) => state.action3); 191 | const action4Animation = !animated ? null : useGame((state) => state.action4); 192 | 193 | /** 194 | * Debug settings 195 | */ 196 | let characterControlsDebug = null; 197 | let floatingRayDebug = null; 198 | let slopeRayDebug = null; 199 | let autoBalanceForceDebug = null; 200 | if (debug) { 201 | // Character Controls 202 | characterControlsDebug = useControls( 203 | "Character Controls", 204 | { 205 | maxVelLimit: { 206 | value: maxVelLimit, 207 | min: 0, 208 | max: 10, 209 | step: 0.01, 210 | }, 211 | turnVelMultiplier: { 212 | value: turnVelMultiplier, 213 | min: 0, 214 | max: 1, 215 | step: 0.01, 216 | }, 217 | turnSpeed: { 218 | value: turnSpeed, 219 | min: 5, 220 | max: 30, 221 | step: 0.1, 222 | }, 223 | sprintMult: { 224 | value: sprintMult, 225 | min: 1, 226 | max: 5, 227 | step: 0.01, 228 | }, 229 | jumpVel: { 230 | value: jumpVel, 231 | min: 0, 232 | max: 10, 233 | step: 0.01, 234 | }, 235 | jumpForceToGroundMult: { 236 | value: jumpForceToGroundMult, 237 | min: 0, 238 | max: 80, 239 | step: 0.1, 240 | }, 241 | slopJumpMult: { 242 | value: slopJumpMult, 243 | min: 0, 244 | max: 1, 245 | step: 0.01, 246 | }, 247 | sprintJumpMult: { 248 | value: sprintJumpMult, 249 | min: 1, 250 | max: 3, 251 | step: 0.01, 252 | }, 253 | airDragMultiplier: { 254 | value: airDragMultiplier, 255 | min: 0, 256 | max: 1, 257 | step: 0.01, 258 | }, 259 | dragDampingC: { 260 | value: dragDampingC, 261 | min: 0, 262 | max: 0.5, 263 | step: 0.01, 264 | }, 265 | accDeltaTime: { 266 | value: accDeltaTime, 267 | min: 0, 268 | max: 50, 269 | step: 1, 270 | }, 271 | rejectVelMult: { 272 | value: rejectVelMult, 273 | min: 0, 274 | max: 10, 275 | step: 0.1, 276 | }, 277 | moveImpulsePointY: { 278 | value: moveImpulsePointY, 279 | min: 0, 280 | max: 3, 281 | step: 0.1, 282 | }, 283 | camFollowMult: { 284 | value: camFollowMult, 285 | min: 0, 286 | max: 15, 287 | step: 0.1, 288 | }, 289 | }, 290 | { collapsed: true } 291 | ); 292 | // Apply debug values 293 | maxVelLimit = characterControlsDebug.maxVelLimit; 294 | turnVelMultiplier = characterControlsDebug.turnVelMultiplier; 295 | turnSpeed = characterControlsDebug.turnSpeed; 296 | sprintMult = characterControlsDebug.sprintMult; 297 | jumpVel = characterControlsDebug.jumpVel; 298 | jumpForceToGroundMult = characterControlsDebug.jumpForceToGroundMult; 299 | slopJumpMult = characterControlsDebug.slopJumpMult; 300 | sprintJumpMult = characterControlsDebug.sprintJumpMult; 301 | airDragMultiplier = characterControlsDebug.airDragMultiplier; 302 | dragDampingC = characterControlsDebug.dragDampingC; 303 | accDeltaTime = characterControlsDebug.accDeltaTime; 304 | rejectVelMult = characterControlsDebug.rejectVelMult; 305 | moveImpulsePointY = characterControlsDebug.moveImpulsePointY; 306 | camFollowMult = characterControlsDebug.camFollowMult; 307 | 308 | // Floating Ray 309 | floatingRayDebug = useControls( 310 | "Floating Ray", 311 | { 312 | rayOriginOffest: { 313 | x: 0, 314 | y: -capsuleHalfHeight, 315 | z: 0, 316 | }, 317 | rayHitForgiveness: { 318 | value: rayHitForgiveness, 319 | min: 0, 320 | max: 0.5, 321 | step: 0.01, 322 | }, 323 | rayLength: { 324 | value: capsuleRadius + 2, 325 | min: 0, 326 | max: capsuleRadius + 10, 327 | step: 0.01, 328 | }, 329 | rayDir: { x: 0, y: -1, z: 0 }, 330 | floatingDis: { 331 | value: capsuleRadius + floatHeight, 332 | min: 0, 333 | max: capsuleRadius + 2, 334 | step: 0.01, 335 | }, 336 | springK: { 337 | value: springK, 338 | min: 0, 339 | max: 5, 340 | step: 0.01, 341 | }, 342 | dampingC: { 343 | value: dampingC, 344 | min: 0, 345 | max: 3, 346 | step: 0.01, 347 | }, 348 | }, 349 | { collapsed: true } 350 | ); 351 | // Apply debug values 352 | rayOriginOffest = floatingRayDebug.rayOriginOffest; 353 | rayHitForgiveness = floatingRayDebug.rayHitForgiveness; 354 | rayLength = floatingRayDebug.rayLength; 355 | rayDir = floatingRayDebug.rayDir; 356 | floatingDis = floatingRayDebug.floatingDis; 357 | springK = floatingRayDebug.springK; 358 | dampingC = floatingRayDebug.dampingC; 359 | 360 | // Slope Ray 361 | slopeRayDebug = useControls( 362 | "Slope Ray", 363 | { 364 | showSlopeRayOrigin: false, 365 | slopeMaxAngle: { 366 | value: slopeMaxAngle, 367 | min: 0, 368 | max: 1.57, 369 | step: 0.01 370 | }, 371 | slopeRayOriginOffest: { 372 | value: capsuleRadius, 373 | min: 0, 374 | max: capsuleRadius + 3, 375 | step: 0.01, 376 | }, 377 | slopeRayLength: { 378 | value: capsuleRadius + 3, 379 | min: 0, 380 | max: capsuleRadius + 13, 381 | step: 0.01, 382 | }, 383 | slopeRayDir: { x: 0, y: -1, z: 0 }, 384 | slopeUpExtraForce: { 385 | value: slopeUpExtraForce, 386 | min: 0, 387 | max: 5, 388 | step: 0.01, 389 | }, 390 | slopeDownExtraForce: { 391 | value: slopeDownExtraForce, 392 | min: 0, 393 | max: 5, 394 | step: 0.01, 395 | }, 396 | }, 397 | { collapsed: true } 398 | ); 399 | // Apply debug values 400 | showSlopeRayOrigin = slopeRayDebug.showSlopeRayOrigin; 401 | slopeMaxAngle = slopeRayDebug.slopeMaxAngle; 402 | slopeRayLength = slopeRayDebug.slopeRayLength; 403 | slopeRayDir = slopeRayDebug.slopeRayDir; 404 | slopeUpExtraForce = slopeRayDebug.slopeUpExtraForce; 405 | slopeDownExtraForce = slopeRayDebug.slopeDownExtraForce; 406 | 407 | // AutoBalance Force 408 | autoBalanceForceDebug = useControls( 409 | "AutoBalance Force", 410 | { 411 | autoBalance: { 412 | value: true, 413 | }, 414 | autoBalanceSpringK: { 415 | value: autoBalanceSpringK, 416 | min: 0, 417 | max: 5, 418 | step: 0.01, 419 | }, 420 | autoBalanceDampingC: { 421 | value: autoBalanceDampingC, 422 | min: 0, 423 | max: 0.1, 424 | step: 0.001, 425 | }, 426 | autoBalanceSpringOnY: { 427 | value: autoBalanceSpringOnY, 428 | min: 0, 429 | max: 5, 430 | step: 0.01, 431 | }, 432 | autoBalanceDampingOnY: { 433 | value: autoBalanceDampingOnY, 434 | min: 0, 435 | max: 0.1, 436 | step: 0.001, 437 | }, 438 | }, 439 | { collapsed: true } 440 | ); 441 | // Apply debug values 442 | autoBalance = autoBalanceForceDebug.autoBalance; 443 | autoBalanceSpringK = autoBalanceForceDebug.autoBalanceSpringK; 444 | autoBalanceDampingC = autoBalanceForceDebug.autoBalanceDampingC; 445 | autoBalanceSpringOnY = autoBalanceForceDebug.autoBalanceSpringOnY; 446 | autoBalanceDampingOnY = autoBalanceForceDebug.autoBalanceDampingOnY; 447 | } 448 | 449 | /** 450 | * Check if inside keyboardcontrols 451 | */ 452 | function useIsInsideKeyboardControls() { 453 | try { 454 | return !!useKeyboardControls() 455 | } catch { 456 | return false 457 | } 458 | } 459 | const isInsideKeyboardControls = useIsInsideKeyboardControls(); 460 | 461 | /** 462 | * keyboard controls setup 463 | */ 464 | const [subscribeKeys, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null]; 465 | const presetKeys = { forward: false, backward: false, leftward: false, rightward: false, jump: false, run: false }; 466 | const { rapier, world } = useRapier(); 467 | 468 | /** 469 | * Joystick controls setup 470 | */ 471 | const getJoystickValues = useJoystickControls(state => state.getJoystickValues) 472 | const pressButton1 = useJoystickControls((state) => state.pressButton1) 473 | const pressButton2 = useJoystickControls((state) => state.pressButton2) 474 | const pressButton3 = useJoystickControls((state) => state.pressButton3) 475 | const pressButton4 = useJoystickControls((state) => state.pressButton4) 476 | const pressButton5 = useJoystickControls((state) => state.pressButton5) 477 | const releaseAllButtons = useJoystickControls((state) => state.releaseAllButtons) 478 | const setJoystick = useJoystickControls((state) => state.setJoystick) 479 | const resetJoystick = useJoystickControls((state) => state.resetJoystick) 480 | 481 | /** 482 | * Gamepad controls setup 483 | */ 484 | const [controllerIndex, setControllerIndex] = useState(null) 485 | const gamepadKeys = { forward: false, backward: false, leftward: false, rightward: false }; 486 | const gamepadJoystickVec2: THREE.Vector2 = useMemo(() => new THREE.Vector2(), []) 487 | let gamepadJoystickDis: number = 0 488 | let gamepadJoystickAng: number = 0 489 | const gamepadConnect = (e: any) => { setControllerIndex(e.gamepad.index) } 490 | const gamepadDisconnect = () => { setControllerIndex(null) } 491 | const mergedKeys = useMemo(() => Object.assign({}, defaultControllerKeys, controllerKeys), [controllerKeys]) 492 | const handleButtons = (buttons: readonly GamepadButton[]) => { 493 | gamepadKeys.forward = buttons[mergedKeys.forward].pressed 494 | gamepadKeys.backward = buttons[mergedKeys.backward].pressed 495 | gamepadKeys.leftward = buttons[mergedKeys.leftward].pressed 496 | gamepadKeys.rightward = buttons[mergedKeys.rightward].pressed 497 | 498 | // Gamepad trigger the EcctrlJoystick buttons to play animations 499 | if (buttons[mergedKeys.action4].pressed) { 500 | pressButton2() 501 | } else if (buttons[mergedKeys.action3].pressed) { 502 | pressButton4() 503 | } else if (buttons[mergedKeys.jump].pressed) { 504 | pressButton1() 505 | } else if (buttons[mergedKeys.action2].pressed) { 506 | pressButton3() 507 | } else if (buttons[mergedKeys.action1].pressed) { 508 | pressButton5() 509 | } else { 510 | releaseAllButtons() 511 | } 512 | } 513 | 514 | const handleSticks = (axes: readonly number[]) => { 515 | // Gamepad first joystick trigger the EcctrlJoystick event to move the character 516 | if (Math.abs(axes[0]) > 0 || Math.abs(axes[1]) > 0) { 517 | gamepadJoystickVec2.set(axes[0], -axes[1]) 518 | gamepadJoystickDis = Math.min(Math.sqrt(Math.pow(gamepadJoystickVec2.x, 2) + Math.pow(gamepadJoystickVec2.y, 2)), 1) 519 | gamepadJoystickAng = gamepadJoystickVec2.angle() 520 | const runState = gamepadJoystickDis > 0.7 521 | setJoystick(gamepadJoystickDis, gamepadJoystickAng, runState) 522 | } else { 523 | gamepadJoystickDis = 0 524 | gamepadJoystickAng = 0 525 | resetJoystick() 526 | } 527 | // Gamepad second joystick trigger the useFollowCam event to move the camera 528 | if (Math.abs(axes[2]) > 0 || Math.abs(axes[3]) > 0) { 529 | joystickCamMove(axes[2], axes[3]) 530 | } 531 | } 532 | 533 | // can jump setup 534 | let canJump: boolean = false; 535 | let isFalling: boolean = false; 536 | const initialGravityScale: number = useMemo(() => props.gravityScale ?? 1, []) 537 | 538 | // on moving object state 539 | let massRatio: number = 1; 540 | let isOnMovingObject: boolean = false; 541 | const standingForcePoint: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 542 | const movingObjectDragForce: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 543 | const movingObjectVelocity: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 544 | const movingObjectVelocityInCharacterDir: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 545 | const distanceFromCharacterToObject: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 546 | const objectAngvelToLinvel: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 547 | const velocityDiff: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 548 | 549 | /** 550 | * Initial light setup 551 | */ 552 | let dirLight: THREE.DirectionalLight = null; 553 | 554 | /** 555 | * Follow camera initial setups from props 556 | */ 557 | const cameraSetups = { 558 | disableFollowCam, 559 | disableFollowCamPos, 560 | disableFollowCamTarget, 561 | camInitDis, 562 | camMaxDis, 563 | camMinDis, 564 | camUpLimit, 565 | camLowLimit, 566 | camInitDir, 567 | camMoveSpeed: isModeFixedCamera ? 0 : camMoveSpeed, // Disable camera move in fixed camera mode 568 | camZoomSpeed: isModeFixedCamera ? 0 : camZoomSpeed, // Disable camera zoom in fixed camera mode 569 | camCollisionOffset, 570 | camCollisionSpeedMult, 571 | camListenerTarget, 572 | }; 573 | 574 | /** 575 | * Load camera pivot and character move preset 576 | */ 577 | const { pivot, followCam, cameraCollisionDetect, joystickCamMove } = 578 | useFollowCam(cameraSetups); 579 | const pivotPosition: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 580 | const pivotXAxis: THREE.Vector3 = useMemo(() => new THREE.Vector3(1, 0, 0), []); 581 | const pivotYAxis: THREE.Vector3 = useMemo(() => new THREE.Vector3(0, 1, 0), []); 582 | const pivotZAxis: THREE.Vector3 = useMemo(() => new THREE.Vector3(0, 0, 1), []); 583 | const followCamPosition: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 584 | const modelEuler: THREE.Euler = useMemo(() => new THREE.Euler(), []); 585 | const modelQuat: THREE.Quaternion = useMemo(() => new THREE.Quaternion(), []); 586 | const moveImpulse: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 587 | const movingDirection: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 588 | const moveAccNeeded: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 589 | const jumpVelocityVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 590 | const jumpDirection: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 591 | const currentVel: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 592 | const currentPos: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 593 | const dragForce: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 594 | const dragAngForce: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 595 | const wantToMoveVel: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 596 | const rejectVel: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 597 | 598 | /** 599 | * Floating Ray setup 600 | */ 601 | let floatingForce = null; 602 | const springDirVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 603 | const characterMassForce: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 604 | const rayOrigin: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 605 | const rayCast = new rapier.Ray(rayOrigin, rayDir); 606 | let rayHit: RayColliderHit | null = null; 607 | 608 | /**Test shape ray */ 609 | // const shape = new rapier.Capsule(0.2,0.1) 610 | 611 | /** 612 | * Slope detection ray setup 613 | */ 614 | let slopeAngle: number = null; 615 | let actualSlopeNormal: Vector = null; 616 | let actualSlopeAngle: number = null; 617 | const actualSlopeNormalVec: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 618 | const floorNormal: THREE.Vector3 = useMemo(() => new THREE.Vector3(0, 1, 0), []); 619 | const slopeRayOriginRef = useRef(); 620 | const slopeRayorigin: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 621 | const slopeRayCast = new rapier.Ray(slopeRayorigin, slopeRayDir); 622 | let slopeRayHit: RayColliderHit | null = null; 623 | 624 | /** 625 | * Point to move setup 626 | */ 627 | let isBodyHitWall = false; 628 | let isPointMoving = false; 629 | const crossVector: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 630 | const pointToPoint: THREE.Vector3 = useMemo(() => new THREE.Vector3(), []); 631 | const getMoveToPoint = useGame((state) => state.getMoveToPoint); 632 | const bodySensorRef = useRef(); 633 | const handleOnIntersectionEnter = () => { 634 | isBodyHitWall = true 635 | } 636 | const handleOnIntersectionExit = () => { 637 | isBodyHitWall = false 638 | } 639 | 640 | /** 641 | * Character moving function 642 | */ 643 | let characterRotated: boolean = true; 644 | const moveCharacter = ( 645 | _: number, 646 | run: boolean, 647 | slopeAngle: number, 648 | movingObjectVelocity: THREE.Vector3 649 | ) => { 650 | /** 651 | * Setup moving direction 652 | */ 653 | // Only apply slope angle to moving direction 654 | // when slope angle is between 0.2rad and slopeMaxAngle, 655 | // and actualSlopeAngle < slopeMaxAngle 656 | if ( 657 | actualSlopeAngle < slopeMaxAngle && 658 | Math.abs(slopeAngle) > 0.2 && 659 | Math.abs(slopeAngle) < slopeMaxAngle 660 | ) { 661 | movingDirection.set(0, Math.sin(slopeAngle), Math.cos(slopeAngle)); 662 | } 663 | // If on a slopeMaxAngle slope, only apply small a mount of forward direction 664 | else if (actualSlopeAngle >= slopeMaxAngle) { 665 | movingDirection.set( 666 | 0, 667 | Math.sin(slopeAngle) > 0 ? 0 : Math.sin(slopeAngle), 668 | Math.sin(slopeAngle) > 0 ? 0.1 : 1 669 | ); 670 | } else { 671 | movingDirection.set(0, 0, 1); 672 | } 673 | 674 | // Apply character quaternion to moving direction 675 | movingDirection.applyQuaternion(characterModelIndicator.quaternion); 676 | 677 | /** 678 | * Moving object conditions 679 | */ 680 | // Calculate moving object velocity direction according to character moving direction 681 | movingObjectVelocityInCharacterDir 682 | .copy(movingObjectVelocity) 683 | .projectOnVector(movingDirection) 684 | .multiply(movingDirection); 685 | // Calculate angle between moving object velocity direction and character moving direction 686 | const angleBetweenCharacterDirAndObjectDir = 687 | movingObjectVelocity.angleTo(movingDirection); 688 | 689 | /** 690 | * Setup rejection velocity, (currently only work on ground) 691 | */ 692 | const wantToMoveMeg = currentVel.dot(movingDirection); 693 | wantToMoveVel.set( 694 | movingDirection.x * wantToMoveMeg, 695 | 0, 696 | movingDirection.z * wantToMoveMeg 697 | ); 698 | rejectVel.copy(currentVel).sub(wantToMoveVel); 699 | 700 | /** 701 | * Calculate required accelaration and force: a = Δv/Δt 702 | * If it's on a moving/rotating platform, apply platform velocity to Δv accordingly 703 | * Also, apply reject velocity when character is moving opposite of it's moving direction 704 | */ 705 | moveAccNeeded.set( 706 | (movingDirection.x * 707 | (maxVelLimit * (run ? sprintMult : 1) + 708 | movingObjectVelocityInCharacterDir.x) - 709 | (currentVel.x - 710 | movingObjectVelocity.x * 711 | Math.sin(angleBetweenCharacterDirAndObjectDir) + 712 | rejectVel.x * (isOnMovingObject ? 0 : rejectVelMult))) / 713 | accDeltaTime, 714 | 0, 715 | (movingDirection.z * 716 | (maxVelLimit * (run ? sprintMult : 1) + 717 | movingObjectVelocityInCharacterDir.z) - 718 | (currentVel.z - 719 | movingObjectVelocity.z * 720 | Math.sin(angleBetweenCharacterDirAndObjectDir) + 721 | rejectVel.z * (isOnMovingObject ? 0 : rejectVelMult))) / 722 | accDeltaTime 723 | ); 724 | 725 | // Wanted to move force function: F = ma 726 | const moveForceNeeded = moveAccNeeded.multiplyScalar( 727 | characterRef.current.mass() 728 | ); 729 | 730 | /** 731 | * Check if character complete turned to the wanted direction 732 | */ 733 | characterRotated = 734 | Math.sin(characterModelIndicator.rotation.y).toFixed(3) == 735 | Math.sin(modelEuler.y).toFixed(3); 736 | 737 | // If character hasn't complete turning, change the impulse quaternion follow characterModelIndicator quaternion 738 | if (!characterRotated) { 739 | moveImpulse.set( 740 | moveForceNeeded.x * 741 | turnVelMultiplier * 742 | (canJump ? 1 : airDragMultiplier), // if it's in the air, give it less control 743 | slopeAngle === null || slopeAngle == 0 // if it's on a slope, apply extra up/down force to the body 744 | ? 0 745 | : movingDirection.y * 746 | turnVelMultiplier * 747 | (movingDirection.y > 0 // check it is on slope up or slope down 748 | ? slopeUpExtraForce 749 | : slopeDownExtraForce) * 750 | (run ? sprintMult : 1), 751 | moveForceNeeded.z * 752 | turnVelMultiplier * 753 | (canJump ? 1 : airDragMultiplier) // if it's in the air, give it less control 754 | ); 755 | } 756 | // If character complete turning, change the impulse quaternion default 757 | else { 758 | moveImpulse.set( 759 | moveForceNeeded.x * (canJump ? 1 : airDragMultiplier), 760 | slopeAngle === null || slopeAngle == 0 // if it's on a slope, apply extra up/down force to the body 761 | ? 0 762 | : movingDirection.y * 763 | (movingDirection.y > 0 // check it is on slope up or slope down 764 | ? slopeUpExtraForce 765 | : slopeDownExtraForce) * 766 | (run ? sprintMult : 1), 767 | moveForceNeeded.z * (canJump ? 1 : airDragMultiplier) 768 | ); 769 | } 770 | 771 | // Move character at proper direction and impulse 772 | characterRef.current.applyImpulseAtPoint( 773 | moveImpulse, 774 | { 775 | x: currentPos.x, 776 | y: currentPos.y + moveImpulsePointY, 777 | z: currentPos.z, 778 | }, 779 | true 780 | ); 781 | }; 782 | 783 | /** 784 | * Character auto balance function 785 | */ 786 | const autoBalanceCharacter = () => { 787 | // Match body component to character model rotation on Y 788 | bodyFacingVec.set(0, 0, 1).applyQuaternion(quat(characterRef.current.rotation())) 789 | bodyBalanceVec.set(0, 1, 0).applyQuaternion(quat(characterRef.current.rotation())) 790 | 791 | bodyBalanceVecOnX.set(0, bodyBalanceVec.y, bodyBalanceVec.z) 792 | bodyFacingVecOnY.set(bodyFacingVec.x, 0, bodyFacingVec.z) 793 | bodyBalanceVecOnZ.set(bodyBalanceVec.x, bodyBalanceVec.y, 0) 794 | 795 | // Check if is camera based movement 796 | if (isModeCameraBased) { 797 | modelEuler.y = pivot.rotation.y 798 | pivot.getWorldDirection(modelFacingVec) 799 | // Update slopeRayOrigin to new positon 800 | slopeRayOriginUpdatePosition.set(movingDirection.x, 0, movingDirection.z) 801 | camBasedMoveCrossVecOnY.copy(slopeRayOriginUpdatePosition).cross(modelFacingVec) 802 | slopeRayOriginRef.current.position.x = slopeRayOriginOffest * Math.sin(slopeRayOriginUpdatePosition.angleTo(modelFacingVec) * (camBasedMoveCrossVecOnY.y < 0 ? 1 : -1)) 803 | slopeRayOriginRef.current.position.z = slopeRayOriginOffest * Math.cos(slopeRayOriginUpdatePosition.angleTo(modelFacingVec) * (camBasedMoveCrossVecOnY.y < 0 ? 1 : -1)) 804 | } else { 805 | characterModelIndicator.getWorldDirection(modelFacingVec) 806 | } 807 | crossVecOnX.copy(vectorY).cross(bodyBalanceVecOnX); 808 | crossVecOnY.copy(modelFacingVec).cross(bodyFacingVecOnY); 809 | crossVecOnZ.copy(vectorY).cross(bodyBalanceVecOnZ); 810 | 811 | dragAngForce.set( 812 | (crossVecOnX.x < 0 ? 1 : -1) * 813 | autoBalanceSpringK * (bodyBalanceVecOnX.angleTo(vectorY)) 814 | - characterRef.current.angvel().x * autoBalanceDampingC, 815 | (crossVecOnY.y < 0 ? 1 : -1) * 816 | autoBalanceSpringOnY * (modelFacingVec.angleTo(bodyFacingVecOnY)) 817 | - characterRef.current.angvel().y * autoBalanceDampingOnY, 818 | (crossVecOnZ.z < 0 ? 1 : -1) * 819 | autoBalanceSpringK * (bodyBalanceVecOnZ.angleTo(vectorY)) 820 | - characterRef.current.angvel().z * autoBalanceDampingC, 821 | ); 822 | 823 | // Apply balance torque impulse 824 | characterRef.current.applyTorqueImpulse(dragAngForce, true) 825 | }; 826 | 827 | /** 828 | * Character sleep function 829 | */ 830 | const sleepCharacter = () => { 831 | if (characterRef.current) { 832 | if (document.visibilityState === "hidden") { 833 | characterRef.current.sleep() 834 | } else { 835 | setTimeout(() => { 836 | characterRef.current.wakeUp() 837 | }, wakeUpDelay) 838 | } 839 | } 840 | } 841 | 842 | /** 843 | * Point-to-move function 844 | */ 845 | const pointToMove = (delta: number, slopeAngle: number, movingObjectVelocity: THREE.Vector3, functionKeyDown: boolean) => { 846 | const moveToPoint = getMoveToPoint().moveToPoint; 847 | if (moveToPoint) { 848 | pointToPoint.set(moveToPoint.x - currentPos.x, 0, moveToPoint.z - currentPos.z) 849 | crossVector.crossVectors(pointToPoint, vectorZ) 850 | // Rotate character to moving direction 851 | modelEuler.y = (crossVector.y > 0 ? -1 : 1) * pointToPoint.angleTo(vectorZ); 852 | // If mode is also set to fixed camera. keep the camera on the back of character 853 | if (isModeFixedCamera) pivot.rotation.y = THREE.MathUtils.lerp(pivot.rotation.y, modelEuler.y, fixedCamRotMult * delta * 3); 854 | // Once character close to the target point (distance<0.3), 855 | // Or character close to the wall (bodySensor intersects) 856 | // stop moving 857 | if (characterRef.current) { 858 | if (pointToPoint.length() > 0.3 && !isBodyHitWall && !functionKeyDown) { 859 | moveCharacter(delta, false, slopeAngle, movingObjectVelocity) 860 | isPointMoving = true 861 | } else { 862 | setMoveToPoint(null); 863 | isPointMoving = false 864 | } 865 | } 866 | } 867 | } 868 | 869 | /** 870 | * Rotate camera function 871 | */ 872 | const rotateCamera = (x: number, y: number) => { 873 | pivot.rotation.y += y; 874 | followCam.rotation.x = Math.min( 875 | Math.max(followCam.rotation.x + x, camLowLimit), 876 | camUpLimit 877 | ); 878 | }; 879 | 880 | /** 881 | * Rotate character on Y function 882 | */ 883 | const rotateCharacterOnY = (rad: number) => { 884 | modelEuler.y += rad; 885 | }; 886 | 887 | useEffect(() => { 888 | // Initialize directional light 889 | if (followLight) { 890 | dirLight = characterModelRef.current.parent.parent.children.find( 891 | (item) => { 892 | return item.name === "followLight"; 893 | } 894 | ) as THREE.DirectionalLight; 895 | } 896 | }); 897 | 898 | /** 899 | * Keyboard controls subscribe setup 900 | */ 901 | // If inside keyboardcontrols, active subscribeKeys 902 | if (isInsideKeyboardControls) { 903 | useEffect(() => { 904 | // Action 1 key subscribe for special animation 905 | const unSubscribeAction1 = subscribeKeys( 906 | (state) => state.action1, 907 | (value) => { 908 | if (value) { 909 | animated && action1Animation(); 910 | } 911 | } 912 | ); 913 | 914 | // Action 2 key subscribe for special animation 915 | const unSubscribeAction2 = subscribeKeys( 916 | (state) => state.action2, 917 | (value) => { 918 | if (value) { 919 | animated && action2Animation(); 920 | } 921 | } 922 | ); 923 | 924 | // Action 3 key subscribe for special animation 925 | const unSubscribeAction3 = subscribeKeys( 926 | (state) => state.action3, 927 | (value) => { 928 | if (value) { 929 | animated && action3Animation(); 930 | } 931 | } 932 | ); 933 | 934 | // Trigger key subscribe for special animation 935 | const unSubscribeAction4 = subscribeKeys( 936 | (state) => state.action4, 937 | (value) => { 938 | if (value) { 939 | animated && action4Animation(); 940 | } 941 | } 942 | ); 943 | 944 | return () => { 945 | unSubscribeAction1(); 946 | unSubscribeAction2(); 947 | unSubscribeAction3(); 948 | unSubscribeAction4(); 949 | }; 950 | }); 951 | } 952 | 953 | /** 954 | * Joystick subscribe setup 955 | */ 956 | useEffect(() => { 957 | // Subscribe button 2 958 | const unSubPressButton2 = useJoystickControls.subscribe( 959 | (state) => state.curButton2Pressed, 960 | (value) => { 961 | if (value) { 962 | animated && action4Animation(); 963 | } 964 | } 965 | ) 966 | 967 | // Subscribe button 3 968 | const unSubPressButton3 = useJoystickControls.subscribe( 969 | (state) => state.curButton3Pressed, 970 | (value) => { 971 | if (value) { 972 | animated && action2Animation(); 973 | } 974 | } 975 | ) 976 | 977 | // Subscribe button 4 978 | const unSubPressButton4 = useJoystickControls.subscribe( 979 | (state) => state.curButton4Pressed, 980 | (value) => { 981 | if (value) { 982 | animated && action3Animation(); 983 | } 984 | } 985 | ) 986 | 987 | // Subscribe button 5 988 | const unSubPressButton5 = useJoystickControls.subscribe( 989 | (state) => state.curButton5Pressed, 990 | (value) => { 991 | if (value) { 992 | animated && action1Animation(); 993 | } 994 | } 995 | ) 996 | 997 | return () => { 998 | unSubPressButton2(); 999 | unSubPressButton3(); 1000 | unSubPressButton4(); 1001 | unSubPressButton5(); 1002 | }; 1003 | }) 1004 | 1005 | useEffect(() => { 1006 | // Lock character rotations at Y axis 1007 | characterRef.current.setEnabledRotations( 1008 | autoBalance ? true : false, 1009 | autoBalance ? true : false, 1010 | autoBalance ? true : false, 1011 | false 1012 | ); 1013 | 1014 | // Reset character quaternion 1015 | return (() => { 1016 | if (characterRef.current && characterModelRef.current) { 1017 | characterModelRef.current.quaternion.set(0, 0, 0, 1); 1018 | characterRef.current.setRotation({ x: 0, y: 0, z: 0, w: 1 }, false); 1019 | } 1020 | }) 1021 | }, [autoBalance]); 1022 | 1023 | useEffect(() => { 1024 | // Initialize character facing direction 1025 | modelEuler.y = characterInitDir 1026 | 1027 | window.addEventListener("visibilitychange", sleepCharacter); 1028 | window.addEventListener("gamepadconnected", gamepadConnect); 1029 | window.addEventListener("gamepaddisconnected", gamepadDisconnect); 1030 | 1031 | return () => { 1032 | window.removeEventListener("visibilitychange", sleepCharacter); 1033 | window.removeEventListener("gamepadconnected", gamepadConnect); 1034 | window.removeEventListener("gamepaddisconnected", gamepadDisconnect); 1035 | } 1036 | }, []) 1037 | 1038 | useFrame((state, delta) => { 1039 | if (delta > 1) delta %= 1; 1040 | 1041 | // Character current position/velocity 1042 | if (characterRef.current) { 1043 | currentPos.copy(characterRef.current.translation() as THREE.Vector3); 1044 | currentVel.copy(characterRef.current.linvel() as THREE.Vector3); 1045 | // Assign userDate properties 1046 | (characterRef.current.userData as userDataType).canJump = canJump; 1047 | (characterRef.current.userData as userDataType).slopeAngle = slopeAngle; 1048 | (characterRef.current.userData as userDataType).characterRotated = characterRotated; 1049 | (characterRef.current.userData as userDataType).isOnMovingObject = isOnMovingObject; 1050 | } 1051 | 1052 | /** 1053 | * Apply character position to directional light 1054 | */ 1055 | if (followLight && dirLight) { 1056 | dirLight.position.x = currentPos.x + followLightPos.x; 1057 | dirLight.position.y = currentPos.y + followLightPos.y; 1058 | dirLight.position.z = currentPos.z + followLightPos.z; 1059 | dirLight.target = characterModelRef.current; 1060 | } 1061 | 1062 | /** 1063 | * Camera movement 1064 | */ 1065 | pivotXAxis.set(1, 0, 0) 1066 | pivotXAxis.applyQuaternion(pivot.quaternion) 1067 | pivotZAxis.set(0, 0, 1) 1068 | pivotZAxis.applyQuaternion(pivot.quaternion) 1069 | pivotPosition.copy(currentPos) 1070 | .addScaledVector(pivotXAxis, camTargetPos.x) 1071 | .addScaledVector(pivotYAxis, camTargetPos.y + (capsuleHalfHeight + capsuleRadius / 2)) 1072 | .addScaledVector(pivotZAxis, camTargetPos.z) 1073 | pivot.position.lerp(pivotPosition, 1 - Math.exp(-camFollowMult * delta)); 1074 | 1075 | if (!disableFollowCam) { 1076 | followCam.getWorldPosition(followCamPosition); 1077 | state.camera.position.lerp(followCamPosition, 1 - Math.exp(-camLerpMult * delta)); 1078 | state.camera.lookAt(pivot.position); 1079 | } 1080 | 1081 | /** 1082 | * Camera collision detect 1083 | */ 1084 | camCollision && cameraCollisionDetect(delta); 1085 | 1086 | /** 1087 | * If disableControl is true, skip all following features 1088 | */ 1089 | if (disableControl) return; 1090 | 1091 | /** 1092 | * Getting all gamepad control values 1093 | */ 1094 | if (controllerIndex !== null) { 1095 | const gamepad = navigator.getGamepads()[controllerIndex] 1096 | handleButtons(gamepad.buttons) 1097 | handleSticks(gamepad.axes) 1098 | // Getting moving directions (IIFE) 1099 | modelEuler.y = ((movingDirection) => movingDirection === null ? modelEuler.y : movingDirection) 1100 | (getMovingDirection(gamepadKeys.forward, gamepadKeys.backward, gamepadKeys.leftward, gamepadKeys.rightward, pivot)) 1101 | } 1102 | 1103 | /** 1104 | * Getting all joystick control values 1105 | */ 1106 | const { 1107 | joystickDis, 1108 | joystickAng, 1109 | runState, 1110 | button1Pressed, 1111 | } = getJoystickValues() 1112 | 1113 | // Move character to the moving direction (joystick controls) 1114 | if (joystickDis > 0) { 1115 | // Apply camera rotation to character model 1116 | modelEuler.y = pivot.rotation.y + (joystickAng - Math.PI / 2) 1117 | moveCharacter(delta, runState, slopeAngle, movingObjectVelocity); 1118 | } 1119 | 1120 | /** 1121 | * Getting all the useful keys from useKeyboardControls 1122 | */ 1123 | const { forward, backward, leftward, rightward, jump, run } = isInsideKeyboardControls ? getKeys() : presetKeys; 1124 | 1125 | // Getting moving directions (IIFE) 1126 | modelEuler.y = ((movingDirection) => movingDirection === null ? modelEuler.y : movingDirection) 1127 | (getMovingDirection(forward, backward, leftward, rightward, pivot)) 1128 | 1129 | // Move character to the moving direction 1130 | if (forward || backward || leftward || rightward || gamepadKeys.forward || gamepadKeys.backward || gamepadKeys.leftward || gamepadKeys.rightward) 1131 | moveCharacter(delta, run, slopeAngle, movingObjectVelocity); 1132 | 1133 | // Jump impulse 1134 | if ((jump || button1Pressed) && canJump) { 1135 | // characterRef.current.applyImpulse(jumpDirection.set(0, 0.5, 0), true); 1136 | jumpVelocityVec.set( 1137 | currentVel.x, 1138 | run ? sprintJumpMult * jumpVel : jumpVel, 1139 | currentVel.z 1140 | ); 1141 | // Apply slope normal to jump direction 1142 | characterRef.current.setLinvel( 1143 | jumpDirection 1144 | .set(0, (run ? sprintJumpMult * jumpVel : jumpVel) * slopJumpMult, 0) 1145 | .projectOnVector(actualSlopeNormalVec) 1146 | .add(jumpVelocityVec), 1147 | true 1148 | ); 1149 | // Apply jump force downward to the standing platform 1150 | characterMassForce.y *= jumpForceToGroundMult; 1151 | rayHit.collider 1152 | .parent() 1153 | ?.applyImpulseAtPoint(characterMassForce, standingForcePoint, true); 1154 | } 1155 | 1156 | // Rotate character Indicator 1157 | modelQuat.setFromEuler(modelEuler); 1158 | characterModelIndicator.quaternion.rotateTowards( 1159 | modelQuat, 1160 | delta * turnSpeed 1161 | ); 1162 | 1163 | // If autobalance is off, rotate character model itself 1164 | if (!autoBalance) { 1165 | if (isModeCameraBased) { 1166 | characterModelRef.current.quaternion.copy(pivot.quaternion) 1167 | } else { 1168 | characterModelRef.current.quaternion.copy(characterModelIndicator.quaternion) 1169 | } 1170 | } 1171 | 1172 | /** 1173 | * Ray casting detect if on ground 1174 | */ 1175 | rayOrigin.addVectors(currentPos, rayOriginOffest as THREE.Vector3); 1176 | rayHit = world.castRay( 1177 | rayCast, 1178 | rayLength, 1179 | false, 1180 | QueryFilterFlags.EXCLUDE_SENSORS, 1181 | null, 1182 | null, 1183 | characterRef.current, 1184 | // this exclude any collider with userData: excludeEcctrlRay 1185 | ((collider: Collider) => ( 1186 | collider.parent().userData && !(collider.parent().userData as userDataType).excludeEcctrlRay 1187 | )) 1188 | ); 1189 | 1190 | /**Test shape ray */ 1191 | // rayHit = world.castShape( 1192 | // currentPos, 1193 | // { w: 0, x: 0, y: 0, z: 0 }, 1194 | // {x:0,y:-1,z:0}, 1195 | // shape, 1196 | // rayLength, 1197 | // true, 1198 | // null, 1199 | // null, 1200 | // characterRef.current 1201 | // ); 1202 | 1203 | if (rayHit && rayHit.timeOfImpact < floatingDis + rayHitForgiveness) { 1204 | if (slopeRayHit && actualSlopeAngle < slopeMaxAngle) { 1205 | canJump = true; 1206 | } 1207 | } else { 1208 | canJump = false; 1209 | } 1210 | 1211 | /** 1212 | * Ray detect if on rigid body or dynamic platform, then apply the linear velocity and angular velocity to character 1213 | */ 1214 | if (rayHit && canJump) { 1215 | if (rayHit.collider.parent()) { 1216 | // Getting the standing force apply point 1217 | standingForcePoint.set( 1218 | rayOrigin.x, 1219 | rayOrigin.y - rayHit.timeOfImpact, 1220 | rayOrigin.z 1221 | ); 1222 | const rayHitObjectBodyType = rayHit.collider.parent().bodyType(); 1223 | const rayHitObjectBodyMass = rayHit.collider.parent().mass(); 1224 | massRatio = characterRef.current.mass() / rayHitObjectBodyMass; 1225 | // Body type 0 is rigid body, body type 1 is fixed body, body type 2 is kinematic body 1226 | if (rayHitObjectBodyType === 0 || rayHitObjectBodyType === 2) { 1227 | isOnMovingObject = true; 1228 | // Calculate distance between character and moving object 1229 | distanceFromCharacterToObject 1230 | .copy(currentPos) 1231 | .sub(rayHit.collider.parent().translation() as THREE.Vector3); 1232 | // Moving object linear velocity 1233 | const movingObjectLinvel = rayHit.collider 1234 | .parent() 1235 | .linvel() as THREE.Vector3; 1236 | // Moving object angular velocity 1237 | const movingObjectAngvel = rayHit.collider 1238 | .parent() 1239 | .angvel() as THREE.Vector3; 1240 | // Combine object linear velocity and angular velocity to movingObjectVelocity 1241 | movingObjectVelocity.set( 1242 | movingObjectLinvel.x + 1243 | objectAngvelToLinvel.crossVectors( 1244 | movingObjectAngvel, 1245 | distanceFromCharacterToObject 1246 | ).x, 1247 | movingObjectLinvel.y, 1248 | movingObjectLinvel.z + 1249 | objectAngvelToLinvel.crossVectors( 1250 | movingObjectAngvel, 1251 | distanceFromCharacterToObject 1252 | ).z 1253 | ).multiplyScalar(Math.min(1, 1 / massRatio)); 1254 | // If the velocity diff is too high (> 30), ignore movingObjectVelocity 1255 | velocityDiff.subVectors(movingObjectVelocity, currentVel); 1256 | if (velocityDiff.length() > 30) movingObjectVelocity.multiplyScalar(1 / velocityDiff.length()); 1257 | 1258 | // Apply opposite drage force to the stading rigid body, body type 0 1259 | // Character moving and unmoving should provide different drag force to the platform 1260 | if (rayHitObjectBodyType === 0) { 1261 | if ( 1262 | !forward && !backward && !leftward && !rightward && 1263 | canJump && 1264 | joystickDis === 0 && 1265 | !isPointMoving && 1266 | !gamepadKeys.forward && !gamepadKeys.backward && !gamepadKeys.leftward && !gamepadKeys.rightward 1267 | ) { 1268 | movingObjectDragForce.copy(bodyContactForce) 1269 | .multiplyScalar(delta) 1270 | .multiplyScalar(Math.min(1, 1 / massRatio)) // Scale up/down base on different masses ratio 1271 | .negate() 1272 | bodyContactForce.set(0, 0, 0); 1273 | } else { 1274 | movingObjectDragForce.copy(moveImpulse) 1275 | .multiplyScalar(Math.min(1, 1 / massRatio)) // Scale up/down base on different masses ratio 1276 | .negate(); 1277 | } 1278 | rayHit.collider 1279 | .parent() 1280 | .applyImpulseAtPoint( 1281 | movingObjectDragForce, 1282 | standingForcePoint, 1283 | true 1284 | ); 1285 | } 1286 | } else { // on fixed body 1287 | massRatio = 1; 1288 | isOnMovingObject = false; 1289 | bodyContactForce.set(0, 0, 0); 1290 | movingObjectVelocity.set(0, 0, 0); 1291 | } 1292 | } 1293 | } else { // in the air 1294 | massRatio = 1; 1295 | isOnMovingObject = false; 1296 | bodyContactForce.set(0, 0, 0); 1297 | movingObjectVelocity.set(0, 0, 0); 1298 | } 1299 | 1300 | /** 1301 | * Slope ray casting detect if on slope 1302 | */ 1303 | slopeRayOriginRef.current.getWorldPosition(slopeRayorigin); 1304 | slopeRayorigin.y = rayOrigin.y; 1305 | slopeRayHit = world.castRay( 1306 | slopeRayCast, 1307 | slopeRayLength, 1308 | false, 1309 | QueryFilterFlags.EXCLUDE_SENSORS, 1310 | null, 1311 | null, 1312 | characterRef.current, 1313 | // this exclude any collider with userData: excludeEcctrlRay 1314 | ((collider: Collider) => ( 1315 | collider.parent().userData && !(collider.parent().userData as userDataType).excludeEcctrlRay 1316 | )) 1317 | ); 1318 | 1319 | // Calculate slope angle 1320 | if (slopeRayHit) { 1321 | actualSlopeNormal = slopeRayHit.collider.castRayAndGetNormal( 1322 | slopeRayCast, 1323 | slopeRayLength, 1324 | false 1325 | )?.normal; 1326 | if (actualSlopeNormal) { 1327 | actualSlopeNormalVec?.set( 1328 | actualSlopeNormal.x, 1329 | actualSlopeNormal.y, 1330 | actualSlopeNormal.z 1331 | ); 1332 | actualSlopeAngle = actualSlopeNormalVec?.angleTo(floorNormal); 1333 | } 1334 | } 1335 | if (slopeRayHit && rayHit && slopeRayHit.timeOfImpact < floatingDis + 0.5) { 1336 | if (canJump) { 1337 | // Round the slope angle to 2 decimal places 1338 | slopeAngle = Number( 1339 | Math.atan( 1340 | (rayHit.timeOfImpact - slopeRayHit.timeOfImpact) / slopeRayOriginOffest 1341 | ).toFixed(2) 1342 | ); 1343 | } else { 1344 | slopeAngle = null; 1345 | } 1346 | } else { 1347 | slopeAngle = null; 1348 | } 1349 | 1350 | /** 1351 | * Apply floating force 1352 | */ 1353 | if (rayHit != null) { 1354 | if (canJump && rayHit.collider.parent()) { 1355 | floatingForce = 1356 | springK * (floatingDis - rayHit.timeOfImpact) - 1357 | characterRef.current.linvel().y * dampingC; 1358 | characterRef.current.applyImpulse( 1359 | springDirVec.set(0, floatingForce, 0), 1360 | false 1361 | ); 1362 | 1363 | // Apply opposite force to standing object (gravity g in rapier is 0.11 ?_?) 1364 | characterMassForce.set(0, floatingForce > 0 ? -floatingForce : 0, 0); 1365 | rayHit.collider 1366 | .parent() 1367 | ?.applyImpulseAtPoint(characterMassForce, standingForcePoint, true); 1368 | } 1369 | } 1370 | 1371 | /** 1372 | * Apply drag force if it's not moving 1373 | */ 1374 | if ( 1375 | !forward && !backward && !leftward && !rightward && 1376 | canJump && 1377 | joystickDis === 0 && 1378 | !isPointMoving && 1379 | !gamepadKeys.forward && !gamepadKeys.backward && !gamepadKeys.leftward && !gamepadKeys.rightward 1380 | ) { 1381 | // not on a moving object 1382 | if (!isOnMovingObject) { 1383 | dragForce.set( 1384 | -currentVel.x * dragDampingC, 1385 | 0, 1386 | -currentVel.z * dragDampingC 1387 | ); 1388 | characterRef.current.applyImpulse(dragForce, false); 1389 | } 1390 | // on a moving object 1391 | else { 1392 | dragForce.set( 1393 | (movingObjectVelocity.x - currentVel.x) * dragDampingC, 1394 | 0, 1395 | (movingObjectVelocity.z - currentVel.z) * dragDampingC 1396 | ); 1397 | characterRef.current.applyImpulse(dragForce, true); 1398 | } 1399 | } 1400 | 1401 | /** 1402 | * Detect character falling state 1403 | */ 1404 | isFalling = (currentVel.y < 0 && !canJump) ? true : false 1405 | 1406 | /** 1407 | * Setup max falling speed && extra falling gravity 1408 | * Remove gravity if falling speed higher than fallingMaxVel (negetive number so use "<") 1409 | */ 1410 | if (characterRef.current) { 1411 | if (currentVel.y < fallingMaxVel) { 1412 | if (characterRef.current.gravityScale() !== 0) { 1413 | characterRef.current.setGravityScale(0, true) 1414 | } 1415 | } else { 1416 | if (!isFalling && characterRef.current.gravityScale() !== initialGravityScale) { 1417 | // Apply initial gravity after landed 1418 | characterRef.current.setGravityScale(initialGravityScale, true) 1419 | } else if (isFalling && characterRef.current.gravityScale() !== fallingGravityScale) { 1420 | // Apply larger gravity when falling (if initialGravityScale === fallingGravityScale, won't trigger this) 1421 | characterRef.current.setGravityScale(fallingGravityScale, true) 1422 | } 1423 | } 1424 | } 1425 | 1426 | /** 1427 | * Apply auto balance force to the character 1428 | */ 1429 | if (autoBalance && characterRef.current) autoBalanceCharacter(); 1430 | 1431 | /** 1432 | * Point to move feature 1433 | */ 1434 | if (isModePointToMove) { 1435 | functionKeyDown = (forward || backward || leftward || rightward || joystickDis > 0 || gamepadKeys.forward || gamepadKeys.backward || gamepadKeys.leftward || gamepadKeys.rightward || jump || button1Pressed) 1436 | pointToMove(delta, slopeAngle, movingObjectVelocity, functionKeyDown) 1437 | } 1438 | 1439 | /** 1440 | * Fixed camera feature 1441 | */ 1442 | if (isModeFixedCamera) { 1443 | if ( 1444 | leftward || 1445 | gamepadKeys.leftward || 1446 | (joystickDis > 0 && joystickAng > 2 * Math.PI / 3 && joystickAng < 4 * Math.PI / 3) || 1447 | (gamepadJoystickDis > 0 && gamepadJoystickAng > 2 * Math.PI / 3 && gamepadJoystickAng < 4 * Math.PI / 3) 1448 | ) { 1449 | pivot.rotation.y += (run ? delta * sprintMult * fixedCamRotMult : delta * fixedCamRotMult) 1450 | } else if ( 1451 | rightward || 1452 | gamepadKeys.rightward || 1453 | (joystickDis > 0 && joystickAng < Math.PI / 3 || joystickAng > 5 * Math.PI / 3) || 1454 | (gamepadJoystickDis > 0 && gamepadJoystickAng < Math.PI / 3 || gamepadJoystickAng > 5 * Math.PI / 3) 1455 | ) { 1456 | pivot.rotation.y -= (run ? delta * sprintMult * fixedCamRotMult : delta * fixedCamRotMult) 1457 | } 1458 | } 1459 | 1460 | /** 1461 | * Apply all the animations 1462 | */ 1463 | if (animated) { 1464 | if (!forward && !backward && !leftward && !rightward && !jump && 1465 | !button1Pressed && joystickDis === 0 && 1466 | !isPointMoving && 1467 | !gamepadKeys.forward && !gamepadKeys.backward && !gamepadKeys.leftward && !gamepadKeys.rightward && 1468 | canJump 1469 | ) { 1470 | idleAnimation(); 1471 | } else if ((jump || button1Pressed) && canJump) { 1472 | jumpAnimation(); 1473 | } else if (canJump && 1474 | ( 1475 | forward || backward || leftward || rightward || 1476 | joystickDis > 0 || 1477 | isPointMoving || 1478 | gamepadKeys.forward || gamepadKeys.backward || gamepadKeys.leftward || gamepadKeys.rightward 1479 | )) { 1480 | (run || runState) ? runAnimation() : walkAnimation(); 1481 | } else if (!canJump) { 1482 | jumpIdleAnimation(); 1483 | } 1484 | // On high sky, play falling animation 1485 | if (rayHit == null && isFalling) { 1486 | fallAnimation(); 1487 | } 1488 | } 1489 | }); 1490 | 1491 | return ( 1492 | bodyContactForce.set(e.totalForce.x, e.totalForce.y, e.totalForce.z)} 1498 | onCollisionExit={() => bodyContactForce.set(0, 0, 0)} 1499 | userData={{ canJump: false }} 1500 | {...props} 1501 | > 1502 | 1506 | {/* Body collide sensor (only for point to move mode) */} 1507 | {isModePointToMove && 1508 | } 1517 | 1518 | {/* This mesh is used for positioning the slope ray origin */} 1519 | 1529 | 1530 | 1531 | {/* Character model */} 1532 | {children} 1533 | 1534 | 1535 | ); 1536 | } 1537 | 1538 | export default forwardRef(Ecctrl); 1539 | 1540 | export type camListenerTargetType = "document" | "domElement"; 1541 | 1542 | export interface CustomEcctrlRigidBody extends RapierRigidBody { 1543 | rotateCamera?: (x: number, y: number) => void; 1544 | rotateCharacterOnY?: (rad: number) => void; 1545 | } 1546 | 1547 | export interface EcctrlProps extends RigidBodyProps { 1548 | children?: ReactNode; 1549 | debug?: boolean; 1550 | capsuleHalfHeight?: number; 1551 | capsuleRadius?: number; 1552 | floatHeight?: number; 1553 | characterInitDir?: number; 1554 | followLight?: boolean; 1555 | disableControl?: boolean; 1556 | disableFollowCam?: boolean; 1557 | disableFollowCamPos?: { x: number, y: number, z: number }; 1558 | disableFollowCamTarget?: { x: number, y: number, z: number }; 1559 | // Follow camera setups 1560 | camInitDis?: number; 1561 | camMaxDis?: number; 1562 | camMinDis?: number; 1563 | camUpLimit?: number; 1564 | camLowLimit?: number; 1565 | camInitDir?: { x: number, y: number }; 1566 | camTargetPos?: { x: number, y: number, z: number }; 1567 | camMoveSpeed?: number; 1568 | camZoomSpeed?: number; 1569 | camCollision?: boolean; 1570 | camCollisionOffset?: number; 1571 | camCollisionSpeedMult?: number; 1572 | fixedCamRotMult?: number; 1573 | camListenerTarget?: camListenerTargetType; 1574 | // Follow light setups 1575 | followLightPos?: { x: number, y: number, z: number }; 1576 | // Base control setups 1577 | maxVelLimit?: number; 1578 | turnVelMultiplier?: number; 1579 | turnSpeed?: number; 1580 | sprintMult?: number; 1581 | jumpVel?: number; 1582 | jumpForceToGroundMult?: number; 1583 | slopJumpMult?: number; 1584 | sprintJumpMult?: number; 1585 | airDragMultiplier?: number; 1586 | dragDampingC?: number; 1587 | accDeltaTime?: number; 1588 | rejectVelMult?: number; 1589 | moveImpulsePointY?: number; 1590 | camFollowMult?: number; 1591 | camLerpMult?: number; 1592 | fallingGravityScale?: number; 1593 | fallingMaxVel?: number; 1594 | wakeUpDelay?: number; 1595 | // Floating Ray setups 1596 | rayOriginOffest?: { x: number; y: number; z: number }; 1597 | rayHitForgiveness?: number; 1598 | rayLength?: number; 1599 | rayDir?: { x: number; y: number; z: number }; 1600 | floatingDis?: number; 1601 | springK?: number; 1602 | dampingC?: number; 1603 | // Slope Ray setups 1604 | showSlopeRayOrigin?: boolean; 1605 | slopeMaxAngle?: number; 1606 | slopeRayOriginOffest?: number; 1607 | slopeRayLength?: number; 1608 | slopeRayDir?: { x: number; y: number; z: number }; 1609 | slopeUpExtraForce?: number; 1610 | slopeDownExtraForce?: number; 1611 | // Head Ray setups 1612 | showHeadRayOrigin?: boolean; 1613 | headRayOriginOffest?: number; 1614 | headRayLength?: number; 1615 | headRayDir?: { x: number; y: number; z: number }; 1616 | // AutoBalance Force setups 1617 | autoBalance?: boolean; 1618 | autoBalanceSpringK?: number; 1619 | autoBalanceDampingC?: number; 1620 | autoBalanceSpringOnY?: number; 1621 | autoBalanceDampingOnY?: number; 1622 | // Animation temporary setups 1623 | animated?: boolean; 1624 | // Mode setups 1625 | mode?: string; 1626 | // Controller setups 1627 | controllerKeys?: { forward?: number, backward?: number, leftward?: number, rightward?: number, jump?: number, action1?: number, action2?: number, action3?: number, action4?: number } 1628 | // Point-to-move setups 1629 | bodySensorSize?: Array; 1630 | bodySensorPosition?: { x: number, y: number, z: number } 1631 | // Other rigibody props from parent 1632 | props?: RigidBodyProps; 1633 | }; 1634 | 1635 | export interface userDataType { 1636 | canJump?: boolean; 1637 | slopeAngle?: number | null; 1638 | characterRotated?: boolean; 1639 | isOnMovingObject?: boolean; 1640 | excludeEcctrlRay?: boolean; 1641 | } -------------------------------------------------------------------------------- /src/EcctrlAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { useGLTF, useAnimations } from "@react-three/drei"; 2 | import { useEffect, useRef, Suspense } from "react"; 3 | import * as THREE from "three"; 4 | import { useGame, type AnimationSet } from "./stores/useGame"; 5 | import React from "react"; 6 | 7 | export function EcctrlAnimation(props: EcctrlAnimationProps) { 8 | // Change the character src to yours 9 | const group = useRef(); 10 | const { animations } = useGLTF(props.characterURL); 11 | const { actions } = useAnimations(animations, group); 12 | 13 | /** 14 | * Character animations setup 15 | */ 16 | const curAnimation = useGame((state) => state.curAnimation); 17 | const resetAnimation = useGame((state) => state.reset); 18 | const initializeAnimationSet = useGame( 19 | (state) => state.initializeAnimationSet 20 | ); 21 | 22 | useEffect(() => { 23 | // Initialize animation set 24 | initializeAnimationSet(props.animationSet); 25 | }, []); 26 | 27 | useEffect(() => { 28 | // Play animation 29 | const action = 30 | actions[curAnimation ? curAnimation : props.animationSet.jumpIdle]; 31 | 32 | // For jump and jump land animation, only play once and clamp when finish 33 | if ( 34 | curAnimation === props.animationSet.jump || 35 | curAnimation === props.animationSet.jumpLand || 36 | curAnimation === props.animationSet.action1 || 37 | curAnimation === props.animationSet.action2 || 38 | curAnimation === props.animationSet.action3 || 39 | curAnimation === props.animationSet.action4 40 | ) { 41 | action 42 | .reset() 43 | .fadeIn(0.2) 44 | .setLoop(THREE.LoopOnce, undefined as number) 45 | .play(); 46 | action.clampWhenFinished = true; 47 | } else { 48 | action.reset().fadeIn(0.2).play(); 49 | } 50 | 51 | // When any action is clamp and finished reset animation 52 | (action as any)._mixer.addEventListener("finished", () => resetAnimation()); 53 | 54 | return () => { 55 | // Fade out previous action 56 | action.fadeOut(0.2); 57 | 58 | // Clean up mixer listener, and empty the _listeners array 59 | (action as any)._mixer.removeEventListener("finished", () => 60 | resetAnimation() 61 | ); 62 | (action as any)._mixer._listeners = []; 63 | }; 64 | }, [curAnimation]); 65 | 66 | return ( 67 | 68 | 69 | {/* Replace character model here */} 70 | {props.children} 71 | 72 | 73 | ); 74 | } 75 | 76 | export type EcctrlAnimationProps = { 77 | characterURL: string; 78 | animationSet: AnimationSet; 79 | children: React.ReactNode; 80 | }; 81 | -------------------------------------------------------------------------------- /src/EcctrlJoystick.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from "three" 2 | import { Canvas, type ThreeElements } from "@react-three/fiber"; 3 | import React, { useEffect, useState, forwardRef, useMemo, type ReactNode, useCallback, Suspense } from "react"; 4 | import { useSpring, animated } from '@react-spring/three' 5 | import { useJoystickControls } from "./stores/useJoystickControls"; 6 | 7 | const JoystickComponents = (props: EcctrlJoystickProps) => { 8 | /** 9 | * Preset values/components 10 | */ 11 | let joystickCenterX: number = 0 12 | let joystickCenterY: number = 0 13 | let joystickHalfWidth: number = 0 14 | let joystickHalfHeight: number = 0 15 | let joystickMaxDis: number = 0 16 | let joystickDis: number = 0 17 | let joystickAng: number = 0 18 | const touch1MovementVec2 = useMemo(() => new THREE.Vector2(), []) 19 | const joystickMovementVec2 = useMemo(() => new THREE.Vector2(), []) 20 | 21 | const [windowSize, setWindowSize] = useState({ innerHeight, innerWidth }) 22 | const joystickDiv: HTMLDivElement = document.querySelector("#ecctrl-joystick") 23 | 24 | /** 25 | * Animation preset 26 | */ 27 | const [springs, api] = useSpring( 28 | () => ({ 29 | topRotationX: 0, 30 | topRotationY: 0, 31 | basePositionX: 0, 32 | basePositionY: 0, 33 | config: { 34 | tension: 600 35 | } 36 | }) 37 | ) 38 | 39 | /** 40 | * Joystick component geometries 41 | */ 42 | const joystickBaseGeo = useMemo(() => new THREE.CylinderGeometry(2.3, 2.1, 0.3, 16), []) 43 | const joystickStickGeo = useMemo(() => new THREE.CylinderGeometry(0.3, 0.3, 3, 6), []) 44 | const joystickHandleGeo = useMemo(() => new THREE.SphereGeometry(1.4, 8, 8), []) 45 | 46 | /** 47 | * Joystick component materials 48 | */ 49 | const joystickBaseMaterial = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.3 }), []) 50 | const joystickStickMaterial = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.3 }), []) 51 | const joystickHandleMaterial = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.7 }), []) 52 | 53 | /** 54 | * Joystick store setup 55 | */ 56 | const setJoystick = useJoystickControls((state) => state.setJoystick) 57 | const resetJoystick = useJoystickControls((state) => state.resetJoystick) 58 | 59 | // Touch move function 60 | const onTouchMove = useCallback((e: TouchEvent) => { 61 | e.preventDefault(); 62 | e.stopImmediatePropagation(); 63 | const touch1 = e.targetTouches[0]; 64 | 65 | const touch1MovementX = touch1.pageX - joystickCenterX 66 | const touch1MovementY = -(touch1.pageY - joystickCenterY) 67 | touch1MovementVec2.set(touch1MovementX, touch1MovementY) 68 | 69 | joystickDis = Math.min(Math.sqrt(Math.pow(touch1MovementX, 2) + Math.pow(touch1MovementY, 2)), joystickMaxDis) 70 | joystickAng = touch1MovementVec2.angle() 71 | joystickMovementVec2.set(joystickDis * Math.cos(joystickAng), joystickDis * Math.sin(joystickAng)) 72 | const runState = joystickDis > joystickMaxDis * (props.joystickRunSensitivity ?? 0.9) 73 | 74 | // Apply animations 75 | api.start({ 76 | topRotationX: -joystickMovementVec2.y / joystickHalfHeight, 77 | topRotationY: joystickMovementVec2.x / joystickHalfWidth, 78 | basePositionX: joystickMovementVec2.x * 0.002, 79 | basePositionY: joystickMovementVec2.y * 0.002, 80 | }) 81 | 82 | // Pass valus to joystick store 83 | setJoystick(joystickDis, joystickAng, runState) 84 | }, [api, windowSize]) 85 | 86 | // Touch end function 87 | const onTouchEnd = (e: TouchEvent) => { 88 | // Reset animations 89 | api.start({ 90 | topRotationX: 0, 91 | topRotationY: 0, 92 | basePositionX: 0, 93 | basePositionY: 0, 94 | }) 95 | 96 | // Reset joystick store values 97 | resetJoystick() 98 | } 99 | 100 | // Reset window size function 101 | const onWindowResize = () => { 102 | setWindowSize({ innerHeight: window.innerHeight, innerWidth: window.innerWidth }) 103 | } 104 | 105 | useEffect(() => { 106 | const joystickPositionX = joystickDiv.getBoundingClientRect().x 107 | const joystickPositionY = joystickDiv.getBoundingClientRect().y 108 | joystickHalfWidth = joystickDiv.getBoundingClientRect().width / 2 109 | joystickHalfHeight = joystickDiv.getBoundingClientRect().height / 2 110 | 111 | joystickMaxDis = joystickHalfWidth * 0.65 112 | 113 | joystickCenterX = joystickPositionX + joystickHalfWidth 114 | joystickCenterY = joystickPositionY + joystickHalfHeight 115 | 116 | joystickDiv.addEventListener("touchmove", onTouchMove, { passive: false }) 117 | joystickDiv.addEventListener("touchend", onTouchEnd) 118 | 119 | window.visualViewport.addEventListener("resize", onWindowResize) 120 | 121 | return () => { 122 | joystickDiv.removeEventListener("touchmove", onTouchMove) 123 | joystickDiv.removeEventListener("touchend", onTouchEnd) 124 | window.visualViewport.removeEventListener("resize", onWindowResize) 125 | } 126 | }) 127 | 128 | return ( 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | ) 139 | } 140 | 141 | const ButtonComponents = ({ buttonNumber = 1, ...props }: EcctrlJoystickProps) => { 142 | /** 143 | * Button component geometries 144 | */ 145 | const buttonLargeBaseGeo = useMemo(() => new THREE.CylinderGeometry(1.1, 1, 0.3, 16), []) 146 | const buttonSmallBaseGeo = useMemo(() => new THREE.CylinderGeometry(0.9, 0.8, 0.3, 16), []) 147 | const buttonTop1Geo = useMemo(() => new THREE.CylinderGeometry(0.9, 0.9, 0.5, 16), []) 148 | const buttonTop2Geo = useMemo(() => new THREE.CylinderGeometry(0.9, 0.9, 0.5, 16), []) 149 | const buttonTop3Geo = useMemo(() => new THREE.CylinderGeometry(0.7, 0.7, 0.5, 16), []) 150 | const buttonTop4Geo = useMemo(() => new THREE.CylinderGeometry(0.7, 0.7, 0.5, 16), []) 151 | const buttonTop5Geo = useMemo(() => new THREE.CylinderGeometry(0.7, 0.7, 0.5, 16), []) 152 | 153 | /** 154 | * Button component materials 155 | */ 156 | const buttonBaseMaterial = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.3 }), []) 157 | const buttonTop1Material = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.5 }), []) 158 | const buttonTop2Material = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.5 }), []) 159 | const buttonTop3Material = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.5 }), []) 160 | const buttonTop4Material = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.5 }), []) 161 | const buttonTop5Material = useMemo(() => new THREE.MeshNormalMaterial({ transparent: true, opacity: 0.5 }), []) 162 | 163 | const buttonDiv: HTMLDivElement = document.querySelector("#ecctrl-button") 164 | 165 | /** 166 | * Animation preset 167 | */ 168 | const [springs, api] = useSpring( 169 | () => ({ 170 | buttonTop1BaseScaleY: 1, 171 | buttonTop1BaseScaleXAndZ: 1, 172 | buttonTop2BaseScaleY: 1, 173 | buttonTop2BaseScaleXAndZ: 1, 174 | buttonTop3BaseScaleY: 1, 175 | buttonTop3BaseScaleXAndZ: 1, 176 | buttonTop4BaseScaleY: 1, 177 | buttonTop4BaseScaleXAndZ: 1, 178 | buttonTop5BaseScaleY: 1, 179 | buttonTop5BaseScaleXAndZ: 1, 180 | config: { 181 | tension: 600 182 | } 183 | }) 184 | ) 185 | 186 | /** 187 | * Button store setup 188 | */ 189 | const pressButton1 = useJoystickControls((state) => state.pressButton1) 190 | const pressButton2 = useJoystickControls((state) => state.pressButton2) 191 | const pressButton3 = useJoystickControls((state) => state.pressButton3) 192 | const pressButton4 = useJoystickControls((state) => state.pressButton4) 193 | const pressButton5 = useJoystickControls((state) => state.pressButton5) 194 | const releaseAllButtons = useJoystickControls((state) => state.releaseAllButtons) 195 | 196 | // Pointer down function 197 | const onPointerDown = (number: number) => { 198 | switch (number) { 199 | case 1: 200 | pressButton1() 201 | api.start({ 202 | buttonTop1BaseScaleY: 0.5, 203 | buttonTop1BaseScaleXAndZ: 1.15, 204 | }) 205 | break; 206 | case 2: 207 | pressButton2() 208 | api.start({ 209 | buttonTop2BaseScaleY: 0.5, 210 | buttonTop2BaseScaleXAndZ: 1.15, 211 | }) 212 | break; 213 | case 3: 214 | pressButton3() 215 | api.start({ 216 | buttonTop3BaseScaleY: 0.5, 217 | buttonTop3BaseScaleXAndZ: 1.15, 218 | }) 219 | break; 220 | case 4: 221 | pressButton4() 222 | api.start({ 223 | buttonTop4BaseScaleY: 0.5, 224 | buttonTop4BaseScaleXAndZ: 1.15, 225 | }) 226 | break; 227 | case 5: 228 | pressButton5() 229 | api.start({ 230 | buttonTop5BaseScaleY: 0.5, 231 | buttonTop5BaseScaleXAndZ: 1.15, 232 | }) 233 | break; 234 | default: 235 | break; 236 | } 237 | } 238 | 239 | // Pointer up function 240 | const onPointerUp = () => { 241 | releaseAllButtons(); 242 | api.start({ 243 | buttonTop1BaseScaleY: 1, 244 | buttonTop1BaseScaleXAndZ: 1, 245 | buttonTop2BaseScaleY: 1, 246 | buttonTop2BaseScaleXAndZ: 1, 247 | buttonTop3BaseScaleY: 1, 248 | buttonTop3BaseScaleXAndZ: 1, 249 | buttonTop4BaseScaleY: 1, 250 | buttonTop4BaseScaleXAndZ: 1, 251 | buttonTop5BaseScaleY: 1, 252 | buttonTop5BaseScaleXAndZ: 1, 253 | }) 254 | } 255 | 256 | useEffect(() => { 257 | buttonDiv.addEventListener("pointerup", onPointerUp) 258 | 259 | return () => { 260 | buttonDiv.removeEventListener("pointerup", onPointerUp) 261 | } 262 | }) 263 | 264 | return ( 265 | 266 | {/* Button 1 */} 267 | {buttonNumber > 0 && 268 | 274 | onPointerDown(1)} /> 275 | 276 | } 277 | {/* Button 2 */} 278 | {buttonNumber > 1 && 279 | 285 | onPointerDown(2)} /> 286 | 287 | } 288 | {/* Button 3 */} 289 | {buttonNumber > 2 && 290 | 296 | onPointerDown(3)} /> 297 | 298 | } 299 | {/* Button 4 */} 300 | {buttonNumber > 3 && 301 | 307 | onPointerDown(4)} /> 308 | 309 | } 310 | {/* Button 5 */} 311 | {buttonNumber > 4 && 312 | 318 | onPointerDown(5)} /> 319 | 320 | } 321 | 322 | ) 323 | } 324 | 325 | export const EcctrlJoystick = forwardRef((props, ref) => { 326 | const joystickWrapperStyle: React.CSSProperties = { 327 | userSelect: "none", 328 | MozUserSelect: "none", 329 | WebkitUserSelect: "none", 330 | msUserSelect: "none", 331 | touchAction: "none", 332 | pointerEvents: "none", 333 | overscrollBehavior: "none", 334 | position: 'fixed', 335 | zIndex: '9999', 336 | height: props.joystickHeightAndWidth || '200px', 337 | width: props.joystickHeightAndWidth || '200px', 338 | left: props.joystickPositionLeft || '0', 339 | bottom: props.joystickPositionBottom || '0', 340 | } 341 | 342 | const buttonWrapperStyle: React.CSSProperties = { 343 | userSelect: "none", 344 | MozUserSelect: "none", 345 | WebkitUserSelect: "none", 346 | msUserSelect: "none", 347 | touchAction: "none", 348 | pointerEvents: "none", 349 | overscrollBehavior: "none", 350 | position: 'fixed', 351 | zIndex: '9999', 352 | height: props.buttonHeightAndWidth || '200px', 353 | width: props.buttonHeightAndWidth || '200px', 354 | right: props.buttonPositionRight || '0', 355 | bottom: props.buttonPositionBottom || '0', 356 | } 357 | 358 | return ( 359 |
360 |
e.preventDefault()}> 361 | 369 | 370 | {props.children} 371 | 372 |
373 | { 374 | props.buttonNumber !== 0 && 375 |
e.preventDefault()}> 376 | 383 | 384 | {props.children} 385 | 386 |
387 | } 388 |
389 | ) 390 | }) 391 | 392 | export type EcctrlJoystickProps = { 393 | // Joystick props 394 | children?: ReactNode; 395 | joystickRunSensitivity?: number; 396 | joystickPositionLeft?: number; 397 | joystickPositionBottom?: number; 398 | joystickHeightAndWidth?: number; 399 | joystickCamZoom?: number; 400 | joystickCamPosition?: [x: number, y: number, z: number]; 401 | joystickBaseProps?: ThreeElements['mesh']; 402 | joystickStickProps?: ThreeElements['mesh']; 403 | joystickHandleProps?: ThreeElements['mesh']; 404 | 405 | // Touch buttons props 406 | buttonNumber?: number; 407 | buttonPositionRight?: number; 408 | buttonPositionBottom?: number; 409 | buttonHeightAndWidth?: number; 410 | buttonCamZoom?: number; 411 | buttonCamPosition?: [x: number, y: number, z: number]; 412 | buttonGroup1Position?: [x: number, y: number, z: number]; 413 | buttonGroup2Position?: [x: number, y: number, z: number]; 414 | buttonGroup3Position?: [x: number, y: number, z: number]; 415 | buttonGroup4Position?: [x: number, y: number, z: number]; 416 | buttonGroup5Position?: [x: number, y: number, z: number]; 417 | buttonLargeBaseProps?: ThreeElements['mesh']; 418 | buttonSmallBaseProps?: ThreeElements['mesh']; 419 | buttonTop1Props?: ThreeElements['mesh']; 420 | buttonTop2Props?: ThreeElements['mesh']; 421 | buttonTop3Props?: ThreeElements['mesh']; 422 | buttonTop4Props?: ThreeElements['mesh']; 423 | buttonTop5Props?: ThreeElements['mesh']; 424 | }; -------------------------------------------------------------------------------- /src/hooks/useFollowCam.tsx: -------------------------------------------------------------------------------- 1 | import { useThree } from "@react-three/fiber"; 2 | // import { useRapier } from "@react-three/rapier"; 3 | import { useEffect, useMemo, useRef } from "react"; 4 | import * as THREE from "three"; 5 | import type { camListenerTargetType } from "../Ecctrl"; 6 | 7 | export const useFollowCam = function ({ 8 | disableFollowCam = false, 9 | disableFollowCamPos = null, 10 | disableFollowCamTarget = null, 11 | camInitDis = -5, 12 | camMaxDis = -7, 13 | camMinDis = -0.7, 14 | camUpLimit = 1.5, // in rad 15 | camLowLimit = -1.3, // in rad 16 | camInitDir = { x: 0, y: 0 }, // in rad 17 | camMoveSpeed = 1, 18 | camZoomSpeed = 1, 19 | camCollisionOffset = 0.7, // percentage 20 | camCollisionSpeedMult = 4, 21 | camListenerTarget = "domElement", 22 | ...props 23 | }: UseFollowCamProps = {}) { 24 | const { scene, camera, gl } = useThree(); 25 | // const { rapier, world } = useRapier(); 26 | 27 | let isMouseDown = false; 28 | let previousTouch1: Touch = null; 29 | let previousTouch2: Touch = null; 30 | 31 | const originZDis = useRef(camInitDis ?? -5) 32 | const pivot = useMemo(() => new THREE.Object3D(), []); 33 | const followCam = useMemo(() => { 34 | const origin = new THREE.Object3D(); 35 | origin.position.set( 36 | 0, 37 | originZDis.current * Math.sin(-camInitDir.x), 38 | originZDis.current * Math.cos(-camInitDir.x) 39 | ); 40 | return origin; 41 | }, []); 42 | 43 | /** Camera collison detect setups */ 44 | let smallestDistance = null; 45 | let cameraDistance = null; 46 | let intersects = null; 47 | // let intersectObjects: THREE.Object3D[] = []; 48 | const intersectObjects = useRef([]) 49 | const cameraRayDir = useMemo(() => new THREE.Vector3(), []); 50 | const cameraRayOrigin = useMemo(() => new THREE.Vector3(), []); 51 | const cameraPosition = useMemo(() => new THREE.Vector3(), []); 52 | const camLerpingPoint = useMemo(() => new THREE.Vector3(), []); 53 | const camRayCast = new THREE.Raycaster( 54 | cameraRayOrigin, 55 | cameraRayDir, 56 | 0, 57 | -camMaxDis 58 | ); 59 | // Rapier ray setup (optional) 60 | // const rayCast = new rapier.Ray(cameraRayOrigin, cameraRayDir); 61 | // let rayLength = null; 62 | // let rayHit = null; 63 | 64 | // Mouse move event 65 | const onDocumentMouseMove = (e: MouseEvent) => { 66 | if (document.pointerLockElement || isMouseDown) { 67 | pivot.rotation.y -= e.movementX * 0.002 * camMoveSpeed; 68 | const vy = followCam.rotation.x + e.movementY * 0.002 * camMoveSpeed; 69 | 70 | cameraDistance = followCam.position.length(); 71 | 72 | if (vy >= camLowLimit && vy <= camUpLimit) { 73 | followCam.rotation.x = vy; 74 | followCam.position.y = -cameraDistance * Math.sin(-vy); 75 | followCam.position.z = -cameraDistance * Math.cos(-vy); 76 | } 77 | } 78 | return false; 79 | }; 80 | 81 | // Mouse scroll event 82 | const onDocumentMouseWheel = (e: Event) => { 83 | const vz = originZDis.current - (e as WheelEvent).deltaY * 0.002 * camZoomSpeed; 84 | const vy = followCam.rotation.x; 85 | 86 | if (vz >= camMaxDis && vz <= camMinDis) { 87 | originZDis.current = vz; 88 | followCam.position.z = originZDis.current * Math.cos(-vy); 89 | followCam.position.y = originZDis.current * Math.sin(-vy); 90 | } 91 | return false; 92 | }; 93 | 94 | /** 95 | * Touch events 96 | */ 97 | // Touch end event 98 | const onTouchEnd = (e: TouchEvent) => { 99 | previousTouch1 = null 100 | previousTouch2 = null 101 | } 102 | 103 | // Touch move event 104 | const onTouchMove = (e: TouchEvent) => { 105 | // prevent swipe to navigate gesture 106 | e.preventDefault(); 107 | e.stopImmediatePropagation(); 108 | 109 | const touch1 = e.targetTouches[0]; 110 | const touch2 = e.targetTouches[1]; 111 | 112 | // One finger touch to rotate camera 113 | if (previousTouch1 && !previousTouch2) { 114 | const touch1MovementX = touch1.pageX - previousTouch1.pageX; 115 | const touch1MovementY = touch1.pageY - previousTouch1.pageY; 116 | 117 | pivot.rotation.y -= touch1MovementX * 0.005 * camMoveSpeed; 118 | const vy = followCam.rotation.x + touch1MovementY * 0.005 * camMoveSpeed; 119 | 120 | cameraDistance = followCam.position.length(); 121 | 122 | if (vy >= camLowLimit && vy <= camUpLimit) { 123 | followCam.rotation.x = vy; 124 | followCam.position.y = -cameraDistance * Math.sin(-vy); 125 | followCam.position.z = -cameraDistance * Math.cos(-vy); 126 | } 127 | } 128 | 129 | // Two fingers touch to zoom in/out camera 130 | if (previousTouch2) { 131 | const prePinchDis = Math.hypot( 132 | previousTouch1.pageX - previousTouch2.pageX, 133 | previousTouch1.pageY - previousTouch2.pageY 134 | ); 135 | const pinchDis = Math.hypot( 136 | e.touches[0].pageX - e.touches[1].pageX, 137 | e.touches[0].pageY - e.touches[1].pageY 138 | ); 139 | 140 | const vz = originZDis.current - (prePinchDis - pinchDis) * 0.01 * camZoomSpeed; 141 | const vy = followCam.rotation.x; 142 | 143 | if (vz >= camMaxDis && vz <= camMinDis) { 144 | originZDis.current = vz; 145 | followCam.position.z = originZDis.current * Math.cos(-vy); 146 | followCam.position.y = originZDis.current * Math.sin(-vy); 147 | } 148 | } 149 | 150 | previousTouch1 = touch1; 151 | previousTouch2 = touch2; 152 | } 153 | 154 | /** 155 | * Gamepad second joystick event 156 | */ 157 | const joystickCamMove = (movementX: number, movementY: number) => { 158 | pivot.rotation.y -= movementX * 0.005 * camMoveSpeed * 5; 159 | const vy = followCam.rotation.x + movementY * 0.005 * camMoveSpeed * 5; 160 | 161 | cameraDistance = followCam.position.length(); 162 | 163 | if (vy >= camLowLimit && vy <= camUpLimit) { 164 | followCam.rotation.x = vy; 165 | followCam.position.y = -cameraDistance * Math.sin(-vy); 166 | followCam.position.z = -cameraDistance * Math.cos(vy); 167 | } 168 | } 169 | 170 | /** 171 | * Custom traverse function 172 | */ 173 | // Prepare intersect objects for camera collision 174 | function customTraverseAdd(object: THREE.Object3D) { 175 | // Chekc if the object's userData camExcludeCollision is true 176 | if (object.userData && object.userData.camExcludeCollision === true) { 177 | return; 178 | } 179 | 180 | // Check if the object is a Mesh, and is visible 181 | if ((object as THREE.Mesh).isMesh && (object as THREE.Mesh).visible) { 182 | intersectObjects.current.push(object); 183 | } 184 | 185 | // Recursively traverse child objects 186 | object.children.forEach((child) => { 187 | customTraverseAdd(child); // Continue the traversal for all child objects 188 | }); 189 | } 190 | // Remove intersect objects from camera collision array 191 | function customTraverseRemove(object: THREE.Object3D) { 192 | intersectObjects.current = intersectObjects.current.filter( 193 | (item) => item.uuid !== object.uuid // Keep all items except the one to remove 194 | ); 195 | 196 | // Recursively traverse child objects 197 | object.children.forEach((child) => { 198 | customTraverseRemove(child); // Continue the traversal for all child objects 199 | }); 200 | } 201 | 202 | /** 203 | * Camera collision detection function 204 | */ 205 | const cameraCollisionDetect = (delta: number) => { 206 | // Update collision detect ray origin and pointing direction 207 | // Which is from pivot point to camera position 208 | cameraRayOrigin.copy(pivot.position); 209 | camera.getWorldPosition(cameraPosition); 210 | cameraRayDir.subVectors(cameraPosition, pivot.position); 211 | // rayLength = cameraRayDir.length(); 212 | 213 | // casting ray hit, if object in between character and camera, 214 | // change the smallestDistance to the ray hit toi 215 | // otherwise the smallestDistance is same as camera original position (originZDis) 216 | intersects = camRayCast.intersectObjects(intersectObjects.current); 217 | if (intersects.length && intersects[0].distance <= -originZDis.current) { 218 | smallestDistance = Math.min(-intersects[0].distance * camCollisionOffset, camMinDis) 219 | } else { 220 | smallestDistance = originZDis.current; 221 | } 222 | 223 | // Rapier ray hit setup (optional) 224 | // rayHit = world.castRay(rayCast, rayLength + 1, true, null, null, character); 225 | // if (rayHit && rayHit.toi && rayHit.toi > originZDis) { 226 | // smallestDistance = -rayHit.toi + 0.5; 227 | // } else if (rayHit == null) { 228 | // smallestDistance = originZDis; 229 | // } 230 | 231 | // Update camera next lerping position, and lerp the camera 232 | camLerpingPoint.set( 233 | followCam.position.x, 234 | smallestDistance * Math.sin(-followCam.rotation.x), 235 | smallestDistance * Math.cos(-followCam.rotation.x) 236 | ); 237 | 238 | followCam.position.lerp(camLerpingPoint, 1 - Math.exp(-camCollisionSpeedMult * delta)); // delta * 2 for rapier ray setup 239 | }; 240 | 241 | useEffect(() => { 242 | // Initialize camera facing direction 243 | pivot.rotation.y = camInitDir.y; 244 | followCam.rotation.x = camInitDir.x 245 | 246 | // Prepare for camera ray intersect objects 247 | scene.children.forEach((child) => customTraverseAdd(child)); 248 | 249 | // Prepare for followCam and pivot point 250 | // disableFollowCam ? followCam.remove(camera) : followCam.add(camera); 251 | pivot.add(followCam); 252 | scene.add(pivot); 253 | 254 | if (camListenerTarget === "domElement") { 255 | gl.domElement.addEventListener("mousedown", () => { isMouseDown = true }); 256 | gl.domElement.addEventListener("mouseup", () => { isMouseDown = false }); 257 | gl.domElement.addEventListener("mousemove", onDocumentMouseMove); 258 | gl.domElement.addEventListener("mousewheel", onDocumentMouseWheel); 259 | // Touch event 260 | gl.domElement.addEventListener("touchend", onTouchEnd); 261 | gl.domElement.addEventListener("touchmove", onTouchMove, { passive: false }); 262 | } else if (camListenerTarget === "document") { 263 | document.addEventListener("mousedown", () => { isMouseDown = true }); 264 | document.addEventListener("mouseup", () => { isMouseDown = false }); 265 | document.addEventListener("mousemove", onDocumentMouseMove); 266 | document.addEventListener("mousewheel", onDocumentMouseWheel); 267 | // Touch event 268 | document.addEventListener("touchend", onTouchEnd); 269 | document.addEventListener("touchmove", onTouchMove, { passive: false }); 270 | } 271 | 272 | return () => { 273 | if (camListenerTarget === "domElement") { 274 | gl.domElement.removeEventListener("mousedown", () => { isMouseDown = true }); 275 | gl.domElement.removeEventListener("mouseup", () => { isMouseDown = false }); 276 | gl.domElement.removeEventListener("mousemove", onDocumentMouseMove); 277 | gl.domElement.removeEventListener("mousewheel", onDocumentMouseWheel); 278 | // Touch event 279 | gl.domElement.removeEventListener("touchend", onTouchEnd); 280 | gl.domElement.removeEventListener("touchmove", onTouchMove); 281 | } else if (camListenerTarget === "document") { 282 | document.removeEventListener("mousedown", () => { isMouseDown = true }); 283 | document.removeEventListener("mouseup", () => { isMouseDown = false }); 284 | document.removeEventListener("mousemove", onDocumentMouseMove); 285 | document.removeEventListener("mousewheel", onDocumentMouseWheel); 286 | // Touch event 287 | document.removeEventListener("touchend", onTouchEnd); 288 | document.removeEventListener("touchmove", onTouchMove); 289 | } 290 | // Remove camera from followCam 291 | // followCam.remove(camera); 292 | }; 293 | }, []) 294 | 295 | // If followCam is disabled set to disableFollowCamPos, target to disableFollowCamTarget 296 | useEffect(() => { 297 | if (disableFollowCam) { 298 | if (disableFollowCamPos) camera.position.set(disableFollowCamPos.x, disableFollowCamPos.y, disableFollowCamPos.z) 299 | if (disableFollowCamTarget) camera.lookAt(new THREE.Vector3(disableFollowCamTarget.x, disableFollowCamTarget.y, disableFollowCamTarget.z)) 300 | } 301 | }, [disableFollowCam]); 302 | 303 | // Handle scene add/remove objects events 304 | useEffect(() => { 305 | const onObjectAdded = (e: any) => customTraverseAdd(e.child) 306 | const onObjectRemoved = (e: any) => customTraverseRemove(e.child) 307 | scene.addEventListener("childadded", onObjectAdded); 308 | scene.addEventListener("childremoved", onObjectRemoved); 309 | return () => { 310 | scene.removeEventListener("childadded", onObjectAdded); 311 | scene.removeEventListener("childremoved", onObjectRemoved); 312 | }; 313 | }, [scene]) 314 | 315 | return { pivot, followCam, cameraCollisionDetect, joystickCamMove }; 316 | }; 317 | 318 | export type UseFollowCamProps = { 319 | disableFollowCam?: boolean; 320 | disableFollowCamPos?: { x: number, y: number, z: number }; 321 | disableFollowCamTarget?: { x: number, y: number, z: number }; 322 | camInitDis?: number; 323 | camMaxDis?: number; 324 | camMinDis?: number; 325 | camUpLimit?: number; 326 | camLowLimit?: number; 327 | camInitDir?: { x: number, y: number }; 328 | camMoveSpeed?: number; 329 | camZoomSpeed?: number; 330 | camCollisionOffset?: number; 331 | camCollisionSpeedMult?: number; 332 | camListenerTarget?: camListenerTargetType; 333 | }; 334 | -------------------------------------------------------------------------------- /src/stores/useGame.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { create } from "zustand"; 3 | import { subscribeWithSelector } from "zustand/middleware"; 4 | 5 | export const useGame = /* @__PURE__ */ create( 6 | /* @__PURE__ */ subscribeWithSelector((set, get) => { 7 | return { 8 | /** 9 | * Point to move point 10 | */ 11 | moveToPoint: null as THREE.Vector3, 12 | 13 | /** 14 | * Character animations state manegement 15 | */ 16 | // Initial animation 17 | curAnimation: null as string, 18 | animationSet: {} as AnimationSet, 19 | 20 | initializeAnimationSet: (animationSet: AnimationSet) => { 21 | set((state) => { 22 | if (Object.keys(state.animationSet).length === 0) { 23 | return { animationSet }; 24 | } 25 | return {}; 26 | }); 27 | }, 28 | 29 | reset: () => { 30 | set((state) => { 31 | return { curAnimation: state.animationSet.idle }; 32 | }); 33 | }, 34 | 35 | idle: () => { 36 | set((state) => { 37 | if (state.curAnimation === state.animationSet.jumpIdle) { 38 | return { curAnimation: state.animationSet.jumpLand }; 39 | } else if ( 40 | state.curAnimation !== state.animationSet.action1 && 41 | state.curAnimation !== state.animationSet.action2 && 42 | state.curAnimation !== state.animationSet.action3 && 43 | state.curAnimation !== state.animationSet.action4 44 | ) { 45 | return { curAnimation: state.animationSet.idle }; 46 | } 47 | return {}; 48 | }); 49 | }, 50 | 51 | walk: () => { 52 | set((state) => { 53 | if (state.curAnimation !== state.animationSet.action4) { 54 | return { curAnimation: state.animationSet.walk }; 55 | } 56 | return {}; 57 | }); 58 | }, 59 | 60 | run: () => { 61 | set((state) => { 62 | if (state.curAnimation !== state.animationSet.action4) { 63 | return { curAnimation: state.animationSet.run }; 64 | } 65 | return {}; 66 | }); 67 | }, 68 | 69 | jump: () => { 70 | set((state) => { 71 | return { curAnimation: state.animationSet.jump }; 72 | }); 73 | }, 74 | 75 | jumpIdle: () => { 76 | set((state) => { 77 | if (state.curAnimation === state.animationSet.jump) { 78 | return { curAnimation: state.animationSet.jumpIdle }; 79 | } 80 | return {}; 81 | }); 82 | }, 83 | 84 | jumpLand: () => { 85 | set((state) => { 86 | if (state.curAnimation === state.animationSet.jumpIdle) { 87 | return { curAnimation: state.animationSet.jumpLand }; 88 | } 89 | return {}; 90 | }); 91 | }, 92 | 93 | fall: () => { 94 | set((state) => { 95 | return { curAnimation: state.animationSet.fall }; 96 | }); 97 | }, 98 | 99 | action1: () => { 100 | set((state) => { 101 | if (state.curAnimation === state.animationSet.idle) { 102 | return { curAnimation: state.animationSet.action1 }; 103 | } 104 | return {}; 105 | }); 106 | }, 107 | 108 | action2: () => { 109 | set((state) => { 110 | if (state.curAnimation === state.animationSet.idle) { 111 | return { curAnimation: state.animationSet.action2 }; 112 | } 113 | return {}; 114 | }); 115 | }, 116 | 117 | action3: () => { 118 | set((state) => { 119 | if (state.curAnimation === state.animationSet.idle) { 120 | return { curAnimation: state.animationSet.action3 }; 121 | } 122 | return {}; 123 | }); 124 | }, 125 | 126 | action4: () => { 127 | set((state) => { 128 | if ( 129 | state.curAnimation === state.animationSet.idle || 130 | state.curAnimation === state.animationSet.walk || 131 | state.curAnimation === state.animationSet.run 132 | ) { 133 | return { curAnimation: state.animationSet.action4 }; 134 | } 135 | return {}; 136 | }); 137 | }, 138 | 139 | /** 140 | * Additional animations 141 | */ 142 | // triggerFunction: ()=>{ 143 | // set((state) => { 144 | // return { curAnimation: state.animationSet.additionalAnimation }; 145 | // }); 146 | // } 147 | 148 | /** 149 | * Set/get point to move point 150 | */ 151 | setMoveToPoint: (point: THREE.Vector3) => { 152 | set(() => { 153 | return { moveToPoint: point }; 154 | }); 155 | }, 156 | 157 | getMoveToPoint: () => { 158 | return { 159 | moveToPoint: get().moveToPoint, 160 | }; 161 | }, 162 | }; 163 | }) 164 | ); 165 | 166 | export type AnimationSet = { 167 | idle?: string; 168 | walk?: string; 169 | run?: string; 170 | jump?: string; 171 | jumpIdle?: string; 172 | jumpLand?: string; 173 | fall?: string; 174 | // Currently support four additional animations 175 | action1?: string; 176 | action2?: string; 177 | action3?: string; 178 | action4?: string; 179 | }; 180 | 181 | type State = { 182 | moveToPoint: THREE.Vector3; 183 | curAnimation: string; 184 | animationSet: AnimationSet; 185 | initializeAnimationSet: (animationSet: AnimationSet) => void; 186 | reset: () => void; 187 | setMoveToPoint: (point: THREE.Vector3) => void; 188 | getMoveToPoint: () => { 189 | moveToPoint: THREE.Vector3; 190 | } 191 | } & { 192 | [key in keyof AnimationSet]: () => void; 193 | }; 194 | -------------------------------------------------------------------------------- /src/stores/useJoystickControls.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { subscribeWithSelector } from "zustand/middleware"; 3 | 4 | export const useJoystickControls = /* @__PURE__ */ create( 5 | /* @__PURE__ */ subscribeWithSelector((set, get) => { 6 | return { 7 | /** 8 | * Joystick state manegement 9 | */ 10 | // Initial joystick/button state 11 | curJoystickDis: 0 as number, 12 | curJoystickAng: 0 as number, 13 | curRunState: false as boolean, 14 | curButton1Pressed: false as boolean, 15 | curButton2Pressed: false as boolean, 16 | curButton3Pressed: false as boolean, 17 | curButton4Pressed: false as boolean, 18 | curButton5Pressed: false as boolean, 19 | 20 | setJoystick: ( 21 | joystickDis: number, 22 | joystickAng: number, 23 | runState: boolean 24 | ) => { 25 | set(() => { 26 | return { 27 | curJoystickDis: joystickDis, 28 | curJoystickAng: joystickAng, 29 | curRunState: runState, 30 | }; 31 | }); 32 | }, 33 | 34 | resetJoystick: () => { 35 | set((state) => { 36 | if (state.curJoystickDis !== 0 || state.curJoystickAng !== 0) { 37 | return { 38 | curJoystickDis: 0, 39 | curJoystickAng: 0, 40 | curRunState: false, 41 | }; 42 | } 43 | return {}; 44 | }); 45 | }, 46 | 47 | pressButton1: () => { 48 | set((state) => { 49 | if (!state.curButton1Pressed) { 50 | return { 51 | curButton1Pressed: true, 52 | }; 53 | } 54 | return {}; 55 | }); 56 | }, 57 | 58 | pressButton2: () => { 59 | set((state) => { 60 | if (!state.curButton2Pressed) { 61 | return { 62 | curButton2Pressed: true, 63 | }; 64 | } 65 | return {}; 66 | }); 67 | }, 68 | 69 | pressButton3: () => { 70 | set((state) => { 71 | if (!state.curButton3Pressed) { 72 | return { 73 | curButton3Pressed: true, 74 | }; 75 | } 76 | return {}; 77 | }); 78 | }, 79 | 80 | pressButton4: () => { 81 | set((state) => { 82 | if (!state.curButton4Pressed) { 83 | return { 84 | curButton4Pressed: true, 85 | }; 86 | } 87 | return {}; 88 | }); 89 | }, 90 | 91 | pressButton5: () => { 92 | set((state) => { 93 | if (!state.curButton5Pressed) { 94 | return { 95 | curButton5Pressed: true, 96 | }; 97 | } 98 | return {}; 99 | }); 100 | }, 101 | 102 | releaseAllButtons: () => { 103 | set((state) => { 104 | if (state.curButton1Pressed) { 105 | return { 106 | curButton1Pressed: false, 107 | }; 108 | } 109 | if (state.curButton2Pressed) { 110 | return { 111 | curButton2Pressed: false, 112 | }; 113 | } 114 | if (state.curButton3Pressed) { 115 | return { 116 | curButton3Pressed: false, 117 | }; 118 | } 119 | if (state.curButton4Pressed) { 120 | return { 121 | curButton4Pressed: false, 122 | }; 123 | } 124 | if (state.curButton5Pressed) { 125 | return { 126 | curButton5Pressed: false, 127 | }; 128 | } 129 | return {}; 130 | }); 131 | }, 132 | 133 | getJoystickValues: () => { 134 | return { 135 | joystickDis: get().curJoystickDis, 136 | joystickAng: get().curJoystickAng, 137 | runState: get().curRunState, 138 | button1Pressed: get().curButton1Pressed, 139 | button2Pressed: get().curButton2Pressed, 140 | button3Pressed: get().curButton3Pressed, 141 | button4Pressed: get().curButton4Pressed, 142 | button5Pressed: get().curButton5Pressed, 143 | }; 144 | }, 145 | }; 146 | }) 147 | ); 148 | 149 | type State = { 150 | curJoystickDis: number; 151 | curJoystickAng: number; 152 | curRunState: boolean; 153 | curButton1Pressed: boolean; 154 | curButton2Pressed: boolean; 155 | curButton3Pressed: boolean; 156 | curButton4Pressed: boolean; 157 | curButton5Pressed: boolean; 158 | setJoystick: ( 159 | joystickDis: number, 160 | joystickAng: number, 161 | runState: boolean 162 | ) => void; 163 | resetJoystick: () => void; 164 | pressButton1: () => void; 165 | pressButton2: () => void; 166 | pressButton3: () => void; 167 | pressButton4: () => void; 168 | pressButton5: () => void; 169 | releaseAllButtons: () => void; 170 | getJoystickValues: () => { 171 | joystickDis: number; 172 | joystickAng: number; 173 | runState: boolean; 174 | button1Pressed: boolean; 175 | button2Pressed: boolean; 176 | button3Pressed: boolean; 177 | button4Pressed: boolean; 178 | button5Pressed: boolean; 179 | }; 180 | }; 181 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "skipDefaultLibCheck": true, 7 | "skipLibCheck": true, 8 | "target": "ESNext", 9 | "verbatimModuleSyntax": true, 10 | "strict": true, 11 | "emitDeclarationOnly": true, 12 | "moduleResolution": "node", 13 | "strictNullChecks": false, 14 | "jsx": "react", 15 | "declaration": true, 16 | "lib": ["es2022", "dom"] 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /vercelVite.config.js: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | 3 | const isCodeSandbox = 4 | "SANDBOX_URL" in process.env || "CODESANDBOX_HOST" in process.env; 5 | 6 | export default { 7 | plugins: [react()], 8 | root: "example/", 9 | publicDir: "../public/", 10 | base: "./", 11 | server: { 12 | host: true, 13 | open: !isCodeSandbox, // Open if it's not a CodeSandbox 14 | }, 15 | build: { 16 | outDir: "./exampleDist", 17 | emptyOutDir: true, 18 | sourcemap: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import * as path from "node:path"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | const isCodeSandbox = 6 | "SANDBOX_URL" in process.env || "CODESANDBOX_HOST" in process.env; 7 | 8 | const dev = defineConfig({ 9 | plugins: [react()], 10 | root: "example/", 11 | publicDir: "../public/", 12 | base: "./", 13 | server: { 14 | host: true, 15 | open: !isCodeSandbox, // Open if it's not a CodeSandbox 16 | }, 17 | }); 18 | 19 | const build = defineConfig({ 20 | publicDir: false, 21 | build: { 22 | minify: false, 23 | sourcemap: true, 24 | target: "es2018", 25 | lib: { 26 | formats: ["cjs", "es"], 27 | entry: "src/Ecctrl.tsx", 28 | fileName: "[name]", 29 | }, 30 | rollupOptions: { 31 | external: (id) => !id.startsWith(".") && !path.isAbsolute(id), 32 | output: { 33 | sourcemapExcludeSources: true, 34 | }, 35 | }, 36 | }, 37 | }); 38 | 39 | export default process.argv[2] ? build : dev; 40 | --------------------------------------------------------------------------------