环境
文章中的项目是使用 Unity 创建的 2D 游戏项目, 代码是基于 Microsoft Visual Studio
编写, 使用的编程语言是 csharp
.
思想
Flood Fill 算法翻译为中文可以叫: "洪水填充" 算法.
从起始位置开始, 不断向外围进行检测, 就像从中心一点向外不断蔓延的洪水一般.
话说这个蔓延方式也不像洪水啊, 不应该是核弹爆炸嘛~ 今天又是核平的一天呐~ 😅
实现原理
Flood Fill 算法的基本原理是基于一个待检查队列, 这个队列中存放接下来待检查的结点, 首先取出待检查队列中一个结点, 检查其坐标周围的四个结点,
将这个四个结点中满足条件的结点添加到待检查队列, 之后从待检查队列中取出下一个待检查结点以同样的规则进行检查, 再将满足条件的结点添加到待检查队列, 如此反复,
直到将待检查队列中的结点全部检查完毕!
定义待检查队列中的结点类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
public class DS_FloodFill_Node { public int positionX;
public int positionY;
public int overStep; }
|
定义两个 int 字段存储结点的坐标, 再定义一个 overStep, 表示从起始坐标到达此结点所在坐标时剩余的步数.
创建算法类
1 2 3 4 5 6 7 8
|
public class AR_FloodFill { }
|
创建算法需要的临时变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
private EM_Terrain[,] map;
private bool[,] flagChecked;
private readonly List<DS_FloodFill_Node> range = new List<DS_FloodFill_Node>();
private readonly int checkIndex = 0;
private bool isIgnoreTerrain;
|
map 是整张地图的数据, 虽然这里只是一个枚举数组, 但是项目中有一个字典变量, 通过这个枚举值便可以查到特定的地形类型所对应的全部数据, 包含地形名称, 地形提供的闪避率, 防御力, 攻击力, 生命回复等等. 全部的数据结构牵扯的东西比较多, 这里就不列出了.
flagChecked 用来标记算法在循环过程中哪些坐标已经被检查通过了, 这样可以避免重复检查. 另外, flagChecked 使用的存储方式是二维数组, 它是将检查过的位置标记在相应的坐标处, 且只标记通过的位置, 即 flagChecked 的值就是最终的行动范围, 但是它不附带其他的数据.
range 记录的也是被检查通过的结点, 但是它会将全部信息进行保存.
checkIndex 是待检查队列的游标, 表示当前检查到什么位置了.
isIgnoreTerrain 标识是否无视地形, 用于区分检测的是移动范围还是攻击范围.
实现 Flood Fill 算法
主方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
|
public bool[,] FloodFill(EM_Terrain[,] map, int startPositionX, int startPositionY, int minRange, int maxRange, bool isIgnoreTerrain) { this.map = map;
this.isIgnoreTerrain = isIgnoreTerrain;
flagChecked = new bool[map.GetLength(0), map.GetLength(1)]; for (int i = 0; i < flagChecked.GetLength(0); i++) { for (int j = 0; j < flagChecked.GetLength(1); j++) { flagChecked[i, j] = false; } }
DS_FloodFill_Node startPosition = new DS_FloodFill_Node { positionX = startPositionX, positionY = startPositionY, overStep = maxRange, }; range.Add(startPosition); flagChecked[startPositionX, startPositionY] = true;
ScanMap(checkIndex);
RemoveMinRange(minRange);
return flagChecked; }
|
这个方法做的事情不多 (读者: 整个 Flood Fill 算法做的事情也不多好吧! !), 首先初始化算法需要的数据, 之后将初始位置存入待检查队列等待第一次检查, 记得同时更新 range 和 flagChecked 的值, 然后便可以使用 ScanMap() 方法检查整张地图了, 最后判断一下是否需要移除起始坐标, 像使用物品是可以对自己使用的, 但是移动的时候原地移动是不算真正移动了的, 攻击也同样不能攻击自己, 最后将计算好的范围返回外部, 由外部进行处理.
ScanMap 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
private void ScanMap(int checkIndex) { while (checkIndex < range.Count) { int positionX = range[checkIndex].positionX; int positionY = range[checkIndex].positionY; int overStep = range[checkIndex].overStep;
CheckMapPos(positionX, positionY + 1, overStep); CheckMapPos(positionX - 1, positionY, overStep); CheckMapPos(positionX, positionY - 1, overStep); CheckMapPos(positionX + 1, positionY, overStep);
checkIndex++; } }
|
ScanMap() 方法就是一个循环, 针对当前坐标计算出周围坐标, 然后使用 CheckMapPos() 方法检测特定坐标.
CheckMapPos() 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
|
private void CheckMapPos(int positionX, int positionY, int overStep) { if (positionX < 0 || positionX >= map.GetLength(0)) { return; }
if (positionY < 0 || positionY >= map.GetLength(1)) { return; }
overStep = isIgnoreTerrain ? overStep - 1 : overStep - AD_Terrain.AD_Terrains[map[positionX, positionY]].BaseStepCost; if (overStep < 0) { return; }
if (flagChecked[positionX, positionY]) { int checkedRangeDataIndex = GetCheckedRangeDataIndex(positionX, positionY); if (overStep > range[checkedRangeDataIndex].overStep) { DS_FloodFill_Node updatePosition = new DS_FloodFill_Node { positionX = positionX, positionY = positionY, overStep = overStep, }; range[checkedRangeDataIndex] = updatePosition; } } else { DS_FloodFill_Node currentPosition = new DS_FloodFill_Node { positionX = positionX, positionY = positionY, overStep = overStep, }; range.Add(currentPosition); flagChecked[positionX, positionY] = true; } }
|
CheckMapPos() 方法的重点就是退出条件.
退出条件
- 是否越界
由于我项目中地图的坐标是从 (0, 0) 开始设计的, 所以这里的越界判断自然就是这样的写法啦~
- 是否可以到达当前坐标
这里的判断使用了 'isIgnoreTerrain' 变量, 当无视地形时, 每移动一个位置消耗的步数为 1, 用于使用物品以及攻击范围的计算, 不无视地形时, 每移动一个位置消耗的步数需要根据地形类型进行判断, 用于计算移动范围.
[注] 另一个需要注意的是 overStep, 里面保存的值不一定是最大剩余步数.
这个问题是计算行动范围时会遇到的特殊情况. 由于每个坐标消耗的步数不同, 所以经常会出现这种情况:
假设行动步数为 10 步, 翻越城镇城墙消耗 5 步, 进入城镇消耗 1 步, 那么:
路线1首先检测到了城镇入口, 到达时剩余步数为: 4;
路线2之后检测到了城镇入口, 到达时剩余步数为: 6;
于是单独写一个 if 进行判断, 如果结点已经检查过了, 那么比较两次路线的剩余步数, 并更新为大的剩余步数. 当然这个地方不写 (上面代码段中的最后一个 if else 逻辑) 也已经可以充分的实现计算行动范围了.
独立方法
最后便是提取出来的独立方法.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
private int GetCheckedRangeDataIndex(int positionX, int positionY) { for (int i = checkIndex + 1; i < range.Count; i++) { if (range[i].positionX == positionX && range[i].positionY == positionY) { return i; } }
return 0; }
private void RemoveMinRange(int minRange) { int startPositionX = range[0].positionX; int startPositionY = range[0].positionY;
if (minRange > 0) { for (int i = 0; i < range.Count; i++) { if (Math.Abs(startPositionX - range[i].positionX) + Math.Abs(startPositionY - range[i].positionY) < minRange) { flagChecked[range[i].positionX, range[i].positionY] = false; range.RemoveAt(i); i--; } } } }
|
这样最终返回的 bool 数组便是可行动的范围!
参考文章
cocos creator 实现战棋类游戏移动范围效果