PORTFOLIO/개인

[개인 프로젝트] Become an Adventurer

푸쿠이 2019. 11. 20. 21:02

RPG 장르를 만들게 된 이유

간단한 아케이드 게임과 기능 구현을 해보는 연습만 했기 때문에, 나도 하나의 게임을 만들어 보고 싶었다.

 

개발 기간

2019.10.10. ~ 2019.11.21. (약 6주)

 

영상

 

소리는 없습니다..

 

소스코드

https://github.com/mingyu0403/BecomeAnAdventurer

 

코드 구현 특징

- GameManager, GameUIManager 등 싱글톤 기법을 이용.

- 변수의 Get Set 프로퍼티를 이용한 UI 데이터 바인딩.

- 코루틴을 이용하여 최적화.

 

- 자주 쓰이는 오브젝트는 Object Pooling을 이용.

◎ 설정 값만큼 생성해놓고, 부족하면 더 생성함.

 

- 클래스들은 상속을 하여 유지보수하기 쉽게 구현.

◎ 모든 퀘스트는 Quest 추상 클래스를 상속.

◎ 모든 몬스터는 Monster 추상 클래스를 상속.

 

- 선형 보간 Lerp를 통한 UI Slider 값, 카메라 값 변경의 부드러움 추가.

 

- 몬스터

◎ Enum 타입 정의.

◎ AI 유한상태머신(FSM) 이용.

◎ 몬스터 크기에 따른 Hit Effect 크기 구현.

 

- 플레이어

◎ 행동을 취할 때마다 Delegate Event 발생. (함수 체인을 걸어서 확장 가능.)

◎ 퀘스트 수락 시, Delegate Event에 함수 추가.

◎ 퀘스트 완료 시, Delegate Event에 함수 제거.

◎ Player가 Camera Forward 를 기준으로 이동하기 때문에, 회전벡터 공식  사용.

 

- NPC

◎ Enum 타입 정의

◎ ( ${PlayerName}, 오랜만이야!! ) 처럼 string Replace를 통해 구현.

 

- 랜덤 버프

◎ 플레이어 스탯을 담고 있는 PlayerInfo 클래스를 기초 형태로 가지고 있음.

◎ 버프 발동 시, (Buff 클래스의 PlayerInfo)와 (Player 클래스의 PlayerInfo)의 능력치를 계산.

 

- UI

◎ 코루틴을 이용한 쿨타임 계산.

 

주요 코드

플레이어, NPC, 몬스터 의 상태 변화 Enum

// 플레이어 애니메이션 타입
public enum AnimationType
{
	IDLE,
	RUN,
	ATTACK1,
	DAMAGE,
	JUMP,
	DEATH,
	VICTORY
}

 

플레이어의 행동 이벤트 감지

    [HideInInspector] public List<Quest> questList = new List<Quest>();

    public Action<Quest> AddQuestEvent;
    public Action<Quest> RemoveQuestEvent;
    public Action<Quest> CompleteQuestEvent;
    public Action<Monster> MonsterKillEvent;
    public Action<PlayerInfo> LevelUpEvent;

 

플레이어 스탯의 레벨 당 추가 값

(공격력, 공격속도, 이동속도, 크리티컬 확률, 체력, 마나, 다음 레벨에 필요한 경험치량)

    // 공격 파워
    private int _attackPowerStatic;
    public int attackPowerStatic
    {
        set
        {
            _attackPowerStatic = value;
        }
        get
        {
            int result = _attackPowerStatic + ((level-1) * 5); // 기본 값 + 레벨당 추가 값 
            return result;
        }
    }

 

씬 전환 시, 씬 로드 이벤트 발생

현재 씬을 관리하는 오브젝트를 찾아서 포탈 위치를 검색한 뒤, 플레이어가 포탈 위치로 이동.

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        currentSceneName = scene.name;

        // Debug.Log("씬 교체됨, 현재 씬: " + scene.name);
        
        CurrentSceneManager currentSceneManager = GameObject.FindWithTag("SCENE_MANAGER").GetComponent<CurrentSceneManager>();
        
        // 플레이어 위치를 포탈 위치로 옮김
        GameManager.instance.playerManager.transform.position = currentSceneManager.PortalList[portalNumber].position;

        // NPC들에게 퀘스트 진행 세팅
        GameManager.instance.playerManager.SetQuestToAllNPC();

        // 씬 전환 효과
        GameUiManager.instance.ShowFadeIn();
    }

 

플레이어의 버프 제작

(객체를 통해 원하는대로 제작 가능)

    private Buff AttackPowerBuff()
    {
        Buff buff = new Buff();
        buff.duration = UnityEngine.Random.Range(8, 16); // 8 ~ 15
       
        int RandomUpgradeValue = UnityEngine.Random.Range(3, 11); // 3 ~ 10

        buff.buffName = ChanageColorMsg(buff.duration) + "초 동안 " + ChanageColorMsg_Green("공격력") + " " + (RandomUpgradeValue * 10) + "% 증가";
        buff.buffExplain = ChanageColorMsg(buff.duration) + "초 동안 " + ChanageColorMsg_Green("공격력") + "이 " + (RandomUpgradeValue * 10) + "% 만큼 증가합니다.";

        buff.buffImage = GameUiManager.instance.ICON_AttackPower;

        buff.changeAttackPower = true;
        buff.bonusStatus.attackPowerMul = 1 + RandomUpgradeValue / 10.0f;
        
        return buff;
    }

 

플레이어의 퀘스트

 

Quest_LevelUp 클래스

  • 추상클래스 Quest를 상속함.
  • Override를 통한 공통 메소드 구현, 퀘스트 진행 판정 함수를 LevelUpEvent에 달아 줌.
    // 퀘스트 세팅
    public override void SetQuestEvent()
    {
        GameManager.instance.playerManager.LevelUpEvent += QuestProgress;
        GameManager.instance.playerManager.AddQuest(this);
        
        // 레벨을 넘었을 수도 있으니까 한번 판정함.
        Set_StrQuestProgress();
        GameUiManager.instance.questListUiController.UpdateQuestUI(this);

        if (GameManager.instance.playerManager.playerInfo.level >= targetLevel)
        {
            base.questProgressType = Quest.QuestProgressType.SUCCESS;
            GameUiManager.instance.Notice("퀘스트 \'" + base.shortDescription + "\' 달성", false);
        }
    }

	// 퀘스트 제거
    public override void RemoveQuestEvent()
    {
        GameManager.instance.playerManager.LevelUpEvent -= QuestProgress;
        GameManager.instance.playerManager.RemoveQuest(this);
    }
    
    // 퀘스트 진행
    public void QuestProgress(PlayerInfo playerInfo)
    {
        // 퀘스트 진행 중일 때만
        if (base.questProgressType != Quest.QuestProgressType.PROGRESS)
        {
            return;
        }
        Set_StrQuestProgress();

        int curr_Level = playerInfo.level;
        if (curr_Level >= targetLevel)
        {
            base.questProgressType = Quest.QuestProgressType.SUCCESS;
            GameUiManager.instance.Notice("퀘스트 \'" + base.shortDescription + "\' 달성", false);
        }
        GameUiManager.instance.questListUiController.UpdateQuestUI(this);
    }

 

Quest_MonsterKill 클래스

  • 추상클래스 Quest를 상속함.
  • Override를 통한 공통 메소드 구현, 퀘스트 진행 판정 함수를 MonsterKillEvent에 달아 줌.
// 퀘스트 세팅
public override void SetQuestEvent()
{
	GameManager.instance.playerManager.MonsterKillEvent += QuestProgress;
	GameManager.instance.playerManager.AddQuest(this);
}

// 퀘스트 제거
public override void RemoveQuestEvent()
{
	GameManager.instance.playerManager.MonsterKillEvent -= QuestProgress;
	GameManager.instance.playerManager.RemoveQuest(this);
}

// 퀘스트 진행
public void QuestProgress(Monster monsterCtrl)
{
	// 퀘스트 진행 중일 때만
	if (base.questProgressType != Quest.QuestProgressType.PROGRESS)
	{
	return;
	}

	// 퀘스트 진행 판정
	if (monsterCtrl.nickname == monsterName)
	{
		curr_Count++;
		Set_StrQuestProgress();

		if (curr_Count >= count)
		{
			base.questProgressType = Quest.QuestProgressType.SUCCESS;
			GameUiManager.instance.Notice("퀘스트 \'" + base.shortDescription + "\' 달성" , false);
		}
		GameUiManager.instance.questListUiController.UpdateQuestUI(this);
	}
}

 

스킬 범위 미리보기 구현

    void LaunchProjectile()
    {
        Ray camRay = cam.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

		// layer에 닿을 때만 적용 
        if (Physics.Raycast(camRay, out hit, 100f, layer))
        {
            cursor.SetActive(true);
            cursor.transform.position = hit.point;

			// 포물선 계산
            Vector3 vo = CalculateVelocity(hit.point, shootPoint.position, 1f);
			
            // ProjectTile을 이용한 시각화
            Visualize(vo);

            transform.rotation = Quaternion.LookRotation(vo);

            if (Input.GetMouseButtonDown(0))
            {
                Rigidbody obj = Instantiate(Skill1_ShootedSphere, shootPoint.position, Quaternion.identity);
                obj.velocity = vo;
                SkillRealActive();
                Hide();
            }
        }
    }
    
    // 포물선 계산
    Vector3 CalculateVelocity(Vector3 target, Vector3 origin, float time)
    {
        Vector3 distance = target - origin;
        Vector3 distanceXZ = distance;
        distanceXZ.y = 0f;

        float Sy = distance.y;
        float Sxz = distanceXZ.magnitude;

        float Vxz = Sxz / time;
        float Vy = Sy / time + 0.5f * Mathf.Abs(Physics.gravity.y) * time;

        Vector3 result = distanceXZ.normalized;
        result *= Vxz;
        result.y = Vy;

        return result;
    }
    
    // ProjectTile을 이용한 시각화
    void Visualize(Vector3 vo)
    {
        for (int i = 0; i < lineSegment; i++)
        {
            Vector3 pos = CalculatePosInTime(vo, i / (float)lineSegment);
            lineVisual.SetPosition(i, pos);
        }
    }
    
    // 포물선 중에서 초에 따른 Vector3 값 구함.
    Vector3 CalculatePosInTime(Vector3 vo, float time)
    {
        Vector3 Vxz = vo;
        Vxz.y = 0f;

        Vector3 result = shootPoint.position + vo * time;
        float sY = (-0.5f * Mathf.Abs(Physics.gravity.y) * (time * time)) + (vo.y * time) + shootPoint.position.y;

        result.y = sY;

        return result;
    }
    

 

몬스터 FSM

	StartCoroutine(this.CheckMonsterState()); // 0.2초마다 상태 변화해주기
	StartCoroutine(this.MonsterAction()); // 상태에 따른 행동 변화

    IEnumerator CheckMonsterState()
    {
        float GoHome = 0;
        AngryPower = 0;
        while (!isDie)
        {
            yield return new WaitForSeconds(0.2f);
            
            float dist = (playerTr.position - monsterTr.position).sqrMagnitude;
         
            if(dist <= attackDist * attackDist)
            {
                monsterState = MonsterState.ATTACK;
            }
            else if (dist <= traceDist * traceDist || isAngry)
            {
                monsterState = MonsterState.TRACE;

                GoHome = 0; // 참을성 초기화
                if (AngryPower > 3) // 3초동안 아무 일 없으면, 플레이어를 쫒아오지 않음.
                {
                    isAngry = false;
                }
                else
                {
                    AngryPower += 0.2f;
                }
            }
            else
            {
                float d = (OringinPos - monsterTr.position).sqrMagnitude;

                if (d <= 9f) // 원래 위치랑 가까우면 그냥 있음.
                {
                    monsterState = MonsterState.IDLE;
                    GoHome = 0;
                }
                else // 멀면 카운트 세다가 3초가 되면, 홈으로 돌아감
                {

                    if (GoHome > 3) // 3초동안 아무 일 없으면, 원래 위치로 돌아감.
                    {
                        monsterState = MonsterState.HOME;
                    }
                    else
                    {
                        monsterState = MonsterState.IDLE;
                        GoHome += 0.2f;
                    }
                }
            }
        }

        Die();
    }

    IEnumerator MonsterAction()
    {
        while (!isDie)
        {
            switch (monsterState)
            {
                case MonsterState.IDLE:
                    nvAgent.isStopped = true;
                    anim.SetBool("WALK", false);
                    break;
                case MonsterState.HOME:
                    nvAgent.isStopped = false;
                    nvAgent.destination = OringinPos;
                    anim.SetBool("WALK", true);
                    RotateTowards(OringinPos);
                    break;
                case MonsterState.TRACE:
                    if (isAttack)
                    {
                        nvAgent.isStopped = true;
                    } else
                    {
                        nvAgent.isStopped = false;
                    }
                    nvAgent.destination = playerTr.position;
                    anim.SetBool("WALK", true);
                    RotateTowards(playerTr.position);
                    break;
                case MonsterState.ATTACK:
                    if (isAttack)
                    {
                        break;
                    }
                    anim.SetTrigger("ATTACK");
                    break;
            }
            yield return null;
        }
    }

 

느낀 점

 

JAVA를 배우면서 객체를 처음 배웠고, C# Winform을 배우면서 객체를 활용해 본 적이 있다.

 

이번에 Unity C#을 이용해 게임을 개발하면서

퀘스트, 몬스터, 버프 등 각각의 기능을 구현하면서 객체를 활용하는 것이 재밌었다.

 

플레이어가 가지고 있는 데이터와 UI를 바인딩하는 것도 손이 많이 갔다.