원더메이트는 체스판 형태의 전장에 다양한 동화 속 캐릭터 유닛을 배치하여 적들의 공격을 방어하는 전략형 타워 디펜스 게임입니다. 단순히 타워를 짓는 것을 넘어, 유닛의 배치와 스킬 활용, 코스트 관리가 핵심인 게임입니다. 페어리 테일 캐릭터들에게 체스의 역할(킹, 퀸, 나이트 등)을 부여하여 친숙하면서도 색다른 전략성을 제공합니다.
| 이름 | 역할 | 담당 업무 |
|---|---|---|
| 오유나 | 팀장 | 캐릭터 컨셉 및 기획 조정 |
| 정준오 | 기획 | 전투 밸런스 및 스테이지 기획 |
| 김경학 | 프로그래밍 | 메인 시스템(Unit, Skill, Status), UI, 스테이지 생성 시스템, 데이터 관리 |
| 강민재 | 프로그래밍 | 튜토리얼 및 기타 시스템 |
| 김승진 | 프로그래밍 | 적 AI 및 스킬 구현 |
| 김서빈 | 아트 | 캐릭터 스프라이트 및 애니메이션 |
| 조현래 | 영상 | 트레일러 영상 및 이펙트 소스 제작 |
프로젝트의 기반이 되는 유닛 시스템의 전체 구조를 설계하고 구현했습니다. 모든 유닛의 공통 로직을 관장하는 PieceUnit 추상 클래스를 최상위에
배치하고, 이를 상속받는 PlayerUnit과 EnemyUnit으로 분리하여 플레이어와 적군을 가리지 않고 동일한 인터페이스로
동작하게 했습니다. PieceUnit에서는 HP/MP 관리, 피격/치유, 상태이상 lifecycle, 이동 애니메이션(easing), 공격 타이머, 그리드
좌표 관리 등 모든 유닛이 공유하는 핵심 로직을 담당합니다.
// PieceUnit.cs - 모든 유닛의 기본 추상 클래스
public abstract class PieceUnit : MonoBehaviour {
public int2 gridPos { get; private set; }
protected List<StatusEffectInstance> activeEffects = new();
protected virtual void Update() {
// 상태이상 lifecycle 관리 (OnUpdate → 만료 시 OnEnd 호출 및 제거)
for (int i = activeEffects.Count - 1; i >= 0; i--) {
activeEffects[i].Update(this);
if (activeEffects[i].IsEnd()) {
activeEffects[i].data.OnEnd(this);
activeEffects.RemoveAt(i);
}
}
// 공격 쿨다운 체크 후 자동 발동
if (attack != null && attack.CanActivate(this, atkSpeed))
attack.Activate(this);
}
public virtual bool TakeDamage(int dmg) {
currentHP -= dmg;
uIController.SetHPPercent((float)currentHP / maxHP);
// 체력 0 이하 시 StageManager에서 제거 후 Die 애니메이션
}
}
체스 테마의 전략성을 구현하기 위해 StageManager 싱글톤에서 11x7 그리드 좌표계를 관리합니다. 각 타일은 TileType(None,
Ground, Sea)으로 구분되고, 플레이어 유닛은 타일당 1개, 적 유닛은 타일당 여러 개 배치될 수 있도록 Tile 클래스에 분리된 슬롯으로
관리합니다. 유닛의 이동/배치 시마다 StageManager에 등록/해제하는 방식으로 그리드와 실제 오브젝트의 상태를 항상 동기화합니다.
// StageManager.cs - 타일 및 유닛 관리
public class Tile {
public TileType type = TileType.None;
public List<EnemyUnit> enemies = new List<EnemyUnit>(5);
public PlayerUnit player = null;
}
// 그리드 ↔ 월드 좌표 변환
public int2 WorldToGridPosition(Vector3 worldPos) {
int GridX = Mathf.FloorToInt((worldPos.x + cellOffset.x) / cellSize.x);
int GridY = Mathf.FloorToInt((worldPos.z + cellOffset.z) / cellSize.y);
return new int2(GridX, GridY);
}
// 유닛 등록 시 플레이어/적 분기 처리
public void SetUnit(int2 pos, PieceUnit unit) {
if (unit is PlayerUnit)
tiles[GetTileIndex(pos)].player = (PlayerUnit)unit;
else if (unit is EnemyUnit)
tiles[GetTileIndex(pos)].enemies.Add((EnemyUnit)unit);
}
또한 MovePoint, PiecePoint, PieceStack 세 가지 자원을 관리하는 포인트 시스템을 구현했습니다.
적을 처치하면 PieceStack이 누적되고, 1 이상이 되면 PiecePoint로 변환되어 새 유닛을 배치할 수 있는 구조입니다. 자원
변화 시 Action<T> 이벤트를 발생시켜 UI가 자동으로 갱신되도록 옵저버 패턴을 적용했습니다.
체스판 그리드에서 정확한 거리 계산을 위해 맨해튼 거리(Manhattan Distance)를 사용합니다. FindTargetsInRange는 자신의 그리드
위치를 중심으로 지정된 사거리 내의 모든 적을 탐색하며, diagonalAttack 플래그에 따라 대각선 공격 가능 여부를 판단합니다. 단일 타겟
탐색 시에는 가장 가까운 적을 우선 공격하도록 거리 기반 정렬 로직을 적용했습니다.
// PieceUnit.cs - 그리드 기반 타겟 탐색
public List<PieceUnit> FindTargetsInRange(int atkRange, bool diagonalAttack = false) {
List<PieceUnit> targets = new List<PieceUnit>();
for (int x = -atkRange; x <= atkRange; x++) {
for (int y = -atkRange; y <= atkRange; y++) {
// 대각선 불가 시 맨해튼 거리 초과하면 스킵
if (!diagonalAttack && math.abs(x) + math.abs(y) > atkRange) continue;
int2 targetPos = gridPos + new int2(x, y);
if (!StageManager.instance.IsValidTile(targetPos)) continue;
// isPlayer 여부에 따라 적/아군 탐색 분기
}
}
return targets;
}
// 거리 계산 (맨해튼 거리)
public int GetDistance(int2 pos) {
return math.abs(gridPos.x - pos.x) + math.abs(gridPos.y - pos.y);
}
EnemyUnit은 킹(플레이어 메인 유닛)을 향해 이동하기 위해 A* 경로 탐색 알고리즘을 사용합니다. 휴리스틱 함수로 맨해튼 거리를 사용하고,
4방향 탐색으로 최단 경로를 계산합니다. 경로가 완전히 막힌 경우에는 킹에 가장 가까운 도달 가능한 위치까지의 경로를 대안으로 반환하여 적이 멈추지 않고 항상 의미 있는
이동을 하도록 처리했습니다. 또한 이동 방향의 우선순위를 무작위로 변경하여 적들이 서로 다른 경로로 분산되도록 구현했습니다.
// EnemyUnit.cs - A* 경로 탐색
List<int2> FindSimplePath(int2 start, int2 goal) {
HashSet<int2> closed = new HashSet<int2>();
Dictionary<int2, int2> cameFrom = new Dictionary<int2, int2>();
List<int2> open = new List<int2> { start };
Dictionary<int2, int> gScore = new Dictionary<int2, int> { [start] = 0 };
int2 nearest = start;
int nearestHeuristic = Heuristic(start, goal);
while (open.Count > 0) {
open.Sort((a, b) => (gScore[a] + Heuristic(a, goal))
.CompareTo(gScore[b] + Heuristic(b, goal)));
int2 current = open[0];
open.RemoveAt(0);
if (current.Equals(goal)) return ReconstructPath(cameFrom, current);
closed.Add(current);
foreach (int2 dir in Directions) {
int2 neighbor = current + dir;
if (closed.Contains(neighbor)) continue;
if (!StageManager.instance.IsValidTile(neighbor)) continue;
if (StageManager.instance.GetTileType(neighbor) != TileType.Ground) continue;
// G-score 갱신 및 open 리스트에 추가
}
}
// 목표 도달 불가 시 가장 가까운 위치까지 경로 반환
if (!nearest.Equals(start)) return ReconstructPath(cameFrom, nearest);
return new List<int2>();
}
int Heuristic(int2 a, int2 b) => math.abs(a.x - b.x) + math.abs(a.y - b.y);
EnemySpawner는 상태 머신 패턴으로 웨이브 진행을 관리합니다. None → Wait → Wave → Clear의 상태 전이를
가지며, 각 상태 진입/업데이트/종료 시점에 해당하는 로직을 명확히 분리했습니다. 웨이브 데이터는 CSV 파일로 관리하여 스테이지마다 적 종류, 출현 위치, 수량, 웨이브
간 대기 시간을 에디터에서 쉽게 조정할 수 있도록 구성했습니다.
// EnemySpawner.cs - 상태 전이 로직
enum EnemySpawnerState { None, Wait, Wave, Clear }
private void ChangeState(EnemySpawnerState state) {
// 현재 상태 종료 처리
switch (currentState) {
case EnemySpawnerState.Wait: WaitEnd(); break;
case EnemySpawnerState.Wave: WaveEnd(); break;
case EnemySpawnerState.Clear: ClearEnd(); break;
}
currentState = state;
// 새 상태 시작 처리
switch (currentState) {
case EnemySpawnerState.Wait: WaitStart(); break;
case EnemySpawnerState.Wave: WaveStart(); break;
case EnemySpawnerState.Clear: ClearStart(); break;
}
}
// Wave 상태: SpawnData의 count를 소모하며 적 생성
private void WaveUpdate() {
bool clearWave = true;
foreach (SpawnData spawnData in currentWave.spawnDatas) {
if (spawnData.count <= 0) continue;
clearWave = false;
SpawnEnemy(SpawnPoint[spawnData.point], spawnData.code);
spawnData.count--;
}
if (clearWave) // 다음 웨이브 또는 Clear 상태로 전이
}
45종 이상의 스킬과 상태 이상 효과를 효율적으로 관리하기 위해 SkillBase와 StatusEffectBase를
ScriptableObject로 구현했습니다. 스킬은 발동 방식에 따라 4가지 타입(Auto, TargetTile,
Direction, SelfOnly)으로 분류되며, 각 타입에 맞는 활성화 로직을 추상 메서드로 제공합니다. 상태이상은
OnStart, OnUpdate, OnEnd lifecycle을 가지며, 동일한 상태이상이 중복 적용되면 지속시간만
갱신하는 방식으로 관리합니다.
// SkillBase.cs - ScriptableObject 기반 스킬 추상 클래스
public enum SkillActivationType {
Auto, // 자동 발동
TargetTile, // 타일 클릭 시 발동
Direction, // 방향 선택 후 발동
SelfOnly // 자신에게만 적용
}
public abstract class SkillBase : ScriptableObject {
public int manaCost;
public float cooldown;
public SkillActivationType activationType;
public List<int2> ranges; // 스킬 범위 좌표
public int2 rangeBoxScale; // 박스 범위 스케일
public virtual bool CanActivate(PieceUnit unit, float time = 1.0f) {
cooldownTimer -= Time.deltaTime * time;
return cooldownTimer < 0 && manaCost <= unit.GetMP();
}
public virtual void ShowRange(PieceUnit unit) { /* 범위 시각화 */ }
public virtual void ActivateAt(PieceUnit unit, int2 targetPos) { /* 선택형 스킬 */ }
}
// StatusEffectBase.cs - 상태이상 추상 클래스
public abstract class StatusEffectBase : ScriptableObject {
public abstract void OnStart(PieceUnit unit);
public abstract void OnUpdate(PieceUnit unit);
public abstract void OnEnd(PieceUnit unit);
}
UIManager는 #if UNITY_ANDROID 전처리기를 통해 PC와 모바일 환경의 입력 처리를 분리합니다. PC는 마우스
클릭/드래그 기반, 모바일은 터치 기반으로 유닛 선택, 드래그 배치, 카메라 이동을 처리합니다. 스킬 사용 시 슬로우 모션(0.2x)을 적용하여 플레이어가 타겟을
신중하게 선택할 수 있도록 하고, 전투 속도는 1x/2x/3x로 전환 가능하도록 구현했습니다.
DataManager 싱글톤에서 게임의 모든 정적/동적 데이터를 관리합니다. 캐릭터 스탯, 스킬 정보 등은 CSV 파일에서 파싱하여 로드하고,
플레이어의 진행 상황(레벨, 골드, 보유 캐릭터, 클리어 스테이지 등)은 JsonUtility를 통해 JSON 파일로 저장/로드합니다. 골드, 젬
등 재화 변화 시 Action<T> 이벤트로 UI를 갱신하는 옵저버 패턴을 일관되게 적용했습니다.
// DataManager.cs - JSON 저장/로드
public void SaveGame() {
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(Application.persistentDataPath + "/save.json", json);
}
public void LoadGame() {
string path = Application.persistentDataPath + "/save.json";
if (File.Exists(path)) {
string json = File.ReadAllText(path);
data = JsonUtility.FromJson<SaveData>(json);
} else {
data = new SaveData();
data.ClearData(); // 초기 캐릭터(Alice, Cheshire, King) 지급
}
}
이 프로젝트를 통해 대규모 시스템의 기반을 설계할 때 추상 클래스와 인터페이스의 중요성을 깊이 깨달았습니다. 초기 설계 단계에서 공통 기능을 잘
정의해둔 덕분에 프로젝트 후반부에 유닛 종류가 늘어나도 시스템이 무너지지 않고 안정적으로 확장될 수 있었습니다. 또한 ScriptableObject를
활용한 데이터 기반 작업 방식이 협업 효율성을 얼마나 높여줄 수 있는지 직접 체감하며, 데이터 중심의 프로그래밍 사고를 기를 수 있었습니다. 특히 A* 알고리즘을 직접
구현하면서 경로 탐색의 기본 원리와 최적화 포인트를 이해할 수 있었고, FSM 패턴을 적용한 웨이브 관리 시스템을 통해 상태 기반 설계의 장점을 학습했습니다.