NodeJS 간단한 로그라이크 게임 만들어보기 - 간단 기획과 기초 기능 만들기

아트·2024년 8월 21일
0

CLI-게임만들기

목록 보기
1/5

로그라이크 게임 만들어보기

복잡한 렌더링 기능이 들어있는 것이 아닌 콘솔을 이용해 로그라이크 게임을 만들어볼 예정입니다.

로그라이크(Roguelike)는 1980년대 초반에 출시된 "Rogue"라는 게임에서 유래한 게임 장르입니다. 이 장르의 게임은 무작위로 생성된 던전이나 맵, 영구적인 캐릭터 죽음(퍼머데스, Permadeath), 턴 기반 전투, 타일 기반 그래픽 등 여러 핵심 특징을 가지고 있습니다. 플레이어는 주어진 자원을 활용해 던전을 탐험하며 적을 물리치고, 아이템을 수집하며, 최종 목표를 달성하기 위해 전략적인 결정을 내려야 합니다. 로그라이크 게임의 핵심 매력은 높은 난이도와 반복 플레이를 통해 얻는 성취감에 있습니다.

기획 먼저

기본 규칙

  1. 게임을 시작하면 로비에서 메뉴를 선택합니다.
  2. 모험을 시작하면 10웨이브 단위로 전투를 진행합니다.
  3. 각 전투에서 행동을 선택하면 해당 행동에 맞게 진행해준다.
  4. 전투에서 승리하면 웨이브에 따라 게임이 종료되거나 다음 전투를 위한 준비를 할 수 있다.
  5. 패배시 게임에 대한 정산을 받고 로비로 돌아간다.

전투 규칙

  1. 각 유닛은 기본적으로 가능한 선택지가 있습니다.
    • 일반 공격 : 반드시 적중하는 1회성 공격
    • 특수 공격 : 확률성 공격으로 성공시 일반 공격보다 더 높은 데미지를 전달할 수 있습니다.
    • 회복 : 확률로 잃은 체력의 50%를 회복합니다. 실패시 현제 체력의 10%를 잃습니다.
  2. 플레이어는 기본적으로 추가 선택지가 있습니다.
    • 도박 : 낮은 확률로 전투를 즉시 승리합니다.
  3. 몬스터는 먼저 할 행동을 정하고 플레이어는 그것을 확인 후 무엇을 할지 정할 수 있습니다.
  4. 몬스터가 체력이 0이 되면 승리, 플레이어가 먼저 체력이 0이 되면 패배합니다.

승리 보상

  1. 체력 회복 : 최대 체력의 20%를 회복합니다.
  2. 아이템 : 체력 회복 물약 (잃은 체력의 10%, 20%, 30% 중 하나)를 10% 확률로 획득이 확정 되었을 때 얻게 됩니다.
    • 아이템 획득 후 사용하지 않고 판매하면 추가 스탯을 추가적으로 얻을 수 있습니다.
    • 판매시 절반의 확률로 추가 스탯 물약 비율 혹은 스탯 중 하나 물약 비율로 스탯이 상승합니다.
  3. 추가 스탯 : 최대 체력, 방어력, 기본 공격력, 최대 공격력, 행운 중 하나를 일정치 얻게 됩니다.

업적

게임을 진행하면서 얼마나 진척도가 있었는지 보여주는 척도입니다.

  1. 진행한 전투
  2. 승리
  3. 패배
  4. 도박 성공
  5. 보스 처치 성공
  6. 스탯별 획득한 수치
  7. 준 데미지
  8. 받은 데미지

스탯

유닛이 갖게 되는 전투에 영향을 끼치는 스탯입니다.

  1. 기본 공격력+ : 적에게 가할 수 있는 최소 데미지입니다.
  2. 방어력% : 데미지를 받게 될 때 감소 시키는 비율입니다.
  3. 최대 공격력% : 적에게 가할 수 있는 최소 데미지를 기반으로 상승한 최대 데미지입니다.
  4. 최대 체력+ : 유닛이 갖고있는 최대 생명력으로 이 이상으로 체력이 회복되지 않습니다.
  5. 체력+ : 유닛의 현 생명력입니다.
  6. 행운+ : 성공 퍼센트를 합연산으로 증가시킵니다.

계산식

  1. 데미지 적용 : (기본 공격력 ~ 기본 공격력 (1.0 + 최대 공격력)) (1.0 - 방어력)
  2. 확률 성공 여부 : (요구량) - (행운 / 100.0) < (0 ~ 1)
  3. 회복 : 체력 + 회복량 <= 최대 체력
  4. 피해 : 체력 - 피해량 >= 0

기초 작업

패키지

텍스트를 꾸미기 위해 'chalk'를, 입력을 위해 'readline-sync'를 설치 했다.

npm i chalk
npm i readline-sync

readline-sync 한글 깨짐

분명 다른 한글은 잘 출력하는데 readline-sync를 통해서 출력된 한글만 인코딩이 잘못되어 나오더라고요. 해결방법은 의외로 간단합니다.

OS 언어 설정

윈도우의 경우 "국가 또는 지역"의 관리자 옵션 > 시스템 로캘 변경 > Beta: 세계 언어 지원을 위해 Unicode UTF-8 사용을 체크후 재부팅 하면됩니다.

readline-sync 래핑

readline-sync의 각 입력 메소드를 래핑하여 출력만 별도로 하는 것입니다.
console.log()를 사용하면 편하지만 이는 출력후 바로 줄바꿈이 이루어지므로 줄바꿈 없이 출력후 사용자가 엔터를 누르면 자연스럽게 포인터가 줄바꿈 되도록 process.stdout.write()를 사용합니다.

이 방법은 OS 설정이 되어있지 않아도 유효하므로 사용자 친화적 코드로 생각할 수 있습니다.

import readlineSync from ('readline-sync');

//OS에 따라선  readlineSync가 출력한 텍스트에 인코딩이 문제가 발생할 수 있으므로 따로 래핑했습니다.

class Input {
    constructor() {
        throw new Error('This class cannot be instantiated.');
      }

  static question(query, options) {
    process.stdout.write(query);
    return readlineSync.question('', options);
  }

  static keyIn(query, options) {
    process.stdout.write(query);
    return readlineSync.keyIn('', options);
  }

  static questionInt(query, options) {
    process.stdout.write(query);
    return readlineSync.questionInt('', options);
  }

  static questionFloat(query, options) {
    process.stdout.write(query);
    return readlineSync.questionFloat('', options);
  }

  static questionNewPassword(query, options) {
    process.stdout.write(query);
    return readlineSync.questionNewPassword('', options);
  }

  static keyInYN(query) {
    process.stdout.write(query);
    return readlineSync.keyInYN('');
  }

  static keyInYNStrict(query) {
    process.stdout.write(query);
    return readlineSync.keyInYNStrict('');
  }

  static keyInSelect(items, query, options) {
    process.stdout.write(query);
    return readlineSync.keyInSelect(items, '', options);
  }

  static keyInPause(query) {
    process.stdout.write(query);
    return readlineSync.keyInPause('');
  }

  static keyInContinue(query, options) {
    process.stdout.write(query);
    return readlineSync.keyInContinue('', options);
  }

  static prompt(query, options) {
    process.stdout.write(query);
    return readlineSync.prompt('', options);
  }
}

export default Input;

nodemon 사용시 세팅할 것

C나 JAVA 같은 것으로 키보드 입력을 받아본 사람들은 다 겪어봤을텐데요. 엔터를 두번이나 쳐야 입력이 처리되는 문제를 겪어봤을 것입니다. 이번에 저도 문제를 겪어서 수차례 이것저것 해봤는데 결론은 코드 상의 문제는 아님을 알아냈고 방법을 찾았습니다.
프로젝트 최상위(루트) 디렉토리에 nodemon.json을 만들어 설정값을 적용해야합니다.

{
  "stdin": false
}

이 값은 nodemon이 표준 입력에 대한 감시를 하지 않도록 하는 것 입니다.
쉽게 말해 프로세스의 표준 입력 스트림을 감시하지 않도록 하여(키보드 입력을 감시하지 않도록 하여) 입력중 예기치 못한 문제가 발생하는 것을 방지한 것입니다.

'nodemon'과 'readline-sync'두 모듈이 입력에 대해 충돌이 발생하여 문제가 생긴 것 같습니다.

텍스트 테이블 작업

'chalk'를 이용해 터미널을 꾸밀 수 있어서 좋다 생각은 들었는데 수기로 하드코딩 하려니 코드 가독성도 떨어지는 것 같아서 테이블을 이용하기로 해봤습니다.
간단하게 csv를 이용해 다음 표를 토대로 텍스트를 매핑할 것 입니다.

idtext
포맷 설정한 텍스트

포맷 규칙

특수 처리는 중괄호로 감쌉니다.

색상

색상은 반드시 최상위로 둬야하며 작성법은 다음과 같습니다.

"{색상: 적용할 텍스트}"
  • 올바른 예 : {blue:하고싶은 말}
  • 잘못된 예 : {하고싶은 말: blue}

변수

변수를 적용하려면 중괄호를 두번 감싸야 합니다.

"{{name}}은 인사를 했어요."

색상을 적용하려면 색 괄호를 먼저합니다.

"{blue:{{name}}}은 인사를 했어요."

모듈 다운로드

csv를 사용하기로 했으니 csv-parser를 설치합니다.

npm i csv-parser

이를 토대로 코드를 작성하면 완료입니다~
class 옆에 extends Singleton이란 것이 있는데 이건 다음 차례에 설명하겠습니다.

import fs  from 'fs';
import csv from 'csv-parser';
import chalk from 'chalk';
import Singleton from './Singleton.js';

class TextTable extends Singleton {
  #textTable;
  constructor() {
    super();
    this.#textTable = {};
  }

  // CSV 파일을 비동기로 읽어오는 메서드
  async Load(filePath) {
    return new Promise((resolve, reject) => {
      fs.createReadStream(filePath)
        .pipe(csv())
        .on('data', (row) => {
          this.#textTable[row.id] = row.text.replace(/^"|"$/g, '');
        })
        .on('end', () => {
        //  console.log('CSV file successfully processed.');
          resolve();
        })
        .on('error', (err) => {
          reject(err);
        });
    });
  }

  // 텍스트를 포맷팅하고 색상 적용하는 메서드
  FormatText(id, variables = {}) {
    if (!this.#textTable[id]) {
      return 'Text not found';
    }

    let text = this.#textTable[id];

    // 텍스트 내 변수 치환
    for (let key in variables) {
      text = text.replace(`{{${key}}}`, variables[key]);
    }

    // 텍스트에서 색상 적용 부분 파싱 및 적용
    text = text.replace(/{(.*?):(.*?)}/g, (match, color, content) => {
      // 내용이 또 다른 변수일 경우 변수 치환
      if (content in variables) {
        content = variables[content];
      }
      // 색상 적용
      return chalk[color](content);
    });

    return text.replace(/\\n/g, '\n');;
  }
}


export default new TextTable();;

Singleton

싱글톤은 쉽게말해서 도플갱어가 돌아다니지 않도록 하는 것 입니다. 위에 TextTable.js에서 적용한 예입니다.
TextTable을 불러올 때마다 new TextTable();을 하므로 매번 생성하니 서로 다른 TextTable이 돌아다닐 수 있습니다. 하지만 Singleton을 적용함으로서 생성되는 대신에 이미 생성된 원본을 참조하도록 작업해준 것 입니다.

class Singleton {
    constructor() {
      if (this.constructor.instance) {
        return this.constructor.instance;
      }
  
      this.constructor.instance = this;
    }
  }
  
  export default Singleton;
  

코드를 보면 아주 심플합니다. instance(일꾼)가 있으면 바로 주고 없으면 생성자를 통해서 새로 생성된 일꾼을 준다.
이렇게하면 하나의 TextTable을 이곳 저곳에서 일관성 있게 쓸 수 있게 됩니다.


마무리

그 외에도 전략 패턴이나 캐릭터를 위한 클래스 디자인 등 여러가지를 했지만 오늘자 글은 줄이려 합니다. 작업할 시간이 부족해서요
다음 글에서 이어서 나가겠습니다.

0개의 댓글