写在前面
- 本次项目Github地址:传送门
- 本次项目的视频演示地址(相比之前增加智能提示的步骤):传送门
- 项目的详细内容见潘老师的课程网站:网站链接
- 上一个版本的内容,查看我之前的博客:牧师与魔鬼动作分离版
实验内容
- 实现状态图的自动生成
- 讲解图数据在程序中的表示方法
- 利用算法实现下一步的计算
- 参考:P&D 过河游戏智能帮助实现
(跑过了自己的算法之后,发现在参考博客里面,发现其实有个地方是有错的。它的状态图是用起始岸的魔鬼与牧师数来表示的,另一边岸就可以通过3减去起始岸的角色数量来得到。)
截自参考博客的状态图:
了解游戏规则都知道,这个状态是不能存在的,因为另一边就是1P2D,游戏结束了。
整个状态图里面,除非两者都是2,否则不可能出现2P这样的状态。这个应该是博主的一个小错误。
状态图自动生成(使用DFS)
自动生成过程可以利用搜索算法来实现,实际上我们都可以知道整个状态图的状态数其实不是很多(毕竟要适合用户玩,游戏难度本来就不太高),所以搜索过程实际上也是很快就能够得出解的。而关键在于如何设计状态的转移,如何将其程序实现实现?首先搜索算法中需要表示每一个状态,然后就是状态到状态的转移的表示,最后就是算法的设计(包括Closed表、最佳路径等的生成)。
1. 状态表示
每一个状态都可以看成由两个部分组成:角色的数量、船的位置。
角色的数量又可以看情况分为:河两岸的人数、每一边牧师数量和魔鬼的数量
由于是深度优先搜索,所以还需要记录到下一个节点的状态,类似树的结构。为了方便各个状态之间的双向转移,可以构建一个双向链表,指向父节点。
结构体如下:
public class State{
public int priest;
public int devil;
public bool boat;
public State parent; // 记录深搜时从哪一个状态扩展出来,没什么重要用途
public State best_way; //最佳路径,遍历全部状态后得到一条通向解的路径
public State() {}
public State(int p, int d, bool b) {
this.priest = p;
this.devil = d;
this.boat = b;
}
public State(int p, int d, bool b, State par) {
this.priest = p;
this.devil = d;
this.boat = b;
this.parent = par;
}
public State(State copy) {
this.priest = copy.priest;
this.devil = copy.devil;
this.boat = copy.boat;
this.parent = copy.parent;
this.best_way = copy.best_way;
}
public bool isEqual(State compare) {
return this.priest==compare.priest && this.devil==compare.devil && this.boat == compare.boat;
}
// override object.Equals
public override bool Equals(object obj)
{
if (obj == null || GetType() != obj.GetType())
{
return false;
}
State tmp = (State)obj;
return this.priest==tmp.priest && this.devil==tmp.devil && this.boat == tmp.boat;
}
// override object.GetHashCode
public override int GetHashCode()
{
throw new System.NotImplementedException();
}
public override String ToString() {
if (best_way == null) {
return "priest: " + priest.ToString() + " devil: " + devil.ToString() + " boat: " + boat.ToString() +
"\nNext: " + "NULL";
}
return "priest: " + priest.ToString() + " devil: " + devil.ToString() + " boat: " + boat.ToString() +
"\nNext: " + best_way.priest.ToString() + " " + best_way.devil.ToString() + " " + best_way.boat.ToString();
}
}
这个类定义了上述可以表示状态的一些变量,表示牧师与魔鬼的数量时,只需要记录起始岸边(左边河岸)的数量就好的,因为总数已知,所以可以通过总数减左边河岸得到右边河岸的数量,没有必要再另外存储。
定义了几个不同签名的构造函数,方便创建状态。
重载了Equals
函数,便于使用List等集合结构来存储。
重载了ToString
函数, 便于打印当前状态的信息。
2.DFS算法实现
DFS只需要从一个状态转移到另一个状态,就需要定义转移的操作。
- 可以明确的是一定需要有人在船上,才能发生转移;
- 有船的岸边才能载人;
- 而且船的转移一定是从一个岸边转移到另一个岸边;
除了以上固定的转移规则,其余的规则定义如下:
- 一次转移一个牧师/两个牧师
- 一次转移一个魔鬼/两个魔鬼
- 一次转移一个魔鬼和一个牧师
转移的方法就是将有船的一边人数减少,另一边人数增加;但是我们状态只记录了左岸的人数,所以当船在左岸时,发生转移则人数减少;当船在右岸时,发生转移则人数增加。
每次深搜就是从这些转移的状态中找一个,继续搜索下去,注意只能是有效状态才能够继续搜索。有效状态的定义就是没有触发游戏结束条件的状态。
算法类代码:
public class AI {
public static List<State> closed = new List<State>();
public static State end = new State(0, 0, true);
public bool isFind = false;
public bool DFS(ref State root) {
closed.Add(root);
if (root.isEqual(end)) {
isFind = true;
}
for (int i = 0; i < 5; i ++) {
State next = nextState(root, i);
if (next != null) {
if (closed.Contains(next))
continue;
next.parent = root;
if (isFind) {
next.best_way = root;
}
else {
closed.Remove(root);
root.best_way = next;
closed.Add(root);
}
DFS(ref next);
}
}
if (!root.isEqual(end) && root.best_way == null) {
root.best_way = root.parent;
}
return isFind;
}
public void print() {
for (int i = 0; i < closed.Count; i ++) {
Debug.Log(closed[i].ToString());
}
}
public static bool isValid(State s) {
if (s.priest != 0 && s.priest < s.devil) { // 左边有牧师且 牧师人数不应少于魔鬼
return false;
}
if (s.priest != 3 && (3-s.priest) < (3-s.devil)) { //右边有牧师且 牧师人数不应少于魔鬼
return false;
}
return true;
}
public State nextState(State s, int operation) {
int p, d;
bool b;
p = s.priest;
d = s.devil;
b = s.boat;
State next = null;
if (b) { // 船在右方
if (operation == 0) {
if (3-p >= 1) { // 右方牧师大于1人,可过
next = new State(p+1, d, !b);
}
else {
return null;
}
}
else if (operation == 1) {
if (3-p >= 2) { // 右方牧师大于2人,可过
next = new State(p+2, d, !b);
}
else {
return null;
}
}
else if (operation == 2) {
if (3-d >= 1) { // 右方魔鬼大于1人,可过
next = new State(p, d+1, !b);
}
else {
return null;
}
}
else if (operation == 3) {
if (3-d >= 2) { // 右方魔鬼大于1人,可过
next = new State(p, d+2, !b);
}
else {
return null;
}
}
else if (operation == 4) {
if(3-p >= 1 && 3-d >= 1) {
next = new State(p+1, d+1, !b);
}
else {
return null;
}
}
}
else { // 船在左方
if (operation == 0) {
if (p >= 1) {
next = new State(p-1, d, !b);
}
else {
return null;
}
}
else if (operation == 1) {
if (p >= 2) {
next = new State(p-2, d, !b);
}
else {
return null;
}
}
else if (operation == 2) {
if (d >= 1) {
next = new State(p, d-1, !b);
}
else {
return null;
}
}
else if (operation == 3) {
if (d >= 2) {
next = new State(p, d-2, !b);
}
else {
return null;
}
}
else if (operation == 4) {
if (p >= 1 && d >= 1) {
next = new State(p-1, d-1, !b);
}
else {
return null;
}
}
}
if (isValid(next)) {
return next;
}
return null;
}
}
创建一个closed表,存放已经访问过的节点。搜索过程中,利用list.contain来判断当前状态是否已经访问过,如果访问过就不再拓展。
深搜的过程相信都很熟悉,就不再展开。只不过这里的深搜实际上需要遍历到所有的状态,即使找到了一条正确的状态转移路径也不会马上停止,而是需要找到所有状态,并且找出它下一步的最佳走法。
比如说:一个状态无法在往下扩展,所以他的最佳状态就只能是他的父节点状态。
对于一个父节点,最佳状态就是当前在搜索的那一条路径。如果这条路径又回溯回来,就将最佳状态设为下一条搜索路径。如果这个节点被访问过,但是状态又发生改变的话,就需要从closed表中取出,再重新加入。
至于对下一个状态的寻找,主要是分别根据以上所列几种状态转移来判断,如果没有足够的人转移,则返回;状态生成后,还需要判断牧师与魔鬼的数量是否符合规则,如果不符合返回null。
搜索结束后,得到的结果全部存在了closed表中,closed表中存放的是一个个状态,每个状态都包含了自身信息,以及下一个最佳转移状态。通过这个转移,就可以得到一条通向结果的路径。
3.DFS生成结果
将closed表中的元素全部打印出来得到以下结果:(牧师魔鬼的数量只有在左边岸上的数量,船的状态:False表示在左岸,True表示在右岸)
priest: 3 devil: 2 boat: True
Next: 3 3 False
priest: 3 devil: 3 boat: False
Next: 3 1 True
priest: 3 devil: 1 boat: True
Next: 3 2 False
priest: 2 devil: 2 boat: True
Next: 3 2 False
priest: 3 devil: 2 boat: False
Next: 3 0 True
priest: 3 devil: 0 boat: True
Next: 3 1 False
priest: 3 devil: 1 boat: False
Next: 1 1 True
priest: 1 devil: 1 boat: True
Next: 2 2 False
priest: 2 devil: 2 boat: False
Next: 0 2 True
priest: 0 devil: 2 boat: True
Next: 0 3 False
priest: 0 devil: 3 boat: False
Next: 0 1 True
priest: 0 devil: 1 boat: True
Next: 1 1 False
priest: 1 devil: 1 boat: False
Next: 0 0 True
priest: 0 devil: 0 boat: True
Next: NULL
priest: 0 devil: 1 boat: False
Next: 0 0 True
priest: 0 devil: 2 boat: False
Next: 0 0 True
为了更直观地看结果,我按照以上信息,做了一个图:
箭头方向代表寻找最优解的路径方向,每一个状态都有一个最优的转移状态,这也是智能提示所做的工作:帮助玩家从当前状态更快走到结束状态。也就是判断当前玩家的状态,然后根据next来进行转移。
更改Controller
在Controller开始,就通过AI的类,使用DFS计算出所有状态的转移路径,这样就会存在AI类中的closed表里面,随时可以取用。
实现交互功能,首先需要添加一个新的接口,也就是我们新加的功能,并且实现它:
public void getTips() {
if (forbid) return;
if (boat.getCount()[0] != 0 || boat.getCount()[1] != 0) {
for (int i = 0; i < 2; i ++) {
if (boat.getChar(i) != null)
setCharacterPosition(boat.getChar(i));
}
}
int[] count = leftBank.getCount();
int d = count[0];
int p = count[1];
bool b = boat.getLR()==1;
State current = new State(p,d,b);
State next = AI.closed.Find((State s) => {return s.isEqual(current);}).best_way;
Debug.Log("current: " + current);
Debug.Log("next: " + next);
if (next == null) return;
if (b) {
int d2 = next.devil - d;
int p2 = next.priest - p;
while (d2 > 0 || p2 > 0) {
for (int i = 0; i < 6; i ++) {
if (characters[i].getBank() != null && characters[i].getBank().getLR() == 1) {
if (d2 > 0 && characters[i].getMan() == "Devil") {
setCharacterPosition(characters[i]);
d2 --;
break;
}
if (p2 > 0 && characters[i].getMan() == "Priest") {
setCharacterPosition(characters[i]);
p2 --;
break;
}
}
if (i==5){
Debug.Log("Err");
return;
}
}
}
}
else {
int d2 = -next.devil + d;
int p2 = -next.priest + p;
while (d2 > 0 || p2 > 0) {
for (int i = 0; i < 6; i ++) {
if (characters[i].getBank() != null && characters[i].getBank().getLR() == 0) {
if (d2 > 0 && characters[i].getMan() == "Devil") {
setCharacterPosition(characters[i]);
d2 --;
break;
}
if (p2 > 0 && characters[i].getMan() == "Priest") {
setCharacterPosition(characters[i]);
p2 --;
break;
}
}
if (i==5){
Debug.Log("Err");
return;
}
}
}
}
MoveBoat();
}
首先统计当前人数以确定当前状态,为了方便统计,所以需要先把船上的角色先重新移回岸上(之前的接口设计不完善),由于之前移动角色是用到了动作,有一个时间的问题,这里代码是连续执行的,就会起矛盾,因为这里直接调用了move的动作函数,但是又不能直接设置回调,所以难以修改。所以新建了一个函数,直接改变角色的位置,取消了动作执行的过程。
统计人数并且得出状态后,根据状态的next,构建一个目标状态,根据这个目标的状态选择上下船的人数,最后执行moveBoat()完成一次提示。而这个接口可以绑定在UI的一个按钮上(使用IMGUI实现),然后按钮被调用就执行提示。