Compy's Blog
2765 words
14 minutes
[Algorithm] BSP 알고리즘(맵 생성) 유니티로 구현하기!(2D)
2025-02-16
여기 잠깐!!

기본적인 알고리즘을 이해하는데 있어서 여기!를 참고해주세요!!

BSP 알고리즘 구현으로 해커톤 날먹이 된다고!?!#

사실 이건 장난이고, 그냥 있어보이게 구현할 수 있도록 구조를 다 만들어둬서 그냥 가져다 쓰시면 됩니다.

물론 이 코드가 체계적으로 작성된 코드는 아니지만! 그래도 쓸만한 애들이라고 생각합니다(최적화는 안되어있는 편 ㅎㅎ)

일단 활용하기 편하도록 Scriptable Object로 다 설정하도록 만들어 뒀습니다.

Scriptable Object 주의

이 Scriptable Object를 인게임 상에서 값을 수정하게 되면, editor 환경에서는 작동을 하나.. 나중에 build 이후로 가게되면 유니티에서 이제 최적화 과정을 거치면서 필요없는 prefabs 등 다른 리소스들을 다 없애는데 이때 Scriptable Object의 값이 변경되면 터집니다!

조심하십쇼!

기본적인 세팅#

일단 저는 부가적으로 Layer를 사용합니다. 참고하실 분은 참고해주세용

1. 벽 세팅#

기본적인 벽을 담을 스프라이트(2D Rect 정도?)를 기반으로 방향, 모서리 등의 위치에 배치할 스프라이트들을 넣으면 됩니다.

[CreateAssetMenu(fileName = "WallTileSettings", menuName = "Compy/Wall Tile Settings")]
public class WallTileSettings : ScriptableObject
{
    
    [System.Serializable]
    public class WallSprites
    {
        [Header("기본 벽 스프라이트")]
        [Tooltip("주변에 아무 연결이 없는 기본적인 벽 스프라이트")]
        public Sprite defaultWall;

        [Header("벽 테두리")]
        [Tooltip("아래쪽으로 연결된 상단 벽면")] // │ 의 윗부분
        public Sprite wallTop;
        [Tooltip("위쪽으로 연결된 하단 벽면")] // │ 의 아랫부분
        public Sprite wallBottom;
        [Tooltip("오른쪽으로 연결된 왼쪽 벽면")] // ─ 의 왼쪽
        public Sprite wallLeft;
        [Tooltip("왼쪽으로 연결된 오른쪽 벽면")] // ─ 의 오른쪽
        public Sprite wallRight;

        [Header("벽 모서리 - 외부")]
        [Tooltip("오른쪽과 아래로 연결된 외부 모서리")] // ┌ 모양
        public Sprite cornerOuterTopLeft;
        [Tooltip("왼쪽과 아래로 연결된 외부 모서리")] // ┐ 모양
        public Sprite cornerOuterTopRight;
        [Tooltip("오른쪽과 위로 연결된 외부 모서리")] // └ 모양
        public Sprite cornerOuterBottomLeft;
        [Tooltip("왼쪽과 위로 연결된 외부 모서리")] // ┘ 모양
        public Sprite cornerOuterBottomRight;

        [Header("벽 모서리 - 내부")]
        [Tooltip("위쪽과 왼쪽이 벽인 내부 모서리")] // ┘ 모양이지만 안쪽으로
        public Sprite cornerInnerTopLeft;
        [Tooltip("위쪽과 오른쪽이 벽인 내부 모서리")] // └ 모양이지만 안쪽으로
        public Sprite cornerInnerTopRight;
        [Tooltip("아래쪽과 왼쪽이 벽인 내부 모서리")] // ┐ 모양이지만 안쪽으로
        public Sprite cornerInnerBottomLeft;
        [Tooltip("아래쪽과 오른쪽이 벽인 내부 모서리")] // ┌ 모양이지만 안쪽으로
        public Sprite cornerInnerBottomRight;

        [Header("단독 벽")]
        [Tooltip("주변에 어떤 연결도 없는 단독으로 서있는 벽")]
        public Sprite singleWall;

        [Header("끝부분")]
        [Tooltip("위쪽 방향으로 돌출된 벽의 끝부분 (아래쪽만 연결)")] // ╽ 모양
        public Sprite endTop;
        [Tooltip("아래쪽 방향으로 돌출된 벽의 끝부분 (위쪽만 연결)")] // ╿ 모양
        public Sprite endBottom;
        [Tooltip("왼쪽 방향으로 돌출된 벽의 끝부분 (오른쪽만 연결)")] // ╾ 모양
        public Sprite endLeft;
        [Tooltip("오른쪽 방향으로 돌출된 벽의 끝부분 (왼쪽만 연결)")] // ╼ 모양
        public Sprite endRight;
    }

    [Header("벽 스프라이트 설정")]
    public WallSprites sprites;

    [Header("벽 렌더링 설정")]
    public string sortingLayerName = "Wall";
    public int sortingOrder = 0;
}

Bsp Generation 세팅#

bsp로 맵 생성을 할 때 사용될 변수들

using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "MapSettings", menuName = "Compy/Generator Settings")]
public class GeneratorSetting : ScriptableObject
{

    [Header("Map Size")]
    [Tooltip("전체 너비")]
    public int mapWidth = 100;
    
    [Tooltip("전체 높이")]
    public int mapHeight = 100;

    [Header("Room Generation")]
    [Tooltip("방의 최소 크기")]
    [Min(5)]
    public int minRoomSize = 10;
    
    [Tooltip("BSP 분할 최대 횟수")]
    [Range(1, 10)]
    public int maxIterations = 4;

    [Header("Room Constraints")]
    [Tooltip("방 사이의 최소 거리")]
    [Min(1)]
    public int minRoomDistance = 2;
    
    [Tooltip("방의 최대 크기 (전체 공간 대비 비율)")]
    [Range(0.3f, 0.8f)]
    public float maxRoomSizeRatio = 0.6f;

    [Header("Tile Settings")]
    [Tooltip("벽 타일 설정")]
    public WallTileSettings wallTileSettings;

    [Tooltip("기본 타일 프리팹 (스프라이트 렌더러와 콜라이더가 있는 빈 게임오브젝트)")]
    public GameObject tilePrefab;

    [Header("Prefabs")]
    [Tooltip("바닥 타일 프리팹")]
    public GameObject floorPrefab;

    [Header("Layer Settings")]
    [Tooltip("벽 레이어의 이름")]
    public string wallLayerName = "Wall";

    [Header("Advanced Settings")]
    [Tooltip("통로 너비")]
    [Range(1, 5)]
    public int corridorWidth = 3;
    
    [Tooltip("방 생성 시도 최대 횟수")]
    [Min(1)]
    public int maxRoomPlacementAttempts = 10;

    [Tooltip("추가 통로 생성 확률 (0-1)")]
    [Range(0f, 1f)]
    public float extraCorridorChance = 0.2f;
    
    [Header("Structures")]
    [Tooltip("맵에 배치할 구조물들의 정의")]
    public List<CompyStructure> structures = new List<CompyStructure>();

    [Tooltip("구조물 배치 최대 시도 횟수")]
    [Min(1)]
    public int maxPlacementAttempts = 100;

    
    
    
    public void ValidateSettings()
    {
        mapWidth = Mathf.Max(mapWidth, minRoomSize * 2);
        mapHeight = Mathf.Max(mapHeight, minRoomSize * 2);

        minRoomSize = Mathf.Min(minRoomSize, Mathf.Min(mapWidth, mapHeight) / 2);
        
        corridorWidth = Mathf.Min(corridorWidth, minRoomSize - 2);
        minRoomDistance = Mathf.Max(minRoomDistance, corridorWidth);

        ValidateStructures();
    }


    private void ValidateStructures()
    {
        if (structures == null) return;

        foreach (var structure in structures)
        {
            if (structure == null) continue;
            structure.width = Mathf.Min(structure.width, mapWidth - 4);
            structure.height = Mathf.Min(structure.height, mapHeight - 4);

            structure.minCount = Mathf.Min(structure.minCount, structure.maxCount);

            if (structure.padding > 0) 
                structure.padding = Mathf.Min(structure.padding,
                    Mathf.Min(structure.width, structure.height) / 2);
            
        }
    }
    
    public GeneratorSetting Clone()
    {
        var clone = CreateInstance<GeneratorSetting>();        
        clone.mapWidth = this.mapWidth;
        clone.mapHeight = this.mapHeight;
        clone.minRoomSize = this.minRoomSize;
        clone.maxIterations = this.maxIterations;
        clone.minRoomDistance = this.minRoomDistance;
        clone.maxRoomSizeRatio = this.maxRoomSizeRatio;
        clone.wallTileSettings = this.wallTileSettings;
        clone.tilePrefab = this.tilePrefab;
        clone.floorPrefab = this.floorPrefab;
        clone.wallLayerName = this.wallLayerName;
        clone.corridorWidth = this.corridorWidth;
        clone.maxRoomPlacementAttempts = this.maxRoomPlacementAttempts;
        clone.extraCorridorChance = this.extraCorridorChance;

        clone.structures = new List<CompyStructure>(this.structures);
        clone.maxPlacementAttempts = this.maxPlacementAttempts;

        return clone;
    }

#if UNITY_EDITOR
    // 내가 한번 당하지 두번은 안 당할거임 ㄹㅇ

    private void OnValidate()
    {
        ValidateSettings();
    }
#endif
}

BSP Algorithm#

BSP Room#

public class BSPRoom
{
    public int x, y, width, height;

    public BSPRoom(int x, int y, int width, int height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
}

BSP Generator#

기본적인 생성만 가볍게 만들어본 버전입니다.

public class BSPGenerator : MonoBehaviour{
    private enum WallType {
        Single,
        Top,
        Bottom,
        Left,
        Right,
        CornerOuterTopLeft,
        CornerOuterTopRight,
        CornerOuterBottomLeft,
        CornerOuterBottomRight,
        CornerInnerTopLeft,
        CornerInnerTopRight,
        CornerInnerBottomLeft,
        CornerInnerBottomRight,
        EndTop,
        EndBottom,
        EndLeft,
        EndRight,
        Default
    }
    private int mapWidth = 100;
    private int mapHeight = 100;
    private int minRoomSize = 10;
    private int maxIterations = 4; 
    
    private string wallLayerName = "Wall";
    
    private GameObject wallPrefab; 
    private GameObject floorPrefab; 

    private List<BSPRoom> rooms = new List<BSPRoom>();
    private int[,] map;
    private int wallLayerId;

    private GeneratorSetting setting;
    
    private const bool debug = false;

    private bool generateDone;
    
    public void GeneratorInit(GeneratorSetting setting)
    {
        generateDone = false;
        InitWithSettings(setting);
        
        wallLayerId = LayerMask.NameToLayer(wallLayerName);
        
        if (wallLayerId == -1) {
            Debug.LogError($"Layer '{wallLayerName}' not found! 이거 추가좀 이거 왜 안고쳐짐?");
            wallLayerId = 0;
        }

        this.setting = setting;
    }

    private void InitWithSettings(GeneratorSetting setting) {
        mapWidth = setting.mapWidth;
        mapHeight = setting.mapHeight;
        minRoomSize = setting.minRoomSize;
        maxIterations = setting.maxIterations;
        wallLayerName = setting.wallLayerName;
        floorPrefab = setting.floorPrefab;
    }
    
    public void GenerateMap() {
        map = new int[mapWidth, mapHeight];
        for (int x = 0; x < mapWidth; x++) {
            for (int y = 0; y < mapHeight; y++)
                map[x, y] = 1;
        }

        SplitSpace(0, 0, mapWidth, mapHeight, 0);
        
        ConnectRooms();
        
        InstantiateTiles();
        
        generateDone = true;
    }



    private void SplitSpace(int x, int y, int width, int height, int iteration)
    {
        if (iteration >= maxIterations || width < minRoomSize * 2 || height < minRoomSize * 2) {
            int roomWidth = Random.Range(minRoomSize, width - 4);
            int roomHeight = Random.Range(minRoomSize, height - 4);
            int roomX = x + Random.Range(2, width - roomWidth - 2);
            int roomY = y + Random.Range(2, height - roomHeight - 2);

            CreateRoom(roomX, roomY, roomWidth, roomHeight);
            rooms.Add(new BSPRoom(roomX, roomY, roomWidth, roomHeight));
            return;
        }

        bool splitHorizontal = Random.Range(0f, 1f) > 0.5f;
        if (width > height && width / height >= 1.25f) splitHorizontal = false;
        else if (height > width && height / width >= 1.25f) splitHorizontal = true;

        if (splitHorizontal) {
            int splitAt = Random.Range(minRoomSize, height - minRoomSize);
            SplitSpace(x, y, width, splitAt, iteration + 1);
            SplitSpace(x, y + splitAt, width, height - splitAt, iteration + 1);
        }
        else {
            int splitAt = Random.Range(minRoomSize, width - minRoomSize);
            SplitSpace(x, y, splitAt, height, iteration + 1);
            SplitSpace(x + splitAt, y, width - splitAt, height, iteration + 1);
        }
    }

    private void CreateRoom(int x, int y, int width, int height)
    {
        for (int i = x; i < x + width; i++) {
            for (int j = y; j < y + height; j++)
                if (i >= 0 && i < mapWidth && j >= 0 && j < mapHeight) map[i, j] = 0;
        }
    }

    private void ConnectRooms() {
        for (int i = 0; i < rooms.Count - 1; i++) {
            BSPRoom roomA = rooms[i];
            BSPRoom roomB = rooms[i + 1];

            Vector2Int pointA = new Vector2Int(
                roomA.x + roomA.width / 2,
                roomA.y + roomA.height / 2
            );
            Vector2Int pointB = new Vector2Int(
                roomB.x + roomB.width / 2,
                roomB.y + roomB.height / 2
            );
            
            CreateCorridor(pointA.x, pointA.y, pointB.x, pointA.y);
            CreateCorridor(pointB.x, pointA.y, pointB.x, pointB.y);
        }
    }

    private void CreateCorridor(int startX, int startY, int endX, int endY) {
        int x = startX;
        int y = startY;

        while (x != endX || y != endY)
        {
            if (x < endX) x++;
            else if (x > endX) x--;
            
            if (y < endY) y++;
            else if (y > endY) y--;

            if (x >= 0 && x < mapWidth && y >= 0 && y < mapHeight) {
                map[x, y] = 0;
                
                for (int i = -1; i <= 1; i++)
                {
                    for (int j = -1; j <= 1; j++)
                    {
                        int nx = x + i;
                        int ny = y + j;
                        if (nx >= 0 && nx < mapWidth && ny >= 0 && ny < mapHeight)
                            map[nx, ny] = 0;
                    }
                }
            }
        }
    }

    private void InstantiateTiles()
    {
        GameObject wallParent = new GameObject("Walls");
        GameObject floorParent = new GameObject("Floors");
        wallParent.transform.parent = transform;
        floorParent.transform.parent = transform;

        wallParent.layer = LayerMask.NameToLayer("Wall");
        floorParent.layer = LayerMask.NameToLayer("Floor");

        for (int x = 0; x < mapWidth; x++)
        {
            for (int y = 0; y < mapHeight; y++)
            {
                Vector3 position = new Vector3(x, y, 0);
                if (map[x, y] == 1)
                {
                    GameObject wall = Instantiate(setting.tilePrefab, position, Quaternion.identity, wallParent.transform);
                    wall.layer = LayerMask.NameToLayer("Wall");

                    SpriteRenderer wallRenderer = wall.GetComponent<SpriteRenderer>();
                    if (wallRenderer != null)
                    {
                        WallType wallType = DetermineWallType(x, y);
                        ApplyWallSprite(wallRenderer, wallType);
                        
                        wallRenderer.sortingLayerName = setting.wallTileSettings.sortingLayerName;
                        wallRenderer.sortingOrder = setting.wallTileSettings.sortingOrder;
                    }

                    Collider2D collider = wall.GetComponent<Collider2D>();
                    if (collider != null)
                        collider.isTrigger = false;
                }
                else
                {
                    GameObject floor = Instantiate(setting.floorPrefab, position, Quaternion.identity, floorParent.transform);
                    floor.layer = LayerMask.NameToLayer("Floor");

                    SpriteRenderer floorRenderer = floor.GetComponent<SpriteRenderer>();
                    if (floorRenderer != null)
                    {
                        floorRenderer.sortingLayerName = "Floor";
                        floorRenderer.sortingOrder = 0;
                    }
                }
            }
        }
    }

    private WallType DetermineWallType(int x, int y)
    {
        bool up = IsWall(x, y + 1);
        bool down = IsWall(x, y - 1);
        bool left = IsWall(x - 1, y);
        bool right = IsWall(x + 1, y);
        
        // 대각선 방향도 확인
        bool upLeft = IsWall(x - 1, y + 1);
        bool upRight = IsWall(x + 1, y + 1);
        bool downLeft = IsWall(x - 1, y - 1);
        bool downRight = IsWall(x + 1, y - 1);

        // 단독 벽
        if (!up && !down && !left && !right)
            return WallType.Single;

        // 내부 모서리 검사 (벽이 안쪽으로 꺾이는 경우)
        if (up && right && !upRight) return WallType.CornerInnerTopRight;
        if (up && left && !upLeft) return WallType.CornerInnerTopLeft;
        if (down && right && !downRight) return WallType.CornerInnerBottomRight;
        if (down && left && !downLeft) return WallType.CornerInnerBottomLeft;

        // 외부 모서리 검사 (벽이 바깥쪽으로 꺾이는 경우)
        if (!up && !left && right && down) return WallType.CornerOuterTopLeft;
        if (!up && !right && left && down) return WallType.CornerOuterTopRight;
        if (!down && !left && right && up) return WallType.CornerOuterBottomLeft;
        if (!down && !right && left && up) return WallType.CornerOuterBottomRight;

        // 끝부분 검사
        if (up && !down && !left && !right) return WallType.EndBottom;
        if (!up && down && !left && !right) return WallType.EndTop;
        if (!up && !down && left && !right) return WallType.EndRight;
        if (!up && !down && !left && right) return WallType.EndLeft;

        // 테두리 검사
        if (!up && down) return WallType.Top;
        if (up && !down) return WallType.Bottom;
        if (!left && right) return WallType.Left;
        if (left && !right) return WallType.Right;

        return WallType.Default;
    }

    private void ApplyWallSprite(SpriteRenderer renderer, WallType type)
    {
        var sprites = setting.wallTileSettings.sprites;
        
        switch (type)
        {
            case WallType.Single:
                renderer.sprite = sprites.singleWall;
                break;
            case WallType.Top:
                renderer.sprite = sprites.wallTop;
                break;
            case WallType.Bottom:
                renderer.sprite = sprites.wallBottom;
                break;
            case WallType.Left:
                renderer.sprite = sprites.wallLeft;
                break;
            case WallType.Right:
                renderer.sprite = sprites.wallRight;
                break;
            case WallType.CornerOuterTopLeft:
                renderer.sprite = sprites.cornerOuterTopLeft;
                break;
            case WallType.CornerOuterTopRight:
                renderer.sprite = sprites.cornerOuterTopRight;
                break;
            case WallType.CornerOuterBottomLeft:
                renderer.sprite = sprites.cornerOuterBottomLeft;
                break;
            case WallType.CornerOuterBottomRight:
                renderer.sprite = sprites.cornerOuterBottomRight;
                break;
            case WallType.CornerInnerTopLeft:
                renderer.sprite = sprites.cornerInnerTopLeft;
                break;
            case WallType.CornerInnerTopRight:
                renderer.sprite = sprites.cornerInnerTopRight;
                break;
            case WallType.CornerInnerBottomLeft:
                renderer.sprite = sprites.cornerInnerBottomLeft;
                break;
            case WallType.CornerInnerBottomRight:
                renderer.sprite = sprites.cornerInnerBottomRight;
                break;
            case WallType.EndTop:
                renderer.sprite = sprites.endTop;
                break;
            case WallType.EndBottom:
                renderer.sprite = sprites.endBottom;
                break;
            case WallType.EndLeft:
                renderer.sprite = sprites.endLeft;
                break;
            case WallType.EndRight:
                renderer.sprite = sprites.endRight;
                break;
            default:
                renderer.sprite = sprites.defaultWall;
                break;
        }
        
    }

    private bool IsWall(int x, int y)
    {
        if (x < 0 || x >= mapWidth || y < 0 || y >= mapHeight)
            return false;
        return map[x, y] == 1;
    }

   

    public bool isDone()
    {
        return generateDone;
    }
    
    
    public BSPRoom GetRoomFromPosition(Vector2 position)
    {
        if (!generateDone)
        {
            Debug.LogWarning("Map generation is not complete. Cannot find room.");
            return null;
        }

        return rooms.FirstOrDefault(room =>
            position.x >= room.x && position.x < room.x + room.width &&
            position.y >= room.y && position.y < room.y + room.height);
    }


    public BSPRoom GetRoomFromTransform(Transform transform)
    {
        if (transform == null) return null;
        return GetRoomFromPosition(new Vector2(transform.position.x, transform.position.y));
    }
    
    
 

    


    private void OnDrawGizmos()
    {
        if (!debug) return;
        if (map == null) return;

        for (int x = 0; x < mapWidth; x++) {
            for (int y = 0; y < mapHeight; y++) {
                if (map[x, y] == 1)
                    Gizmos.color = Color.black;
                else
                    Gizmos.color = Color.white;
                Vector3 pos = new Vector3(x, y, 0);
                Gizmos.DrawCube(pos, Vector3.one);
            }
        }
    }
}

구현 영상

여기까지 기본적인 맵 생성이였구. 다음 포스트에서 아이템 배치와 다른 지형 배치 등을 다루는 코드를 이어서 작성하도록 하겠습니다.

수고하셨습니다.

Reference#

Compy Blog

[Algorithm] BSP 알고리즘(맵 생성) 유니티로 구현하기!(2D)
https://compy07.github.io/Blog/posts/algorithms/bsp/unity/
Author
뒹굴뒹굴 이정훈 공부방
Published at
2025-02-16