[C++] C++를 활용하여 카드게임 구현
카테고리: C++
이번에 언리얼반에서 기획 강의를 진행하면서, 수업 중 한 팀에서 카드게임을 구현하고 싶다는 이야기가 나왔습니다. 그러던 중 카드게임을 어떻게 구현할 수 있는지에 대한 질문이 있었고, 이를 돕기 위해 간단한 예제를 직접 작성해 보여주었습니다.
이번 포스팅에서는 그때 구현했던 예제를 어떤 방식으로 만들었는지, 그리고 설계 단계에서 고려한점들을 중심으로 설명합니다.
요구사항
- 이 카드게임에는 Player와 Enemy 2종류의 유닛이 존재합니다.
- Player와 Enemy는 같은 카드를 사용할 수 있습니다.
- Card는 Attack을 할 수 있는 카드, Heal을 할 수 있는 카드가 있습니다.
- Player와 Enemy는 특정 카드를 사용하여 서로에게 데미지를 주거나, 자신에게 힐을 줄 수 있습니다.
요구사항은 위와 같습니다. 보기에는 단순해 보일 수 있는 기획이지만, Player와 Enemy가 구체적인 Card를 알고 있어야 한다는 점, 마찬가지로 Card가 구체적인 Player와 Enemy를 알고 있어야 한다는 점에서 양방 의존관계가 생길 수 있습니다.
또한, 카드가 추가될 때 마다 Player와 Enemy에게 구체적인 카드를 멤버 변수로 계속 추가 해줘야 한다는 문제가 발생할 수 있습니다.
이를 해결하기 위해 디자인 원칙 중 OCP와 DIP 를 사용하여 디자인 하였습니다.
게임 상에 존재하는 카드들을 추상화 하여 Card 라는 클래스를 만들었습니다. 또한, Card의 기능 (Attack, Heal)은 인터페이스를 사용하여 추상화 하였습니다. 그리고, Card들의 사용을 관리하는 CardBattleManager를 만들어 추상화된 Unit과 Card를 의존시킴으로 CardBattleManager는 구체적인 Card나 Unit이 추가될 때, 변경되지 않을 수 있도록 설계 하였습니다.
아래는 위 설계를 그림으로 도식화 한 예 입니다. 아래의 설계를 보면, CardBattleManager는 추상화된 Card와 Unit에 의존하고 있어 구체적인 Card와 Unit이 추가된다고 해도 영향을 받지 않습니다. 또한, Card가 Player에게 직접 의존하는것이 아닌 추상화된 인터페이스가 Player에 의존하는 방식으로 설계하여 양방 의존관계를 제거 하였습니다. 이 구조에서는 어떤 선을 따라가도 의존성이 순환되지 않습니다.

구현
추상화된 Unit, Card 구현
우선, 추상화된 Unit과 Card의 헤더파일을 만듭니다. 이름은 Unit.h, Card.h로 합니다.
모든 Unit은 hp를 가지고 있고, Heal을 할 수 있는 기능과 Damage를 받을 수 있는 기능을 가지고 있다고 가정합니다.
Unit.h
#pragma once
class Unit
{
protected:
float hp = 0.0f;
public:
virtual ~Unit() = default;
public:
virtual void OnHeal(float _amount) = 0;
virtual void OnDamage(float _damage) = 0;
};
Card.h
#pragma once
class Card
{
public:
virtual ~Card() = default; // 다형성 보장
};
Unit을 상속받는 Player와 Enemy 구현
이제, Unit을 상속받는 Player와 Enemy를 구현합니다. Player 와 Enemy 모두 OnHeal과 OnDamage를 필수적으로 구현해야 합니다.(순수 가상함수이기 때문) 로직은 간단하게 hp를 올려주고, hp를 깎아주는 로직만 작성하였습니다.
Player.h
#pragma once
#include "Unit.h"
#include <iostream>
class Player : public Unit
{
public :
Player();
public:
void OnHeal(float _amount) override;
void OnDamage(float _damage) override;
};
Player.cpp
#include "Player.h"
Player::Player()
{
hp = 100.0f;
}
void Player::OnHeal(float _amount)
{
hp += _amount;
std::cout << "player hp : " << hp << std::endl;
}
void Player::OnDamage(float _damage)
{
hp -= _damage;
std::cout << ."player hp : " << hp << std::endl;
}
Enemy.h
#pragma once
#include "Unit.h"
#include <iostream>
class Enemy : public Unit
{
public:
Enemy();
public:
void OnDamage(float _damage);
void OnHeal(float _amount);
};
Enemy.cpp
#include "Enemy.h"
Enemy::Enemy()
{
hp = 100.0f;
}
void Enemy::OnHeal(float _amount)
{
hp += _amount;
std::cout << "enemy hp : " << hp << std::endl;
}
void Enemy::OnDamage(float _damage)
{
hp -= _damage;
std::cout << "enemy hp : " << hp << std::endl;
}
Card를 상속받는 ACard, BCard 구현
ACard는 Attack을 할 수 있는 카드, BCard는 Heal을 할 수 있는 카드로 가정합니다. 해당 카드들은 모두 Card를 상속받도록 구현하였습니다.
ACard.h
#pragma once
#include "Card.h"
#include "IAttackableCard.h"
#include "Unit.h"
class ACard : public Card
{
public:
ACard();
};
BCard.h
#pragma once
#include "Card.h"
#include "IAttackableCard.h"
#include "IHealableCard.h"
class BCard : public Card
{
public:
BCard();
};
카드의 기능을 정의하는 인터페이스 만들기
이제, Card를 상속받는 구체적인 ACard, BCard가 나왔습니다. 하지만 지금은 이 카드에 기능이 없습니다. 만약 ACard에 공격하는 기능의 함수, BCard에 공격하는 기능의 함수를 만들고, 인자로 Player를 직접 받아서 Player에게 효과를 줘야 합니다. 또한, 카드가 추가될 때 마다 CardBattleManager 에서는 구체적인 카드가 추가될 때 마다 구체적인 카드를 멤버 변수로 가지고 있어야 합니다.
해당 문제를 해결하기 위해 카드의 기능은 인터페이스로 정의 합니다. 공격을 할 수 있는 카드를 정의하는 IAttackableCard, 힐을 할 수 있는 카드를 정의하는 IHealableCard를 만듭니다.
IAttackableCard.h
#pragma once
#include "Unit.h"
class IAttackableCard
{
protected :
float attackDamage;
public:
virtual void DoAttack(Unit* _target) = 0;
virtual ~IAttackableCard() = default;
};
IHealableCard.h
#pragma once
#include "Unit.h"
class IHealableCard
{
protected:
float healAmount;
public:
virtual void DoHeal(Unit* _unit) = 0;
virtual ~IHealableCard() = default;
};
카드의 기능을 담당하는 인터페이스 상속
이제, 인터페이스를 만들었으니, 공격을 해야하는 ACard와 힐을 해야 하는 BCard에 각자 인터페이스를 상속시켜 줍니다. 그리고, 인터페이스의 순수가상함수를 cpp파일에서 정의해줍니다.
ACard.h
#pragma once
#include "Card.h"
#include "IAttackableCard.h"
#include "Unit.h"
class ACard : public Card, public IAttackableCard
{
public:
ACard();
public:
void DoAttack(Unit* _unit) override;
};
ACard.cpp
#include "ACard.h"
#include "Unit.h"
ACard::ACard()
{
attackDamage = 10.0f;
}
void ACard::DoAttack(Unit* _target)
{
_target->OnDamage(attackDamage);
}
BCard.h
#pragma once
#include "Card.h"
#include "IAttackableCard.h"
#include "IHealableCard.h"
class BCard : public Card, public IHealableCard
{
public:
BCard();
public:
void DoHeal(Unit* _unit) override;
};
BCard.cpp
#include "BCard.h"
BCard::BCard()
{
healAmount = 10;
}
void BCard::DoHeal(Unit* _unit)
{
_unit->OnHeal(healAmount);
}
이렇게 하면 ACard는 공격을 하는 카드, BCard는 힐을 하는 카드가 됩니다.
CardBattleManager 만들기
이제 카드와 Unit들을 다 구현하였으니, 이것들의 전투를 관리할 CardGameManager를 만듭니다. CardGameManager는 Main 함수에서 생성하여, 어떤 유닛에게 어떤 카드를 줘서 어떤 효과를 줄 지 중재하는 클래스 입니다.
어떤 카드의 효과를 어떤 유닛에게 줄것인가 에 대한 로직을 담당하는 HandleCard라는 함수를 만들었습니다. 해당 함수의 구현에 들어가면, Card 를 상속받는 객체가 들어왔을 때, 해당 객체가 어떤 인터페이스를 상속받는지 런타임에서 검사하게 됩니다.
런타임에서 검사하여 만약 해당 카드가 IAttackableCard라면, 공격할 수 있는 카드로 인식하여 특정 유닛에게 데미지를 주는 로직을 실행하게 됩니다. 마찬가지로, 어떤 카드가 IHealableCard라면 힐을 할 수 있는 유닛으로 인식하여 특정 유닛에게 힐을 주는 로직을 실행하게 됩니다.
또한, 모두 if문으로 구현되어, 특정 카드가 IAttackable, IHealable을 동시에 상속받는다면, 힐과 공격을 동시에 할 수 있습니다.
또한, 추상적인 인터페이스에 의존하여 구체적인 Card가 추가될 때, CardBattleManager는 수정할 필요가 없습니다. (물론 기획이 수정되어 폭발을 할 수 있는 카드인 IBombableCard가 생긴다면, 수정이 불가피 합니다.)
CardBattleManager.h
#pragma once
#include "Enemy.h"
#include "Player.h"
#include "Card.h"
#include "IAttackableCard.h"
#include "IHealableCard.h"
class CardBattleManager
{
public:
void HandleCard(Card* _card, Unit* _unit);
};
CardBattleManager.cpp
#include "CardBattleManager.h"
void CardBattleManager::HandleCard(Card* _card, Unit* _unit)
{
if (IAttackableCard* attackableCard = dynamic_cast<IAttackableCard*>(_card))
{
attackableCard->DoAttack(_unit);
}
if (IHealableCard* healableCard = dynamic_cast<IHealableCard*>(_card))
{
healableCard->DoHeal(_unit);
}
}
Main 함수에서 실행
이제, Main 함수에서 해당 카드게임을 실행해 봅니다.
ACard와 BCard를 생성하고, Player와 Enemy를 생성하여 서로에게 카드를 사용하도록 만들었습니다.
main.cpp
#include "CardBattleManager.h"
#include "Player.h"
#include "ACard.h"
#include "BCard.h"
int main()
{
ACard* aCard = new ACard();
BCard* bCard = new BCard();
Player* player = new Player();
Enemy* enemy = new Enemy();
CardBattleManager* cardBattleManager = new CardBattleManager();
cardBattleManager->HandleCard(bCard, player); //b 카드를 player에게 사용
cardBattleManager->HandleCard(aCard, enemy); //a 카드를 enemy에게 사용
cardBattleManager->HandleCard(aCard, player); //a 카드를 player에게 사용
cardBattleManager->HandleCard(bCard, enemy); //b 카드를 enemy에게 사용
delete cardBattleManager;
delete enemy;
delete aCard;
delete bCard;
delete player;
}
각자 한번씩 공격을 하고, 힐을 하도록 구성하였습니다. 결과를 보면, 다음과 같이 출력이 됩니다.

마무리
본 예제는 카드게임을 간단하게 구현한 예제 입니다. 추상화를 통해 양방 의존관계를 줄이고, 확장성을 고려한 설계 입니다. 실제 카드게임을 굴리는 로직은 이것보다 복잡하겠지만, 기본 원리 자체는 거의 유사하다고 생각합니다.
댓글 남기기