지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=vOC3usydLeE&list=PLctzObGsrjfyevwpeEVQ9pxGVwZtS7gZK&index=14
이번 강의에서는 적들이 랜덤한 위치에서 생성되도록 해보자.
시작해보자.
MapGenerator 스크립트를 통해 만든 타일맵에서 비어있는 타일 중 하나를 랜덤으로 선택하고
해당 타일에 적이 스폰되도록 하자.
이 때, 적을 스폰하기 전에 타일을 붉은색으로 반짝여 적 스폰을 알려주자.
이를 위해 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 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() {
GeneratorMap();
}
// ■ 맵 생성 메소드
public void GeneratorMap() {
currentMap = maps[mapIndex]; // 맵 설정
tileMap = new Transform[currentMap.mapSize.x, currentMap.mapSize.y]; // 타일맵 배열 크기 설정
System.Random prng = new System.Random(currentMap.seed); // 난수 생성
GetComponent<BoxCollider>().size = new Vector3(currentMap.mapSize.x * tileSize, 0.05f, currentMap.mapSize.y * tileSize);
// 박스 콜라이더 맵 크기로 설정
// ■ 좌표(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, 1, 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, 1, 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, 1, (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, 1, (maxMapSize.y - currentMap.mapSize.y) / 2f * tileSize);
// 아래쪽 맵 바깥 마스킹 오브젝트 크기 설정
navmeshFloor.localScale = new Vector3(maxMapSize.x, maxMapSize.y) * tileSize;
// 지정한 최대 맵 사이즈에 맞게 내브메쉬 바닥 크기 조정
}
// ■ 맵 확인 메소드 (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 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); // 중앙 좌표 지정 후 리턴
}
}
}
}
랜덤한 오픈 타일(장애물이 없는 타일)의 좌표를 받아오도록 설정했다.
이제 받아온 이 타일을 Spawner 스크립트에서 사용해 적의 스폰 위치를 랜덤으로 지정하도록 하자.
| Spawner 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
public Wave[] waves; // 웨이브들을 저장할 배열 생성
public Enemy enemy; // 스폰할 적 레퍼런스
Wave currentWave; // 현재 웨이브 레퍼런스
int currentWaveNumber; // 현재 웨이브 번호
int enemiesRemainingToSpawn; // 남아있는 스폰 할 적 수
int enemiesRemainingAlive; // 살아있는 적의 수
float nextSpawnTime; // 다음 스폰까지의 시간
MapGenerator map; // 맵 생성기 레퍼런스
void Start() {
map = FindObjectOfType<MapGenerator>(); // 맵 생성기
NextWave(); // 시작 시 웨이브 실행
}
void Update()
{
if(enemiesRemainingToSpawn > 0 && Time.time >= nextSpawnTime)
// 적 수가 남아있고 현재 시간이 다음 스폰 시간보다 큰 경우
{
enemiesRemainingToSpawn--; // 적 수를 하나 줄임
nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;
// 다음 스폰 시간을 현재시간 + 스폰 간격 으로 저장
StartCoroutine(SpawnEnemy()); // 적 스폰 코루틴 시작
}
}
// ■ 적 스폰 코루틴
IEnumerator SpawnEnemy() {
float spawnDelay = 1;
// 적 스폰 대기시간
float tileFlashSpeed = 4;
// 초당 몇번 타일이 깜빡일지 설정
Transform randomTile = map.GetRandomOpenTile();
// 랜덤 오픈 타일 가져오기
Material tileMat = randomTile.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; // 한 프레임 대기
}
Enemy spawnedEnemy = Instantiate(enemy, randomTile.position + Vector3.up, Quaternion.identity) as Enemy;
// 적을 인스턴스화를 통해 생성, 랜덤 타일 위치에 회전값 없이 배치
spawnedEnemy.OnDeath += OnEnemyDeath;
// 적이 죽을 때 위 메소드를 추가
}
void OnEnemyDeath()
/* 적이 죽을 때 처리하는 메소드
적이 죽으면 LivingEntity 에서 OnDeath 를 호출하고,
OnDeath 는 이 메소드를 호출해서 적이 죽을 때 알려준다. */
{
enemiesRemainingAlive--; // 적의 수를 1 줄임
if(enemiesRemainingAlive == 0) // 적의 수가 0이 되면
{
NextWave(); // 다음 웨이브 실행
}
}
void NextWave() // 다음 웨이브 실행 메소드
{
currentWaveNumber++; // 웨이브 숫자 증가
print("Wave : " + currentWaveNumber); // 현재 웨이브 번호 출력
if(currentWaveNumber - 1 < waves.Length) { // 배열 인덱스 예외 없도록 처리
currentWave = waves[currentWaveNumber - 1];
/* 현재 웨이브 레퍼런스 참조
(웨이브 숫자는 1부터 시작 할 것이므로 -1 하여 배열의 인덱스에 맞게 참조) */
enemiesRemainingToSpawn = currentWave.enemyCount;
// 이번 웨이브의 적 수 저장
enemiesRemainingAlive = enemiesRemainingToSpawn;
// 살아있는 적의 수를 스폰 할 적의 수로 저장
}
}
[System.Serializable]
/* 스크립트 직렬화, 직렬화를 통해 객체, 변수 등을 선언 할 때의
접근 제한(private 등)은 유지되지만 인스펙터에서 값을 변경 가능하게 함 */
public class Wave
// 적의 주기, 스폰 주기 등 웨이브 정보를 저장 할 클래스 생성
{
public int enemyCount; // 적의 수
public float timeBetweenSpawns; // 적의 스폰 주기
}
}
장애물 생성 부분을 참고해 오픈 타일 위치를 받아올 수 있도록 하고,
PingPong 함수를 통한 해당 타일 깜빡임 후에 적을 스폰하도록 했다.
이제 실행해보자.
랜덤한 타일에서 적이 스폰되는 것을 볼 수 있다.
이제 게임의 난이도를 위해 기능을 좀 더 추가해보자.
지금은 플레이어가 구석진 곳에 자리를 잡고 가만히 총만 쏘면 적들을 쉽게 처리한다.
이렇게 유리한 위치에서 멈춰서 플레이하는 것을 캠핑(Camping)이라고도 한다.
이런 캠핑을 막기 위해 플레이어가 몇 초동안 가만히 있는 경우 플레이어의 위치에 적을 소환해보자.
MapGenerator 스크립트에서 플레이어의 좌표를 통해 타일의 위치를 받아오도록 한다.
플레이어의 좌표를 통해 타일의 위치를 받아와야 하는데, 이를 위해 전에 작성한 CoordToPosition 메소드를 보자.
타일의 위치를 받아오는 것은 이 메소드의 반대로 하면 된다.
| CoordToPosition 메소드
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 상의 타일 위치 설정
}
x 를 구해보자.
위치 p 가 있을 때, p = (- MapSize / 2 + 1 / 2 + x) * tileSize 이다.
이를 정리하면 p / tileSize = (-MapSize + 1) / 2 + x 가 되고.
p / tileSize + (size - 1) / 2 = x 가 된다.
y 도 동일하다.
이제 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 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() {
GeneratorMap();
}
// ■ 맵 생성 메소드
public void GeneratorMap() {
currentMap = maps[mapIndex]; // 맵 설정
tileMap = new Transform[currentMap.mapSize.x, currentMap.mapSize.y]; // 타일맵 배열 크기 설정
System.Random prng = new System.Random(currentMap.seed); // 난수 생성
GetComponent<BoxCollider>().size = new Vector3(currentMap.mapSize.x * tileSize, 0.05f, currentMap.mapSize.y * tileSize);
// 박스 콜라이더 맵 크기로 설정
// ■ 좌표(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;
// 지정한 최대 맵 사이즈에 맞게 내브메쉬 바닥 크기 조정
}
// ■ 맵 확인 메소드 (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); // 중앙 좌표 지정 후 리턴
}
}
}
}
내브메쉬 마스크 프리팹에 박스 콜라이더를 추가하고
맵 생성기에서 마스크를 생성할 때 y 사이즈를 크게 설정해서 플레이어가 맵 밖으로 떨어지지 않게 했다.
이제 플레이어의 위치를 계속 받아오고, 위치가 변하지 않는다면 그 위치를 받아올 수 있도록 하자.
| Spawner 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
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; // 플레이어 생존 여부
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 && Time.time >= nextSpawnTime)
// 적 수가 남아있고 현재 시간이 다음 스폰 시간보다 큰 경우
{
enemiesRemainingToSpawn--; // 적 수를 하나 줄임
nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;
// 다음 스폰 시간을 현재시간 + 스폰 간격 으로 저장
StartCoroutine(SpawnEnemy()); // 적 스폰 코루틴 시작
}
}
}
// ■ 적 스폰 코루틴
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;
// 적이 죽을 때 위 메소드를 추가
}
// ■ 플레이어가 죽을 때 처리하는 메소드
void OnPlayerDeath() {
isDisable = true;
// 생존 여부 죽음(비활성화)으로 설정.
}
void OnEnemyDeath()
/* 적이 죽을 때 처리하는 메소드
적이 죽으면 LivingEntity 에서 OnDeath 를 호출하고,
OnDeath 는 이 메소드를 호출해서 적이 죽을 때 알려준다. */
{
enemiesRemainingAlive--; // 적의 수를 1 줄임
if(enemiesRemainingAlive == 0) // 적의 수가 0이 되면
{
NextWave(); // 다음 웨이브 실행
}
}
void NextWave() // 다음 웨이브 실행 메소드
{
currentWaveNumber++; // 웨이브 숫자 증가
print("Wave : " + currentWaveNumber); // 현재 웨이브 번호 출력
if(currentWaveNumber - 1 < waves.Length) { // 배열 인덱스 예외 없도록 처리
currentWave = waves[currentWaveNumber - 1];
/* 현재 웨이브 레퍼런스 참조
(웨이브 숫자는 1부터 시작 할 것이므로 -1 하여 배열의 인덱스에 맞게 참조) */
enemiesRemainingToSpawn = currentWave.enemyCount;
// 이번 웨이브의 적 수 저장
enemiesRemainingAlive = enemiesRemainingToSpawn;
// 살아있는 적의 수를 스폰 할 적의 수로 저장
}
}
[System.Serializable]
/* 스크립트 직렬화, 직렬화를 통해 객체, 변수 등을 선언 할 때의
접근 제한(private 등)은 유지되지만 인스펙터에서 값을 변경 가능하게 함 */
public class Wave
// 적의 주기, 스폰 주기 등 웨이브 정보를 저장 할 클래스 생성
{
public int enemyCount; // 적의 수
public float timeBetweenSpawns; // 적의 스폰 주기
}
}
이제 실행해보자.
위와 같이 플레이어가 한 자리에 오래 있으면 그 자리에 적이 소환된다.
다음 시간에는 게임 오버를 추가해보자.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 15 (2) | 2023.12.06 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 14 (0) | 2023.12.06 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 12 (0) | 2023.12.01 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 11 (1) | 2023.11.30 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 10 (1) | 2023.11.30 |