Corutine
IEnumerator는 **코루틴 전용 반환형(인터페이스)**이다.
코루틴은 일반 함수와 다르게, 실행 도중 yield return을 만나면 일시 중지되었다가 나중에 다시 실행된다.
이 방식 덕분에 시간이 걸리는(프레임을 기다리는) 동작을 자연스럽게 구현 가능하다.
IEnumerator Example()
{
Debug.Log("코루틴 시작");
yield return new WaitForSeconds(2f); // 2초 기다림
Debug.Log("2초 후 실행");
}
위 예제를 실행하면 즉시 첫 번째 로그를 출력하고, 2초 후 두 번째 로그를 출력한다.
yield return null;
위 코드를 만나면 한 프레임 동안 대기한 후 다음 코드가 실행된다.
이번 스크립트에선 넉백 함수에 사용되는데 불필요한 물리 충돌 문제를 방지하고, 부드러운 애니메이션을 위해 쓰였다.
yield return wait; // 다음 FixedUpdate까지 대기
yield return new WaitForSeconds(0.1f); // 0.1초 기다린 후 실행
위 두 방법으로 대체가 가능하다.
wait를 사용하면 FixedUpdate 주기에 맞춰 실행할 수도 있다.
Enemy.cs
적(몬스터) 행동을 제어하는 역할을 한다.
플레이어 추적, 애니메이션, 넉백, 피격 및 사망 처리를 관리하는 기능이 포함되어 있다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Enemy : MonoBehaviour
{
public float speed; // 속도
public float health; // 현재 체력(데미지가 소수점 단위로 들어올 수 있어 float 사용)
public float maxHealth; // 최대 체력
public RuntimeAnimatorController[] animCon; // 애니메이터(몬스터별 다른 애니메이션을 적용할 수 있도록 배열로 저장)
public Rigidbody2D target; // 목표(플레이어)
bool isLive; // 살아있는지 여부
Rigidbody2D rigid; // 물리 이동
Collider2D coll; // 충돌 처리
Animator anim; // 애니메이션 제어
SpriteRenderer spriter; // 스프라이트 반전(좌우 방향 조정)
WaitForFixedUpdate wait;// 넉백 효과에서 사용할 대기 시간(다음 FixedUpdate까지 기다리는 오브젝트)
// 초기화
void Awake()
{
rigid = GetComponent<Rigidbody2D>();
coll = GetComponent<Collider2D>();
anim = GetComponent<Animator>();
spriter = GetComponent<SpriteRenderer>();
wait = new WaitForFixedUpdate(); // 코루틴에서 대기하는 객체를 미리 만들어 성능 최적화
}
// 플레이어 추적 AI(물리적 업데이트이기 때문에 Update가 아닌 FixedUpdate 사용)
void FixedUpdate()
{
// 게임이 종료되면
if (!GameManager.instance.isLive)
return;
/* 아래 MovePosition 사용 시 불필요한 움직임(예: 피격 중 이동)을 막아야 함
* 피격 애니메이션이 재생 중일 때 이동하면 몬스터가 끊기면서 이동하는 문제가 발생할 수 있음
* 따라서 "몬스터가 이동해도 되는 상태인지를 먼저 확인"하고 움직이는 것이 가장 안정적 */
// GetCurrentAnimatorStateInfo() = 현재 상태 정보를 가져오는 함수
// IsName() = 해당 상태의 이름이 지정된 것과 같은지 확인하는 함수
// 몬스터가 죽었거나 피격 애니메이션을 재생 중이라면
if (!isLive || anim.GetCurrentAnimatorStateInfo(0).IsName("Hit"))
return;
// 플레이어를 향하는 방향 계산, 현재 플레이어(target)의 위치 - 현재 적(Enemy)의 위치
Vector2 dirVec = target.position - rigid.position;
/* dirVec 자체는 벡터의 방향 + 거리(크기)를 포함한 값으로
* 플레이어와 거리가 멀어질수록 몬스터가 더 빠르게 이동하게 되는 문제가 생김
* 이를 방지하기위해 normalized를 사용해 크기를 정규화하여 항상 1로 고정해야함 */
// 프레임의 영향으로 결과가 달라지지 않도록 FIxedDeltaTime 사용
Vector2 nextVec = dirVec.normalized * speed * Time.fixedDeltaTime; // 이동할 방향과 거리
// MovePosition = 물리 기반 이동을 수행하는 함수로, 게임 오브젝트를 부드럽게 이동시키며 물리적 충돌을 감지함
// transform.position은 즉시 위치 변경(순간이동)이며 충돌 감지도 되지 않아 MoviePosition 사용
rigid.MovePosition(rigid.position + nextVec); // 지금 현재 위치 + 다음에 가야할 위치를 더해 이동시킴
/* velocity를 0으로 초기화(불필요한 물리 효과를 제거하는 작업)하는 이유
* Rigidbody2D를 사용하는 경우 이전 프레임의 속도가 남아 있을 수 있고,
* 넉백 등 이전 프레임의 힘(Force)로 인해 예상치 못한 이동이 발생할 수 있어
* 이동을 MovePosition()으로 직접 제어하는 경우 초기화 필요 */
rigid.velocity = Vector2.zero;
}
// 좌우 방향 조정
void LateUpdate()
{
// 게임이 종료되면
if (!GameManager.instance.isLive)
return;
// 몬스터가 죽었다면
if (!isLive )
return;
// spriter.flipX는 bool 타입 속성이기 때문에 true 또는 false를 반환
// 플레이어가 몬스터보다 왼쪽에 있으면 flipX = true(좌우 반전)
spriter.flipX = target.position.x < rigid.position.x;
}
// 몬스터가 활성화 될 때 호출
void OnEnable()
{
target = GameManager.instance.player.GetComponent<Rigidbody2D>(); // 플레이어를 추적 목표로 설정
// OnTriggerEnter2D 내 else에서 변경한 상태 초기화
// 하드코딩 된 부분을 추후 ScriptableObject를 활용하여 초기값을 외부에서 설정할 수 있도록 변경 예정
isLive = true;
coll.enabled = true;
rigid.simulated = true;
spriter.sortingOrder = 2;
anim.SetBool("Dead", false);
health = maxHealth; // 체력을 풀로 회복시켜 재사용 가능하도록 설정
}
// 몬스터 생성 정보를 담고 있는 SpawnData 객체를 받아 초기 속성 적용
// 외부 데이터를 받아오므로, 몬스터마다 다른 값을 가질 수 있음
public void Init(SpawnData data)
{
// runtimeAnimatorController = 현재 적용된 AnimatorController
anim.runtimeAnimatorController = animCon[data.spriteType]; // 몬스터의 애니메이션을 spriteType에 따라 변경
speed = data.speed;
maxHealth = data.health;
health = data.health;
}
// 몬스터 피격
void OnTriggerEnter2D(Collider2D collision)
{
// 사망 로직이 연달아 실행되는 것을 방지하기 위해 !isLive 조건 추가
// 충돌한 물체가 총알(Bullet)이 아니라면 or 몬스터가 죽었다면
if (!collision.CompareTag("Bullet") || !isLive)
return;
health -= collision.GetComponent<Bullet>().damage; // 총알의 데미지를 체력에서 차감
StartCoroutine(KnockBack()); // 넉백 실행, 코루틴 함수 실행 방법 1
//StartCoroutine("KnockBack"); // 코루틴 함수 실행 방법 2
// 체력이 남아있다면
if (health > 0)
{
anim.SetTrigger("Hit"); // 피격 부분 Animator의 SetTrigger 함수를 호출하여 상태 변경
AudioManager.instance.PlaySfx(AudioManager.Sfx.Hit);
}
// 죽었다면
else
{
isLive = false;
coll.enabled = false; // Component 비활성화
rigid.simulated = false; // rigidbody의 물리적 비활성화
spriter.sortingOrder = 1; // Inspector 내 Order in Layer 단계 2에서 1로 하향 조정
anim.SetBool("Dead", true); // Animation 상태 전환
GameManager.instance.kill++; // 킬 수 카운트
GameManager.instance.GetExp(); // 레벨 업 함수 실행
if (GameManager.instance.isLive)
AudioManager.instance.PlaySfx(AudioManager.Sfx.Dead);
}
}
/* 코루틴(Coroutine) = 생명 주기와 비동기처럼 실행되는 함수(지연 실행 함수)
* 코루틴은 일반적인 함수랑 다르게, 실행하면 한 번에 끝나지 않고 중간에 멈췄다가 다시 실행이 가능
* 넉백은 물리 연산과 관련된 움직임이므로, Update()보다 FixedUpdate() 주기에 맞춰 실행하는 게 자연스러움 */
// IEnumerator = 코루틴만의 반환형 Interface
// 총알에 맞은 몬스터가 플레이어 반대 방향으로 튕겨나가도록(넉백) 하는 역할
IEnumerator KnockBack()
{
// yield = 코루틴의 반환 키워드
yield return null; // 다음 프레임(다음 Update)까지 대기
Vector3 playerPos = GameManager.instance.player.transform.position; // 넉백 적용 시 몬스터가 플레이어 반대 방향으로 밀려나야 하므로 현재 플레이어 위치 필요
Vector3 dirVec = transform.position - playerPos; // 플레이어 기준 반대 방향 = 현재 몬스터 위치 - 현재 플레이어 위치
// dirVec는 거리(크기)가 포함된 벡터라 넉백의 힘이 플레이어와의 거리에 따라 달라지는 문제가 생길 수 있음
// 이를 해결하기위해 normalized를 사용하여 벡터의 크기를 1로 고정해 어떤 거리에서도 일정한 넉백 효과를 유지
// AddForce = Rigidbody2D에 힘을 추가하여 물리적인 움직임을 발생시키는 함수
// * 3 = 넉백 강도 조절
/* ForceMode2D 종류
* ForceMode2D.Impluse = 순간적인 힘 (한 번만 적용됨, 넉백에 적합)
* ForceMode2D.Force = 지속적인 힘(매 프레임 적용됨, 넉백이 이상하게 적용될 수 있음)
* ForceMode2D.VelocityChange = 속도를 즉시 변경(중력 영향을 받지 않음) */
rigid.AddForce(dirVec.normalized * 3, ForceMode2D.Impulse);
}
}
Spawner.cs
몬스터를 생성(스폰)하는 역할을 한다.
몬스터 생성 위치, 생성 주기, 레벨별 몬스터 데이터를 관리하는 기능이 포함되어 있다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
public Transform[] spawnPoint; // 몬스터가 소환될 위치를 저장하는 배열 변수
public SpawnData[] spawnData; // 몬스터 데이터를 저장하는 배열 변수
public float levelTime; // 소환 레벨 구간을 결정하는 변수
int level; // 게임의 현재 레벨(난이도)
float timer; // 일정 시간 간격을 두고 몬스터를 소환하기 위한 변수
void Awake()
{
// GetComponentsInChildren = 현재 객체와 그 자식 객체들에 연결된 Transform 컴포넌트를 모두 찾아 배열에 저장
/* 이 배열의 첫 번째 요소는 부모 객체의 Transform이고, 그 뒤로 자식 객체들의 Transform이 들어감
* 몬스터가 생성될 여러 개의 위치를 자동으로 가져옴 */
spawnPoint = GetComponentsInChildren<Transform>();
// 최대 게임 시간을 몬스터 데이터 배열 크기로 나누어 각 레벨별 몬스터가 등장하는 시간을 자동으로 설정
levelTime = GameManager.instance.maxGameTime / spawnData.Length;
/* spawnData만 초기화 하지 않은 이유는?
* 에디터에서 프리팹을 만들 때 미리 수동으로 데이터가 입력(정적)되어 있어
* Awake에서 다시 초기화하면 오히려 기존 값이 사라지기에 다시 초기화할 필요가 없음 */
// 반면, spawnPoint는 프리팹이 아닌 현재 씬(Scene)에서 동적으로 찾는 데이터
// 인스펙터에서 수동으로 설정할 수도 있지만 씬에 배치된 스폰 위치를 자동으로 가져오는 방식이 더 유연
}
void Update()
{
if (!GameManager.instance.isLive) // 게임이 진행 중인지 확인
return;
/* Time.deltaTime의 역할
* 현재 프레임과 이전 프레임 간의 시간 차이를 나타내는 값
* 각 프레임은 컴퓨터 성능, 그래픽 설정, 게임 복잡도 등에 따라 시간 차이가 날 수 있음
* timer += Time.deltaTime의 의미
* 이 코드는 각 프레임에서 경과된 시간을 timer에 계속 더해감
* 매 프레임마다 다르게 값이 변하므로, timer는 정확한 경과 시간을 누적하게 됨
* 이런 방식이 중요한 이유
* 프레임 레이트에 관계없이 일정 시간 간격으로 행동을 수행하기 위해 Time.deltaTime을 사용
* 0.2초마다 몬스터를 소환하고 싶다고 할 때, 단순히 timer += 0.2f를 하게 되면 프레임 레이트가 달라지면 소환 주기가 달라질 수 있음
* 결론
* 임의 프레임 레이트에 영향을 받지 않고 정확한 시간 간격을 계산할 수 있음
* 어떤 환경에서도 일정한 주기로 실행 가능 */
// Time.deltaTime = 프레임 간격 시간
timer += Time.deltaTime; // 매 프레임마다 경과된 시간을 누적
// Mathf.FloorToInt = 소수점 아래는 버리고, 정수(Int)형으로 바꾸는 함수
// Mathf.CeilToInt = 소수점 아래는 올리고, 정수(Int)형으로 바꾸는 함수
// Mathf.Min = 두 값 중 작은 값을 반환하는 함수
// 게임이 진행된 시간(gameTime)을 levelTime으로 나눈 값을 level로 설정
// 게임 시간이 levelTime만큼 증가할 때마다 level 상승
// spawnData.Length -1로 배열의 크기를 넘지 않도록 제한
level = Mathf.Min(Mathf.FloorToInt(GameManager.instance.gameTime / levelTime), spawnData.Length - 1); // 게임이 진행된 시간에 따라 level을 증가
// 레벨별로 몬스터 생성 주기가 다를 수 있도록 조건문 설정
if (timer > spawnData[level].spawnTime)
{
timer = 0; // 몬스터 생성 후 timer를 초기화하여 다음 스폰 주기를 기다림
Spawn();
}
/* timer = 0;과 Spawn(); 위치를 바꾸면 어떻게 될까?
* 보통은 큰 문제 없이 작동할 가능성이 높지만, 특정한 경우 아주 작은 오차가 누적될 가능성이 있음
* 이게 매 프레임마다 누적되면 스폰 타이밍이 점점 어긋날 수 있음 */
}
// 소환 관련 함수 따로 생성
void Spawn()
{
// 이전에 사용한 코드 2개
// GameObject enemy = GameManager.instance.pool.Get(Random.Range(0, 2)); // 두 종류(0, 1번) 몬스터 중 랜덤
// GameObject enemy = GameManager.instance.pool.Get(level); // 난이도에 따라 다른 몬스터
GameObject enemy = GameManager.instance.pool.Get(0); // PoolManager의 Get 함수를 활용해 몬스터 생성
// spawnPoint[0]은 부모 오브젝트이므로, 자식 중에서만 랜덤한 위치가 선택되게 1부터로 설정
enemy.transform.position = spawnPoint[Random.Range(1, spawnPoint.Length)].position;
// 생성된 enemy 오브젝트에서 Enemy 스크립트를 가져옴
// spawnData[level] 데이터를 전달하여 현재 레벨에 해당하는 몬스터 속성을 초기화
enemy.GetComponent<Enemy>().Init(spawnData[level]);
}
}
// 직렬화(Serialization) = 객체 데이터를 파일, 메모리, 네트워크 전송을 위해 변환하는 과정
// MonoBehaviour를 상속받지 않아도 배열이나 리스트 형태로 인스펙터에서 직접 설정 가능
// 복잡한 데이터 구조를 쉽게 다룰 수 있음 (예: 캐릭터 능력치, 아이템 정보 등)
// 배열이나 List와 함께 사용하면 유용
[System.Serializable]
public class SpawnData
{
// 몬스터 관련 속성 추가
public float spawnTime; // 생성 주기
public int spriteType; // 타입
public int health; // 체력
public float speed; // 이동속도
}
'Unity > Undead Survivor' 카테고리의 다른 글
열거형(enum) / 월드 좌표(World Position)와 스크린 좌표(Screen Position) / HUD(Head-Up Display) (0) | 2025.03.18 |
---|---|
범위 감지(Scanner) / 무기(Weapon)와 총알(Bullet) (0) | 2025.03.18 |
배열과 리스트의 차이 / 오브젝트 풀링(Object Pooling) (0) | 2025.03.17 |
유니티(Unity)의 주요 이벤트 함수(Event Functions) (0) | 2025.03.12 |
그리드(Grid)와 타일맵(Tilemap) / 재배치(Reposition) (0) | 2025.03.11 |