2年前,因为疫情和空闲,带着一群飞行、签派小伙伴一起学python。为了好玩,干脆徒手教大家做了个游戏,既能学得快,又能强专业。所以就有了《空管达人》这个游戏。
游戏很简单,只要用鼠标左右键控制每架飞机,安全抵达航路终点即可得分,任何相撞都会终止游戏。
这是一个练手的产物,可玩性一般,但仍然有网友坚持不懈的玩,其中一位发出了灵魂提问:为什么玩家要承担安全责任?既然有电脑,为何不让电脑承担防相撞职责?
网友总是对的。对着pdf文件敲五笔字型,不能算文字识别;对着电脑屏幕报表打算盘,不能算电子商务;没有算法支持的雷达,怎么能算作雷达管制呢?
计算机技术可以带来安全和高效,防止脱发,能交给电脑的活儿,尽量不麻烦人。
接受网友的批评,接下来就是游戏改造,想想也不难,曾经做过警察抓小偷的游戏,玩家扮演小偷,只要为小偷设个距离圈,一旦圈内出现警察,算好方向逃跑就是。
这事儿想简单了。将这个思路植入游戏后,发现飞机经常为了规避碰撞而横着飞、后退飞,不合常理。仔细想想,小偷可以向任意方向逃逸,而飞机是大速度向前飞,单位时间内转弯的角度有限,因此警察抓小偷的算法不适用。
哪里有适用的算法呢?都说规章是血泪教训汇总来的,既然画圈不行,就看下规章吧,认真研读空管规章CCAR-93部,读原文,悟原理,理解到其中的间隔原则。
对于程序管制,93部规章为各方向设立了明确的水平间隔,按这些间隔改游戏确实能解决碰撞问题,但是程序管制是假设看不到飞机位置的,所以大部分间隔是10分钟,相遇后2分钟,按这个原则来设计,这个游戏也就没法玩了,屏幕上太寂静。
对于雷达管制,93部还有个专门的间隔规定,很简单,就是9.3Km,合5NM,不分方向,因为雷达管制下假定全程可见所有飞机。
按这个原则设计了算法,结果游戏里撞下来很多飞机,以对头和侧向的为主,对最低间隔加码10Km、20Km、30Km后,情况有所好转,但还会有撞,直到加码到程序管制的间隔,这时候游戏已寂静到没法再玩。
起初对这个问题百思不得其解,后来偶然对比了TCAS原理,才恍然大悟:
当两架777在高空对飞,进入RA的距离是35秒17.5Km,此时距离规章要求的间隔9.3Km居然还有8.2Km,TCAS这么设计,显然是清楚飞机按距离算动作根本来不及,所以对游戏来讲用距离算间隔是注定失败的。
然而93部原文说的是不得小于9.3Km,所以规章永远都对,只是“不得小于”这四个字博大精深,需要揣摩,经翻查AP、AC、MD、MH,并请教了几个专家后,这事儿就搁置了。
两年后...
直到有一天,群里找到张贵庄的大百科要了一本DOC9689学了一下,这个游戏又重新燃起了希望。
CCAR-93中关于间隔的内容源自DOC4444,而DOC4444又指向了DOC9689《最小间隔规划方法》。这个DOC9689实际上是一伙数学家写的关于算法的书。
一眼看上去是这样的,不由地对管制员产生了莫名的数学敬意。
有宝藏马上挖,经过对DOC9689的反复煎煮蒸炸,终于提炼出对游戏适用的算法,并在AI的协助下整合为math library,代码如下:
import math
import numpy as np
def distance(lon1,lat1,lon2,lat2): # 计算两点距离
dis=((lon1-lon2)**2+(lat1-lat2)**2)**0.5
return dis
def hdgpositive(hdg): # 航向统一到0-360区间
while hdg < 0:
hdg += 360
while hdg >= 360:
hdg -= 360
return hdg
def calculate_azimuth(x1, y1, hdg,x2, y2): #计算方位角bearing和相对方位角azimuth
dx = x2 - x1
dy = y2 - y1
# 计算方位角(以正北为基准,逆时针方向为正)
# atan2返回的角度范围是[-pi, pi],需要将其转换到[0, 2*pi]范围
bearing = math.atan2(dx, dy) + math.pi
bearing = bearing if bearing < 2*math.pi else bearing - 2*math.pi
bearing=math.degrees(bearing)
azimuth=bearing-180-hdg
return azimuth,bearing
def check_intersection(x1, y1, h1, len1, x2, y2, h2, len2): # 检测是否平行或离散,及间距
# 计算线段的终点坐标
x1_end = x1 + len1 * math.sin(math.radians(h1))
y1_end = y1 + len1 * math.cos(math.radians(h1))
x2_end = x2 + len2 * math.sin(math.radians(h2))
y2_end = y2 + len2 * math.cos(math.radians(h2))
# 计算线段的参数方程
def parametric_equation(x, y, x_end, y_end):
dx = x_end - x
dy = y_end - y
return lambda t: (x + dx * t, y + dy * t)
# 计算最小垂直距离
def calculate_vertical_distance(x1, y1, x1_end, y1_end, x2, y2, x2_end, y2_end):
# 如果有垂直地平面的线,直接取差值
if x1_end == x1 or x2_end == x2:
dis=min(abs(x1-x2),abs(x1_end-x2_end))
return dis
# 计算线段A的参数
slope_a = (y1_end - y1) / (x1_end - x1)
# 计算线段B的参数
slope_b = (y2_end - y2) / (x2_end - x2)
# 计算线段A起点和终点到线段B的垂直距离
dist_a_start = abs((y2 - y1) - slope_a * (x2 - x1)) / np.sqrt(1 + slope_a ** 2)
dist_a_end = abs((y2_end - y1_end) - slope_a * (x2_end - x1_end)) / np.sqrt(1 + slope_a ** 2)
# 计算线段B起点和终点到线段A的垂直距离
dist_b_start = abs((y1 - y2) - slope_b * (x1 - x2)) / np.sqrt(1 + slope_b ** 2)
dist_b_end = abs((y1_end - y2_end) - slope_b * (x1_end - x2_end)) / np.sqrt(1 + slope_b ** 2)
return min(dist_a_start, dist_a_end, dist_b_start, dist_b_end)
line1 = parametric_equation(x1, y1, x1_end, y1_end)
line2 = parametric_equation(x2, y2, x2_end, y2_end)
det = (x1_end - x1) * (y2_end - y2) - (y1_end - y1) * (x2_end - x2)
if det == 0:
dis = calculate_vertical_distance(x1, y1, x1_end, y1_end, x2, y2, x2_end, y2_end)
return False, dis # 两条线段平行
t = ((x2 - x1) * (y2_end - y2) - (y2 - y1) * (x2_end - x2)) / det
u = -((x1_end - x1) * (y2 - y1) - (y1_end - y1) * (x2 - x1)) / det
if 0 <= t <= 1 and 0 <= u <= 1:
return True,0
else:
dis = calculate_vertical_distance(x1, y1, x1_end, y1_end, x2, y2, x2_end, y2_end)
return False, dis # 两条线段未相交
def closure_rate(a,b,angle):# 计算接近率
closure_rate= (a ** 2 + b ** 2 - 2 * a * b * math.cos(math.radians(angle))) ** 0.5
return closure_rate
代码看上去的略显复杂,下面用图简单讲解下:
如果设置5Km间隔,对头大速度飞机瞬间就能撞上,同向小速度飞机则几个小时也不会撞上。所以,雷达间隔并不是以距离为准,而是以时间为准,这和程序管制是类似的,只不过程序管制按分钟算,雷达管制按秒计算。时间间隔在雷达屏幕上不好表达,转为距离表达的话如下图:
图中红圈是93部规定的9.3Km,绿圈是在此基础上增加25秒(游戏设定,非官方设定)间隔,不同方向,相对速度不同,所以展现出的距离间隔是不一样的,对头最远,等速跟进最小,只有9.3Km。
这个图可以用于理解不同方位上,时间到距离的转换,但却不能直接用于游戏,因为它是基于一个假设,两机会在正前方25秒相遇。然而现实情况要复杂些,例如:
图中的蓝红两机虽在圈内,却没影响,篮机不会和绿机相遇,红机25秒内甚至250秒内也不会和绿机相遇,所以这个绿色保护圈不适用这类情况。或者说,这绿圈并不是保护圈。
深度学习DOC9689后,发现保护圈不是给本机设置的,而是给对象机设置的,就是说这个圈不给自己套,是要给对象套,每个对象还不一样。
在上图中,当紫机为主机时,红机和黄机都是对象机,各有一个紫色间隔圈,紫机在紫圈外就保证了间隔;如果黄机为主机,红机不会相遇,不设间隔,紫机对黄机有间隔,用黄圈表示,黄机要保证不进黄圈;同样的,对红机来讲,不进红圈,避免和紫机相撞。
上图中,因为交汇角度、速度不同,形成的间隔圈也大小不同,蓝、粉虽然近,但是交汇角度小,因此间隔不用很大;而粉、绿交汇角大,绿机速度也大,因此间隔就比较大。而无论哪个间隔,都是9.3Km+25秒。
总结一下,游戏中的隔离圈,都是面向对象设置,都是9.3Km+25秒x接近速度,在此基础上再给出5秒转弯时间,即可有效控制飞机不入隔离圈,进而确保飞机不会相撞。简单说,雷达间隔就是面向对象的时间换空间,靠心算肯定不行,需要算法加持。
另外还有两个原则,用于简化算法,节省算力:
2、后机避让前机,前机无需避让后机,即以-90°和+90°为界,对身后的对象飞机不计算间隔。
算法搞清楚了,游戏的问题也就迎刃而解,直接升级到空管达人X,玩家不再担心撞机风险,只需要专心调配飞机。如果点击红色大X,X就会变X,意味着回到人工控制间隔,头发太多需要牙剪的朋友可以挑战下。
以下是游戏核心代码。完整代码下载:
没有python基础,需要直接上手游戏的,无需担心,下载后解压缩文件包,点击可执行文件ATCTalentV2.exe即可。
while True: # 无限循环,形成动画效果
# ----- 事件处理(鼠标、键盘、时钟等) -----
clock.tick(tick) # 屏幕刷新率,数值越大,刷新越快
button = 0
planedictcurrent = {} # 本次循环飞机组清空,以便后续记录
for event in pygame.event.get(): # 事件处置
if event.type == pygame.QUIT: # 设置点叉退出
sys.exit()
if event.type == MOUSEBUTTONDOWN: # 设置鼠标响应
mouse_pos = pygame.mouse.get_pos() # 获取鼠标位置
button = event.button # 获取按键,左键1,右键3
if (button == 1):
if pygame.Rect(950, 100, 50, 50).collidepoint(mouse_pos): # 判断是否点击了X,切换智能/人工模式
if bg == background1: # 如果是,X变色
bg = background2
else:
bg = background1
for k in range(routenum): # 遍历航路
if pygame.Rect(block[k]).collidepoint(mouse_pos): # 判断是否点击了流控开关
blockcontrol[k] = 500 # 设置流控周期
score -= 20
if timepos.collidepoint(mouse_pos): # 判断有按键并且击中时间窗,暂停5秒
time.sleep(5)
# ----- 数据处理 ---------------------------------------------------------
for j in range(fleet): # 逐个处理每架飞机数据
if ETO[j] > time.time(): # 不处理未出场飞机
continue
# ----- 飞机移动处理 -----
h = SPD[j] * (np.sin(math.radians(HDG[j]))) # 根据航向算水平位移
v = -SPD[j] * (np.cos(math.radians(HDG[j]))) # 根据航向算垂直位移
planeX[j] += h # 飞机位置横向移动
planeY[j] += v # 飞机位置纵向移动
POS[j].centerx = planeX[j] # 飞机图片位置X
POS[j].centery = planeY[j] # 飞机图片位置Y
XPlane[j] = pygame.transform.rotate(plane[j], -HDG[j]) # 按航向设定机头朝向
if (button == 1 or button == 3) and POS[j].collidepoint(mouse_pos): # 判断有按键并且鼠标位置在飞机矩形框内
HDG[j] += 10 * (button - 2) # 调整航向
HDG[j] = hdgpositive(HDG[j]) # 确保航向在0-360区间
intrudelay[j] = 100 # 冲突解除后保持航向延迟时间
planedictcurrent.update({j: [HDG[j], planeX[j], planeY[j], SPD[j]]}) # 本机位置加入当前字典
# ----- 出界处理 -----
if planeX[j] < 0 or planeX[j] > 800 or planeY[j] < 0 or planeY[j] > height: # 飞出边界处理
# 得分处理
if POS[j].collidepoint(routeend[FPL[j]]): # 飞机接近航路末端
score += 10 # 得分10
music.load("atc/win.wav") # 音效
music.play()
else: # 远离航路末端
score -= 10 # 减分10
music.load("atc/lost.wav") # 音效
music.play()
# ----- 下一架飞机的初始状态设置 -----
planetype[j] = (random.choice(['middle', 'large', 'heavy'])) # 设置下一架飞机类型
SPD[j] = (planetypedict[planetype[j]] / 500 * level) # 根据每架飞机类型确定速度
plane[j] = (pygame.image.load('atc/' + planetype[j] + '.png')) # 加载飞机图片 请将图片放入子文件夹atc
while True:
randomnext = random.randint(0, routenum - 1) # 随机选航路
if blockcontrol[randomnext] == 0: # 如果该航路没有流控,则放行,否则重新选航路
break
FPL[j] = (randomnext) # 设置下一架飞机的飞行计划
f = FPL[j] # f=航路代号
planeX[j] = (routestart[f][0]) # 飞机起始位置X
planeY[j] = (routestart[f][1]) # 飞机起始位置Y
POS[j] = (plane[j].get_rect()) # 获取图片矩形区域
POS[j].centerx = planeX[j] # 飞机图片起始位置X
POS[j].centery = planeY[j] # 飞机图片起始位置Y
HDG[j] = (routetrack[f]) # 设置飞机初始航向
XPlane[j] = (pygame.transform.rotate(plane[j], -HDG[j])) # 预设调整后的飞机形状(旋转)
newETO = max(routelast[f], int(time.time())) + random.randrange(2, 4) # 避免追尾,延迟数秒且考虑航路末班时刻
ETO[j] = newETO # 设置下一架飞机出场时间
routelast[f] = newETO # 记录该航路末班时刻
# ----- 屏幕处理 ---------------------------------------------------------
screen.blit(bg, (0, 0)) # 屏幕刷背景
screen2.fill((255, 255, 255, 0)) # 加透明层
# ----- 显示航路 -----
for k in range(routenum): # 逐个画航路
pygame.draw.aaline(screen, [0, 0, 255], routestart[k], routeend[k]) # 画航路
blockcolor = [0, 255, 0, 128] # 流控开关默认绿
if blockcontrol[k] > 0: # 如果流控,显示红
blockcontrol[k] -= 1
blockcolor = [255, 0, 0, 128]
pygame.draw.rect(screen2, blockcolor, block[k]) # 画流控状态
screen.blit(screen2, (0, 0)) # 显示透明层
# ----- 显示飞机 -----
for j in range(fleet): # 逐个画飞机
if ETO[j] > time.time(): # 不显示未出场飞机
continue
# 冲突飞机处理
dismark = 0 # 计算有多少冲突飞机
for intruter in list(planedictcurrent):
# 排除以下:1、本机已飞离;2、他机已飞离;3、前一秒本机不存在;4、本机就是他机
if j not in planedictcurrent or intruter not in planedictlast \
or j not in planedictlast or j == intruter: # 如果飞机已经消失,或是本机,则不再处理
continue
pown = planedictcurrent[j] # 当前本飞机位置
pobj = planedictcurrent[intruter] # 当前对象飞机位置
# 排除平行和离散飞机(最小距离9.3以上)
intersection, converdis = check_intersection(pown[1], -pown[2], pown[0], 500, pobj[1], -pobj[2], pobj[0],
500)
if intersection == False and converdis > 9.3:
continue
azimuth, bearing = calculate_azimuth(pown[1], -pown[2], pown[0], pobj[1], -pobj[2])
azimuth = hdgpositive(azimuth)
intruterdistance = distance(pown[1], pown[2], pobj[1], pobj[2]) # 计算两机距离
pobj_1 = planedictlast[intruter] # 前一秒对象飞机位置
if len(planedictlast) < len(planedictcurrent): # 如果字典增加,说明新飞机进入,记录位置
pown_1 = planedictcurrent[j] # 默认前一秒本机位置和当前一致
else:
pown_1 = planedictlast[j] # 否则调用前一秒本机位置
intruterdistancelast = distance(pown_1[1], pown_1[2], pobj_1[1], pobj_1[2]) # 前一秒两机距离
if intruterdistance < 9.3: # 小于9.3判为相撞
plane[intruter].fill([255, 0, 0]) # 对象变红
plane[j].fill([255, 0, 0]) # 本机变红
music.load("atc/boom.wav") # 音效
music.play()
gameover = True # 标注游戏结束
if 270 >= azimuth >= 90: # 对象在背后,忽略
continue
if intruterdistance < intruterdistancelast: # 如果当前距离小于之前距离,说明接近中
Rstca = closure_rate(pown[3], pobj[3], pobj[0] - pown[0]) # 获取接近率
Ro, R = Rstca * 30 + 9.3, Rstca * 25 + 9.3 # 计算运行间隔和安全间隔
if bg == background1 and intruterdistance < Ro: # 前方有冲突,本机左右转10度: #如果X是灰色,不需要协助
intrudelay[j] = 20 # 记录冲突并延时20秒
dismark += 1 # 冲突+1
if 0 <= azimuth <= 90:
HDG[j] -= 10
if azimuth >= 270:
HDG[j] += 10
HDG[j] = hdgpositive(HDG[j]) # 确保航向在0-360区间
if intruterdistance < Rstca * 75 + 9.3: # 距离在75秒内显示对象飞机的间隔盾牌
# 注意PYGAME的arc方向是90为起点,逆时针计算,这里要转化一下,方法:90-bearing,再加减盾牌弧度
start_angle, end_angle = math.radians(30 - bearing), math.radians(150 - bearing)
arc_rect = pygame.Rect(pobj[1] - R, pobj[2] - R, R * 2, R * 2)
pygame.draw.arc(screen, circlecolor[j], arc_rect, start_angle, end_angle, 2)
if dismark == 0: # ---如果所有intruter距离在增大,且在盾牌外,且完成延时,则直飞终点
if intrudelay[j] > 0: # 如果刚刚处理完冲突,等待延迟秒
intrudelay[j] -= 1
else: # 超过延迟秒的,争取直飞终点
end = routeend[FPL[j]]
endazimuth, bearing = calculate_azimuth(planeX[j], -planeY[j], HDG[j], end[0], -end[1])
endazimuth = hdgpositive(endazimuth)
if 180 > endazimuth > 5:
HDG[j] += 5
elif 355 > endazimuth >= 180:
HDG[j] -= 5
HDG[j] = hdgpositive(HDG[j]) # 确保航向在0-360区间
screen.blit(XPlane[j], POS[j]) # 显示飞机
# pygame.draw.rect(screen, circlecolor[j], [planeX[j],planeY[j],12,12]) # 飞机位置方块色标
pygame.draw.circle(screen, circlecolor[j], [planeX[j], planeY[j]], 5, 5) # 飞机位置圆形色标
if button == 1 or button == 3: # 判断是否按下鼠标左右键
pygame.draw.circle(screen, [0, 255, 0], mouse_pos, 3, 3) # 鼠标位置画圆
# ----- 显示分数和时间 -----
font = pygame.font.Font(None, 30) # 设置字体和大小
scoretext = font.render("Score:" + str(score), True, (250, 35, 15)) # 文字内容和颜色
scorepos = scoretext.get_rect(center=(900, 550)) # 文字位置
screen.blit(scoretext, scorepos) # 显示文字
displaytime = duration - time.time() + starttime # 计算剩余时间
timetext = font.render("%02d:%02d" % (displaytime // 60, displaytime % 60), True, (250, 35, 15)) # 显示时间内容
timepos = timetext.get_rect(center=(900, 30)) # 时间位置
screen.blit(timetext, timepos) # 显示剩余时间
pygame.display.flip() # 显示刷新的屏幕
planedictlast = planedictcurrent # 全部数据处理完毕,飞机组数据转为上一秒飞机组
# ----- 判断是否结束游戏 ---------------------------------------------------------
if gameover or displaytime < 1: # 退出游戏
pygame.mixer.fadeout(2000) # 停止播放背景音乐
break
深入了解CAAC+IT,请进入下列链接: