[Project] FPS Prototype(15)
in Unity
아이템 오브젝트를 만들어 보자.
체력 회복 아이템
(1) 체력 회복 아이템에 적용 할 Material(HealthItem)을 만든다.
(2) 체력 회복 아이템 제작을 위해 Cube(healthItem)을 만든다. 위치(0 / 0.3 / 3), 크기(0.5 / 0.5 / 0.5), IsTrigger(true), Materials(HealthItem)
(3) HealthItem 오브젝트를 프리팹으로 만든다. Item태그를 새로 만들고 HealthItem프리팹에 태그를 Item으로 바꾼다.
(4) 맵에 임의로 HealthItem프리팹을 2개 배치한다.
(5) 체력 회복 이펙트에 사용할 Material(HealthEffect)을 만든다.
- Shader : Particles / Standard Unlit
- Rendering Mode : Fade
- Texture : HealthEffect
(6) 체력 회복 이펙트 제작을 위해 파티클시스템(HealthEffect)을 하나 만든다.
Main
Duration : 2
Looping : false
Start LifeTime : 2
Start Speed : 2
Start Size : 0.1 - 0.5
Start Color : (23 / 212 / 23 / 255)
Max Particles : 50
Emission
- Rate over Time : 50
Shape
- Radius : 0.1
- Radius : Thickness : 0.1
- Rotation (270 / 0 / 0)
- Size over LifeTime
- Size : 1.0 to 0.0 curve line graph
- Renderer
- Material : HealthEffect
(7) 체력 회복 이펙트 오브젝트를 프리팹으로 만든다.
(8) HealthEffect프리팹에 ParticleAutoDestroyerByTime스크립트를 추가한다.
(9) Status스크립트를 수정한다.(HP 증가 함수를 추가한다.)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
[System.Serializable]
public class HPEvent : UnityEvent<int,int> { }
public class Status : MonoBehaviour
{
[HideInInspector]
public HPEvent onHPEvent=new HPEvent();
[Header("Walk, Run Speed")]
[SerializeField]
private float walkSpeed;
[SerializeField]
private float runSpeed;
[Header("HP")]
[SerializeField]
private int maxHP = 100;
private int currentHP;
public float WalkSpeed => walkSpeed;
public float RunSpeed => runSpeed;
public int CurrentHP => currentHP;
public int MaxHP => maxHP;
private void Awake()
{
currentHP = maxHP;
}
public bool DecreaseHP(int damage)
{
int previousHP = currentHP;
currentHP=currentHP- damage> 0 ? currentHP - damage : 0;
onHPEvent.Invoke(previousHP,currentHP);
if (currentHP == 0)
{
return true;
}
return false;
}
/*●*/public void IncreaseHP(int hp)
/*●*/{
/*●*/ int previousHP=currentHP;
/*●*/ currentHP=currentHP+hp > maxHP ? maxHP : currentHP + hp;
/*●*/
/*●*/ onHPEvent.Invoke(previousHP,currentHP);
/*●*/}
}
(10) PlayerHUD스크립트를 수정한다.(체력이 증가하면 빨간색 화면이 나오지 않도록 수정)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class PlayerHUD : MonoBehaviour
{
[Header("Components")]
[SerializeField]
private WeaponAssaultRifle weapon; // 현재 정보가 출력되는 무기
[SerializeField]
private Status status; // 플레이어의 상태 (이동속도, 체력)
[Header("Weapon Base")]
[SerializeField]
private TextMeshProUGUI textWeaponName; // 무기 이름
[SerializeField]
private Image imageWeaponIcon; // 무기 아이콘
[SerializeField]
private Sprite[] spriteWeaponIcons; // 무기 아이콘에 사용되는 sprite 배열
[Header("Ammo")]
[SerializeField]
private TextMeshProUGUI textAmmo; // 현재/최대 탄 수 출력 text
[Header("Magazine")]
[SerializeField]
private GameObject magazineUIPrefab; // 탄창 UI 프리펩
[SerializeField]
private Transform magazineParent; // 탄창 UI가 배치되는 Panel
private List<GameObject> magazineList; // 탄창 UI 리스트
[Header("HP & BloodScreen UI")]
[SerializeField]
private TextMeshProUGUI textHP; // 플레이어의 체력을 출력하는 text
[SerializeField]
private Image imageBloodScreen; // 플레이어가 공격받았을 때 화면에 표시되는 Image
[SerializeField]
private AnimationCurve curveBloodScreen;
private void Awake()
{
SetupWeapon();
SetupMagazine();
// 메소드가 등록되어 있는 이벤트 클래스 (weapon.xx)의
// Invoke() 메소드가 호출될 때 등록된 메소드(매개변수)가 실행된다.
weapon.onAmmoEvent.AddListener(UpdateAmmoHUD);
weapon.onMagazineEvent.AddListener(UpdateMagazineHUD);
status.onHPEvent.AddListener(UpdateHPHUD);
}
private void SetupWeapon()
{
textWeaponName.text=weapon.WeaponName.ToString();
imageWeaponIcon.sprite=spriteWeaponIcons[(int)weapon.WeaponName];
}
private void UpdateAmmoHUD(int currentAmmo,int maxAmmo)
{
textAmmo.text=$"<size=40>{currentAmmo}/</size>{maxAmmo}";
}
private void SetupMagazine()
{
// weapon애 등록되어 있는 최대 탄창 개수만큼 Image Icon을 생성
// magazineParent 오브젝트의 자식으로 등록 후 모두 비활성화/리스트에 저장
magazineList=new List<GameObject>();
for(int i=0;i<weapon.MaxMagazine;i++)
{
GameObject clone = Instantiate(magazineUIPrefab);
clone.transform.SetParent(magazineParent);
clone.SetActive(false);
magazineList.Add(clone);
}
// weapon에 등록되어 있는 현재 탄창 개수만큼 오브젝트 활성화
for(int i=0;i<weapon.CurrentMagazine;i++)
{
magazineList[i].SetActive(true);
}
}
private void UpdateMagazineHUD(int currentmagazine)
{
// 전부 비활성화하고, currentMagazine 개수만큼 활성화
for(int i=0; magazineList.Count > i; i++)
{
magazineList[i].SetActive(false);
}
for(int i = 0; i < currentmagazine; i++)
{
magazineList[i].SetActive(true);
}
}
private void UpdateHPHUD(int previous,int current)
{
textHP.text = "HP " + current;
// 체력이 증가했을 때는 화면에 빨간색 이미지를 출력하지 않도록 return
/*●*/if (previous <= current) return;
if(previous - current > 0)
{
StopCoroutine("OnBloodScreen");
StartCoroutine("OnBloodScreen");
}
}
private IEnumerator OnBloodScreen()
{
float percent = 0;
while (percent < 1)
{
percent+=Time.deltaTime;
Color color = imageBloodScreen.color;
color.a=Mathf.Lerp(1,0,curveBloodScreen.Evaluate(percent));
imageBloodScreen.color=color;
yield return null;
}
}
}
(11) 아이템 오브젝트가 상속받는 기반 클래스 ItemBase스크립트를 만든다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class ItemBase : MonoBehaviour
{
public abstract void Use(GameObject entity);
}
(12) 체력 회복 아이템을 제어하는 ItemMedicBag스크립트를 만든다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ItemMeicBag : ItemBase
{
[SerializeField]
private GameObject hpEffectPrefab;
[SerializeField]
private int increaseHP = 50;
[SerializeField]
private float moveDistance = 0.2f;
[SerializeField]
private float pingpongSpeed = 0.5f;
[SerializeField]
private float rotateSpeed = 50;
private IEnumerator Start()
{
float y = transform.position.y;
while (true)
{
// y축을 기준으로 회전
transform.Rotate(Vector3.up*rotateSpeed*Time.deltaTime);
// 처음 배치된 위치를 기준으로 y 위치를 위, 아래로 이동
Vector3 position= transform.position;
position.y = Mathf.Lerp(y,y+moveDistance,Mathf.PingPong(Time.time*pingpongSpeed,1));
transform.position=position;
yield return null;
}
}
public override void Use(GameObject entity)
{
entity.GetComponent<Status>().IncreaseHP(increaseHP);
Instantiate(hpEffectPrefab,transform.position,Quaternion.identity);
Destroy(gameObject);
}
}
(13) HealthItem프리팹에 ItemMedicBag컴포넌트를 추가하고, 컴포넌트 변수들을 설정한다.
(14) 플레이어와 다른 오브젝트의 충돌을 처리하기 위해 Player오브젝트 자식으로 빈오브젝트(PlayerCollider)를 만든다.
(15) 충돌처리를 위해 CapsuleCollider,Rigidbody를 추가 및 설정 해준다. IsTrigger(true), Height(2), Use Gravity(false) Constraints(모든 항목 true)
(16) 레이어 충돌 설정을 위해 Physics탭으로 가서 Player와 Player의 충돌을 해제한다.
(17) 플레이어의 충돌을 제어하는 PlayerCollider스크립트를 만든다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerCollider : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
if (other.transform.tag == "Item")
{
other.transform.GetComponent<ItemBase>().Use(transform.parent.gameObject);
}
}
}
(18) PlayerCollider오브젝트에 PlayerCollider스크립트를 추가한다.
이제 게임을 실행하고 체력이 단 상태에서 체력회복 아이템을 먹으면 체력이 회복되고, 체력 회복 이펙트가 생기는 걸 볼 수 있다.
탄창 회복 아이템
(1) 탄창 회복 아이템 제작을 위해 assault_rifle_01_mag_full FBX를 하이어라키창에 넣고 이름을 MagazineItem으로 바꾼다.
(2) 위치(0 / 0.4 / 3), 크기(3 / 3 / 3), IsTrigger(true)
(3) 탄창 회복 아이템 오브젝트를 프리팹으로 만들고 태그를 Item으로 바꾼다.
(4) 맵에 임의로 2개의 탄창 회복 아이템을 배치한다.
(5) 탄창 회복 이펙트 제작을 위해 파티클시스템(MagazineEffect)을 만든다.
- Main
- Duration : 1
- Looping : false
- Start LifeTime : 1
- Start Speed : 3
- max Particles : 20
- Emission
- Rate over Time : 20
- Shape
- Shpae : Box
- Rotation : 270 / 0 / 0
- Renderer
- Render Mode : Mesh
- Mesh : big_bullet
- Material : Guns Material
(6) 탄창 회복 이펙트 오브젝트를 프리팹으로 만든다.
(7) WeaponAssaultRifle스크립트를 수정한다.(탄창 증가 함수 추가)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
[System.Serializable]
public class AmmoEvent : UnityEvent<int, int> { }
[System.Serializable]
public class MagazineEvent : UnityEvent<int> { }
public class WeaponAssaultRifle : MonoBehaviour
{
[HideInInspector]
public AmmoEvent onAmmoEvent=new AmmoEvent();
[HideInInspector]
public MagazineEvent onMagazineEvent=new MagazineEvent();
[Header("Fire Effects")]
[SerializeField]
private GameObject muzzleFlashEffect; // 총구 이펙트 (On/Off)
[Header("Spawn Points")]
[SerializeField]
private Transform casingSpawnPoint; // 탄피 생성 위치
[SerializeField]
private Transform bulletSpawnPoint; // 총알 생성 위치
[Header("Audio Clips")]
[SerializeField]
private AudioClip audioClipTakeOutWeapon; // 무기 장착 사운드
[SerializeField]
private AudioClip audioClipFire; // 공격 사운드
[SerializeField]
private AudioClip audioClipReload; // 재장전 사운드
[Header("Weapon Setting")]
[SerializeField]
private WeaponSetting weaponSetting; // 무기 설정
[Header("Aim UI")]
[SerializeField]
private Image imageAim; // default / aim 모드에 따라 Aim 이미지 활성 / 비활성
private float lastAttackTime = 0; // 마지막 발사시간 체크용
private bool isReload = false; // 재장전 중인지 체크
private bool isAttack=false; // 공격 여부 체크용
private bool isModeChange = false; // 모드 전환 여부 체크용
private float defaultModeFOV = 60f; // 기본모드에서의 카메라 FOV
private float aimModeFOV = 30f; // AIM모드에서의 카메라 FOV
private AudioSource audioSource; // 사운드 재생 컴포넌트
private PlayerAnimatorController anim; // 애니메이션 재생 제어
private CasingMemoryPool casingMemoryPool; // 탄피 생성 후 활성/비활성 관리
private ImpactMemoryPool impactMemoryPool; // 공격 효과 생성 후 활성/비활성 관리
private Camera mainCamera; // 광선 발사
// 외부에서 필요한 정보를 열람하기 위해 정의한 Get 프로퍼티들
public WeaponName WeaponName => weaponSetting.weaponName;
public int CurrentMagazine => weaponSetting.currentMagazine;
public int MaxMagazine=>weaponSetting.maxMagazine;
private void Awake()
{
audioSource = GetComponent<AudioSource>();
anim = GetComponentInParent<PlayerAnimatorController>();
casingMemoryPool=GetComponent<CasingMemoryPool>();
impactMemoryPool=GetComponent<ImpactMemoryPool>();
mainCamera = Camera.main;
// 처음 탄창 수는 최대로 설정
weaponSetting.currentMagazine = weaponSetting.maxMagazine;
// 처음 탄 수는 최대로 설정
weaponSetting.currentAmmo=weaponSetting.maxAmmo;
}
private void OnEnable()
{
// 무기 장착 사운드 재생
PlaySound(audioClipTakeOutWeapon);
// 총구 이펙트 오브젝트 비활성화
muzzleFlashEffect.SetActive(false);
// 무기가 활성화될 때 해당 무기의 탄창 정보를 갱신한다.
onMagazineEvent.Invoke(weaponSetting.currentMagazine);
// 무기가 활성화될 때 해당 무기의 탄 수 정보를 갱신한다.
onAmmoEvent.Invoke(weaponSetting.currentAmmo,weaponSetting.maxAmmo);
ResetVariables();
}
// 외부에서 공격 시작할 때 StartWeaponAction(0) 메소드 호출
public void StartWeaponAction(int type = 0)
{
// 재장전 중일 때는 무기 액션을 할 수 없다.
if (isReload == true) return;
// 모드 전환중이면 무기 액션을 할 수 없다.
if (isModeChange == true) return;
// 마우스 왼쪽 클릭 (공격 시작)
if (type == 0)
{
// 연속 공격
if (weaponSetting.isAutomaticAttack == true)
{
isAttack = true;
// OnAttack()을 매 프레임 실행
StartCoroutine("OnAttackLoop");
}
// 단발 공격
else
{
// 실제 공격 정의 함수
OnAttack();
}
}
// 마우스 오른쪽 클릭 (모드 전환)
else
{
// 공격 중일 때는 모드 전환을 할 수 없다
if (isAttack == true) return;
StartCoroutine("OnModeChange");
}
}
public void StartReload()
{
// 현재 재장전 중이면 재장전 불가능
if(isReload == true || weaponSetting.currentMagazine<=0) return;
// 무기 액션 도중에 'R'키를 눌러 재장전을 시도하면 무기 액션 종료 후 재장전
StopWeaponAction();
StartCoroutine("OnReload");
}
// 외부에서 공격 종료할 때 StopWeaponAction(0) 메소드 호출
public void StopWeaponAction(int type = 0)
{
// 마우스 왼쪽 해제 (공격 종료)
if(type == 0)
{
isAttack=false;
StopCoroutine("OnAttackLoop");
}
}
private IEnumerator OnAttackLoop()
{
while (true)
{
OnAttack();
yield return null;
}
}
public void OnAttack()
{
if(Time.time - lastAttackTime > weaponSetting.attackRate)
{
// 뛰고있을 땐 공격x
if (anim.MoveSpeed > 0.5f)
{
return;
}
// 공격주기가 되어야 공격할 수 있도록 하기 위해 현재시간 정보 저장
lastAttackTime = Time.time;
// 탄 수가 없으면 공격 불가능
if (weaponSetting.currentAmmo <= 0)
{
return;
}
// 공격 시 currentAmmo 1 감소, 탄 수 UI 업데이트
weaponSetting.currentAmmo--;
onAmmoEvent.Invoke(weaponSetting.currentAmmo,weaponSetting.maxAmmo);
// 무기 애니메이션 재생
//anim.Play("Fire",-1,0);
// 무기 애니메이션 재생 (모드에 따라 AimFire or Fire 애니메이션 재생)
string animation = anim.AimModeIs == true ? "AimFire" : "Fire";
anim.Play(animation, - 1, 0);
// TIP) anim.Play("Fire"); : 같은 애니메이션을 반복할 때 중간에 끊지 못하고 재생 완료 후 다시 재생
// TIP) anim.Play("Fire",-1,0); : 같은 애니메이션을 반복할 때 애니메이션을 끊고 처음부터 다시 재생
// 총구 이펙트 재생 (default 모드 일 때만 재생)
if(anim.AimModeIs==false) StartCoroutine("OnMuzzleFlashEffect");
// 공격 사운드 재생
PlaySound(audioClipFire);
// 탄피 생성
casingMemoryPool.SpawnCasing(casingSpawnPoint.position, transform.right);
// 광선을 발사해 원하는 위치 공격 (+Impact Effect)
TwoStepRaycast();
}
}
private IEnumerator OnMuzzleFlashEffect()
{
muzzleFlashEffect.SetActive (true);
// 무기의 공격속도보다 빠르게 잠깐 활성화 시켰다가 비활성화 한다
yield return new WaitForSeconds(weaponSetting.attackRate*0.3f);
muzzleFlashEffect.SetActive(false);
}
private IEnumerator OnReload()
{
isReload = true;
// 재장전 애니메이션 사운드 재생
anim.OnReload();
audioSource.PlayOneShot(audioClipReload,1.0f); // 사운드가 겹쳐도 재생이 되도록
while (true)
{
// 사운드가 재생중이 아니고, 현재 애니메이션 Movement이면
// 재장전 애니메이션(, 사운드) 재생이 종료되었다는 뜻
if(audioSource.isPlaying==false && (anim.CurrentAnimationIs("Movement") || anim.CurrentAnimationIs("AimFirePose")))
{
isReload=false;
// 현재 탄창 수를 1 감소시키고, 바뀐 탄창 정보를 Text UI에 업데이트
weaponSetting.currentMagazine--;
onMagazineEvent.Invoke(weaponSetting.currentMagazine);
// 현재 탄 수를 최대로 설정하고, 바뀐 찬 수 정보를 Text UI에 업데이트
weaponSetting.currentAmmo=weaponSetting.maxAmmo;
onAmmoEvent.Invoke(weaponSetting.currentAmmo, weaponSetting.maxAmmo);
yield break;
}
yield return null;
}
}
private void TwoStepRaycast()
{
Ray ray;
RaycastHit hit;
Vector3 targetPoint = Vector3.zero;
// 화면 중앙 좌표 (Aim 기준으로 Raycast연산)
ray = mainCamera.ViewportPointToRay(Vector2.one * 0.5f);
// 공격 사거리(attackDistance) 안에 부딪히는 오브젝트가 있으면 targetPoint는 광선에 부딪힌 위치
if(Physics.Raycast(ray,out hit, weaponSetting.attackDistance))
{
targetPoint = hit.point;
impactMemoryPool.SpawnImpact(hit);
}
// 공격 사거리 안에 부딪히는 오브젝트가 없으면 targetPoint는 최대 사거리 위치
else
{
targetPoint=ray.origin+ray.direction*weaponSetting.attackDistance;
}
Debug.DrawRay(ray.origin, ray.direction * weaponSetting.attackDistance, Color.red);
// 첫번째 Raycast연산으로 얻어진 targetPoint를 목표지점으로 설정하고,
// 총구를 시작지점으로 하여 Raycast 연산
Vector3 attackDirection = (targetPoint - bulletSpawnPoint.position).normalized;
if (Physics.Raycast(bulletSpawnPoint.position, attackDirection, out hit, weaponSetting.attackDistance))
{
impactMemoryPool.SpawnImpact(hit);
if (hit.transform.tag == "ImpactEnemy")
{
hit.transform.GetComponent<EnemyFSM>().TakeDamage(weaponSetting.damage);
}
else if (hit.transform.tag == "InteractionObject")
{
hit.transform.GetComponent<InteractionObject>().TakeDamage(weaponSetting.damage);
}
}
Debug.DrawRay(bulletSpawnPoint.position, attackDirection * weaponSetting.attackDistance, Color.blue);
}
private IEnumerator OnModeChange()
{
float current = 0;
float percent = 0;
float time = 0.35f;
anim.AimModeIs = !anim.AimModeIs;
imageAim.enabled = !imageAim.enabled;
float start = mainCamera.fieldOfView;
float end = anim.AimModeIs==true? aimModeFOV : defaultModeFOV;
isModeChange = true;
while (percent < 1)
{
current+=Time.deltaTime;
percent=current/time;
// mode에 따라 카메라의 시야각을 변경
mainCamera.fieldOfView = Mathf.Lerp(start,end,percent);
yield return null;
}
isModeChange=false;
}
private void ResetVariables()
{
isReload = false;
isAttack = false;
isModeChange=false;
}
private void PlaySound(AudioClip clip)
{
audioSource.Stop(); // 기존에 재생중인 사운드를 정지하고,
audioSource.clip = clip; // 새로운 사운드 clip으로 교체 후
audioSource.Play(); // 사운드 재생
}
/*●*/public void IncreaseMagazine(int magazine)
/*●*/{
/*●*/ weaponSetting.currentMagazine = weaponSetting.currentMagazine + magazine > MaxMagazine ? MaxMagazine : weaponSetting.currentMagazine + magazine;
/*●*/
/*●*/ onMagazineEvent.Invoke(CurrentMagazine);
/*●*/}
}
(8) 탄창 회복 아이템 오브젝트를 제어하는 ItemMagazine스크립트를 만든다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ItemMagazine : ItemBase
{
[SerializeField]
private GameObject magazineEffectPrefab;
[SerializeField]
private int increaseMagazine = 2;
[SerializeField]
private float rotateSpeed = 50;
private IEnumerator Start()
{
while (true)
{
// y축을 기준으로 회전
transform.Rotate(Vector3.up* rotateSpeed*Time.deltaTime);
yield return null;
}
}
public override void Use(GameObject entity)
{
entity.GetComponentInChildren<WeaponAssaultRifle>().IncreaseMagazine(increaseMagazine);
Instantiate(magazineEffectPrefab,transform.position,Quaternion.identity);
Destroy(gameObject);
}
}
(9) MagazineItem프리팹에 ItemMagazine스크립트를 추가한다.
이제 게임을 실행하고 탄창을 소모한 다음 탄창 회복 아이템을 먹으면 탄창 수가 증가하는 모습과 이펙트 재생이 되는 모습을 볼 수 있다. 그리고 최대 탄창 수 이상으로는 증가하지 않는 모습을 볼 수 있다.