DoT와 CC 상태 이상 시스템 아키텍처: 확장 가능한 전투 효과 설계

지속 피해와 군중 제어 효과를 하나의 상태 이상 시스템으로 다루는 방법을 정리합니다. 시간 처리, 중첩 규칙, 면역·해제, 네트워크 동기화까지 실무에서 자주 부딪히는 설계 지점을 살펴봅니다.

✓ 복사 완료!
챕터전투 & 스킬 시스템
난이도심화
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
DoT와 CC 상태 이상 시스템 아키텍처: 확장 가능한 전투 효과 설계

왜 DoT와 CC를 같은 시스템에서 다뤄야 할까

화상, 중독, 출혈 같은 DoT(Damage over Time)와 기절, 빙결, 속박 같은 CC(Crowd Control)는 겉으로는 다르게 보인다. 하지만 둘 다 대상에게 일정 시간 유지되는 효과를 부여하고 시간 경과에 따라 상태를 갱신하며 중첩·면역·해제 규칙을 적용한다는 공통점이 있다.

처음에는 화상 컴포넌트와 기절 컴포넌트를 각각 만들기 쉽다. 효과가 늘어나면 갱신 로직과 UI, 해제 처리, 사망 처리, 네트워크 동기화가 분산된다. 따라서 효과의 공통 수명 주기를 하나의 상태 이상 시스템으로 모으고 효과별 차이만 데이터와 실행 전략으로 분리하는 편이 안전하다.

설계 목표

상태 이상 시스템은 다음 질문에 일관되게 답할 수 있어야 한다.

  • 효과는 언제 시작되고 언제 끝나는가?
  • 같은 효과를 다시 맞으면 지속 시간이 갱신되는가, 강도가 올라가는가, 별도 인스턴스로 쌓이는가?
  • 대상이 면역이면 적용 자체를 막는가?
  • 정화는 어떤 효과를 우선 제거하는가?
  • 기절과 감속이 동시에 걸렸을 때 이동 가능 여부는 어떻게 결정되는가?

효과의 판정과 표시를 분리하는 것도 중요하다. 전투 규칙은 서버 또는 권위 있는 시뮬레이션 계층에서 계산하고 아이콘과 이펙트는 그 결과를 구독해 표현한다. UI가 상태 이상 규칙을 직접 갖고 있으면 동기화 오류와 예외 처리가 늘어난다.

효과 데이터와 런타임 인스턴스 분리

효과의 변하지 않는 정의는 데이터 에셋에 두고 전투 중 달라지는 값은 런타임 인스턴스에 둔다. Unity에서는 ScriptableObject가 정의 데이터에 잘 맞는다.

using System;
using UnityEngine;

public enum EffectTag
{
    DamageOverTime,
    Stun,
    Root,
    Slow,
    Silence,
    Poison,
    Fire
}

public enum StackPolicy
{
    RefreshDuration,
    AddStack,
    ReplaceIfStronger,
    IndependentInstances
}

[CreateAssetMenu(menuName = "Combat/Status Effect")]
public class StatusEffectDefinition : ScriptableObject
{
    public string effectId;
    public EffectTag[] tags;
    public float duration;
    public float tickInterval;
    public int maxStacks = 1;
    public StackPolicy stackPolicy;
    public bool dispellable = true;
    public int dispelPriority;
}

public sealed class ActiveStatusEffect
{
    public StatusEffectDefinition Definition { get; }
    public int SourceId { get; }
    public float RemainingTime { get; set; }
    public float TickAccumulator { get; set; }
    public int Stacks { get; set; }
    public float Magnitude { get; set; }

    public ActiveStatusEffect(StatusEffectDefinition definition, int sourceId, float magnitude)
    {
        Definition = definition;
        SourceId = sourceId;
        Magnitude = magnitude;
        RemainingTime = definition.duration;
        Stacks = 1;
    }
}

effectId는 저장 데이터와 네트워크 메시지에서 안정적으로 참조할 수 있는 식별자다. 에셋 이름이나 배열 순서는 변경될 수 있으므로 식별자로 쓰지 않는 편이 좋다. Magnitude는 초당 피해, 감속 비율, 방어력 감소량처럼 효과별 수치를 담는다.

상태 이상 정의 데이터와 전투 중 활성 인스턴스의 분리 구조

적용 파이프라인을 한곳에 모으기

효과 적용은 단순히 목록에 추가하는 일이 아니다. 면역 검사, 중첩 규칙, 발동 이벤트, 표시 갱신을 정해진 순서로 처리해야 한다. 이를 전담하는 StatusEffectController를 대상 유닛에 둔다.

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public sealed class StatusEffectController : MonoBehaviour
{
    private readonly List<ActiveStatusEffect> activeEffects = new();

    public event Action<ActiveStatusEffect> EffectAdded;
    public event Action<ActiveStatusEffect> EffectRemoved;

    public bool TryApply(StatusEffectDefinition definition, int sourceId, float magnitude)
    {
        if (HasImmunity(definition.tags))
            return false;

        ActiveStatusEffect existing = activeEffects.FirstOrDefault(effect =>
            effect.Definition.effectId == definition.effectId &&
            effect.SourceId == sourceId);

        if (existing == null || definition.stackPolicy == StackPolicy.IndependentInstances)
        {
            var created = new ActiveStatusEffect(definition, sourceId, magnitude);
            activeEffects.Add(created);
            EffectAdded?.Invoke(created);
            return true;
        }

        ApplyStackRule(existing, magnitude);
        return true;
    }

    private void ApplyStackRule(ActiveStatusEffect effect, float magnitude)
    {
        switch (effect.Definition.stackPolicy)
        {
            case StackPolicy.RefreshDuration:
                effect.RemainingTime = effect.Definition.duration;
                effect.Magnitude = Mathf.Max(effect.Magnitude, magnitude);
                break;

            case StackPolicy.AddStack:
                effect.Stacks = Mathf.Min(effect.Stacks + 1, effect.Definition.maxStacks);
                effect.RemainingTime = effect.Definition.duration;
                break;

            case StackPolicy.ReplaceIfStronger:
                if (magnitude > effect.Magnitude)
                {
                    effect.Magnitude = magnitude;
                    effect.RemainingTime = effect.Definition.duration;
                }
                break;
        }
    }

    private bool HasImmunity(EffectTag[] tags)
    {
        // 장비, 패시브, 보스 패턴에서 제공하는 면역 태그를 검사한다.
        return false;
    }
}

중첩 키를 effectId만으로 정할지, effectId + sourceId로 정할지는 게임 규칙에 따라 다르다. 독은 공격자별로 누적되어야 할 수 있지만 기절은 시전자와 무관하게 하나만 유지하는 경우가 많다. 이 규칙을 코드 곳곳에 조건문으로 흩어놓지 말고 효과 정의의 정책으로 명시해야 한다.

flowchart TD
    A[효과 적용 요청] --> B{면역 또는 무효화?}
    B -- 예 --> X[적용 실패 이벤트]
    B -- 아니오 --> C{동일 효과 인스턴스 존재?}
    C -- 아니오 --> D[새 인스턴스 생성]
    C -- 예 --> E[중첩 정책 적용]
    D --> F[시작 이벤트 발행]
    E --> G[갱신 이벤트 발행]
    F --> H[활성 효과 목록]
    G --> H

시간 갱신과 DoT 틱 처리

매 효과마다 코루틴을 실행하면 구현은 빠르지만 효과 수가 많아질수록 취소와 일시 정지, 서버 권위 처리, 프레임 단위 오차를 통제하기 어렵다. 활성 효과를 한 컨트롤러에서 갱신하는 방식이 보통 더 예측 가능하다.

private void Update()
{
    float deltaTime = Time.deltaTime;

    for (int i = activeEffects.Count - 1; i >= 0; i--)
    {
        ActiveStatusEffect effect = activeEffects[i];
        effect.RemainingTime -= deltaTime;
        effect.TickAccumulator += deltaTime;

        if (effect.Definition.tickInterval > 0f)
        {
            while (effect.TickAccumulator >= effect.Definition.tickInterval)
            {
                effect.TickAccumulator -= effect.Definition.tickInterval;
                ExecuteTick(effect);
            }
        }

        if (effect.RemainingTime <= 0f)
        {
            activeEffects.RemoveAt(i);
            EffectRemoved?.Invoke(effect);
        }
    }
}

private void ExecuteTick(ActiveStatusEffect effect)
{
    if (!effect.Definition.tags.Contains(EffectTag.DamageOverTime))
        return;

    float damage = effect.Magnitude * effect.Stacks * effect.Definition.tickInterval;
    GetComponent<Health>().ApplyDamage(damage, effect.SourceId);
}

while 문은 한 프레임이 길어졌을 때 놓친 틱을 보정한다. 다만 심한 프레임 드롭에서 수십 번의 틱이 한 번에 실행될 수 있으므로 온라인 게임에서는 틱 횟수 상한과 서버 시뮬레이션 시간을 별도로 설계하는 편이 좋다.

초당 피해량을 dps, 틱 간격을 interval, 스택 수를 stacks라고 하면 한 틱의 기본 피해량은 다음처럼 계산할 수 있다.

damagePerTick=dps×interval×stacksdamagePerTick = dps \times interval \times stacks

이 방식을 쓰면 틱 간격을 1초에서 0.5초로 바꿔도 총 피해량의 의도가 유지된다. 단, 첫 틱을 적용 직후 발생시킬지 첫 간격 뒤에 발생시킬지는 별도 규칙으로 고정해야 한다. 플레이어 체감과 밸런스에 큰 차이를 만든다.

CC는 개별 효과가 아니라 능력 제한의 집계로 처리한다

기절, 속박, 침묵은 각각 이동·행동·스킬 사용을 제한한다. 이 제한을 효과가 직접 컴포넌트에 쓰는 방식으로 구현하면 해제 순서에 따라 상태가 꼬일 수 있다. 예를 들어 두 개의 기절 중 하나가 끝났다고 이동을 다시 허용하면 남은 기절이 무시된다.

안전한 방식은 활성 효과 전체에서 제한을 집계하고 최종 결과만 이동 및 스킬 시스템에 전달하는 것이다.

public readonly struct ControlState
{
    public bool CanMove { get; init; }
    public bool CanCast { get; init; }
    public float MoveSpeedMultiplier { get; init; }
}

public ControlState EvaluateControlState()
{
    bool stunned = activeEffects.Any(effect =>
        effect.Definition.tags.Contains(EffectTag.Stun));
    bool rooted = activeEffects.Any(effect =>
        effect.Definition.tags.Contains(EffectTag.Root));
    bool silenced = activeEffects.Any(effect =>
        effect.Definition.tags.Contains(EffectTag.Silence));

    float slowMultiplier = activeEffects
        .Where(effect => effect.Definition.tags.Contains(EffectTag.Slow))
        .Select(effect => 1f - effect.Magnitude)
        .DefaultIfEmpty(1f)
        .Min();

    return new ControlState
    {
        CanMove = !stunned && !rooted,
        CanCast = !stunned && !silenced,
        MoveSpeedMultiplier = stunned || rooted ? 0f : slowMultiplier
    };
}

감속 중첩은 곱연산, 최댓값 우선, 최솟값 우선 등 여러 정책이 가능하다. 예시처럼 가장 강한 감속 하나만 적용하면 수치가 폭발하기 어렵다. 반대로 곱연산은 여러 약한 감속이 의미를 갖게 하지만 하한값을 두지 않으면 조작감이 나빠질 수 있다.

기절·속박·침묵·감속을 최종 조작 가능 상태로 집계하는 과정

해제와 면역은 태그 기반으로 설계한다

RemovePoison(), RemoveStun()처럼 효과마다 전용 함수를 만들면 정화 스킬이 늘어날수록 조합을 관리하기 어려워진다. 효과에 태그를 부여하고 해제 대상도 태그 집합으로 표현하면 기능을 재사용할 수 있다.

public int Dispel(params EffectTag[] removableTags)
{
    var tagSet = new HashSet<EffectTag>(removableTags);
    var targets = activeEffects
        .Where(effect => effect.Definition.dispellable)
        .Where(effect => effect.Definition.tags.Any(tagSet.Contains))
        .OrderBy(effect => effect.Definition.dispelPriority)
        .ToList();

    foreach (ActiveStatusEffect effect in targets)
    {
        activeEffects.Remove(effect);
        EffectRemoved?.Invoke(effect);
    }

    return targets.Count;
}

예를 들어 약한 정화는 Poison, Fire만 제거하고 강한 정화는 Stun, Root까지 제거하도록 만들 수 있다. 보스의 강제 패턴에는 dispellable = false를 설정해 일반 정화를 막을 수 있다. 면역도 같은 방식으로 태그를 검사하면 특정 CC만 저항하거나 모든 이동 방해에 저항하는 설계를 자연스럽게 표현할 수 있다.

이벤트와 표시 계층

상태 이상 컨트롤러는 적용, 갱신, 제거 이벤트를 발행한다. UI는 이 이벤트를 받아 아이콘과 남은 시간을 표시하고 애니메이션 시스템은 기절 모션을 재생하며 사운드 시스템은 필요한 효과음만 재생한다. 이때 UI가 매 프레임 활성 효과 목록을 직접 수정하거나 전투 규칙을 재판정하지 않도록 한다.

표시용 데이터에는 다음 정도가 필요하다.

  • 아이콘, 이름, 툴팁 문구
  • 남은 시간과 스택 수
  • 정화 가능 여부
  • 중요한 CC 여부와 우선 표시 순서

DoT의 숫자 팝업은 틱 이벤트에 연결하면 된다. 다만 아주 짧은 주기의 효과가 많으면 숫자가 화면을 덮을 수 있으므로 같은 대상과 같은 시간 구간의 피해를 합산해 표시하는 정책을 별도로 둔다.

네트워크 게임에서의 기준 시간

온라인 게임에서는 상태 이상의 소유자를 명확히 정해야 한다. 일반적으로 서버가 효과 적용, 만료, 피해 틱, 사망을 판정하고 클라이언트는 그 결과를 표시한다. 클라이언트가 화상 피해를 확정하면 지연 시간이나 조작으로 결과가 달라질 수 있다.

동기화할 때 매 프레임 남은 시간을 보내기보다 다음 값을 전송하는 편이 효율적이다.

  • 효과 식별자와 시전자 식별자
  • 적용 시각 또는 만료 시각
  • 스택 수와 강도
  • 서버 시뮬레이션 틱 번호

클라이언트는 서버 시간 기준으로 남은 시간을 보간해 표시한다. 재접속이나 관전 중인 클라이언트에도 활성 효과 목록과 만료 시각을 보내면 현재 상태를 즉시 복원할 수 있다.

테스트에서 반드시 확인할 경계 조건

상태 이상 버그는 보통 정상적인 한 번 적용이 아니라 경계 조건에서 발생한다. 자동화 테스트나 플레이 모드 테스트로 다음 경우를 확인해 두는 것이 좋다.

  • 만료 직전 재적용했을 때 지속 시간과 틱이 의도대로 갱신되는가
  • 여러 시전자의 DoT가 규칙에 맞게 합쳐지거나 분리되는가
  • 두 개 이상의 기절 중 하나만 해제되어도 계속 행동 불가 상태인가
  • 정화와 사망이 같은 프레임에 발생해 제거 이벤트가 중복되지 않는가
  • 무적 상태에서 DoT 틱 피해만 막을지, 효과 적용도 막을지 규칙이 일관적인가
  • 일시 정지와 시간 배속에서 효과 시간이 어떤 시간축을 따르는가

마무리

DoT와 CC의 핵심은 효과별 클래스의 개수가 아니라 공통 수명 주기와 규칙의 위치를 정하는 데 있다. 효과 정의는 데이터로 전투 중 변화는 인스턴스로 이동과 스킬 제한은 최종 상태 집계로 분리하면 새 상태 이상을 추가해도 기존 시스템을 덜 건드리게 된다.

처음에는 화상과 기절처럼 단순한 효과 두 개로 시작하되 중첩 키와 틱 기준, 해제 태그, 능력 제한 집계만큼은 초기에 명확히 정해 두는 것이 좋다. 이 네 가지가 정리되어 있으면 감염, 공포, 보호막 흡수, 회복 지속 효과 같은 확장도 같은 구조 안에서 다룰 수 있다.