3D游戏编程与设计 P&D(牧师与恶魔) 过河游戏智能帮助实现
文章目录
牧师与恶魔(游戏智能版) 完整游戏过程可见以下视频:
https://www.bilibili.com/video/BV1BV411h7oW/
牧师与恶魔(游戏智能版) 完整代码可见以下仓库:
https://gitee.com/beilineili/game3-d/tree/master/10.PD_AI
一、作业与练习
P&D 过河游戏智能帮助实现,程序具体要求:
- 实现状态图的自动生成
- 讲解图数据在程序中的表示方法
- 利用算法实现下一步的计算
- 参考: P&D 过河游戏智能帮助实现
二、设计简述
- 游戏智能,可以理解为在游戏规则约束下,通过适当的算法使得游戏中 NPC 呈现为具有一定人类智能行为的博弈对手,让游戏玩家面临不间断的挑战,并在挑战中有所收获。接下来我们将尝试在《牧师与恶魔》游戏中实现智能
- 为了帮助小朋友玩 P&D 过河,决定开发 next 功能,提示下一步最佳玩法?但怎么设计呢?
因此,需要将牧师与魔鬼过河问题抽象为状态图:
1. 状态图基础
- 状态图是描述一个实体基于事件反应的动态行为,显示了该实体如何根据当前所处的状态对不同的事件做出反应。
2. 状态图表示方式
- 状态图数据如何在代码里表示?
- 可以用邻接矩阵或邻接表。笔者选用了邻接表来实现,邻接表是图的一种链式存储结构。比如对于图 G 中的每个顶点 vi,把所有邻接 vi 的顶点 vj 连接成一个链表,这个链表就是节点 vi 的邻接表。
3. 牧师与恶魔状态图
- 以下参考了师兄博客里的状态图
- 该状态图记录了游戏过程中左岸的情况。其中 P 代表 Priest 牧师,D 代表 Devil 魔鬼,B 代表 Boat 船。当船在右岸时不记录。双箭头代表两个状态可以相互转化。
- 观察状态图:
- 开始状态 [3P3DB];成功状态 [0P0D];失败状态 *
- 中间状态 [xPyDX], 其中 x>=y and [(3-x)>=(3-y) or (3-x)==0]
- 可能动作:{P,D,PP,PD,DD}
- 问题求解:
- 对于任意非成功/失败状态,找一条最短路径到达成功状态
- 在牧师与恶魔这个游戏中,实现游戏智能的方式是先创建一个有向图,每次要提示就通过当前状态来找到在图中的位置,分析当前场景的状态,随后读取能到达终点路径上的下一个状态,执行相应动作以达到下一个状态。
- 牧师数目和恶魔数目的取值范围均是 [0,3],即每个身份的数目取值可能个数为4。以初始状态为例:3P3DB。代表左岸有3个牧师,3个魔鬼,船也在左岸。下一个状态有4种,其中一个执行后会输掉游戏,忽略。在剩下三个状态中,只有 2P2D 和 3P1D 是成功路径上的状态,可以随机任选一个
4. 路径搜索方法
- 笔者选择宽度优先搜索算法 BFS,它的基本原理如下:
- 从初始结点开始,生成第一层结点,检查目标结点是否在这些结点中,如果没有,再将所有这一层的结点逐一扩展,得到第二层结点,如此依次扩展,直到发现目标结点为止
- 它的算法流程如下:
- 当前状态进入队列。
- 队列不为空:队头出队。
- 访问状态节点。
- 当状态节点为结束状态时,回溯至开始状态;否则计算下一个合法状态并压入队列。
三、代码设计
1. 游戏对象的位置 Position
- 对比之前实现的 牧师与恶魔普通版,现在将游戏中对象的位置统一用一个类来存储,方便修改对象的位置
- 包括左右两侧陆地的位置,河水位置,船在左右侧的位置,角色分别在陆地和船上的位置
// 储存游戏当中对象的位置
public class Position
{
// 右侧岸的位置
public static Vector3 rightLand = new Vector3(15, -4, 0);
// 左侧岸的位置
public static Vector3 leftLand = new Vector3(-13, -4, 0);
// 河流的位置
public static Vector3 water = new Vector3(1, -(float) 5.5, 0);
// 船在右边的位置
public static Vector3 rightBoat = new Vector3(7, -(float) 3.8, 0);
// 船在左边的位置
public static Vector3 leftBoat = new Vector3(-5, -(float) 3.8, 0);
// 角色在陆地上的相对位置
public static Vector3[]
landCharacters =
new Vector3[] {
new Vector3((float) - 0.2, (float) 0.7, 0),
new Vector3((float) - 0.1, (float) 0.7, 0),
new Vector3(0, (float) 0.7, 0),
new Vector3((float) 0.1, (float) 0.7, 0),
new Vector3((float) 0.2, (float) 0.7, 0),
new Vector3((float) 0.3, (float) 0.7, 0)
};
// 角色在船上的相对位置
public static Vector3[]
boatCharacters =
new Vector3[] {
new Vector3((float) - 0.1, (float) 1.2, 0),
new Vector3((float) 0.2, (float) 1.2, 0)
};
}
2. 裁判 JudgeController
- 裁判类用于检测当前游戏是否胜利或者失败,如果游戏结束则通知 FirstController 处理
public class JudgeController : MonoBehaviour
{
public FirstController mainController;
public Land leftLand;
public Land rightLand;
public Boat boat;
void Start()
{
mainController =
(FirstController) SSDirector.GetInstance().CurrentSenceController;
this.leftLand = mainController.leftLandController.GetLand();
this.rightLand = mainController.rightLandController.GetLand();
this.boat = mainController.boatController.GetBoat();
}
public void Init()
{
mainController =
(FirstController) SSDirector.GetInstance().CurrentSenceController;
this.leftLand = mainController.leftLandController.GetLand();
this.rightLand = mainController.rightLandController.GetLand();
this.boat = mainController.boatController.GetBoat();
}
void Update()
{
if (!mainController.isRuning) return;
this.gameObject.GetComponent<UserGUI>().gameMessage = "";
// 判断是否已经胜利
if (rightLand.priestNum == 3)
{
mainController.JudgeCallback(false, "You Win!");
return;
}
else
{
// 判断是否已经失败
int
leftPriestNum,
leftDevilNum,
rightPriestNum,
rightDevilNum;
leftPriestNum =
leftLand.priestNum + (boat.isRight ? 0 : boat.priestNum);
leftDevilNum =
leftLand.devilNum + (boat.isRight ? 0 : boat.devilNum);
// 若任意一侧,牧师数量不为 0 且牧师数量少于恶魔数量,则游戏失败
if (leftPriestNum != 0 && leftPriestNum < leftDevilNum)
{
mainController.JudgeCallback(false, "You Lose!");
return;
}
rightPriestNum =
rightLand.priestNum + (boat.isRight ? boat.priestNum : 0);
rightDevilNum =
rightLand.devilNum + (boat.isRight ? boat.devilNum : 0);
if (rightPriestNum != 0 && rightPriestNum < rightDevilNum)
{
mainController.JudgeCallback(false, "You Lose!");
return;
}
}
}
}
3. 路径节点 RouteNode
-
将左右两岸的牧师、魔鬼的数量以及船的位置看作为游戏状态,我们将游戏状态封装成一个状态节点类,用于维护游戏状态。RouteNode 是路径上的节点,也是基本状态单元
-
设置常量表示各个参数
// 船上牧师
public static readonly int BOAT_PRIESTS = 0;
// 船上恶魔
public static readonly int BOAT_DEVILS = 1;
// 左岸牧师
public static readonly int LEFT_PRIESTS = 2;
// 右岸牧师
public static readonly int RIGHT_PRIESTS = 3;
// 左岸恶魔
public static readonly int LEFT_DEVILS = 4;
// 右岸恶魔
public static readonly int RIGHT_DEVILS = 5;
// 船的位置
public static readonly int BOAT_PLACE = 6;
// 保存状态参数
public int[] state;
- 获得当前状态,0,1,2 分别表示 游戏进行,游戏胜利,游戏结束
public int GetState()
{
// 所有牧师都到了右岸
if (state[RIGHT_PRIESTS] == 3)
{
return 1;
}
int
leftPriests,
leftDevils,
rightPriests,
rightDevils;
leftPriests =
state[LEFT_PRIESTS] + state[BOAT_PRIESTS] * (1 - state[BOAT_PLACE]);
leftDevils =
state[LEFT_DEVILS] + state[BOAT_DEVILS] * (1 - state[BOAT_PLACE]);
rightPriests =
state[RIGHT_PRIESTS] + (state[BOAT_PRIESTS] * state[BOAT_PLACE]);
rightDevils =
state[RIGHT_DEVILS] + (state[BOAT_DEVILS] * state[BOAT_PLACE]);
// 有一侧的牧师数量少于恶魔
if (
(leftPriests != 0 && leftPriests < leftDevils) ||
(rightPriests != 0 && rightPriests < rightDevils)
)
{
return 2;
}
// 游戏还未结束
return 0;
}
- 用两个指针分别用来记录路径的下一步和上一步
// 父节点
RouteNode parentNode;
// 下一节点
RouteNode nextNode;
- 节点可以用状态数组或其他节点初始构造
public RouteNode(int[] state)
{
parentNode = null;
nextNode = null;
this.state = (int[]) state.Clone();
}
public RouteNode(RouteNode other)
{
parentNode = other;
nextNode = null;
state = (int[]) other.state.Clone();
}
- 判断状态节点的变更是否有效
// 是否可以移动
public bool isValid(int currentState)
{
if (currentState == LEFT_PRIESTS || currentState == LEFT_DEVILS)
{
return (
state[BOAT_PLACE] == 0 &&
(state[BOAT_PRIESTS] + state[BOAT_DEVILS] < 2) &&
state[currentState] > 0
);
}
else if (currentState == RIGHT_PRIESTS || currentState == RIGHT_DEVILS)
{
return (
state[BOAT_PLACE] == 1 &&
(state[BOAT_PRIESTS] + state[BOAT_DEVILS] < 2) &&
state[currentState] > 0
);
}
else if (currentState == BOAT_PRIESTS || currentState == BOAT_DEVILS)
{
return (state[currentState] > 0);
}
else if (currentState == BOAT_PLACE)
{
return (state[BOAT_PRIESTS] + state[BOAT_DEVILS] > 0);
}
return false;
}
- 在状态图的节点上移动函数
public bool Step(int state)
{
bool isvalid = isValid(currentState);
if (!isvalid) return false;
if (currentState == LEFT_PRIESTS || currentState == RIGHT_PRIESTS)
{
state[currentState]--;
state[BOAT_PRIESTS]++;
}
else if (currentState == LEFT_DEVILS || currentState == RIGHT_DEVILS)
{
state[currentState]--;
state[BOAT_DEVILS]++;
}
else if (currentState == BOAT_PRIESTS)
{
state[currentState]--;
state[state[BOAT_PLACE] == 0 ? LEFT_PRIESTS : RIGHT_PRIESTS]++;
}
else if (currentState == BOAT_DEVILS)
{
state[currentState]--;
state[state[BOAT_PLACE] == 0 ? LEFT_DEVILS : RIGHT_DEVILS]++;
}
else if (currentState == BOAT_PLACE)
{
state[BOAT_PLACE] = 1 - state[BOAT_PLACE];
}
return true;
}
4. 寻找路径 RouteController
- 主要通过广度优先搜索算法 BFS 来寻找到达游戏胜利结果的状态路径
- 将每一个访问状态结点放入队列,每一个结点都可以到下一个节点或返回父亲结点,到最后可以生成一条到达目标状态的路径。
- 首先判断当前节点是否是终点,是则返回状态路径中的下一步
- 若不是,则将当前节点移出队列,遍历它的各种移动方式,将它的邻居节点(移动有效且还未访问过的)放入队列,再随机选取其中一个作为新的初始节点
- 如果队列不为空,则重复以上步骤;如果为空且当前节点不是终点,则无解
// 路径的当前状态节点
protected RouteNode currentNode;
// 保存从起始状态到达目标状态路径中的每一个状态节点
private Hashtable solutionRoute;
// 保存已发现但未访问的节点
private Queue<RouteNode> exploreList;
// 保存已发现的节点
private HashSet<int> visitedList;
- 以上广度优先搜索算法 BFS 的具体实现
public RouteNode BFSSearch(int[] nowState)
{
RouteNode destination = new RouteNode(nowState);
// 如果已经获得路径,则返回结果
if (
solutionRoute != null &&
solutionRoute.Contains(destination.GetHashCode())
) return (RouteNode) solutionRoute[destination.GetHashCode()];
currentNode = null;
solutionRoute = null;
exploreList = new Queue<RouteNode>();
visitedList = new HashSet<int>();
exploreList.Enqueue (destination);
visitedList.Add(destination.GetHashCode());
// 广度优先搜索
while (exploreList.Count != 0)
{
currentNode = exploreList.Peek();
if (currentNode.GetState() == 1)
{
GenerateRoute();
break;
}
exploreList.Dequeue();
RouteNode[] nextNodes =
new RouteNode[] {
new RouteNode(currentNode),
new RouteNode(currentNode),
new RouteNode(currentNode),
new RouteNode(currentNode),
new RouteNode(currentNode),
new RouteNode(currentNode),
new RouteNode(currentNode)
};
// 寻找所有与currentNode邻接且未访问的节点,添加到 exploreList
// 并加入已访问节点队列 visitedList
for (int i = 0; i < 7; i++)
{
if (nextNodes[i].Step(i))
{
if (
nextNodes[i].GetState() <= 1 &&
!visitedList.Contains(nextNodes[i].GetHashCode())
)
{
exploreList.Enqueue(nextNodes[i]);
visitedList.Add(nextNodes[i].GetHashCode());
}
}
}
}
return destination;
}
- 绘制路径,从终点沿着父节点一路回溯到起点,将这条解决问题的路径用 solutionRoute 存储
public void GenerateRoute()
{
if (solutionRoute == null && currentNode != null)
{
solutionRoute = new Hashtable();
RouteNode
cNode,
lNode;
cNode = currentNode;
lNode = null;
while (cNode != null)
{
solutionRoute.Add(cNode.GetHashCode(), cNode);
cNode.SetNext (lNode);
lNode = cNode;
cNode = cNode.GetParent();
}
}
}
5. 主控制器 FirstController
- 增加 ChangeState 函数,输入两个状态节点(起点和终点),完成状态转换,改变游戏对象的参数(位置移动)
private void ChangeState(RouteNode sourceNode, RouteNode destinationNode)
{
int[] change = new int[7];
// 得到转换状态
for (int i = 0; i < 7; i++)
{
change[i] = destinationNode.state[i] - sourceNode.state[i];
}
if (change[RouteNode.BOAT_PLACE] != 0)
{
// 船只移动
MoveBoat();
}
else if (change[RouteNode.BOAT_PRIESTS] < 0)
{
// 船上牧师移动
for (int i = 0; i < 2; i++)
{
if (
boatController.GetBoat().characters[i] != null &&
boatController.GetBoat().characters[i].isPriest
)
{
MoveCharacter(boatController.GetBoat().characters[i]);
break;
}
}
}
else if (change[RouteNode.BOAT_DEVILS] < 0)
{
// 船上恶魔移动
for (int i = 0; i < 2; i++)
{
if (
boatController.GetBoat().characters[i] != null &&
!boatController.GetBoat().characters[i].isPriest
)
{
MoveCharacter(boatController.GetBoat().characters[i]);
break;
}
}
}
else if (change[RouteNode.LEFT_PRIESTS] < 0)
{
//左岸牧师移动
for (int i = 0; i < 6; i++)
{
if (
characterControllers[i].GetCharacter().isPriest &&
!characterControllers[i].GetCharacter().isInBoat &&
!characterControllers[i].GetCharacter().isRight
)
{
MoveCharacter(characterControllers[i].GetCharacter());
break;
}
}
}
else if (change[RouteNode.LEFT_DEVILS] < 0)
{
//左岸恶魔移动
for (int i = 0; i < 6; i++)
{
if (
!characterControllers[i].GetCharacter().isPriest &&
!characterControllers[i].GetCharacter().isInBoat &&
!characterControllers[i].GetCharacter().isRight
)
{
MoveCharacter(characterControllers[i].GetCharacter());
break;
}
}
}
else if (change[RouteNode.RIGHT_PRIESTS] < 0)
{
//右岸牧师移动
for (int i = 0; i < 6; i++)
{
if (
characterControllers[i].GetCharacter().isPriest &&
!characterControllers[i].GetCharacter().isInBoat &&
characterControllers[i].GetCharacter().isRight
)
{
MoveCharacter(characterControllers[i].GetCharacter());
break;
}
}
}
else if (change[RouteNode.RIGHT_DEVILS] < 0)
{
//右岸恶魔移动
for (int i = 0; i < 6; i++)
{
if (
!characterControllers[i].GetCharacter().isPriest &&
!characterControllers[i].GetCharacter().isInBoat &&
characterControllers[i].GetCharacter().isRight
)
{
MoveCharacter(characterControllers[i].GetCharacter());
break;
}
}
}
}
- 增加 NextStep 函数,将当前的各个游戏对象的数量、位置,转换为状态传给 RouteController 生成路径,然后根据这条路径上当前节点和下一节点,作为参数传给 ChangeState 进行状态转换,改变游戏对象的参数
public void NextStep()
{
RouteNode node =
RouteNodeController
.BFSSearch(new int[] {
boatController.GetBoat().priestNum,
boatController.GetBoat().devilNum,
leftLandController.GetLand().priestNum,
rightLandController.GetLand().priestNum,
leftLandController.GetLand().devilNum,
rightLandController.GetLand().devilNum,
boatController.GetBoat().isRight ? 1 : 0
});
if (node.GetState() == 0)
{
RouteNode nNode = node.GetNext();
ChangeState (node, nNode);
}
}
四、游戏效果
- 以上代码增加了一个 “下一步” 按钮,当游戏未结束且用户点击此按钮时,执行 nextMove,完成下一步通往游戏胜利的状态移动。
- 全程按 “下一步” 按钮可以获得游戏胜利
五、心得
- 这次作业让我意识到,利用好状态图,是实现游戏智能的一种有效方法,当然这也是建立在牧师与恶魔游戏的游戏结果明确,中间过程状态数较少的前提下,如果游戏更加复杂还能不能用状态图来模拟游戏智能就不一定了。
- 总的来说,这次练习只是用了最简单的 if-else 来实现开发者事先做好的状态图,远远不是真正意义上的人工智能,对于玩家看来它非常神奇,觉得机器真聪明。但对于开发者,这种所谓的 “智能技术” 就是忽悠人