引言
个人学习积累中,如有任何问题与错误,欢迎指出与讨论。
这系列将会记录我在搭建自己的 2D 平台游戏时遇到的一些问题与解决方案,核心目的均为更好的游戏体验与更棒的代码逻辑结构。所有代码基于 C#与 Unity。
正文
Tilemap 组件是一个存储和处理瓦片资源以便创建 2D 关卡的系统。—— Unity Documentation
使用网格构造关卡与处理机制极大地方便了我的游戏制作。但是网上关于 Tilemap 的教程多集中于编辑器上的可视化操作,对于在脚本中如何动态处理瓦块的内容,却很少提及,就算提及,也不是很全。结合我这一段时间的学习积累,我觉得,也是时候分享下我是如何处理运用 Tilemap 的了。本文集中于代码动态处理上,偏资料整理向。
前置条件
光靠 Unity 自带的 2D Tilemap Editor 当然是不够的,我们还需要它的扩展包,2D Tilemap Extras,这个扩展包中提供了更多样的刷子与瓦块,方便一些功能的实现。我在这里集中使用 Rule Tile 为例。
除此之外,我们还需要了解一些基本的类与方法。当然,详细的需要去找对应的文档。
using UnityEngine.Tilemaps; // 导入 /* 使用的类 */ public RuleTile tile_1; // ruleTile 对应的类,该类继承自 TileBase,意味着它也可以使用父类的一些基本方法 public Tilemap tilemap_1; // tilemap 对应的类,该类继承自 GridLayout,可以使用父类的一些基本方法 /* 放置与删除指定位置瓦块 */ tilemap_1.SetTile(pos, tile_1); // 将'tile_1'瓦块放置到'tilemap_1'瓦块地图的'pos'位置上 tilemap_1.SetTile(pos, null); // 将 tilemap_1'瓦块地图的'pos'位置上的瓦块清空 /* 位置转换 */ Vector3Int currentPos = tilemap_1.WorldToCell(worldPos) // 将对应的'worldPos'世界坐标转换为对应的瓦块地图的坐标,为 Vector3Int 类型 /* 获取位置上的瓦块 */ TileBase tile_2 = tilemap_1.GetTile(currentPos); RuleTile tile_3 = tilemap_1.GetTile<RuleTile>(currentPos); // 获取'tilemap_1'中某个单元格的给定'currentPos'位置处的'tile of type RuleTile'
运用 | 以方块增减为例
好了,既然你已经学会拧螺丝了,是时候造个飞机试试看了。嗯,好像是个不太好笑的笑话。
无论如何,通过例子学习肯定是一个好办法。接下来,我会通过我项目中使用过的例子,介绍一些基本方法。
需要再次明确的是,这些方法并不一定是最优解,我只能保证其能跑,但不保证效果。
这会是一个详尽的例子,涉及 Tilemap 与 Unity 其他方法的结合运用,同时包括我自己思考完善机制的路线,方便大家体会研究。
基本功能实现
首先给出我们的需求(这是我在 GGJ2022 所制作的游戏”胖还是瘦“的核心玩法):
- 人物在瘦子状态可以吸收三个可变方块转变为胖子状态,同时也可以在胖子状态释放三个可变方块转变为瘦子状态。
- 吸收释放方式为发射无碰撞体的子弹
- 通过鼠标点击确认吸收释放的具体方块
- 新释放的方块只能添加在原有方块的边上(即周围一圈)
- 吸收的方块需要是连续的(就像你用一条连线勾选三个方块那样)
我们在这里只考虑瓦块相关的问题,关于如何处理胖瘦状态,子弹如何发射、如何确认方向,如何确认碰撞点,我们就默认已经有完美解决方法了。
首先我们看看如何添加释放方块。明确一下,我们所有的处理都会基于坐标数据,常用 List<Vector3Int>来存储。
由于释放与添加都需要在原有的可变方块基础上操作,我们首先获得原有的方块数组。这实际上就是一个找最大面积的问题,和 LeetCode 上的”695. 岛屿的最大面积“是同样的思路。
[SerializeField] private RuleTile slimeTile; // 对应的可变方块瓦块 [SerializeField] private Tilemap slimeTilemap; // 可变方块放置的瓦块地图 // 获得当前射击位置聚集在一起的可变方块的位置信息,结果存在 result 中 void GetMaxTogetherSlimeTile(ref List<Vector3Int> result, Vector3Int currentPos) { if (GetTile(currentPos) != slimeTile || result.Contains(currentPos)) return; else { result.Add(currentPos); GetMaxTogetherSlimeTile(ref result, currentPos + new Vector3Int(-1, 0, 0)); GetMaxTogetherSlimeTile(ref result, currentPos + new Vector3Int(1, 0, 0)); GetMaxTogetherSlimeTile(ref result, currentPos + new Vector3Int(0, 1, 0)); GetMaxTogetherSlimeTile(ref result, currentPos + new Vector3Int(0, -1, 0)); } } // 得到对应位置的瓦块类型 RuleTile GetTile(Vector3Int currentPos) { RuleTile ans = null; Vector3Int location = slimeTilemap.WorldToCell(currentPos); ans = slimeTilemap.GetTile<RuleTile>(location); return ans; }
当然这时候你可能会想,我们添加的时候可以选择的方块不是外圈上的嘛,那这怎么获得呢?想一想,可以通过在上面代码的基础上改哦。
// 获得当前所有可以添加瓦块的位置,结果存在 result 中 void GetCanAddEmptyTile(ref List<Vector3Int> result, Vector3Int currentPos) { List<Vector3Int> currentSlimeTile = new List<Vector3Int>(); GetMaxTogetherSlimeTile(ref currentSlimeTile, currentPos); // 先拿到已有的可变方块数组 // 找它外圈的 foreach (Vector3Int pos in currentSlimeTile) { if (IsCanAddTile(result, pos + new Vector3Int(-1, 0, 0))) result.Add(pos + new Vector3Int(-1, 0, 0)); if (IsCanAddTile(result, pos + new Vector3Int(1, 0, 0))) result.Add(pos + new Vector3Int(1, 0, 0)); if (IsCanAddTile(result, pos + new Vector3Int(0, -1, 0))) result.Add(pos + new Vector3Int(0, -1, 0)); if (IsCanAddTile(result, pos + new Vector3Int(0, 1, 0))) result.Add(pos + new Vector3Int(0, 1, 0)); } } // 检查该位置能否添加可变方块 bool IsCanAddTile(List<Vector3Int> result, Vector3Int checkPos) { // 不与已有的冲突且该位置上没有添加过瓦块就可以添加 if (!result.Contains(checkPos) && GetTile(checkPos) == null) return true; else return false; }
但这时候你又会想到,外圈就可能遇到各种问题,比如玩家、地面的瓦块刚好在这外圈上,这时候就不能允许在外圈对应的瓦块上进行修改。这怎么办呢?简单,额外判断一下就好。我们会在后面具体放置时候考虑。
接下来我们就要开始进行放置和吸收操作了。
首先我们考虑吸收(删除方块)。这里有几个关键点:
- 鼠标获取对应位置并通过点击确认选择
- 在完成之前,界面需要保持暂停,等待三个方块选择完毕
第二个点,我们很容易就想到使用协程,通过 yield return
的语句来实现等待效果,第一个点,我们则可以通过 Unity 自带方法,获取鼠标在场景中的位置,并转换到瓦块地图上。至于点击,Input.GetButton("Fire1")
即可。
Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); // 获取鼠标位置 Vector3Int pos = slimeTilemap.WorldToCell(mouseWorldPos); // 转换
平时,我们会将其封装成一个函数,放在 Update
里调用。但是,我们其实并不是每一帧都需要鼠标对应的位置,我们只在吸收/释放这个过程中,才需要获取。那么,有什么思路吗?用标识符,在 update
里 if
条件语句判断下,或者,直接在协程里处理每帧调用?
后者能够获得清楚的代码逻辑,不至于把 update
里搞得乱乱的,也杜绝了不必要的外部接触。
协程里的 Yield return null
其实就是表示暂缓一帧,在下一帧的时候再继续处理后续的代码,那么我们就可以利用它和 while
循环,构造简单的每帧调用。
大致代码如下:
/* 协程里的代码 */ while (true) { // 鼠标位置更新 Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); Vector3Int pos = slimeTilemap.WorldToCell(mouseWorldPos); // 点击处理 if (Input.GetButton("Fire1") && slimeTiles.Contains(pos) && canRemoveTiles.Contains(pos)) { /* 在合适的时候 Break 跳出 */ } yield return null; // 中断,下一帧继续 }
然后就是点击处理里的内容了,除了把当前位置的瓦块消除等基本操作外,还有一个关键点需要我们考虑,吸收的方块需要是连续的(就像你用一条连线勾选三个方块那样),很明显,第一个方块是任意选择,从第二个方块开始,就要根据之前已选的进行判断了。仔细一想,后选的方块不也只能在已选的方块的四周吗,那这,不就是选择外圈的方块吗?!让我们来写一下:
public List<Vector3Int> posOfDeletedSilmeTile; // 存储已选的方块的坐标数组 // 书接上回,就是上方点击处理的具体展示,一开始 canRemoveTiles 是当前射击位置获得的在一起的全部可变方块 if (Input.GetButton("Fire1") && slimeTiles.Contains(pos) && canRemoveTiles.Contains(pos)) { // 清空对应的瓦块和数据 slimeTilemap.SetTile(pos, null); slimeTiles.Remove(pos); // 已选方块添加记录 posOfDeletedSilmeTile.Add(pos); nrOfRemoveDone++; // 更新下一次可选的可变方块 canRemoveTiles = GetCanRemoveSlimeTile(slimeTiles); // 在合适的时候跳出,这里’nrOfRemove = 3‘ if (nrOfRemoveDone >= nrOfRemove) break; } // 基于目前已删除的可变方块,获取当前可删除可变方块 List<Vector3Int> GetCanRemoveSlimeTile(List<Vector3Int> slimeTile) { List<Vector3Int> result = new List<Vector3Int>(); List<Vector3Int> neighbourTiles = new List<Vector3Int>(); // 先获取所有已经删除瓦块的邻居 foreach(Vector3Int hasRemovedOne in posOfDeletedSilmeTile) { if (!neighbourTiles.Contains(hasRemovedOne + new Vector3Int(0, -1, 0))) neighbourTiles.Add(hasRemovedOne + new Vector3Int(0, -1, 0)); if (!neighbourTiles.Contains(hasRemovedOne + new Vector3Int(0, 1, 0))) neighbourTiles.Add(hasRemovedOne + new Vector3Int(0, 1, 0)); if (!neighbourTiles.Contains(hasRemovedOne + new Vector3Int(-1, 0, 0))) neighbourTiles.Add(hasRemovedOne + new Vector3Int(-1, 0, 0)); if (!neighbourTiles.Contains(hasRemovedOne + new Vector3Int(1, 0, 0))) neighbourTiles.Add(hasRemovedOne + new Vector3Int(1, 0, 0)); } // 找到既是邻居又是原先瓦块的,就是可以删除的 foreach(Vector3Int neighbourOne in neighbourTiles) { if (slimeTile.Contains(neighbourOne)) { result.Add(neighbourOne); } } return result; }
那这样吸收就解决啦。接下来我们考虑放置。大部分操作是一样的,鼠标选择添加,已有可变方块外围的方块数据我们在之前也给出了获得的方法。不过,还记得我们说过存在的问题吗,”比如玩家、地面的瓦块刚好在这外圈上,这时候就不能允许在外圈对应的瓦块上进行修改“。这里我以玩家所处位置为例:
/* 障碍物所处的方块位置信息数组 */ List<Vector3Int> barrierStayTiles = new List<Vector3Int>();// 根据碰撞体的数据,得到碰撞体四个角对应的瓦块位置信息并添加到数组中 BoxCollider2D playerBoxCollider = player.GetComponent<BoxCollider2D>(); barrierStayTiles.Add(slimeTilemap.WorldToCell(new Vector2(playerBoxCollider.bounds.center.x + playerBoxCollider.bounds.extents.x, playerBoxCollider.bounds.center.y - playerBoxCollider.bounds.extents.y))); barrierStayTiles.Add(slimeTilemap.WorldToCell(new Vector2(playerBoxCollider.bounds.center.x - playerBoxCollider.bounds.extents.x,playerBoxCollider.bounds.center.y - playerBoxCollider.bounds.extents.y))); barrierStayTiles.Add(slimeTilemap.WorldToCell(new Vector2(playerBoxCollider.bounds.center.x + playerBoxCollider.bounds.extents.x,playerBoxCollider.bounds.center.y + playerBoxCollider.bounds.extents.y))); barrierStayTiles.Add(slimeTilemap.WorldToCell(new Vector2(playerBoxCollider.bounds.center.x - playerBoxCollider.bounds.extents.x,playerBoxCollider.bounds.center.y + playerBoxCollider.bounds.extents.y)));
获得对应的信息后,我们只需要在鼠标点击判断的时候添加一个判断语句 !barrierStayTiles.Contains(pos)
即可。
到此为止,需求所要的基本功能已经实现。
进一步完善美化
当然我们不可能仅满足于此,这个游戏还太”硬核“了,对玩家不够友好,缺少 UI 提示,打个比方,玩家怎么知道,他当前选择的方块能不能添加呢,甚至是,玩家怎么知道他当前选择了那个方块呢,鼠标移上去又没有变化。
所以我们还需要使用 Tilemap 去添加一些简易的交互提示。这里以实现鼠标移动到的瓦块会有一个选择外框为例。
我们首先需要一个外框的瓦块图片,然后,我们要创建一个新的 Tilemap,放在原有 Tilemap 之上 [通过修改 Tilemap Renderer 组件的 Sorting Layer
或者 Order in Layer
(如果他们在同一个图层上)来实现] 。
PS: Tilemap 组件有 Color 一栏,所以有别的效果别忘了这个哦,比如改颜色、透明度等。
接着,我们就可以开始写代码了。别忘了,之前我们已经可以实现每帧调用更新鼠标位置了,我们只要在原有基础上进一步完善即可。
[SerializeField] private Tilemap cursorTilemap; // 外框所在的 Tilemap [SerializeField] private RuleTile frameTile; // 外框的 RuleTile // 更新框的位置,很简单,清空 Tilemap,然后指定位置放一块即可 void UpdataFrameTile(Vector3Int currentPos) { cursorTilemap.ClearAllTiles(); cursorTilemap.SetTile(currentPos, frameTile);} /* 在对应的位置调用方法 */ ... Vector3 mouseWorldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition); Vector3Int pos = slimeTilemap.WorldToCell(mouseWorldPos); UpdataFrameTile(pos); ...
是不是觉得很简单?原有框架搭好了,在其基础上更改就是这样方便。
小结
到此为止,该例子就已展示完毕,碍于篇幅,只展示了一些关键代码,如果觉得太过模糊,可以去这里下载当时 GGJ 上传的文件,里面包含完整的项目文件,关键代码在 CursorController.cs
里,子弹碰撞之类的在 BulletContoller.cs
里。
后记
到此为止,本篇就结束了,看着蛮长,其实用来用去也就涉及到了 Tilemap 里一点点的功能,希望对大家有所帮助。靠着他人提供的轮子,结合一些简单的算法,我们也能很快地完成一个不错的游戏机制。下一篇,我们会就 Tilemap 的持久化数据存储展开讨论(希望自己不咕吧)。
顺便也纪念下自己第一次参加 Game jam 类型的活动。
同样,我在学习本文相关内容时,借鉴了不少帖子、视频,包括但不限于:
暂无关于此文章的评论。