从零开始手搓联机弹幕PVP&PVE对战游戏《东方枪林弹雨》的个人总结
前言
从上次更新到现在已经有将近一个月了,在这一个月里,我制作了一款联机弹幕PVP&PVE对战游戏《东方枪林弹雨》,请看下方演示视频。
本篇博客将会简要介绍程序的框架、我的开发思路以及一些个人感想,由于我也是头一回开发联网游戏,可能存在思路上的不严谨,望各位读者海涵。
项目源码和发行版以及游玩教程
- 获取客户端
https://gitee.com/half_tree/touhou-bullet-rain-client - 获取服务端&服务端搭建文字教程
https://gitee.com/half_tree/touhou-bullet-rain-server - 游戏视频演示
https://www.bilibili.com/video/BV1B8qWYcEWG/?vd_source=b101593d20983860cd3e333b3a1f5eeb - 服务端搭建视频教程
https://www.bilibili.com/video/BV1f8qWYcEVx?spm_id_from=333.788.recommend_more_video.-1&vd_source=b101593d20983860cd3e333b3a1f5eeb
非技术部分
游戏设计
对局规则
最初设计该游戏的核心理念就是 “我希望设计一个多人联机的弹幕STG,以躲避弹幕为核心乐趣” ,所以最一开始我就设计了玩家可以在场地里发射弹幕的简单对战游戏模型:
- 每一局游戏中可以有一个或者多个玩家;
- 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
- 击倒对手、造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。
不过我很快发现了这样的话游戏未免过于单调了,所以我添加了符卡系统,玩家每隔一段时间可以获得符卡并发射更强的弹幕:
- 每一局游戏中可以有一个或者多个玩家;
- 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
- 玩家每隔一段时间可以获得更强力的弹幕;
- 击倒对手、造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。
这样的话场上的弹幕就没那么单调了,玩家躲避弹幕的密度也上升了,但是这样设计也有很明显的缺点,就是无法适应“以躲避弹幕为核心乐趣”这个部分。
如果场上只有玩家这个对局要素的话,那么游戏的侧重点就会偏向“如何更好地向对手扔出弹幕”而不是“躲避场上的弹幕了”,尽管前者也是重要的对局要素,但是我希望后者可以在游戏中被强调,并且对局的形式也可以更多样化。
所以我添加了NPC(游戏中被称作妖怪)作为对局的一部分,并且把新弹幕获取的方式改为了击败妖怪掉落道具而非直接给予。
加入NPC一方面是增大了战场弹幕密度,一方面是让玩家分配精力去处理妖怪从而获取更好的弹幕,降低了玩家直接冲突的游戏占比。换句话说,这个改动可以使得玩家的冲突更加多元化(进攻、躲避、道具抢夺),而不仅仅是丢弹幕。
所以,该游戏最终的规则就是:
- 每一局游戏中可以有一个或者多个玩家;
- 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
- 每过一定时间,场地上就会刷新妖怪,用弹幕把它们解决掉后,它们会掉落道具;
- 玩家可以靠近道具拾取之,并获得更强力的弹幕;
- 击倒对手、妖怪,造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。
符卡设计
还有比较重要的一点就是玩家所持符卡的设计,游戏中所有的符卡列表如下:
武器名称 | 原型角色 | 备弹量 | 描述 |
---|---|---|---|
Reimu Spell Rifle | 博丽灵梦 | 30 | 发射具有制导能力的符纸 |
Marisa Star Machine Gun | 雾雨魔理沙 | 50 | 快速散射星弹 |
Reimilia Scarlet Arrow | 蕾米莉亚·斯卡雷特 | 8 | 发射具有制导能力的蝙蝠集群 |
Cirno Ice Rail Gun | 琪露诺 | 12 | 发射一连串冰晶弹幕 |
Alice Doll Pair | 爱丽丝·玛格特罗伊德 | 2 | 召唤上海和蓬莱,向周围发射弹幕 |
Keine Permanent Launcher | 上白泽慧音 | 20 | 发射大玉状弹幕,并在一定时间后引爆为指向鼠标位置的两个小弹幕 |
Mokou Flame Thrower | 藤原妹红 | 150 | 喷射近距离火焰弹 |
Sanae Star Shot Gun | 东风谷早苗 | 7 | 一次性散射多枚星弹 |
Koishi Heartbeat Sniper | 古明地恋 | 5 | 传导快速飞行的心跳流 |
Murasa Anchor Launcher | 村纱水蜜 | 1 | 发射指向鼠标指针的船锚,船锚在到达鼠标位置时爆炸,散射出环形弹幕 |
Seija Backward Rapid Arrow | 鬼人正邪 | 45 | 向所指方向的反方向发射箭矢 |
Doremy Dream Catcher | 哆来咪·苏伊特 | 5 | 放置捕梦网,向四面八方发射弹幕 |
Okina WideSpread Star Rail Gun | 摩多罗隐岐奈 | 2 | 在自身周围生成七颗星星,并召唤它们向指定方向飞去,并在沿途产生广阔的轨迹 |
Yachie Sheild Rifle | 吉吊八千慧 | 25 | 发射三枚互相相差120度的弹幕,这些弹幕以同样的规则再分裂为3个子弹幕,龟甲地狱的青春版 |
Zanmu WideSpread Ghost Launcher | 日白残无 | 80 | 围绕自身不断发射具有追踪能力的幽灵弹 |
Report this to developer! | 冴月麟 | -1 | 没有这个武器,如果你在武器栏看见了这行字,记得报告开发者! |
如果你实际体验了游戏,你就会发现这些符卡在攻击敌人时并没有你想象中的那么“高效”:Reimu Spell Rifle
的符卡飞行速度很慢,Cirno Ice Rail Gun
的冰晶展开时间足以让敌人跑出攻击位置,Sanae Star Shot Gun
的散射范围太大,Zanmu WideSpread Ghost Launcher
根本无法瞄准。
实际上,这些符卡的作用是 压制对手 而非直接致死敌人:在适当时机放出符卡, 配合场上的弹幕分布情况压制对手逼迫其被弹 才是符卡的正确作用。
这也恰好符合游戏“以躲避弹幕为核心乐趣”的理念,而不是“把准心放在敌人头上按下鼠标左键他们就会被干掉”。我希望玩家可以针对每张符卡探索出它们的使用时机,以及反制手段,这是STG的乐趣所在。
上图就是一个压制示例,虽然符纸飞行速度慢,但是结合环境,玩家左右都存在威胁,就会因此更容易失误。
第一张图是一个
Okina WideSpread Star Rail Gun
攻击失利的例子,由于七星飞行速度慢,即便玩家之间很近,弹幕也可能会被躲开;第二张图是一个得手的例子,倒下的玩家本来正在尝试拾取道具,其移动目的暴露,所以就被下方玩家预判压制命中。
游戏美术
美术并不是这个游戏的关键,我用了尽量简洁的图像来代替游戏中的要素,保证场面信息的可读性。
例如上方的弹幕材质,清晰、分辨性合理。
PS:不过,还是得承认游戏的画面缺少细节,这主要是怪我不会画画。
这点要向ZUN看齐(
技术部分:网络通信与游戏进程如何协调
最初的想法:完全分治
模型介绍
一个比较简单的想法即为:
- 服务端跑游戏主循环,游戏状态一改变就向客户端发消息,客户端接到了消息就该变本地状态。
- 客户端接受玩家操作,玩家一旦做出操作,客户端就把操作信息发送给服务端供其处理。
- 客户端每帧都基于服务端传回的对局状态进行渲染,这样就完成了:客户端操作->发送给服务器->服务器处理计算->发回给客户端结果->客户端渲染的完整处理逻辑。
服务端
首先构建C++服务端,感谢libhv
的易用性,WebSocket服务端的构建非常便捷。
1 | void GameServer::startServer(string ip, int port, int maxPlayer) { |
规定服务端将缓冲区数据输出。
1 | void GameServer::boardcastResponse() { |
然后定义在游戏主循环中传出数据的函数。
1 | void pushToResponseBuffer(string actionName, vector<string> paramsList, |
接下来在Level
的游戏逻辑处理循环中,就可以通过调用pushToResponseBuffer()
来传出对局数据,例如同步弹幕的syncYoukaiData()
方法。
1 | void Level::syncYoukaiData(Youkai* youkai, queue<LevelAction>& responseBuffer) { |
客户端
接着构建C#客户端,C#的系统库System.Net.WebSockets
直接支持C#使用套接字通信的相关功能,依靠之也可以很方便地定义客户端行为,用StartRequestTask()
启动客户端。
1 | /// <summary> |
接下来,让客户端可以获取用户操作并上传,定义AddInteractToBuffer()
将用户行为推送到缓冲区。
1 | /// <summary> |
在PlayerController
对象中获取玩家操作,并将其推送,例如:
1 | if (input.GetTrackingKey(Keys.W).fired) { |
同时,客户端也具备处理服务端传入数据并改变本地对局状态的能力,定义在Level
游戏主循环中有下列处理代码。
1 | public void Update(GameTime gameTime) { |
由此,我们成功构建了客户端和服务端的通讯。但是这样的通讯方法有严重问——在实际的广域网测试中,该程序的通讯吞吐量极大,完全摧毁了我那峰值带宽只有2MiB/s的服务器。
实际上,这个3.5Mbps在保持了很短一段时间后就下降到了2.0Mbps以下,而我的客户端也发生了严重的延迟,这说明基于当前的算法,数据交换量过大,是不可接受的,应当修改数据同步算法。
通讯改进:降低同步频率
以下方对掉落道具Item
的网络更新为例,规定每30
游戏刻才进行一次同步。
1 | void Item::Update(const float rate, queue<LevelAction> &responseBuffer) { |
这也就意味着,传输道具信息的消息频率下降到了原来的1/30
,但是这样会带来其它问题,在这30
帧之间的29
帧是没有服务端消息的,在这期间如何确定道具的位置?
客户端改进:预测机制
在之前的模型中,我们一直只是让客户端接受服务端的消息,这样的客户端被称作Dumb Client,它只负责渲染而不参与游戏逻辑计算。据说曾今把德国Boy逼疯的游戏《虚幻竞技场》就使用的是类似算法。
这样做的好处是在一定程度上保证了游戏数据的准确性,也有一定的防作弊效果,但是缺点在于客户端有相当的算力浪费。对于我的破服务器而言,我需要调动客户端的算力来减轻我的服务器压力。
我们知道,游戏中掉落的道具会保持匀速直线运动。所以如果我同步了道具的速度信息,我就可以在客户端中预测接下来道具的运动轨迹,从而弥补中间帧的空白,这就是预测机制。
1 | public void HandleServerRequest(string[] paramsList) { |
需要注意,尽管有预测机制,但是这并不意味着我们可以完全不进行坐标同步,因为服务器对客户端传递的信息存在潜在的延迟,这对于速度会改变的对象——比如一些弹幕而言很不友好。
上图是对一次弹幕发射过程的模拟,试想在传输中存在延迟,则在服务器位于t1'
时刻时发送同步消息时,服务器的弹幕经历了t1' - t1
时间的减速,而客户端的弹幕经历了t2' - t0
时间的减速,由此一来,客户端模拟的弹幕和服务端的弹幕位置便不相同。所以,为了保持服务端和客户端同步性,时刻同步还是有必要的。
客户端改进:平滑插值
上文提到,服务端数据和客户端预测存在误差,所以不可避免地,收到服务端同步信息时游戏会出现跳帧现象:具体表现为本来处在这个位置的弹幕突然跳到另外一个其行进轨迹上的位置。
要解决跳帧现象,我们可以通过服务端传来信息的时间戳和当前延迟信息进行一些程度的回放推演来减小这种误差,即尽量让本地的弹幕更新时间接近t1' - t1
而非本地的t2' - t0
。
不过考虑到在该弹幕游戏中,与服务器的延迟一般在50ms~100ms
内,我们没有太大的必要纠结于这一部分误差。为了缓解跳帧现象,我们要做的是让跳帧不要在一帧内完成,将位置的突变分散到几帧内进行,这就是平滑插值算法。
1 | public void HandleServerRequest(string[] paramsList) { |
如图所示,本地预测位置和服务器位置的偏差值即为图中蓝色箭头所指,我们令程序每次同步时记录该偏差值为posCorrectSwift
。
1 | private void CorrectCurrPos(GameTime gameTime) { |
接下来,在单位的Update()
方法中执行CorrectCurrPos()
方法,根据偏差距离计算出回正速度approachVelocity
,并使弹幕在正常移动的同时也拥有一个回归速度。
学过物理的都知道,出于速度的可分解性,回归速度带来的运动和正常预测带来的运动合成后是可以让弹幕回到正确的位置上的,于是我们温和地解决了由跳帧带来的问题。
后记
到此为止,游戏的网络通信框架建成。其实在实际开发中的问题远远不止这些,我只是挑出了比较有讨论价值的问题写了这些总结。
回顾开发流程,我很惊诧,因为在我刚开始的时候,我只有两个最简单的服务端和客户端程序,而前者能做的也只有从后者处读取按键操作而已。
我已经忘了我是怎么一步一步开发到现在的,我不断抱着“下一步添加这个功能应该能够成功”的心态,似乎只是纯粹想要验证我的个人猜想、完成我的个人设计而已,往程序里添加模块。不知不觉间,我就从上面这个蓝色屏幕开始做成了整个游戏。
我很享受这个过程,我在开发的过程中把我的好奇心、求知欲发挥到了我认为完美且充分的地步,或许有一天我会站到更高的平台上,做更有意义的项目,不过我永远也不会忘记好奇心和求知欲是我往上攀的基石。
引用和感谢
博客内容为我原创,允许转载,但请注明出处。
项目文档里已经有引用与感谢信息了,就不重复感谢了。