깃허브 주소 : https://github.com/yunu95/GuardianShooter
유튜브 주소 : https://youtu.be/tS9Ps5plkps?si=ffo03FuEch4AwP40
목차
1. 프로젝트의 시작
2. Yunuty 게임엔진
2.1. Collider2D
2.2. D2DAnimatedSprite
2.3. YunutyCycle
2.4. D2DCamera
3. 인게임 로직 코드
3.1. 플랫포머 시스템
3.2. 디버그 출력
4. 게임 에디터 기능
4.1. 에디터 버튼 기능
4.2. 플랫폼 배치
4.3. 장식물 배치
4.4. 카메라 레일
4.5. 적군 마커
4.6. 적군 웨이브
5. 소감
6. 기타 설계문서
1. 프로젝트의 시작
이번에 맡게 된 게임의 기획은 2D 플랫포머 슈팅 게임을 만들자는 것으로, 한 마디로 메탈슬러그와 유사한 게임을 만들자는 것이었다. 자체엔진의 안정성을 시험하고 싶었던 나에게 있어서는 좋은 기회였다. 주어진 개발 기간은 3주일이었다.
팀 구성 | |
프로그래밍 팀장 | 나 |
프로그래밍 팀원 (레벨 에디터, 전체 UI) | A |
프로그래밍 팀원 (인게임 로직) | B |
프로그래밍 팀원 (인게임 로직) | C |
아트 팀장( 캐릭터 ) | D |
아트 팀원( 배경 ) | E |
기획 팀장 | F |
기획 팀원 | G |
외국어 이름은 모두 가명임.
A는 대학에서 기계공학과와 시각디자인학과를 복수 전공한 분으로, 동료 학생 중 가장 큰 맏형님이셨다. 코딩을 배운지 얼마되지 않으셨지만, 의지와 재능이 매우 출중하신데다 솔직하면서도 인간적인 정이 많아 여러모로 의지가 되는 분이어서 언젠가 현업에서 꼭 다시 뵈었으면 하는 마음이 있었다.
B는 해야 하는 과제가 있으면 천천히, 그리고 묵묵히 해치우는 타입이었다. 가끔씩 작업에 대한 큰 줄기만 짚어주면 일일히 디테일하게 지시를 하지 않아도 되어 좋았다.
C는 음악을 좋아하는 유쾌하고 싹싹한 성격의 건실청년이었다. 생각을 코드로 옮기는 것은 많이 미숙한 친구였지만, 이 친구와 이야기를 할 때마다 날카로운 포인트를 잘 짚어낼 줄 안다는 느낌을 종종 받았다.
2. Yunuty 게임엔진
게임인재원에서 2학기를 보내면서 여유시간이 날 때마다 종갓집 김치를 개발할 때 썼던 자체 게임엔진의 기능을 개선하고 개발해왔었다. 2학기 프로젝트를 시작하기 전 구현한 게임엔진의 기능으로는 OOBB 기반 2차원 박스 충돌체, 스프라이트 애니메이션, 카메라, A*알고리즘 기반 길찾기 등이 있었다.
2.1. Collider2D
제대로 된 2D게임을 만들기 위해서는 어느정도 최적화가 고려된 2D 충돌처리 모듈들을 개발할 필요가 있었다. 따라서 OOBB(Object Oriented Bounding Box) 사각형 콜라이더와 원형 콜라이더들을 만들고 쿼드트리를 만들어 같은 구역에 있는 충돌체들만 서로 겹침 여부를 체크하게 만들었다.
#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Component.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"
#include "D2DGraphic.h"
#include "Interval.h"
#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif
using namespace std;
namespace YunutyEngine
{
class BoxCollider2D;
class CircleCollider2D;
class LineCollider2D;
class RigidBody2D;
class YUNUTY_API Collider2D abstract : public Component
{
public:
struct YUNUTY_API QuadTreeNode
{
static unique_ptr<QuadTreeNode> rootNode;
double GetArea() { return xInterval.GetLength() * yInterval.GetLength(); }
Interval xInterval;
Interval yInterval;
unique_ptr<QuadTreeNode> leftTop;
unique_ptr<QuadTreeNode> rightTop;
unique_ptr<QuadTreeNode> leftBottom;
unique_ptr<QuadTreeNode> rightBottom;
vector<Collider2D*> colliders;
};
const unordered_set<Collider2D*>& GetOverlappedColliders() const;
virtual double GetArea() const = 0;
virtual bool isOverlappingWith(const Collider2D* other) const = 0;
virtual bool isOverlappingWith(const BoxCollider2D* other) const = 0;
virtual bool isOverlappingWith(const CircleCollider2D* other) const = 0;
virtual bool isOverlappingWith(const LineCollider2D* other) const = 0;
private:
// called by yunuty cycle
static void InvokeCollisionEvents();
protected:
static unordered_set<Collider2D*> colliders2D;
unordered_set<Collider2D*> overlappedColliders;
Collider2D();
virtual ~Collider2D();
virtual bool isInsideNode(const QuadTreeNode* node)const = 0;
static bool isOverlapping(const BoxCollider2D* a, const BoxCollider2D* b);
static bool isOverlapping(const BoxCollider2D* a, const CircleCollider2D* b);
static bool isOverlapping(const CircleCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a, b); }
static bool isOverlapping(const BoxCollider2D* a, const LineCollider2D* b);
static bool isOverlapping(const LineCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a,b); }
static bool isOverlapping(const CircleCollider2D* a, const CircleCollider2D* b);
static bool isOverlapping(const CircleCollider2D* a, const LineCollider2D* b);
static bool isOverlapping(const LineCollider2D* b, const CircleCollider2D* a) { return isOverlapping(a,b); }
static bool isOverlapping(const LineCollider2D* a, const LineCollider2D* b);
friend YunutyCycle;
};
}
Collider2D는 모든 유형의 2차원 충돌체 인스턴스들의 기본 클래스다. Collider2D::InvokeCollisionEvents() 함수는 YunutyCycle 객체로부터 호출되는 스태틱 함수로 게임에 존재하는 모든 쿼드트리 노드들을 순회하며 같은 노드에 속한 충돌체들이 서로서로 겹치는지 확인하고, 콜라이더들 간에 충돌이 일어나거나, 충돌이 끝나는 등 특기할만한 이벤트가 생기면 그에 맞는 콜백 함수들을 호출하게 한다.
namespace YunutyEngine
{
class YUNUTY_API Component : public Object
{
.....
// 충돌체들이 서로 충돌을 시작할 때 호출되는 콜백 함수입니다.
virtual void OnCollisionEnter(const Collision& collision) {};
virtual void OnCollisionEnter2D(const Collision2D& collision) {};
// 충돌체들이 서로 겹쳐진 상태로 있을때 매 프레임마다 호출되는 콜백 함수입니다.
virtual void OnCollisionStay(const Collision& collision) {};
virtual void OnCollisionStay2D(const Collision2D& collision) {};
// 충돌체들이 충돌을 마치고 서로 떨어져 나갈때 호출되는 콜백 함수입니다.
virtual void OnCollisionExit(const Collision& collision) {};
virtual void OnCollisionExit2D(const Collision2D& collision) {};
....
}
}
이벤트가 생기면 콜라이더를 포함하는 게임오브젝트의 모든 컴포넌트들의 콜백함수가 호출되며, 호출될 수 있는 콜백함수들의 목록은 위 코드에서 확인할 수 있다.
2.2. D2DAnimatedSprite
폴더에 프레임별로 이미지를 넣고 폴더의 경로를 제공하면 2D 스프라이트 애니메이션을 재생할 수 있게 하는 애니메이션 재생 컴포넌트를 만들었다.
#pragma once
#include "D2DGraphic.h"
#include "YunutyEngine.h"
#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif
using namespace YunutyEngine::D2D;
namespace YunutyEngine
{
namespace D2D
{
typedef vector<pair<double, wstring>> SpriteAnim;
class YUNUTY_API D2DAnimatedSprite : public D2DGraphic
{
public:
void SetIsRepeating(bool repeating) { this->isRepeating = repeating; }
bool GetIsRepeating() { return this->isRepeating; }
void LoadAnimationFromFile(wstring folderName, double interval = 0.0);
void Play();
void SetWidth(double width);
void SetHeight(double width);
double GetWidth() { return width; }
double GetHeight() { return height; }
protected:
virtual void Update() override;
virtual void Render(D2D1::Matrix3x2F transform) override;
static const SpriteAnim* LoadAnimation(wstring folderName, double interval = 0.0);
void SetAnimation(const SpriteAnim* animation);
const SpriteAnim* GetAnimSprites() const;
private:
wstring loadedAnimFilePath;
bool isRepeating = true;
const SpriteAnim* animSprites = nullptr;
int index = 0;
double elapsed = 0;
double width = 100;
double height = 100;
static unordered_map<wstring, vector<pair<double, wstring>>> cachedAnims;
};
}
}
D2D AnimatedSprite는 이미지 파일 경로 하나가 아니라 폴더 경로를 매개변수로 받아 폴더 내부의 이미지들을 스프라이트 애니메이션의 매 프레임 이미지들로 등록한다. Play 함수를 호출하면 게임 오브젝트의 위치에 이미지들을 촤라라락 넘기며 스프라이트 애니메이션을 재생한다.

void D2DAnimatedSprite::Update()
{
if (!animSprites)
return;
if (animSprites->empty())
return;
if (index >= animSprites->size())
return;
elapsed += Time::GetDeltaTime();
if ((*animSprites)[index].first < elapsed)
{
index++;
if (index >= animSprites->size())
{
if (isRepeating)
{
index = 0;
elapsed = 0;
}
else
{
index--;
}
}
}
}
업데이트 함수에서는 게임엔진의 델타 타임을 누적시키며 시간이 충분히 찼다 싶으면 다음 스프라이트로 그림을 전환한다.
const SpriteAnim* D2DAnimatedSprite::LoadAnimation(wstring folderName, double interval)
{
HANDLE dir;
WIN32_FIND_DATA file_data;
if (cachedAnims.find(folderName) != cachedAnims.end())
return &cachedAnims[folderName];
cachedAnims[folderName] = SpriteAnim();
if ((dir = FindFirstFile((folderName + L"/*").c_str(), &file_data)) == INVALID_HANDLE_VALUE)
return nullptr; /* No files found */
double time = 0;
bool intervalFixed = interval > 0;
do {
const wstring file_name = file_data.cFileName;
const wstring full_file_name = folderName + L"/" + file_name;
if (file_name == L"." || file_name == L"..")
continue;
if (!intervalFixed)
{
wsmatch match;
smatch match2;
interval = 0;
regex_match(file_name, match, wregex(L".*?(\\d+)ms.*"));
//string a(file_name.begin(), file_name.end());
//regex_match(a, match2, regex("\\D(\\d+)ms.*"));
if (match.size() >= 2)
interval = 0.001 * stoi(match[1]);
if (interval <= 0)
interval = 0.1;
}
const bool is_directory = (file_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
if (file_name[0] == '.')
continue;
if (is_directory)
continue;
cachedAnims[folderName].push_back(make_pair(time, full_file_name));
time += interval;
} while (FindNextFile(dir, &file_data));
FindClose(dir);
return &cachedAnims[folderName];
}
LoadAnimation은 폴더명을 매개변수로 받아 애니메이션 정보를 가져와 스프라이트 애니메이션 객체에 적용시킨다. 이미 불러온 정보라면 캐싱된 데이터를 쓴다.
2.3. YunutyCycle
#pragma once
#include <thread>
#include "Object.h"
#include <functional>
#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif
using namespace std;
namespace YunutyEngine
{
class Component;
class YUNUTY_API YunutyCycle : Object
{
private:
thread updateThread;
bool isGameRunning = false;
void ActiveComponentsDo(function<void(Component*)> todo);
void ActiveComponentsDo(void (Component::* method)());
vector<Component*> GetActiveComponents();
vector<GameObject*> GetGameObjects(bool onlyActive = true);
static void UpdateComponent(Component* component);
static void StartComponent(Component* component);
void ThreadFunction();
protected:
static YunutyCycle* _instance;
YunutyCycle();
virtual ~YunutyCycle();
virtual void ThreadUpdate();
public:
static YunutyCycle& GetInstance();
virtual void Initialize();
virtual void Release();
void Play();
void Stop();
void Pause();
void SetMaxFrameRate();
bool IsGameRunning();
};
}
( YunutyCycle 클래스의 헤더파일 )
void YunutyEngine::YunutyCycle::ThreadUpdate()
{
Time::Update();
for (auto i = GlobalComponent::globalComponents.begin(); i != GlobalComponent::globalComponents.end(); i++)
(*i)->Update();
//i->second->Update();
for (auto each : Scene::getCurrentScene()->destroyList)
{
for (auto each : each->GetComponents())
each->OnDestroy();
each->parent->MoveChild(each);
}
Scene::getCurrentScene()->destroyList.clear();
for (auto each : GetGameObjects(false))
each->SetCacheDirty();
//ActiveComponentsDo(&Component::Update);
for (auto each : GetActiveComponents())
UpdateComponent(each);
Collider2D::InvokeCollisionEvents();
if (Camera::mainCamera)
Camera::mainCamera->Render();
}
( YunutyCycle 클래스의 스레드가 한 틱마다 돌리는 함수, ThreadUpdate )
모든 게임엔진에는 매 프레임마다 게임의 진행상황을 갱신하기 위해 실행시키는 게임 루프가 있다. Yunuty 엔진에서는 YunutyCycle이라는 싱글톤 객체에 게임 루프를 정의했다.
void YunutyEngine::D2D::D2DCycle::ThreadUpdate()
{
YunutyCycle::ThreadUpdate();
if (IsGameRunning())
RedrawWindow(hWnd, NULL, NULL, RDW_INVALIDATE | RDW_UPDATENOW);
}
게임루프는 엔진이 동작하는 기반환경에 따라 어떻게 내용이 바뀔지 모른다고 생각했기 때문에 YunutyCyle 클래스는 상속을 고려해 만들었다. 이번 프로젝트에서는 DirectX 2D 그래픽스 인터페이스를 사용해서 만들었기 때문에 D2DCycle이라는 YunutyCycle의 파생 클래스에서 게임 루프를 정의했다. D2DCycle은 YunutyCycle과 동일하게 틱 동작을 진행한 후 RedrawWindow라는 윈도우API 함수를 불러 화면의 렌더링을 유도한다.
2.4. D2DCamera
D2D카메라는 모든 DirectX 2D 그래픽스 객체들의 트랜스폼 정보를 카메라 중심의 뷰 공간으로 변환한 다음 화면에 그림을 그리는 역할을 수행했다.
void D2DCamera::Render()
{
}
LRESULT CALLBACK D2DCamera::Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
YunuD2D::YunuD2DGraphicCore::GetInstance()->BeginDraw();
YunuD2D::YunuD2DGraphicCore::GetInstance()->ResizeResolution(resolutionW, resolutionH);
SetNearPlane(Rect(resolutionW, resolutionH));
auto halvedRenderSize = YunuD2D::YunuD2DGraphicCore::GetInstance()->GetRenderSize();
halvedRenderSize.width /= 2;
halvedRenderSize.height /= 2;
vector<D2DGraphic*> graphics;
// render world space graphics
for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::WorldSpace])
{
if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
graphics.push_back(each);
}
#if _DEBUG
GameObject::messyIndexingCalled = 0;
#endif
sort(graphics.begin(), graphics.end(), [](const D2DGraphic* item1, const D2DGraphic* item2)->bool
{
return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
});
D2D1::Matrix3x2F eachTransform;
Vector3d camPos;
Vector3d pos;
Vector3d scale;
for (auto each : graphics)
{
eachTransform = D2D1::Matrix3x2F::Identity();
camPos = GetTransform()->GetWorldPosition();
pos = each->GetTransform()->GetWorldPosition();
scale = each->GetTransform()->GetWorldScale();
eachTransform = eachTransform * ScaleTransform(scale.x * 1 / zoomOutFactor, scale.y * 1 / zoomOutFactor);
eachTransform = eachTransform * RotationTransform(each->GetTransform()->GetWorldRotation().Euler().z);
eachTransform = eachTransform * TranslationTransform(halvedRenderSize.width + (pos.x - camPos.x) * 1 / zoomOutFactor, halvedRenderSize.height - (pos.y - camPos.y) * 1 / zoomOutFactor);
each->Render(eachTransform);
}
graphics.clear();
// render camera space graphics
for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::CameraSpace])
{
if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
graphics.push_back(each);
}
sort(graphics.begin(), graphics.end(), [](D2DGraphic*& item1, D2DGraphic*& item2)->bool
{
return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
});
for (auto each : graphics)
{
eachTransform = D2D1::Matrix3x2F::Identity();
camPos = Vector2d::zero;
pos = each->GetTransform()->GetWorldPosition();
scale = each->GetTransform()->GetWorldScale();
eachTransform = eachTransform * ScaleTransform(float(scale.x / zoomOutFactor), float(scale.y / zoomOutFactor));
eachTransform = eachTransform * RotationTransform(float(each->GetTransform()->GetWorldRotation().Euler().z));
eachTransform = eachTransform * TranslationTransform((halvedRenderSize.width + pos.x - camPos.x) * 1 / zoomOutFactor, (halvedRenderSize.height - (pos.y - camPos.y)) * 1 / zoomOutFactor);
each->Render(eachTransform);
}
YunuD2D::YunuD2DGraphicCore::GetInstance()->EndDraw();
return 0;
}
이전 프로젝트에서는 카메라의 멤버 함수 Render()가 렌더링 동작을 담당했지만 이번에는 윈도우의 Redraw 이벤트의 콜백함수 LRESULT CALLBACK Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)가 렌더링을 담당한다.
3. 인게임 로직 코드
3.1. 플랫포머 시스템
교수님께서는 자체 엔진으로 플랫포머 게임을 만들때 플랫폼 그 자체를 구현하는 것이 큰 난관이 될 것이라고 하셨다. 나는 플랫폼을 "유닛들을 수직방향으로 밀어내고, 수평방향으로 이동하게 만드는 객체"라고 정의하고 개발했다. 생각보다 처리해야할 예외사항이 많아 힘들었다.
#pragma once
#include "YunutyEngine.h"
using namespace YunutyEngine;
class Ground : public Component
{
public:
static const double groundHeight;
static Ground* CreateGround(Vector2d position, double rotationRadian = 0, double length = 300, bool isPenetrable = false);
bool isPenetrable;
bool isSteppable()
{
return GetTransform()->rotation.Up().y > 0.4;
//&&
//Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0;
}
bool isUnderFoot(Vector3d footPosition) { return Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0; }
BoxCollider2D* groundCollider;
protected:
void OnCollisionStay2D(const Collision2D& colliision) override;
};
void Ground::OnCollisionStay2D(const Collision2D& collision)
{
auto player = collision.m_OtherCollider->GetGameObject()->GetComponent<Player>();
auto enemy = collision.m_OtherCollider->GetGameObject()->GetComponent<Threat>();
auto position = GetTransform()->GetWorldPosition();
auto rotation = GetTransform()->GetWorldRotation();
if (!enemy && !player)
return;
if (player &&
player->GetMovementState() == PlayerMovementState::JUMP &&
Vector3d::Dot(player->GetPlayerSpeed(), GetTransform()->GetWorldRotation().Up()) > 0)
return;
if (player &&
isPenetrable &&
(player->GetMovementState() == PlayerMovementState::JUMP ||
(!isUnderFoot(player->GetPlayerFootPos()))&& !isUnderFoot(player->lastFramePlayerPos)))
{
return;
}
if (player &&
abs(Vector3d::Dot(player->GetPlayerFootPos() - position, rotation.Right())) > groundCollider->GetWidth() * 0.5
)
{
return;
}
// 아래의 코드를 통해 지형이 플레이어나 적을 지형 반대방향으로 밀어냅니다.
auto otherTransform = collision.m_OtherCollider->GetTransform();
BoxCollider2D* otherBoxCollider = collision.m_OtherCollider->GetGameObject()->GetComponent<BoxCollider2D>();
Vector3d delta;
Vector3d delta_norm = Vector2d::DirectionByAngle((GetTransform()->GetWorldRotation().Euler().z + 90) * YunutyMath::Deg2Rad);
//Vector3d delta_norm2 = GetTransform()->GetWorldRotation().Euler().;
Vector3d otherBox_DeltaPoint = 0.5 * Vector3d(otherBoxCollider->GetWidth(), -otherBoxCollider->GetHeight(), 0);
if (GetTransform()->GetWorldRotation().Up().x > 0)
otherBox_DeltaPoint.x *= -1;
Vector3d centerDelta = otherTransform->GetWorldPosition() - GetTransform()->GetWorldPosition();
// 살짝 파고 들게 하기 위한 식
const double diggingRate = 0.9;
delta = delta_norm * ((groundCollider->GetHeight() * 0.5 + abs(Vector3d::Dot(otherBox_DeltaPoint, delta_norm))) * diggingRate - abs(Vector3d::Dot(centerDelta, delta_norm)));
otherTransform->SetWorldPosition(otherTransform->GetWorldPosition() + delta);
}
플레이어가 점프상태인지, 플랫폼이 점프로 뚫고 올라갈 수 있는 플랫폼인지의 여부에 따라 플랫폼이 플레이어와 상호작용하는 방법을 다르게 설정해줘야 했다.
3.2. 디버그 출력
나는 B와 C에게 각각 플레이어 캐릭터의 구현과 적군 유닛의 구현을 맡길 생각이었다. 각 객체들의 현재 상태가 어떤지, 공격을 하면 어느 범위에 맞게 되는지, 유닛과 탄환들의 충돌 크기는 어떻게 되는지, 이벤트가 일어났는지, 안 일어났는지 일일이 코드에 중단점을 찍으며 디버깅을 하도록 시킬 수는 없었기 때문에 디버깅에 도움이 될만한 출력기능들을 몇가지 만들었다. 디버그 박스나 텍스트 필드, 팝업 효과를 간편한 인터페이스를 통해 출력할 수 있게 만들고 이런 디버깅 기능들의 활용법을 B와 C에게 알려주고 나니 이제 가만히 있어도 클라이언트 코드가 척척 만들어졌고 남은 역량을 맵 에디터 기능 개발에 쓸 수 있게 되었다.
class DebugObject : public Component
{
public:
DebugObject();
~DebugObject();
static void EnableDebugmode();
static void DisableDebugmode();
static void ToggleDebugmode();
static void CreateDebugRectImage(GameObject* parent, double width, double height, D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
static void CreateColliderImage(BoxCollider2D* collider, D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
static void CreateDebugCircleImage(GameObject* parent, double radius, D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
static void CreateDebugText(GameObject* parent, function<wstring()>, Vector3d relativePosition = Vector3d::zero, double fontSize = 20, D2D1::ColorF color = D2D1::ColorF::Black);
static void CreateArrow(GameObject* parent, Vector3d origin, Vector3d destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
static void CreateArrow(GameObject* origin, GameObject* destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
static void CreatePopUpCircle(GameObject* parent, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
static void CreatePopUpCircle(Vector3d position, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
protected:
void Update() override;
void OnDestroy() override;
private:
static void _CreatePopUpCircle(GameObject* parent, Vector3d position, double radius, double duration, D2D1::ColorF color);
static tuple<GameObject*, DebugObject*, D2DRectangle* ,GameObject*,GameObject*> _CreateArrow(double lineLength, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
static bool debugMode;
static unordered_set<DebugObject*> debugObjects;
function<void()> onUpdate = []() {};
function<void()> onDebugEnabled = []() {};
function<void()> onDebugDisabled = []() {};
function<void()> onDestory = []() {};
}
디버그 사각형, 원, 화살표, 팝업 등을 생성하는 함수들은 아예 한 클래스의 정적 함수들로 몰아서 선언해 놓았다. 디버그 텍스트를 띄워야 하는 경우 어떤 정보를 어떻게 띄워야 하는지 알아야 하므로 string을 반환하는 functor를 매개변수로 받게 했다.
4. 게임 에디터 기능
내가 한동안 클라이언트 팀원들의 개발을 독려하는데에 집중하고 있을 때 A는 에디터의 역할은 무엇인지, 코딩을 어디서부터 시작해야 하는지 고민하고 있었다. 에디터 개발이라는 큰 과제를 소단위 과제로 분할하지도 않은 채 A에게 너무 덩그러니 맡겨서 그런지, 개발의 진척도가 많이 부진한 상태였다. 기획자들에게 레벨디자인 툴을 빠르게 넘겨줘야 할 필요가 있었기 때문에 맵 에디터의 기초적인 틀과 기능들은 내가 빠르게 만들기로 했다.
4.1. 에디터 버튼 기능
먼저 맵 에디터의 UI를 조작하거나 장식물이나 지형을 배치하기 위해 마우스 클릭이 가능한 버튼을 만들었다.
#pragma once
#include "YunutyEngine.h"
class Button :
public Component
{
public:
virtual ~Button();
virtual void Update() override;
static Button* CreateButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** text = nullptr);
static Button* CreateToggleButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** textOut=nullptr);
static Button* CreateToggleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& animSpritePath, double width = 150, double height = 150);
static Button* CreateToggleSimpleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& spritePath, double width = 150, double height = 150);
static Button* AddDraggableButton(GameObject* parent, double radius = 50);
static Button* AddDraggableButton(GameObject* parent, double width,double height,BoxCollider2D** colliderOut);
function<void(void)> onClick = []() {};
function<void(void)> onDrag = []() {};
function<void(void)> onSelect = []() {};
function<void(void)> onDeselect = []() {};
function<void(void)> onEnable = []() {};
function<void(void)> onMouseOver = []() {};
function<void(void)> onMouseExit = []() {};
function<void(void)> onUpdate = []() {};
function<void(void)> onUpdateWhileSelected = []() {};
virtual void OnMouseDrag();
virtual void OnLeftClick();
virtual void OnSelected();
virtual void OnDeselected();
virtual void OnMouseOver();
virtual void OnMouseExit();
virtual void OnEnable() { onEnable(); }
virtual void OnCollisionEnter2D(const Collision2D& collision);
virtual void OnCollisionExit2D(const Collision2D& collision);
//void OnMiddleClick();
//void OnRightClick();
bool toggleButton = false;
bool selected = false;
bool deselectOnESC=false;
vector<Button*> radioSelectGroup;
struct Comp
{
bool operator()(Button* const input1, Button* const input2)const
{
return input1->GetGameObject()->GetSceneIndex() < input2->GetGameObject()->GetSceneIndex();
}
};
private:
D2DRectangle* buttonPanel = nullptr;
};
버튼은 마우스 포인터와 버튼 객체에 각각 충돌체를 달아두고 충돌이벤트가 뜨면 버튼을 하이라이트시키는 방식으로 만들었다. 각 버튼이 클릭되거나 하이라이트되었을 때 어떻게 어떻게 동작할지 커스터마이징하고 싶다면 onClick, onDrag, onMouseOver 등 버튼 객체의 std::function 멤버변수들에 적절한 콜백 함수를 등록시키면 된다.
4.2. 플랫폼 배치
플랫폼은 뚫고 올라갈 수 있는 플랫폼과 가로막히게 되는 플랫폼, 두가지 종류로 나누었다. 레벨 디자이너는 맵에 노드들을 위치를 찍어가며 플랫폼을 배치할 수 있다. 이 지형들의 배치는 디버그 정보 표시를 끄면 보이지 않으므로 나중에 장식물 이미지를 적절히 배치해 캐릭터가 의도한 위치에 서 있을 수 있다는 당위성을 제공해야 한다.
4.3. 장식물 배치
장식물 이미지들은 빈 이미지를 배치하고 이미지 경로를 적절하게 입력하면 해당 이미지가 출력되도록 만들었다. 이미지의 위치나 회전값을 임의로 설정할 수 있으며, 캐릭터들을 가릴 것인지 캐릭터들에 가려질 것인지 설정할 수 있다.
카메라로부터 멀리 있는 장식물들은 스크롤 속도를 느리게 줄 필요가 있고, 카메라로부터 가까이 있는 장식물들은 스크롤 속도를 빠르게 줄 필요가 있다. 원근 투영을 하지 않는 카메라라도 이런 디테일을 넣어주면 연출에 정성을 들인 티가 난다.
4.4. 카메라 레일
메탈슬러그와 같은 일직선 레일 슈팅 게임은 게임의 진행경로가 고정되어 있으며 한번 앞으로 나아간 카메라가 다시 뒤로 돌아오지 못한다. 따라서 게임의 진행 방향과 카메라의 궤적을 설정할 수 있는 카메라 레일 기능을 개발할 필요가 있었다. 나는 A에게 "레벨 디자이너가 카메라 중점이 거치게 될 경유지를 노드로 찍을 수 있게 에디터 기능을 확장해달라. 카메라 중점이 나아갈 궤적을 디버그 이미지로 표시해야 하니 노드와 노드 사이에 화살표를 그려주고, 카메라의 위치는 캐릭터의 위치에 따라 화살표를 따라서 업데이트되어야 하니 이런 사양에 맞게 동작하는 카메라 컴포넌트의 코드도 짜 달라."고 부탁했다. 내가 어느정도 만들어 둔 맵 에디터의 코드들을 참고하여 A는 어렵지 않게 추가 기능을 확장할 수 있었으며 디버그 이미지 표시, 카메라 업데이트 코드 구현과 같은 코드도 어렵지 않게 만들어냈다.
4.5. 적군 배치
게임을 진행하면서 적들을 마주해야 할테니 적 유닛을 생성하는 마커를 맵에 배치할 수 있게 했다. 카메라에 비춰지는 화면 영역에 사각형 콜라이더를 달았는데, 이 화면 콜라이더가 마커와 부딪히면 적이 생성된다. 일반병을 생성하는 마커 코드를 내가 짰고, 나머지 병사들에 대한 코드는 A에게 구현을 부탁했다.
4.6. 적군 웨이브 배치
적군들이 스테이지를 전진하면서 앞에서만 나타난다면야 아무래도 게임의 긴장감이 떨어질 것이다. 한번씩 카메라를 고정시킨 다음 적들이 제파식 공격을 가하게 만드는 웨이브 구간을 중간 중간에 설정할 필요가 있는 것이다. 웨이브 구간을 클릭한 채로 특정 키를 누르면 웨이브의 시점을 조절할 수 있고, 이 때 병사 마커들을 배치하면 해당 시점에 적 유닛들이 출몰하게 된다. 웨이브와 관계가 있는 적 마커는 디버그 이미지로 줄을 달아 웨이브와의 연관성을 표시하게 만들었다. 반드시 처치해야만 웨이브를 넘길 수 있게 만든 필수척결 대상 마커들은 빨간색 줄로 연관성이 표시된다.
5. 소감
개인적으로 게임 인재원 생활중 가장 기억에 남는 프로젝트였다. 내가 팀원들에게 제시한 로드맵이나, 팀원들의 생산성 향상을 위한 노력에 대한 팀원들의 호응이 매우 좋아 내심 뿌듯한 기분이 많이 들었기 때문이다.
잘된 점 | 아쉬운 점 |
팀원들의 적극적인 프로젝트 참여에 대한 독려가 잘 되었고, 프로젝트 진행 중 사기가 높았음. 게임엔진의 안정성을 검증함. |
충돌체가 너무 많아지면 FPS가 급격히 떨어졌음. 충돌 체크 코드의 최적화에 아쉬운 부분이 많았음. 기획자들에게 에디터를 너무 늦게 줘 레벨 디자인의 완성도가 낮아짐. 에디터 기능으로 적이나 플레이어의 공격력, 체력 등을 조절할 수 있는 수치 조절 기능을 제공하지 못함. |
6. 기타 설계 문서
'완결된 게임 프로젝트' 카테고리의 다른 글
워크래프트3 던전 주파형 유즈맵, 용병던전 (0) | 2025.01.01 |
---|---|
InWanderLand - 쿼터뷰 전략 액션 게임 (1) | 2024.08.14 |
스트레시 - 소코반 퍼즐게임 (0) | 2023.10.11 |
종갓집 김치 - 클리커 게임 모작 프로젝트 (0) | 2023.10.05 |