플레이어 동작 시스템

플레이어 동작 시스템은 Character Controller를 이용해 이동과 충돌을 처리하고, Animator를 통해 이동·점프·공격·비행 등의 애니메이션을 제어하는 시스템이다.

카메라 회전과 플레이어 방향을 연동하여 3인칭 시점 이동을 구현하였으며, Animator Layer와 Avatar Mask를 이용해 상체 공격 애니메이션과 하체 이동 애니메이션이 동시에 자연스럽게 동작하도록 구성하였다.

목차

1. 유니티 구현

     1.1. 플레이어 오브젝트 구성

    1.2. Animator

    1.3. Character Controller

2. 전체 코드

유니티 구현

1.1. 플레이어 오브젝트 구성

플레이어 오브젝트는 Animator와 Character Controller 컴포넌트로 구성하였다.

Animator는 플레이어의 이동, 점프, 공격, 비행 등의 애니메이션 상태를 제어하는 역할을 한다.

Animator Controller를 이용하여 상태 기반(State Machine) 구조로 애니메이션이 전환되도록 구성하였다.

Character Controller는 플레이어의 이동과 충돌 처리를 담당하는 컴포넌트이다

일반적인 Rigidbody 기반 물리 이동 대신 Character Controller를 사용하여 캐릭터 이동을 직접 제어하고, 안정적인 충돌 처리가 이루어지도록 설계하였다.

1.2. Animator

플레이어 애니메이션은 Unity의 Animator 시스템을 이용하여 상태 기반(State Machine) 구조로 구성하였다.

Animator에는 다음과 같은 파라미터가 존재한다.

각 파라미터는 플레이어의 상태에 따라 활성화되며, Animator 상태 전환 조건으로 사용된다.

플레이어의 이동 애니메이션과 공격 애니메이션이 동시에 동작하도록 Animator Layer를 추가하였다

.Animator는 Base Layer와 Attack Layer 두 개의 Layer 구조로 구성하였다.

Base Layer는 Idle, Run, Jump 등 플레이어의 기본 이동 애니메이션을 담당한다.

Attack Layer는 공격 애니메이션을 담당하며, Avatar Mask를 이용하여 상체에만 애니메이션이 적용되도록 설정하였다.

Attack Layer에는 Upper Avatar Mask를 적용하였다.

Avatar Mask는 특정 신체 부위에만 애니메이션을 적용할 수 있도록 하는 기능이다.

Upper Avatar Mask에서는 상체와 팔 부분만 활성화하고 하체는 비활성화하였다.

이를 통해 플레이어가 이동하는 동안에도 공격 애니메이션이 자연스럽게 재생되도록 구성하였다.

이 방식은 하체는 이동 애니메이션을 유지하면서 상체에만 공격 애니메이션을 적용할 수 있도록 하여, 캐릭터 애니메이션이 자연스럽게 유지되면서 동시에 여러 동작을 수행할 수 있도록 한다.

1.3. Character Controller

Character Controller를 사용하여 물리 연산의 영향을 최소화하고 캐릭터 이동을 직접 제어할 수 있도록 설계하였다.

Character Controller는 Rigidbody 기반 물리 시스템과 달리 직접적인 이동 제어가 가능하며 캐릭터 이동에 적합한 충돌 처리를 제공하는 컴포넌트이다.

플레이어 이동 시 안정적인 충돌 처리가 이루어지도록 Character Controller의 주요 값을 다음과 같이 설정하였다.

- Slope Limit : 캐릭터가 이동 가능한 최대 경사 각도를 설정한다.
- Step Offset : 계단이나 작은 장애물을 자동으로 올라갈 수 있는 높이를 설정한다.
- Radius : 캐릭터 충돌 영역의 반지름을 설정한다.
- Height : 캐릭터 충돌 영역의 전체 높이를 설정한다.
- Center : 충돌 영역의 중심 위치를 설정한다.

이러한 설정을 통해 플레이어가 다양한 지형 위에서도 자연스럽게 이동할 수 있도록 구성하였다.

전체 코드

using System.Collections;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    public static PlayerController instance;

    StatusManager statusManager;        // 플레이어 스탯 관리 클래스

    [SerializeField] Transform camPos;  // 카메라 위치

    // 필요한 컴포넌트
    Animator anime;                 // 움직임에 따른 애니메이션 관리
    CharacterController cc;         // 플레이어 충돌 및 움직임 관리

    // 플레이어 움직임 관련 변수
    float speed;                    // 속도
    float jumpForce = 10.0f;        // 점프력
    float gravity = 10.0f;          // 중력 가속도
    float yVelocty = 0;             // 점프 및 중력의 영향 관리
    bool groundCheck;               // 땅 체크
    Vector3 moveDir = Vector3.zero; //이동 방향

    // 플레이어 무기 배열
    public Equipment[] equipments = new Equipment[3];

    // 헤드라이트 관련 변수
    public GameObject headLight; //헤드라이트 오브젝트
    bool isLight = false;        //라이트 On/Off 여부 변수

    // 맵 관련 변수
    public int mapIndex; // 맵 인덱스 => 맵 ui에서 사용됨
    [HideInInspector] public bool isHouse = false;
    [HideInInspector] public bool isMeadow = false;
    [HideInInspector] public bool isForest = false;
    [HideInInspector] public bool isDesert = false;
    [HideInInspector] public bool isCave = false;

    // 소리 무한 안되도록 하는 로직
    bool isRunning = false;

    // 비행 관련 변수
    public float push;
    public GameObject booster;
    public bool isFlying;

    // 공격 진행 변수
    bool isAttack = false;  // 무한 공격 방지
    public bool isAtk;      // 공격 진행 변수

    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;
    }

    private void LateUpdate()
    {
        // 카메라 위치를 camPos의 위치로 설정
        Camera.main.transform.position = camPos.transform.position;
        this.transform.rotation = Camera.main.transform.rotation;
    }

    private void Update()
    {
        push += Time.deltaTime;

        if (GameManager.instance.isTeleport) return; //텔레포트 진행상태일 때 움직임 제한
        Move();
        Attack();

        if (!GameManager.instance.isGetLight) return; //헤드라이트 미획득시 활성화 불가
        OnLight(); //헤드라이트 온오프
    }

    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;

        if (x != 0 || z != 0)
        {
            cc.Move(moveDir * speed * Time.deltaTime);
            anime.SetBool("Run", true);

            if (groundCheck)
            {
                if (isMeadow) StartCoroutine(PlaySound(SurfaceType.Grass));
                else if (isForest) StartCoroutine(PlaySound(SurfaceType.Forest));
                else if (isDesert) StartCoroutine(PlaySound(SurfaceType.Sand));
                else if (isCave) StartCoroutine(PlaySound(SurfaceType.Cave));
            }
        }
        else
        {
            anime.SetBool("Run", false);
        }

        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);
    }

    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;
        }
    }

    // 땅 확인 로직
    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();
        }

        if (hit.gameObject.layer == LayerMask.NameToLayer("Meadow")) //초원
        {
            mapIndex = 1;
            BoolSetting(false, true, false, false, false);
            GroundCheck();
        }

        if (hit.gameObject.layer == LayerMask.NameToLayer("Forest")) //숲
        {
            mapIndex = 0;
            BoolSetting(false, false, true, false, false);
            GroundCheck();
        }

        if (hit.gameObject.layer == LayerMask.NameToLayer("Desert")) //사막
        {
            mapIndex = 2;
            BoolSetting(false, false, false, true, false);
            GroundCheck();
        }

        if (hit.gameObject.layer == LayerMask.NameToLayer("Cave")) //동굴
        {
            mapIndex = 3;
            BoolSetting(false, false, false, false, true);
            GroundCheck();
        }

        if (hit.collider.tag == "die")
        {
            statusManager.CurrentHp = 0;
        }
    }

    void Attack()
    {
        if (UiManager_.instance.uiAllDeactive)
        {
            if (Input.GetMouseButton(0) && isAttack == false)
            {
                anime.SetLayerWeight(1, 1);
                StartCoroutine(AttackMotion("Attack"));
            }
            if (anime.GetCurrentAnimatorStateInfo(1).IsName("Attack") || anime.GetCurrentAnimatorStateInfo(1).IsName("Aim"))
            {
                anime.SetLayerWeight(1, 1);
            }
            else if (anime.GetLayerWeight(1) > 0)
            {
                anime.SetLayerWeight(1, anime.GetLayerWeight(1) - 0.1f);
            }
        }
    }

    IEnumerator AttackMotion(string motionName)
    {
        if (!isAttack)
        {
            isAttack = true;
            anime.SetBool(motionName, true);
            yield return new WaitForSeconds(1.5f);
            anime.SetBool(motionName, false);
            isAttack = false;
        }
    }

    public void AtkStart()
    {
        isAtk = true;
        if (equipments[WeaponChange.instance.curWeapon] != null)
        {
            equipments[WeaponChange.instance.curWeapon].GetComponent<Equipment>().ListClear();
        }
    }
    public void AtkEnd()
    {
        isAtk = false;

        if (equipments[WeaponChange.instance.curWeapon] != null)
        {
            equipments[WeaponChange.instance.curWeapon].GetComponent<Equipment>().GiveDamage();
            equipments[WeaponChange.instance.curWeapon].GetComponent<Equipment>().ListClear();
        }
    }

    public void OnLight()
    {
        if (Input.GetKeyDown(KeyCode.E))
        {
            if (!isLight)
            {
                headLight.SetActive(true);
                isLight = true;
            }
            else
            {
                headLight.SetActive(false);
                isLight = false;
            }
        }
    }

    void BoolSetting(bool house, bool meadow, bool forest, bool desert, bool cave)
    {
        isHouse = house;
        isMeadow = meadow;
        isForest = forest;
        isDesert = desert;
        isCave = cave;
    }

    void GroundCheck()
    {
        groundCheck = true;          
        anime.SetBool("Jump", false); 
        anime.SetBool("Flying", false);
    }

    public void CheatDie()
    {
        statusManager.CurrentHp = 0;
    }

    public enum SurfaceType { Grass, Forest, Sand, Cave }

    IEnumerator PlaySound(SurfaceType type)
    {
        if (isRunning) yield break;
        isRunning = true;

        yield return new WaitForSeconds(0.2f);

        var sm = SoundManager.instance;
        AudioClip[] targetClips = null;

        switch (type)
        {
            case SurfaceType.Grass: targetClips = sm.grassClips; break;
            case SurfaceType.Forest: targetClips = sm.forestClips; break;
            case SurfaceType.Sand: targetClips = sm.sandClips; break;
            case SurfaceType.Cave: targetClips = sm.caveClips; break;
        }

        if (targetClips != null && targetClips.Length > 0)
        {
            int randomIndex = Random.Range(0, targetClips.Length);
            sm.PlaySfx(transform.position, targetClips[randomIndex], 0, sm.sfxVolum);
        }

        isRunning = false;
    }
}
using System.Collections.Generic;
using UnityEngine;

public class Equipment : MonoBehaviour
{
    public List<GameObject> hitMons = new List<GameObject>();

    private void OnTriggerStay(Collider other)
    {
        Debug.Log("OnTrigger : " + other.name);

        if (other.tag == "Monster"|| other.tag == "MonsterHitBox")
        {
            if (!hitMons.Contains(other.gameObject))
            {
                hitMons.Add(other.gameObject);
            }
        }
    }
    public void ListClear()
    {
        hitMons.Clear();
        hitMons = new List<GameObject>();
    }

    public void GiveDamage()
    {
        float dmg = StatusManager.instance.PlayerStatus.ATK;

        foreach (GameObject monster in hitMons)
        {            
            if (monster != null)
            {
                if (monster.transform.tag == "Monster")
                {
                    monster.GetComponent<Monster>().GetDamage(dmg);
                }
                else if (monster.transform.tag == "MonsterHitBox")
                {
                    monster.GetComponentInParent<Monster>().GetDamage(dmg);
                }
            }
        }
    }
}
using UnityEngine;

public class MainCamera : MonoBehaviour
{
    public static MainCamera instance;

    UiManager_ uiManager;
    public float rotationSpeed = 600f;

    float rotationX = 0.0f;
    float rotationY = 0.0f;

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        else Destroy(this.gameObject);
    }

    void Start()
    {
        uiManager = UiManager_.instance;    
    }

    void Update()
    {
        if(SettingUi_.instance != null) rotationSpeed = SettingUi_.instance.MouseSensitivity();
        CameraRotation();
    }

    void CameraRotation()
    {
        if (uiManager.uiAllDeactive)
        {            
            float mouseX = Input.GetAxis("Mouse X"); // 마우스의 수평 움직임 캡처
            float mouseY = Input.GetAxis("Mouse Y"); // 마우스의 수직 움직임 캡처

            rotationX += mouseX * rotationSpeed * Time.deltaTime; // 수평 회전량 계산
            rotationY += mouseY * rotationSpeed * Time.deltaTime; // 수직 회전량 계산

            if (!PlayerController.instance.isFlying)
            {
                if (rotationY < -15) rotationY = -15;
                else if (rotationY > 15) rotationY = 15;
            }
            else
            {
                if (rotationY < -80) rotationY = -80;
                else if (rotationY > 80) rotationY = 80;
            }

            transform.localEulerAngles = new Vector3(-rotationY, rotationX, 0);
        }
    }
}