NodeJS 간단한 로그라이크 게임 만들어보기 - 최종 설명

아트·2024년 8월 27일
0

CLI-게임만들기

목록 보기
5/5

여태까지 간단한 기능들의 모음을 먼저 만들고 게임 시나리오를 구현하는 식으로 소개를 했었습니다.
이번에는 스크립트 흐름을 통해서 어떻게 구현했는지 완전히 작성해보려 합니다.

디렉토리 구조

이전글에서 설명했던 디렉토리 구조입니다.
루트 디렉토리에 있는 index.js로 부터 기본적인 정보를 미리 불러오고 로비로 진입시켜줍니다.

실행 방법

npm 이라는 패키지 관리자를 이용했기에 해당 매니저를 통해서 패키지들을 다운로드 받습니다.

$ npm i

그리고 package.json에 적혀있는 실행 명령을 npm을 통해서 실행할 것 입니다.

 "scripts": {
    "start": "node index.js",
    "dev": "nodemon --legacy-watch index.js"
  }

실행 명령어

$ npm run start

위 명령어를 실행하면 자동으로 아래 명령이 실행되는 것 입니다.

$ node index.js

index.js

index.js는 이 프로그램의 진입점입니다.
Texttable 및 Achievements를 불러와 미리 데이터를 준비한 후 Lobby.js를 실행해 로비 시나리오를 시작하도록 해주는 것 입니다.

import TextTable from './src/lib/TextTable.js';
import Utils from './src/lib/Utils.js';
import server from './src/scenes/Lobby.js';
import _ from './src/lib/Achievements.js'; //업적 불러오기

async function Start() {
    console.log("Loading...");
    await TextTable.Load('./resources/TextTable.csv');
    console.log("Table Load Done.");
    console.log("Start Game");
    await Utils.Delay(1000);
    await server();
}

Start();

Lobby.js

로비 시나리오 입니다. Command를 통해서 미리 사용자가 할 수 있는 행동을 정의 해놓고 입력에 따라서 처리하도록 되어 있습니다.
setCommand() 함수를 들여다보면 미리 정의하고 있음을 알고있습니다.

import { startGame as StartGame } from './Game.js';
import TextTable from '../lib/TextTable.js';
import Input from '../lib/Input.js';
import Command from '../lib/Command.js';
import Utils from '../lib/Utils.js';
import Option from './Option.js';
import Achievements from '../lib/Achievements.js';

let continued = true;
let menus = null;
let lobbyText = '';

/**
 * 로비 명령어를 설정하는 함수입니다.
 */
function setCommand() {
  if (menus === null) {
    menus = new Command();
    menus.AddCommand(TextTable.FormatText('lobby_game_start'), StartGame);
    menus.AddCommand(TextTable.FormatText('lobby_option'), Option);
    menus.AddCommand(TextTable.FormatText('lobby_exit'), async () => {
      TextTable.Output('game_exit');
      continued = false;
    });
  }

  const achievementsText = TextTable.FormatText('lobby_achievements');
  const included = Array.from(menus.keys).includes(achievementsText);
  if (!included && Achievements.achievements.start_count > 0) {
    menus.InsertCommandAt(achievementsText, viewAchievements, 1);
  } else if (included && Achievements.achievements.start_count <= 0) {
    menus.RemoveCommand(achievementsText);
  }

  lobbyText = TextTable.FormatText('lobby_menu', { menu_list: Array.from(menus.keys).join('\n') });
}

/**
 * 로비 화면을 출력하는 함수입니다.
 */
function displayLobby() {
  console.clear();
  console.log(lobbyText);
}

/**
 * 업적을 조회하는 함수입니다.
 */
async function viewAchievements() {
  console.clear();
  const delay = 500;
  TextTable.Output('achievements_view_start');
  await Utils.Delay(delay);
  TextTable.Output('achievements_start_count', { count: Achievements.achievements.start_count });
  await Utils.Delay(delay);
  TextTable.Output('achievements_monster');
  for (let monster in Achievements.achievements.encounter_monster) {
    try {
      const encounter = Achievements.achievements.encounter_monster[monster];
      const killed = Achievements.achievements.kill_monster[monster];
      let txt = TextTable.FormatText('achievements_monster_count', {
        name: monster,
        killed,
        encounter,
      });
      console.log(txt);
    } catch (error) {
      console.error(error);
      await Input.question('');
    }
    await Utils.Delay(200);
  }

  TextTable.Output('achievements_collect_elixir', {
    count: Achievements.achievements.collect_elixir,
  });
  await Utils.Delay(delay);

  TextTable.Output('achievements_total_dmg_dealt', {
    total: Achievements.achievements.total_dmg_dealt,
  });
  await Utils.Delay(delay);

  TextTable.Output('achievements_total_dmg_taken', {
    total: Achievements.achievements.total_dmg_taken,
  });
  await Utils.Delay(delay);

  TextTable.Output('achievements_total_heal', { total: Achievements.achievements.total_heal });
  await Utils.Delay(delay);

  TextTable.Output('achievements_victory_count', {
    count: Achievements.achievements.victory_count,
  });
  await Utils.Delay(delay);

  TextTable.Output('achievements_lose_count', { count: Achievements.achievements.lose_count });

  TextTable.Output('achievements_view_end');
  await Input.question(TextTable.FormatText('any_key'));
}

/**
 * 유저 입력을 받아 처리하는 함수입니다.
 */
async function handleUserInput() {
  while (continued) {
    displayLobby();
    const text = TextTable.FormatText('input');
    const choice = await Input.question(text);
    if ((await menus.ExecuteCommand(choice)) !== false) {
    } else {
      TextTable.Output('wrong_select');
    }
    await Utils.Delay(1000);
  }
}

/**
 * 게임을 시작하는 함수입니다.
 */
async function start() {
  continued = true;
  setCommand();
  handleUserInput();
}

export default start;

TextTable.js

미리 외부 파일로 출력할 텍스트를 정의해두고 이를 불러와 처리하는 스크립트입니다.
csv를 통해서 처리하도록 되어 있습니다.

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 파일을 읽어 텍스트 테이블을 로드합니다.
   * 이 메서드를 호출한 후에 다른 메서드를 사용해야 합니다.
   * @param {string} filePath CSV 파일 경로
   * @returns {Promise<void>}
   */
  async Load(filePath) {
    return new Promise((resolve, reject) => {
      fs.createReadStream(filePath)
        .pipe(csv())
        .on('data', (row) => {
          try{
            this.#textTable[row.id] = row.text.trim().replace(/^"|"$/g, '').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
          }catch{
            console.error(row);
          }
        })
        .on('end', () => {
          resolve();
        })
        .on('error', (err) => {
          reject(err);
        });
    });
  }

  /**
   * 주어진 id에 해당하는 텍스트를 변수 치환을 포함하여 포맷팅합니다.
   * @param {string} id 텍스트 id
   * @param {Object} variables 텍스트 내 치환할 변수들
   * @returns {string} 포맷된 텍스트
   */
  FormatText(id, variables = {}) {
    if (!this.#textTable[id]) {
      return 'Text not found';
    }

    let text = this.#textTable[id];

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

    // 텍스트에서 색상 적용 부분 파싱 및 적용
    text = text.replace(/{(.*?):(.*?)}/gs, (match, color, content) => {
        return chalk[color](content);
    });

    return text;
  }

  /**
   * 주어진 id에 해당하는 텍스트를 포맷팅한 후 콘솔에 출력합니다.
   * @param {string} id 텍스트 id
   * @param {Object} variables 텍스트 내 치환할 변수들
   */
  Output(id, variables = {}) {
    console.log(this.FormatText(id, variables));
  }

  /**
   * @ 문자를 기준으로 텍스트를 일정 간격으로 정렬합니다.
   * @param {string} inputText 입력 텍스트
   * @param {number} [spacing=60] 좌우 텍스트 간격
   * @returns {string} 정렬된 텍스트
   */
  FormatTextForConsole(inputText, spacing = 60) {
    const lines = inputText.split('\n');

    return lines.map(line => {
        const [left, right] = line.split('@');

        if (!right) {
            return left;
        }

        const spaces = ' '.repeat(Math.max(spacing - left.length, 1));

        return left + spaces + right;
    }).join('\n');
  }
}

const textTable = new TextTable();
export default  textTable;

CSV

CSV는 콤마(,)로 구분된 데이터 테이블을 의미합니다.
전 아래와 같이 id와 text로 구분하고 TextTable.js는 미리 약속된 규칙에 따라 텍스트를 변환하여 출력할 수 있도록 도와주는 것 입니다.

id,text
attack_action, "공격"
damage, "{yellow:{{unit}}}가 {yellow:{{target_unit}}}에게 공격하여 {red:{{damage}}}만큼의 피해를 입혔습니다."
encounter, "몬스터 {red:{{name}}}(이/가) 출몰했습니다.\n잠시후 전투가 시작됩니다!"
double_attack_action, "연속공격"
action_failed, "{yellow:{{unit}}}가 {blue:{{action_name}}}에 {red:실패}하였습니다."
try_heal_action, "회복"
try_heal_result, "{yellow:{{unit}}}의 {blue:회복 시도}가 {yellow:{{success}}}하여 체력이 {{prev_hp}}에서 {{current_hp}}으로 변경되었습니다."
gambling_action, "도박"
gambling_success, "도박에 성공하여 {blue:승리}하였습니다!"
gambling_failed, "도박에 {red:실패}하였습니다..."
lobby_menu, "==================================================\n{yellow:CLI 게임에 오신것을 환영합니다!}\n{green:옵션을 선택해주세요.}\n\n{blue:{{menu_list}}}\n==================================================\n{grey:직접 메뉴를 입력해주세요.}"
not_allow, "{yellow:구현 준비중입니다. 다른 메뉴를 선택해주세요}"
game_exit, "{red:게임을 종료합니다.}"
input, "{white:입력: }"
wrong_select, "{red:잘못된 선택입니다.}"
battle_stage_info, "{magentaBright:=== Current Status ===}\n{cyan:| 스테이지 : {{stage}} |}\n{blue:{{player_name}}}@{red:{{monster_name}}}\nHP : {blue:{{player_current_hp}}/{{player_max_hp}}}@HP : {red:{{monster_current_hp}}/{{monster_max_hp}}}\nATK : {blue:{{player_atk_min}} - {{player_atk_max}}}@ATK : {red:{{monster_atk_min}}} - {red:{{monster_atk_max}}}\nDEF : {blue:{{player_defense_rating}}}%@DEF : {red:{{monster_def}}}%\nLUCK : {blue:{{player_luck}}}%@LUCK : {red:{{monster_luck}}}%\n{magentaBright:=====================}"
action_info, "{green: {{actions}}}"
victory, "{yellow:{{player}}}가 {red:{{monster}}}의 전투에서 승리하였습니다!"
lose, "{red:{{monster}}}는 {yellow:{{player}}}를 도륙내었습니다..."
back_to_lobby, "{grey:로비로 복귀하려면 엔터를 눌러주세요.}"
get_elixir, "{green:축하드립니다!} {yellow:전설의 비약({{percent}}%)}을 획득 하였습니다. 사용시 전체 체력의 {{percent}}%만큼 회복하며, 거절시 임의의 버프를 얻습니다."
use_question, "사용 하시겠습니까? (네) : "
used_elixir, "{yellow:전설의 비약({{percent}}%)}을 사용하여 체력을 {yellow:{{recovery_hp}}}만큼 회복하여 체력이 {{current_hp}}가 되었습니다.";
sell_elixir, "{yellow:전설의 비약({{percent}}%)}을 {grey:흡수}하여 이로운 효과를 얻었습니다.:"
buff, "{blue:{{stat_name}}}의 수치가 {cyan:{{value}}}만큼 증가했습니다."
heal, "체력이 {{value}}만큼 회복 되었습니다. | {grey:{{current_hp}}}"
any_key, "{grey: 계속하려면 아무키나 눌러주세요. )}"
question_action, "당신의 선택은?) "
clear_all_stage, "{redBright:  _______ _                 _______             _           _    _             _  }\n{redBright: |__   __(_)               |__   __|           | |         | |  | |           | | }\n{redBright:    | |   _  __ _ _ __ ___     | | ___  __ _  __| | ___ _ __| |__| | __ _ _ __ | | }\n{redBright:    | |  | |/ _` | '__/ _ \    | |/ _ \/ _` |/ _` |/ _ \ '__|  __  |/ _` | '_ \| | }\n{redBright:    | |  | | (_| | | |  __/    | |  __/ (_| | (_| |  __/ |  | |  | | (_| | | | |_| }\n{redBright:    |_|  |_|\__,_|_|  \___|    |_|\___|\__,_|\__,_|\___|_|  |_|  |_|\__,_|_| |_(_) }\n\n\n}\n{yellowBright:용사여, 그대는 마침내 {{boss_monster_name}}를 무찌르고}\n{yellowBright:어둠의 세력을 물리쳤습니다!}\n\n{greenBright:전설 속 영웅이여, 그대의 이름은 영원히 기억될 것입니다!!}"
select_option, "{magentaBright:==================================================}\n{green:변경하길 원하는 옵션을 선택해주세요.}\n\n{blue:{{option_list}}}\n==================================================\n{grey:직접 메뉴를 입력해주세요.}"
option_player_name, "닉네임 변경"
option_monster_name, "등장 몬스터 변경"
option_boss_name, "보스 이름 변경"
option_exit, "확인"
default_player_name, "플레이어"
default_boss_name, "마왕"
option_change_player_name, "변경할 닉네임을 입력해주세요. ({grey:{{default_name}}}) : "
option_changed_player_name, "변경된 닉네임은 {yellow:{{new_name}}}입니다."
option_monster_list, "{magentaBright:===================몬스터 종류=======================}\n{red:{{monster_list}}}\n{magentaBright:==================================================}\n{green:원하는 옵션을 선택해주세요.}\n\n{grey:{{monster_options}}}"
option_monster_add, "몬스터 추가"
option_monster_remove, "몬스터 삭제"
option_monster_change, "몬스터 변경"
option_monster_add_question, "추가할 몬스터 이름을 입력해주세요.) "
option_monster_add_result, "{red:{{new_name}}} 몬스터가 추가 되었습니다."
option_monster_remove_question, "{red:{{monster_list}}}\n\n삭제할 몬스터 이름을 입력해주세요. )"
option_monster_remove_result, "{red:{{monster_name}}}이 정상적으로 삭제되었습니다."
option_monster_change_question, "{red:{{monster_list}}}\n\n변경할 몬스터 이름을 입력해주세요. )"
option_monster_change_question2, "{red:{{prev_name}}}의 {grey:변경할 이름}) "
option_monster_change_result, "{red:{{prev_name}}} 몬스터는 이제부터 {red:{{new_name}}}로 불립니다."
option_boss_name_question, "지금 마왕의 이름은 {red:{{prev_name}}}입니다.\n 새로이 부를 이름을 입력해주세요. ({grey:{{default_name}}}) : "
option_boss_name_result, "지금부터 마왕은 {red:{{new_name}}}입니다."
lobby_game_start, "시작하기"
lobby_achievements, "업적보기"
lobby_option, "옵션설정"
lobby_exit, "게임종료"
achievements_view_start, "{magentaBright:=====================업적=========================}"
achievements_view_end, "{magentaBright:==================================================}"
achievements_monster, "몬스터 전투 기록 : "
achievements_monster_count, "ㄴ{{name}} : {cyan:{{killed}}}/{red:{{encounter}}}"
achievements_collect_elixir, "전설의 비약 : {{count}}개"
achievements_start_count, "게임 시작 : {{count}}회"
achievements_total_dmg_dealt, "준 데미지 : {{total}}"
achievements_total_dmg_taken, "받은 데미지 : {{total}}"
achievements_total_heal, "회복량 : {{total}}"
achievements_victory_count, "마왕에게서 승리 : {{count}}회"
achievements_lose_count, "패배 : {{count}}회"

Command.js

명령어를 저장할 수 있도록 도와주는 객체입니다.
여기서는 사용자의 입력을 처리할 것이므로 키로 문자열을 값으로 콜백을 보관하고 있다가 결과를 전달합니다.

/**
 * 명령어와 그에 따른 콜백을 관리하는 클래스입니다.
 */
class Command {
  #commands = null;

  constructor() {
    this.#commands = new Map();
  }

  /** @returns {Map} 명령어와 콜백이 저장된 맵 */
  get commands() {
    return this.#commands;
  }

  /** @returns {Iterator} 명령어의 키들 */
  get keys() {
    return this.#commands.keys();
  }

  /** @returns {Iterator} 콜백 함수들 */
  get callbacks() {
    return this.#commands.values();
  }

  /**
   * 명령어를 추가합니다.
   * @param {string} key 명령어 키
   * @param {Function} callback 명령어 실행 시 호출될 콜백 함수
   * @returns {boolean} 추가 성공 여부
   * @throws {Error} 유효하지 않은 키나 콜백 함수일 경우 예외를 발생시킵니다.
   */
  AddCommand(key, callback) {
    if (typeof key !== 'string' || key.trim() === '') {
      throw new Error('Key must be a non-empty string');
    }
    if (typeof callback !== 'function') {
      throw new Error('Callback must be a function');
    }
    this.#commands.set(key, callback);
    return true;
  }

  /**
   * 명령어를 제거합니다.
   * @param {string} key 제거할 명령어 키
   * @returns {boolean} 제거 성공 여부
   */
  RemoveCommand(key) {
    if (!this.#commands.has(key)) {
      return false;
    }
    this.#commands.delete(key);
    return true;
  }

  /**
   * 특정 인덱스에 명령어를 삽입합니다.
   * @param {string} key 명령어 키
   * @param {Function} callback 명령어 실행 시 호출될 콜백 함수
   * @param {number} index 삽입할 인덱스 위치
   * @returns {boolean} 삽입 성공 여부
   * @throws {Error} 유효하지 않은 키, 콜백 함수, 또는 인덱스일 경우 예외를 발생시킵니다.
   */
  InsertCommandAt(key, callback, index) {
    if (typeof key !== 'string' || key.trim() === '') {
      throw new Error('Key must be a non-empty string');
    }
    if (typeof callback !== 'function') {
      throw new Error('Callback must be a function');
    }

    const keys = Array.from(this.#commands.keys());
    const values = Array.from(this.#commands.values());

    if (index < 0) {
      index = keys.length + index; // 끝에서 n번째
    }

    if (index < 0 || index > keys.length) {
      throw new Error('Index out of bounds');
    }

    keys.splice(index, 0, key);
    values.splice(index, 0, callback);

    this.#commands = new Map(keys.map((k, i) => [k, values[i]]));
    return true;
  }

  /**
   * 주어진 키의 명령어를 실행합니다.
   * @param {string} key 실행할 명령어 키
   * @param {...any} args 콜백 함수에 전달할 인수들
   * @returns {Promise<*>} 콜백 함수의 반환 값
   * @throws {Error} 명령어 실행 중 발생한 예외를 전달합니다.
   */
  async ExecuteCommand(key, ...args) {
    const command = this.#commands.get(key);
    if (command) {
      try {
        return await command(...args);
      } catch (error) {
        throw new Error(`Error executing command "${key}":`, error);
      }
    }
    return false;
  }
}

export default Command;

Option.js

옵션을 설정할 수 있는 시나리오 스크립트입니다.
여기서도 명령어를 활용하여 입력을 처리합니다.

import Command from "../lib/Command.js";
import TextTable from "../lib/TextTable.js";
import Input from "../lib/Input.js";
import Utils from "../lib/Utils.js";
import {Settings, Save} from "../lib/Settings.js";

let selectOptions = null;
let selectOptionText = '';
let continued  = true;

let changePlayerNameText = '';
let defaultPlayerName = '';

let monsterChangeOptions = null;
let monsterChangeOptionListText = '';

let defaultBossName = '';

/**
 * 옵션 메뉴를 화면에 출력하는 함수입니다.
 */
function displayOption(){
    console.clear();
    console.log(selectOptionText);
}

/**
 * 플레이어 이름을 변경하는 함수입니다.
 */
async function changePlayerName() {
    let newName = await Input.question(changePlayerNameText);
    if(newName == ''){
        newName = defaultPlayerName;
    }
    Settings.player_name = newName;
    TextTable.Output('option_changed_player_name', {new_name : newName});
}

/**
 * 보스 이름을 변경하는 함수입니다.
 */
async function changeBossName(){
    const prev_name = Settings.boss_monster_name;
    let newName = await Input.question(TextTable.FormatText('option_boss_name_question', {prev_name: Settings.boss_monster_name, default_name: defaultBossName}));
    if(newName == ''){
        newName = defaultBossName;
    }
    Settings.boss_monster_name = newName;
    TextTable.Output('option_changed_player_name', {new_name : newName, prev_name});
}

/**
 * 새로운 몬스터를 추가하는 함수입니다.
 */
async function addMonster() {
    const text = TextTable.FormatText('option_monster_add_question');
    const new_name = await Input.question(text);

    if(new_name){
        Settings.normal_monster_names.push(new_name);
        TextTable.Output('option_monster_add_result', {new_name})
    }else{
        TextTable.Output('wrong_select');
    }
}

/**
 * 몬스터를 제거하는 함수입니다.
 */
async function removeMonster() {
    console.clear();
    const text = TextTable.FormatText('option_monster_remove_question', {monster_list : Settings.normal_monster_names.join("\n")});
    const name = await Input.question(text);
    let index = Settings.normal_monster_names.findIndex(monsterName => monsterName === name);

    if(index === -1){
        TextTable.Output('wrong_select');
    }else{
        Settings.normal_monster_names.splice(index, 1);
        TextTable.Output('option_monster_remove_result', {monster_name: name});
    }
}

/**
 * 몬스터의 이름을 변경하는 함수입니다.
 */
async function changeMonster() {
    console.clear();
    let text = TextTable.FormatText('option_monster_change_question', {monster_list : Settings.normal_monster_names.join("\n")});
    let name = await Input.question(text);
    let index = Settings.normal_monster_names.findIndex(monsterName => monsterName === name);

    if(index === -1){
        TextTable.Output('wrong_select');
    }else{
        const prev_name = Settings.normal_monster_names[index];
        text = TextTable.FormatText('option_monster_change_question2', {prev_name});
        name = await Input.question(text);
        if(name){
            Settings.normal_monster_names[index] = name;
            TextTable.Output('option_monster_change_result', {prev_name, new_name:name});
        }else{
            TextTable.Output('wrong_select');
        }
    }
}

/**
 * 몬스터와 관련된 옵션을 변경하는 함수입니다.
 */
async function changeMonsters() {
    console.clear();
    TextTable.Output('option_monster_list', {monster_list: Settings.normal_monster_names.join("\n"), monster_options : monsterChangeOptionListText});
    
    const text = TextTable.FormatText('input');
    const choice = await Input.question(text);
    if(await monsterChangeOptions.ExecuteCommand(choice) !== false){

    }else{
        TextTable.Output('wrong_select');
    }
}

/**
 * 옵션 메뉴 명령어를 설정하는 함수입니다.
 */
function setCommands(){
    if(selectOptions === null){
        selectOptions = new  Command();
        selectOptions.AddCommand(TextTable.FormatText( 'option_player_name'), changePlayerName);
        selectOptions.AddCommand(TextTable.FormatText( 'option_monster_name'), changeMonsters);
        selectOptions.AddCommand(TextTable.FormatText( 'option_boss_name'), changeBossName);
        selectOptions.AddCommand(TextTable.FormatText('option_exit'), async () => { try{ await Save(); continued = false; } catch(error){console.error(error); throw error;}})
        selectOptionText =  TextTable.FormatText('select_option', {option_list: Array.from( selectOptions.keys).join('\n')});
    
        defaultPlayerName =  TextTable.FormatText('default_player_name');
        changePlayerNameText = TextTable.FormatText('option_change_player_name', {default_name : defaultPlayerName});
    
        defaultBossName = TextTable.FormatText('default_boss_name');
    }
    
    if(monsterChangeOptions === null){
        monsterChangeOptions = new  Command();
        monsterChangeOptions.AddCommand(TextTable.FormatText('option_monster_add'), addMonster);
        monsterChangeOptions.AddCommand(TextTable.FormatText('option_monster_remove'), removeMonster);
        monsterChangeOptions.AddCommand(TextTable.FormatText('option_monster_change'), changeMonster);
        monsterChangeOptionListText = Array.from(monsterChangeOptions.keys).join("\n");    
    }
}

/**
 * 옵션 메뉴를 시작하는 함수입니다.
 */
async function Start() {
    continued = true;
    try{
        setCommands();
        while(continued){
            displayOption();
            const text = TextTable.FormatText('input');
            const choice = await Input.question(text);
            if(await selectOptions.ExecuteCommand(choice) !== false){
    
            }else{
                TextTable.Output('wrong_select')
            }
            await Utils.Delay(1000);
        }
    }catch(error){
        console.error(error);
        await Input.question('');
    }
}

export default Start;

Input.js

static 클래스로 사용자의 입력을 처리할 수 있도록 구현한 클래스입니다.
무겁지 않으며 자주 사용하는 기능이므로 static으로 처리하였습니다.

import readline from 'readline';

let createdReads = {};

/**
 * 콘솔 입력을 관리하는 클래스입니다.
 * 인스턴스를 생성할 수 없으며, 모든 메서드는 정적 메서드로 제공됩니다.
 */
class Input {
  constructor() {
    throw new Error('This class cannot be instantiated.');
  }

  /**
   * 사용자에게 질문하고 입력을 받습니다.
   * @param {string} query 질문 내용
   * @returns {Promise<string>} 사용자의 입력 값
   */
  static async question(query) {
    let text = await this._askQuestion(query);
    return text;
  }

  /**
   * 사용자에게 질문하고 정수를 입력받습니다.
   * @param {string} query 질문 내용
   * @returns {Promise<number>} 입력된 정수
   * @throws {Error} 입력 값이 유효한 정수가 아닐 경우 예외를 발생시킵니다.
   */
  static async questionInt(query) {
    const answer = await this._askQuestion(query);
    const intAnswer = parseInt(answer, 10);
    if (isNaN(intAnswer)) {
      throw new Error('Input is not a valid integer.');
    }
    return intAnswer;
  }

  /**
   * 사용자에게 질문하고 실수를 입력받습니다.
   * @param {string} query 질문 내용
   * @returns {Promise<number>} 입력된 실수
   * @throws {Error} 입력 값이 유효한 실수가 아닐 경우 예외를 발생시킵니다.
   */
  static async questionFloat(query) {
    const answer = await this._askQuestion(query);
    const floatAnswer = parseFloat(answer);
    if (isNaN(floatAnswer)) {
      throw new Error('Input is not a valid float.');
    }
    return floatAnswer;
  }

  /**
   * 사용자에게 예/아니오 질문을 하고 대답을 받습니다.
   * @param {string} query 질문 내용
   * @returns {Promise<boolean>} 'Y' 또는 '네'의 경우 true, 그 외의 경우 false
   */
  static async keyInYN(query) {
    const answer = (await this._askQuestion(query)).trim(); // 입력값의 공백을 제거
    return answer === '' || answer.toLowerCase() === '네' || answer.toLowerCase() === 'y';
  }

  /**
   * 사용자에게 엄격한 예/아니오 질문을 하고 대답을 받습니다.
   * @param {string} query 질문 내용
   * @returns {Promise<boolean>} 'Y' 또는 'N'으로 입력된 경우, 그에 맞는 boolean 값
   * @throws {Error} 입력 값이 'Y' 또는 'N'이 아닐 경우 예외를 발생시킵니다.
   */
  static async keyInYNStrict(query) {
    const answer = await this._askQuestion(query);
    if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'n') {
      return answer.toLowerCase() === 'y';
    } else {
      throw new Error('Input must be Y or N.');
    }
  }

  /**
   * 주어진 항목들 중에서 선택지를 받아옵니다.
   * @param {Array<string>} items 선택지 목록
   * @param {string} query 질문 내용
   * @returns {Promise<number>} 선택된 항목의 인덱스 (0부터 시작), 유효하지 않은 경우 -1 반환
   */
  static async keyInSelect(items, query) {
    const answer = await this._askQuestion(query + items.join(', ') + ': ');
    const index = parseInt(answer, 10);
    if (index >= 0 && index < items.length) {
      return index;
    }
    return -1;
  }

  /**
   * 내부적으로 질문을 처리하는 함수입니다.
   * @param {string} query 질문 내용
   * @returns {Promise<string>} 사용자의 입력 값
   * @private
   */
  static _askQuestion(query) {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
      terminal: true 
    });

    createdReads[query] = rl;
    return new Promise((resolve) => {
      rl.question(query, (answer) => {
      delete  createdReads[query];
        rl.close();
        resolve(answer);
      });
    });
  }
}

process.on('exit', () => {
  AllClose();
});

process.on('SIGINT', () => {
  AllClose();
  process.exit();
});

/**
 * 열린 모든 readline 인터페이스를 닫습니다.
 * @private
 */
function AllClose(){
  for(const key in createdReads){
    createdReads[key].close();
  }
  createdReads = {};
}

export default Input;

Achievements.js

업적을 기록하는 객체입니다.
이 인스턴스를 통해서 런타임 중 업적에 접근할 수도 있으며, 게임 실행시 업적을 불러오고 업적을 저장하는 기능도 구현할 수 있습니다.

import Singleton from './Singleton.js';
import eventBus from './eventBus.js';
import path from 'path';
import fs from 'fs';

/**
 * 게임 내에서의 업적을 관리하는 클래스입니다.
 * 싱글톤 패턴을 사용하여 하나의 인스턴스만 존재합니다.
 */
class Achievements extends Singleton {
  #achievements;
  #jsonPath = '';

  constructor() {
    super();

    if (this.initialized) {
      return this;
    }

    this.#jsonPath = path.join(process.cwd(), './resources/Achievements.json');
    if (fs.existsSync(this.#jsonPath)) {
      const json = fs.readFileSync(this.#jsonPath, 'utf8');
      if (json !== '') {
        this.#achievements = JSON.parse(json);
      }
    } else {
      this.#achievements = {
        kill_monster: {},
        encounter_monster: {}, // 몬스터 조우 업적
        collect_elixir: 0,
        start_count: 0,
        total_dmg_dealt: 0,
        total_dmg_taken: 0,
        total_heal: 0,
        victory_count: 0,
        lose_count: 0,
      };
    }

    eventBus.on('monsterKilled', this.#incrementKillMonsterAchievement.bind(this));
    eventBus.on('monsterEncountered', this.#incrementEncounterMonsterAchievement.bind(this)); // 몬스터 조우 이벤트
    eventBus.on('achievementsSave', this.Save.bind(this));
    for (const key in this.#achievements) {
      if (this.#achievements.hasOwnProperty(key) && this.#achievements[key] === 0) {
        eventBus.on(key, this.#incrementAchievement.bind(this, key));
      }
    }

    this.initialized = true;
  }

  /** @returns {Object} 현재 업적 정보 */
  get achievements() {
    return this.#achievements;
  }

  /**
   * 몬스터 처치 업적을 증가시킵니다.
   * @param {string} monsterType 몬스터 종류
   */
  #incrementKillMonsterAchievement(monsterType) {
    if (!this.#achievements.kill_monster[monsterType]) {
      this.#achievements.kill_monster[monsterType] = 0;
    }
    this.#achievements.kill_monster[monsterType]++;
  }

  /**
   * 몬스터 조우 업적을 증가시킵니다.
   * @param {string} monsterType 몬스터 종류
   */
  #incrementEncounterMonsterAchievement(monsterType) {
    if (!this.#achievements.encounter_monster[monsterType]) {
      this.#achievements.encounter_monster[monsterType] = 0;
    }
    this.#achievements.encounter_monster[monsterType]++;
  }

  /**
   * 특정 업적을 증가시킵니다.
   * @param {string} achievementName 업적 이름
   * @param {number} [incrementBy=1] 증가시킬 값
   */
  #incrementAchievement(achievementName, incrementBy = 1) {
    if (this.#achievements[achievementName] !== undefined) {
      this.#achievements[achievementName] += incrementBy;
    } else {
      throw new Error(`Achievement ${achievementName} does not exist.`);
    }
  }

  /**
   * 업적 정보를 저장합니다.
   * @returns {Promise<void>}
   */
  async Save() {
    const data = JSON.stringify(this.#achievements, null, 2);
    await fs.writeFile(this.#jsonPath, data, 'utf8', (error) => {
      if (error) throw error;
    });
  }
}

const achievements = new Achievements();
export default achievements;

EventBus.js

게임에서 이벤트를 처리할 수 있는 허브와 같은 개념입니다.
EventEmitter 인스턴스를 단 한개만 만들고 이를 EventBus 모듈을 가져다 씀으로서 모든 곳에서 하나의 EventEmitter를 통해 소통할 수 있도록 처리한 것 입니다.

import EventEmitter from 'events';

/**
 * 이벤트를 발행하고 구독하는 이벤트 버스입니다.
 * 이를 통해 모듈 간에 이벤트를 전달할 수 있습니다.
 */
const eventBus = new EventEmitter();

export default eventBus;

Game.js

게임을 진행하는 시나리오 스크립트입니다.
스테이지를 진행하며, 전투를 통해 승리 보상, 엘릭서 획득, 패배 이벤트 등을 처리할 수 있으며 업적을 기록할 수 있도록 합니다.
위에서 언급한 업적과 이벤트버스를 활용하여 업적 인스턴스에는 접근하지 않고 기록을 요청하기만 합니다.

import Unit from '../unit/Unit.js';
import * as Actions from '../unit/Action.js';
import Input from '../lib/Input.js';
import Stats from '../unit/Stats.js';
import MyMath from '../lib/MyMath.js';
import TextTable from '../lib/TextTable.js';
import Command from '../lib/Command.js';
import Utils from '../lib/Utils.js';
import Settings from '../lib/Settings.js';
import EventBus from '../lib/eventBus.js';
const commands = new Command();

const achievements = {
  /**
   * 몬스터가 처치될 때 호출됩니다.
   * @param {string} monsterType 몬스터의 종류
   */
  monsterKilled: (monsterType) => {
    EventBus.emit('monsterKilled', monsterType);
  },

  /**
   * 몬스터를 조우할 때 호출됩니다.
   * @param {string} monsterType 몬스터의 종류
   */
  monsterEncounterd: (monsterType) => {
    EventBus.emit('monsterEncountered', monsterType);
  },

  /**
   * 보스에게서 승리했을 때 호출됩니다.
   */
  victory: () => {
    EventBus.emit('victory_count');
  },

  /**
   * 게임 시작 시 호출됩니다.
   */
  gameStart: () => {
    EventBus.emit('start_count');
  },

  /**
   * 데미지를 가했을 때 호출됩니다.
   * @param {number} dmg 가한 데미지 양
   */
  dmgDealt: (dmg) => {
    EventBus.emit('total_dmg_dealt', dmg);
  },

  /**
   * 데미지를 받았을 때 호출됩니다.
   * @param {number} dmg 받은 데미지 양
   */
  dmgTaken: (dmg) => {
    EventBus.emit('total_dmg_taken', dmg);
  },

  /**
   * 치유를 받았을 때 호출됩니다.
   * @param {number} heal 치유량
   */
  healTaken: (heal) => {
    EventBus.emit('total_heal', heal);
  },

  /**
   * 패배했을 때 호출됩니다.
   */
  lose: () => {
    EventBus.emit('lose_count');
  },

  /**
   * 엘릭서를 수집했을 때 호출됩니다.
   */
  elixirCollected: () => {
    EventBus.emit('collect_elixir');
  },

  /**
   * 업적을 저장할 때 호출됩니다.
   */
  save: () => {
    EventBus.emit('achievementsSave');
  },
};

/**
 * 현재 스테이지, 플레이어, 몬스터의 상태를 출력합니다.
 * @param {number} stage 현재 스테이지 번호
 * @param {Unit} player 플레이어 유닛
 * @param {Unit} monster 몬스터 유닛
 */
function displayStatus(stage, player, monster) {
  console.clear();
  let text = TextTable.FormatText('battle_stage_info', {
    stage,
    player_name: player.name,
    player_current_hp: player.stats.current_hp,
    player_max_hp: player.stats.max_hp,
    player_atk_min: player.stats.atk_range.min_atk,
    player_atk_max: player.stats.atk_range.max_atk,
    player_defense_rating: (player.stats.defense_rating * 100.0).toFixed(2),
    player_luck: (player.stats.luck * 100.0).toFixed(2),
    monster_name: monster.name,
    monster_current_hp: monster.stats.current_hp,
    monster_max_hp: monster.stats.max_hp,
    monster_atk_min: monster.stats.atk_range.min_atk,
    monster_atk_max: monster.stats.atk_range.max_atk,
    monster_def: (monster.stats.defense_rating * 100.0).toFixed(2),
    monster_luck: (monster.stats.luck * 100.0).toFixed(2),
  });

  console.log(TextTable.FormatTextForConsole(text));
}

/**
 * 몬스터가 행동을 수행합니다.
 * @param {Unit} player 플레이어 유닛
 * @param {Unit} monster 몬스터 유닛
 * @returns {Array<string>} 몬스터 행동 결과
 */
const monsterAction = (player, monster) => {
  const playerHp = player.stats.current_hp;
  const monsterHp = monster.stats.current_hp;
  const monsterMaxAtk = monster.stats.atk_range.max_atk;
  const playerDefense = player.stats.defense_rating;

  let actionResult = null;

  if (playerHp <= monsterHp) {
    if (playerHp <= monsterMaxAtk) {
      if (playerDefense < 0.2) {
        actionResult = monster.actions[0].DoAction(monster, player);
      } else {
        actionResult = monster.actions[2].DoAction(monster, player);
      }
    } else {
      if (monsterHp - playerHp >= monsterMaxAtk) {
        if (Math.random() < 0.8) {
          actionResult = monster.actions[1].DoAction(monster, player);
        } else {
          actionResult = monster.actions[2].DoAction(monster, player);
        }
      } else {
        if (Math.random() < 0.6) {
          actionResult = monster.actions[0].DoAction(monster, player);
        } else {
          actionResult = monster.actions[2].DoAction(monster, player);
        }
      }
    }
  } else {
    if (monsterHp <= monster.stats.max_hp * 0.2) {
      if (Math.random() < 0.5) {
        actionResult = monster.actions[0].DoAction(monster, player);
      } else {
        actionResult = monster.actions[1].DoAction(monster, player);
      }
    } else if (playerHp <= monsterMaxAtk) {
      if (Math.random() < 0.7) {
        actionResult = monster.actions[1].DoAction(monster, player);
      } else {
        actionResult = monster.actions[0].DoAction(monster, player);
      }
    } else {
      if (playerDefense < 0.3) {
        actionResult = monster.actions[0].DoAction(monster, player);
      } else {
        actionResult = monster.actions[1].DoAction(monster, player);
      }
    }
  }

  const dmgTaken = playerHp - player.stats.current_hp;
  if (dmgTaken !== 0) {
    achievements.dmgTaken(dmgTaken);
  }

  return actionResult;
};

/**
 * 전투를 수행하는 함수입니다.
 * @param {number} stage 현재 스테이지 번호
 * @param {Unit} player 플레이어 유닛
 * @param {Unit} monster 몬스터 유닛
 */
const battle = async (stage, player, monster) => {
  let logs = [];
  let player_actions_text = player.actions
    .map((action) => {
      const probabilityWithLuck = (
        100.0 -
        MyMath.Clamp(action.probability - player.stats.luck, 0.0, 1.0) * 100.0
      ).toFixed(2);
      return `${TextTable.FormatText(action.name)}(${probabilityWithLuck}%)`;
    })
    .join(', ');
  player_actions_text = TextTable.FormatText('action_info', { actions: player_actions_text });

  while (true) {
    displayStatus(stage, player, monster);
    console.log(logs.slice(11 - process.stdout.rows).join('\n'));
    console.log(player_actions_text);
    const choice = await Input.question(TextTable.FormatText('question_action'));

    const prev_monster_hp = monster.stats.current_hp;
    const prev_player_hp = player.stats.current_hp;

    const command_result = await commands.ExecuteCommand(choice, player, monster);
    if (command_result !== false) {
      logs.push(...command_result);
      command_result.forEach((desc) => {
        console.log(desc);
      });

      let delta = player.stats.current_hp - prev_player_hp;
      if (delta > 0) {
        achievements.healTaken(delta);
      }

      delta = prev_monster_hp - monster.stats.current_hp;
      if (delta !== 0) {
        achievements.dmgDealt(delta);
      }

      await Utils.Delay(500);
      if (monster.stats.current_hp <= 0) {
        break;
      }
      const monster_result = monsterAction(player, monster);
      logs.push(...monster_result);
      monster_result.forEach((desc) => {
        console.log(desc);
      });
      if (player.stats.current_hp <= 0) {
        break;
      }
    } else {
      TextTable.Output('wrong_select');
    }
    await Utils.Delay(500);
  }
};

/**
 * 엘릭서 시나리오를 처리하는 함수입니다.
 * @param {Unit} player 플레이어 유닛
 */
const elixirScenario = async (player) => {
  if (MyMath.CalcProbability(0.9)) {
    achievements.elixirCollected();
    let percent = MyMath.RandomRangeInt(10, 31) * 1.0;
    const org_percent = percent;
    TextTable.Output('get_elixir', { percent });

    if (await Input.keyInYN(TextTable.FormatText('use_question'))) {
      const recovery_hp = MyMath.Floor(player.stats.max_hp * percent * 0.01);
      player.stats.modifyCurrentHP(recovery_hp);
      TextTable.Output('used_elixir', {
        percent: org_percent,
        recovery_hp,
        current_hp: player.stats.current_hp,
      });
    } else {
      const statsToUpgrade = ['max_hp', 'default_atk', 'atk_rating', 'defense_rating', 'luck'];
      const upgrades = {};

      statsToUpgrade.forEach((stat, index) => {
        if (index === statsToUpgrade.length - 1) {
          upgrades[stat] = percent;
        } else {
          const allocation = MyMath.RandomRangeInt(0, percent + 1);
          upgrades[stat] = allocation;
          percent -= allocation;
        }
      });

      let changes = {};

      const maxHpChange = MyMath.Floor(player.stats.max_hp * (upgrades['max_hp'] / 100.0));
      if (maxHpChange !== 0) {
        player.stats.modifyMaxHP(maxHpChange);
        player.stats.modifyCurrentHP(maxHpChange);
        changes['HP'] = maxHpChange;
      }

      const { _, max_atk } = player.stats.atk_range;
      const delta = upgrades['default_atk'] % 10;
      upgrades['atk_rating'] += MyMath.Floor(upgrades['default_atk'] * 0.1);
      if (delta !== 0) {
        player.stats.modifyDefaultAtk(delta);
        changes['MIN_ATK'] = delta.toString();
      }

      const atkRatingChange = player.stats.atk_rating * (upgrades['atk_rating'] / 100.0);
      player.stats.modifyAtkRating(atkRatingChange);
      let stat_delta = player.stats.atk_range.max_atk - max_atk;
      if (stat_delta !== 0) {
        changes['MAX_ATK'] = stat_delta.toString();
      }

      if (upgrades['defense_rating'] !== 0) {
        const defenseRatingChange = upgrades['defense_rating'] / 100.0;
        player.stats.modifyDefenseRating(defenseRatingChange);
        changes['DEF'] = defenseRatingChange.toFixed(2);
      }

      if (upgrades['luck'] !== 0) {
        const luckChange = upgrades['luck'] / 100.0;
        player.stats.modifyLuck(luckChange);
        changes['LUCK'] = luckChange.toFixed(2);
      }

      TextTable.Output('sell_elixir', { percent: org_percent });
      for (const [key, value] of Object.entries(changes)) {
        TextTable.Output('buff', {
          stat_name: key,
          value: `${key}(${value === '' ? '' : `+${value}`})`,
        });
        await Utils.Delay(500);
      }
    }
    await Input.question(TextTable.FormatText('any_key'));
  }
};

/**
 * 승리 시나리오를 처리하는 함수입니다.
 * @param {Unit} player 플레이어 유닛
 * @param {number} stage 현재 스테이지 번호
 */
const victoryScenario = async (player, stage) => {
  const maxHpIncrease = MyMath.Floor(player.stats.max_hp * MyMath.RandomRange(0.01, 0.1) * stage);
  player.stats.modifyMaxHP(maxHpIncrease);
  TextTable.Output('buff', { stat_name: 'MAX_HP', value: maxHpIncrease });
  await Utils.Delay(500);
  let statIncrease = 0;
  let statName = '';
  for (let i = 0; i < 4; i++) {
    if (MyMath.CalcProbability(0.55)) {
      switch (i) {
        case 0:
          statIncrease = Math.round(
            player.stats.default_atk * stage * MyMath.RandomRange(0.1, 0.2),
          );
          statName = 'MIN_ATK';
          player.stats.modifyDefaultAtk(statIncrease);
          break;
        case 1:
          statIncrease = stage * MyMath.RandomRange(0.01, 0.1);
          const tmp = player.stats.atk_range.max_atk;
          statName = 'MAX_ATK';
          player.stats.modifyAtkRating(statIncrease);
          statIncrease = player.stats.atk_range.max_atk - tmp;
          if (statIncrease === 0) {
            continue;
          }
          break;
        case 2:
          statIncrease = MyMath.RandomRange(0.01, 0.02);
          statName = 'DEF';
          player.stats.modifyDefenseRating(statIncrease);
          statIncrease = statIncrease.toFixed(2);
          break;
        case 3:
          statIncrease = MyMath.RandomRange(0.01, 0.015);
          statName = 'LUCK';
          player.stats.modifyLuck(statIncrease);
          statIncrease = statIncrease.toFixed(2);
          break;
      }

      TextTable.Output('buff', { stat_name: statName, value: statIncrease });

      await Utils.Delay(500);
    }
  }
  const currentHpIncrease = MyMath.Floor((player.stats.max_hp - player.stats.current_hp) * 0.5);
  player.stats.modifyCurrentHP(currentHpIncrease);
  TextTable.Output('heal', { value: currentHpIncrease, current_hp: player.stats.current_hp });
  await Utils.Delay(500);

  await Input.question(TextTable.FormatText('any_key'));
};

/**
 * 기본 플레이어 스탯을 생성하는 함수입니다.
 * @returns {Stats} 생성된 플레이어 스탯
 */
function CreatePlayerDefaultStats() {
  const max_hp = MyMath.RandomRangeInt(100, 121);
  const default_atk = MyMath.RandomRangeInt(5, 10);
  const atk_rating = MyMath.RandomRange(0.4, 0.6);
  const defense_rating = MyMath.RandomRange(0.0, 0.011);
  const luck = 0.0;
  const stats = new Stats(max_hp, default_atk, atk_rating, defense_rating, luck);
  return stats;
}

/**
 * 몬스터의 스탯을 생성하는 함수입니다.
 * @param {number} stage 현재 스테이지 번호
 * @returns {Stats} 생성된 몬스터 스탯
 */
function CreateMonsterStats(stage) {
  const max_hp = MyMath.RandomRangeInt(21 + 10 * stage, 20 + 15 * stage);
  const default_atk = MyMath.RandomRangeInt(stage * 3, stage * 3 + stage * 2);
  const atk_rating = MyMath.RandomRange(0.5, 0.9 + stage * 0.1);
  const defense_rating = MyMath.RandomRange(stage, stage * stage) * 0.01;
  const luck = MyMath.RandomRange(stage - 1.0, stage + 1.0) * 0.01;
  const stats = new Stats(max_hp, default_atk, atk_rating, defense_rating, luck);
  return stats;
}

/**
 * 게임을 시작하는 함수입니다.
 */
export async function startGame() {
  achievements.gameStart();

  let stage = 1;
  const last_stage = 10;
  const player = new Unit(Settings.player_name, CreatePlayerDefaultStats());
  player.InsertAction(new Actions.GamblingAction());
  player.actions.forEach((action) => {
    commands.AddCommand(TextTable.FormatText(action.name), action.DoAction);
  });

  while (stage <= last_stage) {
    console.clear();
    let monster_name = '';
    if (stage === last_stage) {
      monster_name = Settings.boss_monster_name;
    } else {
      monster_name = MyMath.RandomPick(Settings.normal_monster_names);
    }
    achievements.monsterEncounterd(monster_name);
    const monster = new Unit(monster_name, CreateMonsterStats(stage));
    TextTable.Output('encounter', { name: monster_name });
    await Utils.Delay(750);
    await battle(stage, player, monster);

    const victory = player.stats.current_hp > 0;
    displayStatus(stage, player, monster);
    TextTable.Output(victory ? 'victory' : 'lose', {
      player: player.name,
      monster: monster.name,
    });
    if (victory) {
      achievements.monsterKilled(monster_name);
      if (stage !== last_stage) {
        await victoryScenario(player, stage);
        displayStatus(stage, player, monster);
        await elixirScenario(player);
      } else {
        achievements.victory();
        TextTable.Output('clear_all_stage', { boss_monster_name: Settings.boss_monster_name });
        await Utils.Delay(1000);
      }
    } else {
      achievements.lose();
      stage = last_stage;
    }
    achievements.save();
    stage++;
  }

  await Input.question(TextTable.FormatText('back_to_lobby'));
}

Unit.js

플레이어와 몬스터를 구현해둔 객체입니다.
간단하게 이름, 스탯, 행동 목록으로 구성되어 있으며 외부 요청에따라 처리만 합니다.
Game.js의 startGame()을 보시면 플레이어 및 몬스터를 Unit을 통해 생성후 처리하고 있음을 볼 수 있습니다.

import Stats from "./Stats.js";
import * as Actions from './Action.js';

/**
 * 게임 내 유닛을 나타내는 클래스입니다.
 * 각 유닛은 이름, 스탯, 그리고 행동 리스트를 가집니다.
 */
class Unit {
    #name = '';
    #stats = null;
    #actions = [];

    /**
     * Unit 클래스의 생성자입니다.
     * @param {string} name 유닛의 이름
     * @param {Stats} stats 유닛의 스탯
     */
    constructor(name = 'UNKNOWN', stats = null){
        this.#name = name;
        this.#stats = (stats === null ? new Stats() : stats);
        this.#actions = [new Actions.AttackAction(), new Actions.DoubleAttackAction(), new Actions.TryHealAction()];
    }

    /** @returns {string} 유닛의 이름 */
    get name(){
        return this.#name;
    }

    /** @returns {Stats} 유닛의 스탯 */
    get stats(){
        return this.#stats;
    }

    /** @returns {Array<Action>} 유닛의 행동 리스트 */
    get actions(){
        return this.#actions;
    }

    /**
     * 유닛에 새로운 행동을 추가합니다.
     * @param {Action} action 추가할 행동
     * @param {number} index 행동을 추가할 위치 (-1일 경우 리스트 끝에 추가)
     * @throws {Error} 행동이 null이거나 유효하지 않은 경우, 혹은 같은 이름의 행동이 이미 존재할 경우 예외를 발생시킵니다.
     */
    InsertAction(action, index = -1){
        if (action == null) {
            throw new Error('Action cannot be null or undefined.');
        }

        if (!(action instanceof Actions.Action)) {
            throw new Error('Invalid action. Action must be an instance of the Action class.');
        }

        const existingAction = this.#actions.find(a => a.name === action.name);
        if (existingAction) {
            throw new Error(`Action with name "${action.name}" is already added.`);
        }

        if(index === -1){
            this.#actions.push(action);
        }else{
            this.#actions.splice(index, 0, action);
        }
    }
}

export default Unit;

Stats.js

스탯을 보관하고 있는 객체입니다. 정해진 규칙에 따라 스탯을 변동할 수 있도록 도와줍니다.

import MyMath from "../lib/MyMath.js";

class Stats {
    #current_hp = 0;
    #max_hp = 0;
    #default_atk = 0;
    #atk_rating = 0.0;
    #defense_rating = 0.0;
    #atk_range = { min_atk: 0, max_atk: 0 };
    #luck = 0.0;

    constructor(max_hp = 100, default_atk = 10, atk_rating = 0.1, defense_rating = 0.0, luck = 0.0) {
        this.#current_hp = max_hp;
        this.#max_hp = max_hp;
        this.#default_atk = default_atk;
        this.#atk_rating = atk_rating;
        this.#defense_rating = defense_rating;
        this.#luck = luck;
        this.#applyAtkRange(); // 초기화 시 공격 범위 설정
    }

    // Getter methods
    get current_hp() {
        return this.#current_hp;
    }

    get max_hp() {
        return this.#max_hp;
    }


    get default_atk() {
        return this.#default_atk;
    }

    get atk_rating() {
        return this.#atk_rating;
    }

    get defense_rating() {
        return this.#defense_rating;
    }

    get atk_range() {
        return this.#atk_range;
    }

    get luck(){
        return this.#luck;
    }

    modifyLuck(delta){
        this.#luck = MyMath.Clamp(this.#luck + delta, Number.MIN_VALUE, Number.MAX_VALUE);
    }

    modifyCurrentHP(delta) {
        this.#current_hp = MyMath.Clamp(this.#current_hp + delta, 0, this.#max_hp) | 0;
    }

    modifyMaxHP(delta) {
        this.#max_hp = MyMath.Clamp(this.#max_hp + delta, 0) | 0;
        if (this.#current_hp > this.#max_hp) {
            this.#current_hp = this.#max_hp;
        }
    }

    modifyDefaultAtk(delta) {
        this.#default_atk = MyMath.Clamp(this.#default_atk + delta, 0) | 0;
        this.#applyAtkRange();
    }

    modifyAtkRating(delta) {
        this.#atk_rating = MyMath.Clamp(this.#atk_rating + delta, 0);
        this.#applyAtkRange();
    }

    modifyDefenseRating(delta) {
        this.#defense_rating = MyMath.Clamp(this.#defense_rating + delta, 0);
    }

    #applyAtkRange() {
        this.#atk_range.min_atk = this.#default_atk;
        this.#atk_range.max_atk = this.#default_atk * (1.0 + this.#atk_rating ) | 0;
    }
}

export default Stats;

Action.js

행동 객체입니다. Action 클래스를 상속받은 여러 행동 객체를 토대로 새로운 동작을 할 수 있도록 도와줍니다.

import TextTable from '../lib/TextTable.js';
import MyMath from '../lib/MyMath.js';

const ACTION_FAILED = 'action_failed';

/**
 * 게임에서 모든 행동의 기본 클래스입니다.
 * 각 행동은 이름, 설명, 성공 확률을 가집니다.
 */
class Action {
    _name = "";
    _description = "";
    _probability = 0.0; // 확률이 낮을수록 성공 확률이 높습니다.

    /**
     * Action 클래스의 생성자입니다.
     * @param {string} name 행동의 이름
     * @param {string} description 행동에 대한 설명
     * @param {number} probability 행동의 성공 확률
     */
    constructor(name, description, probability) {
        this._name = name;
        this._description = description;
        this._probability = probability;
    }

    /** @returns {string} 행동의 이름 */
    get name(){
        return this._name;
    }

    /** @returns {string} 행동의 설명 */
    get description(){
        return this._description;
    }

    /** @returns {number} 행동의 성공 확률 */
    get probability(){
        return this._probability;
    }

    /**
     * 이 메서드는 상속받는 클래스에서 구현되어야 하는 추상 메서드입니다.
     * @param {Unit} unit 행동을 수행하는 유닛
     * @param {Unit} target_unit 행동의 대상이 되는 유닛
     * @throws {Error} 추상 메서드가 호출되었을 때 발생합니다.
     */
    DoAction = (unit, target_unit) =>{
        throw new Error("This is abstract action.");
    }
}

/**
 * 공격 행동을 계산하는 함수입니다.
 * @param {Unit} unit 공격하는 유닛
 * @returns {number} 계산된 공격력
 */
const CalcAtk = (unit) =>{
    return MyMath.RandomRangeInt(unit.stats.atk_range.min_atk, unit.stats.atk_range.max_atk+1);
}

/**
 * 피격 유닛의 방어력을 반영한 데미지를 계산합니다.
 * @param {Unit} target_unit 피격당하는 유닛
 * @param {number} atk 공격력
 * @returns {number} 계산된 데미지
 */
const CalcDamage = (target_unit, atk) => {
    return (atk * (1.0 - target_unit.stats.defense_rating)) | 0;
}

/**
 * 행동이 성공했는지 확률을 계산합니다.
 * @param {number} probability 행동의 기본 확률
 * @param {Unit} unit 행동을 수행하는 유닛
 * @returns {boolean} 성공 여부
 */
const CalcProbability = (probability , unit) =>{
    return MyMath.CalcProbability(probability + unit.stats.luck);
}

/**
 * 기본 공격 행동 클래스입니다.
 */
class AttackAction extends Action {
    /**
     * AttackAction 클래스의 생성자입니다.
     */
    constructor(){
        super("attack_action", 'damage', 0.0);
    }

    /**
     * 공격 행동을 수행합니다.
     * @param {Unit} unit 행동을 수행하는 유닛
     * @param {Unit} target_unit 행동의 대상이 되는 유닛
     * @returns {Array<string>} 행동 결과 설명
     */
    DoAction = (unit, target_unit) =>{
        let descriptions = [];

        const damage = CalcDamage(target_unit, CalcAtk(unit));
        target_unit.stats.modifyCurrentHP(-damage);
        descriptions.push( TextTable.FormatText(this._description, {unit: unit.name, target_unit: target_unit.name, damage}));
        return descriptions ;
    }
}

/**
 * 이중 공격 행동 클래스입니다.
 */
class DoubleAttackAction extends Action {
    /**
     * DoubleAttackAction 클래스의 생성자입니다.
     */
    constructor(){
        super("double_attack_action", 'damage', 0.55);
    }

    /**
     * 이중 공격 행동을 수행합니다.
     * @param {Unit} unit 행동을 수행하는 유닛
     * @param {Unit} target_unit 행동의 대상이 되는 유닛
     * @returns {Array<string>} 행동 결과 설명
     */
    DoAction = (unit, target_unit) => {
        let descriptions = [];
        if(CalcProbability(this._probability, unit)){
            for(let i = 0; i < 2; i++){
                const damage = CalcDamage(target_unit, CalcAtk(unit));
                target_unit.stats.modifyCurrentHP(-damage);            
                descriptions.push( TextTable.FormatText(this._description, {unit: unit.name, target_unit: target_unit.name, damage}));
            }
        } else {
            descriptions.push( TextTable.FormatText(ACTION_FAILED, {unit: unit.name, action_name: TextTable.FormatText( this._name)}));
        }
        return  descriptions ;
    }
}

/**
 * 치유 시도 행동 클래스입니다.
 */
class TryHealAction extends Action {
    /**
     * TryHealAction 클래스의 생성자입니다.
     */
    constructor(){
        super('try_heal_action', 'try_heal_result', 0.75);
    }

    /**
     * 치유 시도 행동을 수행합니다.
     * @param {Unit} unit 행동을 수행하는 유닛
     * @param {Unit} target_unit 행동의 대상이 되는 유닛
     * @returns {Array<string>} 행동 결과 설명
     */
    DoAction = (unit, target_unit) =>{
        let descriptions = [];
        const success = CalcProbability(this._probability, unit);
        const success_text = success ? '성공' : '실패';
        const prev_hp = unit.stats.current_hp;
        unit.stats.modifyCurrentHP(success ? (unit.stats.max_hp - prev_hp) * 0.5 : prev_hp * -0.1);
        const current_hp = unit.stats.current_hp;
        const text = TextTable.FormatText(this.description, {unit: unit.name, success: success_text, prev_hp, current_hp });
        descriptions.push(text);
        return  descriptions ;
    }
}

/**
 * 도박 행동 클래스입니다.
 */
class GamblingAction extends Action{
    /**
     * GamblingAction 클래스의 생성자입니다.
     */
    constructor(){
        super("gambling_action", 'gambling_result', 0.99);
    }

    /**
     * 도박 행동을 수행합니다.
     * @param {Unit} unit 행동을 수행하는 유닛
     * @param {Unit} target_unit 행동의 대상이 되는 유닛
     * @returns {Array<string>} 행동 결과 설명
     */
    DoAction = (unit, target_unit) =>{
        let descriptions = [];
        const success = CalcProbability(this._probability, unit);
        let text = '';
        if(success){
            text = TextTable.FormatText('gambling_success' );
            target_unit.stats.modifyCurrentHP(Number.MIN_SAFE_INTEGER);
        } else {
            text = TextTable.FormatText('gambling_failed' );
         
        }
        descriptions.push(text);
        return  descriptions ;
    }
}

export {Action, AttackAction, DoubleAttackAction, TryHealAction, GamblingAction };

MyMath.js

자주 사용하는 수학적 연산을 할 수 있도록 구성한 스크립트입니다.
랜덤 및 소수점 제거와 같은 어디서든 많이 사용할 만한 것들을 넣어놨습니다.

import crypto from 'crypto';

/**
 * 다양한 수학적 연산을 제공하는 유틸리티 클래스입니다.
 * 인스턴스를 생성할 수 없으며, 모든 메서드는 정적 메서드로 제공됩니다.
 */
class MyMath{

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

    /**
     * 입력된 값을 최소값과 최대값 사이로 고정합니다.
     * @param {number} value 고정할 값
     * @param {number} [min=Number.MIN_SAFE_INTEGER] 최소값
     * @param {number} [max=Number.MAX_SAFE_INTEGER] 최대값
     * @returns {number} 고정된 값
     */
    static Clamp(value, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER){
        if(value < min){
            return min;
        }else if(value > max){
            return max;
        }

        return value;
    }

    /**
     * 0과 1 사이의 무작위 수를 반환합니다.
     * @returns {number} 무작위 수
     */
    static Random01() {
        const randomBytes = crypto.randomBytes(4);
        const randomNumber = randomBytes.readUInt32BE(0);
        return randomNumber / 0xFFFFFFFF;
    }

    /**
     * 주어진 범위 내에서 무작위 수를 반환합니다.
     * @param {number} [min=Number.MIN_VALUE] 최소값
     * @param {number} [max=Number.MAX_VALUE] 최대값
     * @returns {number} 무작위 수
     */
    static RandomRange(min = Number.MIN_VALUE, max = Number.MAX_VALUE){
        if(min > max){
            const tmp = min;
            min = max;
            max = tmp;
        }

        return this.Random01() * (max - min) + min;
    }

    /**
     * 주어진 범위 내에서 무작위 정수를 반환합니다.
     * @param {number} [min=Number.MIN_VALUE] 최소값
     * @param {number} [max=Number.MAX_VALUE] 최대값
     * @returns {number} 무작위 정수
     */
    static RandomRangeInt(min = Number.MIN_VALUE, max = Number.MAX_VALUE){
        return this.Floor(this.RandomRange(min, max));
    }

    /**
     * 주어진 확률에 따라 성공 여부를 반환합니다.
     * @param {number} probability 확률 (0.0 ~ 1.0)
     * @returns {boolean} 성공 여부
     */
    static CalcProbability(probability){
        return this.Random01() > probability ;
    }

    /**
     * 배열 중 하나의 요소를 무작위로 선택하여 반환합니다.
     * @param {Array} array 선택할 배열
     * @returns {*} 선택된 요소
     */
    static RandomPick(array){
        return array[this.RandomPickIndex(array)];
    }

    /**
     * 배열 중 하나의 인덱스를 무작위로 선택하여 반환합니다.
     * @param {Array} array 선택할 배열
     * @returns {number} 선택된 인덱스
     */
    static RandomPickIndex(array){
        return this.RandomRangeInt(0, array.length);
    }

    /**
     * 주어진 수의 소수점을 제거하여 반환합니다.
     * @param {number} num 처리할 수
     * @returns {number} 소수점이 제거된 수
     */
    static Floor(num){
        return num | 0;
    }
}

export default MyMath;

Singletone.js

싱글톤 패턴을 미리 구현해둔 객체입니다.
이것을 상속받으면 해당 객체는 단 하나의 인스턴스만 생성이되며 이미 생성된 인스턴스가 있을시 그 인스턴스만 반환합니다.

/**
 * 싱글톤 패턴을 구현한 기본 클래스입니다.
 * 이 클래스를 상속받으면 하나의 인스턴스만 생성됩니다.
 */
class Singleton {
  constructor() {
    if (this.constructor.instance) {
      return this.constructor.instance;
    }

    this.constructor.instance = this;
  }
}

export default Singleton;

Utils.js

개발중에 자잘하지만 여기저기서 자주 사용하는 기능들을 모아두는 스크립트입니다.
이 프로젝트에선 ms초 만큼 기다리도록 하여 연출에 도움이 되는 메소드를 만들어 놨습니다.

/**
 * 다양한 유틸리티 함수를 제공하는 클래스입니다.
 */
class Utils {
    /**
     * 주어진 밀리초(ms) 동안 지연시킵니다.
     * @param {number} ms 지연시킬 시간 (밀리초)
     * @returns {Promise<void>} 지연이 완료된 후 반환되는 Promise
     */
    static Delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

export default Utils;

Settings.js

게임 설정에 대한 스크립트입니다. Settings.json을 필요한 곳마다 불러올 경우 한곳에서 값을 변경하면 다른 곳엔 반영되지 않는 문제가 발생할 수 있습니다.
따라서 이 모듈에서 메모리상에 들고있고 필요한 곳에서 참조하여 빌려갈 수 있도록 처리했습니다.

import Settings from "../../resources/Settings.json"   assert { type: 'json' }; 
import fs from "fs";
import path from "path";

/**
 * 게임 설정을 관리하는 모듈입니다.
 * 설정은 JSON 파일에서 로드되며, 변경된 설정을 저장할 수 있습니다.
 */

/**
 * 설정을 저장하는 함수입니다.
 * @returns {Promise<void>}
 */
async function Save(){
    await fs.writeFile(path.join(process.cwd(), "./resources/Settings.json"), JSON.stringify(Settings, null, 2), 'utf8', (error => {if(error) throw error}));
}

export default  Settings;
export {Settings, Save};

JSDOC

간단하게 문서화를 진행하고 싶어서 JSDOC이란 것을 활용해봤습니다.
본래 html로 처리하지만 git에 올리기 위해 MD으로 처리하고 싶어 관련 모듈을 같이 설치하였습니다.

$ npm install --save-dev jsdoc jsdoc-to-markdown

위에 코드들을 보면 주석들이 달려있음을 알 수 있는데 이게 JSDOC의 주석 규칙입니다. 이를 토대로 문서를 작업해주는겁니다.
그래서 다음 명령어를 통해 문서로 변경합니다.
중간에 src/*/.js는 src 하위의 모든 디렉토리중 js 파일을 읽어서 문서화 해달라는 뜻 입니다.

$ npx jsdoc2md src/**/*.js > DOCS.md

이렇게하면 다음 URL 처럼 작성해줍니다.
https://github.com/artbiit/SpartaCLI_Rogue/blob/main/Docs.md

0개의 댓글