查看原文
其他

酷毙了!自制体感小游戏《决战二仙桥》


【飞桨开发者说】韩磊,飞桨开发者技术专家(PPDE),台湾清华大学资讯工程学系硕士,创业公司算法工程师。


又菜又爱玩,是我对自己游戏生活的总结。玩网游怕被队友喷,玩3A又打不过BOSS,还是安安静静的玩一玩小游戏吧(当然,我玩小游戏也菜......)。我喜欢小游戏的另一个原因是学习成本比较低,你可以很快地了解游戏规则并上手,而不用去阅读大量的规则,然后探索打怪升级。除了玩游戏,我也热衷于开发游戏,做过一些小demo自娱自乐。我觉得制作游戏是一个很有趣的创造过程,你可以制定自己的规则,在自己制作的小小游戏里,你就是控制一切的主宰(然后被自己制作的游戏虐哭......),我想有谁不喜欢这种感觉呢?(等等,不会只有我吧)。今天给大家分享我使用飞桨预训练模型应用工具PaddleHub制作了一款体感小游戏——《决战二仙桥》。在游戏中,我们不再使用键盘鼠标,只使用头部来控制主角二仙桥大爷来躲避谭SIR的追踪。当然,这样一款游戏也可以让我们活动起来。闲暇时间来一局,轻轻松松预防颈椎病。


效果展示




详细介绍







第一版: 人像细粒度分割版本


在一开始,我的想法是做一个类似于“是男人就坚持一百秒”的小游戏,实质上就是人脸在场景里躲避子弹,而人脸的部分则是通过摄像头实时获取的图像,然后进行人像分割的结果。(对,就是这么懒,就是不想用手来玩游戏)。分析一下场景里只有两个元素——我们控制的Player以及飞散在场景中的Enemy,需要处理的事件则只有Enemy与边界、Enemy与Player之间的碰撞检测。

秉持着一切从简的原则,项目没有使用Pygame,而单纯地使用OpenCV。用OpenCV显示图像是最基础的功能,而碰撞检测可以通过各种mask的叠加来判断,从而避免了使用OpenCV和Pygame之间各种格式转换的麻烦,也降低了学习的门槛。


定位与绘制


Player:游戏的Player是通过人像细粒度分割模型分割出的人脸,这里使用PaddleHub中的ace2p,这个模型可以分割出人体的不同部位。我们通过ace2p可以得到想要部位的mask,这个mask不仅可以用于绘制我们的人脸,也可以用于碰撞检测。人脸的绘制只需要将输入的图像和mask相乘就可以得到。

Enemy:Enemy的绘制仅仅是n*n的小黑点,通过把指定位置的像素置0即可完成。我们需要给小黑点一个偏移量作为初始速度,然后每帧去更新这个小黑点的新坐标并重新绘制。


图1:人脸分割



现在,我们有了Player的mask(坐标信息),也有了所有的Enemy的坐标信息。当Enemy的点运动到ace2p中的mask为1的位置时,即可判定发生了碰撞,游戏结束。这里可以制作一张只有Enemy的mask2,将mask2和Player的mask相乘(相当于取交集)并统计结果,就可以知道碰撞的结果。


图2 碰撞检测



由于ace2p模型对电脑的硬件要求有一点高,通过几个玩家的反应,我又制作了硬件需求低的版本——决战二仙桥。




第二版: 人脸检测版本


为了让游戏更有故事性和热点性,第二版使用了“谭谈交通”(成都本土一档寓教于乐的交通警示类节目)的一些素材,制作一个二仙桥主题的游戏。游戏中我们控制二仙桥大爷,来躲避谭SIR“追捕”。另外,我还引入了两个新的NPC——气球眼镜哥和“强人锁男”哥,这二位的出现,让整个游戏更有趣味性,也让整个“追捕”过程更加惊心动魄。同时,我增加了开始动画和不同结局的结束动画,并增加了语音特效、优化了UI。


人脸检测使用的是PaddleHub中ultra_light_fast_generic_face_detector_

1mb_320这个模型(以下简称face_detector模型),主要也是为了降低模型的硬件需求。通过测试,这个模型在CPU上也可以在很高的帧率下运行。相较于前一版本,这一版只需要定位人脸的中心点坐标,然后把我们想要的贴图文件画上去即可。当然,我们还是要处理碰撞检测这个问题,这里同样通过mask的方式。


定位与绘制


Player:Player的定位即是我们拍摄画面中的人脸的位置,这里通过face_detector模型获得。为了程序能够鲁棒地运行,我对该模型进行封装,加入更多的后处理以解决检测失败、误检等问题。Player的绘制仍然使用mask的方式,我用一些软件处理了程序用到的所有贴图资源,让它们全是具有通明通道的png图片,这样我可以在程序中很轻松地通过通道值来获取贴图的mask。


图3 人脸定位


以下代码将人脸检测模型重新封装了一下,通过一些后处理,使得检测的结果更鲁棒,解决了一些漏检和误检的问题。


class detUtils():
    def __init__(self):
        super(detUtils, self).__init__()
        self.lastres = None
        # 对人脸检测模型进行封装,之后调用dodet拿到后处理过的检测结果
        self.module = hub.Module(name="ultra_light_fast_generic_face_detector_1mb_320")

    def distance(self, a, b):
        # 计算两个点的欧式距离,这里主要用于两个bbox的中心点距离
        return math.sqrt(math.pow(a[0]-b[0], 2) + math.pow(a[1]-b[1], 2))

    def iou(self, bbox1, bbox2):
        # 计算两个bbox 的IOU
        b1left = bbox1['left']
        b1right = bbox1['right']
        b1top = bbox1['top']
        b1bottom = bbox1['bottom']

        b2left = bbox2['left']
        b2right = bbox2['right']
        b2top = bbox2['top']
        b2bottom = bbox2['bottom']

        area1 = (b1bottom - b1top) * (b1right - b1left)
        area2 = (b2bottom - b2top) * (b2right - b2left)

        w = min(b1right, b2right) - max(b1left, b2left)
        h = min(b1bottom, b2bottom) - max(b1top, b2top)

        dis = self.distance([(b1left+b1right)/2, (b1bottom+b1top)/2],[(b2left+b2right)/2, (b2bottom+b2top)/2])

        if w <= 0 or h <= 0:
            return 0, dis

        iou = w * h / (area1 + area2 - w * h)
        return iou, dis


    def dodet(self, frame):
        # 后处理bbox,尽量保持人脸框稳定,解决一些误检漏检的情况
        result = self.module.face_detection(images=[frame], use_gpu=False)
        result = result[0]['data']
        if isinstance(result, list):
            if len(result) == 0:
                return None, None
            if len(result) > 1:
                if self.lastres is not None:
                    maxiou = -float('inf')
                    maxi = 0
                    mind = float('inf')
                    mini = 0
                    for index in range(len(result)):
                        tiou, td = self.iou(self.lastres, result[index])
                        if tiou > maxiou:
                            maxi = index
                            maxiou = tiou
                        if td < mind:
                            mind = td
                            mini = index  
                    if tiou == 0:
                        return result[mini], result
                    else:
                        return result[maxi], result
                else:
                    self.lastres = result[0]
                    return result[0], result
            else:
                self.lastres = result[0]
                return result[0], result
        else:
            return None, None


Enemy及特殊NPC:这两者的位置与运动和第一版的相同。在绘制方面使用了和绘制Player时同样的方法来获取mask等步骤,就不再重复说明。

以下代码为Enemy和NPC的类,其中包含了Enemy的基本的位置信息,运动信息,以及技能信息等,并提供了更新函数来完成绘制和碰撞检测。


class Ball():
    # 谭sir和特殊NPC的类
    def __init__(self, x, y, speed_x, speed_y, img, skill, mask=None):
        # 位置信息、运动信息、贴图及对应的mask、技能
        self.x = x
        self.y = y
        self.speed_x = speed_x
        self.speed_y = speed_y
        self.img = img
        if mask is None:
            self.mask = np.zeros_like(img)
            self.mask[img > 0] = 1
        else:
            self.mask = np.repeat(mask[:,:,np.newaxis], 32)
        self.h, self.w = img.shape[:2]  
        self.skill = skill      

    def move(self, screen, checkimg):
        # 处理运动
        global GM
        global llock
        # 在没有时停的时候可以更新位置
        if not llock:
            self.x += self.speed_x
            self.y += self.speed_y

            if self.x > W - self.w/2 or self.x < self.w/2:
                self.speed_x = -self.speed_x

            if self.y > H - self.h/2 or self.y < self.h/2:
                self.speed_y = -self.speed_y

        t, l, b, r, tt, tl, tb, tr = getPIXEL(self.x, self.y, self.w/2self.h/2)

        ctimg = checkimg[t:b,l:r]  
        stimg = screen[t:b,l:r]          
        # 检测碰撞检测,发生碰撞检测则触发技能,播放音效
        if np.sum(ctimg[self.mask[tt:tb,tl:tr]>0]) > 0:
            self.skill.trigger()
            if self.skill.finish is False:
                GM.appendskill(self.skill)
            if isinstance(self.skill, Balloon):
                _thread.start_new_thread(sound.thread_playsound, ("sound1",RES.getballoonmusic()))
            elif isinstance(self.skill, Lock):
                _thread.start_new_thread(sound.thread_playsound, ("sound1",RES.getlockmusic()))
            else:
                _thread.start_new_thread(sound.thread_playsound, ("sound1",RES.gettmusic()))
            return True
        else:
            screen[t:b,l:r] = screen[t:b,l:r] * (1 - self.mask[tt:tb,tl:tr]) +  self.mask[tt:tb,tl:tr] * self.img[tt:tb,tl:tr]
            return False


技能与碰撞检测


第二版中增加了两个特殊的NPC——气球眼镜哥和强人锁男哥。当我们控制二仙桥大爷碰撞这两个NPC时,就会触发他们的技能。气球眼镜哥的技能是让我们的二仙桥大爷增加一次游戏的机会;强人锁男哥则会让除了二仙桥大爷外的所有角色无法动弹,时间随机。


碰撞检测的方法和第一版基本相同,不过这里的子弹变成了一张张小的贴图,因此判定方法需要做出改变,直接判定每个谭SIR、特殊NPC的mask和二仙桥大爷的mask是否有重叠。一旦重叠,则判定为发生了碰撞。因为不同的碰撞会触发不同的效果,现在也不再绘制Enemy的mask,这里会为每个NPC增加对应的碰撞检测的成员方法。


为了降低程序的耦合性,我把技能单独制作了一个类别,这里称作Class Skill。为了统一管理,谭SIR则被赋予了与气球哥相反效果的技能:让二仙桥大爷失去一次游戏的机会。每种技能都是Class Skill的一个子类。在制作NPC的类别时,技能类会作为一个NPC类的成员变量。在发生碰撞的时候,通过一个继承的多态方法来调用各自的技能类别。


Manager


为了解耦以及方便管理,游戏中包含了几个Manager——控制所有NPC生成和更新的NPC Manager,负责贴图、视频、音频资源加载及相应后处理的Resource Manager,负责控制游戏进程的Game Manager等。这些Manager让项目的逻辑更加清晰,代码更加简洁。




后记




以上便是这个游戏制作过程的介绍,游戏的制作都秉持着一切从简的原则,通过构建array,按照一定的顺序依次贴上贴图,并使用OpenCV来完成游戏的绘制。同时,保存一张大的mask地图,用于碰撞检测。除此之外,结合了一些热点元素,让游戏更有趣味性。由于只使用了OpenCV,所以学习的门槛很低。详细阅读本文后,大家也可以制作出一款类似的游戏。


如果您想详细了解更多飞桨的相关内容,请参阅以下文档。

·飞桨官网地址·
https://www.paddlepaddle.org.cn/

·飞桨开源框架项目地址·
GitHub: https://github.com/PaddlePaddle/Paddle 
Gitee: https://gitee.com/paddlepaddle/Paddle




你真的了解开源吗?

「开源长廊」

为广大开发者展开一幅全球开源生态画卷


Chrome OS成全球第二大桌面系统,国内市场能否复制成功?

2021-06-08

你真的了解开源?

2021-06-09

许式伟:Go+门槛比Go低,小孩6年级可开始学Go+

2021-06-05



觉得不错,请点个在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存