Tilemap 里瓦块的动态添加与删除

作者:Fe
2022-03-18
17 16 0

引言

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

这系列将会记录我在搭建自己的 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 里调用。但是,我们其实并不是每一帧都需要鼠标对应的位置,我们只在吸收/释放这个过程中,才需要获取。那么,有什么思路吗?用标识符,在 updateif 条件语句判断下,或者,直接在协程里处理每帧调用?

后者能够获得清楚的代码逻辑,不至于把 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 类型的活动。

同样,我在学习本文相关内容时,借鉴了不少帖子、视频,包括但不限于:

Unity: Set Get and Detect tiles in tilemaps! —— Pxl Dev