BNB AI设计
概述
在本次python的项目中,我们小组顺利的完成了游戏泡泡堂的基本功能,总代码量接近5k行,并在游戏泡泡堂的基本功能上实现了创新和拓展。选择泡泡堂作为python课程的项目题目是因为泡泡堂这一游戏,本身被多数玩家青睐,自2003年泡泡堂创建至今,泡泡堂就一直流行于中国甚至世界。因此,在学习了python基础之后,我们小组选择实现泡泡堂这个游戏,在实现过程中能帮助我们更好地掌握python,了解python相关的工具和环境,理解python的特性。在实现方面,泡泡堂有很大的扩展空间,在各个功能模块上面也容易区分开来,整个项目能够由简到繁,使得小组内的每个成员能够很好的完成自己的工作。
功能
游戏截图
AI设计
这篇博客将与大家分享BNB项目过程中的AI设计,AI模块设计代码量394行。所分享的内容仅为此次BNB项目AI设计的重点部分,要想设计出一个较为完整的AI还需要许多的功能和长时间的Debug。下面将依次介绍设计过程中的重点部分,非常欢迎各位博友一起讨论。
a) 设计思路
b) 计算安全区域
c) 判断当前位置放泡泡是否安全
d) 寻找路径
e) 按照路径移动
AI设计流程图
设计思路
我们希望游戏中的AI能够存活足够长的时间,并且能够在此基础上开辟通到玩家的路径,离玩家越来越近,当与玩家足够接近时尝试放泡泡将玩家炸死。游戏中的AI能够首先根据周围的地形、整体的局势来决定下一步该怎么做。AI将会根据环境来计算出一个合适的路径,接着根据路径移动,在每一步移动中都会刷新路径,达到AI实时反应的效果。
为达到这个目标,AI在每一步行动中会采取如下策略。
a) 计算安全区域和危险区域。
b) 计算玩家位置。
c) 寻找AI到达玩家的路径,同时判定AI与玩家是否连通。
d) 判断AI当前的位置是否安全。
e) 若AI当前位置危险,规避泡泡,计算路径。
否则若AI与玩家不连通,炸箱子,计算路径。
否则若AI与玩家连通,攻击玩家,计算路径。
f) 判断AI是否需要推箱子。若AI需要推箱子,则推箱子,计算路径。
g) 根据路径移动
def control(self):
"""运行AI"""
self.compute_safe_region()
self.compute_player_pos()
self.find_path("JudgeReachable")
self.judge_evade()
if self.evade:
self.find_path("EvadeBubble")
elif not self.attack:
self.find_path("FindBox")
else:
self.find_path("FindPlayer")
self.judge_push()
if self.push:
self.find_path("PushBox")
self.move()
self.kill()
计算安全区域
AI运行的第一步就是计算安全区域。先说明在此函数中定义的数据结构,record是一个二维列表,表示当前的地图情况,列表中存储布尔类型,True为可走,False为不可走。那么哪些地方是可走的、哪些地方是不可走的呢?在我们的地图中,如果某个格子有障碍物或超出了边界,那么这个格子当然是不可走的,为False。还有一种情况的格子也是不可走的,就是有水柱的格子和即将出现水柱的格子。除此之外其它格子为安全格子。
def compute_safe_region(self):
"""计算安全区域与危险区域,存储在record中"""
delay = 10
for i in range(1, self.screen_x + 1):
for j in range(1, self.screen_y + 1):
if self.plat.f1[i][j] == None or isinstance(self.plat.f1[i][j], Prop):
if self.record_count[i][j] >= delay:
self.record[i][j] = True
self.record_count[i][j] = 0
else:
self.record_count[i][j] += 1
else:
self.record[i][j] = False
self.record_count[i][j] = 0
for i in range(1, self.screen_x + 1):
for j in range(1, self.screen_y + 1):
if type(self.plat.f1[i][j]) == Bubble or type(self.plat.f1[i][j]) == TimingBubble:
# 对泡泡四个方向的水柱区域
for k in range(0, self.plat.f1[i][j].field + 1):
if j - k >= 1:
self.record[i][j - k] = False
self.record_count[i][j - k] = 0
if j + k <= self.screen_y:
self.record[i][j + k] = False
self.record_count[i][j + k] = 0
if i - k >= 1:
self.record[i - k][j] = False
self.record_count[i - k][j] = 0
if i + k <= self.screen_x:
self.record[i + k][j] = False
self.record_count[i + k][j] = 0
判断当前位置放泡泡是否安全
这个函数是为了让AI不炸死自己而设计的。函数的功能为判断当前位置放泡泡是否安全。在该函数中,我定义了两种数据,一个是若放泡泡,泡泡波及的范围,另一个是若放泡泡,AI的逃脱范围。在逃脱范围的十字中,若存在一个拐角,那么AI是能够逃脱的。此外,若在泡泡波及范围之外有安全的区域,则AI也是能够逃脱的。其它情况都是不能逃脱的。
def try_bubble(self, x, y):
"""
判断当前位置放泡泡是否安全
spout_range 表示若放泡泡,该泡泡波及的范围
escape_range 表示若放泡泡,逃脱的范围
"""
# 左右上下
spout_range = [0, 0, 0, 0]
escape_range = [0, 0, 0, 0]
# 计算若放泡泡,泡泡波及的范围
for i in range(0, 4):
for j in range(1, self.power + 2):
if i == 0 and not self.check_f1(x - j, y):
break
elif i == 1 and not self.check_f1(x + j, y):
break
elif i == 2 and not self.check_f1(x, y - j):
break
elif i == 3 and not self.check_f1(x, y + j):
break
if i == 0: spout_range[i] = x - (j - 1)
elif i == 1: spout_range[i] = x + (j - 1)
elif i == 2: spout_range[i] = y - (j - 1)
elif i == 3: spout_range[i] = y + (j - 1)
# 计算若放泡泡,AI的逃脱范围
for i in range(0, 4):
for j in range(1, self.power + 2):
if i == 0 and not self.check_record(x - j, y):
break
elif i == 1 and not self.check_record(x + j, y):
break
elif i == 2 and not self.check_record(x, y - j):
break
elif i == 3 and not self.check_record(x, y + j):
break
if i == 0: escape_range[i] = x - (j - 1)
elif i == 1: escape_range[i] = x + (j - 1)
elif i == 2: escape_range[i] = y - (j - 1)
elif i == 3: escape_range[i] = y + (j - 1)
# 沿着可通行的十字,人物只要可以在任意一个地方中途跳出 (-1 / +1)就说明这个泡泡不会困住人的所有逃生路径
for i in range(1, x - escape_range[0] + 1):
if self.check_record(x - i, y + 1) or self.check_record(x - i, y - 1):
return True
for i in range(1, escape_range[1] - x + 1):
if self.check_record(x + i, y + 1) or self.check_record(x + i, y - 1):
return True
for i in range(1, y - escape_range[2] + 1):
if self.check_record(x - 1, y - i) or self.check_record(x + 1, y - i):
return True
for i in range(1, escape_range[3] - y + 1):
if self.check_record(x - 1, y + i) or self.check_record(x + 1, y + i):
return True
# 检查泡泡威力之外有没有可通行的地方
for i in range(0, 4):
if spout_range[i] != escape_range[i]:
return False
if self.check_record(spout_range[0] - 1, y) or self.check_record(spout_range[1] + 1, y) or self.check_record(
x, spout_range[2] - 1) or self.check_record(x, spout_range[3] + 1):
return True
return False
寻找路径
算法思路:AI依据参数option计算路径时,应用了BFS的思路。即判断在AI当前位置的上下左右四个方向的网格中,是否存在符合要求的网格,不同的option要求不同。若在AI的四周存在这样的网格,则将该网格坐标加入temp_grid[i][j]中,即从AI位置到达该位置(i, j)的路径。在遍历到目标位置时,结束BFS,将AI到该目标位置的路径赋值到self.path中,这样就完成了一次寻找路径。
为什么要选择BFS算法来搜索路径?第一点是基于性能的考虑,我们在图论中知道计算路径的算法有BFS、DFS、Floyd、Dijkstra这些算法,然而更加适用于网格地图的算法是BFS和DFS,从平均性能的角度出发,BFS性能是优于DFS的。第二点是基于Debug方面的考虑,使用BFS能更加清楚的了解算法的执行过程和数据结构的内容,便于测试和修改。
在遍历过程中,将可走的网格加入路径。当option为判断与玩家的连通性或者规避泡泡时,只要网格是空地或者是道具类型,那么该网格就是可走的。当option为寻找玩家或寻找箱子或推箱子时,如果网格是安全的,那么该网格才是可走的。
遍历的终止条件。由于option的不同,在计算路径的时候遍历的终止条件也将不同。当判断与玩家的连通性或寻找玩家时,若遍历到玩家位置,则终止。当规避泡泡时,若遍历到一个安全位置,则终止。当寻找箱子时,若遍历到一个周围有箱子的位置,并且在这个位置放泡泡不会将自己炸死,则终止。当推箱子时,若遍历到一个合适的推箱子位置,则终止。
def find_path(self, option):
"""
BFS
option = JudgeReachable 判断AI是否与玩家连通
option = EvadeBubble 规避所有泡泡
option = FindPlayer 寻找最近玩家
option = FindBox 寻找最近箱子
option = PushBox 计算推箱子路径
"""
# temp_grid[i][j] = [(), (), (), ...], 存储到达该位置的路径
temp_grid = [[[] for j in range(self.screen_y + 1)] for i in range(self.screen_x + 1)]
# queue = [(), (), (), ...], 存储单点位置
queue = [(self.grid_x, self.grid_y)]
# temp_grid[i][j] = [(), (), (), ...], 存储到达该位置的路径
temp_grid[self.grid_x][self.grid_y].append((self.grid_x, self.grid_y))
# visited[i][j] = bool, 存储某位置是否已遍历过
visited = [[False for j in range(self.screen_y + 1)] for i in range(self.screen_x + 1)]
# 记录搜索层数
search_count = 0
if option == "JudgeReachable":
self.reachable = False
while queue:
search_count += 1
if search_count > self.max_search_range:
return
cur = queue.pop(0)
visited[cur[0]][cur[1]] = True
# 遍历顺序 方向朝玩家
offset = [self.player_pos[0] - self.grid_x, self.player_pos[1] - self.grid_y]
# 右下左上
if offset[0] >= 0 and offset[1] >= 0:
next = [(cur[0] + 1, cur[1]), (cur[0], cur[1] + 1), (cur[0] - 1, cur[1]), (cur[0], cur[1] - 1)]
# 右上左下
elif offset[0] >= 0 and offset[1] < 0:
next = [(cur[0] + 1, cur[1]), (cur[0], cur[1] - 1), (cur[0] - 1, cur[1]), (cur[0], cur[1] + 1)]
# 左下右上
elif offset[0] < 0 and offset[1] >= 0:
next = [(cur[0] - 1, cur[1]), (cur[0], cur[1] + 1), (cur[0] + 1, cur[1]), (cur[0], cur[1] - 1)]
# 左上右下
elif offset[0] < 0 and offset[1] < 0:
next = [(cur[0] - 1, cur[1]), (cur[0], cur[1] - 1), (cur[0] + 1, cur[1]), (cur[0], cur[1] + 1)]
# 当遍历到目标位置时,结束
if option == "JudgeReachable":
if cur == self.player_pos:
self.reachable = True
return
elif option == "EvadeBubble":
if self.record[cur[0]][cur[1]]:
self.path = temp_grid[cur[0]][cur[1]]
return
elif option == "FindPlayer":
if cur == self.player_pos:
self.path = temp_grid[cur[0]][cur[1]]
return
elif option == "FindBox":
for ne in next:
if not (1 <= ne[0] <= self.screen_x and 1 <= ne[1] <= self.screen_y):
continue
if (type(self.plat.f1[ne[0]][ne[1]]) == plats.Box or type(self.plat.f1[ne[0]][ne[1]]) == plats.Wall) and type(self.plat.g[cur[0]][cur[1]]) != plats.Spine:
if not self.try_bubble(cur[0], cur[1]):
continue
self.path = temp_grid[cur[0]][cur[1]]
self.box_pos = cur
return
elif option == "PushBox":
for ne in next:
if not (1 <= ne[0] <= self.screen_x and 1 <= ne[1] <= self.screen_y):
continue
if type(self.plat.f1[ne[0]][ne[1]]) == plats.Box:
# c_记录cur与ne的改变量,用于指出ne在cur的哪个方向, (cur_x + 2*c_x, cur_y + 2*c_y)即为目标位置
c_x, c_y = ne[0] - cur[0], ne[1] - cur[1]
des_pos = (cur[0] + 2 * c_x, cur[1] + 2 * c_y)
if not (1 <= des_pos[0] <= self.screen_x and 1 <= des_pos[1] <= self.screen_y):
continue
if self.plat.f1[des_pos[0]][des_pos[1]] == None or isinstance(self.plat.f1[des_pos[0]][des_pos[1]], Prop):
temp_grid[ne[0]][ne[1]] = deepcopy(temp_grid[cur[0]][cur[1]])
temp_grid[ne[0]][ne[1]].append(ne)
self.path = temp_grid[ne[0]][ne[1]]
return
# 遍历优先顺序 下左上右
for ne in next:
if not (1 <= ne[0] <= self.screen_x and 1 <= ne[1] <= self.screen_y):
continue
if visited[ne[0]][ne[1]]:
continue
if option == "JudgeReachable" or option == "EvadeBubble":
if self.plat.f1[ne[0]][ne[1]] == None or isinstance(self.plat.f1[ne[0]][ne[1]], Prop):
queue.append(ne)
# 需要采用深复制策略
temp_grid[ne[0]][ne[1]] = deepcopy(temp_grid[cur[0]][cur[1]])
temp_grid[ne[0]][ne[1]].append(ne)
elif (option == "FindPlayer" and self.attack) or option == "FindBox" or option == "PushBox":
if self.record[ne[0]][ne[1]]:
queue.append(ne)
# 需要采用深复制策略
temp_grid[ne[0]][ne[1]] = deepcopy(temp_grid[cur[0]][cur[1]])
temp_grid[ne[0]][ne[1]].append(ne)
按照路径移动
让AI根据前面计算好的路径运动起来,在这个函数中,通过路径来设置AI的移动参数。在这里要注意的是只能取路径的开头部分来设置移动参数,否则会出现AI愣住的问题。
def move(self):
"""按照路径移动"""
path_count = 0
for des in self.path:
# 只需取path开头部分移动
path_count += 1
if path_count >= 3: break
if (self.grid_x, self.grid_y) == des:
self.moving_left = False
self.moving_right = False
self.moving_down = False
self.moving_up = False
else:
move_x, move_y = des[0] - self.grid_x, des[1] - self.grid_y
if move_x == 1: self.moving_right = True
if move_x == -1: self.moving_left = True
if move_y == 1: self.moving_down = True
if move_y == -1: self.moving_up = True
# 攻击玩家
if self.attack:
if (self.grid_x, self.grid_y) == self.player_pos:
if self.try_bubble(self.grid_x, self.grid_y) == True:
self.space = True
self.make_bubble()
self.space = False
# 炸箱子
else:
if (self.grid_x, self.grid_y) == self.box_pos:
self.space = True
self.make_bubble()
self.space = False
个人感悟
我们希望游戏中的AI能够存活足够长的时间,并且能够在此基础上开辟通到玩家的路径,离玩家越来越近,当与玩家足够接近时尝试放泡泡将玩家炸死。在AI的设计初期,为使AI能达到比较理想的状态,需要首先明确AI的决策。在经过了较长时间的思考和观察后,我最终决定使用先算路径后移动、先自保后攻击的基本思路。在这个思路下的AI,能够存活最长的时间,给玩家更好的体验。在实现过程中,最大的难点在于如何找路,在思考了许多地图路径算法后,基于性能、Debug方面的考虑,最终我使用了BFS的算法,也取得了不错的效果。在AI基本成型后,由于地图因素的扩展,出现了许多待解决的问题,并且由于AI始终处于运动的状态,关于AI的变量时刻在变化着,Debug也是件不容易的事情。通过长时间的观察和思考,我添加了一些限制AI活动的功能,使得AI在寻找路径和移动的过程中更加“谨慎”,最终大大延长了AI的存活时间,取得了不错的效果。