글 작성자: Doublsb
현재 회사에서 설계하고 유지보수한 3매치 코드는 이미 검증되었지만,
과거의 자신을 돌아보는 것이 늘 그렇듯 다시 읽어보면 구데기인 부분이 너무 많다.

그래서 이 시리즈는 과거의 설계를 돌아보며 더 확장성있는 코드를 고민해보는 글이 되겠다.

 

조건

블록을 4개 이상 매치하면 특수 블록이 만들어지는 모습은 이제는 흔한 장면이 되었다.

그 중에서도 똑똑하게 미션을 깰 수 있도록 도와주는 유도 미사일, 혹은 부메랑을 개발해야 했다.

 

1. 게임 목표에 있는 장애물을 우선적으로 타겟으로 할 것

2. 도중에 타겟이 된 블록이 파괴되거나 식별할 수 없게 되면 다른 타겟을 찾을 것

3. 장애물을 파괴하는 것이 목적이 아닌 경우도 있음

 


 

과거의 나

 

3매치를 만들면서 가장 공수를 많이 들인 부분이 타겟팅 로직이 아닐까 싶다.

그만큼 코드도 많이 갈아엎었고, 버그도 많이 고쳤다. 아무튼, 초기의 구조부터 회고해보자.

 

사실, 처음에는 크게 어렵지 않았다.

TargetAssistant라는 타겟을 선정하는 매니저를 만들고, GetTarget 메서드를 호출하면 넘겨줬다.

보드를 검색할 때에는 미션 목표에 있는 블록들을 모두 찾아서 그 중 랜덤한 결과를 넘겨주면 되는 일이었다.

 

이 때, 나는 탐색 조건이 많아질 것을 염려해서 가중치 로직을 사용했다.

 

class Block {

    int weight;

    void AddTargetWeight() {

        weight = 0;
        
        if(this.IsMissionBlock())
            weight++;
    }
}

class TargetAssistant {
    
    Block GetTarget() {
        
        var result = GetMostWeightBlock();
        
        if(result != null)
            return result;
        else
            return GetRandomNormalBlock();
    }
    
    Block GetMostWeightBlock() {
        
        // 가장 가중치가 높은 블록을 가져오되,
        // state가 Transform일 경우 제외함
    }
}

 

가중치 로직을 사용하는 경우 확장성과 종속성에서 자유로울 수 있다는 것이 장점이었다.

만약 가려져 타겟이 될 수 없는 블록일 경우에는 AddTargetWeight()를 오버라이드해서 weight를 0으로 설정하면 되니까.

그 외에도 먼저 깨져야 하는 장애물이라면 가중치를 높이는 등의 작업을 할 수 있었다.

 

그런데 언제나 변수는 존재했다...

 


 

근대의 나 (?)

로얄 매치의 예시 : Jelly

예시는 로얄 매치의 Jelly 블록이며, 주변에서 매치를 하여 보라색 구역을 넓혀 나가야만 하는 목표물이다.

이런 젠장! 일단 기존 구조를 유지하며 구현하기에는 세가지 큰 문제가 있었다.

 

1. 기존 구조는 블록만 대상으로 할 수 있었다

젤리가 없는 슬롯을 타겟으로 해야 하는 상황이다. 그런데, 만약 그 위에 블록이 하나도 없다면?

슬롯을 타겟으로 할 수 있도록 바꿔야 했다. 그러나 블록을 타겟팅하는 로직 또한 유지해야 했다.

당연히, 블록은 여러 슬롯을 오가며 움직일 수 있었기 때문이었다.

 

2. AddTargetWeight는 자기 자신만 제어할 수 있다

논리대로라면 weight는 빈 슬롯일 경우에만 더해 줘야 했고, 오히려 보라색 구역이 존재하는 경우에는 가중치를 빼야 했다.

그럼 뭐, 옆에 있는 슬롯을 참조해서 젤리가 없으면 weight를 더해줘야 하나? 그럼 어떤 슬롯을 더했고 어떤 슬롯은 아직 안 더했는지 어떻게 조사하란 말인가?

 

3. 특수 블록이 젤리 위에서 사용되는 경우와 그렇지 않을 때의 결과가 다르다

이게 가장 고통스러운 부분인데, 특수 블록이 젤리 위에서 사용될 경우에는 도착 지점에 젤리가 퍼졌다.

하지만 젤리가 없는 슬롯에서 사용될 경우에는 도착 지점에 젤리가 퍼지지 않았다.

GetTarget을 요청할 때 파라미터가 필요해진 것이다.

 

 

첫 구현 때에는, GetTarget(string parameter)로 선언해서 젤리가 아래에 있다는 정보를 넘기며 코드를 더럽게 만들었다.

그랬더니 당연하게도 예외처리가 너무 많아지고, 코드 관리가 힘들어져서 다른 방법을 생각하게 되었다.

 

interface ITarget {
    GameObject GetObj();
    bool IsMissing();
}

class TargetBlock : ITarget {
    
    Block target;
    GameObject GetObj() => target.gameObject;
    virtual bool IsMissing() => target.State != Transform;
}

class TargetSlot : ITarget {
    
    Slot target;
    GameObject GetObj() => target.gameObject;
    virtual bool IsMissing() => target.Blocks.IsDirty();
}

class TargetSpread : TargetSlot {
    override bool IsMissing() => target.Blocks.Contains(Block_Jelly);
}

class TargetNormal : TargetBlock { 
    //내용이 있지만 생략
}

 

우선 ITarget이라는 인터페이스를 만들고, 그것을 상속하는 타겟 종류들을 만들었다.

이렇게 하면 특수한 타겟이 여전히 보드에 존재하는지, 여전히 타겟 대상인지 쉽게 판단할 수 있었다.

 

class TargetWeight {

    enum Param {
        Normal,
        Spread,
    }
    
    dictionary<Param, int> weight;
    
    void Add(Param param, int value) {
        weight[param] += value;
    }
    
    int Get(IEnumerable<Param> list) {
        return list.Sum(e => weight[e]);
    }
}

class Block {
    TargetWeight weight;
}

class Slot {
    TargetWeight weight;
}

class TargetAssistant {
    
    ITarget GetTarget(IEnumerable<Param> @params) {
        //슬롯, 블록 별 weight를 수집하여 가장 높은 weight의 객체들만 가져온다.
        //그 중 랜덤한 객체를 지정한 후, ITarget 객체로 만들어 return
    }
}

 

그리고 TargetWeight 클래스를 만들어 파라미터를 커버할 수 있도록 만들었다.

이러면 파라미터에 따라 가중치를 다르게 적용하여 결과값을 가져올 수 있었다.

 

class Block {

    virtual void AddTargetWeightSelf() {
        if(this.IsMissionBlock())
            targetWeight.Add(Normal, 1);
    }

    virtual void AddTargetWeightGlobal() { }
}

//Example
class Block_Jelly {

    override void AddTargetWeightSelf() {
        targetWeight.Add(Normal, -1);
    }
    
    override void AddTargetWeightGlobal() {
    
        //미션에 있을 때, 한번만 수행
        foreach(var s in board.slots) {
            if(!s.Blocks.Contains(Block_Jelly)) {
                s.targetWeight.Add(Spread, 1);
            }
        }
    }

}

 

그리고 AddTargetWeight가 자신만 제어할 수 있었던 부분을 수정했다.

AddTargetWeightGlobal 메서드를 새로 구현했는데, 이는 미션에 존재할 때 단 1회만 수행했다.

Block_Jelly의 예시로, 모든 슬롯을 참조해서 젤리가 없는 경우에만 가중치를 더해 주는 모습이다.

 

다만 그 이후 파라미터를 추가할 일이 없었기에, Spread만 남아 있게 되었다.

 

 


 

 

현재의 나

 

1. ITarget은 Block과 Slot에서 구현하는 것이 맞다

굳이 TargetSlot, TargetBlock, TargetSpread, TargetNormal 객체를 따로 만들어가며 구현했다는 생각이 든다.

일단 ITarget 객체를 만들고 해제할 때마다 가비지가 튀어나오는 게 마음에 안 들었다. 그냥 블록과 슬롯에 구현해서 쓰면 할당할 필요가 없어지잖아!

ITarget을 사용할 때 transform을 참조하려면 종류에 따라 변환을 추가로 거쳐야 한다는 것도 마음에 안 든다.

 

2. 결국 Slot과 Block을 분리해서 처리해야만 했다

이렇게 구현하면 슬롯이 타겟인지, 블록이 타겟인지 경우에 따라 다른 구현을 해야만 한다.

하나로 통합하여 타겟을 할당하고 해제할 수 있는 방법이 없을까?

 

 

class TargetWeight {

    enum Param { Common, NormalOnly, Spread }
    
    dictionary<Param, int> weight;
    
    void Add(Param param, int value) {
        weight[param] += value;
    }
    
    int Get(Param type) => weight[type];
    void Clear() => weight.Clear();
}

interface ITarget {
    bool IsMissing(Param type);
}

class Slot : ITarget {
    TargetWeight weight;
    
    int GetWeight(Param type) {
        weight.Clear();
        Blocks.ForEach(b => b.AddWeight(type));
    }
    
    bool IsMissing(Param type) {
        switch(type) {
            default:
                return Blocks.IsDirty();
            case Spread:
                return Blocks[Floor] is not Null;
        }
    }
}

class Block : ITarget {
    void AddWeight(Param type) {
        //Type에 따라 weight 처리
    }
    
    void AddWeightGlobal(Param type) {
        //Type에 따라 weight 처리
    }
    
    bool IsMissing(Param type) {
        return State == Transform;
    }
}

class TargetAssistant {
    ITarget Get(Param type) {
    
        Mission.Blocks.ForEach(e => e.AddWeightGlobal(type));
        
        var slot = board.slots.MostWeight(type); //가장 가중치가 높은 로직을 가져오는 것은 생략
        
        switch(type) {
            default:
                return slot.Blocks.Top;
            
            case Spread:
                return slot;
        }
    }
}

 

블록은 무조건 슬롯에 있을 수밖에 없다는 근거로, 슬롯만 TargetWeight를 가지도록 했다.

ITarget은 구상한 대로 Block과 Slot에서 상속받도록 했다. 이렇게 하니 추가적인 변환 처리를 할 필요가 없어졌다.

 

Param은 좀 더 세분화해서 일반적인 상황, Normal 블록만 필요한 상황, Spread를 원하는 상황으로 나눴다.

결과적으로 특수 블록에서는 ITarget만 들고 있다가 IsMissing인 경우 재요청, 제 위치에 도달한 경우 타입을 캐스트해 Crush 메서드를 호출하면 되도록 바꾸었다.

반응형