[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 15회차 미션 시작합니다.


원거리 공격 구현입니다.

projectile이라는 "발사체"를 구현해야 합니다. 게임개발에서는 굉장히 많이 사용하는 용어입니다. GameObject를 원하는 방향으로 지속적으로 움직이게하는 컴포넌트라고 보면 됩니다.

 

 



위와 같이 Projectile 컴포넌트 스크립트를 생성하여 코드 작업에 진입합니다. 코드 작업이 바로바로.. ㅎㅎ

public class Projectile : MonoBehaviour
{
    // 변수들
    public float speed;
    public GameObject muzzlePrefab; // muzzle flash라고도 하며, 총 발사시 총구에서 불꽃이 튀는 등의 이팩트를 처리.
    public GameObject hitPrefab; // hit 이펙트. 총알이 벽에 맞았을 때 튀는 형태의 이팩트 처리.
    public AudioClip shotSFX;
    public AudioClip hitSFX;
    private bool collided; // 적에게 부딪혔는지를 확인
    private Rigidbody rigidbody;
    [HideInInspector]
    public AttackBehaviour attackBehaviour; // 공격 파워 등의 정보를 재사용.
    [HideInInspector]
    public GameObject owner; // 발사체의 소유자. 누가 쏜겨!?
    [HideInInspector]
    public GameObject target; // 발사체를 맞은 Gameobject. 누가 맞은겨?
    
    void Start()
    {
        // 타겟 바라보는 방향으로 설정
        if (target != null) {
            Vector3 dest = target.transform.position;
            dest.y += 1.5f;
            transform.LookAt(dest);
        }
        
        if (owner) {
            Collider projectileCollider = GetComponent<Collider>();
            Collider[] ownerColliders = owner.GetComponentsInChildren<Collider>();
            
            foreach (Collider collider in ownerColliders) {
                Physics.IgnoreCollision(projectileCollider, collider);
            }
        }
        
        rigidbody = GetComponent<Rigidbody>();
        
        if (muzzlePrefab != null) {
            GameObject muzzleVFX = Instantiate(muzzlePrefab, transform.position, Quaternion.identity);
            muzzleVFX.transform.forward = gameObject.transform.forward; // 발사 방향 지정
            PaticleSystem particleSystem = muzzleVFX.GetComponent<ParticleSystem>();
            if (particleSystem) {
                Destroy(muzzleVFX, particleSystem.main.duration); // 이팩트 삭제
            }
            else {
                // 자식노드에 달려있는 이팩트도 처리
                ParticleSystem childParticleSystem = muzzleVFX.transform.GetChild(0).GetComponent<ParticleSystem>();
                if (childParticleSystem) {
                    Destroy(muzzleVFX, childSystem.main.duration); // 이팩트 삭제
                }
            }
        }
        
        if (shotSFX != null && GetComponent<AudioSource>()) {
            GetComponent<AudioSource>().PlayOneShot(shotSFX);
        }
    }
    
    private void FixedUpdate()
    {
        if (speed != 0 && rigidbody != null) {
            rigidbody.position += (transform.forward) * (speed * Time.deltaTime);
        }
    }
   
    private void OnCollisionEnter(Collision collision)
    {
        // 여러번 부딪히는 경우를 방지
        if (collided) return;
        collided = true;
        
        Collider projectileCollider = GetComponent<Collider>();
        projectileCollider.enabled = false;
        
        if (hitSFX != null && GetComponent<AudioSource>()) {
            GetComponent<AudioSource>().PlayOneShot(hitSFX);
        }
        
        speed = 0;
        rigidbody.isKinematic = true; // 발사체가 더이상 rigidbody에 의해 위치가 결정되지 않기 때문에 물리엔진 사용하지 않겠다는 의미이고, 더이상 OnCollisionEnter()가 호출되지 않게됨.
        
        ContactPoint contact = collision.contacts[0]; // 첫번째 충돌 지점 얻기.
        Quaternion contactRotation = Quaternion.FromToRotation(Vector3.up, contact.normal);
        Vector3 contactPosition = contact.point;
        
        if (hitPrefab) {
            GameObject hitVFX = Instantiate(hitPrefab, contactPosition, contactRotation);
            PaticleSystem particleSystem = hitVFX.GetComponent<ParticleSystem>();
            if (particleSystem) {
                Destroy(hitVFX, particleSystem.main.duration);
            }
            else {
                // 자식노드에 달려있는 이팩트도 처리
                ParticleSystem childParticleSystem = hitVFX.transform.GetChild(0).GetComponent<ParticleSystem>();
                if (childParticleSystem) {
                    Destroy(hitVFX, childSystem.main.duration);
                }
            }
            
            IDamagable_Original damageable = collision.gameObject.GetComponent<IDamagable_Original>();
            if (damageable != null) {
                damageable.TakeDamage(attackBehaviour?.damage ?? 0, null);
            }
            
            StartCoroutine(DestroyParticle(3.0f));
        }
    }
   
    public IEnuerator DestroyParticle(float waitTime) {
        if (transform.childCount > 0 && waitTime != 0) {
            List<Transform> childs = new List<Transform>();
            foreach (Transform t in transform.GetChild(0).transform) {
                childs.Add(t);
            }
            while (transform.GetChild(0).localScale.x > 0) {
                yield return new waitForSeconds(0.01f);
                transform.GetChild(0).localScale -= new Vector3(0.1f, 0.1f, 0.1f); // 점점 사라지는 효과
                // 자식노드도 동일하게
                for (int i = 0; i < childs.Count; ++i) {
                    childs[i].localScale -= new Vector3(0.1f, 0.1f, 0.1f);
                }
            }
        }
        
        yield return new WaitForSeconds(waitTime);
        Destroy(gameObject);
    }
}





위의 내용중 IgnoreCollision() 함수를 사용한 이유입니다. projectile이 발사하는 GameObject의 뒤쪽에서 시작되는 경우 발사체가 소유자에게 맞아서 이벤트가 끝나버리는 상황이 발생할 수 있기 때문에 모든 경우에 대해 충돌을 무시하도록 처리한 것입니다.





발사체를 구현하기 위해 Sphere GameObject를 만들어 처리합니다. 해당 Sphere에 Projectile 컴포넌트를 드래그하여 가져다 놓으면 위와 같은 설정 내용을 확인 할 수 있습니다.





OnCollisionEnter() 부분을 디버깅 확인하는 방법입니다. Speed값을 올려 Sphere가 벽에 부딪히면 지정된 시간 후에 사라지는 연출이 잘 되고 있는 것을 확인할 수 있습니다.

 



AttackBehaviour_Projectile 컴포넌트 스크립트를 하나 만듭니다.

public class AttackBehaviour_Projectile : AttackBehaviour
{
    public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
    {
        if (target == null) return;
        
        Vector3 projectilePosition = startPoint?.position ?? transform.position;
        if (effectPrefab) {
            GameObject projectileGO = GameObject.Instantiate<GameObject>(effectPrefab, projectilePosition, Quaternion.identity);
            projectileGO.transform.forward = transform.forward;
            
            Projectile projectile = projectileGO.GetComponent<Projectile>();
            if (projectile) {
                projectile.owner = this.gameObject;
                projectile.target = target;
                projectile.attackBehaviour = this;
            }
            
            calcCoolTime = 0.0f;
        }
    }
}





플레이를 실행해보면 적이 발사체를 발사하는 것을 확인할 수 있고, 열심히 피하면 됩니다만.. ㅋㅋ
한 번 맞아보면 녹색으로 파티클 이팩트가 발생하는 것을 확인할 수 있습니다. 적의 근처로 이동하면 근접공격을 하는 것도 확인할 수 있습니다.



플레이어를 따라다니는 발사체로 변경해보기입니다. 일정시간을 따라오다가 사라지는 발사체입니다.

public class FollowProjectile : Projectile
{
    public float destroyDelay = 5.0f;
    
    protected override void Start()
    {
        base.Start();
        
        StartCoroutine(DestroyParticle(destroyDelay));
    }
    
    protected override void FixedUpdate()
    {
        if (target) {
            Vector3 dest = target.transform.position;
            dest.y += 1.5f;
            transform.LookAt(dest); // 현재 캐릭터의 위치로 다시 설정
        }
        
        base.FixedUpdate();
    }
}




이제 따라다니는 Projectile을 적용해야겠지요?

"Projectile Attack Behaviour"에 있는 "Effect Prefab" 값을 "Projectile" -> "Projectile_Follow"로 변경합니다. 이렇게하여 플레이를 진행하면 아래와 같이 따라오는 발사체가 처리된 것을 확인할 수 있습니다.





여기까지가 한 캐릭터가 여러 공격 행동을 가지는 기능을 구현해 본 것입니다. 이러한 Behaviour 구현 방식은 게임의 여러 요소에서 적용할 수 있는 범위가 다양합니다. 응용범위가 엄청나다고 보면 됩니다. ^^~




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 14회차 미션 시작합니다.


저번에 이어 전투시스템 구현입니다.
IAttackable에 대한 인터페이스 함수 구현을 완료하였고, 이번에는 IDamageable에 대한 인터페이스 함수를 구현합니다.

구성되어 있던 IDamageable 인터페이스는 다음과 같았습니다.
public interface IDamageable
{
    bool IsAlive { get; }
    void TakeDamage(int damage, GameObject hitEffectPrefabs);
}


이를 EnemyController_New 스크립트에 구현을 해줍니다.

public class EnemyController_New : EnemyController, IAttackable, IDamageable
{
    // 데미지 관련 체력 변수 및, 초기화 등을 해줍니다.
    
    public Transform hitTransform; // Hit Effect가 발생했을 때의 위치값.
    public int maxHealth = 100; // 최대 체력. 레벨 올라감에 따른 최대 체력 증가 고려.
    public int health
    {
        get;
        private set;
    }
    
    protected override void Start()
    {
        // 기존 구현부..
        
        health = maxHealth;
    }
    

    // IDamageable 구현
    bool IsAlive => health > 0; // 체력 0이상으로 살아있음을 체크
    
    void TakeDamage(int damage, GameObject hitEffectPrefabs)
    {
        if (!IsAlive) return;
        
        health -= damage;
        if (hitEffectPrefabs)
        {
            Instantiate(hitEffectPrefabs, hitTransform);
        }
        
        if (IsAlive)
            animator?.SetTrigger(hitTriggerHash); // 피격 애니메이션 실행
        else
            stateMachine.ChangeState<DeadState>();
    }
}



AttackState_New.cs

public class AttackState_New : State<EnemyController>
{
    private Animator animator;
    private AttackStateController attackStateController;
    private IAttackable attackable;
    
    protected int attackTriggerHash = Animator.StringToHash("AttackTrigger");
    protected int attackIndexHash = Animator.StringToHash("AttackIndex");
    
    pubilc override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        attackStateController = context.GetComponent<AttackStateController>();
        attackable = context.GetComponent<IAttackable>();
    }
    
    public override void OnEnter()
    {
        // IAttackable이 구현되어 있지 않았거나, 구현되어 있지만 CoolTime이 안되었다면..
        if (attackable == null || attackable.CurrentAttackBehaviour == null) {
            statemachine.ChangeState<IdleState>();
            return;
        }
        
        attackStateController.enterAttackStateHandler += OnEnterAttackState();
        attackStateController.exitAttackStateHandler += OnExitAttackState();

        animator?.SetInteger(attackIndexHash, attackable.CurrentAttackBehaviour.animationIndex);
        animator?.SetTrigger(attackTriggerHash);
    }
    
    public void OnEnterAttackState()
    {
        // 해당 애니메이션만 변경되므로 처리할 내용은 없음.
    }
    
    public void OnExitAttackState()
    {
        stateMachine.ChangeState<IdleState>();
    }
}




StateMachine이 구현된 상태입니다.




원거리 공격을 위한 projectilePoint이 설정한 화면입니다.

해당 적 캐릭터는 근거리, 원거리 공격 둘 다 가능하므로 Projectile Attack Behaviour와 Melee Attack Behaviour를 둘다 구현해 주면 됩니다.

내용이 완전 쉽지는 않고 캡슐화를 위해 여러 컴포넌트 스크립트와 클래스, 인터페이스들로 인해 좀 정신이 없기는 합니다. 사실 이런 부분은 해당 내용 및 코드를 완벽히 이해한 상태에서 직접 구현해봐야 이해할 수 있습니다.



근접 공격에 대한 내용입니다.
근접 공격에 대한 충돌은 ManualCollision 컴포넌트 스크립트로 자체 구현합니다.


ManualCollision_New.cs

public class ManualCollision_New : MonoBehaviour
{
    public Vector3 boxSize = new Vector3(3, 2, 2);
    
    public Collider[] CheckOverlapBox(LayerMask layerMask)
    {
        return Physics.OverlapBox(transform.position, boxSize * 0.5f, transform.rotation, layerMask);
    }
    
    private void OnDrawGizmos()
    {
        Gizmos.matrix = transform.localToWorldMatrix; // Gizmos의 상태 위치를 World 위치로..
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireCube(Vector3.zero, boxSize);
    }
}




MeleeAttackCollision을 Amphisbaena라는 적 캐릭터의 자식 컴포넌트로 등록하고, 그 안에서 "Manual Collision" 컴포넌트를 등록하여 사용합니다. 자식 노트로 한 이유는 캐릭터를 이동할 때 Collision 영역이 캐릭터를 따라다녀야 편하기 때문이지요.
이제 근접 공격 스크립트를 작성합니다.

AttackBehaviour_Melee.cs

public class AttackBehaviour_Melee : AttackBehaviour
{
    public manualCollision attackCollision;
    
    public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
    {
        Collider[] colliders = attackCollision?.CheckOverlapBox(targetMask);
        
        foreach (Collider collider in colliders)
        {
            collider.gameObject.GetComponent<IDamagable_Original>()?.TakeDamage(damage, effectPrefabs);
        }
    }
}




드디어 유니티 실행입니다. 플레이를 시작하면 적캐릭터가 따라오며 근접공격이 가능한 경우 근접 공격 애니메이션이 발생됨을 볼 수 있습니다.



실제 해당 공격이 발생되는 시점에 Manual Collision을 보면 캐릭터가 해당 영역 (boxSize)내에 있기 때문에 Damage를 받아 체력이 감소하는 처리를 하게 됩니다.

다음 번에는 원거리 공격을 구현할 예정입니다.



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 13회차 미션 시작합니다.




어느덧 챕터 5 진입이네요.

전투 시스템에 대하여 알아보는 시간입니다.

배우게 될 전체 구성 순서는 다음과 같습니다.

1. 전투 시스템 구성 알아보기
2. 전투 시스템 구현하기
3. 근접 전투 로직 구현하기
4. 원거리 전투 로직 구현하기
5. NPC의 HP UI 구현하기




EnemyController는 기존에 구현하였던 플레이어 캐릭터나 적캐릭터라고 보면 됩니다. 여기서는 Idle, Attack, Move State만 가지는 Controller를 구현합니다.
AttackStateController는 Animator에서 공격중인지 상태를 알게해주고, 이를 확인하여 EnemyController Logic에 전달하여 상태를 확인하도록 구현합니다. 물론 EnemyController 컴포넌트에서 직접 작업하여도 되지만, AttackStateController를 재활용할 수 있도록 별도의 컴포넌트로 제작을 하는 것입니다.
IAttackable은 Animator에서 공격이 발생하는 시점에 호출이되는 이벤트 인터페이스입니다. IAttackable 인터페이스를 따로 구현한 이유 역시 공격가능한 GameObject에 쉽게 부착하여 구현할 수 있도록 구성한 것입니다. 정말 좋은 내용입니다.
IDamageable도 마찬가지이지요. 데미지를 받아 에너지가 줄어드는 GameObject인 경우 적용합니다.
인터페이스의 장점은 클래스 상속과 상관없이 Attach/Dettach가 가능하고, 식별이 용이하다는 것이 장점입니다. 단점이라면 구조가 복잡해질 수 있다는 점입니다. Interface를 컴포넌트로 구성할 수도 있지만 참조가 많이 발생하게 되어 복잡성이 높아질 수 있어서 여기서는 Interface로만 구현합니다.
AttackBehaviour_xx는 공격 종류라고 보면 됩니다. EnemyController에서 여러 공격의 종류 중 하나를 선택하여 공격이 발생하는 시스템입니다.


AttackStateController와 Animator를 먼저 구성해 봅니다.



StateMachine은 위와 같이 구성합니다.


AttackStateController.cs

public class AttackStateController : MonoBehaviour
{
    public delegate void OnEnterAttackState();
    public delegate void OnExitAttackState();
    
    public OnEnterAttackState enterAttackStateHandler;
    public OnExitAttackState exitAttackStatehandler;
    
    public bool IsInAttackState
    {
        get;
        private set; // 외부에서 값 설정 불가.
    }
    
    void Start()
    {
        enterAttackStateHandler = new OnEnterAttackState(EnterAttackState);
        exitAttackStateHandler = new OnExitAttackState(ExitAttackState);
    }
    
    public void OnStartOfAttackState()
    {
        IsInAttackState = true;
        enterAttackStateHandler();
    }
    
    public void OnEndOfAttackState()
    {
        IsInAttackState = false;
        exitAttackStateHandler();
    }
    
    private void EnterAttackState()
    {
    }
    
    private void ExitAttackState()
    {
    }
    
    // Animator에서 호출할 함수
    public void OnCheckAttackCollider(int attackIndex)
    {
        GetComponent<IAttackable>()?.OnExecuteAttack(attackIndex);
    }
}

 



AttackStateMachineBehaviour.cs

public class AttackStateMachineBehaviour : StateMachineBehaviour
{
    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.getObject.GetComponent<AttackStateController>()?.OnStartOfAttackState();
    }
    
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
        animator.getObject.GetComponent<AttackStateController>()?.OnEndOfAttackState();
    }
}

 



IAttackable.cs

public interface IAttackable
{
    AttackBehaviour CurrentAttackBehaviour
    {
        get;
    }
    
    void OnExecuteAttack(int attackIndex);
}


IDamageable.cs

public interface IDamageable
{
    bool IsAlive
    {
        get;
    }
    
    void TakeDamage(int damage, GameObject hitEffectPrefabs);
}



AttackBehaviour.cs

public abstract class AttackBehaviour : MonoBehaviour
{
// 개발자가 어떤 컴포넌트인지 확인할 수 있도록 주석 만들기.
#if UNITY_EDITOR
    [Multiline]
    public string developmentDescription = "";
#endif

    public int animationIndex;
    public int priority; // 여러 공격중 어떤 것을 선택할지에 대한 우선순위
    public int damage = 10;
    public float range = 3f;
    
    [SerializedField]
    protected float coolTime; // 공격후 대기 시간?
    protected float calcCoolTime; = 0.0f;
    
    public GameObject effectPrefab;
    
    [HideInInspector]
    public LayerMask targetMask;
    
    void Start()
    {
        calcCoolTime = coolTime; // 바로 공격 가능 상태
    }
    
    void Update()
    {
        if (calcCoolTime < coolTime)
        {
            calcCoolTime += Time.deltaTime;
        }
    }
    
    // startPoint는 발사체가 발사되는 지점용
    public abstract void ExecuteAttack(GameObject target = null, Transform startPoint = null);
}

 


EnemyController_New.cs

public EnemyController_New : EnemyController, IAttackable, IDamageable
{
    public Transform projectilePoint;
    
    [SerializedField]
    private List<AttackBehaviour> attackBehavious = new List<AttackBehaviour>();
    
    protected override void Start()
    {
        base.Start();
        stateMachine.AddState(new MoveState());
        stateMachine.AddState(new AttackState());
        stateMachine.AddState(new DeadState());
        
        InitAttackBehaviour();
    }
    
    protected override void Update()
    {
        CheckAttackBehaviour();
        base.Update();
    }
    
    private void InitAttackBehaviour()
    {
        foreach (AttackBehaviour behaviour in attackBehavious)
        {
            if (CurrentAttackBehaviour == null)
                CurrentAttackBehaviour = behaviour;
                
            behaviour.targetMask = TargetMask;
        }
    }
    
    private void CheckAttackBehaviour()
    {
        if (CurrentAttackBehaviour == null || !CurrentAttackBehaviour.IsAvailable)
        {
            CurrentAttackBehaviour = null;
            foreach (AttackBehaviour behaviour in attackBehavious)
            {
                if (behaviour.IsAvailable)
                {
                    // 가장 우선순위가 높은 공격 설정
                    if (CurrentAttackBehaviour == null || CurrentAttackBehaviour.priority < behaviour.priority)
                    {
                        CurrentAttackBehaviour = behaviour;
                    }
                }
            }
        }
    }
    
    public AttackBehaviour CurrentAttackBehaviour
    {
        get;
        private set; // 내부에서만 set 가능하도록..
    }
    
    public void OnExecuteAttack(int attackIndex)
    {
        if (CurrentAttackBehaviour != null && Target != null)
        {
            CurrentAttackBehaviour.ExecuteAttack(Target.gameObject, projectilePoint); // Target은 공격가능 거리내에 있는 타겟중 가장 가까운 것
        }
    }
}

 

 



적의 Animation 구동중에 OnCheckAttackCollider() 함수를 호출하도록 구현되어 있습니다.

캐릭터의 전투 시스템 구성은 C# 코딩으로 모두 이루어진다고 할 수 있겠네요.
이젠 배우는 내용들이 많아지므로 이전에 배운 내용은 코드 재사용을 하므로 정말 빠르게 진행됩니다.
이번에 배운 중요한 부분은 IAttackable이라고 봐야겠네요. 다음시간에는 IDamageable에 대해 구현하면 마무리가 될 것 같습니다.



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 12회차 미션 시작합니다.


저번 시간에 이어 FOV 마무리하고, 동적 AI 캐릭터 구현하기입니다.




FieldOfView를 Editor 기능으로 확장하여 적 캐릭터의 시야 반지름이 원으로 표시되는 것을 확인할 수 있고, View Angle값을 변경하여 시야각을 조절하면 Editor 상에서 Realtime으로 변하며 확인할 수 있습니다.

이제 SearchEnemy() 관련 부분을 수정합니다.


EnemyController_New.cs

public class EnemyController_New : MonoBehaviour
{
    protected StateMachine_New<EnemyController_New> stateMachine;
    public StateMachine_new<EnemyController_New> StateMachine => stateMachine;
    
    private FieldOfView_New fov; // FOV 코드 추가.
    
    // 기존 코드 제거. -> FOV에서 처리하므로 불필요.
    //public LayerMask targetMask;
    //public Transform target;
    //public float viewRadius;
    public float attackRange;
    public Transform Target => fov?.NearestTarget; // FieldOfView_New.cs에 public Transform NearestTarget => nearestTarget; 추가해줍니다.
    
    private void Start()
    {
        stateMachine = new StateMachine_New<EnemyController_New>(this, new IdleState_New());
        stateMachine.AddState(new MoveState_New());
        stateMachine.AddState(new AttackState_New());
        
        fov = GetComponent<FieldOfView_New>();
    }
    
    private void Update()
    {
        stateMachine.Update(Time.deltaTime);
    }
    
    public bool IsAvailableAttack
    {
        get
        {
            if (!Target) // target -> Target property
            {
                return false;
            }
            
            float distance = Vector3.Distance(transform.position, Target.position); // target -> Target
            return (distance <= attackRange);
        }
    }
    
    public Transform SearchEnemy()
    {
        return Target; // FOV에서 검색된 Target return

        // FOV의 Target 사용으로 불필요.
        //target = null;
        //Collider[] targetInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        //if (targetInViewRadius.Length > 0)
        //{
        //    target = targetInViewRadius[0].transform;
        //}
    }
}


여기까지 구현을 하면 적을 찾는 루틴 SearchEnemy() 함수의 기존 직접 적을 찾는 코딩 방식에서, Editor에 설정된 FOV를 이용하여 적을 찾는 방식으로 변경하여 구현을 할 수 있게 되었습니다.

대단합니다. 결국 기존에는 저러한 값들이 바뀌면 항상 프로그램 소스 코드를 무조건 변경해야 했고, 관련 코드들도 모두 수정하는 일이 빈번할텐데.. FOV 컴포넌트를 추가하여 Editor 상에서도 실시간 수정을 할 수가 있게 되고, 어떻게 보면 다른 컴포넌트들에서도 FOV 컴포넌트를 공통으로 사용하게 될 것이므로 코드의 집중화까지 되는 효과가 있다고 할 수 있겠습니다.

FieldOfView_New.cs의 Update()시 매번 FindVisibleTargets()를 호출하는 것은 부하가 많이 걸리므로 delay를 사용하는 방식으로 조금 수정합니다.


FieldOfView_New.cs 수정 부분들

public float delay = 0.2f;

void Start()
{
    StartCoroutine("FindTargetsWithDelay", delay); // delay 시간으로 자동호출되는 함수 등록.
}

void Update()
{
    //FindVisibleTargets(); // Update시마다 매번 호출하지 않고 Start()에서 자동호출되는 함수 등록.
}

// delay 시간을 가지고 FindVisibleTargets()을 호출하는 함수.
IEnumerator FindTargetsWithDelay(float delay)
{
    while(true)
    {
        yield return new WaitForSeconds(delay);
        FindVisibleTargets();
    }
}

 



Update()는 매 Frame마다 호출되므로 너무 빈번하게 호출됩니다. FindTargetsWithDelay() 함수를 구현하여 0.2초마다 호출되는 함수 방식을 구현하였습니다.

기존 코드들을 정리해 줍니다. target => fov?.Target을 사용하는 형태로.. OnDrawGizmos()와 같은 불필요해진 함수들도 삭제를 합니다.




Unity에서 BarbarianWarrior_FOV (적 FOV)의 설정을 해줍니다.
+ View Radius = 5
+ View Angle = 90
+ Delay = 0.2
+ Target Mask = Player
+ Obstacle Mask = Ground, Wall
  => Obstacle Mask에 Ground와 Wall을 설정해 줌으로써 해당 GameObject들에서는 시야가 무시되도록 처리한 것입니다.
  



현재까지 상태에서 구동을 해보면 위의 화면과 같이 실행이 됩니다.
기존과 다르게 적의 반경 내에 있다고 하더라도 시야각에 들어가지 않으면 적이 MoveState로 Transition 되지 않기 때문에 캐릭터를 공격하러 오지 않는 것을 확인할 수 있습니다.

이런 것들을 이용하면 적의 뒤에서 공격하는 게임 형식이라던지, 잠입 방식의 게임 방식도 응용하여 개발이 가능해집니다.



이제 FSM을 좀더 확장하여 캐릭터가 2지점 사이를 오가는 Patrol 기능을 구현해 보도록 하겠습니다.



패트롤중 적발견하고 공격거리 이내면 Attack State로 Transition하고,
패트롤중 적발견하고 공격거리 밖이면 Move State로 Transition하고,
패트롤중 적을 발견하지 못하면 랜덤하게 Idle State로 Transition하는 방식을 취할 예정입니다.

Idle, Attack, Move State는 기존 코드를 사용하며, Patrol State만 추가로 구현을 하면 됩니다. 단지 Patrol 상태에 따른 Random Idle 처리를 위해 Idle State는 약간 변경을 해주어야 합니다.

Patrol State를 추가하는 루틴을 공부하는 이유는 기존 Attack - Idle - Move 상태만 처리하던 시스템에서 Patrol 상태만 추가를 해줌으로서 쉽게 캐릭터의 상태를 추가하고 제어할 수 있다는 것을 보여주기 위함입니다.

와우.. 상태 State 개념... 대박 좋은 거 같습니다. 어떻게 저런 아이디어를 생각해내고 구현해 낼 수 있는지.. ㅎㅎ 이건 게임이 아니라 다른 개발 프로젝트에서도 충분히 응용할 수 있고.. 꼭 그렇게 해야할 것 같은 중압감을 느낄 정도네요 ㅠ.,ㅜ;

 

 



Patrol waypoint를 구현하기 위해 Unity 상에 Sphere 2개를 놓았고, 해당 위치를 반복하며 이동하는 기능을 "MoveToWaypoint_New" 스크립트 컴포넌트를 추가하여 구현합니다.


MoveToWaypoint_New.cs

public class MoveToWaypoints : StateMachine_New<EnemyController_New>
{
    private Animator animator;
    private CharacterController controller;
    private NavMeshAgent agent;
    protected int hashMove = Animator.StringToHash("Move");
    protected int hashMoveSpeed = Animator.StringToHash("MoveSpeed");
    
    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        controller = context.GetComponent<CharacterController>();
        agent = context.GetComponent<NavMeshAgent>();
    }
    
    public override void OnEnter()
    {
        if (context.targetWaypoint == null)
            context.FindNextWaypoint(); // Patrol 위치 설정
         
        if (context.targetWaypoint)
        {
            agent?.SetDestination(context.targetWaypoint.position);
            animator?.SetBool(hashMove, true);
        }
    }
   
    public override void Update(float deltaTime)
    {
        Transform enemy = context.SearchEnemy();
        if (enemy)
        {
            if (context.IsAvailableAttack)
                statemachine.ChangeState<AttackState_New>();
            else
                stateMachine.ChangeState<MoveState_New>();
        }
        else
        {
            // pathPending : NavMeshAgent가 이동해야할 경로가 존재하는지 체크
            if (!agent.pathPending && (agent.remainingDistance <= agent.stoppingDistance))
            {
                // 이동해야할 경로도 없고, 도착지점에 도착했다면 다음 목표지점 검색
                Transform nextDest = context.FindNextWaypoint();
                if (nextDest)
                {
                    agent.SetDestination(nextDest.position);
                }
                stateMachine.ChangeState<IdleState_New>(); // 잠시 Idle 상태로 Transition
            }
            else
            {
                // 경로가 남았다면 이동
                controller.Move(agent.velocity * deltaTime);
                animator.SetFloat(hashMoveSpeed, agent.velocity.magnitude / agent.speed, .1f, deltaTime);
            }
        }
    }
    
    public override void OnExit()
    {
        animator?.SetBool(hashMove, false);
        agent.ResetPath();
    }
}



EnemyController_New.cs 수정 부분들

public Transform[] waypoints; // Unity상의 Patrol 위치점들
[HideInInspector]
public Transform targetWaypoint = null;
private int waypointIndex = 0;

public Transform FindNextWaypoint()
{
    targetWaypoint = null;
    if (waypoints.Length > 0)
    {
        targetWaypoint = waypoints[waypointIndex];
    }
    
    waypointIndex = (waypointIndex + 1) % waypoints.Length; // Index Cycling..
}



IdleState_New.cs 수정 부분들

bool isPatrol = false;
private float minIdleTime = 0.0f;
private float maxIdleTime = 3.0f;
private float idleTime = 0.0f;

public override void OnEnter()
{
    animator?.SetBool(hashMove, false);
    animator?.SetFloat(hashMoveSpeed, 0);
    controller?.Move(Vector3.zero);
    
    if (isPatrol)
        idleTime = Random.Range(minIdleTime, maxIdleTime);
}

public override void Update(float deltaTime)
{
    Transform enemy = context.SearchEnemy();
    if (enemy)
    {
        if (context.IsAvailableAttack) stateMachine.ChangeState<AttackState_New>();
        else stateMachine.ChangeState<MoveState_New>();
    }
    else if (isPatrol && stateMachine.ElapsedTimeInState > idleTime)
    {
        stateMachine.ChangeState<MoveToWaypoints>();
    }
}




위와 같은 FSM 상태 구현은 나중에 디아블로 게임 제작시 구현할 체력이 떨어졌을 때 특정 지점으로 회피하였다가 다시 상태가 변경되어 다른 루틴을 구현하는 방식으로 활용될 것입니다.

캐릭터 AI를 위한 FSM 모델을 구현하고 이를 토대로 여러 상태를 가진 캐릭터 구현이 완료되었습니다.

 

<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>


패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 11회차 미션 시작합니다.



적 캐릭터 AI 구현의 캐릭터 가시선 시뮬레이션 구현에 대한 내용입니다.

캐릭터 시야 뷰에 대한 FOV 등을 컴포넌트로 따로 구현하여 사용하도록 합니다.

 



대략 위의 그림과 같이 캐릭터의 시야에 대한 처리를 하게 되고, 오른쪽 그림은 실제로는 반지름을 가진 부채꼴 형태로 인지하게 될 것입니다.

별도 컴포넌트로 제작하기 위해서

Project - Scripts - FieldOfView_New 컴포넌트를 추가합니다.


FieldOfView_New.cs

public class FieldOfView_New : MonoBehaviour
{
    public float viewRadius = 5f;
    [Range(0, 360)]
    public float viewAngle = 90f;
    
    public LayerMask targetMask; // 적을 검색하기 위한 레이어마스크
    public LayerMask obstacleMask; // 캐릭터와 적 사이의 장애물 레이어마스크
    
    private List<Transform> visibleTargets = new List<Transform>(); // 탐색된 적들을 리스트로 관리
    
    private Transform nearestTarget; // 가장 가까이 있는 적
    private float distanceToTarget = 0.0f; // 가장 가까운 적까지의 거리
    
    void Start()
    {
    }
   
    void Update()
    {
        FindVisibleTargets();
    }
    
    // 보이는 적 찾기
    void FindVisibleTargets()
    {
        distanceToTarget = 0.0f;
        nearestTarget = null;
        visibleTargets.Clear();
        
        Collider[] targetsInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        for (int i = 0; i < targetsInViewRadius.Length; ++i)
        {
            Transform target = targetsInViewRadius[i].transform;
            
            Vector3 dirToTarget = (target.position - transform.position).normalized; // 방향 검색
            if (Vector3.Angle(transform.forward, dirToTarget) < viewAngle / 2)
            {
                float dstToTarget = Vector3.Distance(transform.position, target.position);
                // 장애물이 있는지 검사
                if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask))
                {
                    visibleTargets.Add(target);
                    if (nearestTarget == null || (distanceToTarget > dstToTarget))
                    {
                        nearestTarget = target;
                        distanceToTarget = dstToTarget;
                    }
                }
            }
        }
    }
}

 

 


역시 Physics.OverlapSphere() 함수를 사용하여 특정 거리 내의 충돌 GameObject들을 걸러 냅니다.
자기 시야각에 있는 적들을 지속 검색하고 가장 가까운 적을 찾는 것까지입니다.
사진에 보이는 것 처럼 FOV내 모든 적을 검색하고 장애물에 가리지 않고 시야에 들어오는 적만 찾아내는 것입니다.


여기에다가 FieldOfView Editor의 Debugging을 위해서 Editor 기능을 추가해보도록 합니다.

 

 


위와 같이 FieldOfView_NewEditor 스크립트를 추가해 줍니다.


FieldOfView_NewEditor.cs

[CustomEditor(typeof(FieldOfView_New))]
public class FieldOfView_NewEditor : Editor
{
    private void OnSceneGUI()
    {
        private FieldOfView_New fov = (FieldOfView_New)target;
        
        // 시야거리 그리기
        Handles.color = Color.white;
        Handles.DrawWireArc(fov.transform.position, Vector3.up, Vector3.forward, 360, fov.viewRadius);
        
        Vector3 viewAngleA = fov.DirFromAngle(-fov.viewAngle / 2, false); // 왼쪽 꼭지점
        Vector3 viewAngleB = fov.DirFromAngle(fov.viewAngle / 2, false); // 오른쪽 꼭지점
       
        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleA * fov.viewRadius);
        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleB * fov.viewRadius);
        
        Handles.color = Color.red;
        foreach (Transform visibleTarget in fov.VisibleTargets)
        {
            Handles.DrawLine(fov.transform.position, visibleTarget.position);
        }
    }
}


 


시야반경과 시야각 등을 그리기 위해서 삼각함수 계산에 대해 설명해주십니다.


FieldOfView_New.cs에 아래의 함수를 추가합니다.

    public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
    {
        if (!angleIsGlobal)
        {
            angleInDegrees += transform.eulerAngles.y;
        }
        
        return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
    }

    

 

 


컴파일이 잘 되면 이렇게 구현이 되어 나타나고, SearchEnemy 부분을 수정해주어야 최종적으로 시야를 체크하는 부분이 완성될텐데 내일 이어서 보도록 하지요.



패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 10회차 미션 시작합니다.



저번에 이어지는 적 캐릭터를 위한 AI 구현입니다.



사실 구현 내용이 대부분이라 캡쳐 화면도 필요없었지만 너무 없어서 ㅋㅋ 제일 유용한 부분을 넣었습니다.
IdleState -> MoveState -> IdleState가 언제 어떻게 발생하는지, 그리고 그러한 이유때문에 스크립트 소스코드를 그렇게 짠 것을 이해하기 위함입니다.

 



EnemyController_New.cs

public LayserMask targetaMask; // targer Layer를 체크하기 위함.
public float viewRadius; // 적이 접근해 있는 반경 체크하기
public Transform target; // 적에 대한 위치
public float attackRange;

public bool IsAvailableAttack
{
    get
    {
        if (!target) return false;
        float distance = Vector3.Distance(transform.position, target.position);
        return (distance <= attackRange);
    }
}

 

public Transform SearchEnemy()
{
    target = null;
    Collider[] targetInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
    if (targetInViewRadius.Length > 0)
    {
        target = targetInViewRadius[0].transform;
    }
    return target;
}

// 적의 시야 반경과 공격 거리를 디버깅 해보기 위해서 그려줍니다.
private void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    Gizmos.DrawWireSphere(transform.position, viewRadius);

    Gizmos.color = Color.green;
    Gizmos.DrawWireSphere(transform.position, attackRange);
}

 




적의 시야 반경과 공격 거리를 각각 빨강색과 초록색으로 그리고 있는 것을 확인할 수 있습니다.


Physics.OverlapSphere()를 사용하여 특정 Object가 특정 반경내에 있는지를 체크합니다.
적이 있다고 판단이 되었을 때 공격 거리 안에 있는지를 검사하도록 합니다.


이건 왜 이렇게 하냐면, 캐릭터마다 적이 있다고 판단하는 거리는 동일하지만 공격이 가능한지는 다른 이슈라는 것입니다. 예를 들어 근접 공격 유닛이 있다면 적이 있다고 판단은 했지만 공격은 할 수 없으므로 적에게 이동하여 공격을 해야할 것이고, 원거리 공격 유닛이라면 적이 있다고 판단되었을 때 바로 화살 등을 쏴서 공격할 수 있을 것이기 때문입니다.

 

여기서는 캐릭터의 시야 거리는 동일하다고 보는 것입니다. 대신 공격 거리만 차이가 있다고 생각하고 프로그래밍을 하는 것이지요. 원거리 유닛은 바로 attackState로 transform되어 공격 상태로 이전합니다. 반면 근거리 유닛은 moveState로 transform되어 이동 상태로 전이되는 것입니다.

 

 


MoveState_New.cs

public class MoveState_New : State_New<EnemyController_New>
{
    private Animator animator;
    private CharacterController controller;
    private NavMeshAgnet agent;

    private int hashMove = Animator.StringToHash("Move");
    private int hashMoveSpeed = Animator.StringToHash("MoveSpeed");

    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
        controller = context.GetComponent<CharacterController>();
        agent= context.GetComponent<NavMeshAgnet>();
    }


    public override void OnEnter()
    {
        agent?.SetDestination(context.target.position);
        animator?.SetBool(hashMove, true);
    }

    public override void Update(float deltaTime)
    {
        Transform enemy = context.SearchEnemy(); // 적에게 지속 접근
        if (enemy) // 적이 지속 존재한다면
        {
            agent.SetDestination(context.target.position);
            if (agent.remainingDistance > agent.stoppingDistance) // 해당 거리만큼 지속 이동
            {
                controller.Move(agnet.velocity * deltaTime);
                animator.SeFloat(hashMoveSpeed, agnet.velocity.magnitude / agent.speed, 1f, deltaTime);
            }
        }

        if (!enemy && agent.remainingDistance <= agnet.stoppingDistance) // 적이 시야에서 벗어났다면
        {
            stateMachine.ChangeState<IdleState_New>(); // IdleState로 전환
        }
    }

    public override void OnExit()
    {
        animator?.SetBool(hashMove, false);
        animator?.SetFloat(hashMoveSpeed, 0f);
        agent.ResetPath(); // 길찾기 더이상 하지 않도록 초기화
    }
}


AttackState_New도 비슷한 루틴으로 구현되겠죠.. 하지만 오히려 지속 이동이 아니라 공격을 하는 애니메이션이 주가 되므로 Animation 처리가 주 업무가 됩니다. MoveState 보다 구현할 내용이 간단하다는 것입니다 ^^

 



AttackState_New.cs

public class AttackState_New : State_New<EnemyController_New>
{
    private Animator animator;
    private int hashAttack = Animator.StringToHash("Attack");
    public override void OnInitialized()
    {
        animator = context.GetComponent<Animator>();
    }

    public override void OnEnter()
    {
        if (context.IsAvailableAttack)
        {
            animator?.SetTrigger(hashAttack);
        }
        else
        {
            stateMachine.ChangeState<IdleState_New>();
        }
    }

    public override void Update(float deltaTime)
    {
    }
}




실행을 해보면 적이 시야에 인지가 되었을 때 이동해 오는 것을 확인할 수 있고, 공격거리 내에 진입하게 되면 공격 애니메이션이 구동되는 것을 확인할 수 있습니다.

여기서 한가지 문제점은 AttackState 상태에서 다시 IdleState로 전환해주는 것이 필요한데 이를 위해서는 스크립트를 하나 추가로 작성해 주어야 합니다.

 


EndOfAttackStateMachineBehavior.cs

public class EndOfAttackStateMachineBehavior : StateMachineBehaviour
{
    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        animator.GetCompoent<EnemyController_New>()?.StateMachine.ChangeState<IdleState_New>();
    }
}


animator와 자신이 구현한 FSM을 연동할 때 정보가 한쪽 방향으로만 흐르도록 해야한다는 것에 주의해야 합니다.
요구사항에 의해 이렇게도 저렇게도 구현되어야 한다면 로직이 꼬이게 되는 경우가 많고 버그도 많이 발생할 수 있기 때문이지요.




아래 내용은 교육과 무관한 미션 제출 관련 내용이에요. ㅎㅎ
지금까지 진행해보니 미션 제출할 때 불편한 점이 몇개 있습니다.
패캠에서 보실지는 모르지만 작성해 둘께요. 보신다면 개선해주시면 좋을 듯합니다.

1. 학습통계 - 이게 하나도 맞질 않아요. ㅋㅋ 왜 있는건지.. 2~3시간을 들어도 20, 30분으로 나와있는게 대부분 -_-;
2. 미션2개 - 강의 2개를 듣고 작성하면 되는데, 오늘 어떤 강의를 들었는지 확인할 수 있는게 없어요. 1개를 들었는지 2개를 들었는지..
3. 제출시 - 시작일이 정해져 있는 것이라 회차가 분명한데, 몇회차를 제출하는지 알수가 없어요.

10회차까지 오니 어떤 강의를 언제 했는지 헛갈리기 시작하네요 ㅠ.,ㅜ; 물론 지난 작성한거 보고 확인도 하고, 캡쳐해놓은것 보고 확인 또 재차 확인하고 있지만, 조금만 직관적이면 좋을텐데.. 라는 아쉬움에 남깁니다. 확인하는데도 시간이 걸리니 ㅎㅎ 그래도 많은 내용 공부할 수 있어 너무 좋고 강추합니다.



패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 9회차 미션 시작합니다.




C#의 Generic 문법을 이용하여 FSM을 구현하는 내용입니다.

상태를 나타내는 State 01~ 구현을 하고, 이 State를 관리하는 State Machine을 구현합니다.

초기화를 진행하면서 State Machine에 각 State 01~ 등록을 진행합니다.

게임 플레이 중에 "Current State", 즉 Transition이 State 간 변경되며 상태가 바뀌는 방식으로 구현합니다.

[Project View] - [Assets] - Scripts 폴더 내에서 "Create - C# Script"를 진행하여 "StateMachine_New"로 작성합니다.


StateMachine_New.cs

public abstract class State<T>
{
    protected StateMachine_New<T> stateMachine;
    protected T context;

    public State()
    {
    }

    internal void SetStateMachineAndContext(StateMachine<T> stateMachine, T context)
    {
        this.stateMachine = stateMachine;
        this.context = context;

        OnInitialized();
    }

    public virtual void OnInitialized()
    {
    }

    public virtual void OnEnter()
    {
    }

    public abstract virtual void Update(float deltaTime);

    public virtual void OnExit()
    {
    }
}

 


public sealed class StateMachine_New<T>
{
    private T context;

    private State<T> currentState;
    public State<T> CurrentState => currentState;

    private State<T> previousState;
    public State<T> PreviousState => previousState;

    private float elapsedTimeInState = 0.0f; // 상태가 변환 되었을 때 변환된 상태에서 얼마나 시간이 흘렀는가를 위한 변수.

    public float ElapsedTimeInState => elapsedTimeInState;

    private Dictionary<System.Type, State<T>> states = new Dictionary<System.Type, State<T>>();

    public StateMachine(T context, State<T> initialState)
    {
        this.context = context;

        // 초기 상태 설정
        AddState(initialState);
        currentState = initialState;
        currentState.OnEnter(); // 초기 State 실행
    }

    public void AddState(State<T> state)
    {
        state.SetMachineAndContext(this, context);
        states[state.GetType()] = state;
    }

    public void Update(float deltaTime)
    {
        elapsedTimeInState += deltaTime;

        currentState.Update(deltaTime);
    }

    public R ChangeState<R> where R : State<T>
    {
        var newType = typeof(R);
        if (currentState.GetType() == newType)
        {
            return currentState as R;
        }
    
        if (currentState != null)
        {
            currentState.OnExit();
        }

        previousState = currentState; // 이전상태 설정
        currentState = states[newType]; // 현재상태 설정
        currentState.OnEnter();
        elapsedTimeInState = 0.0f; // 초기화

        return currentState as R;
    }
}

초기화시 상태를 등록하는 것은 C#의 Dictionary 문법을 사용하여 구현합니다. Template class를 사용하여 State class를 구현하고 StateMachine class에서는 해당 상태 및 흘러간 시간을 체크할 수 있도록 하고, ChangeState() 함수를 통해 instance의 상태 전환을 하는 방법입니다.



이를 이용하여 캐릭터 AI 구현을 해보도록 하겠습니다.


적 GameObject를 구성한 모습입니다.

시간 관계상 대부분 작업된 상태로 준비를 해놓고 시작합니다. NavMesh, Animation 등 앞 시간에서 배운 내용들이기 때문에 다시 설명을 하지는 않습니다. 공격 State도 3가지를 가지고 있고, 연동된 변수도 미리 준비되어 있습니다.
실제로 멋진 적 캐릭터를 가지고 구현을 해보니 좋긴합니다.. 단지 배운 내용이 한번에 집성체로 나타나니 ㅋㅋ 가..감당하기가 어렵네요.. 머 그래도 괜찮습니다. 저희가 하려는 건 FSM이니까요



+ Idle State - 적을 찾아다니는 상태. 사정거리 내에 적이 있는지 체크. 사정거리 밖에 있고 적에게 이동해야 하는지 체크.
+ Attack State - 적이 사정거리내 있는 상태. 공격 애니메이션 구동.
+ Move State - 적에게 이동하는 상태. 이동 애니메이션 구동.

 



EnemyController_New.cs

public class EnemyController_New : MonoBehaviour
{
    protected StateMachine<EnemyController_New> stateMachine;

    private void Start()
    {
        stateMachine = new StateMachine<EnemyController_New>(this, new IdleState());
        stateMachine.AddState(new MoveState());
        stateMachine.AddState(new AttackState());
    }

    private void Update()
    {
        stateMachine.Update(Time.deltaTime);
    }
}


오늘은 완전 코드 레벨이네요. ㅋㅋ 유니티 화면을 거의 보지도 못했어요. ㅎㅎ
강의에서 설명되는 내용들을 코드로 작성하였지만, 제가 보니 설명에는 없지만 실제 코드화된 부분들이 있습니다.
하지만 실제 코드가 교육자료로 첨부되어 있기 때문에 공부하는데에는 지장이 있지는 않습니다. 자료는 첨부하지 않습니다. 그냥 저런 형태로 구현을 하는구나라고만 생각하고 보면 될 것 같습니다.



IdleState.cs

    public class IdleState : State<EnemyController_New>
    {
        private Animator animator;
        private CharacterController controller;

        protected int hasMove = Animator.StringToHash("Move");
        protected int hasMoveSpeed = Animator.StringToHash("MoveSpeed");

        public override void OnInitialized()
        {
            animator = context.GetComponent<Animator>();
            controller = context.GetComponent<EnemyController_New>();
        }

        public override void OnEnter()
        {
            animator?.SetBool(hasMove, false);
            animator?.SetFloat(hasMoveSpeed, 0);
            controller?.Move(Vector3.zero);
        }

        public override void Update(float deltaTime)
        {
            Transform enemy = context.SearchEnemy();
            if (enemy)
            {
                if (context.IsAvailableAttack)
                {
                    stateMachine.ChangeState<AttackState>();
                }
                else
                {
                    stateMachine.ChangeState<MoveState>();
                }
            }
        }

        public override void OnExit()
        {
        }
    }

 



코드 레벨로는 파일도 여러개이고, 직접 작성하여 컴파일도 해보아야 하는 부분이라 그림으로 마지막 정리를 해주시네요. ^^




IdleState 상태 기준으로의 설명입니다.

결국 초기화시에 OnInit으로 시작을 하고,
상태가 시작되면 OnEnter로 진입을 하게되고,
현 Idle 상태로 지속 OnUpdate가 호출되며 준비하다가,
적이 사정거리내에 있다면 OnExit를 호출하고,
AttackState로 전환이 이루어지도록 만든 소스코드인 것입니다.

ㅎㅎ 진짜 중요한 내용이 많은 코드인데.. 짧게 할수도 없고 너무 길게 할수도 없기에 어쩔수 없는 것이라고 봐야합니다. 이것과 관련해서는 C# 문법과 프로그래밍 스킬이 어느정도 있어야 이해가 가능하다고 할 수 있겠네요.

 

 

아쉬워서 유니티의 Enemy가 설정된 화면 스샷 하나 첨부합니다. ㅎㅎ

 

 



패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 8회차 미션 시작합니다.


환경 시스템중 네비게이션 시스템 알아보기와 적 캐릭터 AI 구현 모델에 대해서 공부하는 시간입니다.


일단 저번시간에 배운 Terrain 시스템 중 나무를 처리하는 부분중 괜찮은 유틸리티에 대한 소개입니다.
TreeIt: Tree Generator 라는 무료 유틸리티 소개 www.evolved-software.com/treeit/treeit

 

TreeIt | Tree Generator

 

www.evolved-software.com

 

 


네비게이션 시스템은 게임 월드에서 이동할 수 있는 길찾기 시스템입니다. 네비게이션 시스템의 단점은 NavMesh위에 항상 NavMeshAgnet가 존재해야 한다는 점입니다.

이 문제를 해결하려면 캐릭터 콘트롤의 navMesh를 사용하는 것이 아니라 단순히 길찾기만 사용하는 방법도 있습니다. CalculatePath() 함수에 대해 알아보시면 됩니다.

NavMesh는 캐릭터가 걸어다닐 수 있는 표면들을 Mesh로 표현한 것입니다.
NavMeshAgent는 GameObject가 원하는 목표로 지정했을 때 경로를 계산해주고 장해물과 또다른 Agent들끼리의 충돌을 피할 수 있도록 해줍니다.
NavMeshObstacle는 Agent가 경로를 탐색하는 동안 회피해야하는 움직이는 장해물들을 정의할 수 있습니다. 문이 열리고 닫히는 등의 작업에 사용할 수 있습니다.
OffMeshLink는 Agent가 연결되지 않은 내부 Mesh를 이동하는 방법에 대한 정의입니다. 특정 영역에 대해서 Agent가 Jump를 통해서만 이동할 수 있는 등의 특수한 경로라고 보면 될 것같습니다.



네비게이션 편집을 위해서 [Window] - [AI] - [Navigation] 메뉴로 진입합니다.
처음에는 아무것도 없는 Level이 표시됩니다. Navigation Mesh도 Baking 한다고 표현합니다.

[Object] 탭으로 진행하여 [Mesh Renderers] 필터를 클릭하고, Hierarchy 뷰에서 NavMesh를 정의할 GameObject들을 선택하고 "Navigation Static [v]"을 Check하여 주고, Navigation Area는 "Walkable"을 선택하여 줍니다. 예제에서는 Floor와 이동가능한 계단으로 설정된 Cube들을 선택하였습니다.
이렇게 Navigation Static [v]을 선택해야 NavMesh에서 사용되는 영역으로 Baking 됩니다.

Terrain이 있는 경우 [Terrains] 탭으로 진입하여 마찬가지로 "Navigation Static [v]"과 "Walkable"을 선택해주면 됩니다.


[Bake] 탭으로 이동하여 아래와 같이 각종 옵션들을 설정하고 [Bake]를 클릭하여 Baking을 시작합니다.

+ Agent Radius: 0.5
+ Agent Height: 2
+ Max Slope: 45
+ Step Height: 0.3

아래 2개의 값은 "Off Mesh Link"를 Baking 하게될 때 참고하는 값입니다. 여기서는 자동적으로 하지 않을 것이라서 0으로 처리합니다.

+ Drop Height: 0
+ Jump Distance: 0

Baking을 완료하면 GameObject가 이동할 수 있는 NavMesh가 생성된 것을 확인할 수 있습니다.


GameObject가 걸어 올라갈 수 없는 컨테이너, 박스 같은 오브젝트는 "Not Walkable"로 설정하는 것이 좀 더 좋겠지요.



Scene View를 보는 상태로 플레이를 진행해 봅니다. Game View에서 특정 위치를 클릭하면 자동으로 계산된 NavMesh를 보여주며 캐릭터가 이동하는 것을 볼 수 있습니다. 이를 활용하면 NavMesh와 NavMeshAgent가 정상동작하는지 등을 디버깅하는데 도움이 됩니다.





Off Mesh Link는 컴포넌트입니다.
Jump_Link GameObject에 "Off Mesh Link" 컴포넌트를 추가합니다.
Jump_Link GameObject의 자식으로 "Start", "End"라는 Sphere를 2개 구성해줍니다.
Start 구는 언덕 위에 배치하고, End 구는 Floor 위에 배치하여 줍니다.

[Off Mesh Link] 옵션을 설정합니다.
+ Start = Start (Transform) - 위에 설정한 Start GameObject 설정
+ End = End (Transform) - 위에 설정한 End GameObject 설정
+ Cost Override = -1 - 길찾기를 할 때 현재 Off Mesh Link가 얼마정도의 길찾기 비용이 들어가는가 설정
+ Bi Directional = [ ] - Check를 해주게 되면 양방향 이동이 가능하게 됩니다. 여기서는 언덕에서 내려오는 것이므로 Uncheck 합니다.
+ Activated = [v] - 현재 Off Mesh Link의 활성화 여부. 특정 아이템을 가진 경우에만 이동 가능과 같은 기능을 구현할 때 사용.
+ Auto Update Positions = [v] - Agent에 대한 위치를 자동화으로 업데이트
+ Navigation Area = Jump



실행하면 다음과 같이 구동되는 것을 볼 수 있습니다. "Bi Directional [v]"을 Check하면 End 점에서 Start 점으로 날아서 점프하는 멋진 모습을 볼 수 있네요 ㅋㅋㅋ



Off Mesh Link 자동 생성

[Bake] 탭 - [Generated Off Mesh Links]의 값에서
+ Drop Height = 5
+ Jump Distance = 5
정도로 설정을 해줍니다.

[Object] 탭 - Floor와 Cube로 구성된 Stair를 선택하고 "Generate OffMeshLinks [v]"를 체크한 후에 [Bake]를 클릭하여 Baking 하면 됩니다. 아래와 같이 생성된 Off Mesh Link를 확인할 수 있습니다.






동적 장애물에 대한 NavMeshObstacle 설정하기.

Cube GameObject를 하나 추가합니다.
컴포넌트 추가를 하여 "Nav Mesh Obstacle"을 검색하여 추가합니다.

설정값중.
Carve = [v] - Agent가 이 Object에 대한 회피값 설정
Move Threshold = 0.1 - 이동시 얼마나 회피할 것인가
Time To Stationary = 0.5 - 이 Object가 이동할 때 내부 Mesh를 얼마나 빨리 갱신할 것인가


구동을 해보면 실시간으로 이동가능한 경로가 생기거나 사라지게 하거나 하는 것이 가능합니다.




앞으로 강의에 사용하게 될 Map을 소개해 주시네요.


와우 멋집니다 ^^~ㅎㅎ 대신 저 정도의 퀄러티는 유료 에셋을 구매하여 구성한 것이라고 합니다.
유료 에셋은 게임에 어느정도 최적화된 에셋들이 에셋 스토어에 많기 때문에 포트폴리오 제작 등에 많이 도움이 될 것입니다.

해당 레벨은 총 3개의 방으로 구성되어 있습니다.
방의 연결 부분들은 문이나 함정 등을 설정하여 사용할 수 있도록 할 예정입니다.
방마다 조금씩 다른 Lighting System을 적용해 두었고, Light Probe도 적용되어 있습니다. 현재 촘촘하게 하여 Texture가 18장이라 메모리 용량이 커진 상태입니다.
위에서 공부한 NavMesh도 적용하여 이동가능한 위치 설정이 완료된 상태입니다.




문같은 테스트를 위해 Cube 오브젝트를 적당한 크기로 방과 방 사이에 배치해 주고 "Nav Mesh Obstacle"을 설정하여 캐릭터가 이동하지 못하게 바로 설정이 가능합니다.



포트폴리오를 위한 게임 레벨을 구성을 할때의 팁입니다.

+ Random dungeon generator watabou.itch.io/one-page-dungeon

 

One Page Dungeon by watabou

One page dungeon generator

watabou.itch.io

무작정 맵을 그리는 것이 아니라 미리 어느 정도 잘 구성된 맵을 구성하여 사용가능합니다.

+ 스냅핑 기능을 위한 ProGrids
 [Window] - Package Manager - Advanced - Show preview packages에서 "ProGrids"를 검색하여 설치합니다.
 오브젝트들의 배치들을 편하게 도와주는 기능이라고 보시면 됩니다.

 
 
 
 
 

  
게임 캐릭터의 AI 구현하기
 
AI 구현 모델들
+ Finit State Machine
+ Behavior Tree
+ 캐릭터 AI 구현
+ 캐릭터 시야 및 향상된 AI 구현 




좌측 Finit State Machine 유한 상태 머신은 캐릭터의 상태 Transition에 대한 제어를 직관적으로 할 수 있습니다.
우측 Behavior Tree는 캐릭터의 행동에 대한 기능과 제약을 직관적으로 할 수 있습니다.

FSM
한번에 하나의 상태만 가능합니다. 한 상태에서 다른 상태로의 이전은 Transition입니다.
사각형 박스는 Node, 화살표는 Transition이라고 보면 됩니다.

내용중 주황색으로 표시된 "player is near"를 보면 캐릭터가 돌아다니는 상태에서 주변에 다른 플레이어나 적이 나타나는 경우 "공격" state로 전환되는 transition이 발생하게 됩니다.

Behavior Tree의 Node 3가지 종류
+ Control node = Selector(특정 행동 선택), Sequence(자식 노드들을 순차적으로 수행) 등.
+ Decorator node = if문, loop문 등
+ Execution node = Action. 특정 행동을 수행.



예를 들어 위와 같은 특정 루틴을 수행해야 하는 시퀀스가 있다고 했을 때, 가진 돈이 200원 미만이 되면 "If Decorator" node가 failure를 부모 "Sequence"에 return하고, 결론적으로 전체 root가 Failure로 처리되어 특정 Sequence 동작 전체가 Failure 상태임을 처리할 수가 있게 됩니다.

성공 상황은 쉽겠지요 ^^.  하위 노드가 모두 수행되어 "Action 6"인 의자에 앉는다 까지 모두 Success로 성공하게 되면, 모든 Sequence 동작이 정상적으로 수행되었음을 "Success"로 처리되어 확인하게 됩니다.

이렇듯 Behavior Tree는 특정 흐름들을 제어할 수 있도록 도와주는 AI 모델이라고 보면 됩니다.


아직 구현은 하지 않았지만 벌써 상상이 가네요 ㅠ.ㅜ; Node Editor 같은 화면에서 함수들을 저렇게 Tree 구조로 배치하면 동적으로 조건들이 처리될테고.. 기존 if-else로 지저분하게 만들던 코드들을 깔끔하게 처리할 수 있겠네요. 정말 머리 좋습니다. 대단 대단.




패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 7회차 미션 시작합니다.


Lighting 시스템 알아보기 2




Light Probe는 Bake된 조명정보를 Scene에 저장하는 기법입니다.
Light Map은 표면에 대한 정보를 저장하고, Light Probe는 공간에 대한 정보를 저장하는 것입니다.

Light Probe는 Spherical Harmonic 기법을 사용합니다. 복잡한 계산을 처리합니다.
Dynamic object에 Global illumination 효과를 주기 위해서 사용합니다.

Reflection Probe는 Cube 맵의 주변 환경을 Textutre로 처리하는 것이라고 이해하면 됩니다.
상하좌우앞뒤를 카메라로 촬영한 이미지를 object에 입혔다고 보면 됩니다.

유니티에서는 실시한 reflection으로 처리가 가능하지만 성능의 문제가 발생할 수 있으므로, 덜 중요한 object들에는 reflection probe로 처리된 이미지를 object에 baking하여 사용하는 방식을 보통 취합니다.



Light Probe 설정 방법


Light Probe Group을 추가합니다.
Light Probe Group을 조밀하게 설정하는 것이 좋기는 하지만, Baking에 시간이 많이 소요되고 Light Map의 개수가 많이 늘어나서 필요한 메모리의 양이 많이 높아지기 때문에 적절한 설정을 해주어야 합니다.

"Edit Light Probes"를 선택한 이후에 Vertex들을 선택하고 Ctrl+C Ctrl+V로 복사하여 확장해 나아갑니다.

벽, 즉 object들 주위로는 조명 변화가 많이 일어나므로 조밀하게 설정하는 것이 좋습니다. 그리고 Generate로 Baking을 진행하면 됩니다.




Sphere를 추가하여 공간에서 이동하여 보면 영향을 받는 Light Probe들이 표시가 되어 확인이 가능합니다.

PlayerCharacter를 가지고 플레이를 시작해보면 캐릭터의 위치에 따라 조명의 영향을 받는 것을 볼 수 있습니다.

 





Reflection Probe 설정 방법



Light - Reflection Probe를 추가합니다.
Type - Baked로 진행합니다. "Bake"를 누르면 ReflectionProbe가 생성되는 것을 확인할 수 있습니다.

타겟 Platform에 맞는 옵션으로 지속적으로 최적화하는 노력이 필요합니다.






 

Terrain 시스템

 



Terrain 시스템에서는 공간배치, 지형 높이, 홀 배치, 나무와 풀 배치, Height Map 등을 할 수 있습니다.

나무는 Mesh object로 저정을 하지만 풀은 개수가 많기 때문에 billboard로 처리를 하게 됩니다. 물론 detail함을 위해 object로 처리할 수도 있습니다.
나무의 윈드존 설정 가능합니다. 바람이 불어 흔들리는 효과입니다.

시스템에 많은 영향을 주게 되므로 Terrain Object의 개수나 Texture의 크기 등을 고려하여 개발해주어야 합니다.





[Window] - TextMeshPro 를 선택하여 편집 설정 창이 나타납니다.

원하는 타일을 선택하여 Terrain을 지정한 후, Brush 툴을 선택하고 옵션 값들을 조정한 후, LMB 와 Shift + LMB 등을 활용하여 높이를 조절합니다.

Paint Texture 등을 이용하여 특정 Texture를 입히며 그릴 수도 있고, Terrain Hole 등으로 던전 입구를 표시한다거나 할 수가 있습니다. 다양한 기능과 옵션을 사용하여 구성해 봅니다.




Wind Settings for Grass (On Terrain Data)
풀이 얼마나 자동으로 흔들릴지를 설정하는 부분입니다.

Wind Zone 오브젝트를 추가하면 나무에 바람의 영향을 줄 수 있습니다.

Terrain 상태에서도 Global Illumination 설정과 Light Probe Group 등을 설정하여 테스트를 해보면 지형에 영향을 받는 조명 시스템을 구성할 수 있습니다.


 

캐릭터가 빨간 조명이나 던전 입구의 노란 조명에 영향을 받는 것을 확인할 수 있습니다.





패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 


[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 6회차 미션 시작합니다.


6회차 플레이어 카메라, 라이팅 시스템에 대한 교육 시간입니다.


일단 저번 시간에 이어 카메라 에디터 기능을 확장하는 것에 대한 내용입니다.
카메라 기능을 Scene Editor에서 수정할 수 있도록 Editor 기능을 확장하는 것입니다.

TopDownCamera 스크립트와 별개로 동작하는 스크립트가 필요합니다.
/Assets/ 위치에서 "Create C# Script" - "TopDownCamera_SceneEditor" Script를 만들어서 더블클릭하여 Editor로 진입합니다.

 


TopDownCamera_SceneEditor.cs

Editor를 확장하는 것이므로, Editor를 상속받는 클래스로 변경해주고, Start() Update() 함수는 제거합니다.
그리고 중요한 CustomEditor 임을 지정하는 코드를 추가합니다.

[CustomEditor(typeof(TopDownCamera))] //## 지정자를 지정해야지만 Scene View에 표시됨.
public class TopDownCamera_SceneEditor : Editor
{
}

 


필요한 변수들을 추가해줍니다.

private TopDownCamera targetCamera;


타겟 카메라를 받아오기 위해 OnInspectorGUI() 함수를 구현합니다.

public override void OnInspectorGUI()
{
  targetCamera = (TopDownCamera)target;
  base.OnInspectorGUI();
}

받아온 카메라에 대한 로직을 구현하기 위해서 OnSceneGUI() 함수를 구현합니다.

 


private void OnSceneGUI()
{
  if (!targetCamera || !targetCamera.target)
  {
    return;
  }
  
  Transform cameraTarget = targetCamera.target;
  Vector3 targetPosition = cameraTarget.position;
  targetPosition.y += targetCamera.lookAtHeight; // 타겟 카메라가 바라보는 위치값
    
  Handles.color = new Color(1f, 0f, 0f, 0.15f);
  Handles.DrawSolidDisc(targetPosition, Vector3.up, targetCamera.distance);
  
  Handles.color = new Color(0f 1f, 0f, 0.75f);
  Handles.DrawWireDisc(targetPosition, Vector3.up, targetCamera.distance);
  
  // Slider 기능 추가
  Handles.color = new Color(1f, 0f, 0f, 0.5f);
  targetCamera.distance = Handles.ScaleSlider(targetCamera.distance, targetPosition, -cameraTarget.forward, Quaternion.identity, targetCamera.distance, 0.1);

  targetCamera.distance = Mathf.Clamp(targetCamera.distance, 2f, float.MaxValue); // 최소, 최대 지정
  
  Handles.color = new Color(0f, 0f, 1f, 0.5f);
  targetCamera.height = Handles.ScaleSlider(targetCamera.height, targetPosition, Vector3.up, Quaternion.identity, targetCamera.height, 0.1f);

  targetCamera.height = Mathf.Clamp(targetCamera.height, 2f, float.MaxValue);


  GUIStyle labelStyle = new GUIStyle();
  labelStyle.fontSize = 15;
  labelStyle.normal.textColor = Color.white;
  
  labelStyle.alignment = TextAnchor.UpperCenter;

  Handles.Label(targetPosition + (-cameraTarget.forward * targetCamera.distance), "Distance", labelStyle);
 
  labelStyle.alignment = TextAnchor.MiddleRight;
  Handles.Label(targetPosition + (Vector3.up * targetCamera.height), "Height", labelStyle);
  
  targetCamera.HandleCamera(); // TopDownCamera 스크립트의 HandleCamera() 함수를 public으로 수정.
}

 


이렇게 ScaleSlider()를 사용하여 Camera distance와 height를 가변하는 기능으로 확장한 것입니다.
Debug 상태에서 지저분한 라인들을 표시하지 않기 위해 TopDownCamera 스크립트에서 Debug.DrawLine() 부분들을 주석처리하여 제거합니다.



플레이를 진행해보면 Scene View에서 Camera를 조절하여 Game View에 실시간으로 적용되어 구동되는 것을 확인할 수 있습니다. 대단하네요. 그냥 단순한 Object의 배치만이 아니라 각종 기능들을 추가함으로써 좀더 개발자 및 디자이너, 기획자에게 유용한 도구로 확장할 수가 있습니다.

캐릭터에 AI를 표시한다던지 게임요소들의 디버깅을 위해 정보를 표시한다던지 하는 형태로 확장할 수 있는 것이므로 꼭 카메라만이 아니라 여러 클래스에서 유용한 정보입니다.


 

 

 

 

 


이전까지 캐릭터 구현 및 카메라 구현등을 완료하였다면, 이제 게임 환경 관련 구축하는 내용입니다.

 

+ Lighting의 기본 개념 - 3D 게임중에 중요한 부분중 하나가 Lighting에 대한 이해와 활용입니다. 
+ Lighting Mapping 알아보기 - Lighting을 Texture에 입혀서 사용하는 방법입니다.
+ Light/Reflection Probe 알아보기 - 동적인 오브젝트에 대한 Global Illumination 처리.
+ 지형 시스템 알아보기 - 유니티에 내장된 Terrain System.
+ 네비게이션 시스템 알아보기 - 유니티에 내장된 Navigation System


라이팅이란 광원에서 반사된 빛이 물체에 반사되어 이것을 카메라로 보는 것입니다. 음영인 부분은 빛이 반사되지 않는 부분을 말합니다.

이와 관련하여 3D Graphics에서 보이는 색상을 계산하는 기본 공식입니다.


3D Graphics에서는 Ambient와 Diffuse Color 2가지를 조합하여 Final Color를 나타냅니다.
공식이 조금씩 다를 수는 있지만 보통 3D를 처리하는 OpenGL과 같은 Shader 기능에서는 비슷한 공식을 사용한다고 보면 됩니다.

이렇게 직접 계산하는 방식을 Direct Illumination이라고 합니다.
하지만 실제로는 한 오브젝트에 여러 Lighting이 적용되어 보여지는 경우가 많겠지요.

 

 

 


위와 같은 상태에서 오른쪽 구의 좌측면에는(마우스 위치) 아주 옅지만 붉은색 조명이 방사된 빛이 비치는 걸 볼 수 있습니다.

이러한 조명 처리를 Indirect Illumination이라고 합니다.

그리고 빛은 반사되는 물체에 따라 여러가지 반응을 나타내게 됩니다. 아래처럼 말이죠.


+ Transmission - 투영
+ Reflection - 반사
+ Refraction - 굴절
+ Diffraction - 회절
+ Adsorption - 흡착
+ Scattering - 산란

결국 Direct와 Indirect Lighting이 합쳐져서 Global Illumination이 만들어지게 되는 것이네요.
우리가 익히 알고 있는 자연현상이지만 이렇듯 하나하나 쪼개보니 이 또한 재미지네요.. 결국 이런 것이 물리학의 시작이겠지요.. ㅎㅎ



하지만 조명값을 실시간 계산하려면 부하가 많이 걸리므로 LightMapping 기능을 많이 사용하게 됩니다.

정적인 Object에 대한 Direct Lighting과 Indrect Lighting에 대한 Global Illumination 결과값을 Texture에 저장해 놓고 Object에 입혀서 사용하는 방식인 것입니다. 이러한 기능을 유니티에서는 "Baking"이라고 합니다.

유니티에서 베이킹은 시간이 걸리는 작업이라 준비된 프로젝트로 진행합니다.



유니티에서 Baking 작업하기.

일단 Global Illumination입니다.

+ Type: Directional
+ Color:
+ Mode: Mixed (Realtime | Mixed | Baked)
  '- Realtime - 실시간으로 모든 Object에 영향을 미칩니다. (Global Illumination Baking에는 포함되지 않음)
  '- Baked - 정적인 Object에만 영향을 미칩니다.
  '- Mixed - 정적인 Object에는 Lighting 기법을 사용하고, 동적인 Object에는 실시간 계산을 합니다.

 


Lighting 속성 설정 방법

[Window] - Rendering - Lighting Settings

+ Skybox Material: Default-Skybox
+ Sun Source: Directional Light (Light)
+ Source: Skybox
+ Realtime Global Illumination [ ] - 실시간 태양의 각도 변화를 따라 변화하는 것등을 할 수 있지만 많은 Performance를 요구하기 때문에 꼭 필요한 곳에서 잘 활용해야겠습니다.

 


기본적으로 태양광으로 설정되어 있으며, 던전 같은 Scene의 작업이라면 위의 값을 모두 삭제하여 "None" 처리를 하면 됩니다.

Lightmapper는 예전에는 Enlighten을 사용하였었는데 옆에 Deprecated 표시된 것처럼 향후에 사라질 예정입니다. 현시점에서의 유니티에서는 개발 상태에서 Progressive GPU로 테스트를 하고 Build할 때 Progressive CPU로 하는 방식을 추천하고 있습니다.

[Genrate Light]을 누르면 Baking을 준비하게 됩니다. 시간이 걸린 이후에 "Baked Lightmaps"에서 생성된 Texture를 볼 수 있으며, 이를 확인하기 위해 다음을 진행합니다.

 

 


[Scene View] - [Shaded] - Baked Lightmap


와우 예쁘네요 ^^~
이렇게 처리하여 결국 정적인 Object에서도 Global Illumination의 효과를 볼 수가 있게된 것입니다.

녹색, 빨강, 파랑, 흰색 등 오브젝트가 가진 색상에 따라 각각 처리되고 있습니다.



움직이는 캐릭터에는 어떤 영향을 미치게 될까요?


실행을 해보면 캐릭터는 주변 오브젝트들의 Lighting(Global Illumination)에 영향을 받지 않고, Direct Light에 설정한 직접 광원에만 영향을 받고 있습니다.

이걸 처리하기 위해 Light Probe를 처리해주어야 하고 다음 시간에 이어지겠습니다 ^^~

 

 


패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

+ Recent posts