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

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

 

조건

처음 입사했을 때, 새 3매치 프로젝트의 주요 사양에는 네 가지가 있었다.

 

1. 두 칸 이상의 크기를 가진 블록

기존 블록이 1x1이라면, 2x2 이상이나 5x1 등으로 이루어진 블록들을 만들 수 있어야 한다.

심지어 어떤 장애물 블록은 실시간으로 크기가 변경될 수도 있다.

 

2. 블록 위에 블록이 있을 수 있다

상자 블록이 있다고 하면, 그 블록을 가리는 꿀 블록이 있을 수도 있다.

이런 경우 꿀 블록이 사라질 때까지 상자 블록을 찾을 수 없다. (특수 블록의 대상이 될 수 없음)

 

3. 턴제가 아닌 실시간 매치

원하면 언제든 매치를 시도할 수 있어야 한다.

한 번 이동하고 모든 블록이 떨어질 때까지 기다리는 턴제가 아니라, 동시에 두 개의 블록을 스왑할 수도 있는 실시간 매치여야 한다.

 

4. 실시간으로 목표를 찾는 특수 블록

목표에 있는 블록들을 타겟으로 하는 일종의 유도 미사일이 있어야 한다.

단, 목표가 사라지거나 타겟으로 할 수 없는 상태가 될 경우 즉시 타겟을 변경해야 한다.

 

 


 

과거의 나

이 네 가지 사양을 충족시키기 위해, 과거의 나는 슬롯 안에 블록, 그리고 블록과 블록이 연결된 구조를 떠올렸다.

 

 

4x4 보드는 슬롯 (0,0)부터 (3, 3)까지의 16개 Slot 객체로 이루어져 있다.

 

- A : 두 칸 이상의 크기를 가진 블록

- B : 일반적인 장애물 블록

- C : B에, 가림막 역할을 하는 장애물 블록을 올림

 

 

 

 

A는 2x2 크기의 블록이지만,

실제로는 A1, A2, A3, A4의 블록이 이어져 있는 형태로 정했다.

이 경우 넷 중 하나가 파괴될 때, 나머지 A2~A4도 같이 파괴되도록 했다.

 

C는 레이어 개념을 도입했다.

슬롯은 4개의 레이어를 가지고 있었고, 슬롯 파괴 시 가장 상단 레이어의 블록을 파괴하도록 했다.

레이어는 아래에서부터 Floor, Block, Equip, Overlay라고 지정했다.

 

 

 

의사 코드로 표현해보겠다.

enum Layer { Floor = 0, Block = 1, Equip = 2, Overlay = 3 }

class BlockFactory {
    
    CreateBlock(Vector2Int index, BlockInfo info) {
        
        var slot = slots[index];
        var layer = info.layer();
        
        slot.blocks[layer] = block;
    }
}

class Slot 
{
    Vector2Int index;
    Block[4] blocks;
    
    Crush() {
    	blocks.Top?.Crush();
    }
}

class Block
{
    List<Block> childs;
    
    Crush() {
        childs.ForEach(e => e.DeSpawn());
    	DeSpawn();
    }
}

class Block_Child : Block
{
    Block parent;
    
    Crush() {
        parent?.Crush();
    }
}

 

우선 중력이 작용해야 하므로, 과거의 나는 중력의 주체를 Slot으로 정했다.

각 Slot은 레이어가 비어 있다면 보드에서 위에 있는 블록을 가져왔다.

그리고 블록들은 Update마다 해당 슬롯의 위치를 향해 position을 조정했다.

 

class Slot {

    Drop() {
        if(Blocks[Layer] == null) {
            Blocks[Layer] = UpSlot.Blocks[Layer];
        }
    }
}

class Block {
    
    Slot slot;

    Update() {
        MoveTo(slot);
    }
}

 

그리고 턴제가 아닌 실시간 매치를 수행하기 위해, 나는 각 Block의 상태를 선언했다.

 

Normal은 스왑이나 매치를 수행할 수 있었고, 특수 블록의 타겟이 될 수도 있었다.

Drop은 특수 블록의 타겟만이 될 수 있었다.

Transform은 중력의 영향을 받지도 않았고, 스왑/매치/타겟의 대상이 되지도 않았다.

class Block
{
    State state;
    
    Update() {
        if(state == Drop) {
            MoveTo(slot);
            if(CloseEnough(slot))
                state = Normal;
        }
    }
    
    Match() {
        state = Transform;
        DeSpawn();
    }
}

enum State { Normal, Drop, Transform }

 

특수 블록은 목표를 지정하고 그 위치로 날아가다가, 타겟이 사라진 경우에는 다시 타겟을 찾았다.

class Block_Missile : Block {

    Block target;

    FindTarget() {
        target = Board.Slots.Select(s => s.Blocks.Top)
                            .Where(b => b.state != Transform)
                            .GetRandom();
    }
    
    Update() {
    
        if(target.state == Transform) {
            FindTarget();
            return;
        }
            
        MoveTo(target);
        
        if(CloseEnough(target))
            target.slot.Crush();
    }
}

 

 

 


 

 

현재의 나

 

1. 왜 class Block과 Layer.Block의 이름은 같아야 했는가

왜 헷갈리게 이딴 짓을 했는가 (...)

 

맵툴에서 기획자가 구분하기 쉽도록 Floor > Block > Equip > Overlay로 지은 건 이해하겠는데, 굳이 Block이었어야 했나.

Object여도 괜찮았을 것 같은데, 아니면 Enum이 아니라 숫자여도 괜찮았을 것이다.

 

 

2. 그런 김에 Layer는 Enum이 아니라 int였으면 좋았을까

이랬다면 blocks는 Array가 아니라 Dictionary가 되었을지도 모르겠다. 확장성있고 좋지 않은가?

그리고 Array로 선언하면 Layer Enum이 추가될 때 귀찮아지잖아! (실제로 귀찮았음)

 

 

3. 블록의 레이어를 고정하기보다는 유동적인 게 낫지 않았을까

예시로 잔디 블록은 Floor 타입이었고, 같은 타입 블록들은 동일한 레이어에 넣을 수 없었다.

나는 이 명확함을 좋아했다. 맵 편집할 때 이해하기 쉬웠고, 휴먼 에러도 없었으니까.

 

그러나 1년 뒤 예상치 못한 '보물찾기 블록' 기획이 등장한다.

이 블록은 윗 블록이 모두 파괴되면서 모습이 드러나면 수집되는 녀석이었다.

기획에서는 이게 Floor 아래 뿐만 아니라 위에도 있기를 원했다.

다행히 모종의 사유로 개발에서 제외됐지만 아직도 뇌리에 깊게 박혀 있다 (...)

 

아무튼, 잔디 위에 잔디를 쌓을 수 있는 게 나쁜 일일까?

지금 생각해보면 맵툴의 기능을 조절하고, 코드 상으로는 확장성을 열어놓는 게 좋은 방법이었던 듯.

 

 

4. State를 더 구체적으로 관리했어야 했다

접근할 수 없는 경우를 모두 Transform으로 관리했더니, 해당 블록이 정확히 어떤 상태인지 알기 힘들었다.

이 블록이 Swap 중인지, 다른 블록으로 Convert 중인지, 이미 Match 중인지...

 

글쎄, [Flags]를 선언해서 다양한 상태를 관리하는 게 나았을까? 아래처럼 말이다.

[Flags]
public enum State
{
    Normal    = 0b_0000_0000,  // 0
    Drop      = 0b_0000_0001,  // 1
    
    Swap      = 0b_0000_0010,  // 2
    Match     = 0b_0000_0100,  // 4
    Convert   = 0b_0000_1000,  // 8
    Transform = Swap | Match | Convert
}

 

혹은, 아예 인터페이스를 선언하는게 나았을지도 모르겠다.

interface IState {
    bool IsSwappable();
    bool IsDroppable();
    bool IsCrushable();
    //...
}

class State_Normal : IState {
    //...
}

class State_Swap : IState {
    //...
}

class State_Match : IState {
    //...
}

class Block {

    IState state;

    Crush() {
        if(!state.IsCrushable())
            return;
        Despawn();
    }
}

 

반응형