본문 바로가기
Unity 궁둥이 쪼물쪼물

002. CharacterController로 플레이어 움직임 구현

by daco2020 2026. 4. 3.

Rigidbody 기반 이동을 CharacterController + New Input System으로 바꾸고,
달리기와 이단 점프까지 구현한 과정을 기록합니다.

 

 

1. Rigidbody 제거 → CharacterController 추가

왜 바꾸나?

  • Rigidbody : 물리 엔진이 움직임을 알아서 제어 (중력, 충돌 반응 자동)
  • CharacterController : 물리 없이 내가 직접 코드로 움직임을 제어

CharacterController는 예측 가능한 움직임을 만들 수 있다. 대신 중력 같은 건 직접 코딩해야 한다.

 

 

Inspector에서 한 작업

  1. Player 오브젝트에서 Rigidbody 컴포넌트 제거
  2. Capsule Collider 컴포넌트 제거 (CharacterController가 자체 콜라이더를 포함하므로)
  3. CharacterController 컴포넌트 추가

 

 

CharacterController 설정값

  • Center: X 0, Y 1, Z 0
  • Radius: 0.5
  • Height: 2

Center Y = 1 인 이유

CharacterController의 캡슐은 Center를 중심으로 위아래로 펼쳐진다.

Height = 2, Center Y = 1 이면:
  캡슐 꼭대기: Y = 2
  캡슐 중심:   Y = 1  ← Center
  캡슐 바닥:   Y = 0  ← 캐릭터 발 위치

Height = 2, Center Y = 0 이면:
  캡슐 꼭대기: Y = 1
  캡슐 중심:   Y = 0
  캡슐 바닥:   Y = -1  ← 바닥 아래로 묻힘!

 

즉, 높이의 절반(2 ÷ 2 = 1)을 Y에 넣으면 캡슐 바닥이 캐릭터 발 위치에 맞는다.

 

 

 

2. MoveControl.cs 코드 수정

핵심 변경 사항

항목기준 변경 전 변경 후
이동 방식 Rigidbody CharacterController
입력 읽기 Input.GetKey(KeyCode.W) _moveAction.ReadValue<Vector2>()
이동 함수 transform.Translate() characterController.Move()
중력 Rigidbody가 자동 처리 직접 계산
착지 판정 Physics.CheckSphere() characterController.isGrounded

 

코드 자체는 이 글에 기재하지는 않겠다.

대산 Unity 스크립트에서 코드를 작성할 때 사용하는 규칙이나 패턴을 정리해 보았다.

 

1) Unity 스크립트 생명주기

Unity 는 스크립트의 함수를 정해진 순서로 자동 호출한다.

Awake()      ← 가장 먼저 1번 (재료 준비)
  ↓
OnEnable()   ← 활성화될 때 (기계 전원 켜기)
  ↓
Start()      ← 첫 Update 직전에 1번 (초기 설정)
  ↓
Update()     ← 매 프레임 반복 (1초에 60~120번)
  ↓
OnDisable()  ← 비활성화될 때 (기계 전원 끄기)

 

이번 과제를 코드에서 적용해 본다면,

Awake()    → InputActions 객체 생성, Move/Jump 액션 가져오기
OnEnable() → 입력 감지 시작
Start()    → CharacterController 연결, 상태 초기화
Update()   → 매 프레임 입력 읽기, 이동, 점프, 중력 처리
OnDisable()→ 입력 감지 중지

 

 

2) 읽기 → 계산 → 적용

게임에서 움직임을 만드는 코드는 거의 항상 이 3단계다.

// [읽기] WASD 입력을 2D 벡터로 읽는다
Vector2 input = _moveAction.ReadValue<Vector2>();

// [계산] 3D 이동 방향을 만든다
Vector3 moveDirection = transform.forward * input.y + transform.right * input.x;

// [적용] 캐릭터를 실제로 움직인다
characterController.Move(finalMove * Time.deltaTime);

 

 

3) FSM (Finite State Machine, 유한 상태 기계)

"캐릭터가 지금 뭘 하고 있는지"를 상태(State)로 관리하는 패턴이다.

┌──────┐   Space 누름   ┌──────┐
│ Idle │ ──────────────→ │ Jump │
│(대기) │                │(점프) │
│      │ ←────────────── │      │
└──────┘   착지(landed)  └──────┘

 

FSM은 3단계로 동작한다.

  1. 상태 업데이트 (Update) - 변경 사항에 따라 상태를 바꾼다.
  2. 전환 조건 판단 (Transition Check) - 바뀐 상태가 조건을 충족하는지 판단한다.
  3. 상태 전환 처리 (Transition) - 조건을 충족하면 정의된 로직을 실행한다.

 

4) Time.deltaTime 보정

컴퓨터마다 프레임 속도가 다르기 때문에 이동량에 Time.deltaTime을 곱해서 보정한다.

좋은 컴퓨터 (120fps): 1 × 0.0083 × 120번 = 약 1.0 / 초
느린 컴퓨터 (30fps):  1 × 0.033  × 30번  = 약 1.0 / 초
→ 어떤 컴퓨터든 1초에 같은 거리를 이동!

 

Update() 안에서 이동이나 시간 경과를 다루면 Time.deltaTime을 곱한다.

반면, FixedUpdate() 를 사용하는 경우 Time.fixedDeltaTime을 적용해야 한다.

 

쉽게 말해, Update()는 “화면 그리는 타이밍” 이고,
FixedUpdate()는 “물리 계산 타이밍” 이라고 볼 수 있다.

 

 

5) 중력 직접 구현

CharacterController는 물리 엔진이 없어서 중력을 직접 코딩한다.

// 물리 공식: v = v + g × t
_verticalVelocity += gravity * Time.deltaTime;  // gravity = -9.81

 

점프 + 중력의 흐름:

Space 누름 → _verticalVelocity = 5 (위로 솟음)
  ↓ 매 프레임 중력이 속도를 줄임
올라가는 중: 5 → 4.8 → 4.6 → ... → 0 (꼭대기)
  ↓ 속도가 음수로 바뀜
떨어지는 중: -0.2 → -0.4 → ... (점점 빨라짐)
  ↓
착지! → 리셋

 

 

 

3. 카메라 끊김 문제 해결

처음 테스트했을 때 마우스 카메라 회전이 느리고 끊겼다.

원인은 CameraControl.cs의 FixedUpdate()에서 마우스를 처리하고 있었다.

  • FixedUpdate() = 물리 계산용, 0.02초마다 실행되는 물리 업데이트
  • Update() = 매 프레임, 1초에 60~120번

카메라처럼 부드러워야 하는 건 Update()에서 해야 한다. → FixedUpdate를 Update로 한 줄만 바꿔서 해결했다!

 

더 알아보니 아래처럼 사용하는 것이 정석이라고 한다.

 

  • 입력 & 카메라 → Update
  • CharacterController 이동 → Update
  • Rigidbody 물리 → FixedUpdate

 

Rigidbody 외에는 물리가 아니기 때문에 FixedUpdate 를 사용할 필요가 없는 것!

 

참고) FixedUpdate의 주기는 Edit > Project Settings > Time > Fixed Timestep 에서 바꿀 수 있다 (기본 0.02초 = 50fps).

 

 

 

4. 달리기 구현

.triggered vs .IsPressed()

_jumpAction.triggered       // "이번 프레임에 눌렸나?" → 한 번만 감지
_sprintAction.IsPressed()   // "지금 누르고 있나?" → 누르는 동안 계속 감지

 

  • triggered = 눌렀을 때 한 번만 켜짐
  • IsPressed() = 누르고 있는 동안만 켜짐

 

배수(multiplier) 패턴

float currentSpeed = moveSpeed;               // 기본 속도 5
if (_sprintAction.IsPressed())
    currentSpeed = moveSpeed * sprintMultiplier;  // 5 × 1.5 = 7.5

핵심: 원본 값(moveSpeed)은 절대 건드리지 않고, 임시 변수에 계산한다.
직접 바꾸면 Shift 누를 때마다 5 → 7.5 → 11.25 → ... 무한 가속되기 때문...

 

 

 

5. 이단 점프 구현 

카운터(counter) 패턴

"몇 번 했는지 세서 제한을 두는" 패턴

private int _jumpCount;           // 현재 점프 횟수
int maxJumpCount = 2;             // 최대 2번

// 점프할 때: _jumpCount++
// 가능 조건: _jumpCount < maxJumpCount
// 착지하면: _jumpCount = 0 (초기화)

 

 

구현 흐름

바닥 (_jumpCount = 0)
  → Space → 1번째 점프 (_jumpCount = 1)
  → 공중 Space → 2번째 점프 (_jumpCount = 2)
  → 공중 Space → 2 >= 2 → 점프 안 됨!
  → 착지 → _jumpCount = 0 → 다시 가능

FSM에서 같은 상태로 재진입

이단 점프는 Jump → Jump 전환이다. nextState = State.Jump을 설정하면
2단계 초기화가 다시 실행되면서 위로 다시 솟구친다.

 

 

 

6. 시작 시 플레이어가 떨어지는 문제

구현을 마치고 Play를 눌렀더니 시작하자마자 플레이어가 바닥 아래로 떨어졌다????

 

왜 그런가 살펴보니 Player의 Position Y가 1로 설정되어 있었다.
CharacterController의 Center Y=1, Height=2이므로 Position Y 는 0으로 설정해야 바닥 높이를 맞출 수 있다.

 

발이 지면에 닿는 지점을 수식으로 쓰면 [Position Y + Center Y − Height/2] 인데, 여기서 Center Y(=1) − Height/2(=1) = 0이므로  Position Y를 0으로 설정해야 지면에 닿는 지점(=0)이 나올 수 있다.

 

단, 위 수식은 “Center가 정확히 Height/2일 때만” Center 값을 Height의 중간 지점으로 지정했을 때만 성립한다.

 

 

다음에 계속...