一、前言
距离上一次发文已经稳稳超过一年了,去年一直在做 #¥@#*!%……%#&…%&^# 然后待在家里了!偶尔写写 BUG ,一直默默关注着 Godot ,这不已经 3.2.2 版本了,距离“神秘”的 4.0 版本又近了一步。接下来我还是会不断探索,努力提高自己,努力提高别人,哈哈。有时间多和大家交流探讨 Godot 游戏开发中的一些技能、技巧、技术吧。 :sunglasses:
该结束了!我说的是往期的 Godot3 游戏引擎入门系列正是宣布完成,我们不能总是停留在入门阶段,不要局限于写小 Bug ,大 Boss 也得搞搞,我打算邀请大家一起进入下一阶段的深入学习,本人斗胆提了个高大上的名字: Godot 游戏开发实践系列。说白了,就是“踩坑填坑”系列,至于内容,我暂时能想到、能做到的只有以下一点东西:
- Godot 的开发技巧、高级 API 的探索
- Shader 着色器入门和应用
- AI 的一些入门级应用学习
- 继续实践,做不同类型的游戏 Demo
- 赶在 4.0 之前入个 3D 游戏开发的门
- 其他,或者资源,还有太多没学到的……
我也是新手,很多内容都是第一次尝试,不过不要紧,有梁静茹给的“勇气”,希望“我的一小步,让大家前进一大步吧!”哈哈。另外,喜欢 Godot 游戏引起的朋友们,强烈推荐入群交流, QQ 群号: 692537383 ,和我上次推荐的不是一个群,该群群主是 Godot 第三方语言 QuickJS 绑定者,技术大牛,而且群里的学习讨论、交流气氛也不错,记得在入群申请的时候报上我的名字,进群后可以享受“发际线高端维护优惠券”一张还有群主香吻一个! :joy: 不谢!(PS: 另有新群 831931065 也推荐加入。)
主要内容: High Level Multiplayer API 局域网多人游戏开发应用undefined阅读时间: 10 分钟undefined永久链接: http://liuqingwen.me/2020/07/22/godot-game-devLog-1-making-game-with-high-level-multiplayer-api-part-1/undefined系列主页: http://liuqingwen.me/introduction-of-godot-series/
二、正文
本次示例是一个局域网联机小游戏:炸弹人,当然不能直接在网上进行联机,我还没写过任何服务器代码,不过有一个平台支持 Godot 的局域网游戏进行“网络联机”,并能邀请他人一起玩: gotm.io ,想试一下这个游戏的朋友,这里有体验链接: https://gotm.io/spkingr/bomberman ,进入游戏后,创建服务器,然后网页的右下角有个邀请链接,复制后发送给朋友就可以一起痛苦地玩耍了。由于服务器在国外,要想不卡,对网速要求是比较高的。关于 Godot 中局域网游戏开发可以参考官方文档教程:High-level multiplayer ,文档内容有点简洁,本着“填坑”的思想,我把开发过程中遇到的一些问题和解决方案记录下来,这也是本篇文章的出发点,大致内容:
- 局域网多人联网游戏开发介绍
- 远程调用基础知识
- Godot 中几个重要的关键字
- 游戏结构、代码简析
- 经验总结
示例源码我已经上传到 Github 并且被打包运往北极,妈妈再也不担心我的“祖传代码”会被弄丢了!哈哈。 :joy:
多人游戏开发简介
多人游戏开发听上去感觉要比单机游戏开发高端,实际上并不复杂,只要了解多人游戏开发中的几个重要概念,开发起来和单人游戏几乎没啥区别。在多人游戏中,有一个重要的概念是区分:服务端和客户端。在一场局域网联机游戏中,有一个玩家是服务器,即 Server ,其他加入的玩家都是 Client 客户端,在游戏开发代码编写上,它们几乎“平等”:
- 服务端和客户端共享相同的场景和代码
- 都可以互相调用远程方法,发送通知等
- 也可以独立运行相关逻辑,比如初始化一些共有的数据
上图显示的是服务器端和客户端的场景图,节点和结构完全一样,当然也共享同一套代码,不过我们知道,在运行过程中不可能让客户端随意、单独、自定义地运行任何代码,那样的话游戏就不能保持进度同步了,多人游戏也就成了单机游戏。相比客户端,服务端至少拥有以下特殊职能:
- 服务端优先于其他客户端先运行、创建游戏实例
- 服务端负责统一分配某些属性值,比如给玩家随机分配颜色,确保不重复
- 服务端可以踢人,可以通知并开始游戏,客户端一般不具有该功能
- 服务端一般不会随便退出正在进行中的游戏,至少也要发送一个通知或者提示
如何在代码中判断当前游戏是否为服务器非常简单,在 Godot 中可以使用下面的代码:
代码语言:txt复制if self.get_tree().is_network_server():
print('this is the server.') # 服务器端
else:
print('this is the client.') # 客户端
在这个 Demo 中,所有的“怪物”都在服务器端产生,然后“同时通知所有其他客户端生成相同属性的敌人”:
代码语言:txt复制func _spawnEnemies() -> void:
# 只有服务端可以控制敌人对象的生成
if ! self.get_tree().is_network_server():
return
var count := _enemiesContainer.get_child_count()
if count <= maxEnemyCount:
_spawnEnemy() # 生成怪物
逻辑很简单,那么服务端如何通知客户端怪物对象的生成呢?换句哈说,也就是服务端如何在运行时发送消息到客户端,消息内容包括客户端需要生成怪物的位置、名字、状态等变量值,这就需要高大上且专业的远程调用相关 API 了:低端点,就是远程方法调用的实现。在 Godot 中我们使用 rpc
关键字调用远程方法, rset
调用远程属性,了解了服务器和客户端,接下来一起深入探讨远程调用相关知识。
远程调用基础
前方预警:各种七嘴八舌、鱼龙混杂、绕口令式的句段可能会让小白们感觉不适,慎读!莫晕!勿醉!
何谓远程调用?有点网络知识的朋友都知道,所谓“远程”就是本地与非本地,或者联网中的服务端、客户端之间的关系,举一个很简单的例子:玩家A和玩家B联网游戏,玩家A发送一条消息后,这条消息会同时显示在两个玩家的屏幕上,玩家A的消息就是通过远程调用传送到玩家B的游戏场景进行显示的。
再举个例子:玩家A进入多人游戏场景,那么服务器端和客户端都有玩家A对象,但实际上只有一个地方(比如服务端)可以操作控制自己的角色,比如玩家A在服务器端通过键盘事件控制位置移动后,客户端几乎同时也能看到玩家A移动到了相同的某个新位置,这个流程就是一个简单的远程调用实现过程。具体点,就是服务端接收键盘输入,玩家移动后,通过远程调用客户端相应方法,让客户端实现移动该场景中的玩家A(傀儡/镜像),这个所谓的傀儡有个专业名词叫奴隶( slave )或者木偶 ( puppet )。有点啰嗦,用一个简单的动态图演示如下,注意左边是受控制的真实玩家A所在场景,右边反映的是另一个玩家所在游戏场景:
undefined(https://upload-images.jianshu.io/upload_images/4470535-78795823ae49ff74.gif?imageMogr2/auto-orient/strip
对于小白来说,了解了这个过程就是理解了这个游戏的核心部分。在 Godot 中,除了 rpc/rset
关键字外,还有几个关键字。还是用例子来说:假设三个玩家联网玩游戏,玩家A/B/C在紧张刺激地进行游戏,这里他们各自控制自己的主角,我们把他们各自打开的游戏界面或场景定义为各自所谓的主场景。某个时候玩家A在自己的主场景中发送了一条私密信息,这条信息以玩家C为特定的接收对象,也就是说玩家B所在场景是看不到该消息的,只有玩家C才能看到,如何实现呢?这就是有选择性、定向性的远程调用了,是通过一个 network id
实现的。游戏联网后,每个玩家(服务器、客户端)都有一个特定的网络 id
(在前面的场景结构图中,两个玩家 1 和 62889 实际就是他们各自的 ID ),通过这个 id
利用 rpc_id
或者 rset_id
方法就可以向指定端发送私密信息了。
说明:服务器端 ID = 1 ,其他客户端 ID 都是随机数。
例子到此为止,在 Godot 中远程调用 API 有以下几个,这些都是 Node
节点自带的方法:
- rpc/rset 调用远程方法或者属性
- rpc_id/rset_id 调用指定 id 对象的远程方法或者属性
- rpc_unreliable/rset_unreliable 和上面类似,但不保证一定会调用,可能因为延迟等原因掉包
- rpc_unreliable_id/rset_unreliable_id 和上面类似,针对指定 id 的不稳定远程调用
"talk is cheap, show me the code!" 多人游戏中,服务端有“玩家A”和“玩家B(镜像)”,客户端同样有“玩家A(镜像)”和“玩家B”,当服务器端玩家A(客户端的玩家B同理)按下“攻击”按键的时候,服务端的玩家A和客户端的玩家A(镜像)都会同时发出攻击动作,代码如下:
代码语言:txt复制func _input(e : Event) -> void:
# 只会在本地运行(玩家A)
attack()
# 可以调用远程方法(玩家A的所有镜像)
rpc('attack')
# remote 表示该方法可以被远程调用
remote func attack() -> void:
print('attack something...')
同理,远程属性的调用代码示例:
代码语言:txt复制# remote 表示该属性可以被远程调用
remote var health := 100
func damage(value : int) -> void:
self.health -= value
rset('health', self.health)
大家应该注意到了,有的方法、属性的定义前多了一个关键字 remote
,正如单词的意思,这个关键字修饰的方法/属性不同于普通方法/属性:能使用 rpc/rset
进行远程调用。
除此之外,细心的朋友能发现,在上面的 GIF 演示图中还有两个关键字: master/puppet
。这两个关键字并不是玩家的名字(因为他们不同),同样是远程调用中的关键字,分别代表该节点为当前场景的“主节点”或者“奴隶(傀儡、木偶、镜像)节点”。而普通方法前除了可以用 remote
修饰外,也可以使用 master/puppet
修饰,接下来重点讨论这些关键字的意义和应用。
远程调用关键字
为了把主/奴区分开来,我还是继续举例子,假设联机玩家A/B/C在各自电脑上的各自场景中一起游戏(果然 RAP ),那么下面的高深结论成立:
- 相对于玩家A来说:玩家B和玩家C都属于远程端(他们三个有一个服务端,两个客户端)
- 相对于玩家A电脑中的场景:玩家A对象是主人节点,玩家B和玩家C是对应的奴隶节点
- 同理,相对于玩家B中的场景:玩家B对象是主人节点,A和C都是奴隶节点
- 玩家A只能是玩家A的主人节点或者奴隶节点,不可能玩家A的主人节点或者奴隶节点是玩家B/C
- 比如:玩家A场景中的A对象是玩家B场景中A对象的主人节点,玩家B/C场景中A也是玩家A场景中A对象的奴隶节点( RAP 唱起来! )
不管你有没有搞懂,反正我是没办法再举例子了。太混乱了!小二,来瓶 80 年的 XO 压压惊……“酒醒后第二天,发现下图能看懂了!”
上图说明两个联机游戏场景的结构是完全一样的,但有“主次”节点之分,在实际游戏中的就像下图:
总结一下,在 Godot 中用于修饰远程属性/方法的几个主要关键字就这几个:
- remote 表示该方法是一个远程方法或者属性,可以使用 rpc/rset 调用
- remotesync 以前写作 sync ,它不仅会调用远程方法,也会在本地调用一次
- master 表示该方法只能在“主人”节点中调用,“奴隶”节点不会调用
- puppet 以前写作 slave ,和 master 相反,在所有“奴隶”身份节点中调用
"talk is cheap, show me the code!" 为了区分 remote/remotesync
关键字,再举个栗子,我发誓这最后一个 RAP :假设“炸弹K”所在的场景,调用了一个“爆炸然后消失”的远程方法,因此其他场景中,不论服务器端还是客户端的“炸弹K”镜像都会“爆炸然后消失”。但问题来了,“炸弹K”本身并没有爆炸,为啥?因为这里调用的是远程方法,本地方法并没有调用,所以,为了保证游戏中炸弹K“同步”爆炸,在本地也需要手动调用一次普通方法:
# 玩家A中的“炸弹K”,使用 rpc 调用远程爆炸方法
self.rpc('_deleteObject')
# 本地调用:本身也需要调用一次该方法
_deleteObject()
# 通用方法:玩家A/B/C中的:“炸弹K”
remote func _deleteObject() -> void:
print('Explode and delete self.')
上面的代码显然有点啰嗦,我们改用 remotesync
可以让代码稍许简洁:
# rpc 远程调用,因为是 remotesync 修饰所以本身也会调用一次
self.rpc('_deleteObject')
# 使用 remotesync 表示该方法调用时本地也会触发
remotesync func _deleteObject() -> void:
print('Explode and delete self.')
实际上, remote
完全可以替代 remotesync
,视具体情而定吧,像类似上述的场景中 remotesync
更加方便。另一方面, master
和 puppet
也具有类似的特点,同样表示远程属性或者方法,不过他们明确了调用者的“身份”,比如游戏中的一段代码:
# 炸弹触发爆炸事件后所调用的一个方法
func _on_Explosion_body_entered(body : CollisionObject2D) -> void:
if body != null && body.has_method('bomb'):
# 调用 body 的 bomb 方法,这里 bomb 方法只有主人节点才会发生实际调用
body.rpc('bomb')
self.queue_free()
# 玩家场景中的代码,使用 master 表示远程调用中只有“主人节点”会触发
master func bomb() -> void:
print('Damaged by bomb.')
_isStunning = true
stun()
# 主人节点使用远程调用通知所有其他奴隶节点
self.rpc('stun')
# 这里当然可以改为 remotesync 或者 puppet
remote func stun() -> void:
print('stunning...')
相同的道理, puppet
关键字保证了方法或者属性只能在“奴隶”节点上发生调用:
func _physics_process(delta):
# 这里对当前节点进行判断:非主人节点则返回
if ! self.is_network_master():
return
if _isStuning || _isDead:
return
# 主人节点根据键盘输入移动位置
self.move_and_slide(_velocity)
# 因为奴隶节点不接受键盘输入的控制,所以必须由主人节点远程控制移动
self.rpc_unreliable('_updatePosition', self.position)
# 这个方法只会在奴隶节点中调用(依然可以改为 remote )
puppet func _updatePosition(pos : Vector2) -> void:
self.position = pos
在源码中,你会发现很多方法中都包含 Node.is_network_master()
的判断语句,这是为了避免该方法在非“主人”节点中运行。值得注意的是,这个方法和 Node.get_tree().is_network_server()
是完全不相干的两种判断,前者表示当前节点是否为主人节点,是任何 Node 节点具有的一个方法;后者表示当前游戏是否为服务器,是场景树 Tree 的一个方法。
写了这么多,说了那么多 RAP ,也举了不少例子,对于编写过服务器代码的朋友来说应该不难,作为新手还是需要一些思考和实践的,现在,总结一下前面的内容:
方法(属性) | 本地节点是否运行 | 远程节点是否运行 | 本地主节点是否运行 | 本地奴隶节点是否运行 |
---|---|---|---|---|
普通法法 | 是 | 否 | 是 | 否 |
remote | 否 | 是 | 否 | 否 |
remotesync | 是 | 是 | 是 | 是 |
master | 是/否(视情况) | 是/否(视情况) | 是 | 否 |
puppet | 是/否(视情况) | 是/否(视情况) | 否 | 是 |
完成了这个游戏后,我发现:本质上来说,我们完全只需要一个 remote
结合 is_network_master()
方法就可以实现其他所有关键字的功能,因为在 remote
方法中完全可以判断当前节点是否为主人节点还是奴隶节点。当然,那样会很麻烦,合理且灵活地应用每个修饰符,能够写出更加简洁、易读的代码。
另外的另外,还有几个关键字,比如 mastersync/puppetsync
我没有在游戏中用到,大家可以到官方文档中进行查询了解,接下来我们一起讨论本 Demo 中的场景结构和相关代码吧。
游戏结构
限于篇幅过长,我将在下部分再详述,尽情期待! :smiley:
未完待续……
我的博客地址: http://liuqingwen.me ,我的博客即将同步至腾讯云 社区,邀请大家一同入驻: https://cloud.tencent.com/developer/support-plan?invite_code=3sg12o13bvwgc