기록 보관소

[Unity/유니티] 기초-3D 쿼터뷰 액션 게임: 다양한 패턴을 구사하는 보스 만들기[B50] 본문

유니티 프로젝트/3D 쿼터뷰 액션게임

[Unity/유니티] 기초-3D 쿼터뷰 액션 게임: 다양한 패턴을 구사하는 보스 만들기[B50]

JongHoon 2022. 4. 7. 00:16

개요

유니티 입문과 독학을 위해서 아래 링크의 골드메탈님의 영상들을 보며 진행 상황 사진 또는 캡처를 올리고 배웠던 점을 요약해서 적는다.

현재는 영상들을 보고 따라하고 배우는 것에 집중할 것이며, 영상을 모두 보고 따라한 후에는 개인 프로젝트를 설계하고 직접 만드는 것이 목표다.

https://youtube.com/playlist?list=PLO-mt5Iu5TeYI4dbYwWP8JqZMC9iuUIW2 

 

유니티 강좌 기초 채널 Basic

유니티 개발을 처음 시작하시는 입문자 분들을 위한 기초 채널. [ 프로젝트 ] B00 ~ B12 (BE1) : 유니티 필수 기초 B13 ~ B19 (BE2) : 2D 플랫포머 B20 ~ B26 (BE3) : 2D 탑다운 대화형 RPG B27 ~ B37 (BE4) : 2D 종스크롤

www.youtube.com


3D 쿼터뷰 액션 게임: 다양한 패턴을 구사하는 보스 만들기[B50]

1. 보스 기본 세팅

보스로 사용할 Enemy D 생성
Enemy C 애니메이터를 복사해서 Enemy D로 바꾼다음, Mesh Object에 컴포넌트로 등록
Enemy D의 애니메이션들을 추가 및 수정해주고 Transition을 설정해준다
Trigger형 매개변수 3개를 추가하고, Transition들을 설정해준다.

  • Transition은 이전 애니메이션 작업들과 같다. AnyState -> @는 Transition Duration을 0, Has Exit Time을 체크 해제하고, @ -> Exit은 Transition Duration만 0.1로 변경해준다.

Enemy D에 Rigidbody와 Box Collider, Nav Mesh Agent 컴포넌트를 추가
보스가 미사일을 발사할 위치에 Empty를 생성
Tag와 Layer 설정
보스가 점프 공격 범위를 위한 Empty 생성. Collider를 추가해서 범위를 지정한다.

  • 애니메이션 작동 테스트를 위해 실행해보니 크기가 작아지는 문제가 있었다. 그래서 댓글을 찾아보니 이는 애니메이션을 통해 Scale이 고정되는 문제이므로, Enemy D의 MeshArea가 아닌 Enemy D의 Scale을 3으로 바꿔주면 게임 실행시 문제 없을 것이라고 해서 이후 파트 진행하면서 중간에 바꿔주었다.

2. 투사체(미사일) 만들기

보스가 패턴으로 사용할 바위와 미사일을 생성
Z축이 방향에 미사일이 가야할 방향에 맞도록 Mesh Object의 Y rotation을 변경하고, Missile 스크립트를 추가
미사일 불꽃을 표현할 오브젝트를 추가하여 Particle System을 설정
이제 Missile에 Rigidbody와 Box Collider, Nav Mesh Agent 컴포넌트와 Bullet 스크립트 파일을 추가

  • 그런데 Bullet 스크립트는 분명 보스의 미사일에 기능에 필요하지만, 유도 미사일로서의 기능을 추가하기에는 기존에 Bullet 스크립트 파일을 사용하는 오브젝트 전체에 영향이 가므로 무리가 있다. 따라서, 새로운 스크립트를 만들어 Bullet을 상속받는 형식으로 진행한다.

BossMissile 스크립트 파일 생성

//BossMissile 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class BossMissile : Bullet {
	public Transform target;
	NavMeshAgent nav;

	void Awake() {
		nav = GetComponent<NavMeshAgent>();
	}

	void Update() {
		nav.SetDestination(target.position);
    }
}

Boss Missile 스크립트 파일로 교체
값을 넣어준다
실행해보니 잘 따라오는 미사일
Bullet 스크립트 파일 내용때문에 벽에 닿으니 미사일이 사라졌다


3. 투사체(바위) 만들기

BossRock 스크립트 파일 생성

//BossRock 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BossRock : Bullet {
	Rigidbody rigid;
	float angularPower = 2;
	float scaleValue = 0.1f;
	bool isShoot;

	void Awake() {
		rigid = GetComponent<Rigidbody>();
		StartCoroutine(GainPowerTimer());
		StartCoroutine(GainPower());
	}

	IEnumerator GainPowerTimer() {	//발사 타이밍 제어
		yield return new WaitForSeconds(2.2f);
		isShoot = true;
	}

	IEnumerator GainPower() {	//발사 전 기 모으기
		while (!isShoot) {
			angularPower += 0.02f;
			scaleValue += 0.005f;
			transform.localScale = Vector3.one * scaleValue;
			rigid.AddTorque(transform.right * angularPower, ForceMode.Acceleration);	//Acceleration : 가속도 형태?
			yield return null;	//while문 안에 넣지 않으면 게임 정지 문제 발생 가능
		}
	}
}
  • 미사일과 마찬가지의 이유로 Bullet을 상속받도록 한다.
//Bullet 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bullet : MonoBehaviour {
	public int damage;
	public bool isMelee;
	public bool isRock;

	void OnCollisionEnter(Collision collision) {
		if (!isRock && collision.gameObject.tag == "Floor")    //탄피
			Destroy(gameObject, 3); //3초 뒤에 사라지기
	}

	void OnTriggerEnter(Collider other) {	//총알을 isTrigger로 바꾸었으므로
		if(!isMelee && other.gameObject.tag == "Wall")   //총알
			Destroy(gameObject);
	}
}
  • 보스가 패턴으로 사용한 것은 3초 뒤에 사라지면 안되니까 Bullet 스크립트에 이를 구별하는 bool형 변수를 추가해 조건으로 이를 막는다.

BossRock 설정

  • SphereCollider를 2개로 한 이유는 하나는 트리거로 이용해서 벽이나 플레이어에 닿았을때 사라지게 하기위함이다.

실행직후는 작았다가
조금 지나니 크기가 매우 커지고 굴러가기 시작한다.
굴러가다가 벽에 닿으니
사라졌다
마찬가지로 플레이어에 닿아도 사라지고, 피해를 준다

+) 추가

  • 위 실행화면을 보면 보이듯 바위가 원본 크기보다 훨씬 커진다. 그래서 간단하게 코루틴의 while문에 scaleValue에 제한을 거는 if문을 추가해서 1이하면 scale을 증가시키고, 아니면 증가시키지 않도록 만들어서 해결했다.
//BossRock 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BossRock : Bullet {
	Rigidbody rigid;
	float angularPower = 2;
	float scaleValue = 0.1f;
	bool isShoot;

	void Awake() {
		rigid = GetComponent<Rigidbody>();
		StartCoroutine(GainPowerTimer());
		StartCoroutine(GainPower());
	}

	IEnumerator GainPowerTimer() {	//발사 타이밍 제어
		yield return new WaitForSeconds(2.2f);
		isShoot = true;
	}

	IEnumerator GainPower() {	//발사 전 기 모으기
		while (!isShoot) {
			angularPower += 0.02f;
			scaleValue += 0.005f;
			if (scaleValue < 1)
				transform.localScale = Vector3.one * scaleValue;
			rigid.AddTorque(transform.right * angularPower, ForceMode.Acceleration);	//Acceleration : 가속도 형태?
			yield return null;	//while문 안에 넣지 않으면 게임 정지 문제 발생 가능
		}
	}

더 이상 커지지않고 원본 Scale인 1에 가까운 Scale을 유지 한다.
보스 크기를 잠시 늘려 비교한 모습
작업이 끝난 투사체들은 모두 프리펩으로 만들어준다. 당연히 Position값들도 초기화한다.


4. 보스 로직 준비

//Enemy 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Enemy : MonoBehaviour {
	public enum Type { A, B, C, D };
	public Type enemyType;
	public int maxHealth;
	public int curHealth;
	public Transform target;
	public BoxCollider meleeArea;
	public GameObject bullet;
	public bool isChase;
	public bool isAttack;

	Rigidbody rigid;
	BoxCollider boxCollider;
	MeshRenderer[] meshs;
	NavMeshAgent nav;
	Animator anim;

	void Awake() {
		rigid = GetComponent<Rigidbody>();
		boxCollider = GetComponent<BoxCollider>();
		meshs = GetComponentsInChildren<MeshRenderer>();
		nav = GetComponent<NavMeshAgent>();
		anim = GetComponentInChildren<Animator>();

		if (enemyType != Type.D)
			Invoke("ChaseStart", 2);
	}

	void ChaseStart() {
		isChase = true;
		anim.SetBool("isWalk", true);
	}

	void Update() {
		if (nav.enabled && enemyType != Type.D) {
			nav.SetDestination(target.position);
			nav.isStopped = !isChase;
		}
	}

	void FreezeVelocity() {
		if (isChase) {
			rigid.velocity = Vector3.zero;
			rigid.angularVelocity = Vector3.zero;
		}
	}

	void Targeting() {
		if (enemyType != Type.D) {
			float targetRadius = 0;
			float targetRange = 0;

			switch (enemyType) {
				case Type.A:
					targetRadius = 1.5f;
					targetRange = 3f;
					break;
				case Type.B:
					targetRadius = 1f;
					targetRange = 12f;
					break;
				case Type.C:
					targetRadius = 0.5f;
					targetRange = 25f;
					break;
			}

			RaycastHit[] rayHits = Physics.SphereCastAll(transform.position, targetRadius, transform.forward, targetRange, LayerMask.GetMask("Player"));

			if (rayHits.Length > 0 && !isAttack) {
				StartCoroutine(Attack());
			}
		}
	}

	IEnumerator Attack() {
		isChase = false;
		isAttack = true;
		anim.SetBool("isAttack", true);

		switch(enemyType) {
			case Type.A:
				yield return new WaitForSeconds(0.2f);
				meleeArea.enabled = true;

				yield return new WaitForSeconds(1f);
				meleeArea.enabled = false;

				yield return new WaitForSeconds(1f);
				break;
			case Type.B:
				yield return new WaitForSeconds(0.1f);
				rigid.AddForce(transform.forward * 20, ForceMode.Impulse);
				meleeArea.enabled = true;

				yield return new WaitForSeconds(0.5f);
				rigid.velocity = Vector3.zero;
				meleeArea.enabled = false;

				yield return new WaitForSeconds(2f);
				break;
			case Type.C:
				yield return new WaitForSeconds(0.5f);
				GameObject instantBullet = Instantiate(bullet, transform.position, transform.rotation);
				Rigidbody rigidBullet = instantBullet.GetComponent<Rigidbody>();
				rigidBullet.velocity = transform.forward * 20;

				yield return new WaitForSeconds(2f);
				break;
		}

		isChase = true;
		isAttack = false;
		anim.SetBool("isAttack", false);
	}

	void FixedUpdate() {
		Targeting();
		FreezeVelocity();
	}

	void OnTriggerEnter(Collider other) {
		if (other.tag == "Melee") {
			Weapon weapon = other.GetComponent<Weapon>();
			curHealth -= weapon.damage;
			Vector3 reactVec = transform.position - other.transform.position;

			StartCoroutine(OnDamage(reactVec, false));
		}
		else if (other.tag == "Bullet") {
			Bullet bullet = other.GetComponent<Bullet>();
			curHealth -= bullet.damage;
			Vector3 reactVec = transform.position - other.transform.position;
			Destroy(other.gameObject);

			StartCoroutine(OnDamage(reactVec, false));
		}
	}

	public void HitByGrenade(Vector3 explosionPos) {
		curHealth -= 100;
		Vector3 reactVec = transform.position - explosionPos;

		StartCoroutine(OnDamage(reactVec, true));
	}

	IEnumerator OnDamage(Vector3 reactVec, bool isGrenade) {
		foreach(MeshRenderer mesh in meshs)
			mesh.material.color = Color.red;

		yield return new WaitForSeconds(0.1f);

		if (curHealth > 0) {
			foreach (MeshRenderer mesh in meshs)
				mesh.material.color = Color.white;
		}
		else {
			foreach (MeshRenderer mesh in meshs)
				mesh.material.color = Color.gray;
			gameObject.layer = 12;  //Enemy Dead 레이어로 변경
			isChase = false;
			nav.enabled = false;
			anim.SetTrigger("doDie");

			if (isGrenade) {
				reactVec = reactVec.normalized;
				reactVec += Vector3.up * 5;
				rigid.freezeRotation = false;
				rigid.AddForce(reactVec * 5, ForceMode.Impulse);
				rigid.AddTorque(reactVec * 15, ForceMode.Impulse);
			}
			else {
				reactVec = reactVec.normalized;
				reactVec += Vector3.up;
				rigid.AddForce(reactVec * 5, ForceMode.Impulse);
			}

			if(enemyType != Type.D)
				Destroy(gameObject, 4);
		}
	}
}
  • 보스 또한 Enemy 스크립트 파일을 상속받을 것이므로, 보스 타입인 D를 배열에 추가해주고 이 보스에 맞지 않는 일부 동작들(Chase 등)을 조건을 달아서 보스에게는 작동하지 않도록 수정해주었다.
  • 추가로 보스와 이전 시간에 만들었던 Enemy C의 경우, 여러가지 Material을 사용해서 피격시 일부분만 색상에 변화가 생기게 된다. 그래서 플레이어처럼 MeshRenderer를 통해서 모든 Material이 변경되도록 수정해주었다.

보스가 사용할 스크립트 파일 Boss 생성
보스에게 넣어준다


5. 기본 로직

//Boss 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Boss : Enemy {
	public GameObject missile;
	public Transform missilePortA;
	public Transform missilePortB;

	Vector3 lookVec;
	Vector3 tauntVec;
	bool isLook;

	void Start() {
		isLook = true;
	}

	void Update() {
		if (isLook) {
			float h = Input.GetAxisRaw("Horizontal");
			float v = Input.GetAxisRaw("Vertical");
			lookVec = new Vector3(h, 0, v) * 5f;	//플레이어의 입력값으로 예측 벡터값 생성
			transform.LookAt(target.position + lookVec);
		}
	}
}
  • 주석 설명처럼 플레이어 입력 값을 통해서 해당 값 조금 더 앞의 예측 벡터값을 생성해서 보스가 그곳을 바라보도록 만들었다.

Boss 스크립트 변수들을 채워준다
이동하니 해당 방향으로 몸을 돌리는 보스
반대로 이동하니 그쪽을 바라보고있다


6. 패턴 로직

 

//Enemy 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Enemy : MonoBehaviour {
	public enum Type { A, B, C, D };
	public Type enemyType;
	public int maxHealth;
	public int curHealth;
	public Transform target;
	public BoxCollider meleeArea;
	public GameObject bullet;
	public bool isChase;
	public bool isAttack;
	public bool isDead;

	public Rigidbody rigid;
	public BoxCollider boxCollider;
	public MeshRenderer[] meshs;
	public NavMeshAgent nav;
	public Animator anim;

	void Awake() {
		rigid = GetComponent<Rigidbody>();
		boxCollider = GetComponent<BoxCollider>();
		meshs = GetComponentsInChildren<MeshRenderer>();
		nav = GetComponent<NavMeshAgent>();
		anim = GetComponentInChildren<Animator>();

		if (enemyType != Type.D)
			Invoke("ChaseStart", 2);
	}

	void ChaseStart() {
		isChase = true;
		anim.SetBool("isWalk", true);
	}

	void Update() {
		if (nav.enabled && enemyType != Type.D) {
			nav.SetDestination(target.position);
			nav.isStopped = !isChase;
		}
	}

	void FreezeVelocity() {
		if (isChase) {
			rigid.velocity = Vector3.zero;
			rigid.angularVelocity = Vector3.zero;
		}
	}

	void Targeting() {
		if (!isDead && enemyType != Type.D) {
			float targetRadius = 0;
			float targetRange = 0;

			switch (enemyType) {
				case Type.A:
					targetRadius = 1.5f;
					targetRange = 3f;
					break;
				case Type.B:
					targetRadius = 1f;
					targetRange = 12f;
					break;
				case Type.C:
					targetRadius = 0.5f;
					targetRange = 25f;
					break;
			}

			RaycastHit[] rayHits = Physics.SphereCastAll(transform.position, targetRadius, transform.forward, targetRange, LayerMask.GetMask("Player"));

			if (rayHits.Length > 0 && !isAttack) {
				StartCoroutine(Attack());
			}
		}
	}

	IEnumerator Attack() {
		isChase = false;
		isAttack = true;
		anim.SetBool("isAttack", true);

		switch(enemyType) {
			case Type.A:
				yield return new WaitForSeconds(0.2f);
				meleeArea.enabled = true;

				yield return new WaitForSeconds(1f);
				meleeArea.enabled = false;

				yield return new WaitForSeconds(1f);
				break;
			case Type.B:
				yield return new WaitForSeconds(0.1f);
				rigid.AddForce(transform.forward * 20, ForceMode.Impulse);
				meleeArea.enabled = true;

				yield return new WaitForSeconds(0.5f);
				rigid.velocity = Vector3.zero;
				meleeArea.enabled = false;

				yield return new WaitForSeconds(2f);
				break;
			case Type.C:
				yield return new WaitForSeconds(0.5f);
				GameObject instantBullet = Instantiate(bullet, transform.position, transform.rotation);
				Rigidbody rigidBullet = instantBullet.GetComponent<Rigidbody>();
				rigidBullet.velocity = transform.forward * 20;

				yield return new WaitForSeconds(2f);
				break;
		}

		isChase = true;
		isAttack = false;
		anim.SetBool("isAttack", false);
	}

	void FixedUpdate() {
		Targeting();
		FreezeVelocity();
	}

	void OnTriggerEnter(Collider other) {
		if (other.tag == "Melee") {
			Weapon weapon = other.GetComponent<Weapon>();
			curHealth -= weapon.damage;
			Vector3 reactVec = transform.position - other.transform.position;

			StartCoroutine(OnDamage(reactVec, false));
		}
		else if (other.tag == "Bullet") {
			Bullet bullet = other.GetComponent<Bullet>();
			curHealth -= bullet.damage;
			Vector3 reactVec = transform.position - other.transform.position;
			Destroy(other.gameObject);

			StartCoroutine(OnDamage(reactVec, false));
		}
	}

	public void HitByGrenade(Vector3 explosionPos) {
		curHealth -= 100;
		Vector3 reactVec = transform.position - explosionPos;

		StartCoroutine(OnDamage(reactVec, true));
	}

	IEnumerator OnDamage(Vector3 reactVec, bool isGrenade) {
		foreach(MeshRenderer mesh in meshs)
			mesh.material.color = Color.red;

		yield return new WaitForSeconds(0.1f);

		if (curHealth > 0) {
			foreach (MeshRenderer mesh in meshs)
				mesh.material.color = Color.white;
		}
		else {
			foreach (MeshRenderer mesh in meshs)
				mesh.material.color = Color.gray;
			gameObject.layer = 12;  //Enemy Dead 레이어로 변경
			isDead = true;
			isChase = false;
			nav.enabled = false;
			anim.SetTrigger("doDie");

			if (isGrenade) {
				reactVec = reactVec.normalized;
				reactVec += Vector3.up * 5;
				rigid.freezeRotation = false;
				rigid.AddForce(reactVec * 5, ForceMode.Impulse);
				rigid.AddTorque(reactVec * 15, ForceMode.Impulse);
			}
			else {
				reactVec = reactVec.normalized;
				reactVec += Vector3.up;
				rigid.AddForce(reactVec * 5, ForceMode.Impulse);
			}

			if(enemyType != Type.D)
				Destroy(gameObject, 4);
		}
	}
}
//Boss 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Boss : Enemy {
	public GameObject missile;
	public Transform missilePortA;
	public Transform missilePortB;

	Vector3 lookVec;
	Vector3 tauntVec;
	bool isLook;

	void Awake() {
		rigid = GetComponent<Rigidbody>();
		boxCollider = GetComponent<BoxCollider>();
		meshs = GetComponentsInChildren<MeshRenderer>();
		nav = GetComponent<NavMeshAgent>();
		anim = GetComponentInChildren<Animator>();

		nav.isStopped = true;
		StartCoroutine(Think());
	}

	void Update() {
		if (isDead) {
			StopAllCoroutines();
			return;
		}

		if (isLook) {
			float h = Input.GetAxisRaw("Horizontal");
			float v = Input.GetAxisRaw("Vertical");
			lookVec = new Vector3(h, 0, v) * 5f;    //플레이어의 입력값으로 예측 벡터값 생성
			transform.LookAt(target.position + lookVec);
		}
		else
			nav.SetDestination(tauntVec);
	}

	IEnumerator Think() {
		yield return new WaitForSeconds(0.1f);

		int ranAction = Random.Range(0, 5);	//확률 설정
		switch(ranAction) {
			case 0:
			case 1:
				//미사일 발사 패턴
				StartCoroutine(MissileShot());
				break;
			case 2:
			case 3:
				//돌 굴러가는 패턴
				StartCoroutine(RockShot());
				break;
			case 4:
				//점프 공격 패턴
				StartCoroutine(Taunt());
				break;
		}
	}

	IEnumerator MissileShot() {
		anim.SetTrigger("doShot");
		yield return new WaitForSeconds(0.2f);
		GameObject instantMissileA = Instantiate(missile, missilePortA.position, missilePortA.rotation);
		BossMissile bossMissileA = instantMissileA.GetComponent<BossMissile>();
		bossMissileA.target = target;

		yield return new WaitForSeconds(0.3f);
		GameObject instantMissileB = Instantiate(missile, missilePortB.position, missilePortB.rotation);
		BossMissile bossMissileB = instantMissileB.GetComponent<BossMissile>();
		bossMissileB.target = target;

		yield return new WaitForSeconds(2f);

		StartCoroutine(Think());
	}

	IEnumerator RockShot() {
		isLook = false;
		anim.SetTrigger("doBigShot");
		Instantiate(bullet, transform.position, transform.rotation);
		yield return new WaitForSeconds(3f);

		isLook = true;
		StartCoroutine(Think());
	}

	IEnumerator Taunt() {
		tauntVec = target.position + lookVec;

		isLook = false;
		nav.isStopped = false;
		boxCollider.enabled = false;
		anim.SetTrigger("doTaunt");

		yield return new WaitForSeconds(1.5f);
		meleeArea.enabled = true;

		yield return new WaitForSeconds(0.5f);
		meleeArea.enabled = false;

		yield return new WaitForSeconds(1f);
		isLook = true;
		nav.isStopped = true;
		boxCollider.enabled = true;
		StartCoroutine(Think());
	}
}
  • Awake() 함수는 자식 스크립트만 단독 실행된다. 따라서 Enemy 스크립트에서 Rigidbody 등을 public으로 전환하고 Awake()에서 초기화를 해주더라도 Boss에서는 실행되지 않게된다. 그래서 Awake()를 Start()로 바꾸거나, 위 코드처럼 Boss 스크립트에서도 Awake()에서 초기화해주는 방식으로 해결할 수 있다.

7. 로직 점검

점프 공격 범위인 Melee Area에 Bullet 스크립트 파일 추가 및 설정

  • 점프 공격 범위인 Melee Area에 Bullet 스크립트 파일이 없어서 플레이어가 EnemyBullet에 맞았는데도 피해량 등을 계산하지 못해서 오류가 뜨는 문제가 있었다. 그래서 스크립트 파일을 추가해서 이를 해결해주었다.
//Player 스크립트 파일

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player : MonoBehaviour {
    public float speed;
	public GameObject[] weapons;
	public bool[] hasWeapons;
	public GameObject[] grenades;
	public int hasGrenades;
	public GameObject grenadeObj;
	public Camera followCamera;

	public int ammo;
	public int coin;
	public int health;

	public int maxAmmo;
	public int maxCoin;
	public int maxHealth;
	public int maxHasGrenades;

	float hAxis;
    float vAxis;

    bool wDown;
    bool jDown;
	bool fDown;
	bool gDown;
	bool rDown;
	bool iDown;
	bool sDown1;	//망치
	bool sDown2;	//권총
	bool sDown3;	//기관총

    bool isJump;
	bool isDodge;
	bool isSwap;
	bool isReload;
	bool isFireReady = true;
	bool isBorder;
	bool isDamage;

    Vector3 moveVec;
	Vector3 dodgeVec;

    Animator anim;
    Rigidbody rigid;
	MeshRenderer[] meshs;

	GameObject nearObject;
	Weapon equipWeapon;
	int equipWeaponIndex = -1;
	float fireDelay;

    void Awake() {
        rigid = GetComponent<Rigidbody>();
        anim = GetComponentInChildren<Animator>();  //Player 자식 오브젝트에 있으므로
		meshs = GetComponentsInChildren<MeshRenderer>();
    }

    void Update() {
        GetInput();
        Move();
        Turn();
        Jump();
		Grenade();
		Attack();
		Reload();
		Dodge();
		Swap();
		Interation();
    }

    void GetInput() {
        hAxis = Input.GetAxisRaw("Horizontal");	//좌우 방향키
        vAxis = Input.GetAxisRaw("Vertical");	//상하 방향키
        wDown = Input.GetButton("Walk");	//shift 키
		jDown = Input.GetButtonDown("Jump");	//스페이스바
		fDown = Input.GetButton("Fire1");   //마우스 왼쪽 클릭
		gDown = Input.GetButton("Fire2");   //마우스 오른쪽 클릭
		rDown = Input.GetButtonDown("Reload");	//R키
		iDown = Input.GetButtonDown("Interation");	//E키
		sDown1 = Input.GetButton("Swap1");	//번호 1번 키
		sDown2 = Input.GetButton("Swap2");	//번호 2번 키
		sDown3 = Input.GetButton("Swap3");	//번호 3번 키
	}

    void Move() {
        moveVec = new Vector3(hAxis, 0, vAxis).normalized;  //normalized : 방향 값이 1로 보정된 벡터

		if (isDodge)	//회피 중일때는
			moveVec = dodgeVec; //회피하는 중인 방향으로 유지

		if (isSwap || isReload || !isFireReady)	//무기 교체, 재장전, 공격 중일때는
			moveVec = Vector3.zero;	//멈추기

		if (!isBorder)
			transform.position += moveVec * speed * (wDown ? 0.3f : 1f) * Time.deltaTime;

		anim.SetBool("isRun", (moveVec != Vector3.zero));   //이동을 멈추면
        anim.SetBool("isWalk", wDown);
    }

    void Turn() {
		//키보드로 회전
		transform.LookAt(transform.position + moveVec); //나아갈 방향 보기

		//마우스로 회전
		if (fDown) {
			Ray ray = followCamera.ScreenPointToRay(Input.mousePosition);
			RaycastHit rayHit;
			if (Physics.Raycast(ray, out rayHit, 100)) {
				Vector3 nextVec = rayHit.point - transform.position;
				nextVec.y = 0;
				transform.LookAt(transform.position + nextVec);
			}
		}
    }

    void Jump() {
        if (jDown && (moveVec == Vector3.zero) && !isJump && !isDodge && !isSwap) {	//움직이지 않고 점프
            rigid.AddForce(Vector3.up * 15, ForceMode.Impulse);
			anim.SetBool("isJump", true);
			anim.SetTrigger("doJump");
			isJump = true;
        }
    }

	void Grenade() {
		if (hasGrenades == 0)
			return;

		if (gDown && !isReload && !isSwap) {
			Ray ray = followCamera.ScreenPointToRay(Input.mousePosition);
			RaycastHit rayHit;
			if (Physics.Raycast(ray, out rayHit, 100)) {
				Vector3 nextVec = rayHit.point - transform.position;
				nextVec.y = 10;

				GameObject instantGrenade = Instantiate(grenadeObj, transform.position, transform.rotation);
				Rigidbody rigidGrenade = instantGrenade.GetComponent<Rigidbody>();
				rigidGrenade.AddForce(nextVec, ForceMode.Impulse);
				rigidGrenade.AddTorque(Vector3.back * 10, ForceMode.Impulse);

				hasGrenades--;
				grenades[hasGrenades].SetActive(false);
			}
		}
	}

	void Attack() {
		if (equipWeapon == null)
			return;

		fireDelay += Time.deltaTime;
		isFireReady = (equipWeapon.rate < fireDelay);

		if (fDown && isFireReady && !isDodge && !isSwap && !isReload) {
			equipWeapon.Use();
			anim.SetTrigger(equipWeapon.type == Weapon.Type.Melee ? "doSwing" : "doShot");
			fireDelay = 0;
		}
	}

	void Reload() {
		if (equipWeapon == null)
			return;

		if (equipWeapon.type == Weapon.Type.Melee)
			return;

		if (ammo == 0)
			return;

		if (rDown && !isJump && !isDodge && !isSwap && isFireReady) {
			anim.SetTrigger("doReload");
			isReload = true;

			Invoke("ReloadOut", 3);
		}
	}

	void ReloadOut() { 
		int reAmmo = (ammo < equipWeapon.maxAmmo) ? ammo : equipWeapon.maxAmmo;
		equipWeapon.curAmmo = reAmmo;
		ammo -= reAmmo;
		isReload = false;
	}

	void Dodge() {
		if (jDown && (moveVec != Vector3.zero) && !isJump && !isDodge && !isSwap) {    //이동하면서 점프
			dodgeVec = moveVec;
			speed *= 2;
			anim.SetTrigger("doDodge");
			isDodge = true;

			Invoke("DodgeOut", 0.4f);
		}
	}

	void DodgeOut() {
		speed *= 0.5f;
		isDodge = false;
	}

	void Swap() {
		if (sDown1 && (!hasWeapons[0] || equipWeaponIndex == 0))	return;
		if (sDown2 && (!hasWeapons[1] || equipWeaponIndex == 1))	return;
		if (sDown3 && (!hasWeapons[2] || equipWeaponIndex == 2))	return;

		int weaponIndex = -1;
		if (sDown1) weaponIndex = 0;
		if (sDown2) weaponIndex = 1;
		if (sDown3) weaponIndex = 2;

		if ((sDown1 || sDown2 || sDown3) && !isJump && !isDodge) {
			if (equipWeapon != null)
				equipWeapon.gameObject.SetActive(false);

			equipWeaponIndex = weaponIndex;
			equipWeapon = weapons[weaponIndex].GetComponent<Weapon>();
			equipWeapon.gameObject.SetActive(true);

			anim.SetTrigger("doSwap");
			isSwap = true;
			Invoke("SwapOut", 0.4f);
		}
	}

	void SwapOut() {
		isSwap = false;
	}

	void Interation() {
		if (iDown && nearObject != null && !isJump && !isDodge) {
			if (nearObject.tag == "Weapon") {
				Item item = nearObject.GetComponent<Item>();
				int weaponIndex = item.value;
				hasWeapons[weaponIndex] = true;

				Destroy(nearObject);
			}
		}
	}

	void FreezeRotation() {
		rigid.angularVelocity = Vector3.zero;
	}

	void StopToWall() {
		Debug.DrawRay(transform.position, transform.forward * 5, Color.green);
		isBorder = Physics.Raycast(transform.position, moveVec, 5, LayerMask.GetMask("Wall"));
	}

	void FixedUpdate() {
		FreezeRotation();
		StopToWall();
	}

	void OnCollisionEnter(Collision collision) {
		if (collision.gameObject.tag == "Floor") {
			anim.SetBool("isJump", false);
			isJump = false;
		}
	}

	void OnTriggerEnter(Collider other) {
		if (other.tag == "Item") {
			Item item = other.GetComponent<Item>();
			switch(item.type) {
				case Item.Type.Ammo:
					ammo += item.value;
					if (ammo > maxAmmo)
						ammo = maxAmmo;
					break;
				case Item.Type.Coin:
					coin += item.value;
					if (coin > maxCoin)
						coin = maxCoin;
					break;
				case Item.Type.Heart:
					health += item.value;
					if (health > maxHealth)
						health = maxHealth;
					break;
				case Item.Type.Grenade:
					if (hasGrenades == maxHasGrenades)
						return;

					grenades[hasGrenades].SetActive(true);
					hasGrenades += item.value;
					if (hasGrenades > maxHasGrenades)
						hasGrenades = maxHasGrenades;
					break;
			}
			Destroy(other.gameObject);
		}
		else if (other.tag == "EnemyBullet") {
			if (!isDamage) {
				Bullet enemyBullet = other.GetComponent<Bullet>();
				health -= enemyBullet.damage;


				bool isBossAtk = other.name == "Boss Melee Area";
				StartCoroutine(OnDamage(isBossAtk));
			}

			if (other.GetComponent<Rigidbody>() != null)    //Rigidbody 유무를 판단(미사일)
				Destroy(other.gameObject);
		}
	}

	IEnumerator OnDamage(bool isBossAtk) {
		isDamage = true;
		foreach(MeshRenderer mesh in meshs) {
			mesh.material.color = Color.yellow;
		}

		if (isBossAtk)
			rigid.AddForce(transform.forward * -25, ForceMode.Impulse);

		yield return new WaitForSeconds(1f);

		isDamage = false;

		foreach (MeshRenderer mesh in meshs) {
			mesh.material.color = Color.white;
		}

		if (isBossAtk)
			rigid.velocity = Vector3.zero;
	}

	void OnTriggerStay(Collider other) {
		if (other.tag == "Weapon")
			nearObject = other.gameObject;
	}

	void OnTriggerExit(Collider other) {
		if (other.tag == "Weapon")
			nearObject = null;
	}
}
  • Player 스크립트에서 플레이어가 피해를 받은 직후 미사일에 맞게되면 이 미사일이 없어지지 않는 문제가 있어서 EnemyBullet을 맞았을 때(!isDamage) 조건문 안에 있는 Destroy() 조건문을 바깥으로 옮겨서 해결하였다.
  • 또한, 보스가 점프 공격 후 보스 Collider가 돌아오면서 플레이어가 멀리 튕겨나가는 문제가 있어서, 보스의 Collider에 닿기 전에 플레이어가 뒤로 튕겨나가는 로직을 추가해서 이를 해결해주었다. 또한 좀 더 직관성을 위해서 위의 보스 점프 공격 범위인 Melee Area를 Boss Melee Area로 이름을 변경해주었다.

돌 굴리기 공격을 하는 보스
미사일 공격을 하는 보스
보스의 점프 공격
맞으니 튕겨져나간다
보스 처치 모습