이탈리안 브레인롯 서바이버즈 | 게임하기
개요
이 프로젝트는 자작 게임 프레임워크를 활용해 개발한 2D 서바이벌 액션 게임입니다. 게임 아티스트와 협업하여, 이탈리안 브레인롯 밈을 활용한 뱀파이어 서바이버즈를 모작을 성공적으로 완성했습니다. 또한, 프레임워크의 확장성과 재사용성을 검증하기 위한 실험적 프로젝트이기도 합니다.
게임 소개
플레이어는 끝없이 몰려오는 적을 상대하여 최대한 생존해야 하며, 5분이 경과하면 최종 보스가 등장합니다. 적을 처치하여 경험치를 획득해서 무기와 파워업을 강화하며, 이를 활용해 생존 시간을 최대화해야 합니다. 또한, 게임 시작 전에 파워업을 구매하여 초반 생존을 강화할 수 있습니다.
핵심 기능
⚠️ 참고: 아래의 코드 예제들은 간소화된 버전입니다. 전체 구현은 저장소에서 확인할 수 있습니다.
1. CSV 파일 기반 데이터 관리
모든 게임 관련 데이터(몹, 무기, 파워업 등)는 CSV 파일로 관리하여, 코드 수정 없이 CSV 파일만 수정하여 손쉽게 밸런스 조정과 콘텐츠 확장이 가능합니다.
프로그램 실행 시 CEngine::Init()에서 CDataLoader 인스턴스를 생성 및 초기화하여 모든 CSV 데이터 파일을 읽어드려 데이터를 저장합니다.
이를 통해 개발자는 코드 수정 없이 데이터만으로 게임 밸런스를 유연하게 조정할 수 있습니다. 이 구조는 유지보수성과 확장성을 모두 고려한 데이터 중심 설계의 핵심이라 할 수 있습니다.void CDataLoader::LoadAllMobData() { std::string filePath = CPathManager::GetInst()->FindPath(DATA_PATH); filePath += "GameData\\Mob_Data.csv"; std::ifstream file(filePath); if (!file.is_open()) { std::cerr << "Cannot open file at: " << filePath << "\n"; return; } std::string line; while (std::getline(file, line)) { if (line[0] == '#') continue; std::vector<std::string> row = Split(line, ','); FRegularMobData data; data.type = static_cast<ERegularMobType>(std::stoi(row[0])); data.baseHP = std::stof(row[1]); data.baseAttack = std::stof(row[2]); data.baseSpeed = std::stof(row[3]); data.baseExp = std::stof(row[4]); CGameDataManager::GetInst()->GetMobDataManager()->AddMobData(data); row.clear(); } file.close(); }
2. 스크롤 환경 시스템 구성
카메라 이동에 따라 단일 텍스처만으로도 끝없는 맵처럼 보이도록 스크롤됩니다. 오브젝트는 일정 거리(
![]()
snap distance) 이상 이동했을 때만 위치가 보정되어, 맵과 환경이 자연스럽게 동기화된 스크롤 효과를 효율적으로 구현합니다.
위 이미지는 실제 구현에서는 단일 텍스처를 사용하지만, 시각적 이해를 돕기 위해 4개의 텍스처를 결합한 예시로 구성했습니다. 이를 통해 동일한 환경 오브젝트 간 간격, 맵 단위 배치, 그리고 위치 보정 판단 기준인 일정 거리(
![]()
snap distance)의 개념을 직관적으로 표현합니다.
CScrollMapComponent 클래스
맵이 반드시 스크롤되는 형태가 아닐 수 있기 때문에, 스크롤 맵 컴포넌트로 구현했습니다. 그래서 현재 게임에서 사용하는 맵은 CScrollMapComponent를 추가하여 사용합니다.
카메라 좌표를 기준으로 단일 텍스처의 일부만 잘라 출력하는 방식입니다. 카메라가 텍스처 내부에 있을 때는 해당 영역만 출력하고, 경계를 넘어가면 넘어간 방향에 따라 텍스처를 2등분 또는 4등분으로 나누어 출력하여 하나의 텍스처로 무한히 이어지는 스크롤 맵 효과를 구현합니다.void CScrollMapComponent::Render(SDL_Renderer* renderer) { SDL_Rect viewRect = mCamera->GetViewRect(); viewRect.x = (int)roundf(viewRect.x); viewRect.y = (int)roundf(viewRect.y); const SDL_Rect& texRect = mTexture->GetTextureFrame(); const FVector2D& mapScale = mTransform->GetWorldScale(); // 텍스처 내에서 카메라 오프셋 계산 int offsetX = viewRect.x % (int)mapScale.x; int offsetY = viewRect.y % (int)mapScale.y; if (offsetX < 0) offsetX += (int)mapScale.x; if (offsetY < 0) offsetY += (int)mapScale.y; // 텍스처 크기를 월드 스케일에 맞춰 비율로 계산 float texW = texRect.w / mapScale.x; float texH = texRect.h / mapScale.y; // 카메라 뷰가 텍스처 경계를 넘어가는지 확인 bool overX = offsetX + viewRect.w > (int)mapScale.x; bool overY = offsetY + viewRect.h > (int)mapScale.y; // 내부 영역과 넘침 영역 계산 int innerW = overX ? (int)mapScale.x - offsetX : viewRect.w; int innerH = overY ? (int)mapScale.y - offsetY : viewRect.h; int outerW = viewRect.w - innerW; int outerH = viewRect.h - innerH; SDL_Rect src, dst; // 좌상단 src.x = (int)roundf(offsetX * texW); src.y = (int)roundf(offsetY * texH); src.w = (int)roundf(innerW * texW); src.h = (int)roundf(innerH * texH); dst = { 0, 0, innerW, innerH }; SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst); // 우상단 if (outerW > 0) { src.x = 0; src.y = (int)roundf(offsetY * texH); src.w = (int)roundf(outerW * texW); src.h = (int)roundf(innerH * texH); dst = { innerW, 0, outerW, innerH }; SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst); } // 좌하단 if (outerH > 0) { src.x = (int)roundf(offsetX * texW); src.y = 0; src.w = (int)roundf(innerW * texW); src.h = (int)roundf(outerH * texH); dst = { 0, innerH, innerW, outerH }; SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst); } // 우하단 if (outerW > 0 && outerH > 0) { src.x = 0; src.y = 0; src.w = (int)roundf(outerW * texW); src.h = (int)roundf(outerH * texH); dst = { innerW, innerH, outerW, outerH }; SDL_RenderCopy(renderer, mTexture->GetTexture(), &src, &dst); } CComponent::Render(renderer); }
CScrollEnvObj 클래스
스크롤 맵을 위한 환경 오브젝트 클래스입니다. 처음엔 이 로직을 컴포넌트로 분리하는 것도 고려했지만, 스프라이트나 콜라이더 등 여러 컴포넌트를 함께 이동하기 때문에 오브젝트 단위로 처리하는 편이 단순하고 명확하다고 판단했습니다.
void CScrollEnvObj::Update(float deltaTime) { CObject::Update(deltaTime); const FVector2D& cameraPos = mCamera->GetLookAt(); const FVector2D& objPos = GetTransform()->GetWorldPos(); FVector2D delta = cameraPos - objPos; float snapDistance = delta.Length(); if (snapDistance >= mSnapThreshold) { int directionX = (int)roundf(delta.x / mMapScale.x); int directionY = (int)roundf(delta.y / mMapScale.y); float offsetX = mMapScale.x * directionX; float offsetY = mMapScale.y * directionY; GetTransform()->SetWorldPos(objPos + FVector2D(offsetX, offsetY)); } }
CScrollEnvObj는 카메라 위치를 참조하여, 환경 오브젝트가 일정 거리(snap distance)를 초과하여 떨어지면 카메라와의 상대 방향을 계산하고, 이를 x, y 방향 단위로 정규화한 뒤 맵 스케일을 곱해 이동 거리를 산출하여 환경 오브젝트 위치를 효율적으로 1회 이동합니다.
3. 몹 소환 및 재소환 시스템
CMobSpawner 클래스는 카메라 주변 일정 범위 내에서 몹을 무작위로 소환합니다. 카메라 주변을 4개의 테두리 영역으로 나눈 뒤, 그 중 하나를 선택하여 해당 영역 안에서 랜덤한 위치로 소환함으로써, 플레이어 주변에 동등한 확률로 적이 등장하도록 합니다.
플레이어로부터 일정 거리 이상 떨어진 몹은 다시 위치를 갱신해 재소환됩니다. 이 과정을 통해 플레이어 주변에는 항상 적이 존재하고 긴장감이 끊기지 않도록 유지됩니다.
![]()
CMobSpawner 클래스 - 핵심 함수들
함수 #1: 몹 스포너 업데이트 함수
매 프레임마다 호출되어 몹 소환/재소환을 관리합니다. 시간 간격을 줄여가며, 조건이 충족되면void CMobSpawner::Update(float deltaTime) { if (!mScene->GetPlayer()) return; mRegularSpawnTime -= deltaTime; mSubBossSpawnTime -= deltaTime; SpawnMob(); RespawnMob(); }
SpawnMob()과 RespawnMob()을 실행합니다. 플레이어가 존재하지 않는 상황에서는 불필요한 계산을 하지 않고 바로 종료합니다.
함수 #2: 몹 소환 함수
일정한 주기마다 일반 몹과 중간 보스를 소환합니다. 소환된 몹들은 GetRandomSpawnPos()로 계산된 카메라 주변의 무작위 위치에 등장하며, 반복될수록 새로운 종류의 몹이 해금되어 점차 더 강한 적들이 추가됩니다.void CMobSpawner::SpawnMob() { // SPAWN REGULAR MOB if (mRegularSpawnTime <= 0.0f) { mRegularSpawnTime = CONST_REGULAR_MOB_SPAWN_INTERVAL; CEnemy* mob = SpawnRegularMob(mUnlockedRegIdx); mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f)); // INDEX CONTROL mRegSpawnAmount--; if (mRegSpawnAmount <= 0) { mRegSpawnAmount = CONST_REGULAR_MOB_SPAWN_AMOUNT; mUnlockedRegIdx = std::min(mUnlockedRegIdx + 1, (int)ERegularMobType::MAX - 1); } } // SPAWN SUB BOSS if (mSubBossSpawnTime <= 0.0f) { mSubBossSpawnTime = CONST_SUBBOSS_MOB_SPAWN_INTERVAL; CEnemy* mob = SpawnSubBossMob(mUnlockedBosIdx); mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f)); // INDEX CONTROL mUnlockedBosIdx = std::min(mUnlockedBosIdx + 1, (int)ESubBossMobType::MAX - 1); } }
함수 #3: 몹 재소환 함수
카메라 화면 밖, 일정 거리 이상 떨어진 몹은 GetRandomSpawnPos()로 계산된 위치에 다시 재소환하여 항상 플레이어 주변에 적이 존재하도록 유지합니다.void CMobSpawner::RespawnMob() { for (size_t i = mSpawnedMobs.size(); i > 0; i--) { CEnemy* mob = mSpawnedMobs[i - 1]; if (!mob->GetActive()) { std::swap(mSpawnedMobs[i - 1], mSpawnedMobs.back()); mSpawnedMobs.pop_back(); continue; } FVector2D delta = mob->GetTransform()->GetWorldPos() - mScene->GetPlayer()->GetTransform()->GetWorldPos(); if (delta.Length() >= mDespawnThreshold) { mob->GetTransform()->SetWorldPos(GetRandomSpawnPos(1.1f)); } } }
4. 플레이어: 능력치, 인벤토리, 무기 연관성
기본적으로 플레이어는 능력치 컴포넌트와 인벤토리 컴포넌트를 보유합니다.
- 능력치 컴포넌트:
- 플레이어의 기본 능력치와 아이템으로 증가하는 능력치를 관리합니다.
- 인벤토리 컴포넌트:
- 플레이어가 소유한 여러 무기 컴포넌트를 관리합니다.
- 무기 컴포넌트:
- 무기가 발사하는 투사체를 생성하고 초기 상태를 설정합니다.
무기를 사용한 투사체 발사 및 공격력 적용 과정
0. 연관성 설계도
/Block-References/01_Game-Projcets/Italian-Brainrot-Survivors/Source/Player---Correlation-of-Stats,-Inventory,-and-Weapons_Kor.png)
플레이어와 무기의 관계를 간결하게 나타낸 연관성 설계도입니다. 무기로 공격할 때는 투사체를 생성하고, 플레이어와 무기의 공격력을 합산한 총 데미지를 계산하여 투사체에 적용합니다.
1. 투사체 생성 및 데미지 설정
해당 무기로 공격할 때는 효과음을 재생한 뒤 투사체를 생성 및 초기화하며, 소유자와 무기의 공격력을 합산해 투사체에 적용합니다. 이 방식의 장점은, 이미 발사된 투사체는 이후 파워업으로 공격력이 변경되어도 영향을 받지 않는다는 점입니다.void CPistolComponent::Shoot() { CSoundManager::GetInst()->GetSound<CSFX>("SFX_Bullet")->Play(); CBullet* bullet = InstantiateObject<CBullet>("Bullet"); bullet->SetDamage(mOwner->GetAttack() + mPistolAttack); }
2. 플레이어 공격력 계산
플레이어의 공격력은 기본 공격력에 아이템 능력치 보너스를 합산하여 계산됩니다. 이 방식의 장점은, 디버깅과 유지보수가 용이하고, 새로운 무기나 능력치 시스템을 추가할 때 기존 코드를 최소한으로 수정하면서 확장할 수 있다는 점입니다.float CPlayer::GetAttack() const { int mightLevel = mStatus->GetMenuPowerUpLvl(EPowerUpType::MIGHT) + mInventory->GetPowerUpLevel(EPowerUpType::MIGHT); float mightBonus = mStatus->GetStatModifier(EPowerUpType::MIGHT); float mightAttack = mStatus->GetBaseAttack() * mightLevel * mightBonus; return mStatus->GetBaseAttack() + mightAttack; }
맺는 말
자작 게임 프레임워크를 활용해 게임을 개발하는 과정에서 예상하지 못했던 문제들을 발견하고 보완하며, 더 나은 구조와 안정성을 갖춘 프레임워크로 발전시킬 수 있었습니다. 이번 프로젝트는 자작 게임 프레임워크를 개선하고 이를 활용해 게임을 제작하는 과정을 동시에 경험한 의미 있는 프로젝트였습니다.