개요
저번 글 적고 바로 적는 개발일지입니다.
제가 어제 하루종일 적은 코드니 아직 머릿속에 생생합니다.
Vector2
Entity를 적기 전에 일단 각각의 위치들을 훨씬 원활하게 관리하기 위해 Vector2 구조체를 적겠습니다.
//Vector2.h
struct Vector2
{
Vector2() : x{ 0 }, y{ 0 } {};
Vector2(int x, int y) : x{ x }, y{ y } {};
int x;
int y;
Vector2 operator+(const Vector2& other);
void operator+=(const Vector2& other);
Vector2 operator-(const Vector2& other);
void operator-=(const Vector2& other);
Vector2 operator*(const Vector2& other);
void operator*=(const Vector2& other);
Vector2 operator*(int scalar);
void operator*=(int scalar);
};
가지고 있는건 x,y가 전부지만 사실 Vector2 구조체를 만드는데엔 연산자 오버로딩이 전부입니다.
+ 랑 +=이 사실상 다른거여서 오버로딩을 따로 해주어야 합니다.
이거 좀 충격이에요. 알아서 v1 += v2는 v1 = v1 + v2로 풀어주는줄 알았는데 안되더라구요.
//Vector2.cpp
#include "Vector2.h"
Vector2 Vector2::operator+(const Vector2& other)
{
return Vector2(x + other.x, y + other.y);
}
void Vector2::operator+=(const Vector2& other)
{
x += other.x;
y += other.y;
}
Vector2 Vector2::operator-(const Vector2& other)
{
return Vector2(x - other.x, y - other.y);
}
void Vector2::operator-=(const Vector2& other)
{
x -= other.x;
y -= other.y;
}
Vector2 Vector2::operator*(const Vector2& other)
{
return Vector2(x * other.x, y * other.y);
}
void Vector2::operator*=(const Vector2& other)
{
x *= other.x;
y *= other.y;
}
Vector2 Vector2::operator*(int scalar)
{
return Vector2(x * scalar, y * scalar);
}
void Vector2::operator*=(int scalar)
{
x *= scalar;
y *= scalar;
}
...연산자 오버로딩을 전부 적었습니다. 이건 이거대로 노가다네요.
Vector2를 적었으니 이제 int x, int y 로 매개변수를 넘겨주는 대신 const Vector2& pos로 매개변수를 넘겨줘야겠죠.
//MapManager.cpp
void MapManager::setCell(const Cell& cell, const Vector2& pos)
{
_arrMap[pos.y][pos.x] = cell;
}
Cell* MapManager::getCell(const Vector2& pos)
{
return &_arrMap[pos.y][pos.x];
}
이런식으로요.
다른 곳도 다 적용해주면 Vector2 기반 위치 체계 끝입니다.
Entity
시작하기 앞서 Entity란?
: 아군, 적, 덫 등 객체를 이르는 말.
...객체인데 왜 Object가 아니라 Entity냐구요? 그게 조금 더 맥락상 맞을 것 같아서 그렇습니다.
아무튼 클래스를 적어줍시다.
있어야 할건 이 Entity의 색상 및 모양(글자), 위 , 그리고 타입 입니다.
치
타입은... 저번 글에 잠깐 스쳐지나갔는데 이렇게 생겼습니다.
enum class ENTITY_TYPE
{
ALLY,
ENEMY,
TRAP
};
아무튼 Entity에서 필요한건 이 것 뿐입니다.
//Entity.h
class Entity
{
public:
Entity();
virtual ~Entity();
protected:
COLOR _color;
ENTITY_TYPE type;
Vector2 _currentPos;
string _renderString;
public:
COLOR getColor() { return _color; }
ENTITY_TYPE getType() { return type; }
Vector2 getPos() { return _currentPos; }
string getRenderString() { return _renderString; }
virtual void setPos(const Vector2& pos);
};
setPos는... 왜 저거 virtual이죠? 저도 잘 모르겠습니다.
Cell, MapManager
아무튼 Entity를 다 적고나니 얘가 위치는 있는데 위치만있죠?
이러면 문제가 생깁니다. 만약 아군이 1x1짜리 공격을 (0, 10)에 했다고 해봅시다.
그러면 그 위치에 해당하는 Enemy가 있는지 모든 Enemy를 하나씩 뜯어봐야 하는 불상사가 생깁니다.
실제로 격자를 만들때 알고리즘을 그따구로 짜놔서 코드가 정말 개판입니다.
1학기 프로젝트가 전부 끝나면 그것부터 뜯어고칠겁니다. 격자 업데이트 기대해주세요.
아무튼 이 이야기를 왜 하느냐?
위에서 말했던 상황을 방지하기 위해 Cell 클래스를 조금 만져줄것이기 때문입니다.
어짜피 모든 Enemy는 똑같이 Vector2라는 구조체를 통해 위치를 갖고 있으니 공격 범위 내에 한해서 Cell을 가져온 뒤에 그곳에 있는 적들에게 공격을 하면 되겠죠?
그렇다면 Cell이 본인 위치에 있는 Entity들을 갖고 있을 필요가 있습니다.
아군이나 덫 또한 검사를 해야할 수도 있기 때문이죠.
덫은 검사를 하는게 정석이지만요.
아무튼 이제 Cell은 Entity*를 담은 vector를 갖습니다.
//Cell.h
vector<Entity*> entityVec;
그 뒤에 Entity들이 이 Cell에 들어오고 나갈 수 있도록 함수 2개를 만들어줍니다.
//Cell.h
void registerEntity(Entity* entity);
void deregisterEntity(Entity* entity);
그리고 들어올떄마다 vector에 push_back 해주고 나갈땐 erase 해주면 됩니다.
나머지 코드는 렌더링 관련입니다.
들어올때마다 renderString과 charColor를 들어오는 Entity에 맞춰 바꿔주고 나갈때 vector에 남은 적이 없다면 초기 상태로, 있다면 가장 최근에 들어온 Entity의 renderString과 charColor를 받아옵니다.
//Cell.cpp
void Cell::registerEntity(Entity* entity)
{
entityVec.push_back(entity);
renderString = entity->getRenderString();
charColor = entity->getColor();
}
void Cell::deregisterEntity(Entity* entity)
{
auto it = find(entityVec.begin(), entityVec.end(), entity);
if (it != entityVec.end())
{
entityVec.erase(it);
if (entityVec.size() != 0)
{
renderString = entityVec.back()->getRenderString();
charColor = entityVec.back()->getColor();
}
else
{
renderString = " ";
charColor = COLOR::WHITE;
}
}
}
Cell 수정을 거쳤으니 MapManager 또한 수정해줍니다,
별거 없습니다. 그냥 getCell을 거치지 않게 해주는 함수이죠.
//MapManager.h
void registerEntityInCell(Entity* entity, const Vector2& pos);
void deregisterEntityInCell(Entity* entity, const Vector2& pos);
//MapManager.cpp
void MapManager::registerEntityInCell(Entity* entity, const Vector2& pos)
{
_arrMap[pos.y][pos.x].registerEntity(entity);
}
void MapManager::deregisterEntityInCell(Entity* entity, const Vector2& pos)
{
_arrMap[pos.y][pos.x].deregisterEntity(entity);
}
Enemy
이제 Enemy부터 적어볼까요?
Enemy는 뭐가 필요할까요?
일단 HP, 죽였을때 주는 재화의 양, 이동 속도... 정도가 있겠죠?
그리고 길을 따라 움직여야 하니 움직이는 함수가 필요하구요.
HP를 건드려줄 함수도 필요하구요.
죽어버리는 함수 또한 필요합니다.
구상 끝났죠? 적읍시다.
//Enemy.h
class Enemy : public Entity
{
public:
Enemy();
virtual ~Enemy();
protected:
int _hp;
int _rewardGold;
int _moveTime = 2000;
public:
virtual void Move();
virtual void ModifyHP(int value);
virtual bool CheckDead();
virtual void Dead();
};
이제 함수를 대충 구현해주면... 끝입니다.
//Enemy.cpp
void Enemy::Move()
{
}
void Enemy::ModifyHP(int value)
{
_hp += value;
}
bool Enemy::CheckDead()
{
return _hp <= 0;
}
void Enemy::Dead()
{
GET_SINGLETON(EntityManager)->despawnEntity(this);
}
움직이는건 나중에 해주죠.
Ally
Ally도 해줄건 딱히 없습니다.
먼저 죽진 않을테니 HP는 필요 없고 공격 속도, 공격력, 사정거리, 가격 정도만 있으면 될것 같습니다.
함수는 공격 함수와 공격 조건에 따라 공격할 Enemy를 판단할 함수만 있으면 되겠네요.
//Ally.h
class Ally : public Entity
{
public:
Ally();
virtual ~Ally();
private:
int _attackTime = 500;
int _attackRange = 5;
int _damage = 5;
int _price = 20;
public:
virtual void attack() abstract;
virtual Enemy* defineTarget() abstract;
};
공격이랑 공격할 Enemy를 고르는 방식은 아군마다 제각각이니 추상 함수로 만들어줍니다.
EntityManager
사실 이 글의 핵심이자 제가 제일 고생한 곳입니다.
EntityManager는 Entity의 스폰, 디스폰을 관리하는 싱글톤 클래스입니다.
스폰과 디스폰은 enum을 넣었을때 그에 해당하는 Entity를 만들어주도록 구현하겠습니다.
그래서 이 스폰과 디스폰을 구현하기 위해 모든 적과 아군의 종류를 enum으로 관리해주겠습니다.
//Type.h
enum class ALLY_TYPE
{
ARCHER
};
enum class ENEMY_TYPE
{
GOBLIN
};
일단 간단하게 궁수랑 고블린만 적어줍시다.
그 뒤에 MapManager에서 map을 선언해주는데...
//EntityManager.h
map<ALLY_TYPE, Ally*> _allyMap;
map<ENEMY_TYPE, Enemy*> _enemyMap;
이렇게 하면 문제가 있죠?
포인터는 '주소를 담는 변수'죠? 그러면 제가 이 map을 통해 ALLY_TYPE::ARCHER를 두번 스폰했다고 해봅시다.
그러면 객체가 2개가 생기는게 아니라 똑같은 객체를 공유하게 되겠죠.
왜냐? 같은 주소를 가르키니까요.
...당연한 말이였죠?
왜 이런 말을 했냐면 제가 진짜 개멍청하게 이렇게 구조를 짰거든요.
물론 테스트 직전에 깨닫긴 했지만요...
아무튼 map에서 해당하는 객체를 꺼내오는 코드부터 간단하게 짜봅시다.
//EntityManager.h
Ally* spawnEntity(ALLY_TYPE type, const Vector2& pos);
Enemy* spawnEntity(ENEMY_TYPE type, const Vector2& pos);
void despawnEntity(Ally* ally);
void despawnEntity(Enemy* enemy);
type과 pos를 받아서 해당하는 위치에 해당하는 타입의 Entity를 소환해줄겁니다.
그저 map에 type을 넣어서 나오는 값을 return하면 됩니다.
despawn같은 경우는 받아온 객체를 Cell에서 deregister 시켜주고 delete 시켜주면 됩니다.
Ally* EntityManager::spawnEntity(ALLY_TYPE type, const Vector2& pos)
{
auto it = _allyMap.find(type);
if (it != _allyMap.end())
{
Ally* ally = it->second;
ally->setPos(pos);
_allyVec.push_back(ally);
return ally;
}
return nullptr;
}
Enemy* EntityManager::spawnEntity(ENEMY_TYPE type, const Vector2& pos)
{
auto it = _enemyMap.find(type);
if (it != _enemyMap.end())
{
Enemy* enemy = it->second;
enemy->setPos(pos);
_enemyVec.push_back(enemy);
return enemy;
}
return nullptr;
}
void EntityManager::despawnEntity(Ally* ally)
{
GET_SINGLETON(MapManager)->deregisterEntityInCell(ally, ally->getPos());
auto it = find(_allyVec.begin(), _allyVec.end(), ally);
if (it != _allyVec.end())
{
_allyVec.erase(it);
delete& it;
}
}
void EntityManager::despawnEntity(Enemy* enemy)
{
GET_SINGLETON(MapManager)->deregisterEntityInCell(enemy, enemy->getPos());
auto it = find(_enemyVec.begin(), _enemyVec.end(), enemy);
if (it != _enemyVec.end())
{
_enemyVec.erase(it);
delete& it;
}
}
_allyVec과 _enemyVec 같은 경우엔 얘네가 new 키워드를 통해 힙 메모리에 할당되다보니 혹시 모를 메모리 누수 현상 방지를 위해 만들때마다 미리미리 집어넣고 혹시 Entity들을 모두 despawn시키지 않았을때 vector를 돌며 delete 해주는 그런 그림을 노렸습니다.
스마트 포인터
하지만 이 코드가 제가 위에서 말했듯이 spawn할때마다 객체를 생성해주는 코드는 아니죠.
그렇기 때문에 GPT에게 문의를 했습니다.
GPT-4o는 생각보다 똑똑한 편입니다. 요즘 신세지고 있죠.
매우 기이이인 답변이 왔습니다.
대충 제가 원하는 예시긴 했습니다.
vv GPT의 코드 전문 보기 vv
#include <iostream>
#include <map>
#include <memory>
#include <functional>
// Enum 정의
enum class MyEnum {
TypeA,
TypeB,
TypeC
};
// Base 클래스 정의
class Base {
public:
virtual void print() const = 0;
virtual ~Base() = default;
};
// TypeA 클래스 정의
class TypeA : public Base {
public:
void print() const override {
std::cout << "TypeA instance" << std::endl;
}
};
// TypeB 클래스 정의
class TypeB : public Base {
public:
void print() const override {
std::cout << "TypeB instance" << std::endl;
}
};
// TypeC 클래스 정의
class TypeC : public Base {
public:
void print() const override {
std::cout << "TypeC instance" << std::endl;
}
};
// 함수 정의
std::unique_ptr<Base> createInstance(MyEnum type) {
// enum 값과 클래스 생성 함수를 매핑하는 map
static std::map<MyEnum, std::function<std::unique_ptr<Base>()>> factoryMap = {
{ MyEnum::TypeA, []() { return std::make_unique<TypeA>(); } },
{ MyEnum::TypeB, []() { return std::make_unique<TypeB>(); } },
{ MyEnum::TypeC, []() { return std::make_unique<TypeC>(); } }
};
// map에서 해당 enum 값에 대한 생성 함수 호출
auto it = factoryMap.find(type);
if (it != factoryMap.end()) {
return it->second();
} else {
throw std::invalid_argument("Invalid enum value");
}
}
int main() {
// 예제 사용
std::unique_ptr<Base> instanceA = createInstance(MyEnum::TypeA);
instanceA->print();
std::unique_ptr<Base> instanceB = createInstance(MyEnum::TypeB);
instanceB->print();
std::unique_ptr<Base> instanceC = createInstance(MyEnum::TypeC);
instanceC->print();
return 0;
}
하지만 전 스마트 포인터는 알아서 객체를 지워주는 편리한 메모리 관리 포인터라는 점 외엔 몰랐습니다.
그렇기 때문에 GPT에게 정리를 부탁했죠.
...그렇다네요.
하지만 unique_ptr을 사용할 경우 매개변수로 넘겼을때 항상 std::move() 를 통해 소유권을 이전해야 하고 일반 포인터들은 std::make_unique() 함수로 묶어 저장해야 한다는 단점이 있습니다.
그러니까, EntityManager에서 unique_ptr을 쓴다면 지금까지 썼던 거의 모든 포인터들을 전부 unique_ptr로 바꿔주고 std::move와 std::make_unique를 하나하나 뜯어가며 적어줘야 합니다.
...너무 귀찮죠? 그래서 원시 포인터만으로 해보기로 했습니다.
...사실 문제가 너무 많았어요.
일단 엄청난 오류가 저를 반겨줬습니다.
이유는 알 수 없었어요. 삭제된 함수를 참조한다는데 전 그저 it->second를 했을 뿐입니다 ㅠㅠㅠㅠㅠㅠ
아무튼 이러한 연유로 원시 포인터를 사용하기로 했습니다.
함수 포인터
C#에서 delegate가 있다면 C++에선 함수 포인터가 있다고 누군가 말했습니다.
누군진 몰라요. 제가 방금 적은말이거든요.
함수 포인터는 말 그대로 함수를 가르키는 포인터입니다.
이 포인터가 가르키는 함수는 포인터를 통해 실행할 수 있습니다.
그렇기 때문에 map에 enum과 그에 해당하는 클래스 대신 클래스를 만들어준 뒤 반환해주는 함수를 묶어주겠습니다.
//EntityManager.h
map<ALLY_TYPE, Ally*(*)()> _allyMap;
map<ENEMY_TYPE, Enemy*(*)()> _enemyMap;
...map은 이래 생겨먹었는데 이게 무슨 뜻이냐하면
Ally*/Enemy* 를 반환하는 함수 포인터라는 뜻입니다.
조금 난해한데 앞에 클래스 포인터와 (*)() 를 떼어놓고 보면 볼만합니다.
시험을 해보기 위해 테스트용으로 Archer와 Goblin 클래스를 만들어줍니다.
//Archer.h
class Archer : public Ally
{
public:
void attack() override;
Enemy* defineTarget() override;
};
//Goblin.h
class Goblin : public Enemy
{
};
그리고 EntityManager를 초기화 할때 map에 짝지어서 넣어줍니다.
//EntityManager.cpp
void EntityManager::init()
{
_allyMap.insert(std::make_pair(ALLY_TYPE::ARCHER, []() -> Ally* { return new Archer(); }));
_enemyMap.insert(std::make_pair(ENEMY_TYPE::GOBLIN, []() -> Enemy* { return new Goblin(); }));
}
람다도 썼는데... 아직은 잘 모르겠습니다.
개인적으로 모르는건 안써먹는다는 주의지만 너무 급했습니다.
프로젝트 일정에 여유가 생기면 전체적으로 개념 정리하면서 추가로 정리하겠습니다.
진짜로요.
아무튼 이렇게 될 경우 spawnEntity함수에서 it-second부분이 오류를 뱉습니다.
왜냐구요?
//EntityManager::spawnEntity()
auto it = _allyMap.find(type);
if (it != _allyMap.end())
{
Ally* ally = it->second;
왜긴 왭니까? 함수 포인터를 클래스 포인터에 넣으려고 하니까 오류가 나죠.
아무튼 함수 포인터에 있는 함수를 실행시키기 위해
괄호를 붙여줍시다.
//EntityManager::spawnEntity()
Ally* ally = it->second();
이러면, 끝입니다.
GPT한테 도움을 상당히 받아서 커밋 이름도 GPT에게 추천받았습니다.
저 중에서 맘에 드는 것이였던
'Feat: EntityManager embraces the power (and responsibility) of raw pointers.'
추가: EntityManager는 원시 포인터의 힘(그리고 책임)을 수용합니다.
를 커밋 이름으로 했습니다.
많이 신났죠?
...
GPT는 신이 맞습니다.
마치는 말
조금 두서 없게 적었는데 최대한 정보를 꽉꽉 눌러담으려고 노력했습니다.
드디어 개발 진도를 개발 일지가 따라왔습니다.
물론 아직 적 움직임을 안적었습니다. 그래도 금방 적죠. 다음글에서 뵙겠습니다.......
'개발 > Default Defense' 카테고리의 다른 글
Default Defense | 다섯번째 프로젝트 # 6 : Ally Attack (0) | 2024.06.29 |
---|---|
Default Defense | 다섯번째 프로젝트 # 5 : Enemy Movement (1) | 2024.06.21 |
Default Defense | 다섯번째 프로젝트 # 3 : 맵 (2) | 2024.06.15 |
Default Defense | 다섯번째 프로젝트 # 2 : Transition (0) | 2024.06.14 |
Default Defense | 다섯번째 프로젝트 # 1 : Scene 구조, 싱글톤 (0) | 2024.06.03 |