플레이어 이동 상태 기준 설계
1. 시스템 요구 사항
플레이어 이동 시스템은 단순히 캐릭터를 움직이는 기능이 아니라,
'입력 → 상태 → 애니메이션 → 실제 이동 결과' 가 하나의 기준으로 일관되게 연결되어야 하는 핵심 시스템이다.
특히 3D RPG 환경에서는 카메라 방향과 입력 방향이 어긋나 조작감이 불안정해지거나, 점프·착지 시 이동 속도가 비정상적으로 변하는 문제가 자주 발생한다.
또한 이동 속도와 애니메이션이 서로 다른 기준으로 동작할 경우, 캐릭터가 미끄러져 보이거나 조작감이 어색해지는 문제가 생긴다.
이를 해결하기 위해 이동 시스템은 수평 이동과 수직 이동을 명확히 분리하고, 입력 방향을 플레이어 로컬 기준에서 월드 기준으로 변환해 처리할 필요가 있다.
또한 이동 속도, 점프력, 중력과 같은 밸런스 값은 코드에 하드코딩하지 않고, 플레이어 상태 데이터(PlayerStatus)를 기준으로 관리할 수 있어야 한다.
이로 인해 이후 장비, 강화, 상태 이상 시스템과 자연스럽게 연동 가능한 구조를 목표로 했다.
2. 설계 목표
- 입력 방향을 로컬 기준에서 월드 기준으로 변환해 조작 일관성을 확보할 것
- 수평 이동과 수직 이동(점프/중력)을 명확히 분리할 것
- 이동 속도와 중력을 분리해 점프·낙하 시 비정상적인 가속을 방지할 것
- 이동 상태(걷기/달리기/점프)를 애니메이션과 직접 연결할 것
- 이동 관련 밸런스 값을 PlayerStatus 데이터 기준으로 관리할 것
3. 흐름도

이 흐름에서 중요한 점은, 수직 속도(yVelocity)가 수평 속도와 별개로 누적되고 마지막에 합산된다는 점이다.
이 구조 덕분에 점프/낙하 상황에서도 수평 이동은 입력 기반 속도로만 유지되고, 중력 영향은 수직으로만 적용된다.
4. 구현
4.1. 이동 로직 호출 구조
private void Update()
{
HandleMovement();
}
플레이어의 이동 로직은 Update()에서 HandleMovement()를 호출하는 단일 흐름으로 구성했다.
Update 함수 자체에서는 이동에 필요한 조건 판단이나 세부 로직을 직접 처리하지 않고, 오직 이 프레임에 이동 처리를 할 것인가라는 진입 역할만 담당하도록 제한했다.
이렇게 구성한 이유는, 이동과 관련된 모든 로직을 하나의 함수로 수렴시켜 이동 처리의 책임 범위를 명확히 하기 위함이다.
이동, 점프, 중력, 애니메이션 전환 등은 모두 HandleMovement() 내부에서만 관리되며, Update에서는 해당 로직이 언제 호출되는지만 결정한다.
이 구조를 통해 Update가 비대해지는 것을 방지하고, 이동 로직의 수정이나 디버깅이 필요할 때도 HandleMovement() 하나만 추적하면 되도록 흐름을 단순화했다.
또한 사망 상태, 경직 상태 등 이동을 차단해야 하는 조건은 Update 단계가 아닌 별도의 상태 제어 게시글에서 다루도록 분리했다.
이를 통해 이동 로직이 언제 실행되지 않는가에 대한 책임을 이 게시글에서 의도적으로 제외했다.
4.2. 이동 처리
void HandleMovement()
{
HandleJump();
float x = Input.GetAxisRaw("Horizontal");
float z = Input.GetAxisRaw("Vertical");
// 입력 → 로컬 → 월드 (수평 성분만)
Vector3 inputDir = new Vector3(x, 0f, z);
Vector3 planarDir = transform.TransformDirection(inputDir).normalized;
// 속도 선택
walkSpeed = playerManager.PlayerStatus.MoveSpeed;
runSpeed = playerManager.PlayerStatus.RunSpeed;
bool isRunning = Input.GetKey(KeyCode.LeftShift);
float speed = (x != 0 || z != 0) ? (isRunning ? runSpeed : walkSpeed) : 0f;
// 애니메이션
anim.SetBool(AnimHash.Walk, speed > 0f && !isRunning);
anim.SetBool(AnimHash.Run, speed > 0f && isRunning);
// 중력 갱신 (아래로 가속)
yVelocity -= gravity * Time.deltaTime;
// 수평과 수직을 분리해서 합산
Vector3 velocity = planarDir * speed; // 수평 속도
velocity.y = yVelocity; // 수직 속도
// 최종 이동
cc.Move(velocity * Time.deltaTime);
}이동 로직에서 가장 먼저 분리한 것은 수평 이동과 수직 이동(점프/낙하)이었다.
HandleMovement의 시작에서 HandleJump를 먼저 호출하는 이유는, 점프 입력은 수직 속도(yVelocity)를 바꾸는 이벤트이고 그 결과가 같은 프레임의 최종 velocity에 반영되어야 하기 때문이다.
즉, 점프가 눌린 프레임에 yVelocity가 갱신되지 않으면, 점프 입력이 한 프레임 늦게 반영된 것처럼 느껴질 수 있다.
그 다음 입력은 Horizontal/Vertical을 Raw로 가져오는데, 이는 이동의 즉각적인 반응성을 위해 가속/감속 보간이 들어간 GetAxis 대신 Raw를 선택한 것이다.
이 입력값 x/z는 곧바로 월드 방향으로 쓰지 않고, inputDir를 플레이어 로컬 기준 입력 벡터로 만든 뒤 TransformDirection으로 월드 기준으로 변환했다.
이렇게 하면 카메라 회전이나 플레이어 회전에 따라 이동 방향이 자연스럽게 따라가고, 조작감이 항상 내가 바라보는 방향으로 이동한다는 느낌으로 통일된다.
마지막에 normalized를 적용한 이유는 대각선 입력에서 벡터 크기가 커져 이동 속도가 빨라지는 것을 방지하기 위함이다.
속도 선택은 PlayerStatus에서 가져오도록 설계했다.

이 구조는 이동 밸런싱(걷기/달리기 속도)을 코드가 아니라 데이터에서 조절할 수 있게 만든다.
또한 입력이 없을 때 speed를 0으로 확정해 이동 입력이 없는데도 애니메이션이 켜지는 상태를 구조적으로 막는다.
달리기 여부는 Shift 입력으로 결정하고, speed는 현재 입력 상태와 달리기 상태에 의해 단일 값으로 결정되기 때문에, 이후 로직(애니메이션 갱신, 이동 벡터 계산)은 speed만 바라보면 된다.
애니메이션은 실제 이동 결과(예: cc.velocity)를 읽는 방식이 아니라, 현재 프레임에 선택된 이동 상태를 기준으로 갱신했다.
즉, speed와 isRunning만으로 Walk/Run을 결정한다.
이렇게 하면 애니메이션이 물리 결과에 뒤늦게 끌려가지 않고, '입력 → 판단 → 표현' 이 같은 기준으로 동기화된다.
이 구조의 장점은 애니메이션이 이동 시스템에 종속된 것이 아니라, 이동 시스템과 동일한 판단 기준을 공유하는 표현 계층으로 남는다는 점이다.
중력은 yVelocity에 누적 적용한다.
여기서 핵심은 중력이 speed에 곱해지지 않는다는 점이다.
중력은 수직 성분에만 반영되어야 하며, 이를 위해 velocity를 만들 때 수평 성분(planarDir*speed)과 수직 성분(yVelocity)을 마지막에 합산한다.
이 방식은 점프 중에도 수평 이동이 입력 기반 속도로만 유지되게 만들어, 낙하하면서 수평 이동이 빨라지거나 느려지는 이상 현상을 방지한다.
마지막으로 CharacterController.Move는 한 프레임에 한 번만 호출되며, 합산된 velocity에 Time.deltaTime을 곱해 프레임 독립적인 이동을 보장한다.
CharacterController를 사용한 이유는 Rigidbody 기반 물리 캐릭터가 필요한 게임이 아니라, 입력 기반으로 안정적으로 이동하고 지형 충돌을 처리하는 캐릭터가 필요했기 때문이다.
CharacterController는 이동 로직이 얼마나/어디로 이동할지에 집중하게 해주고, 충돌/경사 처리/접지 판정 등은 컴포넌트가 제공하는 기반 기능을 활용할 수 있다는 장점이 있다.
따라서 이동 시스템 구현은 더 단순해지고, 디버깅 포인트도 명확해진다.
4.3. 점프 및 접지 기반 수직 속도 제어
void HandleJump()
{
if (!cc.isGrounded) return;
// 접지 시 살짝 붙여주기(경계에서 붕뜸 방지)
if (yVelocity < 0f) yVelocity = -2f;
if (Input.GetKey(KeyCode.Space))
{
yVelocity = jumpForce;
anim.SetBool(AnimHash.Jump, true);
}
else if (anim.GetBool(AnimHash.Jump))
{
anim.SetBool(AnimHash.Jump, false);
}
}점프는 공중에서 다시 점프가 가능한가를 먼저 차단해야 했다.
접지 중이 아닐 때도 점프 입력을 받아버리면 사실상 무한 점프가 가능해지고, 점프 시스템이 설계한 제약이 무너진다.
그래서 HandleJump의 첫 줄에서 isGrounded가 false면 바로 return 하도록 설계해, 점프는 땅에 닿아 있을 때만 실행되는 구조를 강제했다.
접지 상태에서는 yVelocity가 음수로 크게 내려가 있을 수 있는데, 이 상태를 그대로 두면 접지 경계에서 캐릭터가 순간적으로 붕 뜨거나, 접지 판정이 흔들리는 프레임이 생길 수 있다.
그래서 접지 중이고 yVelocity가 음수라면 -2 정도의 작은 음수로 고정해 바닥에 살짝 붙는 느낌을 만들었다.
이 처리는 CharacterController 기반 이동에서 흔히 쓰는 안정화 패턴이고, 접지 경계에서 발생하는 미세한 떨림을 줄여준다.
점프 입력이 들어오면 yVelocity에 jumpForce를 대입해 수직 초기 속도를 만든다.
이때 점프는 단순히 y좌표를 올리는 것이 아니라 수직 속도의 시작값을 설정하는 것이기 때문에, 이후 중력 누적에 의해 자연스럽게 '최고점 → 낙하' 로 이어진다.
점프 애니메이션은 Jump Bool로 직접 연결해 시각적 일관성을 유지했고, Space가 눌리지 않는 프레임에서는 Jump Bool을 다시 false로 되돌려 점프 상태가 불필요하게 유지되지 않도록 했다.
isGrounded 판정은 정밀한 물리 판정에 비해 한계가 있을 수 있지만, 이 프로젝트에서는 정교한 플랫폼 점프 게임이 아니라 RPG 이동이 핵심이므로, isGrounded를 기반으로 한 접지 판정으로도 충분하다고 판단했다.
만약 이후 계단/경사에서 점프가 씹힘 같은 문제가 생기면, Raycast 기반의 보조 접지 판정을 추가하는 방식으로 확장할 수 있도록 구조를 단순하게 유지했다.
5. 개발 의도
이 게시글의 의도는 플레이어가 움직인다가 아니라, 이동 시스템이 유지보수 가능한 구조로 정리되어 있다는 것을 보여주는 것이다.
이를 위해 Update는 진입점으로만 두고, 이동의 핵심 계산은 HandleMovement로 수렴시켜 책임 범위를 명확히 했다.
또한 수평 이동과 수직 이동을 분리해 점프/낙하 상황에서도 이동 속도가 깨지지 않도록 했고, 이동 속도는 PlayerStatus에서 가져오도록 분리해 밸런싱을 코드 밖으로 옮겼다.
결과적으로 이 구조는 이후 확장에도 안정적이다.
예를 들어 경직 상태에서는 이동 차단, 스킬 캐스팅 중 이동 제한, 사망 상태에서는 모든 입력 차단 같은 기능이 붙더라도, 이동 계산 자체는 건드리지 않고 Update 단계의 상태 제어에서 호출 여부만 조절하면 된다.
즉, 이동 시스템은 어떻게 움직이는가에 집중하고, 상태 시스템은 언제 움직이지 않는가를 책임지는 구조로 분리되어 있다.
