지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=8fZIZMlC69s&list=PLFt_AvWsXl0ctd4dgE1F8g3uec4zKNRV0&index=19
이번 강의에서는 웨이브 별 난이도를 조정해본다.
시작해보자.
지금 각 웨이브에서는 웨이브에서 나오는 적의 수, 적의 스폰 시간 간격만 설정이 가능하다.
Spawner 스크립트와 Enemy 스크립트로 가서 적의 속도, 적의 체력 등을 추가해
웨이브 별로 난이도를 좀 더 세밀하게 조정할 수 있도록 해보자.
또한 테스트에서 편의성을 위해 개발자 모드를 추가해 웨이브를 건너뛸 수 있도록 하자.
| Spawner 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
public bool devMode; // 개발자 모드 여부
public Wave[] waves; // 웨이브들을 저장할 배열 생성
public Enemy enemy; // 스폰할 적 레퍼런스
LivingEntity playerEntity; // 플레이어 레퍼런스
Transform playerT; // 플레이어 위치
Wave currentWave; // 현재 웨이브 레퍼런스
int currentWaveNumber; // 현재 웨이브 번호
int enemiesRemainingToSpawn; // 남아있는 스폰 할 적 수
int enemiesRemainingAlive; // 살아있는 적의 수
float nextSpawnTime; // 다음 스폰까지의 시간
MapGenerator map; // 맵 생성기 레퍼런스
float timeBetweenCampingChecks = 2; // 캠핑 방지 체크 딜레이
float nextCampingCheckTime; // 다음 캠핑 체크 시간
float campThresholdDistance = 1.5f; // 캠핑 방지를 위해 이동해야 하는 거리
Vector3 campPositionOld; // 이전 플레이어 위치
bool isCamping; // 캠핑중인지
bool isDisable; // 플레이어 생존 여부
public event System.Action<int> OnNewWave; // 웨이브 번호를 매개변수로 갖는 웨이브 이벤트 추가
void Start() {
playerEntity = FindObjectOfType<Player>(); // 플레이어 오브젝트 할당
playerT = playerEntity.transform; // 플레이어 위치
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
campPositionOld = playerT.position;
// 플레이어 위치 설정
map = FindObjectOfType<MapGenerator>(); // 맵 생성기
NextWave(); // 시작 시 웨이브 실행
playerEntity.OnDeath += OnPlayerDeath; // 플레이어가 죽을 때 위 메소드를 추가
}
void Update()
{
if (!isDisable) { // 비활성화(플레이어 죽음)가 아닌 경우
if (Time.time > nextCampingCheckTime)
{ // 캠핑 체크 시간이 지난 경우
nextCampingCheckTime = Time.time + timeBetweenCampingChecks;
// 다음 캠핑 체크 시간 설정
isCamping = (Vector3.Distance(playerT.position, campPositionOld) < campThresholdDistance);
// 캠핑중인지 확인
campPositionOld = playerT.position;
// 위치 갱신
}
if ((enemiesRemainingToSpawn > 0 || currentWave.infinite) && Time.time >= nextSpawnTime)
// 적 수가 남아있거나 무한모드이고, 현재 시간이 다음 스폰 시간보다 큰 경우
{
enemiesRemainingToSpawn--; // 적 수를 하나 줄임
nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;
// 다음 스폰 시간을 현재시간 + 스폰 간격 으로 저장
StartCoroutine("SpawnEnemy"); // 적 스폰 코루틴 시작
}
}
if (devMode) { // 개발자 모드인 경우
if(Input.GetKeyDown(KeyCode.Return)) { // 엔터 키를 누른 경우
StopCoroutine("SpawnEnemy"); // 적 스폰 코루틴 중지
foreach(Enemy enemy in FindObjectsOfType<Enemy>()) { // Enemy 오브젝트를 모두 찾고
GameObject.Destroy(enemy.gameObject); // 해당 게임 오브젝트 파괴
}
NextWave(); // 다음 웨이브 실행
}
}
}
// ■ 적 스폰 코루틴
IEnumerator SpawnEnemy() {
float spawnDelay = 1;
// 적 스폰 대기시간
float tileFlashSpeed = 4;
// 초당 몇번 타일이 깜빡일지 설정
Transform spawnTile = map.GetRandomOpenTile();
// 랜덤 오픈 타일 가져오기
if (isCamping) { // 플레이어가 캠핑중인 경우
spawnTile = map.GetTileFromPosition(playerT.position);
// 타일 위치를 플레이어 위치로 설정
isCamping = false;
// 캠핑 해제
}
Material tileMat = spawnTile.GetComponent<Renderer>().material;
// 가져온 타일의 마테리얼 가져오기
Color initialColour = tileMat.color;
// 기존 타일 색상 저장
Color flashColour = Color.red;
// 변경할 색상 저장
float spawnTimer = 0;
// 적 소환 시간 타이머
while(spawnTimer < spawnDelay) { // 적 스폰 대기시간이 안된 경우
tileMat.color = Color.Lerp(initialColour, flashColour, Mathf.PingPong(spawnTimer * tileFlashSpeed, 1));
// 보간값을 통해 색상을 설정하여 깜빡이도록, PingPong 함수로 설정한 횟수만큼 깜빡이도록 속도를 설정.
spawnTimer += Time.deltaTime; // 타이머에 시간 추가
yield return null; // 한 프레임 대기
}
tileMat.color = initialColour; // 타일 색상 초기화
Enemy spawnedEnemy = Instantiate(enemy, spawnTile.position + Vector3.up, Quaternion.identity) as Enemy;
// 적을 인스턴스화를 통해 생성, 랜덤 타일 위치에 회전값 없이 배치
spawnedEnemy.OnDeath += OnEnemyDeath;
// 적이 죽을 때 위 메소드를 추가
spawnedEnemy.SetCharactoristics(currentWave.moveSpeed, currentWave.hitsToKillPlayer, currentWave.enemyHealth, currentWave.skinColour);
// 적 정보 설정 메소드 호출
}
// ■ 플레이어가 죽을 때 처리하는 메소드
void OnPlayerDeath() {
isDisable = true;
// 생존 여부 죽음(비활성화)으로 설정.
}
void OnEnemyDeath()
/* 적이 죽을 때 처리하는 메소드
적이 죽으면 LivingEntity 에서 OnDeath 를 호출하고,
OnDeath 는 이 메소드를 호출해서 적이 죽을 때 알려준다. */
{
enemiesRemainingAlive--; // 적의 수를 1 줄임
if(enemiesRemainingAlive == 0) // 적의 수가 0이 되면
{
NextWave(); // 다음 웨이브 실행
}
}
// ■ 플레이어 위치 리셋 메소드
void ResetPlayerPosition() {
playerT.position = map.GetTileFromPosition(Vector3.zero).position + Vector3.up * 1.5f;
// 플레이어 위치를 가운데 타일 위치로 설정
}
// ■ 다음 웨이브 실행 메소드
void NextWave()
{
currentWaveNumber++; // 웨이브 숫자 증가
if(currentWaveNumber - 1 < waves.Length) { // 배열 인덱스 예외 없도록 처리
currentWave = waves[currentWaveNumber - 1];
/* 현재 웨이브 레퍼런스 참조
(웨이브 숫자는 1부터 시작 할 것이므로 -1 하여 배열의 인덱스에 맞게 참조) */
enemiesRemainingToSpawn = currentWave.enemyCount;
// 이번 웨이브의 적 수 저장
enemiesRemainingAlive = enemiesRemainingToSpawn;
// 살아있는 적의 수를 스폰 할 적의 수로 저장
if (OnNewWave != null) { // 이벤트 구독자가 있는 경우
OnNewWave(currentWaveNumber); // 웨이브 번호 전달
}
ResetPlayerPosition(); // 플레이어 위치 초기화
}
}
[System.Serializable]
/* 스크립트 직렬화, 직렬화를 통해 객체, 변수 등을 선언 할 때의
접근 제한(private 등)은 유지되지만 인스펙터에서 값을 변경 가능하게 함 */
public class Wave
// 적의 주기, 스폰 주기 등 웨이브 정보를 저장 할 클래스 생성
{
public bool infinite; // 무한 모드 설정
public int enemyCount; // 적의 수
public float timeBetweenSpawns; // 적의 스폰 주기
public float moveSpeed; // 적의 속도
public int hitsToKillPlayer; // 적의 공격력(설정된 횟수만큼 플레이어 공격 시 플레이어 사망)
public float enemyHealth; // 적의 체력
public Color skinColour; // 적의 색상
}
}
| Enemy 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(UnityEngine.AI.NavMeshAgent))]
// 현재 오브젝트에 NavMeshAgent 를 포함
public class Enemy : LivingEntity
{
public enum State { Idle, Cashing, Attacking };
// 적의 상태, { 기본(아무것도 안함), 추적, 공격 }
State currentState; // 현재 상태
public ParticleSystem deathEffect; // 사망 이펙트 레퍼런스
UnityEngine.AI.NavMeshAgent pathfinder; // 내비게이션 레퍼런스
Transform target; // 적의 타겟(플레이어) 트랜스폼
LivingEntity targetEntity; // 타겟(플레이어) 레퍼런스
Material skinMatreial; // 현재 오브젝트의 마테리얼
Color originalColor; // 현재 오브젝트의 기존 색상
float attackDistanceThreshold = 0.5f;
// 공격 거리 임계값(사거리). 유니티에서 단위 1은 1 meter 이다..!!
float timeBetweenAttacks = 1;
// 공격 시간 간격
float nextAttackTime;
// 실제 다음 공격 시간
float damage = 1;
// 적의 공격 데미지
float myCollisionRadius; // 자신의 충돌 범위 반지름
float targetConllisionRadius; // 타겟의 충돌 범위 반지름
bool hasTarget; // 타겟이 있는지 확인
void Awake()
{
pathfinder = GetComponent<UnityEngine.AI.NavMeshAgent>();
// NavMeshAgent 레퍼런스 생성
if (GameObject.FindGameObjectWithTag("Player") != null)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
hasTarget = true; // 타겟(플레이어) 있음으로 설정
target = GameObject.FindGameObjectWithTag("Player").transform;
// 타겟으로 "Player" 라는 태그를 가진 오브젝트의 트랜스폼을 저장
targetEntity = target.gameObject.GetComponent<LivingEntity>();
// 위에서 저장 한 플레이어 오브젝트의 LivingEntity 컴포넌트를 저장
myCollisionRadius = GetComponent<CapsuleCollider>().radius;
// 자신의 충돌체(collider)의 반지름을 저장
targetConllisionRadius = target.GetComponent<CapsuleCollider>().radius;
// 타겟의 충돌체(collider)의 반지름을 저장
}
}
protected override void Start()
// override 로 부모 클래스의 메소드를 재정의.
{
base.Start(); // base 를 통해 부모 클래스의 기존 메소드를 호출.
if(hasTarget)
// 적 스폰 후 플레이어 오브젝트가 있는(플레이어가 살아있는) 경우
{
currentState = State.Cashing; // 기본 상태를 추적 상태로 설정
targetEntity.OnDeath += OnTargetDeath;
// 타겟 사망 메소드 추가.
StartCoroutine(UpdatePath());
// 지정된 시간마다 목적지 쪽으로 위치를 갱신하는 코루틴 실행
}
}
// ■ 정보 수정 메소드
public void SetCharactoristics(float moveSpeed, int hitsToKillPlayer, float enemyHealth, Color skinColour) {
pathfinder.speed = moveSpeed; // 적의 이동속도 설정
if (hasTarget) { // 목표 (플레이어)가 있다면
damage = Mathf.Ceil(targetEntity.startingHealth / hitsToKillPlayer); // 적의 공격력 설정
}
startingHealth = enemyHealth; // 적의 체력 설정
skinMatreial = GetComponent<Renderer>().material; // 현재 오브젝트의 마테리얼 저장.
skinMatreial.color = skinColour; // 적의 색상 설정
originalColor = skinMatreial.color; // 현재 색상 저장
}
// ■ 타격 메소드 오버라이딩
public override void TakeHit(float damage, Vector3 hitPoint, Vector3 hitDirection) {
if(damage >= health) { // 데미지가 현재 체력 이상인 경우
Destroy(Instantiate(deathEffect.gameObject, hitPoint, Quaternion.FromToRotation(Vector3.forward, hitDirection)) as GameObject, deathEffect.startLifetime);
// 이펙트(파티클)을 인스턴스화 하여 생성(FromToRotation 으로 방향 설정), 설정한 시간경과 후 파괴
}
base.TakeHit(damage, hitPoint, hitDirection);
// 부모 클래스의 기존 TakeHit 메소드 호출
}
void OnTargetDeath() // 타겟(플레이어)이 죽었을 때 호출되는 메소드
{
hasTarget = false; // 타겟 없음으로 설정
currentState = State.Idle; // 현재 상태를 기본(정지)상태로 변경
}
void Update()
{
if (hasTarget) // 타겟(플레이어)가 있는 경우
{
if (Time.time >= nextAttackTime) // 공격 시간이 지나면
{
float sqrDstToTarget = (target.position - transform.position).sqrMagnitude;
/* 자신(적)과 목표(플레이어) 사이의 거리를 저장.
두 오브젝트 사이의 거리를 측정하기 위해 Vector3 의 Distace 메소드를
쓰는 방법도 있지만 벡터에 제곱 연산이 들어가다 보니 연산 수가 많다.
두 오브젝트 사이의 실제 거리가 필요한 것이 아닌 비교값이 필요 한
것이므로 위와 같이 연산을 줄여 볼 수 있다. */
if (sqrDstToTarget <= Mathf.Pow(attackDistanceThreshold + targetConllisionRadius + myCollisionRadius, 2))
// 거리의 제곱과 임계 거리의 제곱을 비교하여 공격 범위 내인지 확인
{
nextAttackTime = Time.time + timeBetweenAttacks;
// 다음 공격 시간 지정
StartCoroutine(Attack());
// 공격 코루틴 실행
}
}
}
}
IEnumerator Attack() // 적의 공격 코루틴
{
currentState = State.Attacking; // 공격 상태로 변경
pathfinder.enabled = false; // 공격 중 플레이어 추적 중지
Vector3 originalPosition = transform.position; // 자신(적)의 위치
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 attackPosition = target.position - dirToTarget * (myCollisionRadius);
// 타겟(플레이어)의 위치
float attackSpeed = 3; // 공격 속도
float percent = 0;
skinMatreial.color = Color.magenta; // 공격 시 색상 지정
bool hasAppliedDamage = false; // 데미지를 적용 중인지 확인
while(percent <= 1) // 찌르는 거리가 1 이하일 때 루프
{
if(percent >= .5f && hasAppliedDamage == false)
// 적이 공격 지점에 도달했고 데미지를 적용중이지 않은 경우
{
hasAppliedDamage = true; // 데미지 적용 중으로 설정
targetEntity.TakeDamage(damage); // 타겟(플레이어)에게 데미지 적용
}
percent += Time.deltaTime * attackSpeed;
float interpolation = (-Mathf.Pow(percent, 2) + percent) * 4;
/* 대칭 함수를 사용
원지점->공격지점 이동에서 보간 값을 참조한다. */
transform.position = Vector3.Lerp(originalPosition, attackPosition, interpolation);
/* Lerp 메소드는 내분점을 반환한다.
원점, 공격지점, 보간 값을 참조값으로 전달한다
보간 값이 0이면 원점에, 1이면 공격지점에 있게 된다. */
yield return null;
/* while 루프의 처리 사이에서 프레임을 스킵합니다.
이 지점에서 작업이 멈추고 Update 메소드 작업이 끝나고
다음 프레임으로 넘어가면 이 아래의 코드나 루프가 실행 */
}
skinMatreial.color = originalColor; // 공격이 끝나면 기존 색상으로 변경
currentState = State.Cashing; // 공격이 끝나면 추적 상태로 변경
pathfinder.enabled = true; // 공격이 끝나면 다시 플레이어 추적
}
IEnumerator UpdatePath()
// 해당 코루틴 실행 시 지정한 시간마다 루프문 내부 코드 반복
{
float refreshRate = .25f; // 루프 시간
while(hasTarget) // 타겟이 있을 때 반복
{
if(currentState == State.Cashing)
// 상태가 추적 상태인 경우
{
Vector3 dirToTarget = (target.position - transform.position).normalized;
// 타겟으로의 방향 벡터 계산
Vector3 targetPosition = target.position - dirToTarget * (myCollisionRadius + targetConllisionRadius + attackDistanceThreshold / 2);
/* 자신의 위치에서 자신과 타겟의 반지름 길이에 공격 사거리의 절반을 더하고, 방향 벡터를 곱한 값을 뺀다.
즉, 타겟(플레이어) 공격 가능한 위치를 타겟 위치로 정한다. */
if (!dead) // 죽지 않은 경우
{
pathfinder.SetDestination(targetPosition);
// 내비게이션의 목적지를 타겟(플레이어)의 위치로 설정
}
}
yield return new WaitForSeconds(refreshRate);
// refreshRate 시간만큼 기다림
}
}
}
Awake 를 통해 오브젝트가 생성되자 마자 필요한 정보들을 미리 불러온다.
이제 맵의 바닥을 설정하자.
임시로 쓰던 NavMesh Floor 오브젝트를 복제해 Map Floor 로 바꾸고 그림자 드리우기를 꺼짐으로 설정.
머터리얼 Ground backing 을 생성해 검은색으로 설정하고 적용한 뒤, 오브젝트 y 좌표를 -0.1 로 설정해주자.
Map 오브젝트의 박스 콜라이더 컴포넌트를 복사한 다음 제거하고, 레이어를 Default 로 설정한다.
그리고 복사한 박스 콜라이더 컴포넌트를 Map Floor 오브젝트에 붙여넣고 레이어를 Floor 로 설정한다.
이제 MapGenerator 스크립트를 수정하자.
| MapGenerator 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MapGenerator : MonoBehaviour
{
public Map[] maps; // 맵 배열
public int mapIndex; // 맵 배열 참조용 인덱스
public Transform tilePrefab; // 인스턴스화 할 타일 프리팹
public Transform obstaclePrefab; // 인스턴스화 할 장애물 프리팹
public Transform mapFloor; // 맵 바닥 레퍼런스
public Transform navmeshFloor; // 내브메쉬를 위한 바닥 사이즈
public Transform navemeshMaskPrefab; // 맵 바깥쪽 마스킹 프리팹
public Vector2 maxMapSize; // 최대 맵 크기
[Range(0, 1)] // 범위 지정
public float outlinePercent; // 테두리 두께
public float tileSize; // 타일 사이즈
List<Coord> allTileCoords; // 모든 좌표값을 저장할 리스트 생성
Queue<Coord> shuffledTileCoords; // 셔플된 좌표값을 저장할 큐 생성
Queue<Coord> shuffledOpenTileCoords; // 셔플된 오픈 타일 좌표값을 저장할 큐 생성
Transform[,] tileMap; // 생성한 타일맵 배열
Map currentMap; // 현재 맵
void Start() {
FindObjectOfType<Spawner>().OnNewWave += OnNewWave; // 이벤트 구독
}
// ■ 웨이브 이벤트 구독자 메소드
void OnNewWave(int waveNumber) {
mapIndex = waveNumber - 1; // 맵 배열 인덱스 설정
GeneratorMap(); // 맵 생성
}
// ■ 맵 생성 메소드
public void GeneratorMap() {
currentMap = maps[mapIndex]; // 맵 설정
tileMap = new Transform[currentMap.mapSize.x, currentMap.mapSize.y]; // 타일맵 배열 크기 설정
System.Random prng = new System.Random(currentMap.seed); // 난수 생성
// ■ 좌표(Coord)들을 생성
allTileCoords = new List<Coord>(); // 새 리스트 생성
for (int x = 0; x < currentMap.mapSize.x; x++) {
for (int y = 0; y < currentMap.mapSize.y; y++) { // 지정한 맵 크기만큼 루프
allTileCoords.Add(new Coord(x, y)); // 리스트에 타일 좌표 추가
}
}
shuffledTileCoords = new Queue<Coord>(Utility.ShuffleArray(allTileCoords.ToArray(), currentMap.seed));
// 새 큐 생성, 셔플된 좌표값 배열을 저장함
// ■ 맵 홀더(부모 오브젝트) 생성
string holderName = "Generator Map"; // 타일 오브젝트를 가질 부모 오브젝트 이름
if (transform.Find(holderName)) // 위 오브젝트에 자식 오브젝트가 있다면
// * 강의에서는 FindChild 를 사용하는데, 버전이 바뀌면서 사용하지 않고 Find 로 변경되었다.
{
DestroyImmediate(transform.Find(holderName).gameObject);
/* 오브젝트의 자식 게임 오브젝트를 제거한다.
에디터에서 호출 할 것이기 때문에 DestroyImmediate 를 사용한다. */
}
Transform mapHolder = new GameObject(holderName).transform;
// 타일을 자식 오브젝트로 가질 새 오브젝트를 생성
mapHolder.parent = transform;
// 현재 오브젝트를 mapHolder 오브젝트의 부모 오브젝트로 설정.
// ■ 타일들을 생성
for (int x = 0; x < currentMap.mapSize.x; x++) {
for (int y = 0; y < currentMap.mapSize.y; y++) { // 지정한 맵 크기만큼 루프
Vector3 tilePosition = CoordToPosition(x, y);
/* 타일이 생성될 위치 저장.
-지정한 맵 가로 길이/2 를 설정하면 0에서 가로길이의 절반만큼 왼쪽으로 이동한 위치가 된다.
이를 활용하여 z 좌표에도 적용해 화면의 왼쪽 위 모서리부터 맵이 생성되도록 한다. */
Transform newTile = Instantiate(tilePrefab, tilePosition, Quaternion.Euler(Vector3.right * 90)) as Transform;
/* 타일을 인스턴스화 하여 생성하고 위치, 각도를 설정
위치는 위에서 생성한 값을 전달하고 각도는 Quaternion(사원수) 메소드를 사용,
오일러 각을 기준으로 회전시킨다. */
newTile.localScale = Vector3.one * (1 - outlinePercent) * tileSize;
/* localScale 은 오브젝트의 상대적 크기, Vector3.one 은 Vector3(1,1,1) 이다.
크기를 지정한 테두리 두께만큼 줄여서 지정한다. */
newTile.parent = mapHolder;
// 타일의 부모 오브젝트 설정
tileMap[x, y] = newTile;
// 타일맵 배열에 타일 저장
}
}
// ■ 장애물들을 생성
bool[,] obstaclemap = new bool[(int)currentMap.mapSize.x, (int)currentMap.mapSize.y]; // 장애물 위치 확인 배열
int currentObstacleCount = 0; // 현재 올바른 장애물 생성 개수
int obstacleCount = (int)(currentMap.mapSize.x * currentMap.mapSize.y * currentMap.obstaclePercent); // 지정한 비율에 따른 장애물 개수
List<Coord> allOpenCoords = new List<Coord>(allTileCoords); // 오픈 타일 배열, 일단 모든 타일 좌표로 초기화.
for (int i = 0; i < obstacleCount; i++) { // 장애물 갯수만큼 루프
Coord randomCoord = GetRandomCoord(); // 랜덤한 좌표를 받아옴
obstaclemap[randomCoord.x, randomCoord.y] = true; // 해당 랜덤 위치 활성화
currentObstacleCount++; // 장애물 개수 증가
if (randomCoord != currentMap.mapCenter && MaplsFullyAccessible(obstaclemap, currentObstacleCount)) {
// 랜덤 위치가 맵 중앙이 아니고 막힌 곳을 만들지 않을 때 아래 코드 실행
float obstacleHeight = Mathf.Lerp(currentMap.minObstacleHeight, currentMap.maxObstacleHeight, (float)prng.NextDouble());
// 장애물 높이 설정
Vector3 obstaclePosition = CoordToPosition(randomCoord.x, randomCoord.y);
// 좌표 변환
Transform newObstacle = Instantiate(obstaclePrefab, obstaclePosition + Vector3.up * obstacleHeight / 2, Quaternion.identity);
// 장애물 인스턴스화 하여 생성
newObstacle.parent = mapHolder;
// 장애물의 부모 오브젝트 설정
newObstacle.localScale = new Vector3((1 - outlinePercent) * tileSize, obstacleHeight, (1 - outlinePercent) * tileSize);
// 장애물 크기를 지정한 테두리 두께만큼 줄여서 타일 사이즈와 맞게 지정하고 높이를 지정한다.
Renderer obstacleRenderer = newObstacle.GetComponent<Renderer>();
// 인스턴스화 하여 생성한 장애물의 렌더러 레퍼런스 생성
Material obstacleMatetial = new Material(obstacleRenderer.sharedMaterial);
// 장애물의 마테리얼 생성, 위 레퍼런스를 통해 장애물의 셰어드 마테리얼 저장
float colourPercent = randomCoord.y / (float)currentMap.mapSize.y;
// 색상 비율 설정
obstacleMatetial.color = Color.Lerp(currentMap.foregroundColour, currentMap.backgroungColour, colourPercent);
// 장애물 색상 설정
obstacleRenderer.sharedMaterial = obstacleMatetial;
// 장애물의 셰어드 마테리얼 설정
allOpenCoords.Remove(randomCoord);
// 오픈 타일 좌표만 남기기 위해 모든 타일 좌표에서 장애물이 있는 타일 좌표를 빼준다.
}
else { // 장애물 생성 조건이 맞지 않는 경우
obstaclemap[randomCoord.x, randomCoord.y] = false; // 해당 랜덤 위치 비활성화
currentObstacleCount--; // 장애물 개수 감소
}
}
shuffledOpenTileCoords = new Queue<Coord>(Utility.ShuffleArray(allOpenCoords.ToArray(), currentMap.seed));
// 새 큐 생성, 셔플된 오픈 타일 좌표값 배열을 저장함
// ■ 내브메쉬 마스크 생성
Transform maskLeft = Instantiate(navemeshMaskPrefab, Vector3.left * (currentMap.mapSize.x + maxMapSize.x) / 4f * tileSize, Quaternion.identity) as Transform;
// 왼쪽 맵 바깥 마스킹 오브젝트 생성
maskLeft.parent = mapHolder;
// 부모 오브젝트 설정
maskLeft.localScale = new Vector3((maxMapSize.x - currentMap.mapSize.x) / 2f * tileSize, 3, currentMap.mapSize.y * tileSize);
// 왼쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskRight = Instantiate(navemeshMaskPrefab, Vector3.right * (currentMap.mapSize.x + maxMapSize.x) / 4f * tileSize, Quaternion.identity) as Transform;
// 오른쪽 맵 바깥 마스킹 오브젝트 생성
maskRight.parent = mapHolder;
// 부모 오브젝트 설정
maskRight.localScale = new Vector3((maxMapSize.x - currentMap.mapSize.x) / 2f * tileSize, 3, currentMap.mapSize.y * tileSize);
// 오른쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskTop = Instantiate(navemeshMaskPrefab, Vector3.forward * (currentMap.mapSize.y + maxMapSize.y) / 4f * tileSize, Quaternion.identity) as Transform;
// 위쪽 맵 바깥 마스킹 오브젝트 생성
maskTop.parent = mapHolder;
// 부모 오브젝트 설정
maskTop.localScale = new Vector3(maxMapSize.x * tileSize, 3, (maxMapSize.y - currentMap.mapSize.y) / 2f * tileSize);
// 위쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskBottom = Instantiate(navemeshMaskPrefab, Vector3.back * (currentMap.mapSize.y + maxMapSize.y) / 4f * tileSize, Quaternion.identity) as Transform;
// 아래쪽 맵 바깥 마스킹 오브젝트 생성
maskBottom.parent = mapHolder;
// 부모 오브젝트 설정
maskBottom.localScale = new Vector3(maxMapSize.x * tileSize, 3, (maxMapSize.y - currentMap.mapSize.y) / 2f * tileSize);
// 아래쪽 맵 바깥 마스킹 오브젝트 크기 설정
navmeshFloor.localScale = new Vector3(maxMapSize.x, maxMapSize.y) * tileSize;
// 지정한 최대 맵 사이즈에 맞게 내브메쉬 바닥 크기 조정
mapFloor.localScale = new Vector3(currentMap.mapSize.x * tileSize, currentMap.mapSize.y * tileSize, 1);
// 맵 바닥 크기 설정
}
// ■ 맵 확인 메소드 (Flood-fill Algorithm)
bool MaplsFullyAccessible(bool[,] obstacleMap, int currentObstacleCount) {
bool[,] mapFlag = new bool[obstacleMap.GetLength(0), obstacleMap.GetLength(1)];
// 지나온 비어있는 타일을 체크할 배열을 생성
Queue<Coord> queue = new Queue<Coord>(); // 큐 생성
queue.Enqueue(currentMap.mapCenter); // 맵 중앙 위치를 큐에 넣음
mapFlag[currentMap.mapCenter.x, currentMap.mapCenter.y] = true; // 맵 중앙을 비어있는 타일로 체크
int accessibleTileCount = 1; // 접근 가능한 타일 개수(맵 중앙 포함이므로 기본 1)
while (queue.Count > 0) { // 큐에 들어있는 값이 있는 경우
Coord tile = queue.Dequeue(); // 큐에 저장된 맨 앞 타일 위치를 빼서 가져옴
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) { // 주변 타일 루프
int neighbourX = tile.x + x; // 주변 타일의 x 좌표
int neighbourY = tile.y + y; // 주변 타일의 y 좌표
if (x == 0 || y == 0) { // 주변 타일 중 대각선상에 위치하지 않은 경우
if (neighbourX >= 0 && neighbourX < obstacleMap.GetLength(0) && neighbourY >= 0 && neighbourY < obstacleMap.GetLength(1)) {
// 체크 중 맵 크기를 벗어나지 않는 경우
if (!mapFlag[neighbourX, neighbourY] && !obstacleMap[neighbourX, neighbourY]) {
// 체크된 타일이 아니고, 장애물이 아닌 경우
mapFlag[neighbourX, neighbourY] = true; // 타일 체크
queue.Enqueue(new Coord(neighbourX, neighbourY)); // 해당 타일 위치를 큐에 삽입
accessibleTileCount++; // 접근 가능한 타일 수 증가
}
}
}
}
}
}
int targetAccessibleTileCount = (int)(currentMap.mapSize.x * currentMap.mapSize.y - currentObstacleCount);
// 현재 접근 가능해야 하는 타일 개수
return targetAccessibleTileCount == accessibleTileCount;
// 개수가 같다면(막힌 곳 없이 모든 타일에 접근 가능) true, 아니면 false 반환
}
// ■ 좌표 변환 메소드
Vector3 CoordToPosition(int x, int y) {
return new Vector3(-currentMap.mapSize.x / 2f + 0.5f + x, 0, -currentMap.mapSize.y / 2f + 0.5f + y) * tileSize;
// 입력받은 x, y 좌표로 Vector3 상의 타일 위치 설정
}
// ■ 플레이어 좌표 -> 타일 좌표 변환 메소드
public Transform GetTileFromPosition(Vector3 position) {
int x = Mathf.RoundToInt(position.x / tileSize + (currentMap.mapSize.x - 1) / 2f);
// 타일 x 좌표 계산, int 형변환시 내림하기 때문에 메소드를 통해 정수로 반올림한다.
int y = Mathf.RoundToInt(position.z / tileSize + (currentMap.mapSize.y - 1) / 2f);
// 타일 y 좌표 계산.
x = Mathf.Clamp(x, 0, tileMap.GetLength(0) - 1);
// 타일맵 배열 인덱스 초과 오류 방지를 위해 x 값 제한.
y = Mathf.Clamp(y, 0, tileMap.GetLength(1) - 1);
// 타일맵 배열 인덱스 초과 오류 방지를 위해 y 값 제한.
return tileMap[x, y];
// 타일 위치 반환
}
// ■ 큐에 저장된 좌표를 가져오는 메소드
public Coord GetRandomCoord() {
Coord randomCoord = shuffledTileCoords.Dequeue(); // 큐의 첫 번째 값을 가져온다.
shuffledTileCoords.Enqueue(randomCoord); // 가져온 값을 큐의 맨 뒤로 넣는다.
return randomCoord;
}
// ■ 랜덤한 오픈 타일을 가져오는 메소드
public Transform GetRandomOpenTile() {
Coord randomCoord = shuffledOpenTileCoords.Dequeue(); // 큐의 첫 번째 값을 가져온다.
shuffledOpenTileCoords.Enqueue(randomCoord); // 가져온 값을 큐의 맨 뒤로 넣는다.
return tileMap[randomCoord.x, randomCoord.y]; // 큐에 저장된 오픈 타일을 반환
}
// ■ 타일 좌표 구조체
[System.Serializable] // 인스펙터에서 보이도록 설정
public struct Coord {
public int x, y; // x, y 좌표값
public Coord(int _x, int _y) { // 생성자로 좌표값 초기화
x = _x;
y = _y;
}
public static bool operator ==(Coord c1, Coord c2) { // 구조체 비교 연산자 정의
return c1.x == c2.x && c1.y == c2.y;
}
public static bool operator !=(Coord c1, Coord c2) { // 구조체 비교 연산자 정의
return !(c1 == c2);
}
public override bool Equals(object obj) { // 비교 메소드 재정의
return base.Equals(obj);
}
public override int GetHashCode() { // GetHashCode 메소드 재정의
return base.GetHashCode();
}
}
// ■ 맵 속성들을 저장 할 클래스
[System.Serializable] // 인스펙터에서 보이도록 설정
public class Map {
public Coord mapSize; // 맵 크기
[Range(0, 1)] // 장애물 비율 범위 설정
public float obstaclePercent; // 맵의 장애물 비율
public int seed; // 장애물 랜덤 생성 시드
public float minObstacleHeight; // 장애물 최소 높이
public float maxObstacleHeight; // 장애물 최대 높이
public Color foregroundColour; // 장애물 전면부 색상
public Color backgroungColour; // 장애물 후면부 색상
public Coord mapCenter { // 맵 중앙 좌표
get {
return new Coord(mapSize.x / 2, mapSize.y / 2); // 중앙 좌표 지정 후 리턴
}
}
}
}
이제 프로젝트로 가서 맵을 재생성해보면 이렇게 검은 바닥이 생긴 것을 볼 수 있다.
이제 맵 생성기, 웨이브 정보를 조절해서 설정하고 실행해보자.
설정한 맵과 웨이브대로 게임이 진행된다.
개발자 모드를 활성화 한 뒤 엔터 키를 누르면 웨이브가 넘어가진다.
다음 강의에서는 크로스헤어(조준점)를 추가해본다.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 20 (1) | 2023.12.12 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 19 (1) | 2023.12.11 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 17 (0) | 2023.12.08 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 16 (0) | 2023.12.07 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 15 (2) | 2023.12.06 |