[Unity] 타워 구조 설계
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요.
현재 진행중인 프로젝트에서 타워 구조를 설계하는 작업을 맡았는데, 구조가 복잡해서 정리할 겸 블로그 주제로 잡았다.
타워를 만들 때 결정해야 하는 건 세 가지다. 이 타워가 무엇을 하는 물건인지, 얼마나 센지, 맞은 쪽에 무슨 일이 나는지. 이 셋을 각각 다른 곳에 두는 게 구조의 전부다.
| 사는 곳 | 담기는 것 | 대답하는 질문 |
|---|---|---|
| 프리팹 | Tower.Actions — List | 무엇을 하는가 |
| SO (TowerAsset) | Attack / Beam / Aura 필드 블록 | 얼마나 세게 |
| SO (TowerAsset) | Effects — List | 맞으면 무슨 일이 나는가 |
원래는 SO에 TowerType enum이 있었고 런타임에 팩토리가 그 값을 보고 AddComponent로 컴포넌트를 붙였다. 프리팹만 봐서는 무슨 타워인지 알 수 없고, 새 종류 하나 추가하려면 enum · 팩토리 · 에디터 세 군데를 고쳐야 했다. 그래서 "무엇을 하는가"의 정본을 프리팹으로 옮겼다.
아처 타워 클래스, 캐논 타워 클래스 같은 건 없다. Tower 스크립트는 딱 하나뿐이고, 이 타워가 뭘 하는 물건인지는 전부 리스트 하나에 들어 있다.
public class Tower : MonoBehaviour, IAttacker, ISelectable
{
[SerializeField] TowerAsset data;
// 인스펙터에서 `+ Attack Action` / `+ Buff Aura Action`을 골라 담는다.
// 리스트인 이유: 공격+오라 하이브리드 타워를 공짜로 허용한다.
[SerializeReference] List<TowerAction> actions = new List<TowerAction>();
그래서 Tower가 소유하는 건 정체성(SO·진영), 스탯 원장, 선택 표현, 전역 레지스트리 네 가지뿐이다. 바깥에서는 구상 타입이 아니라 Has() / Get()로 능력을 묻고, "공격하지 않는 타워"는 분기가 아니라 값 0으로 표현된다.
public float AttackDamage => Get<AttackAction>()?.Damage ?? 0f;
public bool TryAttack(IDamageable target) => Get<AttackAction>()?.TryAttack(target) ?? false;
구동 루프에도 타워 종류를 보는 분기가 한 줄도 없다. 담긴 부품을 순서대로 돌릴 뿐이다.
for (int i = 0; i < actions.Count; i++)
{
TowerAction action = actions[i];
if (action.ActivePhase == TowerActivePhase.NightOnly && !isNight) continue;
action.Tick(deltaTime);
}
계약은 짧다. 언제 도는지, 어떻게 초기화하는지, 매 프레임 뭘 하는지, 치울 때 뭘 걷어내는지.
[Serializable]
public abstract class TowerAction
{
[NonSerialized] protected Tower Owner;
public abstract TowerActivePhase ActivePhase { get; }
protected abstract void OnInitialize(TowerAsset asset);
/// 호스트가 페이즈 게이팅 후 구동한다. 액션이 스스로 Update를 돌지 않는다.
public abstract void Tick(float deltaTime);
public abstract void Dispose();
}
MonoBehaviour가 아니라는 게 핵심이다. [SerializeReference] 덕분에 인스펙터에서 드롭다운으로 골라 담는 순수 C# 객체이고, 프리팹만 열어보면 이 타워가 뭘 하는지 바로 보인다.
규칙은 셋이다. 수치를 갖지 않는다(전부 SO에서 읽는다). 씬 배선은 호스트가 갖는다(firePoint 등은 Owner를 통해). 런타임 상태는 직렬화하지 않는다 — [SerializeReference] 객체는 Instantiate 시 인스턴스마다 깊은 복사되므로 타워별 독립은 공짜로 따라온다.
밤/낮 게이팅을 액션이 아니라 호스트가 하는 것도 의도적이다. 액션마다 DayNightManager를 폴링하면 규칙이 조용히 갈라진다. 실제로 공격 타워엔 밤 게이팅이 있고 오라 타워엔 없어서 낮에도 버프가 돌던 적이 있다.
액션과 같은 패턴을 한 층 아래에 한 번 더 적용했다.
타워 → [액션 목록] "이 타워는 무엇을 하는가"
액션 → [효과 목록] "맞으면 무슨 일이 나는가"
public enum EffectKind { Burn, Poison, Slow, Stun }
[Serializable]
public abstract class HitEffect
{
public abstract EffectKind Kind { get; }
public abstract void Apply(IDamageable target, IAttacker source, TowerStats stats, int sourceId);
public abstract string Describe(TowerStats stats);
}
액션과 다른 점 하나. 효과는 수치를 직접 갖는다. 부품과 수치가 한 덩어리여야 인스펙터에서 + Burn 고르는 즉시 저작이 끝난다. 이 리스트는 프리팹이 아니라 SO에 살기 때문에 "밸런싱 값은 SO에" 규칙과 어긋나지도 않는다.
투사체는 축을 둘로 쪼갰다. 어떻게 나는가(ProjectileFlight)와 누구를 때리는가(ImpactKind)가 독립이다.
원래는 "도달 = 명중 = 소멸"이 한 덩어리로 하드코딩돼 있어서, 가는 길에 여러 번 때리는 탄을 표현할 방법이 없었다. 두 플래그를 가르니 이렇게 됐다.
/// 유도탄·포격 도달 시 Impact + Finished
/// 관통탄 적을 지날 때마다 Impact, 사거리 끝에 Finished
/// 부메랑 왕복하며 여러 번 Impact, 복귀 완료 시 Finished
public struct FlightStep
{
public bool Impact; // 이번 프레임에 명중 판정을 하라
public Vector3 ImpactPos;
public bool Finished; // 수명 종료. Impact와 독립이다.
}
호스트인 Projectile.Update는 그래서 분기가 없다. 비행 부품이 낸 결과를 해석만 한다.
FlightStep step = flight.Step(this, target, ref flightState, Time.deltaTime);
if (step.Impact) OnHit(step.ImpactPos);
if (step.Finished) Destroy(gameObject);
여기서 부품이 액션과 결정적으로 다르게 동작한다. 액션은 프리팹에 담겨 인스턴스마다 깊은 복사되지만, 비행 부품은 SO에 담겨 여러 투사체가 같은 객체를 공유한다. 부품에 진행값을 두면 동시에 날아가는 10발이 서로의 값을 덮어쓴다. 그래서 진행 상태는 전부 FlightState로 빼서 호스트가 소유하고 ref로 넘긴다. 구현체는 HomingFlight / BallisticFlight / StraightFlight / BoomerangFlight 네 종류.
명중 축은 별개로 ImpactKind { Single, Area, Chain }이 정하는데, 이 세 경로가 결국 Hit() 한 지점으로 수렴한다. 효과 적용의 출처가 하나뿐이라 HitEffect를 새로 만들면 단일탄이든 스플래시든 체인이든 전부 자동으로 지원된다.
액션 5종 × 비행 4종 × 명중 3종 × 효과 4종
새 타워를 만들 때 하는 일은 프리팹에 액션을 담고 SO에 수치와 효과를 적는 것뿐이다. 성장형 타워 3종을 추가했을 때도 새로 만든 액션은 하나였고 나머지 차이는 전부 SO 저작으로 갈렸다.
코드를 새로 써야 하는 확장점은 정확히 셋이다.
| 확장점 | 언제 | 어디에 담기나 |
|---|---|---|
| HitEffect 파생 | 새 상태이상 | SO의 Effects |
| ProjectileFlight 파생 | 새 궤적 | SO의 Attack.Flight |
| TowerAction 파생 | 새 행동 축 | 프리팹의 Actions |
이 중 세 번째는 드물게 쓴다. "거동이 다르다"가 곧 "액션이 다르다"는 아니어서다. AttackAction 하나가 단일·스플래시·체인·산탄·부메랑을 전부 덮는 것처럼, 트리거와 수치로 갈릴 수 있으면 액션은 늘리지 않는다.
SerializeReference
![[Unity] 튜토리얼 설계](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1787322579554-zopplu/_____2026-08-21_232731.png)
튜토리얼 설계
![[Unity] Event Trigger Component](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1786976296699-a3tu75/_____2026-08-17_231353.png)
Unity Event Trigger 컴포넌트
![[Unity] 튜토리얼 설계](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1787322579554-zopplu/_____2026-08-21_232731.png)
![[Unity] Event Trigger Component](https://data.develog.develrocket.com/upload/develog/user_1777093328305/1786976296699-a3tu75/_____2026-08-17_231353.png)