본문 바로가기
Unity

Unity(6) - Atlas Block Map & Crafting Table (2)

by Srff5123 2026. 3. 10.
728x90

 

우선 저번에 만든 2x2 Craft를 조금 수정을 하며 시작할것이다.

내가 입력한 레시피대로 칸을 채우면 Result칸에 결과 아이템이 나와야하는데 현재 나오지 않는다 근데 또 결과칸을 클릭하면

만들어진 아이템이 정상적으로 인벤토리에는 잘 들어간다 저번 글에서 보았듯이

 

업데이트가 안되었거나 Result 코드에 무언가 빼먹은거같음 그거를 우선적으로 해결하고 다음 단계로 넘어가고자 한다.

플레이를 하며 CraftResult를 확인해보니 Icon의 위치가 아이템을 인벤토리에서 옮길때 드래그 드롭에서 드롭 시의 위치로 바뀌는 것을 확인하였다 또한 Image도 꺼져있었다.

 

    public void Set(ItemId id, int count, Sprite icon)
    {
        bool empty = (id == ItemId.None) || (count <= 0);

        if (iconImage != null)
        {
           
            if (!iconImage.gameObject.activeSelf)
                iconImage.gameObject.SetActive(true);

            iconImage.enabled = !empty;

            
            var c = iconImage.color;
            c.a = 1f;
            iconImage.color = c;

            // sprite는 마지막에 세팅
            iconImage.sprite = empty ? null : icon;
        }

        if (countText != null)
        {
            bool showCount = !empty && count > 1;
            countText.gameObject.SetActive(showCount);
            if (showCount) countText.text = count.ToString();
        }
    }

이를 해결하기 위해 우선 Image 부터 보이도록 visible을 해주자 Set으로 가서 코드를 조금 수정해준다.

    private void UpdateDragIcon(PointerEventData eventData)
    {
        if (dragIcon == null) return;
        if (!hasPick) { dragIcon.enabled = false; return; }

        ItemStack s = GetStack(pickGroup, pickIndex);
        Sprite sp = Icon(s.id);
        if (s.IsEmpty || sp == null) { dragIcon.enabled = false; return; }

        dragIcon.enabled = true;
        dragIcon.sprite = sp;

        // 항상 위에 보이게
        dragIcon.transform.SetAsLastSibling();

        var dragRect = (RectTransform)dragIcon.transform;
        var canvas = dragIcon.canvas;
        var canvasRect = (RectTransform)canvas.transform;

        Vector2 localPoint;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvasRect,
            eventData.position,
            canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : eventData.pressEventCamera,
            out localPoint
        );

        dragRect.anchoredPosition = localPoint;
    }

 

다음 DragDrop은 UIcon에서 관리하기 때문에 Inventory의 Inspector에 컴포넌트로 가서 확인해보니 Image가 같은 image를 공유하면서 생긴 오류로 확인이 되어 UI에서 Image DragGhost로 하나 새로 만들어 해당 이미지로 바꾸어 주고 

UIcon 코드로 이동해 UpdateDrag의 코드를 바꾸어 준다.

 

이제 테스트를 해보면

 

짠 Result에 아이템이 잘 나오는것을 확인 할 수 있다.

 

다음으로는 현재 블록을 렌더링해서 만든 청크맵은 단순 color값으로 초록 갈색 이런식으로 진행을 하고있는데

나무나 이제 곧 만들어야할 제작대 이런것들을 좀 더 제대로 표기하기 위해서는 텍스쳐를 입혀야한다.

 

종류별 블록 텍스쳐머테리얼을 이용하면 gpu에서 사용하는 연산이 매우 증가하여 힘들기때문에

이러한 블록 텍스터를 한장에 다 모아놓는 텍스쳐 아틀라스를 사용할 것이다.

16x16 픽셀로 된 블록 텍스쳐를 여러개를 만들고 이를 하나의 큰 텍스처파일안에 바둑판처럼 정리해서 넣는것이다.

그리고 해당 블록에 입힐때 잘라서 쓰는 형식으로 할것이다.

이런식으로 만들었다 잔디 상단 부분과 물은 없어서 직접 대강 칠해줬다 ㅋㅋㅋ

이거를 이제 유니티에 넣고 원래 쓰던 Shader에 이런식으로 넣어준다 Vertex Color는 안씀

 

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(MeshCollider))]
public class VoxelChunk : MonoBehaviour
    // 맵을 한번에 만들기에는 너무 크기 때문에 청크로 쪼개서 관리
{
    public const int SizeX = 16;
    public const int SizeZ = 16;
    public const int SizeY = 64;

    // 아틀라스 설정 가로 12칸 세로 1칸, 
    private const int ATLAS_TILES_X = 12; // 가로 텍스쳐 수
    private const int ATLAS_TILES_Y = 1; 

    // UV_EPS는 bleeding 방지
    // UV 경계에서 살짝 안쪽으로 당겨서 경계 밖으로 나가지 않게 하여
    // 타일 경게에서 옆 타일이 비쳐보이는것을 방지한다
    private const float UV_EPS = 0.0015f;

    public Vector2Int ChunkCoord { get; private set; }
    // 청크 좌표, 해당 청크가 월드에서 몇 번째인지, 청크는 X,Y 평면이라 2D로

    private BlockType[,,] blocks;
    // 청크 내부 블록 데이터 저장 X,Y,Z

    private MeshFilter mf; // 메시 데이터
    private MeshRenderer mr; // 메시 렌더링
    private MeshCollider mc; // 메시 충돌 구현

    private VoxelWorld world; // 현재 해당 청크가 속한 곳

    public PhysicsMaterial slipperyMat; // 미끄러운 느낌 적용

    public void Initialize(VoxelWorld world, Vector2Int coord, Material mat)
    {
        // 청크 생성, 초기화
        this.world = world;
        ChunkCoord = coord;
        // 현재 청크가 어디에 속하는지 , 청크 좌표

        mf = GetComponent<MeshFilter>();
        mr = GetComponent<MeshRenderer>();
        mc = GetComponent<MeshCollider>();

        mr.sharedMaterial = mat; // 만들어진 아틀라스 텍스쳐를 미리 넣어둔 셰이더 머테리얼을
                                 // 청크 렌더러에 연결

        mc.convex = false; // 맵을 볼록한 형태로
        mc.isTrigger = false; 

        mc.cookingOptions =
            MeshColliderCookingOptions.CookForFasterSimulation |
            MeshColliderCookingOptions.EnableMeshCleaning |
            MeshColliderCookingOptions.WeldColocatedVertices;
        // 충돌, 시뮬레이션을 빠르게하고
        // 메시 정리를 통해 폴리곤 삼각형 수를 줄여줌
        // 겹치는 정점을 합쳐 안정성을 줌

        blocks = new BlockType[SizeX, SizeY, SizeZ];
        // 청크가 저장할 블록 데이터 공간
    }

    public void SetBlock(int x, int y, int z, BlockType type) => blocks[x, y, z] = type;
    // 청크 내부 좌표에 블록 타입을 기록

    public BlockType GetBlockLocal(int x, int y, int z)
    {
        if (x < 0 || x >= SizeX || y < 0 || y >= SizeY || z < 0 || z >= SizeZ)
            return BlockType.Air;
        return blocks[x, y, z];
        // 청크 로컬 좌표를 조회하고 범위 밖이면 air 반환
    }

    private BlockType GetBlockWithNeighbors(int x, int y, int z)
    {
        if (x >= 0 && x < SizeX && y >= 0 && y < SizeY && z >= 0 && z < SizeZ)
            return blocks[x, y, z];

        int worldX = ChunkCoord.x * SizeX + x;
        int worldZ = ChunkCoord.y * SizeZ + z;
        return world.GetBlockWorld(worldX, y, worldZ);

        // 좌표가 청크 밖으로 나가면 월드를 통해 확인하고 블록 타입을 가져옴
        // 이웃 청크에 블록이 있다면 해당 면은 그리지 않아도 됌
    }

    public void BuildMesh()
    {
        var verts = new List<Vector3>(8192); // 정점 좌표 저장
        var tris = new List<int>(16384); // 삼각형 인덱스 저장
        var cols = new List<Color32>(8192); // 버텍스 컬러,
        var uvs = new List<Vector2>(8192); // 텍스쳐 uv 좌표
        // 8192 는 초기 용량임, 미리미리 크게 잡음 재할당 방지를 위함

        int vCount = 0; // 현재 추가된 버텍스 개수

        // 텍스처 기반이면 VertexColor는 그냥 흰색 고정(곱해도 영향 없게)
        Color32 white = new Color32(255, 255, 255, 255);

        for (int x = 0; x < SizeX; x++)
            for (int y = 0; y < SizeY; y++)
                for (int z = 0; z < SizeZ; z++)
                {
                    // 청크 안의 모든 좌표 순회
                    BlockType t = blocks[x, y, z];
                    if (t == BlockType.Air) continue;
                    // 빈공간은 메시 생성 x

                    Vector3 basePos = new Vector3(x, y, z);
                    // 블록의 원점 

                    if (IsFaceVisible(x + 1, y, z)) AddFace(FaceDir.PosX, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    if (IsFaceVisible(x - 1, y, z)) AddFace(FaceDir.NegX, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    if (IsFaceVisible(x, y + 1, z)) AddFace(FaceDir.PosY, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    if (IsFaceVisible(x, y - 1, z)) AddFace(FaceDir.NegY, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    if (IsFaceVisible(x, y, z + 1)) AddFace(FaceDir.PosZ, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    if (IsFaceVisible(x, y, z - 1)) AddFace(FaceDir.NegZ, basePos, t, ref vCount, verts, tris, cols, uvs, white);
                    // 이웃 블록이 air인지 검사, air면 바깥으로 노출된 면으로 렌더링 생성함
                    // 노출되지 않는 면은 생성하지 않도록하여
                    // 렌더링 비용을 감소 시켜준다.
                }

        Mesh mesh = mf.sharedMesh;
        // 현재 메시 가져옴

        if (mesh == null)
        {
            mesh = new Mesh { name = "ChunkMesh" };
        }
        else
        {
            mesh.Clear();
        }
        // 처음 빌드하는 거면 새로운 mesh 생성
        // 리빌드면 데이터 초기화 후 재사용

        mesh.indexFormat = (verts.Count > 65000)
            ? UnityEngine.Rendering.IndexFormat.UInt32
            : UnityEngine.Rendering.IndexFormat.UInt16;
        // 메시 깨짐 방지를 위해 버텍스의 수가 많담녀 uint32로 변경

        mesh.SetVertices(verts);
        mesh.SetTriangles(tris, 0);
        mesh.SetColors(cols);
        mesh.SetUVs(0, uvs);
        // 지금까지 만든 리스트들을 mesh에 넣어줌
        // uv channel 0에 uvs 적용

        mesh.RecalculateNormals(); // 조명 계산 
        mesh.RecalculateBounds(); // 컬링

        mf.sharedMesh = mesh; // 새 메시를 적용하여 화면 렌더링 준비

        if (mc != null)
        {
            if (slipperyMat != null) mc.material = slipperyMat;
            mc.sharedMesh = null;
            mc.sharedMesh = mesh;
        }
        // mc가 있으면 콜라이더에도 같은 메시를 넣어줌
        // 콜라이더 메시가 바뀌었다는 것을 인식시켜줌

        Physics.SyncTransforms();
        // 변경된 사항을 물리 엔진에 즉시 동기화, 물리가 한프레임 늦게 반영될 수 있어서
        // 현재 오류때문에 추가했는데,, 해결이안됌 다른 문제인거같음
    }

    private bool IsFaceVisible(int nx, int ny, int nz)
        => GetBlockWithNeighbors(nx, ny, nz) == BlockType.Air;
    // 이웃이 air면 면을 보여줌, 청크 경계도 확인

    private enum FaceDir { PosX, NegX, PosY, NegY, PosZ, NegZ }
    // 면 방향을 명확하게 표현,면별로 어떤 텍스쳐를 사용할지
   
    private static readonly Vector3[] facePosX = { new(1, 0, 0), new(1, 0, 1), new(1, 1, 1), new(1, 1, 0) };
    private static readonly Vector3[] faceNegX = { new(0, 0, 1), new(0, 0, 0), new(0, 1, 0), new(0, 1, 1) };
    private static readonly Vector3[] facePosY = { new(0, 1, 0), new(1, 1, 0), new(1, 1, 1), new(0, 1, 1) };
    private static readonly Vector3[] faceNegY = { new(0, 0, 1), new(1, 0, 1), new(1, 0, 0), new(0, 0, 0) };
    private static readonly Vector3[] facePosZ = { new(1, 0, 1), new(0, 0, 1), new(0, 1, 1), new(1, 1, 1) };
    private static readonly Vector3[] faceNegZ = { new(0, 0, 0), new(1, 0, 0), new(1, 1, 0), new(0, 1, 0) };
    // 면은 사각형으로 정점 4개를 통해서 그려짐
    // 방향마다 정점 좌표가 다르기 때문에 미리 테이블로 만들어줌

    private static void AddFace(
        FaceDir dir, Vector3 basePos, BlockType type,
        ref int vCount,
        List<Vector3> verts, List<int> tris, List<Color32> cols, List<Vector2> uvs,
        Color32 vertexColor)
        // 어떤 방향 면인지, 블록의 기준 위치, 어떤 블록인지를 확인
    {
        Vector3[] f = dir switch
        {
            FaceDir.PosX => facePosX,
            FaceDir.NegX => faceNegX,
            FaceDir.PosY => facePosY,
            FaceDir.NegY => faceNegY,
            FaceDir.PosZ => facePosZ,
            _ => faceNegZ,
            // 어떤 방향의 면인지에 따라 배열 선택
        };

        verts.Add(basePos + f[0]);
        verts.Add(basePos + f[1]);
        verts.Add(basePos + f[2]);
        verts.Add(basePos + f[3]);
        // 실제 정점 위치, 한면에 정점 4개 추가

        cols.Add(vertexColor); cols.Add(vertexColor); cols.Add(vertexColor); cols.Add(vertexColor);
        // 컬로 4개, 만들어둔 텍스쳐를 사용할것이기 때문에 컬러는 흰색

        // === UV 계산 ===
        int tileX = GetTileIndex(type, dir); // 만든 아틀라스의 몇 번째 텍스쳐 인지 확인
        float tw = 1f / ATLAS_TILES_X; 
        float th = 1f / ATLAS_TILES_Y;

        float u0 = tileX * tw;
        float v0 = 0f;
        float u1 = u0 + tw;
        float v1 = v0 + th;
        // 타일의 u0,v0 좌하단 ~ u1,v1 우상단 UV 범위 계산
        // 세로 1줄로 v는 0~1 고정

        // 아까 만든 bleeding 방지 타일 경계에서 살짝 안쪽으로
        u0 += UV_EPS; u1 -= UV_EPS;
        v0 += UV_EPS; v1 -= UV_EPS;

        uvs.Add(new Vector2(u0, v0));
        uvs.Add(new Vector2(u1, v0));
        uvs.Add(new Vector2(u1, v1));
        uvs.Add(new Vector2(u0, v1));
        // 정점 4개에 대응되는 uv 4개추가

        if (dir == FaceDir.NegX || dir == FaceDir.NegY || dir == FaceDir.NegZ)
        {
            tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 1);
            tris.Add(vCount + 0); tris.Add(vCount + 3); tris.Add(vCount + 2);
        }
        else
        {
            tris.Add(vCount + 0); tris.Add(vCount + 1); tris.Add(vCount + 2);
            tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 3);
        }
        // quad를 삼각형 2개로 나누어줌, 면 방향이 반대면 뒤집어서 앞면이 바깥을 향하게 해줌

        vCount += 4;
        // 면하나 완성 -> 4개 추가
    }

    // === 여기서 “면(dir) 별로” 어떤 타일을 쓸지 결정 ===
    // 타일 인덱스(가로 12칸):
    // 0 Dirt, 1 GrassSide, 2 Stone, 3 Wood, 4 Planks, 5 Coal, 6 Iron, 7 Gold, 8 Diamond, 9 CraftingTable, 10 GrassTop, 11 Water
    private static int GetTileIndex(BlockType t, FaceDir dir)
        // 아틀라스 몇 번째 텍스쳐 사용할건지 블록 타입에 따라 리턴
    {
        switch (t)
        {
            case BlockType.Dirt:
                return 0;

            case BlockType.Grass:
                // 윗면만 GrassTop(10), 아랫면은 Dirt(0), 옆면은 GrassSide(1)
                if (dir == FaceDir.PosY) return 10;
                if (dir == FaceDir.NegY) return 0;
                return 1;
               

            case BlockType.Stone:
                return 2;

            case BlockType.Wood:
                return 3;

            case BlockType.Planks:
                return 4;

            case BlockType.Coal:
                return 5;

            case BlockType.Iron:
                return 6;

            case BlockType.Gold:
                return 7;

            case BlockType.Diamond:
                return 8;

            case BlockType.CraftingTable:
                return 9;

            case BlockType.Water:
                return 11;
            default:
                return 2; // 안전: 모르면 Stone
        }
    }
}
   [SerializeField] private Texture2D blockAtlas;
   [SerializeField] private string atlasPropertyName = "_BlockAtlas"; // ShaderGraph에서 만든 Texture2D 프로퍼티 Reference

 VoxelWorld 상단에 추가

이제 기존에 쓰던 VoxelChunk 코드를 대거 수정해주고 BlockType에도 새로 만들 블록들 추가

이렇게 하고 실행해보면 꽤 그럴듯하게 만들어졌다 

using UnityEngine;

public enum ItemKind : byte { Block, Tool }
// 아이템 종류 블럭인지 도구인지
public enum ToolKind : byte { None, Pickaxe }
// 도구 종료, 나중 추가
public enum ToolTier : byte { None, Wood, Stone, Iron }
// 블럭 종류

public enum ItemId : int
{
    None = 0,

    // Blocks
    Dirt = 100,
    Grass = 101,
    Stone = 102,
    Coal = 103,
    Iron = 104,
    CraftingTable = 105,
    Gold = 106,
    Diamond = 107,
    Planks = 108,
    Wood = 109,

    // Tools
    WoodenPickaxe = 200,
    StonePickaxe = 201,
    IronPickaxe = 202,


    // Asum
    Stick = 300,
    // 각 블럭과 도구의 id값
}

public static class ItemDb
{
    // ItemId -> 블록 타입 매핑 (블록 설치에 필요)
    public static bool TryGetBlockType(ItemId id, out BlockType bt)
    {
        // 설치 하는 경우 현재 아이템이 블럭인지 확인
        // 블럭이면 type에 따라 결정
        bt = BlockType.Air;
        switch (id)
        {
            // 설치가 가능한 블록
            case ItemId.Dirt: bt = BlockType.Dirt; return true;
            case ItemId.Grass: bt = BlockType.Grass; return true;
            case ItemId.Stone: bt = BlockType.Stone; return true;
            case ItemId.Coal: bt = BlockType.Coal; return true;
            case ItemId.Iron: bt = BlockType.Iron; return true;
            case ItemId.CraftingTable: bt = BlockType.CraftingTable; return true;
            case ItemId.Wood: bt = BlockType.Wood; return true;
            case ItemId.Planks: bt = BlockType.Planks; return true;
            case ItemId.Gold: bt = BlockType.Gold; return true;
            case ItemId.Diamond: bt = BlockType.Diamond; return true;


            default: return false;
        }
    }

    public static ItemKind Kind(ItemId id)
    {
        // ID 대역을 이용해 Block / Tool 을 빠르게 판별
        if ((int)id >= 200) return ItemKind.Tool; // 200 이상 tool
        if ((int)id >= 100) return ItemKind.Block; // 100 이상이면 block
        // 나중 300 이상이면 음 재료? 이런걸로 만들듯
        // 400은 머 소비 이런식
        return ItemKind.Block; 
    }

    public static (ToolKind kind, ToolTier tier) ToolInfo(ItemId id)
    {
        // 도구 종류
        switch (id)
        {
            case ItemId.WoodenPickaxe: return (ToolKind.Pickaxe, ToolTier.Wood);
            case ItemId.StonePickaxe: return (ToolKind.Pickaxe, ToolTier.Stone);
            case ItemId.IronPickaxe: return (ToolKind.Pickaxe, ToolTier.Iron);
            default: return (ToolKind.None, ToolTier.None);
        }
    }

    public static int MaxStack(ItemId id)
    {
        // 도구는 1개, 블록은 64개로 맥스 스택 
        return Kind(id) == ItemKind.Tool ? 1 : 64;
    }

    public static string DisplayName(ItemId id) => id.ToString();
    // 나중 이름 변경
}
public enum BlockType : byte
{
    Air = 0,
    Dirt = 1, 
    Grass = 2,
    Stone = 3,
    Water = 4,

    Coal = 5,
    Iron = 6,

    Wood = 7,
    Planks = 8, // 판자
    Gold = 9,
    Diamond = 10,
    CraftingTable = 11,
}

이제 진짜진짜 제작대 블록으로 3x3 만들겠다

제작대 블록의 설치를 위해 ItemType와 BlockType를 조금 수정보완해준다

나무랑 판자랑 등 다른거도 하는거 같이 추가 해주자

 

    private static ItemId ToItemId(BlockType bt)
    {
        return bt switch
        {
            BlockType.Dirt => ItemId.Dirt,
            BlockType.Grass => ItemId.Grass,
            BlockType.Stone => ItemId.Stone,
            BlockType.Coal => ItemId.Coal,
            BlockType.Iron => ItemId.Iron,

            BlockType.Diamond => ItemId.Diamond,    
            BlockType.Gold => ItemId.Gold, 
            BlockType.Wood => ItemId.Wood, 
            BlockType.CraftingTable => ItemId.CraftingTable,
            BlockType.Planks => ItemId.Planks,

            _ => ItemId.None
        };
    }

 

    private float GetMineDuration(BlockType bt, ItemId held)
    {
        bool isPickaxe = false;
        // 들고 있는 아이템이 곡괭이인지를 판단해서 돌,광물 채굴 시간을 줄여줌

        if (ItemDb.Kind(held) == ItemKind.Tool)
        {
            // 도구인지 확인 후 ToolInfo로 세부 종류 확인
            var info = ItemDb.ToolInfo(held); // 도구 정보 넣어줌
            if (info.kind == ToolKind.Pickaxe) isPickaxe = true; // 곡괭이가 맞다면 true
        }

        return bt switch
        {
            BlockType.Dirt => 1.5f,
            BlockType.Grass => 1.5f,
            BlockType.Stone => isPickaxe ? 2f : 10f,
            BlockType.Coal => isPickaxe ? 3f : 10f,
            BlockType.Iron => isPickaxe ? 4f : 15f,
            BlockType.Planks => isPickaxe ? 2f : 4f, // 도끼 생기면 도끼로 ㅇㅇ
            BlockType.Diamond => isPickaxe ? 5f : 20f,
            BlockType.Gold => isPickaxe ? 4f : 20f,
            BlockType.CraftingTable => isPickaxe ? 2f : 4f,
            BlockType.Wood => isPickaxe ? 2f : 4f,

            BlockType.Water => 1000.0f,
            _ => 9000f
        };
        // 각 블럭에 따라 처리 시간
    }
}

다음 블록과의 상호작용을 위해 BlockInteractor에도 추가해준다.

 

이제 플레이를 해보면 설치, 레이캐스팅, 부수기-> 인벤토리에 들어오는거까지 다 정상적으로 잘 되어지는것을 확인할 수 있다.

 

이제 해당 제작대에 블럭 설치하는 키를 누르면 새로운 UI 3x3 을 띄우고 레시피도 따로 추가해서 도구를 만드는 부분을 해보도록 하겠다. (물론 맵에 나무도 설치하고 좀 할게 많기는 한데 - 이건 나중에 하겠다 ) 

 

이 뒤는 다음글에서 마저 하겠다.

 

 

 

 

 

728x90

'Unity' 카테고리의 다른 글

Unity(8) - Map Error Resolution  (0) 2026.04.13
Unity(7) - 3x3 Crafting Table  (0) 2026.03.13
Unity(5) - Inventory & Crafting  (1) 2026.03.05
Unity(4) - Ray Cast & Block Destroy  (0) 2026.02.24
Unity(3) - Block Map  (1) 2026.02.19