Unity C#에서 스킬마다 조건문을 늘리지 않고 다른 효과를 실행하려면 어떻게 설계할까요? 「졸업까지 100턴」의 실제 코드로 스킬 조건과 효과를 분리하는 전략 패턴, 문자열로 클래스를 고르는 리플렉션, IL2CPP의 link.xml 관리 지점을 살펴봅니다. 보드 스킬의 팩토리·템플릿 메서드와 비교해 각 패턴이 맡는 역할도 구분합니다.
스킬은 어떤 길로 실행되나

「졸업까지 100턴」에서 "타입에 따라 다른 클래스로 실행" 하는 구조는 다섯 갈래입니다. 갈래마다 클래스를 고르는 방법과 실행 시점이 다릅니다.
| 계열 | 기반 클래스 | 구체 클래스 | 타입 → 클래스 | 언제 실행되나 |
|---|---|---|---|---|
| 액티브 전투 스킬 | TDSkillCondition · TDSkillRunner | 조건 8 · 효과 23 | 문자열 + 리플렉션 | 매 프레임 판정, 패드 길게 누르기, 스킬 버튼 |
| 보드 스킬 | TDBoardSkill | 38 | enum + switch | 말이 칸에 도착했을 때 |
| 아이템 사용 | TDItem | 3 | enum + switch | 아이템 사용 응답을 받았을 때 |
| 캐릭터 AI | TDStrategy | 7 | 문자열 if / else | 매 프레임, 실행 중 교체 |
| 패시브 스킬 | 없음 (데이터 클래스 PassiveSkill) | 0 | 없음 | 아직 전투에 적용되지 않음 |
다섯 갈래 가운데 전략 패턴의 모양을 가장 잘 갖춘 것은 액티브 전투 스킬입니다. 패시브는 데이터와 화면까지만 있고 실행하는 클래스가 아직 없습니다. 차례로 보겠습니다.
액티브 스킬: 조건과 효과, 두 축의 전략
전략 패턴은 "같은 모양(인터페이스)의 알고리즘을 여러 개 만들어 두고, 쓰는 쪽은 모양만 알고 갈아 끼운다" 는 설계입니다. 100턴의 액티브 스킬은 이 전략을 두 축으로 나눴습니다. 언제 쓰는가(조건)와 무엇을 하는가(효과).
두 축의 모양은 아주 작습니다. 조건은 메서드 하나, 효과는 메서드 두 개입니다.
public abstract class TDSkillCondition
{
protected int conditionValue1;
protected int conditionValue2;
protected TDField field = null;
protected TDCharacter owner = null;
public void Initialize(TDField field, TDCharacter owner, int conditionValue1, int conditionValue2)
{
this.field = field;
this.owner = owner;
this.conditionValue1 = conditionValue1;
this.conditionValue2 = conditionValue2;
}
public abstract TDCharacter[] CheckCondition(ElStateTypes currentType, out bool check);
}public abstract class TDSkillRunner
{
protected int skillValue1;
protected int skillValue2;
protected double actionDelay;
protected TDField field = null;
protected TDCharacter owner = null;
protected ActiveSkill skillInfo = null;
public bool IsProjectile = false;
public bool IsMagicCircle = false;
public bool CanChangeTarget => !IsMagicCircle;
public virtual void Initialize(TDField field, TDCharacter owner, ActiveSkill skillInfo, int order)
…
public abstract TDField.TeamTypes GetTargetTeamType();
public abstract TDCharacter[] RunSkill(TDCharacter[] targetCharacters);
}같은 모양 뒤에서 조건마다 판정 기준이 다릅니다. 적이 가까이 있는지 보는 조건과, 플레이어가 버튼을 길게 누르고 있는지 보는 조건을 나란히 놓아 봅니다.
public override TDCharacter[] CheckCondition(ElStateTypes currentType, out bool check)
{
TDCharacter[] targetCharacters;
targetCharacters = field.GetCharactersInRange(owner.transform.localPosition, owner.GetEnemyTeam(), conditionValue1);
check = targetCharacters != null && targetCharacters.Length >= conditionValue2;
return targetCharacters;
}public override TDCharacter[] CheckCondition(ElStateTypes currentType, out bool check)
{
check = currentType == ElStateTypes.SKILLING;
var targetCharacters = field.GetCharactersInRange(owner.transform.localPosition, owner.GetEnemyTeam(), conditionValue2);
return targetCharacters;
}효과도 마찬가지입니다. 회복은 아군을 찾아 체력을 채우고, 공격력 버프는 넘겨받은 대상에게 버프를 겁니다.
public override TDCharacter[] RunSkill(TDCharacter[] targetCharacters)
{
if (CanChangeTarget)
{
targetCharacters = field.GetCharactersInRange(owner.transform.localPosition, owner.GetTeam(), skillValue1);
}
if (targetCharacters != null)
{
int damage = Mathf.FloorToInt((float)owner.AttackPoint * ((float)skillValue2 / 100f));
var userChr = owner.GetUserCharacter();
int theologyLevel = GameData.GetStudyLevel(userChr.Study_Theology);
if (userChr != null && theologyLevel > 0) // 신학 적용
{
damage += Mathf.FloorToInt(damage * (theologyLevel * 5 / 100f));
}
foreach (var target in targetCharacters)
{
target.TakeHeal(owner, damage, false);
}public override TDCharacter[] RunSkill(TDCharacter[] targetCharacters)
{
if (targetCharacters != null)
{
for (int i = 0; i < targetCharacters.Length; i++)
{
TDCharacter targetCharacter = targetCharacters[i];
if (targetCharacter != null)
{
targetCharacter.TakeAttackPointBuff(owner, skillValue1, Mathf.CeilToInt((float)owner.AttackPoint * ((float)skillValue2 / 100f)));
}
}
}
return targetCharacters;
}조건 8종과 효과 23종을 표의 문자열로 조합하므로, 기획자는 코드 없이 "적이 가까우면 광역 피해, 이어서 화상" 같은 스킬을 만듭니다. 현재 데이터의 액티브 스킬 61개가 모두 이 조합입니다.
조합을 쥔 컨텍스트, TDActiveSkill
전략 패턴에서 전략을 쥐고 부르는 쪽을 컨텍스트라고 합니다. 여기서는 TDActiveSkill 입니다. 쿨타임과 이펙트를 처리한 뒤, 첫 효과를 실행하고 그 결과(대상 목록)를 둘째 효과에 넘깁니다.
if (runner.CanChangeTarget)
{
skillDelaying = true;
DoAction(delegate
{
targetCharacters = runner.RunSkill(targetCharacters);
if (!string.IsNullOrEmpty(skillInfo.TargetEffectPath) && skillInfo.TargetEffectPath != "-" && targetCharacters != null && targetCharacters.Length > 0)
{
foreach (var target in targetCharacters)
{
var eff = CreateEffect(skillInfo.TargetEffectPath, target.transform);
OnCreateTargetEffect(eff, target, runner);
}
}
if (runner2 != null)
{
targetCharacters = runner2.RunSkill(targetCharacters);
}
skillDelaying = false;
});효과의 계약이 "대상을 받아 대상을 돌려준다" 이기 때문에 이런 이어 붙이기가 됩니다. 첫 효과가 고른 대상에게 둘째 효과가 그대로 이어집니다. 컨텍스트는 효과가 피해인지 회복인지 모릅니다. 이것이 전략 패턴이 주는 가장 큰 이익입니다.
문자열에서 클래스로
- 표의 문자열ConditionType = "EnemyNear"
- 클래스 이름 조립"TDSkillCondition_" + 문자열
- 타입 찾기
Type.GetType - 객체 만들기
Activator.CreateInstance - 못 찾으면기본 클래스로 대신함
string conditionClassName = "TDSkillCondition_" + skillInfo.ConditionType;
Type type = Type.GetType(conditionClassName);
if (type != null)
{
object instance = Activator.CreateInstance(type);
if (instance is TDSkillCondition myClassInstance)
{
condition = (TDSkillCondition)instance;
}
}
if (condition == null)
condition = new TDSkillCondition_Whenever();
condition.Initialize(field, owner, skillInfo.ConditionValue1, skillInfo.ConditionValue2);
CreateRunner(skillInfo.SkillType, 1, ref runner);
CreateRunner(skillInfo.SecondarySkillType, 2, ref runner2);private void CreateRunner(string typeString, int order, ref TDSkillRunner runner)
{
string className = "TDSkillRunner_" + typeString;
Type type = Type.GetType(className);
if (type != null)
{
object instance = Activator.CreateInstance(type);
if (instance is TDSkillRunner myClassInstance)
{
runner = (TDSkillRunner)instance;
}
}
if (runner == null)
runner = new TDSkillRunner_AreaDamage();
runner.Initialize(field, owner, skillInfo, order);
}새 효과를 추가할 때 컨텍스트를 고치지 않아도 되는 비결이 이 이름 규약입니다. TDSkillRunner_ 로 시작하는 클래스를 만들고 표에 이름을 적으면 끝입니다. 대신 대가가 있습니다.
조용한 대체
조건 이름에 오타가 나면 Whenever(언제나)로, 효과 이름이 비거나 틀리면 AreaDamage 로 바뀝니다. 오류가 나지 않아서 데이터 실수를 늦게 알아챕니다.
등록처가 셋
클래스 이름, 표의 문자열, 그리고 IL2CPP 빌드에서 클래스가 걷히지 않게 지키는 link.xml. 셋을 손으로 맞추고, 컴파일러는 검사해 주지 않습니다.
이름 짧은 GetType
어셈블리 이름 없이 클래스 이름으로 찾으므로, 나중에 코드를 어셈블리로 나누면 찾지 못합니다.
<assembly fullname="Assembly-CSharp">
<type fullname="TDSkillCondition_EnemyBack" preserve="all"/>
<type fullname="TDSkillCondition_EnemyFar" preserve="all"/>
<type fullname="TDSkillCondition_EnemyNear" preserve="all"/>
<type fullname="TDSkillCondition_EnemyStraight" preserve="all"/>
<type fullname="TDSkillCondition_FriendlyNear" preserve="all"/>
<type fullname="TDSkillCondition_LongPressEnemy" preserve="all"/>
<type fullname="TDSkillCondition_LongPressFriendly" preserve="all"/>
<type fullname="TDSkillCondition_Whenever" preserve="all"/>
<type fullname="TDSkillRunner_AbnormalStatus" preserve="all"/>
<type fullname="TDSkillRunner_AccuracyPointBuff" preserve="all"/>
<type fullname="TDSkillRunner_AreaDamage" preserve="all"/>리플렉션으로만 만들어지는 클래스는 IL2CPP 빌드가 "아무도 안 쓰는 코드" 로 보고 걷어 낼 수 있습니다. 100턴은 link.xml 에 모든 조건 · 효과 클래스를 적어 막았습니다. 이 함정은 구조 읽기 2편에서 자세히 다뤘습니다.
보드 스킬: switch 팩토리와 템플릿 메서드
보드 위의 칸 스킬(지식 가속, 대장간, 보너스 굴림 …)은 다른 길을 택했습니다. 문자열을 enum 으로 바꾼 뒤 switch 로 클래스를 고르는 정적 팩토리입니다.
public static TDBoardSkill Create(BoardWorld parent, int skillIdx)
{
var skillInfo = JsonDataStorage.BoardSkillInfoTable[skillIdx];
var skillType = Enum.Parse<BoardSkillType>(skillInfo.SkillType, true);
switch (skillType)
{
case BoardSkillType.AccelLearning:
return new TDBoardSkill_AccelLearning(parent, skillInfo);
case BoardSkillType.ArrivalQuest:
return new TDBoardSkill_ArrivalQuest(parent, skillInfo);
case BoardSkillType.BattleResultBuff:
return new TDBoardSkill_BattleResultBuff(parent, skillInfo);
…
case BoardSkillType.WoodStage:
return new TDBoardSkill_WoodStage(parent, skillInfo);
default:
throw new UnityException($"Invalid skill type: {skillType}");
}
}
public TDBoardSkill(BoardWorld parent, BoardSkillInfo info)
{
owner = parent;
skillInfo = info;
BoardSkillType = Enum.Parse<BoardSkillType>(info.SkillType, true);
}
public virtual void Trigger(BoardEvent eventResult)
{
Execute(eventResult);
owner.OnEndRunSkill(this);
}
protected abstract void Execute(BoardEvent eventResult);눈여겨볼 곳은 Trigger 입니다. 부모가 실행 순서(실행 → 끝났다고 알림)를 정하고, 자식은 Execute 만 채웁니다. 이것이 템플릿 메서드입니다. 38종의 칸 스킬이 끝났을 때의 처리를 빠뜨릴 수 없게 만든 좋은 장치입니다.
액티브: 리플렉션
- 새 클래스를 만들고 표에 이름을 적으면 끝
- 잘못된 이름은 조용히 기본 클래스로
- 등록처가 셋이고 컴파일러가 검사하지 못함
보드: enum + switch
- 새 클래스마다 enum 과 case 를 더해야 함
- 잘못된 이름은 예외를 던지고 멈춤
- 같은 목록을 다시 나열한 switch 가 여러 곳에 생김
public void VisitBoardSkill(TDBoardSkill boardSkill)
{
if (!IsRunning) return;
// Debug.Log($"VisitBoardSkill: {boardSkill.GetType()}");
switch (boardSkill)
{
case TDBoardSkill_AccelLearning:
case TDBoardSkill_ArrivalQuest:
case TDBoardSkill_BattleResultBuff:
case TDBoardSkill_CharacterStatBuff:
case TDBoardSkill_CharacterStatDebuff:
case TDBoardSkill_DecelLearning:
case TDBoardSkill_RandomStatBuff:
case TDBoardSkill_RandomStatDebuff:
case TDBoardSkill_RetainExperience:
case TDBoardSkill_RetainGold:자동 진행 에이전트는 같은 보드 스킬 목록을 클래스 타입으로 다시 나열해 분기합니다. 보드 스킬 종류에 따른 분기는 이런 식으로 일곱 곳에 흩어져 있습니다. 새 칸 스킬을 더하면 일곱 곳을 모두 찾아 고쳐야 합니다.
패시브 스킬: 데이터와 화면까지
패시브 스킬은 액티브와 나란히 있을 것 같지만, 코드에서는 모양이 전혀 다릅니다. 캐릭터를 만드는 코드를 보면 차이가 한눈에 보입니다.
if (JsonDataStorage.ActiveSkillTable.TryGetValue(chrInfo.ActiveSkillIDX1, out var skill1))
{
ret.activeSkillInfo1 = skill1;
ret.activeSkill1 = new TDActiveSkill(ret, skill1);
}
if (JsonDataStorage.ActiveSkillTable.TryGetValue(chrInfo.ActiveSkillIDX2, out var skill2))
{
ret.activeSkillInfo2 = skill2;
ret.activeSkill2 = new TDActiveSkill(ret, skill2);
}
if (JsonDataStorage.PassiveSkillTable.TryGetValue(chrInfo.PassiveSkillIDX1, out var passiveSkill1))
{
ret.passiveSkillInfo1 = passiveSkill1;
}
if (JsonDataStorage.PassiveSkillTable.TryGetValue(chrInfo.PassiveSkillIDX2, out var passiveSkill2))
{
ret.passiveSkillInfo2 = passiveSkill2;
}액티브는 new TDActiveSkill(…) 로 실행 객체를 만들지만, 패시브는 표의 행을 필드에 담기만 합니다. 층별로 따라가 보면 실행 층이 비어 있습니다.
표의 설명문에는 "전투 시작 시", "적이 사망할 때", "스킬 사용 시", "매 몇 초마다" 같은 발동 시점이 적혀 있습니다. 기획은 사건이 일어날 때 반응하는 스킬인데, 그 사건을 받아 줄 실행 층이 아직 만들어지지 않은 상태입니다.
액티브에서 잘 통한 방법을 그대로 가져오면 됩니다. 패시브는 매 프레임 판정하는 대신 전투의 사건(시작, 적 사망, 스킬 사용, 시간 경과)에 반응하는 전략으로 만듭니다.
public abstract class TDPassiveEffect // 액티브의 TDSkillRunner 에 해당하는 전략
{
protected TDCharacter owner;
protected PassiveSkill info;
public virtual void OnBattleStart() { }
public virtual void OnTick(float deltaTime) { }
public virtual void OnEnemyDead(TDCharacter enemy) { }
public virtual void OnSkillUsed(TDActiveSkill skill) { }
public virtual void OnAllyDead(TDCharacter ally) { }
}
public class TDPassiveEffect_PassiveAttack : TDPassiveEffect
{
public override void OnBattleStart() => owner.TakeAttackPointBuff(owner, int.MaxValue, info.PassiveValue1);
}
// 등록은 한 곳에서, 시작할 때 표의 모든 PassiveType 을 검사한다
static readonly Dictionary<string, Func<TDPassiveEffect>> registry = new()
{
["PassiveAttack"] = () => new TDPassiveEffect_PassiveAttack(),
};등록처를 Dictionary 한 곳에 두면 리플렉션도, link.xml 도 필요 없습니다. 게임이 시작할 때 표에 적힌 모든 타입이 등록되어 있는지 검사하면, 액티브의 "조용한 대체" 문제도 함께 피합니다.
어디까지가 전략 패턴인가
| 부분 | 판정 | 까닭 |
|---|---|---|
| TDSkillCondition · TDSkillRunner | 전략 | 같은 모양 뒤에서 알고리즘을 갈아 끼우고, 컨텍스트가 모양만 압니다 |
| TDStrategy (캐릭터 AI) | 전략 | 실행 중에도 SetStrategy 로 교체합니다. 가장 교과서적인 전략입니다 |
| TDActiveSkill 생성자 · CreateRunner | 팩토리 | 이름 규약 + 리플렉션으로 전략 객체를 만듭니다 |
| TDBoardSkill.Create | 팩토리 | enum + switch 로 만듭니다 |
| TDBoardSkill.Trigger → Execute | 템플릿 메서드 | 부모가 순서를 정하고 자식이 단계를 채웁니다 |
| 보드 스킬 객체 | 커맨드에 가까움 | 만들고, 한 번 실행하고, 버립니다 |
잘된 점
- 조건과 효과를 나눠 61개 스킬을 데이터 조합으로 만듭니다
- 새 효과를 더할 때 컨텍스트를 고치지 않습니다
- 보드 스킬은 잘못된 타입에서 예외로 멈추고, 끝 처리를 템플릿 메서드로 한곳에 모았습니다
- 클래스가 10~70줄로 작아 읽기 쉽습니다
아쉬운 점
- 액티브는 잘못된 이름을 조용히 기본 클래스로 바꿉니다
- "LongPressEnemy" 문자열과 구체 타입을 직접 검사하는 코드가 여러 곳에 있어 전략 뒤에 숨긴 차이가 새어 나옵니다
- 클래스 이름 · 표 문자열 · link.xml 을 손으로 맞춰야 합니다
- 보드 스킬 종류에 따른 switch 가 일곱 곳에 흩어져 있습니다
- 패시브 스킬의 실행 층이 아직 없습니다
다시 만든다면
등록처 한 곳
Dictionary<string, Func<…>> 레지스트리 하나에 모든 전략을 등록합니다. 리플렉션과 link.xml 이 필요 없어집니다.
대체 대신 오류
시작할 때 표의 모든 타입 문자열을 레지스트리와 대조하고, 없으면 오류로 멈춥니다.
패시브 실행 층
전투 사건(시작 · 사망 · 스킬 사용 · 시간)에 반응하는 TDPassiveEffect 전략을 만듭니다.
정리
- 전략은 두 축으로 나눌 때 강해집니다. 조건 8종 × 효과 23종이 코드 없이 수십 가지 스킬이 됩니다.
- 타입을 고르는 곳이 약점이 됩니다. 리플렉션은 조용히 틀리고, switch 는 여기저기 복사됩니다. 등록처는 한 곳에.
- 패시브는 사건에 반응하는 전략으로. 데이터와 화면은 준비되어 있고, 실행 층을 채울 차례입니다.
구조 읽기 2편 문자열로 조립하는 전투 스킬에서 액티브 스킬의 조합 방식을 더 자세히 볼 수 있습니다.
함께 읽으면 좋은 글
AI가 만든 Unity 코드를 직접 검증해 보고 싶다면
이 글에서 살펴본 구조를 바탕으로 AI에게 작업을 요청하고 결과를 읽는 연습을 이어가세요. 「AI 코드를 읽는 게임 개발자」 무료 세션 1에서 시작하거나, 전체 커리큘럼과 수강 안내를 확인할 수 있습니다.