문제
핵앤슬래시에서 콘텐츠 볼륨은 코드 볼륨보다 훨씬 빠르게 증가한다. 스킬 하나를 추가할 때마다 클래스를 만들어야 한다면, 콘텐츠 확장이 곧 코드 변경이 되고 빌드 사이클에 묶인다. 3명이 소규모 팀으로 볼륨 있는 게임을 만들려면, "코드는 파이프라인을 정의하고, 데이터가 동작을 결정한다"는 원칙이 필수였다.
각 도메인에서 데이터가 어떻게 동작을 결정하는지 먼저 보면:
스킬 (Signal): Signal SO 자체가 스킬의 동작을 정의한다. Shoot Origin SO에 투사체 수, 속도, 사거리가 데이터로 들어 있고, Fan Modulation SO에 부채꼴 각도와 방향 수가 데이터로 들어 있다. 코드는 Origin/Modulation/Final 파이프라인만 정의하고, 어떤 조합이 어떤 동작을 하는지는 전적으로 SO 데이터가 결정한다. 새 스킬 변형을 추가할 때 코드를 건드리지 않는다.
장비 (FrameData): FrameData SO에 슬롯 구성(SlotPool), 옵션 풀(AffixPool), 최대 옵션 수, 세트 ID가 데이터로 정의된다. Roll()을 호출하면 이 데이터를 기반으로 슬롯과 Affix가 확률적으로 생성된다. 장비 종류를 추가하려면 FrameData SO를 하나 만들고 SlotPool과 AffixPool을 설정하면 된다.
AI (TreeAsset): BT 구조를 TreeAsset SO에서 정의한다. 노드 타입, 조건, 프로퍼티, 자식 구조가 모두 SO 데이터다. 런타임에 BlobAsset으로 빌드되어 ECS에서 소비되지만, 설계 시점의 편집은 SO 에디터에서 완결된다.
몬스터 (MonsterData): 기본 스탯(baseStats: SerializedDictionary<StatType, long>), BT 참조, 드롭 테이블, 애니메이션 데이터가 모두 MonsterData SO에 들어 있다. 새 몬스터를 추가하려면 MonsterData SO를 만들고 스탯/BT/드롭을 설정하면 된다.
드롭 (DropTable): DropTable SO에 테이블 활성화 확률과 엔트리 목록이 정의된다. 각 DropEntry는 SerializeReference로 다형적이어서, SignalDropEntry/FrameDropEntry/CurrencyDropEntry를 에디터에서 자유롭게 조합한다.
이 구조가 동작하려면 데이터 자체의 추상화와 일반화가 뒷받침되어야 한다.
접근: 데이터 계층의 추상화
BaseData / Config 분리
게임 데이터를 런타임 데이터와 시스템 설정 두 계층으로 나눴다.
graph TD
SO["ScriptableObject"]
BD["BaseData\n(abstract)\n런타임 소비, Instantiate 복사본"]
CF["Config\n(abstract)\n읽기 전용, 원본 직접 참조"]
UD["UnitData\n유닛 스탯, 애니메이션, 레벨"]
PD["PlayerData\n+ 장비, 인벤토리"]
MD["MonsterData\n+ BT, 드롭 테이블"]
SG["Signal\n(abstract)\n스킬 파편"]
OR["Origin\nShoot, Melee, Beam,\nAura, Summon, Dodge"]
SM["SpawnModulation\nFan, Curve"]
RM["RuntimeModulation\nHoming, Bezier,\nSpiral, Control"]
FN["Final\nDamage, Explosion,\nSplit, Effect"]
FD["FrameData\n장비 프레임 정의"]
DT["DropTable\n드롭 확률 테이블"]
TC["TierConfig\n등급별 스탯 배율, 품질 자격"]
QC["QualityConfig\n품질별 롤 확률, 수치 범위"]
SD["SetDatabase\n세트 보너스 정의"]
SO --- BD
SO --- CF
BD --- UD
BD --- SG
BD --- FD
BD --- DT
UD --- PD
UD --- MD
SG --- OR
SG --- SM
SG --- RM
SG --- FN
CF --- TC
CF --- QC
CF --- SD
BaseData는 드롭 시 Instantiate()로 복사된다. 같은 FrameData 템플릿에서 드롭된 장비 두 개가 서로 다른 Affix를 가져야 하므로, 원본은 템플릿이고 런타임 인스턴스는 독립적으로 변형된다. Config는 시스템 동작을 정의하는 읽기 전용 설정이므로 원본을 직접 참조한다.
이 분리의 핵심은 템플릿과 인스턴스의 구분이다. 에디터에서 "기본 검 FrameData"를 하나 만들면, 그것이 드롭될 때마다 복사본이 생성되고 각 복사본에 독립적인 Affix가 롤링된다. Config는 "Tier 3 장비의 스탯 배율은 1.5"처럼 모든 인스턴스에 공통으로 적용되는 규칙이다.
스탯의 일반화: StatType + CalcType + Source
유닛의 모든 스탯을 SerializedDictionary<StatType, long>으로 표현한다. HP, 공격력, 방어력, 이동속도, 원소 저항 등 스탯 종류가 아무리 추가되어도 필드를 추가할 필요 없이 enum 값 하나만 추가하면 된다.
csharp
// UnitData: 모든 스탯이 하나의 딕셔너리
public SerializedDictionary<StatType, long> baseStats;
// StatType: enum으로 스탯 종류 정의
public enum StatType
{
MAX_HEALTH = 0, CURRENT_HEALTH = 1, DEFENSE = 2,
MOVE_SPEED = 3, LUCK = 4,
MAX_SHIELD = 5, CURRENT_SHIELD = 6,
MEMORY_CAPACITY = 7, MEMORY_REGEN_RATE = 8,
RESISTANCE_PHYSICAL = 60, RESISTANCE_PLASMA = 61, ...
}
스탯 수정도 일반화된 StatModifier 구조로 통일했다.
csharp
public class StatModifier
{
public StatType statType; // 어떤 스탯을
public CalcType calcType; // 어떻게 (PLUS / MINUS / PERCENT)
public long value; // 얼마나
public object source; // 누가 (Frame, SetBonus, Buff ...)
}
장비 Affix, 세트 보너스, 버프, 디버프 모두 동일한 StatModifier를 사용한다. source 필드로 출처를 추적하여, 장비를 해제하면 RemoveFrom(source) 한 줄로 해당 소스의 모든 모디파이어가 정확히 제거된다. 스탯 종류를 추가하거나 새로운 수정 소스가 생겨도, StatModifier 구조 자체는 변하지 않는다.
스킬 옵션의 일반화: SignalID + OptionStat + ModifyType
장비가 스킬 동작을 수정하는 것도 동일한 패턴으로 일반화했다. SignalModifier가 "어떤 Signal의 어떤 파라미터를 어떻게 수정할지"를 데이터로 표현한다.
csharp
public class SignalModifier
{
public SignalID targetSignalID; // 어떤 Signal을 (Shoot, Beam, Homing ...)
public OptionStat stat; // 어떤 파라미터를 (Cooldown, ProjectileSpeed ...)
public ModifyType modifyType; // 어떻게 (Flat / Percent)
public ValueType valueType; // 값 타입 (Float / Int / Long)
public RangedFloat floatValue; // 범위 값 (Roll() 전: min~max, 후: 확정값)
}
SignalID(23종)와 OptionStat(27종)의 조합이 모든 스킬 옵션 공간을 커버한다. 새 스킬 파라미터가 추가되면 OptionStat enum에 값을 추가하고, 해당 Signal 서브클래스의 ApplyOption()에 case를 추가하면 된다. Affix SO 에디터에서 "이 Affix는 Shoot의 Cooldown을 Flat으로 -0.5~-0.3초 수정한다"를 설정하는 것만으로 장비 옵션이 완성된다.
StatModifier와 SignalModifier가 하나의 Affix에 공존한다는 점이 중요하다.
csharp
public class Affix
{
public List<StatModifier> unitStats; // HP +50, 방어력 +20
public List<SignalModifier> signalOptions; // 쿨다운 -10%, 투사체 속도 +15
}
Affix 하나가 유닛 스탯과 스킬 옵션을 동시에 수정하는 구조다. 이 이중 채널이 하나의 데이터 구조에 통합되어 있으므로, 장비 장착/해제 시 양쪽을 항상 함께 바인딩/언바인딩할 수 있다.
Signal 데이터의 추상화
스킬 파편(Signal)은 Signal 추상 클래스를 상속하는 ScriptableObject다. 모든 Signal이 공유하는 인터페이스:
csharp
public abstract class Signal : ScriptableObject, IDroppable
{
public SignalID SignalId;
public SignalType Type; // Origin / Modulation / Final
public ItemData ItemData; // 인벤토리 아이템으로서의 공통 데이터
public abstract void Register(ExecutionContext ctx); // 파이프라인에 자기 등록
public virtual void ApplyOption(SignalModifier mod); // 장비 옵션 적용
public virtual void ResetBonuses(); // 옵션 초기화
public void Roll(); // IDroppable: 드롭 시 랜덤화
}
모든 Signal이 Register(), ApplyOption(), Roll()을 공유한다. 이 인터페이스 덕분에:
- Frame.Build(): 슬롯에 꽂힌 Signal이 무엇이든
Register(ctx)를 호출하면 파이프라인이 구성된다
- EquipmentModule.Bind(): Signal이 무엇이든
ApplyOption(mod)를 호출하면 장비 옵션이 적용된다
- DropTable.Roll(): Signal이 무엇이든
Roll()을 호출하면 RangedType 필드가 랜덤화된다
코드는 Signal의 구체 타입을 알 필요 없이 추상 인터페이스만으로 동작한다. 새로운 Origin이나 Modulation을 추가해도 기존 파이프라인 코드는 변경되지 않는다.
인벤토리 아이템의 일반화: IInventoryItem
Frame(장비), Signal(스킬 파편), Currency(재화)는 서로 다른 타입이지만, 인벤토리에서는 동일하게 취급되어야 한다. IInventoryItem 인터페이스가 이 공통 계약을 정의한다.
csharp
public interface IInventoryItem
{
ItemData ItemData { get; } // 이름, 아이콘, 크기, 카테고리
TooltipData GetTooltipData(); // UI 툴팁 생성
}
ItemData가 모든 아이템의 공통 메타데이터를 담는다.
csharp
public class ItemData
{
public Guid id; // 인스턴스 고유 식별자
public ItemCategory itemCategory; // FRAME / SIGNAL / CURRENCY
public LocaleKey nameKey; // 다국어 이름
public AssetRef<Sprite> icon; // 아이콘
public Vector2Int size; // 인벤토리 그리드 크기
public int maxStackSize; // 최대 중첩 수
public Tier tier; // 등급
}
InventoryGridStorage는 IInventoryItem만 다루므로, Frame이든 Signal이든 Currency든 동일한 배치/이동/스왑 로직으로 처리된다. DropTable도 List<IInventoryItem>을 반환하므로, 드롭 결과가 어떤 타입이든 인벤토리에 바로 들어간다. 새로운 아이템 타입을 추가하려면 IInventoryItem을 구현하고 해당 DropEntry 서브클래스를 만들면 된다.
RangedTypes: 확률적 데이터의 공통 표현
핵앤슬래시에서 대부분의 수치는 고정값이 아니라 범위다. 이 패턴을 RangedFloat, RangedInt, RangedLong 구조체로 일반화했다.
csharp
[Serializable]
public struct RangedFloat
{
public float min, max, value;
public bool IsFixed => min == max;
public float NormalizedValue => (max - min) > 0 ? (value - min) / (max - min) : 1f;
public float Roll() => value = Random.Range(min, max);
}
| 위치 | 용도 |
|---|
SignalModifier.floatValue | 장비 옵션 수치 범위 (쿨다운 -0.5~-0.3초) |
DropEntry.quantity | 드롭 수량 범위 (1~3개) |
| Signal 파라미터 | 스킬 수치 범위 (데미지 50~80) |
AffixDef.CreateInstance() | Affix 인스턴스화 시 범위 내 롤링 |
SO 에디터에서 min/max를 설정하고, 드롭 시점에 Roll()로 확정값을 결정한다. NormalizedValue는 UI 툴팁에서 롤 품질을 색상으로 표현하는 데 사용된다. PropertyDrawer 하나로 모든 곳에서 동일한 min/max 슬라이더를 제공한다.
IDroppable: 드롭 시점의 데이터 확정
모든 드롭 가능한 아이템이 구현하는 인터페이스다.
csharp
public interface IDroppable
{
void Roll(); // RangedType 필드를 랜덤화
}
Signal과 FrameData 모두 IDroppable을 구현한다. DropTable이 아이템을 생성할 때 Instantiate(template) → Roll() → id = Guid.NewGuid() 순서로 처리하면, 템플릿의 SO 데이터 구조를 기반으로 독립적인 인스턴스가 만들어진다. 템플릿은 "이 아이템이 가질 수 있는 범위"를 정의하고, Roll()이 "이 인스턴스의 확정된 값"을 결정한다.
정합성 보장
데이터가 동작을 결정하는 구조에서는 잘못된 데이터가 런타임 오류로 직결된다. 여러 계층에서 정합성을 검증한다.
타입 매칭: SignalSlot.CanAccept(signal)이 슬롯 타입과 Signal 타입의 일치를 강제한다. Origin 슬롯에 Modulation을 넣을 수 없다. SignalModifier.AppliesTo(SignalId)가 옵션이 대상 Signal에만 적용되도록 보장한다.
중복 방지: StatModifier의 (source, statType, calcType) 동일 조합은 value를 merge한다. Frame당 Split Final은 하나만 허용된다. DataSystem은 동일 CONFIG_TYPE/DataType의 중복 등록 시 경고 로그를 남긴다.
enum 기반 키잉: 스탯(StatType), 설정(CONFIG_TYPE), 데이터(DataType), Signal(SignalID), 옵션(OptionStat) 등 모든 데이터 식별이 enum으로 이루어진다. 문자열 키가 아니라 컴파일 타임에 검증되는 enum이므로, 오타나 존재하지 않는 키 참조가 빌드 시점에 잡힌다.
Source 추적: 모든 StatModifier에 source가 기록되어, 특정 장비나 버프가 제거될 때 정확히 해당 소스의 모디파이어만 걷어낸다. Source 없는 모디파이어는 존재할 수 없다.
결과
콘텐츠 추가가 SO 생성 + 데이터 설정으로 완결되는 구조를 확립했다. 새 스킬은 Signal SO 조합, 새 장비는 FrameData SO + AffixPool 설정, 새 몬스터는 MonsterData SO + BT + DropTable 연결로 추가된다. 데이터 추상화(BaseData/Config, StatModifier, SignalModifier, RangedTypes, IInventoryItem, IDroppable)가 도메인마다 반복되는 패턴을 일반화하여, 코드는 파이프라인 로직에만 집중하고 콘텐츠 확장은 데이터 레이어에서 처리된다.