엔티티는 물건만이 아니다
Esc

    엔티티는 물건만이 아니다

    –UnityDOTS

    플레이어가 공격하면 범위 안의 적이 맞아야 합니다. MonoBehaviour 로 짜면 플레이어가 직접 주변을 훑어서 때리겠지만, ECS 에서는 그럴 수 없습니다. GameObject 는 잡 안에서 엔티티를 만질 수 없으니까요.

    그래서 공격을 엔티티로 만들어 던졌습니다.

    public struct AttackRequest : IComponentData
    {
        public float3 Center;
        public float Radius;
        public int Damage;
        public float KnockbackScale;
        public float StunDuration;
    }

    플레이어가 공격 키를 누르면 이 컴포넌트만 붙은 엔티티를 하나 만듭니다. 시스템이 그걸 보고 판정하고, 처리한 뒤 DestroyEntity 합니다. 수명 1프레임입니다.

    엔티티는 캐릭터나 총알 같은 “물건” 만이 아닙니다. 한 번 일어난 사건도 엔티티가 됩니다. 이게 데이터 지향에서 개념적으로 제일 낯선 부분이었습니다.

    판정은 앞에서 만든 격자를 재사용한다

    AttackResolveSystem 이 요청을 받아 반경 안의 적을 찾는데, 이때 이동 회피에 쓰던 공간 해시를 그대로 씁니다. 새로 만들 게 없습니다.

    int range = (int)math.ceil(r.Radius / SpatialHash.CellSize);
    // 중심 셀에서 range 만큼 사방으로 훑습니다

    셀은 사각형이고 반경은 원입니다. 그래서 셀 안의 엔티티를 다 때리면 안 되고, 거리로 한 번 더 걸러야 합니다. 모서리 셀에 있는 놈들이 그 차이입니다.

    잡 안전성 예외 2,531개

    여기서 크게 막혔습니다. 실행하자마자 예외가 2,531개 쏟아졌습니다.

    원인은 싱글톤에 담아 넘긴 raw 네이티브 컨테이너가 ECS 의 자동 의존성 추적을 우회한다는 것이었습니다.

    한 시스템 안에서 잡을 연달아 스케줄하면 state.Dependency 가 알아서 체인을 겁니다. 그런데 해시맵을 싱글톤 컴포넌트에 실어 다른 시스템으로 넘기면, ECS 입장에서는 그냥 필드일 뿐이라 “이 맵을 쓰는 잡이 아직 안 끝났다” 를 모릅니다.

    해법은 JobHandle 을 같이 실어 보내는 것이었습니다.

    public struct SpatialHashMap : IComponentData
    {
        public NativeParallelMultiHashMap<int, float3> Map;
        public JobHandle BuildHandle;      // 빌드 잡이 끝났는지를 같이 넘깁니다
    }

    읽기 전에 BuildHandle.Complete() 를 부르면 됩니다. 추적을 우회했으면 추적을 손으로 해 줘야 합니다.

    검증

    공격 한 번에 129마리가 맞고 총 1,290 데미지 가 들어갔습니다. Damage 를 10으로 뒀으니 129 × 10 입니다. 딱 떨어집니다.

    숫자가 딱 떨어지는지를 보는 이유는, 중복 적용이나 누락이 여기서 드러나기 때문입니다. 1,300이 나오면 누군가 두 번 맞은 것이고 1,280이면 하나가 빠진 것입니다.

    그리고 광역기가 공짜로 붙었다

    몇 주 뒤에 광역기를 넣게 됐습니다. 화면의 적을 한 방에 쓸어버리는 스킬입니다.

    시스템 코드를 한 줄도 안 고쳤습니다.

    // 평타
    new AttackRequest { Radius = 5f,  Damage = 10,  KnockbackScale = 2.5f, StunDuration = 0.2f }
    
    // 광역기
    new AttackRequest { Radius = 25f, Damage = 100, KnockbackScale = 5.0f, StunDuration = 0.5f }

    숫자만 다릅니다. 별도 AoeRequest 타입도, 시스템 안의 분기도 없습니다. 반경이 하드코딩이 아니라 요청에 실려 오니까 25를 넣으면 그게 광역기입니다.

    만약 판정 로직을 PlayerAttackBridge 안에 박아 뒀다면 지금 갈아엎어야 했습니다. 경계를 데이터로 그어 둔 것이 나중에 값을 했습니다.

    KnockbackScale 과 StunDuration 도 나중에 같은 자리에 얹혔습니다. 시스템은 그 값을 흘려보내기만 하고, 실제 값은 인스펙터에서 튜닝됩니다. 평타는 2.5, 광역기는 5.0. 코드 수정 없이 감을 잡을 수 있습니다.

    좋은 경계는 나중에 공짜를 만듭니다. 이건 미리 설계를 잘해서가 아니라, “공격” 을 함수 호출이 아니라 데이터로 표현했기 때문입니다. 데이터는 값을 바꾸는 게 자유롭고, 함수 호출은 그렇지 않습니다.

    예측이 3배 틀렸다

    광역기를 넣기 전에 예상을 적어 뒀습니다.

    반경 5 평타가 129마리였으니, 반경 25면 면적이 25배 → 수천 마리 쯤 맞겠다

    실제로는 10,000 마리 전부 였습니다. 킬 카운터가 정확히 +10,000 을 찍었습니다.

    밀도를 역산하면 10000 / (π × 25²) ≈ 5.1마리/unit² 인데 어림잡았던 값은 1.6이었습니다. 3배 이상 조밀했습니다.

    원인은 회피에 대한 오해였습니다. 셀 크기가 1이니까 적들이 대략 1칸 간격으로 벌어져 있을 거라고 생각했는데, 회피는 딱딱한 제약이 아니라 무른 힘 입니다. 1만 마리가 안쪽으로 미는 압력이 회피력을 이기면 그냥 압축됩니다.

    밀도에 대한 직관은 틀립니다. 이건 재 보기 전에는 알 수 없었습니다.