引言
个人学习积累中,如有任何问题与错误,欢迎指出与讨论。
这系列将会记录我在搭建自己的 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
进行整体管理。具体结构如下图所示:
同样,关卡编辑器也被制作成一个大的预制体,由 LevelEditManager.cs
进行整体管理。结构如下:
上述预制体都可以随着需求变化,不断扩充新的子层。
思路呈现
接下来按照我的思路,给大家讲解下搭建过程。总体分为三部分:关卡、关卡编辑及数据。其具体关系大致如下图所示。
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,测试用 } // ... }
大致流程见注释,我们还剩下两个自定义的方法 GetTilemapData
与 GetTiles<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
即可(当然你也可以自己给一个新的变量,然后赋值)。
不过有一点需要考虑,我们的瓦块,有时候不是完整的一块,它可能是 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 立场。未经授权允许,请勿转载。
好棒!