덧그리기 구조 리팩토링

Sharlotte ·2022년 11월 20일
0

Mindustry

목록 보기
2/2

https://store.steampowered.com/app/1127400/Mindustry/
Mindustry 7.0가 릴리즈됐단 소식을 듣고 부랴부랴 모드 생사여부를 확인해보았다.
v136때 한번 점검하고 손대지 않았던 모드가 4번이나 버전업을 거쳤음에도 불구하고 잘 살아있던 것에 놀라움을 느꼈다. 빌드/배포만 고치고 끝내려다가 추가 기능 건의를 받았다.

도구 디스플레이의 Unit Draws 탭에서 매니폴트(기체 화물 적재소에서 나오는 애)의 위치 추가해주세요

7.0의 에르키아를 플레이해보지 않았지만 찾아본 결과 적재소의 아이템을 하역지점으로 운송하는 유닛의 이동 경로를 그리면 되는 것이였다.

대개 이런 로직의 경우엔 이미 target getter 함수나 target 필드가 마련돼있어서 손쉽게 구현할 수 있었다. 그런데 구현 도중에 OverDraw의 구조가 매우 끔찍함을 발견했다.
모든 OverDraw들은 각자 BlockDraw ~ UtilDraw로 재분류되어 그 속에서 그려지는데, 이는 단일 책임 원칙을 위배한다.

    @Override
    public void draw() {
        super.draw();

        if(!enabled) return;
        Groups.build.each(b -> {
            if(!isInCamera(b.x, b.y, b.block.size/2f)) return;
            if(settings.getBool("blockstatus") && b.team != Vars.player.team() && b.block.consumers.length > 0) {
                float multiplier = b.block.size > 1 ? 1.0F : 0.64F;
                float brcx = b.x + (float)(b.block.size * 8) / 2.0F - 8.0F * multiplier / 2.0F;
                float brcy = b.y - (float)(b.block.size * 8) / 2.0F + 8.0F * multiplier / 2.0F;
                Draw.z(71.0F);
                Draw.color(Pal.gray);
                Fill.square(brcx, brcy, 2.5F * multiplier, 45.0F);
                Draw.color(b.status().color);
                Fill.square(brcx, brcy, 1.5F * multiplier, 45.0F);
                Draw.color();
            }

        });
        for (Tile tile : Vars.world.tiles) {
            if(!isInCamera(tile.worldx(), tile.worldy(), 8) || tile.build == null) continue;

            Building b = tile.build;

            if(settings.getBool("blockBar")) {
                drawBar(b, 0, -(b.block.size * 4 - 2), b.healthf(), Pal.health);

                if (b instanceof Turret.TurretBuild turretBuild) {
                    drawBar(b, 0, b.block.size * 4 - 2, turretBuild.reloadCounter / ((Turret) b.block).reload, Pal.ammo);
                }
                if(b instanceof ConstructBlock.ConstructBuild constructBuild)
                    drawBar(b, 0, b.block.size * 4 - 2, constructBuild.progress(), b.team.color);
                if(b instanceof Reconstructor.ReconstructorBuild reconstructorBuild)
                    drawBar(b, 0, b.block.size * 4 - 2, reconstructorBuild.fraction(), b.team.color);
                if(b instanceof UnitFactory.UnitFactoryBuild factoryBuild)
                    drawBar(b, 0, b.block.size * 4 - 2, factoryBuild.fraction(), b.team.color);
            }
        }
    }

이건 BlockDraw.java의 draw 메서드 코드인데, 이미 if로 각 기능들을 분기처리까지 해놔서 "블록 상태 그리기" 와 "블록 바 그리기"라는 두가지 책임이 있는게 한눈에 보였다.
단순히 디자인적 문제라면 다행이였다, 더 심각한건 저 Groups.build.each와 같이 모든 엔티티의 순회 코드가 매 Draw마다 산재되어있는 것이였다. 즉, 모든 건물 엔티티를 굳이 몇번씩이나 반복한다는 것이다. 심지어 이건 한 프레임에서 이뤄져야 하는 것이므로 프레임 드랍은 기정사실이였다.

이것을 해결하기 위한 솔루션은

  • OverDraw의 명확한 책임 단일화
  • 순회 코드의 의존성 분리

...추가로 OverDraw가 불필요하게 UI 빌드까지 갖고 있는걸 발견했다. ToolWindow가 OverDraw의 ON/OFF를 위해 관련 UI를 빌드할려고 호출하는 메서드들을 OverDraw에 두는건 불필요한 책임 분리다. ToolWindow의 UI 코드까지 리팩토링하면서 메서드 관련 부분을 제거했다.

public class OverDraw {
    public String name;

    OverDraw(String name, OverDrawCategory category) {
        this.name = name;

        OverDraws.draws.get(category).add(this);
    }

    /** 
        * Groups.build 에서 각 건물에 대한 그리기를 처리합니다.
        * @param build 각 Building 엔티티
    */
    public void onBuilding(Building build) { }

    /**
        * Groups.unit 에서 각 유닛에 대한 그리기를 처리합니다.
        * @param unit 각 Unit 엔티티
    */
    public void onUnit(Unit unit) { }

    /**
        * Vars.world.tiles 에서 각 타일에 대한 그리기를 처리합니다.
        * @param tile 각 Tile 엔티티
    */
    public void onTile(Tile tile) { }
    
    /**
        * 매 프레임에 대한 그리기를 처리합니다.
    */
    public void draw() {}
}

OverDraw는 이제 이름에 걸맞게 그리기에 대한 처리만 하게 됐다.
BlockDraw ~ UtilDraw를 둠으로써 마련한 그리기 유형 분리는 enum으로 처리할 생각이다.

public enum OverDrawCategory {
    Range("Range", Icon.commandRally),
    Link("Link", Icon.line),
    Unit("Unit", Icon.units),
    Block("Block", Icon.crafting),
    Util("Util", Icon.github);

    public final String name;
    public final Drawable icon;
    public boolean enabled = false;

    OverDrawCategory(String name, Drawable icon) {
        this.name = name;
        this.icon = icon;
    }
}

자바의 enum 생성자는 BlockDraw ~ UtilDraw 클래스 존재 목적의 완벽한 enum화를 가능케 해주었다. 이들을 사용할 ToolWindow와 그리기 클래스들을 집합시킬 OverDraws도 바뀐 구조에 맞춰 자료형과 코드를 바꾸었다.


BlockDraw ~ UtilDraw 에 들어있던 그리기 코드들은 이미 위와 같이 기능이 분리된 상태라 고민할 필요 없이 클래스로 하나하나씩 모두 다 분리했다.

시-련: EIIE


draws가 null이라 한다.

...분명 초기화를 했는데 이러는거 보면 초기화 순서의 문제인 것 같다.

시도

draws 초기화가 제대로 되지 못한 것이라면 getter 메서드를 활용해 null일 때 초기화하도록 lazy 구현을 하자.

다른게 또 터졌다.

이건 예상 가능한 문제다, 초기화된 ObjectMap엔 value든 key든 없으므로 애초에 존재한다고 가정하고 구현한 내 잘못이다. 이것도 lazy하게 Seq를 생성하면 되는데, 놀랍게도 ObjectMap#get는 두번째 파라미터로 기본값을 제공해주고 있다.

변수를 추가로 선언하지 않아도 돼서 너무 행복하다.

시-련2: 내 UI 어디갔어요


로드는 잘 됐는데 ui가 가출했다.

카테고리 버튼을 생성하는 ui 코드는 초기화될 때 같이 실행되는데, 이때 getDraws()를 하더라도 키들은 비어있으니 결국 keys()는 빈 배열을 뱉을 것이다.

시도


생성할 때 카테고리를 모두 다 집어넣도록 바꿨다.


그래도 안나온다.


애초에 카테고리들을 가져오는거니깐 enum의 values()를 직접 갖다쓰는게 더 나아보인다. 애초에 getDraws()에서도 썼지만 안먹혀서 values()조차 맛간게 아닌가 싶지만 희망의 끈을 집어던질 순 없는 노릇이니 일단 해봤다.


겁나 잘나온다.

시-련3: NPE


버튼을 누르니 터진다.

시도


아니 이게 null일수가 없는데 터지는걸 봐서 getDraws()에서 카테고리 넣는게 작동안되는건 확실한 것 같다. 설마 생성 순서가 변덕을 부려서 이번엔 초기화가 되어 getDraws()의 draws 할당 코드를 넘긴건가?


더이상 이놈을 믿을 수가 없어서 외출금지를 시키고 초기화를 그만뒀다.


그래도 getDraws()가 잘 돌아갈지 의문이니 기본값도 넣어줬다.


겁나 잘돌아간다. 번들 안먹히는건 속성 이름을 중간에 바꿔서 그런거라 금방 해결했다.

https://github.com/Sharlottes/Informatis/commit/4fd6d5975349a93f8e54b59bfa61d376433cfea2
자세한 변경 내용은 이 커밋으로 볼 수 있다.

profile
샤르르르

0개의 댓글