게임을 진행할 때 여러 기능들이 필요합니다. 그래서 이를 미리 만들어 두려 합니다.
따라서 플레이 로직은 나중에 만들 예정입니다.
이전 글에서 작성한 기능으로 텍스트를 미리 파일로 지정해두고 필요할 때 갖다 쓰는 기능입니다. 설명은 이전글에 있으니 업그레이드 된 부분만 말하겠습니다.
아래 사진 처럼 플레이어 정보와 몬스터 정보가 일정한 간격으로 표기하고 싶어서 "텍스트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();;
자주 사용하는 것들을 모아두기 위해 만든 스태틱 클래스입니다. 지금은 간단하게 ms 만큼 기다리는 비동기 메소드를 만들어 놨습니다.
class Utils {
static Delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export default Utils;
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;
0 ~ 1(Include)를 만들어주는 겁니다. Math.Random()을 써도 되겠지만 조금이라도 난수 예측을 어렵게 만들어보고 싶어서 cryto를 이용해봤ㅅ브니다.
min ~ max (Exclude)를 만들어주는 메소드입니다. 소수점을 포함합니다.
RandomRange(min, max)와 같지만 소수점을 버립니다. "|0" 연산이 가능한 이유는 다음과 같습니다.
Javascript는 Number 타입을 64비트 부동소수점 연산을 하지만 비트 or 연산은 32비트 정수 연산입니다. 따라서 부동소수점이 or 연산에 의해 소거됩니다.
입력 클래스인데 이전글에서도 작성을 했었지만 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;
캐릭터의 기본단위입니다. 행동하고 스탯을 보유하고 있어요
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;
캐릭터의 스탯을 표기한 클래스입니다.
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;
캐릭터가 행할 수 있는 행동의 목록입니다.
기본공격, 회복시도, 연속공격, 도박을 미리 만들어놨고 도박은 플레이어만 가능합니다.
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 };
위에서 말한 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(); };
게임로직을 보관하는 모듈입니다. 아직은 미완성이지만 텍스트를 출력하는데 까진 성공해서 올려봅니다.
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++;
}
}
내일은 플레이가 가능하도록 게임 로직을 완성해보려 합니다.
그리고 업적, 옵션에 대해 추가 할 생각입니다.