基于Tilemap制作关卡编辑器并实现持久化存储

作者:Fe
2023-05-05
10 2 1

引言

个人学习积累中,如有任何问题与错误,欢迎指出与讨论。

这系列将会记录我在搭建自己的 2D 平台游戏时遇到的一些问题与解决方案,核心目的均为更好的游戏体验与更棒的代码逻辑结构所有代码基于 C# 与 Unity

本篇文章基于《Tilemap 里瓦块的动态添加与删除》这篇文章进一步拓展,还未阅读过的读者可以先看这篇前置文章。

正文

本篇会使用到 2D Tilemap Extras 这个扩展包以及 Odin 这个编辑器插件。

其中 2D Tilemap Extras 使用到了它的 RuleTile,而 Odin 则用到了[Button]用于在 Inspector 窗口生成对应方法的按钮,方便测试。

Unity 版本为2020.3.26f1c1

部分代码内有// ...这样的注释,表示为从整个代码文件中截取的一部分,非完整代码。

在本篇文章中,我将讲述如何构建游戏内的关卡编辑器,其功能主要包括:

  • 功能 1:可以根据鼠标位置,实现左键放置瓦块,右键清除瓦块;
  • 功能 2:可以使用指定键位切换当前放置的瓦块;
  • 功能 3:可以将关卡数据用 JSON 格式持久化存储,并支持读出与写入。

另外,还会处理以下细节问题:

  • 如何比较有系统、有条理地处理整个工作流程,以确保其可扩展性?
  • 如何在 UI 上以正确比例与位置呈现图像,如何隔绝 UI 层与 GamePlay 层?
  • 如何将 Prefab 与 Tilemap 结合使用?

当然,本篇主要介绍一些核心方法,只保障“能用”,在用户交互层面设计得比较简陋;要想实现“好用”,还得读者自行完善。

成品简易演示

整体结构

在这里我们会写六个脚本文件,分别是:

  • CommonUtil.cs:静态类,存放一些游戏通用的方法与变量(这里主要存放路径),方便后期统一管理。
  • LevelData.cs:关卡的一些数据类。
  • LevelEditManager.cs:关卡编辑器的管理类(功能 1、功能 2 在此实现)。
  • LevelManager.cs:关卡的管理类(功能 3 在此实现)。
  • LevelUtil.cs:静态类,存放一些关卡的扩展方法。
  • BaseRuleTile.cs:Tilemap 里对应的每一个小瓦块。

至于关卡,我会将其制作成一个大的预制体,由 LevelManager.cs 进行整体管理。具体结构如下图所示:

关卡 Prefab 结构

同样,关卡编辑器也被制作成一个大的预制体,由 LevelEditManager.cs 进行整体管理。结构如下:

关卡编辑器 Prefab 结构

上述预制体都可以随着需求变化,不断扩充新的子层。

思路呈现

接下来按照我的思路,给大家讲解下搭建过程。总体分为三部分:关卡关卡编辑数据。其具体关系大致如下图所示。

各部分之间的关系

MVC 模式(Model–view–controller)是软件工程中的一种软件架构模式,把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。

  • 模型(Model) - 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)。
  • 视图(View) - 界面设计人员进行图形界面设计。
  • 控制器(Controller)- 负责转发请求,对请求进行处理。

—— 引自维基百科“MVC”词条

本质上,我们的关卡编辑器也采取的是类似 MVC 模型的结构,其中:数据为 M(Model),用来存储关卡数据,包括全部的瓦块数据等;关卡为 C(Controller),封装了一些关卡相关的基本方法,可以对数据进行直接编辑,比如保存/导出地图数据功能;关卡编辑为 V(View),负责呈现可视化界面,接收用户输入(比如鼠标左键,要在鼠标位置放置一个指定的瓦块),并将输入交给关卡处理,并给出必要反馈。

数据

首先,我们先来明确上一篇文章所采用方法存在的主要问题:

  • 瓦块类型的比较过程较为繁琐 => 你必须将获取到的 RuleTile 与你存好的瓦块类型变量进行逐一比较,才能判断当前是哪种类型。
public RuleTile tile_set;

if (tilemap_1.GetTile(currentPos) == tile_set)
{
        // 是'tile_set'这个类型
}
else
{
        // 反之不是
}
  • 无法进行持久化存储 => 保存下来的数据中缺少类型变量,存下后无法复原,只能存储单一类型的瓦块。

所以,关键在于,我们需要给RuleTile这个类增加一些变量,同时,我们又想要继续使用它已有的一些方法,以便与现有 Tilemap 结合。

那么,答案就很明确了,我们需要创建一个新的瓦块类,并继承 RuleTile,这里我取名为 BaseRuleTile,完整代码见下文,具体内容我们将在后文中做讲解。

/* BaseRuleTile.cs */

// 用于表示瓦块类型
[Serializable]
public enum TileType
{
    Wall = 0,        // 表示墙面
    Slime = 1,        // 表示可吸收的史莱姆方块
    Symbolic = 2,        // 象征,即占位符,这里用来处理 Tilemap 中的 Prefab
}

// 'CreateAssetMenu'用于添加 Assets 菜单按钮,'Serializable'指示可序列化的类或结构,使其可以输出成 JSON 格式存入硬盘。
[CreateAssetMenu(fileName = "M_RuleTile", menuName = "GamePlay/RuleTile/BaseRuleTile")]
[Serializable]
public class BaseRuleTile : RuleTile
{
    public TileType tileType = TileType.Wall;
    public GameObject symbolicPrefab = null;        // 当类型为'Symbolic'时,我们会使用这个变量来找到对应的 Prefab
    public Vector3 symbolicOffsetPos = Vector3.zero;        // 处理 Prefab 自身坐标差导致的一些偏移问题
    
    // 以下两个方法继承自'RuleTile.cs',读者如有需要可以自行修改其代码来满足需求,本篇中未使用到。
    
        // Retrieves any tile rendering data from the scripted tile.
    public override void GetTileData(Vector3Int position, ITilemap tilemap, ref TileData tileData)
    {
        base.GetTileData(position, tilemap, ref tileData);
    }
    
    // This method is called when the tile is refreshed.
    public override void RefreshTile(Vector3Int position, ITilemap tilemap) 
    {
        base.RefreshTile(position, tilemap);
    }
}

这样处理之后,我们就有 tileType 这个变量来存储瓦块类型了,获取的方式也和原先没有区别,使用 tilemap_1.GetTile(currentPos).tileType 即可。另外,随着需求变化,我们可以不断添加新的变量,将单个瓦块本身的数据,都放在这里,保持代码的整洁。

另外,就像 Prefab 和实例之间的区别一样,一个 Prefab 会对应着一或多个实例。一种瓦块会被放置在 Tilemap 上的多个地方,这里有一些每个实例都不同的数据(如每个瓦块的坐标)是无法存储在 BaseRuleTile 内的,我们需要在外面再封装一层。

/* LevelData.cs */

// ...

[Serializable]
public class BaseTileData
{
    public BaseRuleTile baseRuleTile;
    public Vector3Int pos;        // 表示位置信息
    public TilemapType tilemapType;        // 表示对应的 Tilemap 类型,为了方便写入时放置在对应的 Tilemap 上
    
    /*
     * 当然这里可以选择把'BaseRuleTile'类内的变量如'tileType'抛出来,少一个'.'引用,也避免直接对类内变量进行操作。
     * 举例:
     * public TileType tileType;
     * public BaseTileData(BaseRuleTile baseRuleTile, Vector3Int pos, TilemapType tilemapType)
     * {
     *      this.baseRuleTile = baseRuleTile;
     *      this.pos = pos;
     *      this.tilemapType = tilemapType;
     *                this.tileType = baseRuleTile.tileType;
     * }
     */
    
    // 构造函数,序列化与反序列化会使用到(大概?这块缺少深入的了解)
    public BaseTileData(BaseRuleTile baseRuleTile, Vector3Int pos, TilemapType tilemapType)
    {
        this.baseRuleTile = baseRuleTile;
        this.pos = pos;
        this.tilemapType = tilemapType;
    }
}

// ...

既然都做到这了,我们也把关卡数据与 Tilemap 的数据一起完善下。目前整个关卡的数据就是一个所有瓦块数据的数组集合,当然后续也还会添加更多的数据,比如关卡名字...

/* LevelData.cs */

// ...

public enum TilemapType
{
    Base = 0,        // 基本层,墙体以及史莱姆方块放置在此
    Cursor = 1,        // 光标层,用来展示我们的光标瓦块
    Prefab = 2        // Prefab 层,这里会有一些占位符瓦块,指代对应的 Prefab,在开始游戏后会生成实例
}

[Serializable]
public class LevelData : ScriptableObject
{
    [SerializeField] public List tiles = new List();        // 瓦块数据的数组
}


[Serializable]
public class TilemapData
{
    public TilemapType type;        // 指示当前的 Tilemap 类型
    public Tilemap tilemap;                // 对应的 Tilemap
}

// ...

另外,还有一些游戏通用的路径,我们统一放在 CommonUtil.cs 中。

/* CommonUtil.cs */

public static class CommonUtil
{
    public static readonly string LEVEL_PATH = Application.persistentDataPath + "/Level/";        // 关卡路径
    
    public static readonly string BASE_RULETILE_DATA_PATH_BY_RESOURECES = "Data/RuleTile/Base";        // 基础瓦块的存放路径
    
    public static readonly string PREFAB_RULETILE_DATA_PATH_BY_RESOURECES = "Data/RuleTile/Prefab";        // Prefab 类型的瓦块存放路径
}

到此,我们所有的数据部分就处理完毕。接下来,我们要正式编写我们的关卡功能。

关卡

首先,我们来处理关卡数据保存,无非就是把每个瓦块的数据都记录下来,并写入 LevelData,再转为 JSON 输出到指定路径。直接来看代码:

/* LevelManager.cs */

public class LevelManager : MonoBehaviour
{
    public List tilemapDatas;        // Tilemap 数组
    
    // ...
    
    // '[Button]'为'Odin'插件所带功能,可以在 Inspector 窗口直接生成对应的按钮,点击后直接调用'SaveMap()'方法。
    [Button]
    public void SaveMap()
    {
        tilemapDatas.GetTilemap(TilemapType.Base).CompressBounds();        // 将 Tilemap 大小进行压缩,去掉空白部分,缩减长宽,从而减少遍历次数
        BaseTileData[] allTilesInBase = tilemapDatas.GetTilemapData(TilemapType.Base).GetTiles();        // 这里涉及到我们自己写的一些方法,大致意思就是获取指定类型('Base')的 Tilemap 的全部瓦块数据,并输出为一个数组
        
        tilemapDatas.GetTilemap(TilemapType.Prefab).CompressBounds();        // 与上面相同的操作,只是处理另一个 Tilemap
        BaseTileData[] allTilesInPrefab = tilemapDatas.GetTilemapData(TilemapType.Prefab).GetTiles();

        BaseTileData[] allTiles = allTilesInBase.Concat(allTilesInPrefab).ToArray();        // 将两个 Tilemap 导出的数组进行拼接,合为一个
        var newLevel = ScriptableObject.CreateInstance();        // 因为'LevelData'为 ScriptableObject,需要通过这个方式创建出来
        newLevel.tiles = new List(allTiles);        // 将数据赋值进去

        string json = JsonUtility.ToJson(newLevel, true);        // 将其转为 json 样式的字符串,'true'表示使用 pretty print,即有缩进

        string saveName = "111.json";        // 为了方便,这里保存文件的文件名直接写死,读者大可通过形参的方式传递进来,从而能让用户自定义名字
        
        // 如果对应文件夹不存在,生成一个新的,防止路径错误
        if (!Directory.Exists(CommonUtil.LEVEL_PATH))
        {
            DirectoryInfo directoryInfo = new DirectoryInfo(CommonUtil.LEVEL_PATH);
            directoryInfo.Create();
        }
        File.WriteAllText(CommonUtil.LEVEL_PATH + saveName, json);        // 将所有数据写入并生成对应的文件
        
        Debug.Log("Save Success!");        // Log,测试用
    }
    
    // ...
}

大致流程见注释,我们还剩下两个自定义的方法 GetTilemapDataGetTiles<BaseRuleTile>,没有讲解。我们一样通过代码来看:

/* LevelUtil.cs */

public static class LevelUtil
{
    // 获取指定'TilemapData'的全部瓦片数据('BaseTileData')并以数组形式输出
    public static BaseTileData[] GetTiles(this TilemapData tilemapData) where TTile : BaseRuleTile
    {
        List tiles = new List();
        
        // 从左到右,从下到上。取每个位置上的瓦块,并添加进数组中
        for (int y = tilemapData.tilemap.origin.y; y < (tilemapData.tilemap.origin.y + tilemapData.tilemap.size.y); y++)
        {
            for (int x = tilemapData.tilemap.origin.x; x < (tilemapData.tilemap.origin.x + tilemapData.tilemap.size.x); x++)
            {
                Vector3Int pos = new Vector3Int(x, y, 0);
                TTile tile = tilemapData.tilemap.GetTile(pos);
                if (tile != null)
                {
                    tiles.Add(new BaseTileData(tile, pos, tilemapData.type));
                }
            }
        }
        return tiles.ToArray();
    }

    public static TilemapData GetTilemapData(this List tilemapDatas, TilemapType type)
    {
        foreach (var tilemapData in tilemapDatas)
        {
            if (tilemapData.type == type)
            {
                return tilemapData;
            }
        }

        return null;
    }
    
    // ...
}

这里主要用了 C# 的扩展方法,通过带 this 的形参来自动实现匹配,为指定类的实例添加对应方法,算是一种语法糖吧。读者可以通俗理解为下属代码的方法一。

/* 可以认为方法一与方法二是等效的 */
LevelUtil.GetTiles(tilemapData); // 方法一
tilemapData.GetTiles(); // 方法二

关于这一块的知识,具体参见扩展方法 - C# 编程指南,这里不多赘述。通过这一个小技巧,我们可以将一些特定于关卡的通用方法拎出来,放在 LevelUtil.cs 中,从而让代码结构更加清晰。

写完保存,那当然也得有读入。思路类似,将保存好的 JSON 文件内容重新读入,并创建好对应的类,将其写入即可。

/* LevelManager.cs */

public class LevelManager : MonoBehaviour
{
    public LevelData curLevelData;        // 当前关卡数据的引用,方便使用
    
    // ...
    
    // '[Button]'为'Odin'插件所带功能,可以在 Inspector 窗口直接生成对应的按钮,点击后直接调用'LoadMap()'方法。
    [Button]
    void LoadMap()
    {
        tilemapDatas.ClearAllTilemaps();        // 先清空全部数据
        
        string saveName = "111.json";        // 保存的文件名

        var json = File.ReadAllText(CommonUtil.LEVEL_PATH + saveName);        // 读出指定路径的文件的全部内容

        var newLevel = ScriptableObject.CreateInstance();        // 实例化类
        
        JsonUtility.FromJsonOverwrite(json, newLevel);        // 将数据写入这个类中

        // 根据位置信息,逐个放置瓦块
        foreach (var tile in newLevel.tiles)
        {
            tilemapDatas.GetTilemap(tile.tilemapType).SetTile(tile.pos, tile.baseRuleTile);
        }

        curLevelData = newLevel;        // 将其设为当前关卡数据
        
        Debug.Log("Load Success!");        // Log,测试用
    }
    
    // ...
}

/* LevelUtil.cs */

public static class LevelUtil
{
    // 清空指定 TilemapData 数组的全部 Tilemap 数据
           public static void ClearAllTilemaps(this List tilemapDatas)
    {
        foreach (var tilemapData in tilemapDatas)
        {
            tilemapData.tilemap.ClearAllTiles();
        }
    }
    
    // ...
}

这样就实现了复原关卡。但是别忘了,我们还设置了占位符瓦块,它代表的是 Prefab,在正式游戏开始时,我们需要将其代表的 Prefab 实例化并放置在对应的地图位置。创建一个 StartMap() 方法,用来开始游戏关卡。代码如下所示。

/* LevelManager.cs */

public class LevelManager : MonoBehaviour
{
    public GameObject prefabParent;        // 生成的 Prefab 的统一父类
    
    // ...
    
    // '[Button]'为'Odin'插件所带功能,可以在 Inspector 窗口直接生成对应的按钮,点击后直接调用'StartMap()'方法。
    [Button]
    public void StartMap()
    {
        // ...
                
        // 前面代码与'LoadMap()'一致,不再复述,只修改其 for 循环处代码即可。
        foreach (var tile in newLevel.tiles)
        {
                  // 根据瓦块类型进行不同处理  
            switch (tile.baseRuleTile.tileType)
            {
                // 占位符瓦块就实例化其 Prefab 并放置在对应位置
                case TileType.Symbolic:
                    var gameObject = GameObject.Instantiate(tile.baseRuleTile.symbolicPrefab, prefabParent.transform);
                    gameObject.transform.position = tile.pos + tile.baseRuleTile.symbolicOffsetPos;
                    break;
                // 其他瓦块正常处理
                default:
                    tilemapDatas.GetTilemap(tile.tilemapType).SetTile(tile.pos, tile.baseRuleTile);
                    break;
            }
        }

              // ...
    }
    
    // ...
}

到目前为止,三个核心方法已介绍完毕。

通过 SaveMap()LoadMap() ,我们掌握了基本的数据保存/加载方法;通过 StartMap() ,我们掌握了如何对基本方法进行扩展。剩下没讲的,就差如何提供接口,与外部进行联系了。

还记得上篇中的光标吗,我们以此为例,在这个新架构里实现“光标瓦块随鼠标而动”的效果。分析一下需求,光标包括两个部分:

  • 底层逻辑 => 获取鼠标位置,将位置同步给光标瓦块,以及接收鼠标操作(左键/右键/移动...)。这是代码中不会经常变动的部分。
  • 外部表现 => 根据不同的鼠标操作给出不同的效果。这是代码中会经常根据需求灵活变动的部分。

显然,在 LevelManager.cs 中,我们只需要处理底层逻辑这块,并把提供外部表现的接口抛出,供 LevelEditManager.cs 使用。

核心就是使用事件来处理,把外部表现部分封装为对应的事件,由 LevelEditManager.cs 注册,并在 LevelManager.cs 中调用。具体来看代码。

/* LevelManager.cs */

public class LevelManager : MonoBehaviour
{
    public bool isShowCursorFrameTile = false;        // 是否显示光标瓦块

    [SerializeField] private BaseRuleTile frameTile;        // 对应的光标瓦块
    
    // ----------------------- 事件 ------------------------
    
    public delegate void OnCursorClick(Vector3Int pos, List tilemapDatas);
    public event OnCursorClick EventCursorClick;        // 鼠标左键对应事件
    
    public delegate void OnCursorDelete(Vector3Int pos, List tilemapDatas);
    public event OnCursorClick EventCursorDelete;        // 鼠标右键对应事件
    
    public delegate void OnCursorMove(Vector3Int pos, List tilemapDatas);
    public event OnCursorMove EventCursorMove;        // 鼠标移动对应事件
    
    // ----------------------- End ------------------------
    
    private void Update()
    {
        UpdateCursor(Camera.main.ScreenToWorldPoint(Input.mousePosition));        // 鼠标位置的检测需要每帧执行,'Input.mousePosition'即目前的鼠标位置
    }
    
    // 更新鼠标数据
    private void UpdateCursor(Vector3 cursorPos)
    {
        Vector3Int pos = tilemapDatas.GetTilemap(TilemapType.Cursor).WorldToCell(cursorPos);        // 将鼠标位置转换为 Tilemap 上对应的位置
                
        // 若显示光标瓦块,则要更新对应的 Tilemap
        if (isShowCursorFrameTile)
        {
            tilemapDatas.GetTilemap(TilemapType.Cursor).ClearAllTiles();
            tilemapDatas.GetTilemap(TilemapType.Cursor).SetTile(pos, frameTile);
        }
         
        // 如果当前鼠标在 UI 上,则不处理光标事件,直接 return
        if (EventSystem.current.IsPointerOverGameObject()) {
            return;
        }
                
        // 鼠标左键事件
        if (Input.GetButtonDown("Fire1"))
        {
            EventCursorClick?.Invoke(pos, tilemapDatas);
        }
        // 鼠标右键事件
        else if (Input.GetButtonDown("Fire2"))
        {
            EventCursorDelete?.Invoke(pos, tilemapDatas);
        }
        // 鼠标移动事件(这里默认保持光标位置不动也算在内)
        EventCursorMove?.Invoke(pos, tilemapDatas);
    }
    
    // ...
}

到此,我们所有的关卡部分就处理完毕。剩下的就是关卡编辑了。

关卡编辑

如上文所说,关卡编辑负责实现功能 1 与功能 2。有一个点我们需要关注:关卡编辑不单纯只有 UI 表现,还包括一些服务于 UI 的基础功能(比如获取当前配置好的全部瓦块),为了减少类与类的耦合,我们需要把 UI 单独做成一个类(这里我写了一个范例,为 LevelEditPanel.cs)。换一种说法,就是 LevelEditManager.cs 中不应该存在 using UnityEngine.UI;这个语句。其他内容我们直接通过代码讲解。


/* LevelEditManager.cs */

public class LevelEditManager : MonoBehaviour
{
    [ReadOnly] public BaseRuleTile curRuleTile;        // 表明当前选中的瓦块,'[ReadOnly]'为 Odin 提供,只读参数,让其在 Inspector 栏上不可通过拖拽等方式进行编辑

    private int curRuleTileIndex;        // 当前瓦块对应的数组下标
    
    [ReadOnly] [SerializeField] private BaseRuleTile[] allTiles;        // 当前配置好的全部瓦块数组

    [SerializeField] private LevelEditPanel levelEditPanel;        // UI 对应的类
    [SerializeField] private LevelManager levelManager;        // 关卡对应的类
    
    //--------------------------------------------------------------

    private void Awake()
    {
        LoadAllTiles();        // 将所有的瓦块先加载进来
    }

    // Start is called before the first frame update
    private void Start()
    {
        levelManager.isShowCursorFrameTile = true;        // 呈现光标瓦块
        
        // 注册事件,这里没写注销事件,应该在 Destroy 的时候注销(-=)
        levelManager.EventCursorClick += SetTile;
        levelManager.EventCursorDelete += DeleteTile;
    }

    // Update is called once per frame
    private void Update()
    {
        // 使用'Q'与'E'进行瓦块切换。
        if (Input.GetKeyDown(KeyCode.Q))
            UpdateTile(KeyCode.Q);
        else if (Input.GetKeyDown(KeyCode.E))
            UpdateTile(KeyCode.E);
    }
        
    // 加载瓦块
    private void LoadAllTiles()
    {
        // 这里通过'Resources.LoadALL(string path)'的方法进行加载,并进行数组拼接
        var baseTiles = Resources.LoadAll(CommonUtil.BASE_RULETILE_DATA_PATH_BY_RESOURECES).ToArray();
        var prefabTiles = Resources.LoadAll(CommonUtil.PREFAB_RULETILE_DATA_PATH_BY_RESOURECES).ToArray();
        allTiles = baseTiles.Concat(prefabTiles).ToArray();
        curRuleTileIndex = 0;
        curRuleTile = allTiles[curRuleTileIndex];        //默认使用第一个为当前瓦块
        levelEditPanel.UpdateTileImage(curRuleTile);        // 别忘了,也需要更新 UI 上的图片
    }

    private void UpdateTile(KeyCode key)
    {
        switch (key)
        {
            // 循环向前,第一个的前一位为最后一个
            case KeyCode.Q:
                curRuleTileIndex = curRuleTileIndex - 1 < 0
                    ? curRuleTileIndex + allTiles.Length - 1
                    : curRuleTileIndex - 1;
                break;
            // 循环向后,最后一个的后一位为第一个
            case KeyCode.E:
                curRuleTileIndex = (curRuleTileIndex + 1) % allTiles.Length;
                break;
        }
        
        // 同样的,切换后记得更新数据与 UI
        curRuleTile = allTiles[curRuleTileIndex];
        levelEditPanel.UpdateTileImage(curRuleTile);
    }
        
    // 放置瓦块
    private void SetTile(Vector3Int pos, List tilemapDatas)
    {
        switch (curRuleTile.tileType)
        {
            case TileType.Symbolic:
                tilemapDatas.GetTilemap(TilemapType.Prefab).SetTile(pos, curRuleTile);
                break;
            default:
                tilemapDatas.GetTilemap(TilemapType.Base).SetTile(pos, curRuleTile);
                break;
        }
    }
        
    // 清空瓦块,'null'就会把对应位置的瓦块清空
    private void DeleteTile(Vector3Int pos, List tilemapDatas)
    {
        switch (curRuleTile.tileType)
        {
            case TileType.Symbolic:
                tilemapDatas.GetTilemap(TilemapType.Prefab).SetTile(pos, null);
                break;
            default:
                tilemapDatas.GetTilemap(TilemapType.Base).SetTile(pos, null);
                break;
        }
    }
}

最后剩下的就是 UI 了,为了呈现瓦块图片在 UI 上,我们需要对应使用一个 sprite,这里我们直接使用 RuleTile 自带的 Default Sprite 即可(当然你也可以自己给一个新的变量,然后赋值)。

RuleTile 自带的 Default Sprite

不过有一点需要考虑,我们的瓦块,有时候不是完整的一块,它可能是 1/2,1/4 的瓦块大小,像下图这样。

不同大小的瓦块示意

这时候直接将其放在对应的 Image 上会导致图片拉伸,使其比例不对。这里我的解决方案是,通过 AspectRatioFitter 组件控制图片比例,再通过中心点来设置图片位置。

UI 设置上,我们通过父物体把图片的高度限制住,再让子物体充分贴合父物体,具体设置如下图所示。

父物体:

父物体结构示意

子物体(瓦块图片):

子物体结构示意

完整代码如下:


/* LevelEditPanel.cs */

public class LevelEditPanel : MonoBehaviour
{
    // 一些组件的引用
    [SerializeField] private RectTransform tile_RectTransform;
    [SerializeField] private Image tile_Image;
    [SerializeField] private AspectRatioFitter tile_aspectRatioFitter;
    [SerializeField] private Button save_Button;
    
    [SerializeField] private LevelManager levelManager;        // 关卡的引用
    

    // Start is called before the first frame update
    void Start()
    {
        // 这是一个示例,可以通过点击按钮来保存关卡数据,读者可以参照添加'加载关卡数据'的按钮
        save_Button.onClick.AddListener(() =>
        {
            levelManager.SaveMap();
        });
    }

    
    // 根据瓦块更新图片
    public void UpdateTileImage(BaseRuleTile tile)
    {
        tile_Image.sprite = tile.m_DefaultSprite;        // 先获取其 Sprite 并赋到 Image 上
        tile_RectTransform.pivot = new Vector2(1 - tile.m_DefaultSprite.pivot.x / tile.m_DefaultSprite.rect.width, 1 - tile.m_DefaultSprite.pivot.y / tile.m_DefaultSprite.rect.height);        // 获取 Sprite 的中心点数据并除以其长度,获得比例关系并赋值给 Image 对应的 RectTransform,注意我们的 UI 是以右上角为原点,而正常图片为左下角,所以需要'1-n'的操作
        tile_aspectRatioFitter.aspectRatio = tile.m_DefaultSprite.rect.width / tile.m_DefaultSprite.rect.height;        // 保持 UI 长宽比例与 Sprite 一致,避免出现拉伸
    }
}

到此,关卡编辑部分也全部结束。

后记

虽然间隔时间略久,但总算是把上一篇挖下的坑填上了。文章篇幅很长,讲得不够准确和细致的地方,还请多多包涵~
同样,在学习本文相关内容时,我借鉴了不少帖子、视频,包括但不限于:



封面:自制 
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。