와일드 템페스트 - 프로젝트 상세

2026-03-13 뱀서라이크 Unity, C#, GPGS

프로젝트 개요

Google Play Game Services(GPGS)를 활용한 뱀서라이크 게임입니다. 플레이어는 무작위 생성되는 던전을 탐험하며 적을 처치하고 아이템을 수집합니다. 사망 시 모든 진행이 초기화되는 뱀서라이크 특성을 살리면서, 리더보드와 업적 시스템으로 플레이어의 도전 의지를 고취시켰습니다.

팀원 소개

이름 역할 담당 업무
김경학 프로그래머 프로젝트 전체 개발 (던전 생성, 아이템/무기 시스템, AI, 데이터 관리, 수익화)

기술 스택

  • 엔진: Unity 2022.3 LTS
  • 언어: C#
  • 백엔드: GPGS (Google Play Game Services)
  • 데이터 관리: Unity Google Sheets (UGS)
  • 디자인 패턴: Singleton, Object Pool, Factory, Observer, Strategy

아이템 및 무기 시스템

무기와 아이템의 구조를 설계하기 위해 상속 구조를 활용했습니다. 공통적인 속성을 가진 기본 클래스를 정의하고, 각 타입별로 상속받아 구현했습니다.

[아이템 상속 구조]
Item (기본 클래스)
├── Weapon (무기)
│   ├── MeleeWeapon (근접 무기)
│   └── RangedWeapon (원거리 무기)
├── Consumable (소모품)
│   ├── Potion (포션)
│   └── Scroll (스크롤)
└── Equipment (장비)
    ├── Armor (방어구)
    └── Accessory (장신구)
// 기본 아이템 클래스
public abstract class Item : ScriptableObject
{
    public string itemId;
    public string itemName;
    public string description;
    public Sprite icon;
    public int maxStack;
    public bool isConsumable;

    public abstract void Use(GameObject user);
}

// 무기 클래스
public abstract class Weapon : Item
{
    public int baseDamage;
    public float attackSpeed;
    public int durability;
    public int maxDurability;

    public abstract void Attack(GameObject target);

    public void Repair()
    {
        durability = maxDurability;
    }
}

// 근접 무기 구현
[CreateAssetMenu(fileName = "NewSword", menuName = "Data/Weapons")]
public class MeleeWeapon : Weapon
{
    public float attackRange;

    public override void Attack(GameObject target)
    {
        // 근접 공격 로직
        if (Vector3.Distance(transform.position, target.transform.position) <= attackRange)
        {
            var damageable = target.GetComponent<IDamageable>();
            damageable?.TakeDamage(baseDamage);
        }
    }
}

// 원거리 무기 구현
[CreateAssetMenu(fileName = "NewBow", menuName = "Data/Weapons")]
public class RangedWeapon : Weapon
{
    public GameObject projectilePrefab;
    public float projectileSpeed;

    public override void Attack(GameObject target)
    {
        // 투사체 발사 로직
        var projectile = Instantiate(projectilePrefab, transform.position);
        projectile.GetComponent<Projectile>().Initialize(target, baseDamage, projectileSpeed);
    }
}

오브젝트 풀링 시스템

던전 생성 시 타일 프리팹을 매번 인스턴스화하지 않고 오브젝트 풀을 사용하여 인스턴스화 비용을 최소화했습니다. 수명이 다한 오브젝트가 자동으로 풀로 반환되도록 구현했습니다.

// 오브젝트 풀 구현
public class ObjectPool<T> where T : MonoBehaviour
{
    private Queue<T> pool = new Queue<T>();
    private T prefab;
    private Transform parent;
    private int initialSize;

    public ObjectPool(T prefab, Transform parent, int initialSize)
    {
        this.prefab = prefab;
        this.parent = parent;
        this.initialSize = initialSize;
        PreWarm();
    }

    private void PreWarm()
    {
        for (int i = 0; i < initialSize; i++)
        {
            T obj = Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        if (pool.Count > 0)
        {
            T obj = pool.Dequeue();
            obj.gameObject.SetActive(true);
            return obj;
        }
        return Object.Instantiate(prefab, parent);
    }

    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}

// 오브젝트 풀 매니저
public class PoolManager : MonoBehaviour
{
    private static PoolManager instance;
    private Dictionary<string, object> pools = new Dictionary<string, object>();

    public static PoolManager Instance
    {
        get
        {
            if (instance == null)
            {
                GameObject go = new GameObject("PoolManager");
                instance = go.AddComponent<PoolManager>();
                DontDestroyOnLoad(go);
            }
            return instance;
        }
    }

    public void CreatePool<T>(string key, T prefab, int size, Transform parent) where T : MonoBehaviour
    {
        pools[key] = new ObjectPool<T>(prefab, parent, size);
    }

    public T GetFromPool<T>(string key) where T : MonoBehaviour
    {
        if (pools.TryGetValue(key, out var pool))
        {
            return ((ObjectPool<T>)pool).Get();
        }
        return null;
    }

    public void ReturnToPool<T>(string key, T obj) where T : MonoBehaviour
    {
        if (pools.TryGetValue(key, out var pool))
        {
            ((ObjectPool<T>)pool).Return(obj);
        }
    }
}

데이터 시트 시스템 (UGS)

Unity Google Sheets (UGS) 라이브러리를 사용하여 데이터 관리 시스템을 구현했습니다. 구글 시트에 적용된 데이터를 빠르게 게임에 적용하여 밸런스 수정 시간을 단축했습니다.

// UGS 데이터 로더
public class UGSDataLoader : MonoBehaviour
{
    public string spreadsheetId;
    public int sheetIndex = 0;

    private void Start()
    {
        LoadMonsterData();
        LoadStageData();
        LoadItemData();
    }

    private async void LoadMonsterData()
    {
        try
        {
            var data = await GoogleSheetsReader.Read(
                spreadsheetId,
                sheetIndex,
                "Monsters!A2:F100"
            );

            var monsterTable = ParseMonsterData(data);
            MonsterDataManager.Instance.SetMonsterData(monsterTable);
        }
        catch (Exception e)
        {
            Debug.LogError($"Failed to load monster data: {e.Message}");
        }
    }

    private Dictionary<string, MonsterData> ParseMonsterData(string[,] data)
    {
        var monsterTable = new Dictionary<string, MonsterData>();

        for (int row = 0; row < data.GetLength(0); row++)
        {
            string id = data[row, 0];
            if (string.IsNullOrEmpty(id)) continue;

            monsterTable[id] = new MonsterData
            {
                id = id,
                name = data[row, 1],
                hp = int.Parse(data[row, 2]),
                attack = int.Parse(data[row, 3]),
                defense = int.Parse(data[row, 4]),
                expReward = int.Parse(data[row, 5])
            };
        }

        return monsterTable;
    }
}
// 몬스터 데이터 매니저
public class MonsterDataManager : MonoBehaviour
{
    private static MonsterDataManager instance;
    private Dictionary<string, MonsterData> monsterData;

    public static MonsterDataManager Instance
    {
        get
        {
            if (instance == null)
            {
                GameObject go = new GameObject("MonsterDataManager");
                instance = go.AddComponent<MonsterDataManager>();
                DontDestroyOnLoad(go);
            }
            return instance;
        }
    }

    public void SetMonsterData(Dictionary<string, MonsterData> data)
    {
        monsterData = data;
        Debug.Log($"Loaded {data.Count} monsters.");
    }

    public MonsterData GetMonsterData(string id)
    {
        if (monsterData.TryGetValue(id, out var data))
        {
            return data;
        }
        Debug.LogWarning($"Monster data not found: {id}");
        return null;
    }
}

// 몬스터 데이터 클래스
[System.Serializable]
public class MonsterData
{
    public string id;
    public string name;
    public int hp;
    public int attack;
    public int defense;
    public int expReward;
}
// 스테이지 데이터 로드
private async void LoadStageData()
{
    try
    {
        var data = await GoogleSheetsReader.Read(
            spreadsheetId,
            sheetIndex,
            "Stages!A2:H50"
        );

        var stageTable = ParseStageData(data);
        StageDataManager.Instance.SetStageData(stageTable);
    }
    catch (Exception e)
    {
        Debug.LogError($"Failed to load stage data: {e.Message}");
    }
}

private List<StageData> ParseStageData(string[,] data)
{
    var stageTable = new List<StageData>();

    for (int row = 0; row < data.GetLength(0); row++)
    {
        stageTable.Add(new StageData
        {
            stageId = data[row, 0],
            name = data[row, 1],
            floorCount = int.Parse(data[row, 2]),
            minMonsterLevel = int.Parse(data[row, 3]),
            maxMonsterLevel = int.Parse(data[row, 4]),
            bossId = data[row, 5],
            stageDifficulty = float.Parse(data[row, 6]),
            goldReward = int.Parse(data[row, 7])
        });
    }

    return stageTable;
}

구글 시트에 몬스터 데이터와 스테이지 정보를 입력하고, UGS 라이브러리를 통해 실시간으로 데이터를 로드합니다. 이를 통해 빠르게 밸런스 수정이 가능했습니다.

[구글 시트 구조 예시]

Monsters 시트:
| ID | Name | HP | Attack | Defense | Exp Reward |
|----|------|----|--------|---------|------------|
| M001 | 슬라임 | 50 | 10 | 5 | 20 |
| M002 | 고블린 | 80 | 15 | 8 | 35 |
| M003 | 오크 | 150 | 25 | 12 | 80 |

Stages 시트:
| ID | Name | Floor Count | Min Lv | Max Lv | Boss ID | Difficulty | Gold Reward |
|----|------|-------------|---------|---------|-----------|-------------|
| S001 | 숲의 던전 | 5 | 1 | 10 | B001 | 1.0 | 100 |
| S002 | 동굴 던전 | 8 | 5 | 20 | B002 | 1.5 | 200 |
| S003 | 화산 던전 | 12 | 15 | 30 | B003 | 2.0 | 350 |

GPGS 리더보드 연동

Google Play Game Services(GPGS)를 활용하여 전 세계 플레이어와 점수를 경쟁할 수 있는 리더보드 시스템을 구현했습니다.

// GPGS 매니저
public class GPGSManager : MonoBehaviour
{
    private void Start()
    {
        PlayGamesPlatform.Activate();
    }

    public void SubmitScore(long score, string leaderboardId)
    {
        Social.ReportScore(score, leaderboardId, (bool success) =>
        {
            if (success)
            {
                Debug.Log("Score submitted successfully.");
            }
            else
            {
                Debug.LogWarning("Score submission failed.");
            }
        });
    }

    public void ShowLeaderboard(string leaderboardId)
    {
        Social.ShowLeaderboardUI(leaderboardId);
    }
}

IAP 및 광고 시스템

Unity IAP를 사용하여 인앱결제 상품을 설계하고, AdMob SDK를 사용하여 광고 시스템을 구현했습니다.

// IAP 매니저
public class IAPManager : MonoBehaviour
{
    // 상품 종류
    public enum ProductType
    {
        Currency,       // 골드
        Revive,        // 부활
        StarterPack     // 스타터 팩
    }

    public async void PurchaseProduct(string productId)
    {
        try
        {
            var product = _storeController.products.WithID(productId);
            await _storeController.InitiatePurchase(product);
        }
        catch (Exception e)
        {
            HandlePurchaseError(e);
        }
    }
}
// 보상형 광고 매니저
public class RewardAdManager : MonoBehaviour
{
    private RewardedAd _rewardedAd;

    public void LoadRewardAd()
    {
        string adUnitId = "ca-app-pub-xxx/yyy";
        RewardedAd.Load(adUnitId, CreateAdRequest(),
            (RewardedAd ad, LoadAdError error) =>
            {
                if (error != null)
                {
                    Debug.LogError("Reward ad load failed");
                    return;
                }
                _rewardedAd = ad;
            });
    }

    public void ShowRewardAd(Action<Reward> onRewardEarned)
    {
        if (_rewardedAd != null && _rewardedAd.CanShowAd())
        {
            _rewardedAd.Show((Reward reward) =>
            {
                onRewardEarned?.Invoke(reward);
            });
        }
    }
}

주요 성과

  • 아이템 및 무기 상속 구조: 확장 가능한 시스템 구축
  • 오브젝트 풀링: 성능 최적화
  • UGS 라이브러리 활용: 구글 시트 연동으로 빠른 밸런스 수정 가능
  • GPGS 연동: 전 세계 플레이어와 점수 경쟁 가능
  • IAP 및 광고: 수익화 모델 구현

결론/학습 내용

프로젝트의 의의

와일드 템페스트 프로젝트는 뱀서라이크 장르의 핵심 요소(무한한 플레이, 난이도 밸런스, 전략적 전투)를 구현하고 데이터 주도 개발 방식을 통해 유지보수성을 높였습니다.

학습 내용

  • 상속 구조를 활용한 확장 가능한 아이템 시스템 설계
  • 오브젝트 풀링 패턴을 통한 성능 최적화
  • UGS 라이브러리를 활용한 구글 시트 연동
  • 데이터 주도 개발 방식을 통한 빠른 밸런스 수정
  • Google Play Game Services API 활용
  • IAP 및 AdMob을 통한 수익화 모델 구현

코드 예시

던전 생성 시스템

public class DungeonGenerator : MonoBehaviour
{
    private int width = 50;
    private int height = 50;
    private int[,] map;

    private void GenerateDungeon()
    {
        map = new int[width, height];

        // 셀룰러 오토마타 기반 맵 생성
        CellularAutomata();

        // 경로 검증
        if (!ValidatePath())
        {
            GenerateDungeon(); // 재생성
        }
    }

    private void CellularAutomata()
    {
        // 초기화
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                map[x, y] = Random.value < 0.45f ? 1 : 0;
            }
        }

        // 반복 적용
        for (int i = 0; i < 5; i++)
        {
            ApplyCellularAutomataRules();
        }
    }
}

적 AI 상태 머신

public class EnemyAI : MonoBehaviour
{
    private enum State { Idle, Patrol, Chase, Attack, Flee }
    private State currentState;
    private IAttackStrategy attackStrategy;

    private void Update()
    {
        switch (currentState)
        {
            case State.Idle:
                if (HasSpottedPlayer()) ChangeState(State.Chase);
                break;
            case State.Patrol:
                PatrolBehavior();
                if (HasSpottedPlayer()) ChangeState(State.Chase);
                break;
            case State.Chase:
                MoveTowardsPlayer();
                if (IsInAttackRange()) ChangeState(State.Attack);
                if (IsLowHealth()) ChangeState(State.Flee);
                break;
            case State.Attack:
                attackStrategy.Execute(this);
                if (!IsInAttackRange()) ChangeState(State.Chase);
                break;
            case State.Flee:
                MoveAwayFromPlayer();
                if (IsSafe()) ChangeState(State.Patrol);
                break;
        }
    }
}