상세 컨텐츠

본문 제목

프로젝트#1. 로그라이크 게임개발 JavaScript

내일배움캠프 학습/진행 프로젝트

by 남민우_ 2024. 11. 17. 22:31

본문

내일배움캠프 과정의 일환으로, JS를 통한 로그라이크 텍스트 게임을 개발했다.

로그라이크의 특성에 맞게 기본 조건으로 '랜덤 요소, 세이브 불가능, 턴제' 라는 세가지를 주어준다.

또한 기본적으로 주어지는 스켈레톤 코드를 통해 추가 기능과 게임의 동작을 구현한다.

https://github.com/Namminu/RogueLike_JS/tree/_develop

 

프로젝트 소개

로그라이크 특성을 유지하되 추가적인 요소를 가미하기 위해 소울류 장르를 덧붙였다.

따라서 여러개의 스테이지로 구성하여 이를 1. 일반 스테이지, 2. 보스 스테이지 로 나누고, 보스 스테이지에서는 오랜 시간에 걸친 전투를 진행하고자 하였다.

다만 많은 게임에서 재미를 느낄 수 있는 부분은 개인적으로 '플레이어의 성장' 이라고 생각해 레벨/경험치 컨텐츠를 추가하여 레벨에 따라 플레이어의 능력치가 상승하도록 구성했다.

이 모든 것들을 더한 게임의 주요 키워드는 다음과 같다.

 

1. 일반/보스 스테이지 분리 구성

2. 경험치 파밍을 통한 플레이어 레벨 상승

3. 보스 스테이지의 패링 시스템

4. 스테이지에 따른 몬스터의 설정 및 수치 조절

5. 스토리 요소 가미

 

이를 하나씩 정리하도록 한다.

 

Ch0. 프로젝트 설계

모든 프로젝트가 그렇듯, 파일의 구성과 클래스/함수 분리 등 구조적인 설계가 밑바탕이 되어야 그 뒤에 기능구현을 하기 편리하다고 판단했다.

따라서 주어진 스켈레톤 코드와 필요한 클래스들을 별도의 파일로 분리, Import / Export 를 통해 메서드들을 불러올 수 있도록 하고자 했다.

다음과 같이 파일을 분리하여 각자의 기능에 맞게 구성했다.

1. 주요 클래스 : 총 3가지 - Player, Monster, BossMonster

2. json 파일 : 총 2가지 - monster(몬스터들의 데이터 수치 저장), EndGameMessage(엔딩 스크립트 메세지 저장)

다만 이 파일 구성 또한 json 파일끼리, 클래스 파일끼리, 메서드 파일끼리 별도의 폴더로 분리시킬 수 있지 않을까 하는 생각이 든다.

 

전체적인 게임 흐름을 플로우 차트로 그리면 다음과 같이 나타낼 수 있다.

 

그럼 하나씩 코드와 기능에 대해서 살펴보도록 하자.

 

Ch1. Server.js

import chalk from 'chalk';
import figlet from 'figlet';
import readlineSync from 'readline-sync';
import {startGame} from "./Rogue.js";

// 로비 화면을 출력하는 함수
export function displayLobby() {
    console.clear();

    // 타이틀 텍스트
    console.log(
        chalk.cyan(
            figlet.textSync('Rogue and Soul', {
                font: 'Standard',
                horizontalLayout: 'default',
                verticalLayout: 'default'
            })
        )
    );

    const line = chalk.magentaBright('='.repeat(50));
    console.log(line);

    console.log(chalk.yellowBright.bold('로그소울 게임에 오신것을 환영합니다!'));

    console.log(chalk.green('옵션을 선택해주세요.'));
    console.log();

    console.log(chalk.blue('1.') + chalk.white(' 새로운 게임 시작'));
    console.log(chalk.blue('2.') + chalk.white(' 업적 확인하기'));
    console.log(chalk.blue('3.') + chalk.white(' 옵션'));
    console.log(chalk.blue('4.') + chalk.white(' 종료'));

    console.log(line);

    console.log(chalk.gray('1-4 사이의 수를 입력한 뒤 엔터를 누르세요.'));
    //handleUserInput();
}

async function handleUserInput() {
    const choice = readlineSync.question('Enter : ');
    switch (choice) {
        case '1':
            console.log(chalk.green('게임을 시작합니다.'));
            await startGame();
            break;
        case '2':
            console.log(chalk.yellow('구현 준비중입니다.. 게임을 시작하세요'));
            handleUserInput();
            break;
        case '3':
            console.log(chalk.blue('구현 준비중입니다.. 게임을 시작하세요'));
            handleUserInput();
            break;
        case '4':
            console.log(chalk.red('게임을 종료합니다.'));
            process.exit(0); 
        default:
            console.log(chalk.red('올바른 선택을 하세요.'));
            handleUserInput(); 
    }
}

export async function start() {
    displayLobby();
    await handleUserInput();
}

start();

이 Server 파일은 대부분 초기에 주어진 스켈레톤 코드로 구성되어 있다.

기능 자체는 간단한데, 처음 프로세스 실행 시 나타나는 메인 화면과 input 값에 따라 게임을 시작하는 기능을 보유한다.

프로세스 실행 시 start() 함수 실행, displayLobby() 와 비동기 함수 handleUserInput() 을 실행한다.

displayLobby()

게임 제목, 설명, 플레이 옵션 등을 나타낸다. 메인 화면의 주요 기능이라고 생각하면 될 것이다.

handleUserInput()

displayLobby() 함수에서 보여준 선택지(1~4)를 입력 및 후처리를 하는 함수이다.

readlineSync 를 사용해 사용자의 입력을 받고 switch 문을 통해 입력에 따른 처리를 진행하도록 했다.

주요 포인트로는 case '1' 의 await startGame() 으로 이 함수를 통해 본격적인 게임 플레이가 진행된다.

 

이 프로세스를 실행시키면 다음과 같이 화면이 나타난다.

2번과 3번은 이 프로젝트의 목적에 크게 상관있는 항목은 아니라고 판단, 기능 개발을 진행하지 않았다.

 

 

Ch2. Rogue.js

import chalk from 'chalk';
import { Player } from './Player.js';
import { battle } from './Battle.js';
import { EndGame } from './EndGame.js';
import readlineSync from 'readline-sync';

export function displayStatus(stage, player, monster) {
  console.log(monster.TYPE === 'Normal' ? chalk.green(`[ 일반 스테이지 ${stage}]`) 
                                        : chalk.red(`[ 보스 스테이지 ${stage}]`));
  console.log(chalk.magentaBright(`=== Current Status ===`));
  console.log(chalk.cyanBright(`| Stage: ${stage} `));
  console.log(chalk.blueBright(`| 플레이어 정보 | LEVEL : ${player.LEVEL} HP : ${player.CurHP.toFixed(2)} ATK : ${player.ATK} DEF : ${player.DEF} EXP : ${player.EXP} %`,));
  if (monster.TYPE==='Normal') console.log(chalk.redBright(`| 일반 몬스터 정보 | NAME : ${monster.NAME} HP : ${monster.HP.toFixed(2)} ATK : ${monster.ATK}`,));
  else console.log(chalk.redBright(`| 보스 몬스터 정보 | NAME : ${monster.NAME} HP : ${monster.HP.toFixed(2)} ATK : ${monster.ATK} DEF : ${monster.DEF}`,));
  console.log(chalk.magentaBright(`=====================\n`));
}

export async function startGame() 
{
  console.clear();
  const player = new Player(100, 20, 15, 20, 0);
  let stage = 1;
  
  while (stage <= 9) {
    let exp;
    try {
      exp = await battle(stage, player);
    } catch(err) {
      console.error(err);
    }
    console.clear();

    console.log(chalk.magentaBright(`${stage} 스테이지를 클리어 했습니다.`));
    console.log(chalk.blueBright(`${exp}의 경험치를 획득합니다`));
    player.EXP += exp;

    console.log(chalk.magentaBright(`\n1. Continue 2. Stay 3. Exit`));

    const choice = readlineSync.question('Enter : ');
    switch(choice)
    {
      case '1':
        stage++;
        break;
      case '2':
        break;
      case '3':
        console.log(chalk.red('게임을 종료합니다.'));
        process.exit(0);
      default:
        console.log(chalk.red('잘못된 입력입니다. 게임을 진행합니다.'));
        stage++;
        break;
    }
  }

  console.log(chalk.magentaBright(`모든 스테이지를 클리어 했습니다.`));
  readlineSync.question('\nPress Enter...');
  await EndGame();
}

 

기능 두개, startGame() 과 displayStatus() 로 나뉘어져 있다.

사실 displayStatus() 함수는 이 Rogue.js 에서 정의만 됐을 뿐 호출은 Battle.js 에서 이루어지는데, 이는 정확하게 구조 분리가 이루어졌다고 보긴 어려워 끝나고 나니 살짝의 아쉬움으로 남는다.

일단 기능에 대해서 하나씩 설명해보자.

displayStatus()

호출 위치는 startGame() -> battle() 에서 진행된다.

현재 스테이지와 플레이어 정보, 몬스터 정보를 나타내주는데, 몬스터는 일반/보스 두 종류가 있어 if 문을 통해 표시하는 정보를 달리 하도록 했다.

 

startGame()

본격적인 게임 진행이 시작되는 위치로, 게임을 플레이 할 플레이어를 먼저 생성한다.

이후 stage = 1 로 시작 스테이지를 설정, while 문의 반복을 통해 스테이지의 진행을 처리한다.

try - catch 의 사용으로 battle() 함수가 정상적으로 호출되지 않았을 경우의 예외 처리를 진행한다.

또한 battle() 이 정상적으로 끝났을 경우 battle() 함수 안에서 생성한 몬스터의 경험치를 반환하는데 이를 플레이어의 경험치에 더하여 레벨업 시스템의 처리를 진행한다.

또한 switch 문을 통해 스테이지를 반복 진행할지, 더 나아갈지 결정한다.

while 문의 반복이 끝났을 경우, 즉 모든 스테이지가 클리어 됐을 경우 엔딩 스크립트 진행을 위해 EndGame() 함수를 호출하는 것으로 마무리된다.

 

그럼 순서 상 다음으로 소개할 코드는 플레이어 클래스가 되겠다.

Ch3. Player.js

import chalk from 'chalk';

export class Player {
    constructor(maxHp, atk, def, dAtkRate, exp) {
      this._MaxHP = maxHp;
      this._CurHP = this._MaxHP;

      this._DEF = def;
      this._ATK = atk;
      this._DoubleATKRate = dAtkRate;

      this._LEVEL = 1;
      this._EXP = exp;
    }
// ----------------------------------------------------------------------
    //공격 기능
    Attack(isParring) 
    {
        if((this._DoubleATKRate/100) > Math.random())
        {
            //연속 공격 진행
            return { 
                damage : isParring ? (this._ATK*2) *2 : this._ATK * 2,
                message : chalk.blue(`연속 공격 발동!`)
            };
        }
        else
        {
            //일반 공격 진행
            return { 
                damage : isParring ? (this._ATK*2) : this._ATK,
                message : chalk.blue(`${isParring ? (this._ATK*2) : this._ATK} 의 공격을 합니다.`)
            };
        }
    }
    //회피 기능
    Dodge(hit_damage)
    {
        if(Math.random() > 0.35)
        {
            //일정 확률로 회피 성공 + 체력 일정량 회복
            if((this._CurHP * 1.2) > this._MaxHP) this._CurHP = this._MaxHP;
            //else this._CurHP = Math.floor((this._CurHP * 1.2)* 10 )/10;
            else this._CurHP *= 1.2;

            return chalk.blue('회피 성공! 체력을 소량 회복합니다.');
        }
        else
        {
            //회피 실패, 피격 발생
            this.Hitted(hit_damage);

            return chalk.red('회피 실패! 피해를 입습니다.');
        }
    }
    //피격 기능
    Hitted(hit_damage)
    {
        this._CurHP -= (hit_damage * ((100 - this._DEF)/100));
    }
    //레벨업 기능
    LevelUp() 
    { 
        this._LEVEL++;
        this._MaxHP *= 1.2;
        this._CurHP = this._MaxHP;
        this._ATK += Math.floor(Math.random() * 10);
        this._DoubleATKRate += Math.floor(Math.random() * 10);
        this._DEF += Math.floor(Math.random() * 10);
        this._EXP -= 100;
    }
// ----------------------------------------------------------------------
    get MaxHP() { return this._MaxHP; }
    get CurHP() { return this._CurHP; }
    get DEF() { return this._DEF; }
    get ATK() { return this._ATK; }
    get DoubleATKRate() { return this._DoubleATKRate; }
    get EXP() { return this._EXP; }
    get LEVEL() { return this._LEVEL; }

    set MaxHP(value) { this._MaxHP = value; }
    set CurHP(value) { this._CurHP = value; }
    set DEF(value) { this._DEF = value; }
    set ATK(value) { this._ATK = value; }
    set DoubleATKRate(value) { this._DoubleATKRate = value; }
    set EXP(value) 
    { 
        this._EXP = value;
        if(this._EXP >= 100)
        {
            this.LevelUp();
            console.log(chalk.yellow("\nPlayer LEVEL UP! : " + this._LEVEL));
            console.log(chalk.yellow("플레이어 능력이 일정량 증가합니다."));
        }
    }
}

 

코드 작성 중 보기 불편한 감이 있어 주석 처리를 통해 생성자, 메서드, getter/setter를 분리하였다.

보이는 것처럼 코드 전체적으로 크게 어려운 수식을 처리하지는 않는다.

 

먼저 생성자에서는 플레이어의 필요한 정보들의 초기화를 담당한다. 최대 체력(MaxHp)과 현재 체력(CurHp) 을 분리하여 전투 진행 중 감소되는 체력은 현재 체력으로, 레벨업 등 포괄 수치가 필요할 경우는 최대 체력으로 처리하도록 하였다.

그 외에는 방어력, 공격력과 연속 공격 확률을 위한 변수, 레벨과 경험치를 표기할 변수를 선언해주었다.

 

메서드는 4개로 구성되어 있는데 기능 자체는 상당히 간단하다.

Attack(isParring)

플레이어의 공격 처리를 담당하는 메서드이다.

Math.random() 을 통해 공격 중 연속 공격 발동 확률을 처리하였고, 반환은 객체를 반환하는데 이 안에 플레이어가 공격한 데미지, 전투 로그를 표시하기 위한 메세지를 구성하였다.

또한 매개변수로 받는 isParring 은 bool 값으로 보스 스테이지에서 패링 성공 여부에 따라 반환하는 데미지 값을 삼항 연산자를 통해 처리하였다.

Dodge()

플레이어의 회피 기능을 담당하는 메서드이다.

이는 35%의 고정 확률에 따라 다르게 처리되며 성공 시 현재 체력의 1.2배로 회복, 실패 시 데미지를 입도록 하였다.

Hitted(hit_damage)

플레이어의 피격 기능을 담당하는 메서드이다.

매개변수를 통해 몬스터가 공격하는 값을 받아오는데, 내부적으로 플레이어의 방어력(DEF) 수치에 따라 일정량 감소된 데미지를 받도록 하였다.

LevelUp()

플레이어의 레벨업에 따른 처리를 담당하는 메서드이다.

EXP의 setter 에서 호출되며, 각종 스텟의 수치 상승을 맡고 있다.

getter/setter

이 안에서는 대부분 보는 즉시 이해가 가능한 코드들이다.

사실 setter의 경우 수치의 타입, 값 등에 따라 내부 처리를 추가적으로 구현하는 것이 맞겠지만, 1인 개발인 만큼 외부에서 혼동하여 사용할 여지가 없다고 판단하여 굳이 필터링 과정을 구현하지는 않았다.

다만 set EXP(value) 안에서만 플레이어의 경험치가 최대값 100을 넘길 경우에 따른 레벨업 처리를 구현해두었다.

 

Ch4. Battle.js

import chalk from "chalk";
import { displayStatus } from "./Rogue.js";
import { spawnMonster } from "./SpawnMonster.js";
import readlineSync from "readline-sync";

async function displayLogs(logs) {
    for (const log of logs) {
        console.log(log);
        await new Promise((resolve) => setTimeout(resolve, 500));
    }
}

const ParringSide = chalk.white(
  `[1. 좌 상단] [2. 중 상단] [3. 우 상단]\n
[4. 좌 중단] [5. 중 중단] [6. 우 중단]\n
[7. 좌 하단] [8. 중 하단] [9. 우 하단]`
);

export const battle = async (stage, player) => {
  let logs = [];
  const monster = spawnMonster(stage);

  while (player.CurHP > 0 || monster.HP > 0) {
    if (player.CurHP <= 0) {
      console.log(chalk.red("플레이어가 사망했습니다.. 게임을 종료합니다."));
      process.exit(0);
    } else if (monster.HP <= 0) {
      console.clear();
      displayStatus(stage, player, monster);
      //logs.forEach((log) => console.log(log));
      displayLogs(logs);

      console.log(chalk.yellow("\n전투가 종료되었습니다!"));
      await new Promise((resolve) => setTimeout(resolve, 1000));
      break;
    }

    console.clear();
    displayStatus(stage, player, monster);
    await displayLogs(logs);
    //logs.forEach((log) => console.log(log));
    console.log(
      chalk.green(
        `\n1. 공격   2. 회피(35%) ${
          monster.TYPE === "Boss" ? "  3. 패링! " : ""
        }`
      )
    );
    logs = [];
    battleInput(logs, player, monster, stage);
  }
  return monster.EXP;
};

function battleInput(logs, player, monster, stage) {
  let pDamage, mDamage;
  const choice = readlineSync.question("Enter : ");
  switch (choice) {
    case "1":
      //플레이어 공격 처리
      pDamage = player.Attack(false);
      logs.push(chalk.green(`공격 : ${pDamage.message}`));
      logs.push(monster.Mon_Hitted(pDamage.damage));

      //이후 몬스터 공격 진행
      mDamage = monster.Mon_Attack();
      logs.push(mDamage.message);
      player.Hitted(mDamage.damage);
      break;

    case "2":
      mDamage = monster.Mon_Attack();
      logs.push(chalk.green(`회피 : ${player.Dodge(mDamage.damage)}`));
      break;

    case "3":
      if (monster.TYPE !== "Boss") {
        console.log(chalk.red("잘못된 입력입니다."));
        battleInput(logs, player, monster);
      } else {
        console.clear();
        displayStatus(stage, player, monster);
        console.log(ParringSide);
        const pChoice = readlineSync.question("Enter : ");

        const pResult = ParringChoice(pChoice);
        logs.push(pResult.message);
        if (pResult.isParring) {
          pDamage = player.Attack(pResult.isParring);
          logs.push(chalk.green(`반격 : ${pDamage.message}`));
          logs.push(monster.Mon_Hitted(pDamage.damage));
        } else {
          mDamage = monster.Mon_Attack();
          logs.push(mDamage.message);
          player.Hitted(mDamage.damage * 1.1);
        }
      }
      break;

    default:
      console.log(chalk.red("잘못된 입력입니다."));
      battleInput(logs, player, monster, stage);
  }
}

function ParringChoice(pChoice) {
  const randomNumber = `${Math.floor(Math.random() * 9) + 1}`;

  if (pChoice === randomNumber) {
    return {
      isParring: true,
      message: chalk.blue("패링에 성공했습니다! 강하게 반격합니다."),
    };
  } else {
    return {
      isParring: false,
      message: chalk.blue(
        "패링에 실패했습니다.. 데미지를 추가적으로 받습니다."
      ),
    };
  }
}

 

이 로그라이크 게임 내에서 가장 주요한 파일이라고 볼 수 있다.

대부분의 전투 과정, 패링, 로그 출력을 이 안에서 담당한다.

이 파일 또한 더 분리시킬 수 있었지 않을까 하는 아쉬움이 남는 파일이다. 예를 들자면 전투를 담당하는 battle() 과 battleInput() 을 나누고 브가적인 요소를 담당하는 메서드들을 battle_Side.js 로 나눌 수 있었을 것이다.

일단 기능들을 봐보자.

 

displayLogs(logs)

battle() 함수 내부에서 선언한 logs 를 매개변수로 입력받고, 그 안의 정보들을 매 인덱스마다 딜레이 출력하는 메서드이다.

매 인덱스 사이에 딜레이를 주기 위해 await 와 Promise 를 사용해 하나의 인덱스 출력이 완료될 때까지 대기하도록 했으며 이 메서드 또한 async 키워드를 통해 이 메서드가 완료될 때까지 battle() 함수 내부에서 대기할 수 있도록 구성하였다.

battle

초기에 전투 기록을 담당한 logs 리스트와 플레이어와 전투를 치를 monster 를 선언한다. monster는 spawnMonster 메서드를 통해 스테이지에 맞는 몬스터를 받아오는데, 이는 별도의 파일로 처리되므로 이후에 설명한다.

 

그 다음에는 while 문을 통해 플레이어나 몬스터의 체력이 0 이하로 되기 전까지, 즉 둘 중 하나가 쓰러지기 전까지 턴제 전투를 반복한다. Rogue.js 에서 정의했던 displayStatus 를 여기서 호출하며, 플레이어의 선택지를 보여준다.

이 선택지는 일반 몬스터일 경우 '1. 공격   2. 회피' 를 보여주고, 삼항연산자를 통해 보스 몬스터일 경우(monster.TYPE === "Boss") 특수 선택지 3. 패링 을 추가적으로 보여주도록 처리하였다.

이후 battleInput을 호출해 플레이어의 입력에 따른 전투 경과를 처리한다.

battleInput(logs, player, monster, stage)

매개변수로는 전투에 필요한 모든 것들을 넘겨받는다.

이 함수 내부에서도 마찬가지로 readlineSync 를 통해 사용자의 입력을 받고, switch 문에서 입력에 따른 후처리를 진행한다.

1번을 선택할 경우 공격을 진행하는데 플레이어의 Attack메서드를 호출 - pDamage에 할당 - message는 logs에 push/damage는 몬스터의 Mon_Hitted 메서드에 매개변수로 넘겨주어 몬스터의 피격을 처리한다.

이후에 몬스터의 공격을 진행하고 플레이어 공격의 반대 구성으로 처리된다.

2번을 선택할 경우 회피를 실패할 때 데미지 처리를 위해 먼저 몬스터의 데미지 값을 받아오고 플레이어의 Dodge()메서드에 넘겨주어 회피의 성공 여부는 플레이어 클래스 내부에서 처리하고 결과만을 받아오도록 하였다.

3번은 위에서 말했듯 보스 몬스터일 경우에만 진행되는 선택지므로 monster.TYPE을 먼저 검사, 보스가 아닐 경우 잘못된 입력임을 나타내고 보스일 경우 패링 선택지와 그 입력에 따른 성공 판단 여부를 받아와 후처리를 진행한다.

ParringChoice(pChoice)

보스 스테이지에서 패링의 성공 여부를 판단하는 메서드이다.

플레이어의 패링 선택지를 매개변수로 받아오고, randomNumber 에서 패링의 정답을 정한다.

이후 두 값의 비교를 통해 실패와 성공의 값을 객체로 반환한다.

 

 

추가적으로 설명할 코드가 더 있지만, 내용이 너무 길어져 Monster.js 부터는 다음 글에서 설명하도록 하겠다.

 

관련글 더보기