这一章主要进行寻路与地图对象的部分工作。
这一节我们来优化地图相关的代码。
当我们显示移动范围后,再次点击地图角色将移动到指定位置,在我们之前的EditorTestPathFinding
中,我们是保存了移动范围,然后判断点击的格子是否在集合中;而有没有Tile我们使用了一个bool
变量保存;在这里我们可以做一些文章。
其实可以想到,格子有几种状态:
有没有Tile
有没有移动范围网格
有没有攻击范围网格
有没有地图对象
这些属性我们时常需要进行判断,每一个我们都单独会写变量保存状态,这里我们可以整合变量,使用一个二进制来保存它们,每一位表示一个开关。
首先,建立一个二进制Enum
:
using System;
namespace DR.Book.SRPG_Dev.Maps
{
/// <summary>
/// 格子状态
/// </summary>
[Serializable, Flags]
public enum CellStatus : byte
{
/// <summary>
/// 没有任何东西, 0000 0000
/// </summary>
None = 0,
/// <summary>
/// 有TerrainTile, 0000 0001
/// </summary>
TerrainTile = 0x01,
/// <summary>
/// 移动光标, 0000 0010
/// </summary>
MoveCursor = 0x02,
/// <summary>
/// 攻击光标, 0000 0100
/// </summary>
AttackCursor = 0x04,
/// <summary>
/// 地图对象, 0000 1000
/// </summary>
MapObject = 0x08
// 如果有其它需求,在这里添加其余4个开关属性
/// <summary>
/// 全部8个开关, 1111 1111
/// </summary>
All = byte.MaxValue
}
}
这样你会看到,我们有8个开关的位置,而byte
只占1个字节,即1个字节保存了8个“bool”变量。
如果你需要更多的开关位置,请继承相应数字的无符号类型(有符号类型有负数参与,你需要更深入的了解二进制补码知识与计算),例如你需要32个开关,你可以:
using System;
[Serializable, Flags]
public enum YourSwitch : UInt32
{
// your switches
/// <summary>
/// 1111 1111 1111 1111 1111 1111 1111 1111
/// </summary>
All = UInt32.MaxValue
}
然后,在CellData
中添加这个变量:
private CellStatus m_Status = CellStatus.None;
最后,我们来说说如何打开和关闭它们。
之前我们已经使用过二进制的Enum(Direction
),但没有过多详细介绍过它。这里我们将介绍一下二进制的位运算。
我们的基本类型byte
,1字节有8位,取值范围为[0, 255],即二进制的区间[0000 0000, 1111 1111],每一位都可表示成一个开关。
想要更了解进制之间的转换与计算方式,可以访问我的另一篇文章编程基础 - 进制转换(Binary Conversion)
每一位上的二进制,1都代表打开(存在),0都代表关闭(不存在)。我们的开关m_Status
默认为CellStatus.None
即全部是关闭的,二进制位 0000 0000
。
打开开关:
要打开其中某一个开关,根据位运算规则,我们需要或(|)操作这个位置的值;举例来说,如果此格子有移动光标,从右数第2位表示此开关,只需要或(|)上0000 0010
即可打开开关,即0000 0000 | 0000 0010 = 0000 0010
。
关闭开关:
我们先假设Tile、移动光标与攻击光标都存在,即0000 0111
(游戏中不会出现这种状态,移动光标与攻击光标是不能同时出现的)。
我们希望能够关闭移动光标,即希望结果为0000 0101
,而移动光标为0000 0010
;发现4种位运算都不能满足我们的情况,不过很容易看到0000 0111 - 0000 0010 = 0000 0101
可以达到我们的效果。不过这产生了一个新的问题,当我们的开关已经是关闭状态了,就得不到我们的需求。
例如我们希望关闭MapObject
,即0000 0111 - 0000 1000 = 1111 1111
,我的天啊,开关居然全部都打开了。这可不是我们需要的,我们希望开关都能保持不变,即依然是0000 0111
。
要解决这个问题有两种方式:
其一,我们可通过观察,相减的结果再次位与(&)开关原始状态即可得到结果,即(0000 0111 - 0000 0010) & 0000 0111 = 0000 0101
,关闭MapObject
为(0000 0111 - 0000 1000) & 0000 0111 = 0000 0111
。但这种运算使用了减法,在某些语言中(比如C#)是不支持byte
运算的,需要类型转换,这有些麻烦,所以我们采用第二种方式。
其二,我们需要将它关闭,其它开关打开,即取反(~)得到1111 1101
,然后再与开关做位与(&)运算,即0000 0111 & 1111 1101 = 0000 0101
;再次试验关闭MapObject
,即0000 0111 & (~ 0000 1000) = 0000 0111
。这样就解决了关闭问题。
基于以上分析,我们的代码为:
/// <summary>
/// 设置状态开关
/// </summary>
/// <param name="status"></param>
/// <param name="isOn"></param>
public void SwitchStatus(CellStatus status, bool isOn)
{
if (isOn)
{
m_Status |= status;
}
else
{
m_Status &= ~status;
}
}
这样,我们就可以控制是否打开开关与关闭开关了,拥有了设置开关属性,我们还要能够利用开关,即知道开关的状态。
要知道开关是否被打开,只需要其对应的二进制数字是否为1即可。这只需要一个位与(&)运算即可。
例如,假设原始状态为0000 0111
;我们希望知道攻击光标(0000 0010
)是否存在,只需要做位与运算,即0000 0111 & 0000 0010 = 0000 0010
;类似的我们希望知道地图对象(0000 1000
)是否存在,即0000 0111 & 0000 1000 = 0000 0000
;你会发现它们的区别,即存在的结果与输入的结果相同,而不存在的结果为0。
public bool CheckStatus(CellStatus status)
{
return (m_Status & status) == status;
}
这样我们确实可以检测了,但我们还希望能够知道是否有开关开启,比如我们希望知道是否有光标在格子上,这样检测就不可以了,但我们发现,只有关闭时才为0,主要有开启的就会大于0;例如,假设原始状态为0000 0101
,即0000 0101 & (0000 0100 | 0000 0010) = 0000 0100 > 0
,所以修改代码:
/// <summary>
/// 开关是否开启
/// any:
/// true 表示,判断在status中是否存在开启项
/// false 表示,判断status中是否全部开启
/// </summary>
/// <param name="status"></param>
/// <param name="any"></param>
/// <returns></returns>
public bool CheckStatus(CellStatus status, bool any)
{
return any ? (m_Status & status) != 0 : (m_Status & status) == status;
}
我们有开关属性,例如m_HasTile
属性就可以删除了;而且还可以添加许多属性。
修改后的Common属性:
private Vector3Int m_Position;
private MapObject m_MapObject;
private CellStatus m_Status = CellStatus.None;
/// <summary>
/// 坐标位置
/// </summary>
public Vector3Int position
{
get { return m_Position; }
}
/// <summary>
/// 是否有Tile
/// </summary>
public bool hasTile
{
get { return CheckStatus(CellStatus.TerrainTile, false); }
set { SwitchStatus(CellStatus.TerrainTile, value); }
}
/// <summary>
/// 是否有Cursor
/// </summary>
public bool hasCursor
{
get { return CheckStatus(CellStatus.MoveCursor | CellStatus.AttackCursor, true); }
set { SwitchStatus(CellStatus.MoveCursor | CellStatus.AttackCursor, value); }
}
/// <summary>
/// 是否有移动范围光标
/// </summary>
public bool hasMoveCursor
{
get { return CheckStatus(CellStatus.MoveCursor, false); }
set { SwitchStatus(CellStatus.MoveCursor, value); }
}
/// <summary>
/// 是否有攻击范围光标
/// </summary>
public bool hasAttackCursor
{
get { return CheckStatus(CellStatus.AttackCursor, false); }
set { SwitchStatus(CellStatus.AttackCursor, value); }
}
/// <summary>
/// 地图对象
/// </summary>
public MapObject mapObject
{
get { return m_MapObject; }
set
{
m_MapObject = value;
SwitchStatus(CellStatus.MapObject, value != null);
}
}
/// <summary>
/// 是否有地图对象
/// </summary>
public bool hasMapObject
{
get { return mapObject != null; }
}
/// <summary>
/// 是否可移动
/// </summary>
public bool canMove
{
get { return hasTile && !hasMapObject; }
}
/// <summary>
/// 获取状态开关
/// </summary>
/// <returns></returns>
public CellStatus GetStatus()
{
return m_Status;
}
虽然我们的属性有非常多,但其中大部分属性都保存在同1个字节中。
我们主要修改一下调用了这些属性的地方。
在FindMoveRange
与FindPathDirect
中,我们调用了判断是否可移动,而在这里我们有了新的属性。
public override bool CanAddAdjacentToReachable(PathFinding search, CellData adjacent)
{
//// 没有Tile
//if (!adjacent.hasTile)
//{
// return false;
//}
//// 已经有对象了
//if (adjacent.hasMapObject)
//{
// return false;
//}
// 是否可移动
if (!adjacent.canMove)
{
return false;
}
// 省略其它代码
}
在MapGraph
中,显示范围光标与隐藏范围光标时,我们需要对格子进行设置,这样的好处是不必再分别保存光标了。
修改字段(Field):
///// <summary>
///// 移动范围光标集合
///// </summary>
//private List<MapCursor> m_MapMoveCursors = new List<MapCursor>();
///// <summary>
///// 攻击范围光标集合
///// </summary>
//private List<MapCursor> m_MapAttackCursors = new List<MapCursor>();
/// <summary>
/// 光标集合
/// </summary>
private HashSet<MapCursor> m_Cursors = new HashSet<MapCursor>();
修改显示光标函数(Show Cursor Method):
/// <summary>
/// 显示cursor
/// </summary>
/// <param name="cells"></param>
/// <param name="type"></param>
public void ShowRangeCursors(IEnumerable<CellData> cells, MapCursor.CursorType type)
{
if (type == MapCursor.CursorType.Mouse)
{
return;
}
foreach (CellData cell in cells)
{
MapCursor cursor = CreateMapObject(runtimeCursorPrefab, cell.position) as MapCursor;
if (cursor != null)
{
cursor.name = string.Format(
"{0} Cursor {1}",
type.ToString(),
cell.position.ToString());
cursor.cursorType = type;
if (type == MapCursor.CursorType.Move)
{
//m_MapMoveCursors.Add(cursor);
cell.hasMoveCursor = true;
}
else if (type == MapCursor.CursorType.Attack)
{
//m_MapAttackCursors.Add(cursor);
cell.hasAttackCursor = true;
}
m_Cursors.Add(cursor);
}
}
}
修改隐藏光标函数(Hide Cursor Method):
我们的光标已经由新的列表保存了,但我们更新格子信息不再这个函数里进行,而在光标本身进行。
/// <summary>
/// 隐藏cursor
/// </summary>
public void HideRangeCursors()
{
//if (m_MapMoveCursors.Count > 0)
//{
// for (int i = 0; i < m_MapMoveCursors.Count; i++)
// {
// ObjectPool.DespawnUnsafe(m_MapMoveCursors[i].gameObject, true);
// }
// m_MapMoveCursors.Clear();
//}
//if (m_MapAttackCursors.Count > 0)
//{
// for (int i = 0; i < m_MapAttackCursors.Count; i++)
// {
// ObjectPool.DespawnUnsafe(m_MapAttackCursors[i].gameObject, true);
// }
// m_MapAttackCursors.Clear();
//}
if (m_Cursors.Count > 0)
{
foreach (MapCursor cursor in m_Cursors)
{
ObjectPool.DespawnUnsafe(cursor.gameObject, true);
}
m_Cursors.Clear();
}
}
我们在光标本身进行关闭开关,打开MapCursor.cs
,继承OnDespawn
方法:
public override void OnDespawn()
{
if (map != null && mapObjectType == MapObjectType.Cursor)
{
CellData cellData = map.GetCellData(cellPosition);
if (cellData != null)
{
cellData.hasCursor = false;
}
}
base.OnDespawn();
}
在EditorTestPathFinding
中,我们需要修改的地方不是很多,在搜寻移动范围时,可以让我们不用再保存显示的格子数据m_CursorCells
了。
在搜索移动范围后,我们二次搜索攻击范围使用的是各种cells.Contains
方法,这相对来说已经非常消耗性能了,经过属性的修改,我们不用再使用它了。而且在我们不需要顺序,这样性能不如用HashSet
来好。
删除或注释一切与m_CursorCells
有关的代码,然后修改移动范围代码:
/// <summary>
/// 生成Cursors
/// </summary>
/// <param name="cells"></param>
/// <param name="atk"></param>
public void CreateTestCursors(IEnumerable<CellData> cells, bool atk)
{
// 省略
}
/// <summary>
/// 当左键按下时,Move类型的活动
/// </summary>
/// <param name="selectedCell"></param>
/// <returns></returns>
public List<CellData> ShowMoveRangeCells(CellData selectedCell)
{
List<CellData> cells;
//if (m_CursorCells.Count == 0 || !m_CursorCells.Contains(selectedCell))
if (!selectedCell.hasMoveCursor)
{
if (m_DebugInfo)
{
Debug.LogFormat("MoveRange: start {0}, move point {1}, status ({2})",
selectedCell.position.ToString(),
m_MovePoint.ToString(),
selectedCell.GetStatus().ToString());
}
ClearTestCursors();
//m_CursorCells.Clear();
m_TestClass.UpdatePosition(selectedCell.position);
cells = new List<CellData>(m_Map.SearchMoveRange(selectedCell, m_MovePoint, m_MoveConsumption));
//m_CursorCells.AddRange(cells);
CreateTestCursors(cells, false);
if (m_PathfindingType == TestPathfindingType.MoveAndAttack)
{
// 移动范围后,进行查找攻击范围
HashSet<CellData> attackCells = new HashSet<CellData>();
foreach (var cell in cells.ToArray())
{
//foreach (var c in m_Map.SearchAttackRange(cell, m_AttackRange.x, m_AttackRange.y, true))
//{
// //if (!cells.Contains(c) && !attackCells.Contains(c))
// if (!c.hasCursor)
// {
// attackCells.Add(c);
// }
//}
attackCells.UnionWith(
m_Map.SearchAttackRange(cell, m_AttackRange.x, m_AttackRange.y, true)
.Where(c => !c.hasCursor));
}
CreateTestCursors(attackCells, true);
}
}
else
{
if (m_DebugInfo)
{
Debug.LogFormat("Selected end {0} status ({1})",
selectedCell.position,
selectedCell.GetStatus().ToString());
}
ClearTestCursors();
//m_CursorCells.Clear();
Stack<CellData> pathCells = m_Map.searchPath.BuildPath(selectedCell);
cells = new List<CellData>(pathCells);
m_TestClass.animatorController.PlayMove();
m_TestClass.StartMove(pathCells);
CreateTestCursors(cells, false);
}
return cells;
}
在测试代码中,我们移动范围的方法看起来已经比较完善了,经过少许修改就可以移动到MapGraph
中。只不过我们还没有数据,需要数据的地方先保留。
1 创建方法:
/// <summary>
/// 搜寻移动范围与攻击范围
/// </summary>
/// <param name="cls"></param>
/// <param name="nAtk">是否包含攻击范围</param>
/// <param name="moveCells"></param>
/// <param name="atkCells"></param>
/// <returns></returns>
public bool SearchMoveRange(
MapClass cls,
bool nAtk,
out IEnumerable<CellData> moveCells,
out IEnumerable<CellData> atkCells)
{
moveCells = null;
atkCells = null;
if (cls == null)
{
Debug.LogErrorFormat("MapGraph -> SearchMoveRange: `cls` is null.");
return false;
}
CellData cell = GetCellData(cls.cellPosition);
if (cell == null)
{
Debug.LogErrorFormat("MapGraph -> SearchMoveRange: `cls.cellPosition` is out of range.");
return false;
}
// TODO
return true;
}
2 搜寻移动范围
// TODO 搜索移动范围,从MapClass中读取数据
float movePoint = 0;
MoveConsumption consumption = null;
List<CellData> rangeCells = SearchMoveRange(cell, movePoint, consumption);
if (rangeCells == null)
{
return false;
}
moveCells = rangeCells.ToArray();
3 搜寻攻击范围
if (nAtk /* TODO && 是否有武器 */)
{
// TODO 搜索攻击范围,从MapClass中读取数据
Vector2Int atkRange = Vector2Int.one;
HashSet<CellData> atkRangeCells = new HashSet<CellData>();
foreach (CellData moveCell in moveCells)
{
rangeCells = SearchAttackRange(moveCell, atkRange.x, atkRange.y, true);
if (rangeCells != null && rangeCells.Count > 0)
{
atkRangeCells.UnionWith(rangeCells.Where(c => !c.hasCursor));
}
}
atkCells = atkRangeCells;
}
在二次搜索范围的时候,我们使用了HastSet
来更快的添加光标。在HashSet
内部判断时,我们不必使用整个CellData
,使用坐标即可。
private CellPositionEqualityComparer m_CellPositionEqualityComparer = new CellPositionEqualityComparer();
/// <summary>
/// 判断两个Cell的Position是否相等
/// </summary>
public CellPositionEqualityComparer cellPositionEqualityComparer
{
get
{
if (m_CellPositionEqualityComparer == null)
{
m_CellPositionEqualityComparer = new CellPositionEqualityComparer();
}
return m_CellPositionEqualityComparer;
}
}
/// <summary>
/// 判断两个Cell的Position是否相等
/// </summary>
public class CellPositionEqualityComparer : IEqualityComparer<CellData>
{
public bool Equals(CellData x, CellData y)
{
return x.position == y.position;
}
public int GetHashCode(CellData obj)
{
return obj.position.GetHashCode();
}
}
在创建时使用:
HashSet<CellData> atkRangeCells = new HashSet<CellData>(cellPositionEqualityComparer);
/// <summary>
/// 搜寻和显示范围
/// </summary>
/// <param name="cls"></param>
/// <param name="nAtk">包含攻击范围</param>
/// <returns></returns>
public bool SearchAndShowMoveRange(MapClass cls, bool nAtk)
{
IEnumerable<CellData> moveCells, atkCells;
if (!SearchMoveRange(cls, nAtk, out moveCells, out atkCells))
{
return false;
}
if (moveCells != null)
{
ShowRangeCursors(moveCells, MapCursor.CursorType.Move);
}
if (atkCells != null)
{
ShowRangeCursors(atkCells, MapCursor.CursorType.Attack);
}
return true;
}
我们的对象池似乎有bug,查看了源代码有些错误(暂时不影响使用):
PoolManager
的DestroyPool
方法判断写错了;
在InstanceCollection
的Dispose
中没有判断是否为空。
这暂时不影响我们,但请注意,在Destroy
掉我们的Pool之前,最好要Despawn
掉Pool中的所有Object。