내일배움캠프 Node.js 트랙 4일차
1. ZEP에서 이루어진 로그라이크 게임 개발 프로젝트
(1) 지난 시간 복기
지난 시간을 기점으로 자바스크립트 문법 강의를 마친 뒤 본격적인 코드 작성에 돌입했다. 게임의 장르는 덱빌딩 로그라이크. 플레이어, 카드, 몬스터의 인스턴스를 클래스로 생성하였고 메서드와 함수들을 이용해 각자의 기능들을 구현해 나갔다.
자연어로 의사코드를 먼저 작성하고 시작했기 때문에 큰 문제는 없었지만, 종종 알 수 없는 결과물을 맞닥뜨릴 때가 있었다.
"왜 이런 일이 발생한 거지?"
속으로 이런 의문을 품고 웹 서핑을 한참이나 하거나, 지난 강의 자료를 뒤적이며 답을 찾으려 진을 빼기도 했다.
어떤 것은 그저 변수명이 틀려서 어이없게 해결되기도 했었고, 어떤 것은 자료형이 맞지 않아서 일어난 해프닝이기도 했다. 또 어떤 것은 if 조건문의 분기점을 제대로 설정하지 않아서 부족해서 일어나기도 했다.
이 수많은 실수를 어떻게 발견하고 해결할 수 있었는지, 한 가지 사례를 꼽아서 트러블 슈팅을 해보도록 하겠다.
(2) 메서드와 this Binding
우선, 이슈가 일어난 시점은 게임에 새로운 기능을 넣기로 결정했을 때의 일이었다. 그때 당시는 오직 공격과 수비밖에 없는, 이 게임의 초기 버전이 완성된 직후였다. 아무래도 너무 단순하다보니 직업 개념을 넣는 게 좋겠다고 생각했고, 플레이어가 '축복'을 선택해서 덱의 타입을 결정할 수 있도록 코드를 짜기 시작했다.
잠시 이 축복들에 대해 간단히 설명하자면, 가시 수호자를 선택하면 방어와 반사 데미지를 이용해 덱을 짜는 데 유리했고, 광전사는 연속 공격과 흡혈이라는 강점을 가지고 있었으며, 화염 투사는 지속 데미지를 누적하기 위해 공격과 방어 모두를 적절히 챙기는 컨셉이었다.
몬스터를 공격할 때 호출하는 메서드는 Monster라는 클래스 내부에 있었는데 그 내용은 다음과 같았다.
class Monster {
// ...
monsterLoseHp(playingCard, cardPower = 1) {
if (playingCard.attackDmg >= 0 && playingCard.spellDmg >= 0) {
this.hp -= Math.round((playingCard.attackDmg + playingCard.spellDmg) * cardPower);
}
}
// ...
}
해당 메서드의 호출은 Player 클래스, cardPlay()라는 메서드 내부에서 이루어졌다.
class Player {
// ...
cardPlay(playingCard, monster, cardIndex) {
// 100 미만의 랜덤한 밸류를 구하고 카드 발동 확률이랑 비교해보기
const randomValue = Math.random() * 100;
// 카드 발동 확률 = 카드 자체 발동 확률 + 카드와의 유대감
const cardActProb = playingCard.actProb + this.bondingIndex;
// 카드 발동 확률이 랜덤한 숫자를 이기면 발동!
if (cardActProb >= randomValue) {
if (cardActProb > 100) {
let cardPower = 1 + (cardActProb - 100) / 100;
monster.monsterLoseHp(playingCard, cardPower);
this.updateHpByCard(playingCard, cardPower);
this.updateDefenseByCard(playingCard, cardPower);
} else {
monster.monsterLoseHp(playingCard);
this.updateHpByCard(playingCard);
this.updateDefenseByCard(playingCard);
}
setMessage('카드 발동 성공!');
} else if (cardActProb < randomValue) {
setMessage('카드 발동 실패!');
}
// 사용한 카드는 손패에서 카드더미로(splice, push)
const usedCard = this.hasCardInHand.splice(cardIndex, 1)[0];
this.hasCard.push(usedCard);
}
// ...
}
보다시피 해당 턴에 사용한 카드(playingCard)를 인자로 받아와서 특정 계산식을 통해 몬스터의 체력(this.hp)을 감소시키는 메서드다. 만약에 플레이어가 가시 수호자를 선택했다고 가정해 보자. 이 경우 카드의 데미지 뿐만 아니라 가시의 반사 데미지까지 계산식에 넣어줄 필요가 있을 것이다. 그런데 축복은 세 종류였으니, 세 가지 축복이 가진 특별한 계산식을 모두 만들어야 하는 상황일 터.
가장 먼저 든 생각은 기존 코드를 if문으로 각각의 축복을 선택한 경우를 나누는 것이었다. 이때 필요한 조건식은 아래와 같았다.
// ...
if (player.blessing === 'Spike Defender') {
// ...
} else if (player.blessing === 'Berserker') {
// ...
} else if (player.blessing === 'Chieftain') {
// ...
}
"아, player도 매개변수로 받아와야겠구나."
그런 단순한 생각에 다다른 후에 코드를 고치기 시작했다.
// Monster 클래스...
monsterLoseHpByCard(player, playingCard, cardPower = 1) {
if (player.blessing === 'Spike Defender' && player.defense > 0) {
this.hp -= Math.round(playingCard.attackDmg * cardPower + player.spikeDmg);
// ...
// ...
// Player 클래스...
cardPlay(playingCard, monster, cardIndex) {
// 100 미만의 랜덤한 밸류를 구하고 카드 발동 확률이랑 비교해보기
const randomValue = Math.random() * 100;
// 카드 발동 확률 = 카드 자체 발동 확률 + 카드와의 유대감
const cardActProb = playingCard.actProb + this.bondingIndex;
// 카드 발동 확률이 랜덤한 숫자를 이기면 발동!
if (cardActProb >= randomValue) {
if (cardActProb > 100) {
let cardPower = 1 + (cardActProb - 100) / 100;
monster.monsterLoseHp(this, playingCard, cardPower);
this.updateHpByCard(playingCard, cardPower);
this.updateDefenseByCard(playingCard, cardPower);
} else {
monster.monsterLoseHp(this, playingCard);
this.updateHpByCard(playingCard);
this.updateDefenseByCard(playingCard);
}
setMessage('카드 발동 성공!');
} else if (cardActProb < randomValue) {
setMessage('카드 발동 실패!');
}
// 사용한 카드는 손패에서 카드더미로(splice, push)
const usedCard = this.hasCardInHand.splice(cardIndex, 1)[0];
this.hasCard.push(usedCard);
}
// ...
// ...
그 결과는? 슬프게도 오류가 나버렸다. 문제가 무엇일까. 곧장 자바스크립트 디버그 터미널을 열고 확인해보기로 했다.
this에 player가 들어가 있는 모습. 메서드의 this는 호출 객체를 가리키고 있다.
monsterLoseHpByCard() 메소드에도 player 객체가 제대로 전달된 것처럼 보인다.
그런데 어째서인지 if문을 건너뛰고 메서드가 종료되어버렸다. 때문에 몬스터의 체력은 전혀 줄지 않은 상태이다. player 객체를 살펴보면
spikeDmg에 제대로 20이 할당되어 있기 때문에 계산식 때문에 일어난 문제는 아니다. 따라서 디버그 터미널이 제공하는 정보들을 통해 해당 메서드가 if문 내부로 들어가지 못했음을 확신할 수 있게 되었다. 문제는 분명 if문 조건식에 있었다.
// Monster 클래스...
monsterLoseHpByCard(player, playingCard, cardPower = 1) {
if (player.blessing === 'Spike Defender' && player.defense > 0) {
this.hp -= Math.round(playingCard.attackDmg * cardPower + player.spikeDmg);
}
// ...
// ...
그래서 코드를 다시 살펴보니, 문제가 보였다. 나의 의도는 플레이어가 방어도를 가지고 있을 때 해당 계산식이 발동하길 바란 거였지만, 정작 방어도를 가지고 있을 때의 경우를 작성해주지 않았다. 즉, 플레이어가 방어도를 가지고 있지 않은 첫번째 턴에선 아무 일도 일어나지 않고 메서드가 종료되어 버렸던 것이다.
문제의 원인을 파악했으니 코드를 다음과 같이 수정해주었다.
// Monster 클래스...
monsterLoseHpByCard(player, playingCard, cardPower = 1) {
if (player.blessing === 'Spike Defender') {
if (player.defense > 0) {
this.hp -= Math.round(playingCard.attackDmg * cardPower + player.spikeDmg);
} else {
this.hp -= Math.round(playingCard.attackDmg * cardPower);
}
} else if (player.blessing === 'Berserker') {
// ...
// ...
다시 디버그 터미널을 통해 확인해보니 몬스터의 hp가 제대로 감소되고 있었다.
메서드의 this를 콘솔 로그로 찍어 확인해보니 오류가 해결되었음이 자명하다.
(3) 결론
사실 문제 자체는 겨우 한 두 줄이 될까말까한 코드로부터 발생했다. 그러나 문제의 원인을 찾는 과정은 그리 녹록지 못했다. player와 monster라는 두 개의 클래스가 서로의 인스턴스를 매개변수로 주고받아 메서드를 실행시키고 있었고, 이 과정에서 this 바인딩도 제대로 되고 있는 건지 헷갈리는 상태였다. 당연히 객체에 소속되는 메서드니까 this바인딩이 되고 있을 테지만...스스로 확신이 없었다.
그때 내게 도움이 되었던 것이 vscode 검사 도구를 이용한 디버깅 과정이었다. 문제가 일어나고 있다고 추정되는 코드에 중단점을 찍고 디버그 터미널을 통해 매개변수가 제대로 전달되고 있는지 확인해보면서 정확한 원인을 찾아나갈 수 있었다.
위와 같은 과정을 겪으며 this 매개변수엔 문제가 없다는 것을 확인했고, if문 내부에 찍어둔 중단점을 완전히 skip하는 것을 확인하고 난 뒤 if문이 문제라는 걸 곧바로 알아차렸다. 마침내 버그를 고치고선 디버깅 과정의 중요성을 실감했다.
'개발일지 > TIL(Today I Learned)' 카테고리의 다른 글
2024-11-13 (2) | 2024.11.13 |
---|---|
2024-11-12 (1) | 2024.11.12 |
2024-11-08 (3) | 2024.11.08 |
2024-11-07 (4) | 2024.11.07 |
2024-11-06 (0) | 2024.11.06 |
댓글