NodeJS 간단한 로그라이크 게임 만들어보기 - 라이브러리 작성

아트·2024년 8월 22일
0

CLI-게임만들기

목록 보기
2/5

플레이 로직은

  1. server.js - 서버라 작성되어있지만 사실은 로비입니다. 나중에 lobby.js 로 변경할 예정이에요
  2. game.js - 게임 로직입니다. 전투를 치루고 게임을 진행하는 파일입니다.

그럼 그 외에는?

게임을 진행할 때 여러 기능들이 필요합니다. 그래서 이를 미리 만들어 두려 합니다.
따라서 플레이 로직은 나중에 만들 예정입니다.

  • 텍스트 출력
  • 텍스트 변환 : 필요할 경우 보기 좋게 혹은 현 상태값을 출력하기 위해 변환합니다
  • 수학 연산 : 수학적인 것을 자주 사용하게 됩니다. 대표적인 예로 min ~ max 랜덤 뽑기
  • 그 외 자주 쓰는 기능 : 코딩 중에 이곳 저곳에서 자주 사용하는 기능들이 있습니다. 이를 따로 뽑아두는게 좋습니다.

텍스트 테이블

이전 글에서 작성한 기능으로 텍스트를 미리 파일로 지정해두고 필요할 때 갖다 쓰는 기능입니다. 설명은 이전글에 있으니 업그레이드 된 부분만 말하겠습니다.
아래 사진 처럼 플레이어 정보와 몬스터 정보가 일정한 간격으로 표기하고 싶어서 "텍스트1"@"텍스트2" 이런 규격으로 있으면 사이의 간격을 두도록 만들었습니다.

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) => {
          try{
          this.#textTable[row.id] = row.text.trim().replace(/^"|"$/g, '').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
        }catch{
          console.error(row);
        }
        })
        .on('end', () => {
        //  console.log('CSV file successfully processed.');
          resolve();
        })
        .on('error', (err) => {
          reject(err);
        });
    });
  }

  /** id에 해당하는 텍스트를 포맷합니다. */
  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;
}


  /** 포맷후 바로 출력합니다. */
  Output(id, variables = {}){
    console.log(this.FormatText(id, variables));
  }

  /** @ 를 일정한 간격으로 변환해줍니다. */
   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');
}
    
}


export default new TextTable();;

Utils.js

자주 사용하는 것들을 모아두기 위해 만든 스태틱 클래스입니다. 지금은 간단하게 ms 만큼 기다리는 비동기 메소드를 만들어 놨습니다.

class Utils {
    static Delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }


   
}

export default Utils;

MyMath.js

Math 모듈처럼 static class입니다. 자주 사용할만한 산술들을 모아둘 예정입니다.

import crypto from 'crypto';

class MyMath{

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

      /** 입력된 value값을 min ~ max값 사이로 고정해 반환합니다. */
    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 사이로 값을 반환합니다. */
    static Random01() {
        const randomBytes = crypto.randomBytes(4);
        const randomNumber = randomBytes.readUInt32BE(0);
        return randomNumber / 0xFFFFFFFF;
    }

    /** min ~ max 사이로 값을 반환합니다. */
    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;
    }

    /** 소수점을 제거하고 반환합니다. */
    static RandomRangeInt(min = Number.MIN_VALUE, max = Number.MAX_VALUE){
        return this.RandomRange(min, max) | 0;
    }
}

export default MyMath;

Random01()

0 ~ 1(Include)를 만들어주는 겁니다. Math.Random()을 써도 되겠지만 조금이라도 난수 예측을 어렵게 만들어보고 싶어서 cryto를 이용해봤ㅅ브니다.

RandomRange(min, max)

min ~ max (Exclude)를 만들어주는 메소드입니다. 소수점을 포함합니다.

RandomRangeInt(min, max)

RandomRange(min, max)와 같지만 소수점을 버립니다. "|0" 연산이 가능한 이유는 다음과 같습니다.
Javascript는 Number 타입을 64비트 부동소수점 연산을 하지만 비트 or 연산은 32비트 정수 연산입니다. 따라서 부동소수점이 or 연산에 의해 소거됩니다.

Input.js

입력 클래스인데 이전글에서도 작성을 했었지만 readline-sync가 한글과 호환이 안되어서 도저히 못쓰겠다 싶어 기본 모듈인 readline 을 쓰기로 했습니다.
readline의 경우 인스턴스를 직접 만들어서 관리해야하므로 인스턴스를 생성후 프로세스가 종료되면 해당 스트림을 종료하도록 했습니다.

import readline from 'readline';

let createdReads = {};

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

  static async question(query) {
    let text = await this._askQuestion(query);
    return text;
  }

  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;
  }

  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;
  }

  static async keyInYN(query) {
    const answer = await this._askQuestion(query);
    return answer.toLowerCase() === 'y';
  }

  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.');
    }
  }

  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;
  }

  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();
});

function AllClose(){
  for(const key in createdReads){
    createdReads[key].close();
  }
  createdReads = {};
}
export default Input;

Unit

캐릭터의 기본단위입니다. 행동하고 스탯을 보유하고 있어요

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

class Unit {
    #name = '';
    #stats = null;
    #actions = [];

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

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

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

    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()];
    }

    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);
    }

    modifyMaxHP(delta) {
        this.#max_hp = MyMath.Clamp(this.#max_hp + delta, 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);
        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

캐릭터가 행할 수 있는 행동의 목록입니다.
기본공격, 회복시도, 연속공격, 도박을 미리 만들어놨고 도박은 플레이어만 가능합니다.

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

const ACTION_FAILED = 'action_failed';
/**행동의 기본틀 DoAction에서 false 반환시 전투 종료다. */
class Action {
    _name = "";
    _description = "";
    _probability = 0.0; // 0으로 갈수록 100%

    constructor(name, description, probability) {
        this._name = name;
        this._description = description;
        this._probability = probability;
    }

    get name(){
        return this._name;
    }

    get description(){
        return this._description;
    }

    get probability(){
        return this._probability;
    }

    DoAction(unit, target_unit){
        throw new Error("This is abstract action.");
    }
}

/**해당 유닛의 1회 공격에 대한 데미지를 계산합니다. */
function CalcAtk(unit){
    return MyMath.RandomRangeInt(unit.stats.atk_range.min_atk, unit.stats.atk_range.max_atk+1);
}

/** 입력된 공격력을 토대로 피격 유닛의 방어력을 적용한 값을 계산합니다. */
function CalcDamage(target_unit, atk){
    return (atk * (1.0 - target_unit.stats.defense_rating)) | 0;
}

/** 확률상 성공했는지 검사합니다. */
function CalcProbability(probability , unit){
    return MyMath.Random01() < (probability - unit.stats.luck);
}

class AttackAction extends Action {
    constructor(){
        super("attack_action", 'damage', 0.0);
    }

    DoAction(unit, target_unit){
        const damage = CalcDamage(target_unit, CalcAtk(unit));
        target_unit.stats.modifyCurrentHP(-damage);
        console.log(TextTable.FormatText(this._description, {unit: unit.name, target_unit: target_unit.name, damage}));
        return target_unit.stats.current_hp > 0;
    }
}

class DoubleAttackAction extends Action {
    constructor(){
        super("double_attack_action", 'damage', 0.75);
    }

    DoAction(unit, target_unit){
        if(CalcProbability(this._probability, unit)){
            for(let i = 0; i < 2; i++){
                const damage = CalcDamage(target_unit, CalcAtk(unit));
                target_unit.stats.modifyCurrentHP(-damage);
                console.log(TextTable.FormatText(this._description, {unit: unit.name, target_unit: target_unit.name, damage}));

                if(target_unit.stats.current_hp <= 0){
                    return false;
                }
            }
        } else {
            console.log(TextTable.FormatText(ACTION_FAILED, {action_name: this._name}));
        }
       
        return target_unit.stats.current_hp > 0;
    }
}

class TryHealAction extends Action {
    constructor(){
        super('try_heal_action', 'try_heal_result', 0.5);
    }

    DoAction(unit, target_unit){
        const success = CalcProbability(this._probability, unit);
        const success_text = success ? '성공' : '실패';
        const prev_hp = unit.stats.current_hp;
        unit.stats.modifyCurrentHP(success ? prev_hp * 1.5 : prev_hp * 0.9);
        const current_hp = unit.stats.current_hp;
        const text = TextTable.FormatText(this.description, {unit: unit.name, success: success_text, prev_hp, current_hp });
        console.log(text);
        return target_unit.stats.current_hp > 0;
    }
}

class GamblingAction extends Action{
    constructor(){
        super("gambling_action", 'gambling_result', 0.99);
    }

    DoAction(unit, target_unit){
        const success = CalcProbability(this._probability, unit);
        console.log(text);
        if(success){
            TextTable.Output('gambling_success' );
            target_unit.stats.modifyCurrentHP(Number.MIN_SAFE_INTEGER);
        } else {
            TextTable.Output('gambling_failed' );
         //   unit.stats.modifyCurrentHP(Number.MIN_SAFE_INTEGER);
         
        }
        return false;
    }
}

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

Server.js

위에서 말한 Lobby.js로 바꿀 파일입니다. 게임을 시작하면 메뉴를 선택하게 할 생각이에요.
메뉴를 동적으로 제공하고 싶어서 Command 패턴을 이용했습니다.

import {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';

let continued = true;
const menus = new  Command();
menus.AddCommand("시작하기", startGame);
menus.AddCommand("업적보기",async () => {TextTable.Output('not_allow');});
menus.AddCommand("옵션설정",async () => {TextTable.Output('not_allow');});
menus.AddCommand("게임종료",async () => {TextTable.Output('game_exit');  continued = false;})
// 로비 화면을 출력하는 함수
function displayLobby() {
    console.clear();
    const menu_list = Array.from(menus.keys).join("\n");
    TextTable.Output("lobby_menu", {menu_list});

}

// 유저 입력을 받아 처리하는 함수
async function handleUserInput() {
  
    while(continued){
        displayLobby();
        const text = TextTable.FormatText('input');
        const choice = await Input.question(text);
        if(await menus.ExecuteCommand(choice)){

        }else{
            TextTable.Output('wrong_select')
        }
    await Utils.Delay(2000);

    }
}

// 게임 시작 함수
function start() {
    continued = true;
    handleUserInput();
}

export default () => { start(); };

Game.js

게임로직을 보관하는 모듈입니다. 아직은 미완성이지만 텍스트를 출력하는데 까진 성공해서 올려봅니다.

import chalk from 'chalk';
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 Utils from './lib/Utils.js';
import TextTable from './lib/TextTable.js';

function displayStatus(stage, player, monster) {
 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.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.toFixed(2),
    monster_luck : (monster.stats.luck * 100.0 ).toFixed(2)
});

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

}

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

  let player_actions_text = player.actions.map(action => {
    const probabilityWithLuck = (100.0 - (action.probability + player.stats.luck) * 100.0).toFixed(2);
    return `${TextTable.FormatText(action.name)}(${probabilityWithLuck}%)`;
}).join(', ');

player_actions_text = TextTable.FormatText('action_info', {actions: player_actions_text});
  while(player.stats.current_hp > 0) {
    console.clear();
    displayStatus(stage, player, monster);

    logs.forEach((log) => console.log(log));

    console.log(player_actions_text);
    const choice =await Input.question('당신의 선택은? ');

    // 플레이어의 선택에 따라 다음 행동 처리
    logs.push(chalk.green(`${choice}를 선택하셨습니다.`));
  }
  
};

function CreatePlayerDefaultStats(){
  const max_hp = 100;
  const default_atk = 6;
  const atk_rating = 0.5;
  const defense_rating = 0.0;
  const luck = 0.0;
  const stats = new Stats(max_hp,default_atk,atk_rating,defense_rating,luck );
  return stats;
}

function CreateMonsterStats(stage){

  const max_hp = MyMath.RandomRangeInt(20 + 10 * (stage-1), 20 + 15 * (stage-1));
  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 = stage * 0.01;
  const luck = (1 << (stage - 1)) * 0.001;
  const stats = new Stats(max_hp,default_atk,atk_rating,defense_rating,luck );
  return stats;
}

export async function startGame() {
  console.clear();
  const player = new Unit('플레이어', CreatePlayerDefaultStats() );
  player.InsertAction(new Actions.GamblingAction()); //사용자만 도박에 시도할 수 있습니다.
  let stage = 1;

  while (stage <= 10) {
    const monster = new Unit('몬스터', CreateMonsterStats(stage));
    await battle(stage, player, monster);

    // 스테이지 클리어 및 게임 종료 조건

    stage++;
  }
}

마무리

내일은 플레이가 가능하도록 게임 로직을 완성해보려 합니다.
그리고 업적, 옵션에 대해 추가 할 생각입니다.

0개의 댓글