안녕하세요!
오랜만에 글을 작성하게 되었는데 이번에는 타일 맵 기반 게임 만든 방법에 대해 작성 해볼까 합니다. 먼저, 이 게임을 만들게 된 계기는 퍼즐 게임을 만들고 싶었는데 완전 퍼즐은 아닌 긴박한 긴장감을 주는 게임을 만들고 싶었습니다. 그러다가 생각난 전에 플레이 했던 “Castle Crashers” 라는 게임 안에 미니 게임 형식으로 아래와 같은 게임이 있었습니다.
게임 방법은 간단합니다. 멀티 게임이긴 했지만 혼자 플레이하는 요소가 크고 사각 타일 맵에서 상하좌우 이동할 수 있는데 우리가 생각하는 이동키가 아닌 겁니다. 위에 사진 보시다 싶이 분홍색 플레이어 기준으로 오른쪽으로 갈려면 방향키 아래를 눌려야하는 거죠. 이런 식으로 순발력과 사고력이 필요한 게임이라고 볼 수 있습니다. 어떻게 보면 퍼즐 게임보다는 한 발 걸친 게임이라고 생각할 수 있습니다.
이걸 만들기 위해서 몇 가지 중요하게 개발해야하는 요소들이 있는데,
- 자동으로 맵 세팅 알고리즘 → 해당 맵은 타일 기반의 맵으로 상하좌우가 연결되어 있는데 중간에 연결되지 않는 장애물 타일도 존재합니다. 이런 부분들을 고려해서 환경을 구상 했을 때, 연결시켜 주는 코드가 필요합니다. 아니면 엄청난 노가다를 해야겠죠?
- 플레이어 움직임과 무작위 키 설정 → 이 게임의 재미 요소인 플레이어가 실제 방향키에 따라 움직이지 않는다는 설정은 가져가야 하기 때문에 어떻게 무작위로 설정하고 UI 를 표시해주는 게 중요합니다.
- 적 움직임 → 너무 멍청하거나 일관되면 안되기 때문에 그런 부분을 고려해서 코드를 짜줄 예정입니다.
참고로, 전 레드브릭 스튜디오라는 개발 사이트를 이용하여 해당 게임을 제작하고 있습니다.
그래서 코드가 자바스크립트 기반이라는 점 알려드립니다.
기본적인 게임 세팅
먼저, 아래와 같이 맵을 세팅 해주었습니다. 방향키도 대충 미리 캔버스로 이미지를 만들어주었으며 현재 플레이어 오브젝트가 따로 없어 흰색 유령을 플레이어라고 가정하고 제작하였습니다. (빨간색 유령은 적입니다)
자동 맵 세팅 알고리즘
저 같은 경우, 거리 비교로 1차 가까운 블록들, 상하좌우 블록들만 선별 되었다는 과정 하에 x 값과 z 값의 비교를 통해 좌우상하 중 어느 쪽인지 비교 후 데이터를 저장해주었습니다. 이 방법으로 하면 이름이나, 순서에 상관없고 맵을 수정해도 알아서 세팅되니 좋은 것 같습니다. (실제 현업에서도 어떻게 구상하는지 궁금하네요 3 match 퍼즐이라던가 , 위 같은 맵 게임 경우에 아시는 분은 댓글로 정보 공유해주시면 정말 감사하겠습니다. )
class Block{
constructor(id, rightB, leftB, forwardB, backB) {
this.id = id;
this.rightB = rightB;
this.forwardB = forwardB;
this.backB = backB;
this.leftB = leftB;
}
}
GLOBAL.Block = Block;
const blocks_1 = [];
const blockMap_1 = [];
function Start() {
// 이름을 통해 맵1 블록에 대한 모든 오브젝트를 찾아주기
// 이때, 건너 갈 수 없는 블록인 장애물 블록은 포함시키지 않았습니다.
for(let i=1; i<=26; i++){
blocks_1.push(WORLD.getObject("1block" + i));
}
SettingBlock(blocks_1, blockMap_1);
}
function SettingBlock(blocks, blockMap){
// 한 블록씩 돌면서
blocks.forEach((block, index1) => {
let rightB = null;
let forwardB = null;
let backB = null;
let leftB = null;
let findNum = 0;
// 모든 블록을 검사해
blocks.forEach((otherblock, index2) => {
// 자기 자신 블록만 빼고
if(index1 !== index2){
let distance = block.position.distanceTo(otherblock.position);
let diffX = block.position.x - otherblock.position.x;
let diffZ = block.position.z - otherblock.position.z;
// 일정 거리 이하에 있고 (가까이 있고)
if(distance < 7){
// x 이 거의 똑같다면
if(MathUtils.abs(diffX) < 2){
// z 축 차이로 좌우 블록 판별
if(diffZ < 0 ){
leftB = index2+1;
findNum += 1;
}else{
rightB = index2+1;
findNum += 1;
}
}
// z 가 거의 똑같다면
if(MathUtils.abs(diffZ) < 2){
// x 축 차이로 위아래 블록 판별
if(diffX < 0){
backB = index2+1;
findNum += 1;
}else{
forwardB = index2+1;
findNum += 1;
}
}
}
}
});
// 상하좌우 블록을 찾아서 값을 저장해주기
// 이때 블록을 못 찾았다면(가에 블록이거나, 장애물 블록 때문에), null 값이 저장
const blockInfo = new GLOBAL.Block(index1+1, rightB, leftB, forwardB, backB);
blockMap.push(blockInfo);
})
}
다만, 거리 값은 7, 2 이런 부분들은 제 게임 상 블록을 기준으로 맞춘 거라 위 코드를 쓰신다면 그런 부분들은 바꿔주셔야 합니다.
플레이어 움직임과 무작위 키 설정
무작위한 움직임으로 바꾸기 위해서 currentRandomMoveList 리스트를 하나 만들어서 움직일 때마다 매번 무작위로 바꿔가면서 움직였습니다. 아래 코드를 보시면, 방향키에 따라 리스트 몇 번째 자리를 가져올 건지는 고정이지만 안에 내용물이 바뀌고 해당 내용물에 따라 움직이도록 설정해 놓았습니다.
const leftImg = WORLD.getObject("leftImg");
const upImg = WORLD.getObject("upImg");
const rightImg = WORLD.getObject("rightImg");
const downImg = WORLD.getObject("downImg");
const currentRandomMoveList = [3, 2, 1, 0];
let playerTime = 0;
let currentPlayerPos = 4;
let isGameStart = false;
let isPlayerMoving = false;
let playerSpeed = 1;
// 키보드 입력 받는 함수
function OnKeyDown(event) {
switch (event.code) {
case "ArrowUp":
CheckMoveBlock(0);
break;
case 'ArrowDown':
CheckMoveBlock(1);
break;
case 'ArrowLeft':
CheckMoveBlock(2);
break;
case 'ArrowRight':
CheckMoveBlock(3);
break;
}
}
// 입력 받은 키보드 값을 무작위 움직임 배열에 대입해서 움직이게 하는 함수
function CheckMoveBlock(keyNum){
switch(currentRandomMoveList[keyNum]){
case 0: // ArrowUp
console.log(currentBlockMap[currentPlayerPos].rightB)
if(currentBlockMap[currentPlayerPos].rightB){
MoveBlock(currentBlocks[currentPlayerPos], currentBlocks[currentBlockMap[currentPlayerPos].rightB-1], currentBlockMap[currentPlayerPos].rightB-1);
}
break;
case 1: // ArrowDown
console.log(currentBlockMap[currentPlayerPos].leftB)
if(currentBlockMap[currentPlayerPos].leftB){
MoveBlock(currentBlocks[currentPlayerPos], currentBlocks[currentBlockMap[currentPlayerPos].leftB-1], currentBlockMap[currentPlayerPos].leftB-1);
}
break;
case 2: // ArrowLeft
console.log(currentBlockMap[currentPlayerPos].forwardB)
if(currentBlockMap[currentPlayerPos].forwardB){
MoveBlock(currentBlocks[currentPlayerPos], currentBlocks[currentBlockMap[currentPlayerPos].forwardB-1], currentBlockMap[currentPlayerPos].forwardB-1);
}
break;
case 3: // ArrowRight
console.log(currentBlockMap[currentPlayerPos].backB)
if(currentBlockMap[currentPlayerPos].backB){
MoveBlock(currentBlocks[currentPlayerPos], currentBlocks[currentBlockMap[currentPlayerPos].backB-1], currentBlockMap[currentPlayerPos].backB-1);
}
break;
}
}
// 베열 섞어주는 함수
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // 0부터 i까지의 랜덤 인덱스
// i번째 값과 j번째 값을 서로 교환
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
// startB 블록에서 endB 블록으로 이동하고 다음 블록 위치를 현재 블록 위치로 업데이트
// 이때, 플레이어가 포물선으로 움직이게 설정
function MoveBlock(StartB, endB, nextPos){
if(isGameStart == false) return;
if(isPlayerMoving) return;
isPlayerMoving = true;
playerTime = 0;
const tween = new TWEEN.Tween({ x: StartB.position.x, y: StartB.position.y + 2, z: StartB.position.z })
.to({ x: endB.position.x, y: endB.position.y + 2, z: endB.position.z }, 1500/playerSpeed)
.onUpdate(({ x, y, z }) => {
const progress = (endB.position.x - player.position.x) / 3;
const currentHeight = Math.sin(playerTime * 2 * playerSpeed) * 5;
player.position.set(x, y + currentHeight, z);
})
.onComplete(() => { // 이동이 끝났다면
isPlayerMoving = false;
currentPlayerPos = nextPos; // 현재 블록 위치 업데이트
shuffleArray(currentRandomMoveList); // 배열 섞어주기
console.log(currentRandomMoveList);
CheckDirectionImg(currentBlocks, currentBlockMap); // 방향키 이미지 세팅
})
.start();
}
function Update(dt){
if(isGameStart){
playerTime += dt;
}
}
아래 코드는 위에 바뀌는 움직임 설정에 따라 방향키 이미지를 맞게 세팅 해주는 코드입니다.
이 부분 설정해주는 과정에서 전 좀 헷갈렸는데 아래와 같은 방법을 통해 성공적으로 세팅되었습니다.
function CheckDirectionImg(blocks, blockMap){
// 현재 블록 기존으로 해당 블록이 있을 경우에만
if(blockMap[currentPlayerPos].rightB){
// 이미지 세팅
let obj = blocks[blockMap[currentPlayerPos].rightB-1];
ShowDirectionImg(currentRandomMoveList.indexOf(0), obj, true);
}else{
// 이미지 안 보이게 세팅
ShowDirectionImg(currentRandomMoveList.indexOf(0), null, false);
}
if(blockMap[currentPlayerPos].leftB){
let obj = blocks[blockMap[currentPlayerPos].leftB-1];
ShowDirectionImg(currentRandomMoveList.indexOf(1), obj, true);
}else{
ShowDirectionImg(currentRandomMoveList.indexOf(1), null, false);
}
if(blockMap[currentPlayerPos].forwardB){
let obj = blocks[blockMap[currentPlayerPos].forwardB-1];
ShowDirectionImg(currentRandomMoveList.indexOf(2), obj, true);
}else{
ShowDirectionImg(currentRandomMoveList.indexOf(2), null, false);
}
if(blockMap[currentPlayerPos].backB){
let obj = blocks[blockMap[currentPlayerPos].backB-1];
ShowDirectionImg(currentRandomMoveList.indexOf(3), obj, true);
}else{
ShowDirectionImg(currentRandomMoveList.indexOf(3), null, false);
}
}
function ShowDirectionImg(num, obj, isActive){
switch(num){
case 0:
if(isActive){
upImg.visible = true;
upImg.position.set(obj.position.x, obj.position.y + 0.6, obj.position.z);
}else{
upImg.visible = false;
}
break;
case 1:
if(isActive){
downImg.visible = true;
downImg.position.set(obj.position.x, obj.position.y + 0.6, obj.position.z);
}else{
downImg.visible = false;
}
break;
case 2:
if(isActive){
leftImg.visible = true;
leftImg.position.set(obj.position.x, obj.position.y + 0.6, obj.position.z);
}else{
leftImg.visible = false;
}
break;
case 3:
if(isActive){
rightImg.visible = true;
rightImg.position.set(obj.position.x, obj.position.y + 0.6, obj.position.z);
}else{
rightImg.visible = false;
}
break;
}
}
결과적으로 이야기하고 싶은 건 currentRandomMoveList 리스트 같은 랜덤 매개? 중간 체인지 역할을 맞아주는 변수를 두고 로직을 짜시면 편한 것 같습니다.
적 움직임 구현
일단, 전 몬스터 세팅을 용이하게 하기 위해 3가지로 나누어서 코드를 짰습니다. 전반적인 관리하는 Game Manager 내 코드, 몬스터를 관리하는 EnemyManager , 몬스터 객체 클래스 Enemy 로 나누었습니다. 아래 코드는 Game Manager 로 EnemyManager 에게 위에서 만든 현재 맵 데이터와 플레이어 데이터를 넘겨주고 맵 세팅에 따라 몇 마리 몬스터를 추가할 지 세팅해줍니다.
// Game Manager
let EnemyManager = null;
// 처음 맵에 따른 몬스터 세팅
function init(mapNumber){
EnemyManager = new GLOBAL.EnemyManager(currentBlocks, currentBlockMap, player);
switch(mapNumber){
case 1: // 1 마리
EnemyManager.AddEnemy(0, 2, 1); // 몬스터 타입, 이동 텀, 이동 속도 설정
break;
case 2: // 2마리
EnemyManager.AddEnemy(0, 3, 1);
EnemyManager.AddEnemy(1, 1, 3);
break;
default:
break;
}
}
EnemyManager 에서는 몬스터를 더하고 게임 시작하고 끝 설정, 초기화, 생성된 몬스터를 관리해주는 역할을 합니다. (현재 완전 완성본 상태에서 글을 적는 게 아니여서 미흡한 부분들이 있을 수 있습니다)
class EnemyManager{
constructor(currentBlocks, currentBlockMap, player){
this.currentBlocks = currentBlocks;
this.currentBlockMap = currentBlockMap;
this.player = player;
this.enemyList = [];
this.enemyObjList = [];
for(let i=1; i<=2; i++){
this.enemyObjList.push(WORLD.getObject("enemyObj" + i));
}
this.enemyObjList.forEach((enemy) => {
enemy.kill();
});
this.currentEnemyNum = 0;
this.isGameStart = false;
this.init();
}
init(){
this.enemyList = [];
this.isGameStart = false;
this.currentEnemyNum = 0;
}
Update(dt){
if(this.isGameStart){
this.enemyList.forEach((enemy) => {
enemy.Update(dt);
});
}
}
AddEnemy(type, moveingTerm, moveSpeed){
this.currentEnemyNum += 1;
let randomPos = Math.floor( Math.random(0, this.currentBlocks.length)*10);
const enemy = new GLOBAL.Enermy(this.currentEnemyNum, this.enemyObjList[type], randomPos, moveingTerm, moveSpeed, this.currentBlocks, this.currentBlockMap, this.player);
this.enemyList.push(enemy);
this.enemyObjList[type].revive();
}
SetGameStart(isActive){
if(isActive){
this.isGameStart = true;
this.enemyList.forEach((enemy) => {
//enemy.object.revive();
enemy.isGameStart = true;
});
}else{
this.isGameStart = false;
this.enemyList.forEach((enemy) => {
//enemy.object.kill();
enemy.isGameStart = false;
});
}
}
}
GLOBAL.EnemyManager = EnemyManager;
Enemy 에서는 아래와 같은 데이터를 받아오며 게임이 시작하면 현재 블록에서 움직일 수 있는 상하좌우 블록을 찾고 플레이어와의 거리를 맨하탄 거리 측정 방식 (사용 이유 : 타일 맵이고 상하좌우만 움직일 수 있어서 / 만약 대각선으로도 움직일 수 있으며 유클리드 거리 측정 방식으로 하는 것이 좋습니다.) 을 이용해 가까운 블록을 최종적으로 선택해 움직이게 합니다.
이때, 움직이는 텀과 속도를 직접 설정할 수 있게 하여 난이도 조절을 용이하게 했습니다.
class Enermy{
// 몬스터의 아이디, 오브젝트, 시작 위치, 움직이는 텀, 움직이는 속도, 현재 맵 데이터, 플레이어 데이터
constructor(id, obj, currentPos, moveingTerm, moveSpeed, blocks, blockMap, player){
this.id = id;
this.object = obj;
this.moveingTerm = moveingTerm;
this.moveSpeed = moveSpeed;
this.blocks = blocks;
this.blockMap = blockMap;
this.currentPos = currentPos;
this.player = player;
this.time = 0;
this.currentTime = 0;
this.isGameStart = false;
this.isCatchPlayer = false;
this.object.position.set(this.blocks[this.currentPos].position.x , this.blocks[this.currentPos].position.y+2, this.blocks[this.currentPos].position.z);
this.init();
}
init(){
this.time = 0;
this.currentTime = 0;
this.isGameStart = false;
this.isCatchPlayer = false;
}
// 움직이기 (가까운 블록 찾기)
Move(){
console.log("this.currentPos : " + this.currentPos);
let closeBlock = null;
let closeBlockNum = -1;
if(this.blockMap[this.currentPos].forwardB){
closeBlockNum = this.CalculationManhattanDistanceReturnCloseBlockNum(closeBlockNum, closeBlock, this.blockMap[this.currentPos].forwardB);
closeBlock = this.blocks[closeBlockNum];
}
if(this.blockMap[this.currentPos].backB){
closeBlockNum = this.CalculationManhattanDistanceReturnCloseBlockNum(closeBlockNum, closeBlock, this.blockMap[this.currentPos].backB);
closeBlock = this.blocks[closeBlockNum];
}
if(this.blockMap[this.currentPos].leftB){
closeBlockNum = this.CalculationManhattanDistanceReturnCloseBlockNum(closeBlockNum, closeBlock, this.blockMap[this.currentPos].leftB);
closeBlock = this.blocks[closeBlockNum];
}
if(this.blockMap[this.currentPos].rightB){
closeBlockNum = this.CalculationManhattanDistanceReturnCloseBlockNum(closeBlockNum, closeBlock, this.blockMap[this.currentPos].rightB);
closeBlock = this.blocks[closeBlockNum];
}
this.MoveBlock(this.blocks[this.currentPos], closeBlock, closeBlockNum);
}
// 맨하탄 거리 측정 방식으로 비교 후 더 가까운 블록을 픽
CalculationManhattanDistanceReturnCloseBlockNum(currentCloseBlockNum, currentCloseBlock, otherBlockNum){
if(currentCloseBlockNum !== -1){
let obj = this.blocks[otherBlockNum-1];
let diff1 = Math.abs(currentCloseBlock.position.x - this.player.position.x) + Math.abs(currentCloseBlock.position.z - this.player.position.z);
let diff2 = Math.abs(obj.position.x - this.player.position.x) + Math.abs(obj.position.z - this.player.position.z);
if (diff1 > diff2){
return otherBlockNum-1;
}else{
return currentCloseBlockNum;
}
}else{
return otherBlockNum-1;
}
}
Update(dt){
if(this.isGameStart){
this.time += dt;
this.currentTime += dt;
if(this.currentTime > this.moveingTerm){
this.Move();
this.currentTime = 0;
}
let distanceToPlater = this.object.position.distanceTo(this.player.position);
if(distanceToPlater < 3 && this.isCatchPlayer === false){
this.isCatchPlayer = true;
REDBRICK.Signal.send("GAME_OVER", {
enemyID: this.id
});
}
}
}
// 포물선으로 움직이게 설정
MoveBlock(StartB, endB, nextPos){
this.time = 0;
console.log(nextPos);
const tween = new TWEEN.Tween({ x: StartB.position.x, y: StartB.position.y + 2, z: StartB.position.z })
.to({ x: endB.position.x, y: endB.position.y + 2, z: endB.position.z }, 1500/this.moveSpeed)
.onUpdate(({ x, y, z }) => {
const progress = (endB.position.x - this.object.position.x) / 5;
const currentHeight = Math.sin(this.time * 2 * this.moveSpeed)* 5;
this.object.position.set(x, y + currentHeight, z);
})
.onComplete(() => {
this.currentPos = nextPos;
// 플레이어랑 가까우면 카메라 흔들리게 만들어서 긴반감 주기
let distanceToPlater = this.object.position.distanceTo(this.player.position);
if(distanceToPlater < 8){
REDBRICK.Signal.send("SHAKE_CAMERA", {
shakeDuration: 200, // 흔들리는 시간 500ms
shakeMagnitude: 0.4, // 흔들림 크기
shakeSize: 0.5
});
}
})
.start();
}
}
GLOBAL.Enermy = Enermy;
여기까지 주요 핵심 부분들은 설명한 것 같습니다!
아직 완성을 하지 못했지만 여기까지 구현하면서 나온 결과물 영상을 공유해드립니다.
적으면서도 더 효율적이고 좋은 방법이 있을까 고민하게 되네요 재미 요소도 더 추가해야할 것 같습니다.
좋은 아이디어가 있으면 댓글로 적어주시면 감사하겠습니다.
다음에는 더 완성된 버전으로 글을 작성해보겠습니다.
'Project' 카테고리의 다른 글
[게임 개발] 코드 리팩토링 + 데이터 저장 / 로드 (0) | 2024.12.15 |
---|---|
[게임 개발] 자동 타일 맵 만들기 (0) | 2024.12.08 |
[해커톤] Junction Asia 2024 참여 후기 (0) | 2024.08.19 |
[공모전/게임잼] 대한민국 No.1 서버 게임 개발 공모전 / 제 2회 포톤 게임잼 - Photon Game Jam 예선 참여 후기 및 Photon 이용 후기 (0) | 2024.08.06 |
[Even-I] 이븐아이 6기 게임톤 마지막 시상식 후기 (0) | 2024.03.17 |
댓글