whatisthis?

javaScript. 턴제 게임 - 텍스트 기반 RPG (下) 본문

PRACTICE/SELF

javaScript. 턴제 게임 - 텍스트 기반 RPG (下)

thisisyjin 2022. 1. 31. 14:15

턴제 게임 - 텍스트 기반 RGP

-  진행사항

 

javaScript. 턴제 게임 - 텍스트 기반 RPG (中)

턴제 게임 - 텍스트 기반 RGP - 지난번 진행사항 javaScript. 턴제 게임 - 텍스트 기반 RPG (上) 턴제 게임 - 텍스트 기반 RGP skill html / css javascript 텍스트 RPG style.css * { margin: 0; box-s..

mywebproject.tistory.com

 

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>턴제 게임</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="container">
    <form id="start-screen">
        <input id="name-input" required placeholder="영웅 이름을 입력하세요!" />
        <button id="start">시작</button>
    </form>
</div>

<div class="container">
    <div id="screen">
        <div id="hero-stat">
          <span id="hero-name"></span>
          <span id="hero-level"></span>
          <span id="hero-hp"></span>
          <span id="hero-xp"></span>
          <span id="hero-att"></span>
        </div>
        <div class="menu-container">
            <form id="game-menu" style="display: none;">
                <div id="menu-1">1.모험</div>
                <div id="menu-2">2.휴식</div>
                <div id="menu-3">3.종료</div>
                <input id="menu-input" required placeholder="숫자를 입력하세요" maxlength="1" />
                <button id="menu-button">입력</button>
            </form>
        </div>
        <div class="menu-container">
            <form id="battle-menu" style="display: none;">
                <div id="battle-1">1.공격</div>
                <div id="battle-2">2.회복</div>
                <div id="battle-3">3.도망</div>
                <input id="battle-input" required placeholder="숫자를 입력하세요" maxlength="1" />
                <button id="battle-button">입력</button>
            </form>
        </div>
        <div id="message"></div>
        <div id="monster-stat">
          <span id="monster-name"></span>
          <span id="monster-hp"></span>
          <span id="monster-att"></span>  
        </div>
    </div>
</div>

<script src="./turn.js"></script>
</body>
</html>

- 스타일 적용 (flex)을 위한 container div를 추가했다.

- input 태그에 required속성 / placeholder / maxlength를 추가하였다.

 

style.css

* {
    font-family: 'SUIT-Medium';
    margin: 0;
    box-sizing : border-box;
}

@font-face {
    font-family: 'SUIT-Medium';
    src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_suit@1.0/SUIT-Medium.woff2') format('woff2');
    font-weight: normal;
    font-style: normal;
}

html {
    font-size: 16px;
    line-height: 2;
    color: #1f2d3d;
}


body {
    /* display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center; */
    width: 100%;
    height: 100vh;
    background-color: #f2f4ff;
}


.container {
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: #c1c1c1;
    margin: 50px;
    border-radius: 20px;
}


#hero-stat {
    background-color: rgb(241, 241, 241);
}

#hero-name {
    font-size: 20px;
    font-weight: bold;
}

#hero-level {
    background-color: #ae1e1e;
    color: rgb(255, 253, 243);
    font-weight: bold;
    border-radius: 50%;
}

.menu-container {
    text-align: center;
}

- 한글 웹폰트를 적용하였다.

url : https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_suit@1.0/SUIT-Medium.woff2

 

 

- * 선택자에 font-family를 적용하였다.

>> placeholder나 button에도 폰트가 적용할 수 있도록.

(물론, ::placeholder 같이 선택자로 적용해도 된다.)

 

turn.js

let TurnGame = (function () {
    let instance;
    let initiate = function (heroName) {
      let hero = {
        name: heroName,
        lev: 1,
        maxHp: 100,
        hp: 100,
        xp: 0,
        att: 10
      };
      let monsters = [{
        name: '슬라임',
        hp: 25 + hero.lev * 3,
        att: 10 + hero.lev,
        xp: 10 + hero.lev,
      }, {
        name: '스켈레톤',
        hp: 50 + hero.lev * 5,
        att: 15 + hero.lev * 2,
        xp: 20 + hero.lev * 2,
      }, {
        name: '보스',
        hp: 100 + hero.lev * 10,
        att: 25 + hero.lev * 5,
        xp: 50 + hero.lev * 5,
      }];
      let monster = null;
      let turn = true;
      return {
        showLevel: function () {
          document.getElementById('hero-level').innerHTML = hero.lev + ' Lev';
          return this;
        },
        showXp: function () {
          let self = this;
          if (hero.xp > 15 * hero.lev) {
            hero.xp -= 15 * hero.lev;
            hero.maxHp += 10;
            hero.hp = hero.maxHp;
            hero.att += hero.lev;
            hero.lev++;
            window.setTimeout(function() {
              self.setMessage('레벨업!');
            }, 1000);
          }
          document.getElementById('hero-xp').innerHTML = 'XP: ' + hero.xp + '/' + 15 * hero.lev;
          document.getElementById('hero-att').innerHTML = 'ATT: ' + hero.att;
          return this.showLevel().showHp();
        },
        showHp: function () {
          if (hero.hp < 0) {
            return this.gameOver();
          }
          document.getElementById('hero-hp').innerHTML = 'HP: ' + hero.hp + '/' + hero.maxHp;
          
          return this;
        },
        toggleMenu: function () {
          document.getElementById('hero-name').innerHTML = hero.name;
          document.getElementById('start-screen').style.display = 'none';
          if (document.getElementById('game-menu').style.display === 'block') {
            document.getElementById('game-menu').style.display = 'none';
            document.getElementById('battle-menu').style.display = 'block';
            document.getElementById('battle-input').focus();
          } else {
            document.getElementById('game-menu').style.display = 'block';
            document.getElementById('battle-menu').style.display = 'none';
            document.getElementById('menu-input').focus();
          }
          return this;
        },
        setMessage: function (msg) {
          document.getElementById('message').innerHTML = msg;
          return this;
        },
        generateMonster: function () {
          monster = JSON.parse(JSON.stringify(monsters[Math.floor(Math.random() * monsters.length)]));
          document.getElementById('monster-name').innerHTML = monster.name;
          document.getElementById('monster-hp').innerHTML = 'HP: ' + monster.hp;
          document.getElementById('monster-att').innerHTML = 'ATT: ' + monster.att;
          this.setMessage(monster.name + '이(가) 공격해옵니다');
          return this.toggleMenu();
        },
        menuInput: function (input) {
          if (input === '1') {
            return this.generateMonster();
          } else if (input === '2') {
            hero.hp = hero.maxHp;
            return this.showHp().setMessage('체력을 회복했습니다');
          } else if (input === '3') {
            return this.exit();
          } else {
            alert('잘못된 입력');
          }
        },
        battleInput: function (input) {
          if (input === '1') {
            return this.attackMonster();
          } else if (input === '2') {
            if (hero.hp + hero.lev * 20 < hero.maxHp) {
              hero.hp += hero.lev * 20;
            } else {
              hero.hp = hero.maxHp;
            }
            return this.showHp().setMessage('체력을 회복했습니다').nextTurn();
          } else if (input === '3') {
            return this.clearMonster().setMessage('도망쳤습니다');
          } else {
            alert('잘못된 입력');
          }
        },
        attackMonster: function () {
          monster.hp -= hero.att;
          document.getElementById('monster-hp').innerHTML = 'HP: ' + monster.hp;
          if (monster.hp > 0) {
            return this.setMessage(hero.att + '의 데미지를 입혔습니다.').nextTurn();
          }
          return this.win();
        },
        attackHero: function () {
          hero.hp -= monster.att;
          return this.showHp();
        },
        nextTurn: function () {
          let self = this;
          turn = !turn;
          document.getElementById('battle-button').disabled = true;
          if (!turn) {
            window.setTimeout(function () {
              self.setMessage(monster.name + '의 턴입니다');
              window.setTimeout(function () {
                document.getElementById('battle-button').disabled = false;
                if (self.attackHero()) {
                  self.setMessage(monster.att + '의 데미지를 입었습니다');
                  window.setTimeout(function () {
                    self.setMessage(hero.name + '의 턴입니다');
                  }, 1000);
                }
              }, 1000);
            }, 1000);
            return this.nextTurn();
          }
          return this;
        },
        win: function () {
          this.setMessage(monster.name + ' 사냥에 성공해 경험치 ' + monster.xp + '을 얻었습니다');
          hero.xp += monster.xp;
          return this.clearMonster().showXp();
        },
        clearMonster: function () {
          monster = null;
          document.getElementById('monster-name').innerHTML = '';
          document.getElementById('monster-hp').innerHTML = '';
          document.getElementById('monster-att').innerHTML = '';
          return this.toggleMenu();
        },
        gameOver: function () {
          document.getElementById('screen').innerHTML = hero.name + '은 레벨' + hero.lev + '에서 죽었습니다. ㅡ 다시 시작은 F5';
          return false;
        },
        exit: function (input) {
          document.getElementById('screen').innerHTML = '이용해주셔서 감사합니다. ㅡ 다시 시작은 F5';
        }
      };
    };
    return {
      getInstance: function (name) {
        if (!instance) {
          instance = initiate(name);
        }
        return instance;
      }
    };
  })();
  document.getElementById('start-screen').onsubmit = function (e) {
    let name = document.getElementById('name-input').value;
    e.preventDefault();
    if (name && name.trim() && confirm(name + '으로 하시겠습니까?')) {
      TurnGame.getInstance(name).showXp().toggleMenu();
    } else {
      alert('이름을 입력해주세요');
    }
  };
  document.getElementById('game-menu').onsubmit = function (e) {
    let input = document.getElementById('menu-input');
    let option = input.value;
    e.preventDefault();
    input.value = '';
    TurnGame.getInstance().menuInput(option);
  };
  document.getElementById('battle-menu').onsubmit = function (e) {
    let input = document.getElementById('battle-input');
    let option = input.value;
    e.preventDefault();
    input.value = '';
    TurnGame.getInstance().battleInput(option);
  };

설명이 매우 길 예정이니

맨 밑으로 이동

🔻🔻🔻🔻🔻

1 / 싱글턴 객체

 

- 게임을 시작할 때 한번만 시작하기 위해서

객체를 '하나'만 만드는 싱글턴 객체 이용.

IIFE(모듈 패턴)으로 (function() {게임 전체})() 로, 선언하자마자 실행함.

 

 

- TurnGame 자체는 IIFE문으로 된 함수인데,

return하는 것이 객체 {} 이다.

 

- 즉, Turngame()을 하면 ㅡ 객체가 나오고

객체로 메소드들을 사용할 수 있는 것.

 

- 따라서, TurnGame이 반환하는 객체 안에있는 메서드들을 쓰려면

매번 TurnGame을 호출하는 것 보다는

한번만 호출하고, 메소드 체이닝을 사용하면 된다.

 

2 / 메소드 체이닝

 

메소드에서 return this를 하는 이유
- 다른 함수를 계속해서 호출하기 위해서 = 메소드 체이닝을 위해서.

return
 명령문은 함수 실행을 종료하고, 주어진 값을 함수 호출 지점으로 반환함.

return this.showLevel().showHp();​

 

위 코드는 showXp의 return 값을 나타낸다.

this.showLevel()을 먼저 호출했는데
showLevel을 실행하면
마지막줄에 return this;가 된다.
이 this가 showLevel의 호출부분에 반환되고,
결국엔 this는 메서드들이 다 들어있는 객체 { } 를 의미하므로
그 객체.showHp()에 의해 다음으로 showHp 함수가 호출됨.
showHp도 마찬가지로 return this임 ㅡ 다시한번 그 객체를 반환하고
showXp의 리턴값은? >>> 우선 showLevel을 실행하고 ㅡ showHp를 실행한 후 ㅡ 그 객체 {} 를 리턴함.

이 showXp를 호출한 부분으로 가보자.


TurnGame.getInstance(name).showXp().toggleMenu();​


이 코드에서는
Turngame을 하면 getInstance가 있는 객체 반환 ㅡ if(!instance)이면 initiate 함수 실행 ㅡ return 객체 {}를 반환 ㅡ 호출부로 리턴되어서
그객체.showXp().toggleMenu를 실행
>> 아까 과정을 거쳐서 다시 그객체 반환
>>>> 그객체.toggleMenu() 실행.
 
따라서 위 코드는
1. 초기설정 ㅡ 받은 name값으로 initiate 함수 실행
2. showXp ㅡ 캐릭터 스탯 innerHTML로 표시
3. toggleMenu ㅡ 스타트메뉴 안보이게 ㅡ> 게임메뉴 보이게 /  배틀메뉴 안보이게
>> 맨처음 디폴트값은 둘다 안보이므로 (display:none)

if (document.getElementById('game-menu').style.display === 'block') {
            document.getElementById('game-menu').style.display = 'none';
            document.getElementById('battle-menu').style.display = 'block';
            document.getElementById('battle-input').focus();
          } else {
            document.getElementById('game-menu').style.display = 'block';
            document.getElementById('battle-menu').style.display = 'none';
            document.getElementById('menu-input').focus();
          }

else 문 부분이 먼저 실행된다. (none 이므로)
>> 게임메뉴 보이게 / 배틀메뉴 안보이게.

다음에 이 if-else를 통과할 때에는  ㅡ  submit 이벤트 후에. (1,2,3중에 골라서 제출했을 때)
ㅡ 이때는 game-menu 가 display:block 상태이므로 if문 부분이 실행되어
>> 게임메뉴 안보이게 / 배틀메뉴 보이게

 

 

전체 로직

TurnGame f


instance undefined (default) => getInstance      
f   initiate
hero 객체, 
monsters []
hero.name
hero.lev
hero.maxHp
hero.hp
hero.xp
hero.att
=> showLevel
showXp
showHp
   
return showLevel
showXp 
showHp
innerHTML showXp
- setTimeout()
레벨업
- return
this.showLevel().showHP();
그외
return this
toogleMenu
setMessage
display:none game-menu
battle-menu
 
generateMonster monster변수
: monsters 객체 복사(JSON) 
math.random()
인덱스 뽑기
toggleMenu
menuInput 1 - generateMonster 
3 - exit
   
battleInput 1 - attackMonster
3 - clearMonster
   
attackMonster
attackHero
=> hp -= att monster.hp
>0 : __데미지
else : win()
 
nextTurn setTimeout() button.disabled  
win xp += mon.xp
clearMonster
showXp (갱신)
   
clearMonster monster = null
innerHTML = ''
toggleMenu
(배틀 -> 게임)
 
gameOver #screen.
innerHTML
return false ** showHP에서
hp <0
exit #screen.
innerHTML
   
return getInstance initiate(name)      
return instance      

 

 

 


이벤트리스너

 

start-screen 폼이 제출시
(onsubmit)
name = #name-input의 value
name입력하고, name.trim하고 (공백제거)
confirm창 확인 누르면 🔻
TurnGame.getInstance(name).showXp().toggleMenu();
game-menu 폼이 제출시
(onsubmit)
1,2,3 중 입력받아서
option 변수에 저장 후 🔻
TurnGame.getInstance().menuInput(option);
- initial은 안거치고 바로 instance 리턴

(이름 입력해둔 initiate의 리턴결과가 instance임)
- menuInput 메서드의 input 파라미터에 option이 들어감
battle-menu 폼이 제출시
(onsubmit)
상동.
- battleInput 메서드로 실행.
- input 파라미터에 option이 들어감

 

 

 

+)

 

JSON 활용 

 

JSON.stringify와 JSON.parse 를 동시에 사용하면  >>> 객체를 복사할 수 있다.
참조 (reference)가 아닌 복사 (copy)이다!
- 원래 객체(함수 / 일반객체 / 배열)은 대입연산자로는 참조가 되어 원형이 바뀜

 


 

사담 👻

 

역시 어려운 게임만들기.. 우선 메서드가 너무 많은 것도 있지만, 구현이 너무 어렵다.
개인적으로 캐릭터 밸런스가 좀 안맞는 것 같아서
레벨별로 출현하는 몬스터 확률을 패치해보고싶다.
예를들면 레벨이 낮으면 슬라임이 60%, 스켈레톤 30%, 보스가 10% 확률로 등장한다던지
>> 이걸 어떻게 구현할지?
monsters 배열에 index가 있으니까
[1,1,1,1,1,2,2,2,3] 배열에서 인덱스를 뽑아서 그 숫자에 해당하는 인덱스를 가진 몬스터가 나오게?

추가로, 너무 규모가 커지긴 하겠지만 ..
공격 / 방어 / 회복 정도로 나누어도 재밌을듯.
방어 >> 공격 무효화.
이건 몬스터도 쓸 수 있도록. 몬스터가 회복까지 하면 그건 좀 화나니까(?)
몬스터도 방어 쓸 수 있게.
대신, 방어를 연속으로 쓰진 못하게. >> 이 로직은 어떻게 짤지.
- 방어를 쓰면 depend = !depend가 되게 해서 다음번에
if(!depend) { setMessage('방어를 사용할 수 없습니다'); } 이런식으로 하거나?

 

 

REFERENCE

ZeroCho Blog

 

ZeroCho Blog

ZeroCho의 Javascript와 Node.js 그리고 Web 이야기

www.zerocho.com