2023년 11월 26일 일요일

Unity destructible tilemap (유니티에서 깨지는 타일맵 효과 만들기)

일반적인 2D 플래포머 게임에서는 잘 사용하지는 않지만 타일로 구현된 맵이 깨지는 형태의 게임이 있습니다. 대표적으로 테라리아 같은 게임입니다.

타일로 제작된 Object가 깨질때 효과를 여기에서 다뤄 보겠습니다.

소스 코드는 아래 유튜브 영상에서 가져온 뒤 변형하였습니다.

https://youtu.be/jbejkt-Ow-8


1. 완성 영상


처음 3*3 블럭이 사라지는 효과가 나오고 마우스로 클릭하면 1*1 크기의 블럭이 사라지는 효과를 만들어 봤습니다.

먼저 최종 완성된 영상을 봐야 이해가 쉬울 겁니다.


2. 전체 구조

들어가기에 앞서 Object의 전체 구조에 대해서 알아 보겠습니다.

2.1 MouseInput

마우스를 클릭한다면 ImpactPoint Object를 만드는 Object입니다.

2.2 ImpactPoint

어떤 블럭이 깨질지 깨지는 블럭을 위치를 설정하고 깨지는 위치 마다 TileSplitter Object를 만듭니다. 그리고 해당 블럭을 타일맵에서 삭제합니다.

2.3 TileSplitter

타일을 Mask된 Image여러개 쪼개서 여러개의 BrokenSprite들을 만드는 역할을 합니다. 

2.4 BrokenSprite

스프라이트 마스크를 통해서 Sprite를 그리고 일정 시간 후에 Fadeout 시킵니다.


3. 코드 설명

3.1 MouseInput


MouseInput에서는 앞에서 ImpactPoint Object를 생성 시킨다고 하였습니다. Impact Point Prefab 인자를 두었습니다. 그리고 Destructible Tile Map은 파괴가 가능한 타일맵을 설정합니다. 타일맵의 Layer를 여러개 두고 파괴가 안되는 처리도 가능합니다.

public class MouseInput : MonoBehaviour
{
    Vector3 mousePosition;
    public GameObject impactPointPrefab;
    public Tilemap destructibleTileMap;

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            GameObject obj = Instantiate(impactPointPrefab, mousePosition, Quaternion.identity, transform);
            obj.GetComponent<ImpactPoint>().defaultRadius = new Vector2Int(0, 0);
            obj.GetComponent<ImpactPoint>().destructibleTileMap = destructibleTileMap;
        }
    }
}

스크립트 내용은 간단합니다. 마우스를 클릭하면 impactPointPrefab 을 생성하고 ImpactPoint Object에 몇가지 정보를 연결합니다.

3.2 ImpactPoint


ImpactPoint 의 경우 TileSplitter를 생성하기 때문에 정보가 있어야 하고 파괴할 TileMap정보인자도 들어 있습니다. Prefab에서 설정을 할 수가 없기 때문에 None 상태입니다. 이건 MouseInput 당시에 object생성 후 설정을 하도록 구현 하였습니다.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class ImpactPoint : MonoBehaviour
{
    private int updateTimer = 0;
    public int maxTime;

    public Vector2Int defaultRadius;
    public Tilemap destructibleTileMap;
    public GameObject tileSplitterPrefab;

    private List<Vector3Int> tilesToBreak;

    // Start is called before the first frame update
    void Start()
    {
        tilesToBreak = GetAffectedTiles(transform.position, defaultRadius);
    }

    private void FixedUpdate()
    {
        if (tilesToBreak.Count > 0)
        {
            if (updateTimer < maxTime) updateTimer++;
            else
            {
                List<Vector3Int> tilesToRemoveFromList = new List<Vector3Int>();
                List<TileSplitter> convertedTiles = new List<TileSplitter>();
                foreach (var tile in tilesToBreak)
                {
                    Vector3Int position = tile;
                    position.z = (int)destructibleTileMap.transform.position.z;
                    if (destructibleTileMap.GetTile(position))
                    {
                        if (!tilesToRemoveFromList.Contains(position))
                        {
                            GameObject newTile = Instantiate(tileSplitterPrefab, position, Quaternion.identity);
                            TileSplitter handler = newTile.GetComponent<TileSplitter>();

                            handler.tileSprite = destructibleTileMap.GetSprite(position);

                            convertedTiles.Add(handler);

                            destructibleTileMap.SetTile(position, null);
                            tilesToRemoveFromList.Add(position);
                        }
                    }
                    else tilesToRemoveFromList.Add(position);
                }
                foreach (var tile in tilesToRemoveFromList)
                {
                    tilesToBreak.Remove(tile);

                }
                tilesToRemoveFromList.Clear();
                updateTimer = 0;
            }
        }
        else Destroy(this.gameObject);
    }

    private List<Vector3Int> GetAffectedTiles(Vector2 impactPosition, Vector2Int impactRadius)
    {
        List<Vector3Int> allTiles = new List<Vector3Int>();
        Vector3 startPosition = impactPosition;
        startPosition = new Vector3(impactPosition.x, impactPosition.y, 0);
        
        for (int x=-impactRadius.x;x<=impactRadius.x;x++)
        {
            for (int y = -impactRadius.y; y <= impactRadius.y; y++)
            {
                Vector3Int pos1 = destructibleTileMap.WorldToCell(startPosition + new Vector3(x, y, 0));
                if (destructibleTileMap.GetTile(pos1) && !allTiles.Contains(pos1)) allTiles.Add(pos1);
                Vector3Int pos2 = destructibleTileMap.WorldToCell(startPosition - new Vector3(x, y, 0));
                if (destructibleTileMap.GetTile(pos2) && !allTiles.Contains(pos2)) allTiles.Add(pos2);
            }
        }
        return allTiles;
    }
}

GetAffectedTiles 함수는 실제 어떤 블럭을 깰지 계산하는 함수 입니다.

1*1 블럭에서는 하나밖에 없지만 impactRadius의 크기가 주어지면 좀 더 많은 블럭이 깨져야 하기 때문입니다.

maxTime이라는 부분이 보이는데 해당 변수는 위 코드에 delay를 주기위한 장치라고 보면 됩니다.

전체적으로 코드는 TileSplitter 생성하고 인자를 넘겨주는 부분으로 되어있습니다.

3.3 TileSplitter



여기에서는 타일 마스크라는 이미지가 필요한데 검은색이미지를 구분을 지어서 타일 크기에 맞게 각각 만들었습니다. 여기에서는 5조각으로 분리되도록 작업하였기 때문에 5장의 이미지를 그렸습니다.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TileSplitter : MonoBehaviour
{
    public Sprite tileSprite;
    public PhysicsMaterial2D physicsMaterial;

    [SerializeField] private GameObject prefab;
    [SerializeField] private GameObject parentPrefab;
    [SerializeField] private List<Sprite> spriteMasks;

    // Start is called before the first frame update
    void Start()
    {
        BreakSpriteToMasks();
    }

    private void BreakSpriteToMasks()
    {
        List<GameObject> newObjects = new List<GameObject>();
        GameObject parent = Instantiate(parentPrefab, transform.position, Quaternion.identity);
        for(int i = 0;i<spriteMasks.Count; i++)
        {
            GameObject newGO = Instantiate(prefab, transform.position+new Vector3(0.5f,0.5f,0),Quaternion.identity);
            newObjects.Add(newGO);
        }

        UpdateSpriteObject(parent, newObjects, spriteMasks, tileSprite, physicsMaterial);
        Destroy(gameObject);
    }

    private void UpdateSpriteObject(GameObject parent, List<GameObject> newGO, List<Sprite> sprites, Sprite tileSprite, PhysicsMaterial2D physicsMaterial)
    {
        for (int i = 0; i < newGO.Count; i++)
        {
            newGO[i].GetComponent<Rigidbody2D>().sharedMaterial = physicsMaterial;
            newGO[i].GetComponent<SpriteRenderer>().sprite = sprites[i];
            newGO[i].GetComponent<PolygonCollider2D>();
            newGO[i].GetComponent<PolygonCollider2D>().sharedMaterial = physicsMaterial;

            UpdateShapeToSprite(newGO[i].GetComponent<PolygonCollider2D>());

            newGO[i].transform.GetChild(0).GetComponent<SpriteRenderer>().sprite = tileSprite;
            newGO[i].transform.GetChild(0).GetComponent<SpriteMask>().sprite = sprites[i];
            newGO[i].transform.parent = parent.transform;

            newGO[i].transform.localScale *= 0.95f;
        }
    }
    private void UpdateShapeToSprite(PolygonCollider2D collider)
    {
        Sprite sprite = collider.GetComponent<SpriteRenderer>().sprite;
        if (collider != null && sprite != null)
        {
            collider.pathCount = sprite.GetPhysicsShapeCount();
            List<Vector2> path = new List<Vector2>();
            for (int i = 0; i < collider.pathCount; i++)
            {
                path.Clear();
                sprite.GetPhysicsShape(i, path);
                collider.SetPath(i, path.ToArray());
            }
        }
    }
}

깨트릴 sprite block 이미지위에 mask를 씌워서 여러장을 만든형태입니다. localScale도 약간 작게 조절합니다. 그리고 물리 충돌을 넣어주면 좀 더 자연스럽게 부서집니다. 물리 충돌 설정을 위한 코드가 UpdateShapeToSprite() 함수 입니다.

여기에서 DParent 가 있는데 자식들 관리를 위한 부모 dummy prefab이라고 보시면 됩니다.

3.4 BrokenSprite

BrokenSprite 는 아래 VisualSprite 이름의 Sprite Object를 가지고 있습니다.
BrokenSprite 의 Color은 알파값을 0 설정을 해서 투과 설정을 해줍니다. Mask된 이미지는 VisualSprite에서 출력을 하기 때문입니다.

Mask Interaction 설정은 Visible inside Mask 로 설정을 해줍니다. 그리고 Sprite Mask 컴포넌트도 추가해줍니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BrokenSpriteFadeOutHandler : MonoBehaviour
{
    [SerializeField] private float fadeSpeed;
    [SerializeField] private int destroyDistance;
    [SerializeField] private int forceFadeTimer;

    private Rigidbody2D rb;
    private bool startFade;
    private int forceFade;
    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        transform.Rotate(0,0,90*Random.Range(0,4));
    }
    private void LateUpdate()
    {
        float distanceToCamera = Vector2.Distance(transform.position, Camera.main.transform.position);
        if (distanceToCamera > destroyDistance) Destroy(gameObject);
        else if (startFade)
        {
            Color colour = transform.GetChild(0).GetComponent<SpriteRenderer>().color;
            colour.a -= fadeSpeed;
            transform.GetChild(0).GetComponent<SpriteRenderer>().color = colour;
            if (colour.a <=0)Destroy(gameObject);
        }
        else
        {
            if (rb != null && rb.velocity.x == 0) startFade = true;
            if (forceFade < forceFadeTimer) forceFade++;
            else startFade = true;
        }
    }
    // Update is called once per frame
    void Update()
    {
        
    }
}

코드 상으로는 별내용은 없습니다. 일정시간뒤 Fadeout 되도록 설정합니다.

4. 전체 소스





댓글 없음:

댓글 쓰기