空管达人X

文摘   科技   2023-08-23 17:30   北京  

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秒转弯时间,即可有效控制飞机不入隔离圈,进而确保飞机不会相撞。简单说,雷达间隔就是面向对象的时间换空间,靠心算肯定不行,需要算法加持

另外还有两个原则,用于简化算法,节省算力

1、平行或者离散,且最近距离不小于9.3Km,无需计算间隔;

2、后机避让前机,前机无需避让后机,即以-90°和+90°为界,对身后的对象飞机不计算间隔。

算法搞清楚了,游戏的问题也就迎刃而解,直接升级到空管达人X,玩家不再担心撞机风险,只需要专心调配飞机。如果点击红色大XX就会变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(9501005050).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 == 3and 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(24)  # 避免追尾,延迟数秒且考虑航路末班时刻
            ETO[j] = newETO  # 设置下一架飞机出场时间
            routelast[f] = newETO  # 记录该航路末班时刻
    # ----- 屏幕处理 ---------------------------------------------------------
    screen.blit(bg, (00))  # 屏幕刷背景
    screen2.fill((2552552550))  # 加透明层
    # ----- 显示航路 -----
    for k in range(routenum):  # 逐个画航路
        pygame.draw.aaline(screen, [00255], routestart[k], routeend[k])  # 画航路
        blockcolor = [02550128]  # 流控开关默认绿
        if blockcontrol[k] > 0:  # 如果流控,显示红
            blockcontrol[k] -= 1
            blockcolor = [25500128]
        pygame.draw.rect(screen2, blockcolor, block[k])  # 画流控状态
    screen.blit(screen2, (00))  # 显示透明层
    # ----- 显示飞机 -----
    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([25500])  # 对象变红
                plane[j].fill([25500])  # 本机变红
                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]], 55)  # 飞机位置圆形色标
    if button == 1 or button == 3:  # 判断是否按下鼠标左右键
        pygame.draw.circle(screen, [02550], mouse_pos, 33)  # 鼠标位置画圆

    # ----- 显示分数和时间 -----
    font = pygame.font.Font(None30)  # 设置字体和大小
    scoretext = font.render("Score:" + str(score), True, (2503515))  # 文字内容和颜色
    scorepos = scoretext.get_rect(center=(900550))  # 文字位置
    screen.blit(scoretext, scorepos)  # 显示文字

    displaytime = duration - time.time() + starttime  # 计算剩余时间
    timetext = font.render("%02d:%02d" % (displaytime // 60, displaytime % 60), True, (2503515))  # 显示时间内容
    timepos = timetext.get_rect(center=(90030))  # 时间位置
    screen.blit(timetext, timepos)  # 显示剩余时间
    pygame.display.flip()  # 显示刷新的屏幕
    planedictlast = planedictcurrent  # 全部数据处理完毕,飞机组数据转为上一秒飞机组

    # ----- 判断是否结束游戏 ---------------------------------------------------------
    if gameover or displaytime < 1:  # 退出游戏
        pygame.mixer.fadeout(2000)  # 停止播放背景音乐
        break


深入了解CAAC+IT,请进入下列链接:


EBT、哈希和JSON

名词乱炖之区块链

英语900句

CAAC安全规章星座图

AI断案

NOTAM瘦身


Ubuntu330
简约是王道 Keep It Simple, Stupid