지난번에 이어 게임을 만들어보자.
이번에 참고할 강의는 아래 강의이다.
https://www.youtube.com/watch?v=vQgLdFNrCN8&list=PLctzObGsrjfyevwpeEVQ9pxGVwZtS7gZK&index=12
이번 강의에서는 지금까지 만들어놓은 맵 생성기를 게임(내브메쉬)에 적용해본다.
시작해보자.
생성한 맵에서도 적들이 플레이어를 추적하게 하기 위해 Navmesh 를 적용해야 한다.
이 때, 맵 전체를 덮는 내브메쉬를 생성하고 돌아다니지 않는 부분은 맵에서 마스킹(제외)시킨다.
이 때문에 보이지 않는 평평한 바닥이 필요하므로 Quad(쿼드) 오브젝트를 하나 생성하자.
Map 오브젝트의 아래에 Quad 오브젝트를 생성, Navmesh Floor 로 이름을 변경한다.
메쉬 콜라이더는 지워주고 위치를 리셋하여 중앙에 놓자.
90도 회전해 평평하게 한 후 크기를 늘린다.
그리고 이제 메쉬는 남아있고 보이지 않게 하기 위해
조명 설정에서 그림자 드리우기(Cast Shadows)를 그림자만(Shadows Only)으로 설정한다.
이제 내브메쉬를 사용해보자.
내비게이션 탭으로 가서 Navigation Static 을 체크하고 베이크(Bake)하자.
이제 위와 같이 내브메쉬가 생성되었다.
바닥 부분의 크기를 맵 크기에 맞춰줘야 한다.
이를 위해 스크립트를 수정하자.
| MapGenerator 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MapGenerator : MonoBehaviour
{
public Transform tilePrefab; // 인스턴스화 할 타일 프리팹
public Transform obstaclePrefab; // 인스턴스화 할 장애물 프리팹
public Transform navmeshFloor; // 내브메쉬를 위한 바닥 사이즈
public Vector2 mapSize; // 맵 크기
public Vector2 maxMapSize; // 최대 맵 크기
[Range(0, 1)] // 범위 지정
public float outlinePercent; // 테두리 두께
[Range(0, 1)] // 범위 지정
public float obstaclePercent; // 장애물 개수
public float tileSize; // 타일 사이즈
List<Coord> allTileCoords; // 모든 좌표값을 저장할 리스트 생성
Queue<Coord> shuffledTileCoords; // 셔플된 좌표값을 저장할 큐 생성
public int seed = 10; // 좌표값 랜덤 설정 시드
Coord mapCenter; // 맵의 중앙 좌표
void Start() {
GeneratorMap();
}
// 맵 생성 메소드
public void GeneratorMap() {
allTileCoords = new List<Coord>(); // 새 리스트 생성
for (int x = 0; x < mapSize.x; x++) {
for (int y = 0; y < mapSize.y; y++) { // 지정한 맵 크기만큼 루프
allTileCoords.Add(new Coord(x, y)); // 리스트에 타일 좌표 추가
}
}
shuffledTileCoords = new Queue<Coord>(Utility.ShuffleArray(allTileCoords.ToArray(), seed));
// 새 큐 생성, 셔플된 좌표값 배열을 저장함
mapCenter = new Coord((int)mapSize.x / 2, (int)mapSize.y / 2);
// 맵 중앙 좌표 지정
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 오브젝트의 부모 오브젝트로 설정.
int currentObstacleCount = 0; // 현재 올바른 장애물 생성 개수
for(int x = 0; x < mapSize.x; x++) {
for(int y = 0; y < 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;
// 타일의 부모 오브젝트 설정
}
}
bool[,] obstaclemap = new bool[(int)mapSize.x, (int)mapSize.y];
// 장애물 위치 확인 배열
int obstacleCount = (int)(mapSize.x * mapSize.y * obstaclePercent); // 지정한 비율에 따른 장애물 개수
for(int i = 0; i < obstacleCount; i++) { // 장애물 갯수만큼 루프
Coord randomCoord = GetRandomCoord(); // 랜덤한 좌표를 받아옴
obstaclemap[randomCoord.x, randomCoord.y] = true; // 해당 랜덤 위치 활성화
currentObstacleCount++; // 장애물 개수 증가
if(randomCoord != mapCenter && MaplsFullyAccessible(obstaclemap, currentObstacleCount)) {
// 랜덤 위치가 맵 중앙이 아니고 막힌 곳을 만들지 않을 때 아래 코드 실행
Vector3 obstaclePosition = CoordToPosition(randomCoord.x, randomCoord.y); // 좌표 변환
Transform newObstacle = Instantiate(obstaclePrefab, obstaclePosition + Vector3.up * 0.5f, Quaternion.identity);
// 장애물 인스턴스화 하여 생성
newObstacle.parent = mapHolder;
// 장애물의 부모 오브젝트 설정
newObstacle.localScale = Vector3.one * (1 - outlinePercent) * tileSize;
// 장애물 크기를 지정한 테두리 두께만큼 줄여서 타일 사이즈와 맞게 지정한다.
}
else { // 장애물 생성 조건이 맞지 않는 경우
obstaclemap[randomCoord.x, randomCoord.y] = false; // 해당 랜덤 위치 비활성화
currentObstacleCount--; // 장애물 개수 감소
}
}
navmeshFloor.localScale = new Vector3(maxMapSize.x, maxMapSize.y) * tileSize;
// 지정한 최대 맵 사이즈에 맞게 내브메쉬 바닥 크기 조정
}
// 맵 확인 메소드
bool MaplsFullyAccessible(bool[,] obstacleMap, int currentObstacleCount) {
bool[,] mapFlag = new bool[obstacleMap.GetLength(0), obstacleMap.GetLength(1)];
// 지나온 비어있는 타일을 체크할 배열을 생성
Queue<Coord> queue = new Queue<Coord>(); // 큐 생성
queue.Enqueue(mapCenter); // 맵 중앙 위치를 큐에 넣음
mapFlag[mapCenter.x, 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)(mapSize.x * mapSize.y - currentObstacleCount);
// 현재 접근 가능해야 하는 타일 개수
return targetAccessibleTileCount == accessibleTileCount;
// 개수가 같다면(막힌 곳 없이 모든 타일에 접근 가능) true, 아니면 false 반환
}
Vector3 CoordToPosition(int x, int y) { // 좌표 변환 메소드
return new Vector3(-mapSize.x / 2 + 0.5f + x, 0, -mapSize.y / 2 + 0.5f + y) * tileSize;
// 입력받은 x, y 좌표로 Vector3 상의 타일 위치 설정
}
public Coord GetRandomCoord() { // 큐에 저장된 좌표를 가져오는 메소드
Coord randomCoord = shuffledTileCoords.Dequeue(); // 큐의 첫 번째 값을 가져온다.
shuffledTileCoords.Enqueue(randomCoord); // 가져온 값을 큐의 맨 뒤로 넣는다.
return randomCoord;
}
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();
}
}
}
인스펙터 창에서 최대 맵 사이즈 설정이 가능해졌다.
이제 장애물을 설정해주자.
Obstacle 프리팹에 가서 Nav Mesh Obstacle 을 추가해주자.
그리고 컴포넌트에서 파내기(Carve)를 체크한다.
이제 Bake 해보면 내브메쉬가 잘 생성된다.
이제 맵 바깥으로 나가지 못하도록 막아보자.
빈 오브젝트 navmesh Mask 를 생성, Nav Mesh Obstacle 컴포넌트를 추가하고
파내기(Carve) 를 체크해준다.
이제 이 오브젝트를 프리팹으로 만든다.
이 오브젝트를 최대 맵(내브메쉬 맵)의 안쪽과 타일맵의 바깥쪽에 위치시켜
타일맵 바깥쪽의 내브메쉬 맵을 파내야 한다.
이를 위해 구해야 하는 것은 내브메쉬 맵과 타일 맵 사이 공간의 중간지점이다.
내브메쉬 맵과 타일 맵 사이, 상, 하, 좌, 우의 중간지점에 navmesh Mask 오브젝트를 위치해야 한다.
내브메쉬 맵의 가로길이가 m 이고, 타일 맵의 가로 길이가 x 라고 하면,
중앙지점부터 목표 위치까지의 거리는 x / 2 + (m - x) / 4 가 된다.
이는 (2x + m - x) / 4 이므로, (x + m) / 4 이다.
크기는 좌, 우의 경우 가로길이 (내브메쉬 맵 가로길이 - 타일 맵 가로길이) / 2,
세로길이는 타일 맵 세로길이가 된다.
상, 하 에 위치할 오브젝트의 가로길이는 네브매쉬 맵 가로 길이,
세로길이 (내브메쉬 맵 세로길이 - 타일 맵 세로길이) / 2 이다.
아 그리고 항상 타일 사이즈를 곱해주자.
이 식을 통해 맵 바깥 마스킹 오브젝트를 스크립트로 배치하자.
| MapGenerator 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MapGenerator : MonoBehaviour
{
public Transform tilePrefab; // 인스턴스화 할 타일 프리팹
public Transform obstaclePrefab; // 인스턴스화 할 장애물 프리팹
public Transform navmeshFloor; // 내브메쉬를 위한 바닥 사이즈
public Transform navemeshMaskPrefab; // 맵 바깥쪽 마스킹 프리팹
public Vector2 mapSize; // 맵 크기
public Vector2 maxMapSize; // 최대 맵 크기
[Range(0, 1)] // 범위 지정
public float outlinePercent; // 테두리 두께
[Range(0, 1)] // 범위 지정
public float obstaclePercent; // 장애물 개수
public float tileSize; // 타일 사이즈
List<Coord> allTileCoords; // 모든 좌표값을 저장할 리스트 생성
Queue<Coord> shuffledTileCoords; // 셔플된 좌표값을 저장할 큐 생성
public int seed = 10; // 좌표값 랜덤 설정 시드
Coord mapCenter; // 맵의 중앙 좌표
void Start() {
GeneratorMap();
}
// 맵 생성 메소드
public void GeneratorMap() {
allTileCoords = new List<Coord>(); // 새 리스트 생성
for (int x = 0; x < mapSize.x; x++) {
for (int y = 0; y < mapSize.y; y++) { // 지정한 맵 크기만큼 루프
allTileCoords.Add(new Coord(x, y)); // 리스트에 타일 좌표 추가
}
}
shuffledTileCoords = new Queue<Coord>(Utility.ShuffleArray(allTileCoords.ToArray(), seed));
// 새 큐 생성, 셔플된 좌표값 배열을 저장함
mapCenter = new Coord((int)mapSize.x / 2, (int)mapSize.y / 2);
// 맵 중앙 좌표 지정
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 오브젝트의 부모 오브젝트로 설정.
int currentObstacleCount = 0; // 현재 올바른 장애물 생성 개수
for(int x = 0; x < mapSize.x; x++) {
for(int y = 0; y < 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;
// 타일의 부모 오브젝트 설정
}
}
bool[,] obstaclemap = new bool[(int)mapSize.x, (int)mapSize.y];
// 장애물 위치 확인 배열
int obstacleCount = (int)(mapSize.x * mapSize.y * obstaclePercent); // 지정한 비율에 따른 장애물 개수
for(int i = 0; i < obstacleCount; i++) { // 장애물 갯수만큼 루프
Coord randomCoord = GetRandomCoord(); // 랜덤한 좌표를 받아옴
obstaclemap[randomCoord.x, randomCoord.y] = true; // 해당 랜덤 위치 활성화
currentObstacleCount++; // 장애물 개수 증가
if(randomCoord != mapCenter && MaplsFullyAccessible(obstaclemap, currentObstacleCount)) {
// 랜덤 위치가 맵 중앙이 아니고 막힌 곳을 만들지 않을 때 아래 코드 실행
Vector3 obstaclePosition = CoordToPosition(randomCoord.x, randomCoord.y); // 좌표 변환
Transform newObstacle = Instantiate(obstaclePrefab, obstaclePosition + Vector3.up * 0.5f, Quaternion.identity);
// 장애물 인스턴스화 하여 생성
newObstacle.parent = mapHolder;
// 장애물의 부모 오브젝트 설정
newObstacle.localScale = Vector3.one * (1 - outlinePercent) * tileSize;
// 장애물 크기를 지정한 테두리 두께만큼 줄여서 타일 사이즈와 맞게 지정한다.
}
else { // 장애물 생성 조건이 맞지 않는 경우
obstaclemap[randomCoord.x, randomCoord.y] = false; // 해당 랜덤 위치 비활성화
currentObstacleCount--; // 장애물 개수 감소
}
}
Transform maskLeft = Instantiate(navemeshMaskPrefab, Vector3.left * (mapSize.x + maxMapSize.x) / 4 * tileSize, Quaternion.identity) as Transform;
// 왼쪽 맵 바깥 마스킹 오브젝트 생성
maskLeft.parent = mapHolder;
// 부모 오브젝트 설정
maskLeft.localScale = new Vector3((maxMapSize.x - mapSize.x) / 2 * tileSize, 1, mapSize.y * tileSize);
// 왼쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskRight = Instantiate(navemeshMaskPrefab, Vector3.right * (mapSize.x + maxMapSize.x) / 4 * tileSize, Quaternion.identity) as Transform;
// 오른쪽 맵 바깥 마스킹 오브젝트 생성
maskRight.parent = mapHolder;
// 부모 오브젝트 설정
maskRight.localScale = new Vector3((maxMapSize.x - mapSize.x) / 2 * tileSize, 1, mapSize.y * tileSize);
// 오른쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskTop = Instantiate(navemeshMaskPrefab, Vector3.forward * (mapSize.y + maxMapSize.y) / 4 * tileSize, Quaternion.identity) as Transform;
// 위쪽 맵 바깥 마스킹 오브젝트 생성
maskTop.parent = mapHolder;
// 부모 오브젝트 설정
maskTop.localScale = new Vector3(maxMapSize.x * tileSize, 1, (maxMapSize.y - mapSize.y) / 2 * tileSize);
// 위쪽 맵 바깥 마스킹 오브젝트 크기 설정
Transform maskBottom = Instantiate(navemeshMaskPrefab, Vector3.back * (mapSize.y + maxMapSize.y) / 4 * tileSize, Quaternion.identity) as Transform;
// 아래쪽 맵 바깥 마스킹 오브젝트 생성
maskBottom.parent = mapHolder;
// 부모 오브젝트 설정
maskBottom.localScale = new Vector3(maxMapSize.x * tileSize, 1, (maxMapSize.y - mapSize.y) / 2 * tileSize);
// 아래쪽 맵 바깥 마스킹 오브젝트 크기 설정
navmeshFloor.localScale = new Vector3(maxMapSize.x, maxMapSize.y) * tileSize;
// 지정한 최대 맵 사이즈에 맞게 내브메쉬 바닥 크기 조정
}
// 맵 확인 메소드
bool MaplsFullyAccessible(bool[,] obstacleMap, int currentObstacleCount) {
bool[,] mapFlag = new bool[obstacleMap.GetLength(0), obstacleMap.GetLength(1)];
// 지나온 비어있는 타일을 체크할 배열을 생성
Queue<Coord> queue = new Queue<Coord>(); // 큐 생성
queue.Enqueue(mapCenter); // 맵 중앙 위치를 큐에 넣음
mapFlag[mapCenter.x, 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)(mapSize.x * mapSize.y - currentObstacleCount);
// 현재 접근 가능해야 하는 타일 개수
return targetAccessibleTileCount == accessibleTileCount;
// 개수가 같다면(막힌 곳 없이 모든 타일에 접근 가능) true, 아니면 false 반환
}
Vector3 CoordToPosition(int x, int y) { // 좌표 변환 메소드
return new Vector3(-mapSize.x / 2 + 0.5f + x, 0, -mapSize.y / 2 + 0.5f + y) * tileSize;
// 입력받은 x, y 좌표로 Vector3 상의 타일 위치 설정
}
public Coord GetRandomCoord() { // 큐에 저장된 좌표를 가져오는 메소드
Coord randomCoord = shuffledTileCoords.Dequeue(); // 큐의 첫 번째 값을 가져온다.
shuffledTileCoords.Enqueue(randomCoord); // 가져온 값을 큐의 맨 뒤로 넣는다.
return randomCoord;
}
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();
}
}
}
한번 실행해보자.
조정하는 값에 따라 맵이 잘 생성된다.
다음 강의에서 이제 적들이 맵에서 움직일 수 있도록 해보자.
'Unity > 게임개발' 카테고리의 다른 글
[Unity] 게임 개발 일지 | 탑다운 슈팅 13 (2) | 2023.12.05 |
---|---|
[Unity] 게임 개발 일지 | 탑다운 슈팅 12 (0) | 2023.12.01 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 10 (1) | 2023.11.30 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 9 (0) | 2023.11.29 |
[Unity] 게임 개발 일지 | 탑다운 슈팅 8 (0) | 2023.11.29 |