[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 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

 

+ Recent posts