플레이어 이동 메커니즘과 스탯 연동 구조
목차
1. 시스템 요구 사항
플레이어는 키보드 입력에 따라 즉각적으로 이동하고, 점프하며, 특정 조건에서 비행으로 전환될 수 있어야 한다.
이동 속도는 단순 고정 값이 아니라 PlayerStatus에서 계산된 SP 스탯을 기반으로 결정되어야 하며, 스탯이 변화하면 별도의 재설정 없이 즉시 조작 체감에 반영되어야 한다.
점프는 지면 상태에서만 시작 가능해야 하며, 일정 시간 이상 점프 키를 유지하면 비행 상태로 전환되는 확장 입력 구조를 가져야 한다.
비행은 무제한이 아니라 StatusManager가 관리하는 배터리(CurrentBt) 자원에 의해 제한되어야 하며, 배터리가 소진되면 즉시 비행이 종료되어야 한다.
이동은 물리 기반 Rigidbody가 아닌 CharacterController를 사용해 예측 가능한 조작감을 제공해야 하며, 중력과 점프 가속도는 수동으로 제어되어야 한다.
또한 플레이어는 현재 위치한 맵 구역(집/초원/숲/사막/동굴)을 충돌 이벤트를 통해 실시간으로 판별해야 하며, 이 상태는 이후 사운드나 패널티 시스템과 연동될 수 있어야 한다.
이 시스템의 핵심 전제는, PlayerController는 스탯을 계산하지 않고 단지 읽어서 반영한다는 점이다.
계산 규칙은 PlayerStatus가 소유하고, 런타임 상태는 StatusManager가 관리하며, PlayerController는 그 결과를 실제 움직임으로 변환하는 실행 계층이다.
2. 설계 목표
- 이동 속도는 PlayerStatus.SP 계산 결과를 직접 사용
- 비행은 CurrentBt 조건에 의해 제한
- CharacterController 기반 충돌 포함 이동 처리
- 상태 전이(지상 ↔ 비행 ↔ 낙하)를 명확하게 구분
- 맵 구역 판정은 충돌 이벤트 기반으로 처리
3. 흐름도
[Input(Update)]
▼
[PlayerController.Move()]
├─ StatusManager.PlayerStatus.SP 조회
├─ 점프 입력 처리
├─ 비행 전이(push 누적 기반)
├─ CurrentBt 조건 검사
├─ 중력 적용
▼
[CharacterController.Move()]
▼
[OnControllerColliderHit]
├─ 맵 구역 판정
└─ 착지 처리(GroundCheck)
입력은 Update에서 수집된다.
이동 및 점프/비행 전이는 PlayerController 내부에서 처리된다.
이동 속도는 StatusManager를 통해 PlayerStatus.SP 계산 결과를 읽어오며, CharacterController.Move를 통해 실제 충돌 포함 이동이 수행된다.
충돌 이벤트는 OnControllerColliderHit에서 감지되어 맵 구역 상태를 갱신하고 착지 여부를 결정한다.
이 흐름은 '입력 → 상태 조회 → 이동 계산 → 충돌 처리' 라는 명확한 파이프라인을 가진다.
4. 구현
4.1. 초기화 구조와 런타임 생존 범위
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(this.gameObject);
}
else
Destroy(this.gameObject);
}
private void Start()
{
cc = GetComponent<CharacterController>();
anime = GetComponent<Animator>();
statusManager = StatusManager.instance;
isAtk = false;
}Awake에서 싱글톤 패턴을 사용하는 이유는 플레이어가 게임 전체에서 단 하나만 존재해야 하기 때문이다.
플레이어는 입력, 이동, 전투, 상태 전이의 중심이므로 두 개 이상 존재하면 제어 불가능한 충돌이 발생한다.
DontDestroyOnLoad는 Unity에서 씬이 전환될 때 오브젝트를 파괴하지 않도록 하는 API다.
이 기능은 강력하지만 중복 생성 위험이 있으므로, instance가 이미 존재하면 Destroy로 제거하는 보호 장치를 둔다.
이는 단순 편의가 아니라 상태 일관성을 보장하기 위한 구조적 제약이다.
Start에서는 CharacterController와 Animator를 GetComponent로 캐싱한다.
GetComponent는 내부적으로 탐색 비용이 존재하므로 매 프레임 호출하는 것은 비효율적이다.
따라서 한 번만 참조를 확보해두는 것이 바람직하다.
statusManager는 스탯과 자원(CurrentBt)을 조회하기 위한 연결 지점이다.
PlayerController는 스탯을 계산하지 않는다.
계산은 PlayerStatus가 하고, 상태 관리는 StatusManager가 하며, PlayerController는 그 결과를 실행에 반영하는 계층이다.
4.2. Update 입력 처리
private void Update()
{
push += Time.deltaTime;
if (GameManager.instance.isTeleport) return;
Move();
Attack();
if (!GameManager.instance.isGetLight) return;
OnLight();
}Update는 입력 처리를 담당한다.
Unity 입력 시스템은 프레임 단위로 갱신되므로, Update에서 입력을 읽는 것이 자연스럽다.
FixedUpdate는 물리 틱 기반이기 때문에 입력 처리에 적합하지 않다.
push에 Time.deltaTime을 누적하는 구조는 길게 누르는 입력을 구현하기 위한 시간 기반 로직이다.
deltaTime을 사용함으로써 프레임 수와 무관하게 동일한 시간 기준으로 판정이 이루어진다.
GameManager.instance.isTeleport 조건에서 즉시 return 하는 것은 상태 기반 입력 차단이다.
텔레포트 중에는 이동 및 공격을 수행하면 안 되기 때문에, 조작 흐름을 가장 상위에서 차단한다.
이 구조는 입력 분기 로직을 한 곳에서 관리할 수 있다는 장점이 있다.
4.3. 이동 처리와 SP 스탯 연동
void Move()
{
Jump();
float x = Input.GetAxisRaw("Horizontal");
float z = Input.GetAxisRaw("Vertical");
moveDir.x = x;
moveDir.y = 0;
moveDir.z = z;
moveDir = this.transform.TransformDirection(moveDir);
speed = statusManager.PlayerStatus.SP;
...Move 함수는 이동 파이프라인의 중심이다.
먼저 Jump를 호출하여 상태 전이를 처리하고, 이후 Input.GetAxisRaw로 수평 입력을 받는다.
GetAxisRaw는 보간 없는 즉시 입력을 제공하므로, 가속 기반이 아닌 반응 중심 조작에 적합하다.
TransformDirection은 로컬 좌표 기준 방향을 월드 좌표로 변환한다.
카메라 회전에 따라 플레이어 전방이 달라지므로, 이동 벡터를 플레이어 기준으로 변환하는 과정이 필요하다.
이동 속도는 statusManager.PlayerStatus.SP에서 가져온다.
계산은 PlayerStatus가 담당하고, PlayerController는 계산 결과만 사용한다.
이 구조는 계산과 실행의 책임 분리를 유지한다.
if (x != 0 || z != 0)
{
cc.Move(moveDir * speed * Time.deltaTime);
anime.SetBool("Run", true);
if (isMeadow && groundCheck) StartCoroutine(GrassSound());
if (isForest && groundCheck) StartCoroutine(ForestSound());
if (isDesert && groundCheck) StartCoroutine(SandSound());
if (isCave && groundCheck) StartCoroutine(CaveSound());
}
else
{
anime.SetBool("Run", false);
}
...Move함수의 실제 이동 수행 부분이다.
CharacterController.Move는 충돌을 포함한 이동을 처리한다.
Rigidbody와 달리 힘 기반이 아니라 즉시 위치를 보정한다.
장점은 예측 가능하고 제어가 쉽다는 점이다.
단점은 중력과 점프를 직접 계산해야 한다는 점이다.
Time.deltaTime을 곱하는 것은 프레임 독립성을 보장하기 위함이다.
FPS가 변해도 단위 시간당 이동량은 동일하다.
발소리 관련 코드는 다음 게시글에서 별도로 다룬다.
이 부분은 지역 상태에 따라 사운드를 트리거하는 구조다.
if (isFlying && statusManager.CurrentBt > 0)
{
anime.SetBool("Flying", true);
moveDir += Camera.main.transform.forward * 15;
booster.SetActive(true);
if (statusManager.CurrentBt <= 0) isFlying = false;
}
else
{
yVelocty -= gravity * Time.deltaTime;
moveDir.y = yVelocty;
booster.SetActive(false);
}
cc.Move(moveDir * Time.deltaTime);
}Move함수에서 비행 처리와 배터리 제약을 담당하는 부분이다.
실제 이동 수행 부분에서도 cc.Move가 호출되었으나, 여기에서도 cc.Move가 호출된다.
이는 의도적인 부분으로, 수평 이동과 수직 이동(중력/비행)을 단계적으로 계산한 뒤 최종 이동을 적용하기 위함이다.
비행은 단순 상태가 아니라 CurrentBt가 0보다 클 때만 유지된다.
PlayerController는 배터리를 감소시키지 않는다.
자원 소모는 StatusManager가 담당한다.
이 분리는 매우 중요하다.
Camera.main.transform.forward를 더하는 것은 카메라 바라보는 방향으로 추진력을 주기 위함이다.
단점은 Camera.main 호출 비용이 있다는 점이다.
성능이 중요한 상황이라면 Camera 참조를 캐싱하는 것이 바람직하다.
CharacterController는 자동 중력이 없기 때문에 yVelocity를 수동으로 감소시킨다.
이는 제어 가능한 조작감을 위한 선택이다.
4.4. 점프와 상태 전이
void Jump()
{
if (groundCheck && Input.GetKeyDown(KeyCode.Space))
{
anime.SetBool("Jump", true);
groundCheck = false;
yVelocty = jumpForce;
push = 0;
}
if (!groundCheck && Input.GetKey(KeyCode.Space) )
{
if (push >= 0.5f)
{
isFlying = true;
anime.SetBool("Jump", false);
BoolSetting(false, false, false, false, false);
}
}
else if ((!groundCheck && Input.GetKeyUp(KeyCode.Space)))
{
isFlying = false;
}
}점프는 지면 상태(groundCheck가 true일 때)만 시작된다.
이는 이중 점프 방지를 위한 단순하고 안정적인 제어다.
push 기반 전이는 별도의 FSM 없이 입력 지속 시간으로 상태를 확장하는 방식이다.
짧게 누르면 점프, 길게 누르면 비행이 된다.
이 방식은 상태 머신을 도입하지 않고도 자연스러운 확장 입력을 구현한다.
4.5. 충돌 기반 맵 판정
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (isFlying)
{
isFlying = false;
GroundCheck();
}
if (hit.gameObject.layer == LayerMask.NameToLayer("Ground")) //집(마을)
{
mapIndex = 4;
BoolSetting(true, false, false, false, false);
GroundCheck();
}
...
// Forest, Meadow, Desert, Cave 모두 동일 구조
if (hit.collider.tag == "die")
{
statusManager.CurrentHp = 0;
}
}OnControllerColliderHit는 CharacterController 전용 충돌 이벤트다.
Rigidbody와는 다르게 Move 호출 중 충돌 정보를 전달한다.
LayerMask.NameToLayer는 문자열을 레이어 인덱스로 변환한다.
문자열 기반이지만 Unity 레이어 시스템과 직접 연결되므로 태그보다 구조적으로 안정적이다.
현재는 명확성을 위해 if 체인을 사용했지만, 구역이 확장될 경우 enum 기반 구조로 개선 가능하다.
void BoolSetting(bool house, bool meadow, bool forest, bool desert, bool cave)
{
isHouse = house;
isMeadow = meadow;
isForest = forest;
isDesert = desert;
isCave = cave;
} BoolSetting은 현재 지역 상태를 단일 함수로 갱신한다.
이 함수는 단순 대입이지만, 지역 상태 변경을 하나의 진입점으로 모아 유지보수성을 높인다.
void GroundCheck()
{
groundCheck = true;
anime.SetBool("Jump", false);
anime.SetBool("Flying", false);
}GroundCheck는 착지 처리와 애니메이션 동기화를 담당한다.
충돌과 동시에 애니메이션 상태를 정리함으로써 시각적 상태와 실제 물리 상태를 일치시킨다.
5. 개발 의도
이 시스템의 핵심은 스탯이 실제 조작 체감으로 직결되는 구조다.
SP는 이동 속도로 바로 연결되고, 배터리는 비행 가능 여부를 결정하는 제약 조건이 된다.
CharacterController를 선택한 이유는 예측 가능하고 제어하기 쉬운 조작감을 위해서다.
Rigidbody 기반 물리는 자연스럽지만, 액션 중심 게임에서는 반응성과 제어성이 더 중요하다고 판단했다.
점프와 비행을 하나의 입력 흐름으로 묶어 상태 전이를 구성함으로써 복잡한 FSM 없이도 단계적 확장 조작을 구현했다.
또한 PlayerController는 계산을 하지 않는다.
계산은 PlayerStatus가 담당하고, 자원 관리는 StatusManager가 담당한다.
PlayerController는 실행 계층으로서 결과를 실제 움직임으로 변환한다.
이 구조는 이후 공격 시스템, 상태 머신, 애니메이션 계층 확장과 자연스럽게 통합될 수 있는 기반이 된다.
