안녕하세요!
저번 글에 이어서 개발하다가 새롭게 구현한 부분이 있어서 글을 적게 되었습니다. 맥락을 모르시면 어떻게 이 코드를 구현하게 되었는지 모를 수 도 있으니 전 글 링크를 공유드립니다.
저번 글 : https://88-it.tistory.com/438
저번에 이어서 쫓아오는 적 로직 수정, 무작위 아이템 생성 기능, 다시하기 기능, UI, 데이터 연결까지 추가가 되었습니다. 최종적으로 아래 영상까지 만들고 난 후 주변에 해당 영상을 보여주고 피드백을 받았습니다.
제가 만든 게임 목적상 플레이어가 많이 플레이해야 하는데 맵이 고정이면 쉽게 익숙해져서 난이도나 재미 정도가 떨어지지 않을까 하는 피드백이 있었습니다. 그래서 맵을 여러 개 만들어서 돌아가면서 사용하거나 아예 매번 랜덤으로 생성되게 만드는 방식으로 보완할 수 있었는데 후자는 아무리 생각해도 복잡할 거 같아서 내심 피드백 받기 전에 생각했다가 포기했었는데 다시 생각해보는 계기가 되었습니다.
결국 도전해보기로 하였다는…. 그래서 이런 복잡한 일에서 중요한 건 세부적으로 나누는 것인 것 같습니다. 제가 결과적으로 만들어지길 원하는 맵은 기존에 만든 거와 비슷한 맵인데요
위 맵의 공통점을 살펴보면,
- 다양한 종류의 타일 배치 + 각기 다른 높낮이
- 장애물 랜덤하게 배치하는데 장애물의 사이즈가 다를 수 있음
일단, 크게는 이 두 가지인 것 같습니다. 그래서 먼저,
다양한 종류의 타일 배치와 각기 다른 높낮이 맵을 만들어주는 것을 먼저 구현하였고
그 다음으로, 랜덤으로 위치를 골라서 장애물을 배치할 건데 사이즈를 고려하도록 구현하였습니다.
먼저, BlockManager 코드입니다.
// BlockManager: 맵의 블록을 관리
class BlockManager {
constructor(blocks, startX, startY, sizeX, sizeY, xInterval, zInterval, randomY) {
this.blocks = blocks;
this.startX = startX;
this.startY = startY;
this.sizeX = sizeX;
this.sizeY = sizeY;
this.xInterval = xInterval;
this.zInterval = zInterval;
this.randomY = randomY;
}
// 맵 생성
createMap() {
this.blocks.forEach(block => block.visible = true);
const totalBlocks = this.sizeX * this.sizeY;
const originBlockLength = this.blocks.length;
if (this.blocks.length < totalBlocks) {
while (this.blocks.length < totalBlocks){
const randomBlockIndex = Math.floor(Math.random() * originBlockLength);
const cloneBlock = this.blocks[randomBlockIndex].clone();
WORLD.add(cloneBlock);
this.blocks.push(cloneBlock);
}
}else{
for (let i = totalBlocks; i < this.blocks.length; i++) {
this.blocks[i].visible = false;
this.blocks[i].hasWall = true;
}
this.blocks.splice(totalBlocks);
}
this.blocks.forEach((block, index) => {
const x = this.startX + (index % this.sizeX) * this.xInterval;
const z = this.startY + Math.floor(index / this.sizeX) * this.zInterval;
const y = Math.random() * this.randomY;
block.position.set(x, y, z);
block.hasWall = false;
});
}
// 블록 목록 가져오기
getBlocks() {
return this.blocks;
}
}
여기서 고려했던 점은 제 게임은 다시하기 기능이 있고 이 버튼을 누르면 맵도 또 새롭게 바꿔야합니다. 이때, 모든 오브젝트를 삭제하고 새로 생성할 수도 있지만 그럴 경우, 메모리 낭비가 있어 최적화를 위해 오브젝트 풀링 방식을 적용했습니다. 그래서 만약 특정 사이즈 맵이 들어왔을 때, 현재 블록보다 사이즈가 크면 그만큼 블록을 더 생성해서 배열에 추가해주었고 반대라면 안 보이게 설정해주었습니다.
// AutoMapMaker: 맵과 장애물을 설정
class AutoMapMaker {
constructor(blocks, walls, wallSizes, wallYvalues, wallList, startX, startY, sizeX, sizeY) {
this.blockManager = new BlockManager(blocks, startX, startY, sizeX, sizeY, 5, 5, 1);
this.wallManager = new WallManager(sizeX, sizeY,walls, wallSizes, wallYvalues, wallList);
this.create();
}
// 맵과 벽을 생성
create() {
this.blockManager.createMap();
this.wallManager.placeWalls(this.blockManager.getBlocks());
}
// 새로운 크기로 맵을 생성
create2(sizeX, sizeY) {
this.blockManager.sizeX = sizeX;
this.blockManager.sizeY = sizeY;
this.blockManager.createMap();
this.wallManager.placeWalls(this.blockManager.getBlocks(), sizeX, sizeY);
}
}
const blocks = []; // 맵 블록이 들어갈 배열
GLOBAL.blocks = blocks;
for (let i = 1; i <= 6; i++) {
blocks.push(WORLD.getObject("block" + i)); // 타일 종류 6개
}
const walls = [];
for (let i = 1; i <= 3; i++) {
walls.push(WORLD.getObject("wall" + i)); // 장애물 오브젝트 3개
}
const wallSizes = [1, 4, 1]; // 사이즈
const wallYvalues = [2, 2.5, 2.8]; // 배치 높이
const wallList = []; // 장애물 블록이 들어갈 배열
let autoMapMaker = new AutoMapMaker(blocks, walls, wallSizes, wallYvalues, wallList, 0, 0, 7, 5);
function OnKeyDown(event) {
switch (event.code) {
case 'KeyE':
autoMapMaker.create();
break;
case 'KeyQ':
const randomX = 7 + Math.floor(Math.random() * 3);
const randomY = 5 + Math.floor(Math.random() * 2);
autoMapMaker.create2(randomX, randomY);
break;
}
}
그리고 결과적으로 맵 만드는 거에 맵 구현 → 장애물 구현 이렇게 나눠져 있기에 AutoMapMaker 라는 큰 클래스 안에서 blockManager, wallManager 를 나누어 구현해 SOLID 원칙에 단일 책임 원칙에 위배하지 않게 해주었습니다. 또한, 의존 역전 원칙에 따라서도 AutoMapMaker 클래스는 이제 blockManager와 wallManager라는 구체적인 클래스에 의존하기보다는 각 관리자가 맡고 있는 기능을 위임합니다. 이로 인해 맵 생성 로직을 확장할 때 AutoMapMaker의 코드를 수정할 필요 없이 각 관리자의 클래스를 수정하는 것으로 충분하게 된 것을 확인할 수 있습니다.
그 다음은, 장애물의 사이즈가 다른 것을 고려해서 장애물 랜덤하게 배치하는 기능입니다.
이 부분은 좀 더 쉽게 구현하기 위해 장애물 사이즈는 정사각형이라는 과정 하에 제작해주었습니다. 랜덤한 위치를 뽑고 해당 위치가 이미 장애물이 배치된 위치가 아니라면 정사각형 사이즈만큼 자리가 있는지 확인 후 배치해주는 방식으로 구현했습니다. 하지만 결과는….?
보시다싶이 전혀 마음에 들지 않는 맵이 나오게 되었습니다. 일단 장애물이 붙어서 갈 수 없는 길이 생기고 크기가 1인 오브젝트는 모르겠지만 그 이상 사이즈의 장애물이 가장자리에 붙을 경우 쓸모 없는 길이 탄생해버리고 말았습니다. 그래서 저는 이를 해결하기 위해 추가적으로 아래와 같은 조치를 취해주었습니다.
- 길이 막히는 문제를 방지하기 위해 2칸 이상의 장애물은 가장자리에 배치하지 않게 예외 처리
- 다른 장애물과 붙어 생성되지 않게 처리
1번은 적힌 대로 예외처리 해주었고 2번째는 장애물 블록을 지정할 때, 주변 8블록을 검사해서 이미 장애물이 배치되어 있다면 피하도록 설정해주었습니다.
결과적으로 탄생한 코드는 아래와 같습니다.
// WallManager: 벽 배치 및 관리
class WallManager {
constructor(sizeX, sizeY, walls, wallSizes, wallYvalues, wallList) {
this.sizeX = sizeX;
this.sizeY = sizeY;
this.walls = walls;
this.wallSizes = wallSizes;
this.wallYvalues = wallYvalues;
this.wallList = wallList;
this.useWallBlockNum = 0;
}
// 벽 배치
placeWalls(blocks, sizeX = this.sizeX, sizeY = this.sizeY) {
this.useWallBlockNum = 0;
this.sizeX = sizeX;
this.sizeY = sizeY;
this.walls.forEach((wall, index) => {
const wallSize = this.wallSizes[index];
const wallPosition = this.findValidWallPosition(blocks, wallSize);
if (wallPosition) {
wall.position.set(wallPosition.x, this.wallYvalues[index], wallPosition.z);
}
});
}
// 벽 배치 가능한 위치 찾기
findValidWallPosition(blocks, wallSize) {
const availableBlocks = this.getRandomBlocks(blocks, wallSize);
if (!availableBlocks) return null;
availableBlocks.forEach(block => block.hasWall = true);
const centerPos = this.getBlockGroupCenter(availableBlocks);
this.markBlocksAsWall(availableBlocks)
return centerPos;
}
// 블록 그룹의 중앙 계산
getBlockGroupCenter(blockGroup) {
let sumX = 0, sumZ = 0;
blockGroup.forEach(block => {
sumX += block.position.x;
sumZ += block.position.z;
});
const centerX = sumX / blockGroup.length;
const centerZ = sumZ / blockGroup.length;
return { x: centerX, z: centerZ };
}
// 벽에 해당하는 블록들을 표시
markBlocksAsWall(blockGroup) {
blockGroup.forEach(block => {
block.visible = false;
if(this.wallList.length <= this.useWallBlockNum){
const wallBlock = WORLD.getObject("wallBlock");
const cloneWallBlock = wallBlock.clone();
WORLD.add(cloneWallBlock);
this.wallList.push(cloneWallBlock);
console.log(this.useWallBlockNum);
}
this.wallList[this.useWallBlockNum].position.set(block.position.x, 0, block.position.z);
this.useWallBlockNum += 1;
});
}
// 랜덤 블록 추출 (벽 크기만큼)
getRandomBlocks(blocks, wallSize) {
let availableBlocks = [];
let attempts = 0;
const maxAttempts = 100; // 최대 시도 횟수 (무한 루프 방지)
let width = wallSize;
let height = wallSize;
if( wallSize % 2 === 0 ){
width = wallSize/2;
height = wallSize/2;
}
// 최대 시도 횟수 동안 가능한 블록을 찾기
while (attempts < maxAttempts) {
const randomBlockIndex = Math.floor(Math.random() * blocks.length);
const block = blocks[randomBlockIndex];
const startX = randomBlockIndex % this.sizeX;
const startY = Math.floor(randomBlockIndex / this.sizeX);
availableBlocks = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const newX = startX + x;
const newY = startY + y;
// 범위 밖이면 skip
if(wallSize == 1){
if (newX >= this.sizeX || newY >= this.sizeY) {
availableBlocks = [];
break;
}
}else{
if (newX >= (this.sizeX-1) || newY >= (this.sizeY-1) || newX == 0 || newY == 0) {
availableBlocks = [];
break;
}
}
const blockIndex = newY * this.sizeX + newX;
const block = blocks[blockIndex];
if (block.hasWall) {
availableBlocks = [];
break;
}
const directions = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
let isValid = true;
for (const [dx, dy] of directions) {
const neighborX = newX + dx;
const neighborY = newY + dy;
// 맵 범위 밖인 경우는 넘어감
if (neighborX < 0 || neighborX >= this.sizeX || neighborY < 0 || neighborY >= this.sizeY) {
continue;
}
const neighborIndex = neighborY * this.sizeX + neighborX;
const neighborBlock = blocks[neighborIndex];
if (neighborBlock.hasWall) {
isValid = false;
break;
}
}
if (!isValid) {
availableBlocks = [];
break; // 주변 블록에 장애물이 있으면 다시 시도
}
availableBlocks.push(block);
}
// 세로 방향 블록을 하나라도 찾지 못하면 중지
if (availableBlocks.length === 0) break;
}
// 충분히 큰 직사각형 블록을 찾았으면 반환
if (availableBlocks.length === wallSize) {
return availableBlocks;
}
attempts++; // 시도 횟수 증가
}
// 주어진 크기에 맞는 블록이 없으면 null 반환
return null;
}
}
대입 값은 위위 코드에서 참고해주시면 됩니다. 이 경우, 배치할 장애물 오브젝트, 사이즈, 적절한 높이값 등은 미리 지정해주었습니다. 위에 처럼 적용한 결과,
휴 아까보다는 훨씬 나은 결과가 나왔습니다. 머리 싸매면서 코드 짠 게 의미가 있어서 다행입니다. 사진으로만 보여주기 섭섭하는 바로바로 잘 바뀌는 영상을 gif로도 보여드립니다.
추가적으로 전반적으로 고려한 부분에 대해 말씀드리자면,
개방-폐쇄 원칙 (OCP) 고려한 부분 : BlockManager와 WallManager 클래스는 서로 독립적이며, 새로운 맵이나 벽 배치 방식을 추가할 때 기존 코드를 수정하지 않고 기능을 확장할 수 있게 만들었습니다.
인터페이스 분리 원칙 (ISP) 고려한 부분 : BlockManager와 WallManager는 각자 독립된 책임을 가지고 있으며, 각 클래스가 제공하는 메서드는 그 클래스의 목적에 맞는 기능만을 제공하게 만들었습니다. 이로 인해 불필요한 의존성을 가지지 않게 했습니다.
다만, SOLID 법칙 중 리스코프 치환 원칙 (LSP) 고려한 부분은 크게 두드러지지 않는데 ,이 경우 나중에 WallManager 클래스가 SimpleWallManager와 AdvancedWallManager라는 두 가지 서브클래스를 가질 수 있다고 가정해 보고 이 두 서브클래스는 각각 다른 방식으로 벽을 배치하는 방식이라 했을 때, 아래와 같이 사용해서 수정없이 방식을 바꿀 수 있습니다. 또한 이 경우, 부모랑 자식이 동일한 역할을 해주게 되니 위 법칙에 준수하다고 이야기할 수 있습니다.
// SimpleWallManager로 생성
let autoMapMakerSimple = new AutoMapMaker(blocks, walls, wallSizes, wallYvalues, wallList, 0, 0, 7, 5, SimpleWallManager);
// AdvancedWallManager로 생성
let autoMapMakerAdvanced = new AutoMapMaker(blocks, walls, wallSizes, wallYvalues, wallList, 0, 0, 7, 5, AdvancedWallManager);
혹시 몰라 풀 코드도 올려드립니다.
위의 코드를 다 합친 전체 코드
// BlockManager: 맵의 블록을 관리
class BlockManager {
constructor(blocks, startX, startY, sizeX, sizeY, xInterval, zInterval, randomY) {
this.blocks = blocks;
this.startX = startX;
this.startY = startY;
this.sizeX = sizeX;
this.sizeY = sizeY;
this.xInterval = xInterval;
this.zInterval = zInterval;
this.randomY = randomY;
}
// 맵 생성
createMap() {
this.blocks.forEach(block => block.visible = true);
const totalBlocks = this.sizeX * this.sizeY;
const originBlockLength = this.blocks.length;
if (this.blocks.length < totalBlocks) {
while (this.blocks.length < totalBlocks){
const randomBlockIndex = Math.floor(Math.random() * originBlockLength);
const cloneBlock = this.blocks[randomBlockIndex].clone();
WORLD.add(cloneBlock);
this.blocks.push(cloneBlock);
}
}else{
for (let i = totalBlocks; i < this.blocks.length; i++) {
this.blocks[i].visible = false;
this.blocks[i].hasWall = true;
}
this.blocks.splice(totalBlocks);
}
this.blocks.forEach((block, index) => {
const x = this.startX + (index % this.sizeX) * this.xInterval;
const z = this.startY + Math.floor(index / this.sizeX) * this.zInterval;
const y = Math.random() * this.randomY;
block.position.set(x, y, z);
block.hasWall = false;
});
}
// 블록 목록 가져오기
getBlocks() {
return this.blocks;
}
}
// WallManager: 벽 배치 및 관리
class WallManager {
constructor(sizeX, sizeY, walls, wallSizes, wallYvalues, wallList) {
this.sizeX = sizeX;
this.sizeY = sizeY;
this.walls = walls;
this.wallSizes = wallSizes;
this.wallYvalues = wallYvalues;
this.wallList = wallList;
this.useWallBlockNum = 0;
}
// 벽 배치
placeWalls(blocks, sizeX = this.sizeX, sizeY = this.sizeY) {
this.useWallBlockNum = 0;
this.sizeX = sizeX;
this.sizeY = sizeY;
this.walls.forEach((wall, index) => {
const wallSize = this.wallSizes[index];
const wallPosition = this.findValidWallPosition(blocks, wallSize);
if (wallPosition) {
wall.position.set(wallPosition.x, this.wallYvalues[index], wallPosition.z);
}
});
}
// 벽 배치 가능한 위치 찾기
findValidWallPosition(blocks, wallSize) {
const availableBlocks = this.getRandomBlocks(blocks, wallSize);
if (!availableBlocks) return null;
availableBlocks.forEach(block => block.hasWall = true);
const centerPos = this.getBlockGroupCenter(availableBlocks);
this.markBlocksAsWall(availableBlocks)
return centerPos;
}
// 블록 그룹의 중앙 계산
getBlockGroupCenter(blockGroup) {
let sumX = 0, sumZ = 0;
blockGroup.forEach(block => {
sumX += block.position.x;
sumZ += block.position.z;
});
const centerX = sumX / blockGroup.length;
const centerZ = sumZ / blockGroup.length;
return { x: centerX, z: centerZ };
}
// 벽에 해당하는 블록들을 표시
markBlocksAsWall(blockGroup) {
blockGroup.forEach(block => {
block.visible = false;
if(this.wallList.length <= this.useWallBlockNum){
const wallBlock = WORLD.getObject("wallBlock");
const cloneWallBlock = wallBlock.clone();
WORLD.add(cloneWallBlock);
this.wallList.push(cloneWallBlock);
console.log(this.useWallBlockNum);
}
this.wallList[this.useWallBlockNum].position.set(block.position.x, 0, block.position.z);
this.useWallBlockNum += 1;
});
}
// 랜덤 블록 추출 (벽 크기만큼)
getRandomBlocks(blocks, wallSize) {
let availableBlocks = [];
let attempts = 0;
const maxAttempts = 100; // 최대 시도 횟수 (무한 루프 방지)
let width = wallSize;
let height = wallSize;
if( wallSize % 2 === 0 ){
width = wallSize/2;
height = wallSize/2;
}
// 최대 시도 횟수 동안 가능한 블록을 찾기
while (attempts < maxAttempts) {
const randomBlockIndex = Math.floor(Math.random() * blocks.length);
const block = blocks[randomBlockIndex];
const startX = randomBlockIndex % this.sizeX;
const startY = Math.floor(randomBlockIndex / this.sizeX);
availableBlocks = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const newX = startX + x;
const newY = startY + y;
// 범위 밖이면 skip
if(wallSize == 1){
if (newX >= this.sizeX || newY >= this.sizeY) {
availableBlocks = [];
break;
}
}else{
if (newX >= (this.sizeX-1) || newY >= (this.sizeY-1) || newX == 0 || newY == 0) {
availableBlocks = [];
break;
}
}
const blockIndex = newY * this.sizeX + newX;
const block = blocks[blockIndex];
if (block.hasWall) {
availableBlocks = [];
break;
}
const directions = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
let isValid = true;
for (const [dx, dy] of directions) {
const neighborX = newX + dx;
const neighborY = newY + dy;
// 맵 범위 밖인 경우는 넘어감
if (neighborX < 0 || neighborX >= this.sizeX || neighborY < 0 || neighborY >= this.sizeY) {
continue;
}
const neighborIndex = neighborY * this.sizeX + neighborX;
const neighborBlock = blocks[neighborIndex];
if (neighborBlock.hasWall) {
isValid = false;
break;
}
}
if (!isValid) {
availableBlocks = [];
break; // 주변 블록에 장애물이 있으면 다시 시도
}
availableBlocks.push(block);
}
// 세로 방향 블록을 하나라도 찾지 못하면 중지
if (availableBlocks.length === 0) break;
}
// 충분히 큰 직사각형 블록을 찾았으면 반환
if (availableBlocks.length === wallSize) {
return availableBlocks;
}
attempts++; // 시도 횟수 증가
}
// 주어진 크기에 맞는 블록이 없으면 null 반환
return null;
}
}
// AutoMapMaker: 맵과 장애물을 설정
class AutoMapMaker {
constructor(blocks, walls, wallSizes, wallYvalues, wallList, startX, startY, sizeX, sizeY) {
this.blockManager = new BlockManager(blocks, startX, startY, sizeX, sizeY, 5, 5, 1);
this.wallManager = new WallManager(sizeX, sizeY,walls, wallSizes, wallYvalues, wallList);
this.create();
}
// 맵과 벽을 생성
create() {
this.blockManager.createMap();
this.wallManager.placeWalls(this.blockManager.getBlocks());
}
// 새로운 크기로 맵을 생성
create2(sizeX, sizeY) {
this.blockManager.sizeX = sizeX;
this.blockManager.sizeY = sizeY;
this.blockManager.createMap();
this.wallManager.placeWalls(this.blockManager.getBlocks(), sizeX, sizeY);
}
}
const blocks = []; // 맵 블록이 들어갈 배열
GLOBAL.blocks = blocks;
for (let i = 1; i <= 6; i++) {
blocks.push(WORLD.getObject("block" + i)); // 타일 종류 6개
}
const walls = [];
for (let i = 1; i <= 3; i++) {
walls.push(WORLD.getObject("wall" + i)); // 장애물 오브젝트 3개
}
const wallSizes = [1, 4, 1]; // 사이즈
const wallYvalues = [2, 2.5, 2.8]; // 배치 높이
const wallList = []; // 장애물 블록이 들어갈 배열
let autoMapMaker = new AutoMapMaker(blocks, walls, wallSizes, wallYvalues, wallList, 0, 0, 7, 5);
function OnKeyDown(event) {
switch (event.code) {
case 'KeyE':
autoMapMaker.create();
break;
case 'KeyQ':
const randomX = 7 + Math.floor(Math.random() * 3);
const randomY = 5 + Math.floor(Math.random() * 2);
autoMapMaker.create2(randomX, randomY);
break;
}
}
모든 건 거의 복제를 통해 이루어졌기 때문에 실제 월드 상에 존재하는 오브젝트는 아래와 같이 각 복제할 오브젝트들의 견본과 장애물 밖에 없습니다.
이렇게 설정 후에 게임을 실행시키면,
각 누른 스테이지 별로 잘 생성된 것을 확인할 수 있습니다. 영상으로 보기에는 빠를 수도 있어서 나온 결과들을 캡처해서 모아보면 아래처럼 나오게 됩니다. 단순 값만 넣어주면 자동으로 바뀔 수 있게 세팅하였기에 특정 스테이지의 맵 크기 등 바꾸기 용이해서 좋은 것 같습니다.
사실상 복잡한 알고리즘을 사용한 건 아니라 대단하지 않을 수 있지만 그래도 일일이 (제가) 노가다 안해도 되고 플레이하는 사람들에게도 익숙해지지 않고 새로운 느낌을 줄 수 있다는 점이 보람찬 것 같습니다.
안그래도 이번 부분을 구현하면서 객체지향적인 코드를 짜려고 많이 고민했습니다. 더불어 저번 글에 올린 코드들도 다 리팩토링 했는데요. 다음 글을 적을 때 기회가 된다면 코드를 올리며 어떤 식으로 바꾸게 되었는지 작성해보겠습니다.
여기까지 긴 글을 읽어주셔서 감사합니다.
'Project' 카테고리의 다른 글
[게임 개발] 게임 중 발생하는 이벤트 추가하기 (0) | 2024.12.27 |
---|---|
[게임 개발] 코드 리팩토링 + 데이터 저장 / 로드 (0) | 2024.12.15 |
[게임 개발] 사각 타일 맵 기반 게임 기초 만들기 (0) | 2024.12.01 |
[해커톤] Junction Asia 2024 참여 후기 (0) | 2024.08.19 |
[공모전/게임잼] 대한민국 No.1 서버 게임 개발 공모전 / 제 2회 포톤 게임잼 - Photon Game Jam 예선 참여 후기 및 Photon 이용 후기 (0) | 2024.08.06 |
댓글