Godot 游戏引擎

Godot游戏开发实践之一:使用High Level Multi

2020-07-24  本文已影响0人  spkingr
Godot游戏开发实践之一

一、前言

距离上一次发文已经稳稳超过一年了,去年一直在做 #¥@#*!%……%#&…%&^# 然后待在家里了!偶尔写写 BUG ,一直默默关注着 Godot ,这不已经 3.2.2 版本了,距离“神秘”的 4.0 版本又近了一步。接下来我还是会不断探索,努力提高自己,努力提高别人,哈哈。有时间多和大家交流探讨 Godot 游戏开发中的一些技能、技巧、技术吧。 :sunglasses:

该结束了!我说的是往期的 Godot3 游戏引擎入门系列正是宣布完成,我们不能总是停留在入门阶段,不要局限于写小 Bug ,大 Boss 也得搞搞,我打算邀请大家一起进入下一阶段的深入学习,本人斗胆提了个高大上的名字: Godot 游戏开发实践系列。说白了,就是“踩坑填坑”系列,至于内容,我暂时能想到、能做到的只有以下一点东西:

我也是新手,很多内容都是第一次尝试,不过不要紧,有梁静茹给的“勇气”,希望“我的一小步,让大家前进一大步吧!”哈哈。另外,喜欢 Godot 游戏引起的朋友们,强烈推荐入群交流, QQ 群号: 692537383 ,和我上次推荐的不是一个群,该群群主是 Godot 第三方语言 QuickJS 绑定者,技术大牛,而且群里的学习讨论、交流气氛也不错,记得在入群申请的时候报上我的名字,进群后可以享受“发际线高端维护优惠券”一张还有群主香吻一个! :joy: 不谢!(PS: 另有新群 831931065 也推荐加入。

主要内容: High Level Multiplayer API 局域网多人游戏开发应用
阅读时间: 10 分钟
永久链接: http://liuqingwen.me/2020/07/22/godot-game-devLog-1-making-game-with-high-level-multiplayer-api-part-1/
系列主页: http://liuqingwen.me/introduction-of-godot-series/

二、正文

demo12.jpg

本次示例是一个局域网联机小游戏:炸弹人,当然不能直接在网上进行联机,我还没写过任何服务器代码,不过有一个平台支持 Godot 的局域网游戏进行“网络联机”,并能邀请他人一起玩: gotm.io ,想试一下这个游戏的朋友,这里有体验链接: https://gotm.io/spkingr/bomberman ,进入游戏后,创建服务器,然后网页的右下角有个邀请链接,复制后发送给朋友就可以一起痛苦地玩耍了。由于服务器在国外,要想不卡,对网速要求是比较高的。关于 Godot 中局域网游戏开发可以参考官方文档教程:High-level multiplayer ,文档内容有点简洁,本着“填坑”的思想,我把开发过程中遇到的一些问题和解决方案记录下来,这也是本篇文章的出发点,大致内容:

  1. 局域网多人联网游戏开发介绍
  2. 远程调用基础知识
  3. Godot 中几个重要的关键字
  4. 游戏结构、代码简析
  5. 经验总结

示例源码我已经上传到 Github 并且被打包运往北极,妈妈再也不担心我的“祖传代码”会被弄丢了!哈哈。 :joy:

多人游戏开发简介

多人游戏开发听上去感觉要比单机游戏开发高端,实际上并不复杂,只要了解多人游戏开发中的几个重要概念,开发起来和单人游戏几乎没啥区别。在多人游戏中,有一个重要的概念是区分:服务端和客户端。在一场局域网联机游戏中,有一个玩家是服务器,即 Server ,其他加入的玩家都是 Client 客户端,在游戏开发代码编写上,它们几乎“平等”:

服务器和客户端场景结构图对比

上图显示的是服务器端和客户端的场景图,节点和结构完全一样,当然也共享同一套代码,不过我们知道,在运行过程中不可能让客户端随意、单独、自定义地运行任何代码,那样的话游戏就不能保持进度同步了,多人游戏也就成了单机游戏。相比客户端,服务端至少拥有以下特殊职能:

  1. 服务端优先于其他客户端先运行、创建游戏实例
  2. 服务端负责统一分配某些属性值,比如给玩家随机分配颜色,确保不重复
  3. 服务端可以踢人,可以通知并开始游戏,客户端一般不具有该功能
  4. 服务端一般不会随便退出正在进行中的游戏,至少也要发送一个通知或者提示

如何在代码中判断当前游戏是否为服务器非常简单,在 Godot 中可以使用下面的代码:

if self.get_tree().is_network_server():
    print('this is the server.') # 服务器端
else:
    print('this is the client.') # 客户端

在这个 Demo 中,所有的“怪物”都在服务器端产生,然后“同时通知所有其他客户端生成相同属性的敌人”:

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所在场景,右边反映的是另一个玩家所在游戏场景:

![远程调用移动](https://img.haomeiwen.com/i4470535/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 节点自带的方法:

"talk is cheap, show me the code!" 多人游戏中,服务端有“玩家A”和“玩家B(镜像)”,客户端同样有“玩家A(镜像)”和“玩家B”,当服务器端玩家A(客户端的玩家B同理)按下“攻击”按键的时候,服务端的玩家A和客户端的玩家A(镜像)都会同时发出攻击动作,代码如下:

func _input(e : Event) -> void:
    # 只会在本地运行(玩家A)
    attack()
    # 可以调用远程方法(玩家A的所有镜像)
    rpc('attack')

# remote 表示该方法可以被远程调用
remote func attack() -> void:
    print('attack something...')

同理,远程属性的调用代码示例:

# 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 ),那么下面的高深结论成立:

不管你有没有搞懂,反正我是没办法再举例子了。太混乱了!小二,来瓶 80 年的 XO 压压惊……“酒醒后第二天,发现下图能看懂了!”

master和puppet场景结构

上图说明两个联机游戏场景的结构是完全一样的,但有“主次”节点之分,在实际游戏中的就像下图:

master和puppet在场景中的节点

总结一下,在 Godot 中用于修饰远程属性/方法的几个主要关键字就这几个:

"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 更加方便。另一方面, masterpuppet 也具有类似的特点,同样表示远程属性或者方法,不过他们明确了调用者的“身份”,比如游戏中的一段代码:

# 炸弹触发爆炸事件后所调用的一个方法
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 ,欢迎关注我的微信公众号:

IT自学不成才
上一篇下一篇

猜你喜欢

热点阅读