惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
V2EX - 技术
V2EX - 技术
P
Privacy & Cybersecurity Law Blog
T
The Exploit Database - CXSecurity.com
美团技术团队
WordPress大学
WordPress大学
博客园 - 司徒正美
S
Securelist
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - Franky
Attack and Defense Labs
Attack and Defense Labs
Security Latest
Security Latest
L
LINUX DO - 最新话题
NISL@THU
NISL@THU
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
腾讯CDC
Y
Y Combinator Blog
The Hacker News
The Hacker News
Security Archives - TechRepublic
Security Archives - TechRepublic
IT之家
IT之家
T
Threatpost
Hugging Face - Blog
Hugging Face - Blog
Scott Helme
Scott Helme
S
SegmentFault 最新的问题
Cyberwarzone
Cyberwarzone
C
Cisco Blogs
阮一峰的网络日志
阮一峰的网络日志
U
Unit 42
B
Blog
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
小众软件
小众软件
V
Vulnerabilities – Threatpost
J
Java Code Geeks
V
Visual Studio Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
A
Arctic Wolf
博客园 - 【当耐特】
Microsoft Security Blog
Microsoft Security Blog
S
Security @ Cisco Blogs
雷峰网
雷峰网
Help Net Security
Help Net Security
The Last Watchdog
The Last Watchdog
Recent Announcements
Recent Announcements
G
Google Developers Blog
C
CERT Recently Published Vulnerability Notes
T
Troy Hunt's Blog
MyScale Blog
MyScale Blog

机核

游戏性能旗舰最强之选,一加 Ace 6 至尊版国补到手价2999元起 6元钱自己更换电动车刹车线 《生化危机9:安魂曲》编剧Haris Orkin专访 摸金游戏?音乐游戏!暗区新赛季这把能弹的琴有何来历? 好评国产武侠SRPG《息风谷战略》免费DLC现已推出 | 机核 GCORES 碎片 《生化危机:安魂曲》将于5月实装首个免费更新内容 | 机核 GCORES 新锐东方游戏,谱写世界新章! 沉浸式恋爱视觉小说游戏《心象演算》免费试玩版现已正式上线 | 机核 GCORES 互动影游《代号三国:龙起》上线!穿越三国与曹操并肩、与佳人同行、与权谋博弈! 《老头收集梦想生活》,游戏酒桌会6,录音笔VOL.689 | 机核 GCORES 破界·共生——《白日梦:无限世界》五大核心游戏特点解析 价格已到史低,锐龙5 9600X/锐龙7 9700X正适合抄底 时间循环之旅即刻启程!《归环》一周目测试今日开启 热门在线韩游变魂游,洛奇衍生作能否打破“花瓶”魔咒? LG UltraGear evo 全新高端显示器系列:当“5K”遇见“AI”,不止强大,更懂热爱 反套路三国互动影游《代号三国:龙起》今日上线! 愿望单登记人数突破10万!备受瞩目的“女儿养成游戏” 《梦幻魔法公主》今日于Steam平台上线!限时八折优惠中 《黑神话:悟空》全球音乐会2026巡演将于4月29日12时开票 | 机核 GCORES “Snowguelike”生存肉鸽挖矿新作《蛙穿雪境》公布发售日期,5月7日正式上线 | 机核 GCORES 重塑移动办公、AI创作新境!全新华硕灵耀Air系列、ProArt 骁龙版震撼首发,创芯未来 均分88:《Saros》媒体评分汇总 | 机核 GCORES 上海烛龙公布合作遗迹探险游戏《吉时已到》首支预告片 | 机核 GCORES 首个独立游戏《萝薇日记》已上线Steam! 烛龙新IP《吉时已到》首曝,打造国内首款中式合作遗迹探险游戏 喜加一:《暗黑破坏神Ⅳ》国服现已开启限时免费领取本体活动 | 机核 GCORES 新版《生化危机》电影定于9月18日上映,官方网站现已上线 | 机核 GCORES 《冲就完事模拟器2》“星球大战”联动DLC正式宣布 | 机核 GCORES SteamController将于5月4日发售,售价99美元 | 机核 GCORES 事已至此,内存这么用也算省钱了,“2+1”非对称双通道应用实测 505游戏母公司现已收购《明末:渊虚之羽》IP | 机核 GCORES 基石 手动杂谈12|格斗游戏也能讲好故事 | 机核 GCORES I Love You Mr Snowball 我的向日葵小姐 会比GTA6先发售吗?最硬核的生存游戏《DAYZ》要推新DLC了 周记02:在2026遇到新怪谈 什么硬件,能让游戏Loading界面快速消失? | 机核 GCORES GadioSpec《百年风云世界杯》免费试听集 | 机核 GCORES 百年风云世界杯Vol.1丨足球崛起 | 机核 GCORES 100年前,一群人提前替我们经历了AI恐惧 | 机核 GCORES 《呼啸山庄》2026 ——一辆当代艺术的大卡车冲撞了我的大脑 手游《天穗之咲稻姬:日之香巡灵传》宣布将于7月27日停服 | 机核 GCORES 集结梦之队,征战世界杯,《最佳球会ONLINE》上线Steam 山水绝景随心拼 休闲建造游戏《千里山河录》Steam商店页公开 巫师帽、法袍、魔杖,为什么它们是影视、游戏里的法师必备三件套? 《无鞘信使》-第一章 复古风自动战斗肉鸽《终结之终结》Steam商店页面现已上线 电脑里有一款不破不立的MMO,录音笔VOL.688 | 机核 GCORES 《生化危机》30周年纪念周边发售,这次是真的“保护伞” 可靠耐用+AI全能,惠普战66 2025锐龙版深度体验 《时之书:无尽终章》关卡“大航海时代”全球首次公开 经典名作《乌龙派出所》改编经营模拟游戏《乌龙派出所~阿两的商店街物语~》正式宣布支持简体中文 明日开冲,解锁反套路三国互动影游《代号三国:龙起》即将上线 独立游戏《这是我的宝藏!》已发售~ 降低难度不是唯一解,无压力死亡也是好体验 Netflix官宣新片《普通人》:讲述韩国现代史上的权力风暴 《生化危机:安魂曲》全球销量现已突破700万份 《绝地鸭卫》PC版5月15日正式发售 亡妻回忆录?女性向情感叙事游戏《S-mail》现已正式发售 全新酷黑风格,酷睿Ultra 200S PLUS的高性价比搭档!七彩虹BATTLE-AX B860M-PLUS S WIFI7 V20 超级黑刃主板测评 胖狗 索尼发布了港台地区PS5产品价格调整公告,将于5月1日起实施 二次元怪猎+性感美女!《碧蓝幻想:无尽黄昏》开启Beta公测 这款怪谈类型中式恐怖游戏居然更新了?! 《百日战纪 -最终防卫学园-》改编漫画将于今年冬季开启连载 《如被附身,请致电我们》:匈牙利黑色幽默恐怖小说 《竹屿山房杂部卷五》(译文) 大树 超越引擎 摄影分享丨四月 碎片杂记vol.72 当老式FPS与老式动画技术碰撞出新时代的火花—《神探杰克鼠》 想做独游,如何避免首个项目就褒姒? 在线多人动作游戏《OCTOPinbs》将于5月12日上线Steam! 《酒鬼女神的酒诡》确定将于2026年登陆Steam 《CRYMELIGHT》将于11月5日(周四)正式发售!4月25日开始预购! 钢铁国度MKI部落Evolution,蛮兵部分 【钢铁国度】部落-Primal MK I,蛮兵部分 这是一个高中生用ai跑出来的作品,我自称他为物理神话 钢铁国度MKI部落Metamorphosis,蛮兵部分上 钢铁国度MKI部落Metamorphosis,蛮兵部分下 INDIE Live Expo于4月25日举办:首发9款新作,超200款独立游戏亮相 暗黑卡牌策略新作《魔忌:穷鼠啮狸》发布全新中文试玩版和发售预告片,4月30日正式上线在即 战锤40K长篇小说:变节者・苦难主宰(三)(全书完) 战锤40K长篇小说:变节者・苦难主宰(二) 战锤40K长篇小说:变节者・苦难主宰(一) 英语语言学习丨短语专题1:短语的特点 唯一的EVE战争 【少前同人】【M200】战术人形会梦见音乐会吗 【昏迷3】即将发售,前作主角悉数到场!“恶灵”宋老师化身可操控角色 业内人士:游戏公司“十有八九”使用生成式AI,包括卡普空 欢庆一周年:《光与影:33号远征队》全新纪念艺术图、超值折扣与免费更新同步上线 才刚刚开始呢 【抽奖】《星际卡车司机》推出免费大型更新,四折平史低折扣进行中 四人合作FPS游戏《佣兵猎手》抢先体验重大更新 1 现已上线 Raw Fury新作《深馅地牢 Deep Dish Dungeon》将于今秋加入 XGP 塔防幸存者游戏《魔怪来袭》推出首个 DLC 《饿狼传说:群狼之城》1周年纪念!新DLC“沃尔夫冈·克劳萨”今日参战 猫狗相伴 欢乐闯关 双人合作平台跳跃游戏《猫狗同行》上线Steam商店页 《同行:月球逃脱》(Together: Moon Escape)上线将于明日上线Steam
Godot入门到弃坑:独乐不如众乐
cameLcAsE · 2025-07-15 · via 机核

这里讨论的是“真正的”多人游戏——它至少不是两个玩家用键盘的不同部分坐在同一个屏幕前玩同一个游戏。当然,我们先不想那么多。毕竟根据我们的游戏经验可以知道的是,很多时候参与到同一个多人游戏中的玩家使用的客户端一般都是一样的。所以先从做一个简单的游戏开始。

做一个Pong

如何设计一个多人游戏显然不是这篇文章的重点,所以我们用一个足够简单的游戏来学习如何实现多人游戏功能。Pong的玩法可以说是足够简单了,有了前面的铺垫做起来也是真的非常简单。所以我就不逐字介绍如何实现了,只介绍要点。

墙壁和边界

按照原版Pong的设计,上下两面墙是实的,球碰到会反弹,且玩家不能穿过。因此直接用Area2D表示即可。CollisionShape选个segment或者box都行。

左右两面“墙”是虚的,球可以穿过,然后计算谁得分了。所以这两面墙用Area2D表示。不过因为要处理碰撞和更新得分的逻辑,所以左右的墙我还是做了一个场景。由于要区分左右,所以这里简单给它一个属性来表示墙的左右,以便于判断应该哪个玩家得分。

玩家

虽然玩家很简单,但是为了实现各种碰撞效果,我们还是要弄个CharacterBody2D比较方便。还需要一个Sprite2D用来显示代表玩家的长条。长条的纹理你可以网上找一个图片或者自己画一个,也可以直接新建一个GradientTexture1D。这个东西本身是用来产生渐变色纹理的,但是!我们把调整颜色的把手删掉只剩一个的话,我们就得到了一个单色纹理:

球也根本不用去模拟真实物理运动,因为它的运动方式就是碰到其它东西之后反弹。但是由于我们要手动控制其速度,因此还是直接上CharacterBody2D。

比较值得注意的还是球的脚本。我们之前在使用CharacterBody2D的时候,实现其移动除了设置velocity属性之外就是简单地调用move_and_slide。不过,这里的球用这个方法就不太合适。因为我们希望它碰到其他东西的时候直接反弹,而move_and_slide会让它沿碰撞面滑动。

这里需要搬出另一个名字相似的方法move_and_collide。顾名思义,这个方法是让CharacterBody2D移动并发生碰撞而不是滑动。默认情况下发生碰撞后角色会直接停下。此方法有一个必填参数motion,实际上就是一小段位移,也就是velocity * delta,相当于我们学物理时的vΔt(有没有想起被物理和微积分支配的恐惧)。

还没完。为了知道碰撞的发生,你当然可以给涉及碰撞的所有东西都套一个Area2D,然后连上信号。但是这里既然已经用到了move_and_collide,我们实际上就可以直接当场处理。move_and_collide有一个返回值,其类型为KinematicCollision2D,就是一个包含碰撞数据的对象。由于我们需要在_physics_process中不断调用move_and_collide,我们检查一下它的返回值就知道是否发生了碰撞然后进行处理。

发生碰撞时,我们希望球反弹——也就是修改其速度(主要是方向)。我们希望球像光线射到反射面上那样反射出去。好消息是Vector2内置了这样一个方法。但是需要注意的是此处要使用bounce而不是reflect,正如Godot文档所说,Godot的bounce相当于其他引擎常见的reflect,而reflect相当于其他引擎的bounce。总之,Godot的reflect的参数是接触面的方向,bounce的参数是碰撞面的法线方向(垂直于此面的方向)。move_and_collide返回的对象中正好包含了碰撞面的法线方向(get_normal,返回值是经过归一化的),因此只需要将velocity设置为velocity.bounce(collision.get_normal())即可实现反弹效果:

func _physics_process(delta: float) -> void: var collision = move_and_collide(velocity * delta) if collision: velocity = velocity.bounce(collision.get_normal())

当然最后可以检查一下碰撞层的设置。

UI

UI虽然不是重点,但是至少应该有创建游戏和加入游戏的按钮,然后一个显示分数的Label。具体的实现代码是后面要介绍的内容。

GameManager

一个注册为全局变量(autoload)的场景和脚本。简单地用它来管理游戏状态。我们至少要有一个数组用来保存双方的分数。

# game_manager.gd var scores = [0, 0]: set(value): scores = value # 切实收到同步过来的分数之后再发射信号 scores_updated.emit() get: return scores ​ # 分数已变化的信号,只在本地触发方便UI更新 signal scores_updated()

从multiplayer属性开始

网络(networking)是一个非常复杂的课题,你永远可以用更低级(low-level)的通信协议进行网络通信。但是既然都在用游戏引擎了,如果没有那么复杂的需求,我们自然可以选择引擎提供的更高级(high-level)更简单的API来实现基本的多人游戏功能。

Godot中的所有节点(所有派生自Node的节点类型)都有一个类型为MultiplayerAPImultiplayer属性,通过这个属性可以访问Godot的高级多人游戏API。默认情况下,所有节点的multiplayer属性都和场景树的get_multiplayer返回的对象是同一个。所以只要你没有进行额外的操作,那么在同一个游戏里任何时候访问multiplayer属性都是得到的同一个MultiplayerAPI实例。

服务器和客户端

玩网络游戏时我们经常会骂糟糕的服务器,又或者是骂糟糕的主机(host)。无论是网络游戏还是其他网络通信,在某些层次上我们会把参与通信的设备分为服务器和客户端。顾名思义,服务器会给客户端们提供一些服务,其中最重要的就是管理多人游戏的进行。而客户端需要连上服务器来获得这些服务并参与游戏。我们先编写两个方法用以创建服务器和客户端。

成为服务器

我们希望在点击Host按钮之后就可以成为一个服务器等待其他玩家的加入。

Godot使用ENet库来处理网络通信(基于UDP的、可以可靠的)。要成为一个服务器,需要先用new创建一个ENetMultiplayerPeer实例。Peer是P2P网络中的一个节点的名称。P2P是所谓的对等网络,也就是说网络中的每个节点都可以是服务器也可以是客户端。

创建好peer之后,调用其create_server方法就可以将peer配置为服务器。此方法有一个必填参数port,也就是端口号。你可能大概知道IP地址就对应着一个设备在网络中的地址,当你看到192.168.1.11:21这样的URL的时候,冒号前面的是IP而后面的就是端口号。端口号存在的目的就是让网络数据可以传递给具体的应用。毕竟同一个设备上可能会有很多个应用可以处理网络请求,我们需要一种机制来进行区分。

func _on_host_pressed() -> void: var peer = ENetMultiplayerPeer.new() peer.create_server(8899, 2) multiplayer.multiplayer_peer = peer

端口号的选择

端口号的取值范围为0至65535。但是熟知端口(well-know ports,比如80)和一些应用的默认端口(比如数据库默认的1052、3306)最好是不要去用。当然你在1024和49151之间选一个就好了,只要你不是运气太差。

完成服务器创建后,需要把peer交给multiplayer属性的multiplayer_peer

成为客户端

要成为客户端,就需要和服务器连接。流程和创建服务器是类似的,只不过这次要调用peer的create_client方法。此方法有两个必填参数,就是服务器的IP和端口。在调用create_server时不要求填IP,因为这个服务器就是建立在本机上的。但是客户端则很有可能不是运行在本机上的,因此必须要指定服务器的IP和端口号。如果你的客户端和服务器都是运行在本机上,那么这个IP应该填入127.0.0.1。这个特殊地址就代表本机,localhost在很多情况下也是和这串数字等价。最终你的服务器和客户端可能运行在局域网中的不同设备上,那么你至少需要知道服务器的IP。

端口号就是在调用create_server时传入的端口号。当然你可能需要做一个可以自己填写IP和端口号的UI,毕竟你的设备的IP很有可能会发生变化。

func _on_join_pressed() -> void: var peer = ENetMultiplayerPeer.new() peer.create_client("127.0.0.1", 8899) multiplayer.multiplayer_peer = peer

启动多个实例

最终你肯定希望你的朋友能够和你一起玩你的游戏,但是在开发过程中我们肯定还是会需要在本机同时调试客户端和服务器。

Godot自然也可以同时启动多个实例来调试。在Debug菜单的最下面的Customize Run Instances(自定义运行实例)选项便是用来配置这个操作。勾选Enable Multiple Instances(启用多个实例)之后,调整下方的数字。现在启动调试的时候就可以看到弹出了多个游戏窗口。这样就方便我们调试服务器和客户端了。

连接相关信号

现在我们做好了准备,可以创建服务器和作为客户端连接到服务器。但是目前就算客户端已经和服务器连上了也不会有任何事情发生。我们首先要关注MultiplayerAPI提供的几个信号。

peer_connected

此信号表示有节点连接到了服务器,其参数为节点的id。但是!要注意的是,此信号会在网络中的所有节点上触发。也就是说每当一个客户端连上服务器时,所有客户端服务器上的实例都会收到这个信号。另外在客户端连上服务器时这个信号也会在客户端上触发。客户端连上服务器时,响应此信号的方法的实参值为1。

connected_to_server

顾名思义是连上服务器时在客户端上触发的信号。此信号无参数。

同样也有断开连接相关的信号。这里不赘述。

判断是否是服务器

当然我们有必要区分客户端和服务器,因为有些事情只能让服务器来处理。一方面是因为服务器涉及一些“特权”,比如开始游戏、踢人之类的操作只能服务器来进行。另一方面,也是为了防止客户端进行一些非法操作,将一些异常数据交给服务器,有些操作只会由服务器执行,并在必要时验证传来的数据。

MultiplayerAPIis_server方法可以判断当前的MultiplayerAPI实例是否是一个服务器。由此我们就可以进行区别对待。

开始游戏

在我们这个Pong中,实际上除了服务器只需要一个客户端即可开始,所以本质上一旦服务器收到peer_connected信号就可以准备开始游戏。如果你的游戏涉及更多的玩家需要检查玩家数量,可以调用multiplayerget_peers方法。此方法会返回所有与此设备相连的节点(的ID)。也就是说这个数组中是不包含自己的。

首先我们要向场景中添加玩家的实例。我这里配置了一个GameManager脚本作为AutoLoad来管理游戏,你也可以采取自己喜欢的方法。收到peer_connected信号之后,服务器就会适时调用start方法。内容很简单,就是生成玩家和球:

func spawn(): var viewport_size = get_viewport().get_visible_rect().size var server_player = player_scene.instantiate() # 根据视口大小设置位置 server_player.position = Vector2(viewport_size.x * 0.2, viewport_size.y * 0.5) get_node("/root/Main").add_child(server_player, true) # 为什么要填个true下面解释 var client_player = player_scene.instantiate() client_player.position = Vector2(viewport_size.x * 0.8, viewport_size.y * 0.5) get_node("/root/Main").add_child(client_player, true) # 生成球的部分略去

我这里默认左侧的是服务器玩家控制的角色,右侧的是客户端玩家控制的角色。

如果此时启动调试,你会发现客户端连接之后,只有服务器上生成了玩家。这是自然,因为所有代码都只会在本机上执行。

RPC

RPC(Remote Procedure Call,远程过程调用)在多人游戏框架中很常见。顾名思义就是调用另外某个地方的某个函数。我们直接来看怎么用:

@rpc("authority", "call_local", "reliable") func spawn(): # ...

这里在前面生成玩家的方法上加上了一个@rpc标记并指定了几个参数。接下来你需要把调用spawn的地方改成这个样子:

spawn.rpc()

如果你像我一样使用了add_child,那么一定要记得设置其第二个参数为true。发起RPC调用时,Godot会根据节点的路径来确定要在哪个节点上远程调用。如果不设置add_child的第二个参数force_readable_name(强制设置可读名称)为true,就不是可读性差那么简单。如果不设置此参数,Godot会自动为实例化的节点取名为类型名加对象ID。而这个ID在不同的游戏实例上是不同的,执行RPC时Godot就会在远端找不到本地节点对应的节点。

标记上了@rpc的函数就有了能远程调用的能力。其第一个参数要指定authority,也就是说“谁说了算”。它的取值目前只有两个,一个是authority,一个是any_peer。authority默认情况下就是服务器,也就是只能服务器调用标记为@rpc("authority")的函数。设置为any_peer的时候,任何实例都可以调用这个函数。对于一些比较重要、比较敏感的操作我们自然想只能由服务器去调用。而其他不那么重要的,但是需要所有客户端都一起执行的操作(比如一个玩家的角色发出了什么声音,可能所有的客户端都得发出声音),就可以设置为any_peer

第二个参数指定要在哪里调用,取值为call_remotecall_local。remote就是只会在自己以外的客户端上调用,local则是自己也会同时调用。由于我们这里的服务器实际上也是一个客户端,也有玩家要参与游戏所以选择call_local

第三个参数选择传输模式。主要的两个取值为reliable(可靠)和unreliable(不可靠)。可靠模式会尽可能尝试重发确保包被收到,且保证顺序——当然这些操作肯定是有消耗的,因此只有比较重要的操作才建议选择可靠模式。相应地,不可靠模式不会确保包被收到,亦不保证顺序。最后一种unreliable_ordered则是不可靠但保证顺序的模式。

@rpc标记配置好后,如果需要远程调用就必须f.rpc()的形式来调用而不是直接调用。如果有参数直接把参数填到rpc的括号里就行了。

移动的问题

正如上面的图片所示,虽然所有玩家已经在所有客户端上生成,但是移动时所有玩家都会响应输入。首先要解决的问题是,如何让本地客户端上只有自己的玩家节点受输入控制。这个问题简单,我们只需要给玩家加一个属性记录它的peer id就可以判断了。比如我把这个属性叫做owning_peer_id(owner是节点的一个内置属性,避免重名):

if multiplayer.is_server(): client_player.owner_peer_id = multiplayer.get_peers()[0] else: client_player.owner_peer_id = multiplayer.get_unique_id()

同样的代码会在服务器和客户端上执行。服务器玩家的ID始终是1,所以无论在客户端还是服务器上都直接设置为1。不过在设置客户端玩家角色的ID的时候,在服务器和客户端上进行的时候就需要分别处理。

接下来在需要的时候就可以通过这样的代码来判断这个东西是不是应该归我们控制的了:

if owner_peer_id == multiplayer.get_unique_id(): pass

这个问题解决了,但是玩家的移动并没有反映到另一个玩家的游戏实例上。

我们要记得,说是多人游戏,实际上还是“很多不同的设备在各自运行同一个游戏的不同副本”。我们只是试图通过网络不断同步各种数据,让大家产生在同一个世界的幻觉。

因此为了实现(看似)同步的运动,我们也需要RPC。需要注意的是,我们现在就不能在RPC函数中去判断玩家的按键了。因为RPC会在其他客户端执行,一个玩家在本机按下了按键,另一个客户端是不知道的。因此,我们转而选择将玩家的输入作为参数传递给RPC调用:

func _process(delta: float) -> void: if owner_peer_id == multiplayer.get_unique_id(): if Input.is_key_pressed(KEY_W): client_move.rpc(KEY_W) return if Input.is_key_pressed(KEY_S): client_move.rpc(KEY_S) return client_move.rpc(-1) @rpc("any_peer","call_local") func client_move(key: int): if key == KEY_W: velocity = Vector2(0, -speed) return if key == KEY_S: velocity = Vector2(0, speed) return velocity = Vector2.ZERO

当实际被本地玩家操控的节点接收到输入时,我们向所有客户端发起RPC。这样所有客户端上和这个节点对应的副本都会执行对应的操作。

当然,这样做是有安全隐患的。any_peer意味着任意客户端都可以要求所有客户端执行此操作。假设某个客户端要求所有客户端直接宣布自己胜利那就不好了。你可以选择这样做:

func _process(delta: float) -> void: if owner_peer_id == multiplayer.get_unique_id(): if Input.is_key_pressed(KEY_W): server_move.rpc_id(1,KEY_W) return # ... @rpc("any_peer","call_local") func server_move(key: int): # 进行一些验证blah blah server_do_move.rpc(key) ​ @rpc("authority","call_local") func server_do_move(key: int): # 同前面的client_move

Callable的rpc_id可以指定在谁身上进行远程调用。这里在客户端身上收到输入之后,我们可以要求服务器来处理。服务器进行必要的操作之后,再广播给所有客户端。

正确的做法

接下来,你还需要为任何需要在所有客户端之间同步的数据做上述工作。我知道你在想这是不是疯了。

有良好习惯的朋友肯定已经看过文档了,但是文档中涉及高级多人游戏API的部分就一篇文章。虽然Godot从4.0开始就提供了开发多人游戏所需要的各种基础设施,但是很奇妙的是文档中似乎没有提到。上面内容中无论是在多个客户端上生成节点,还是实现状态同步其实都有内置节点。

同步生成节点

我们可以利用MultiplayerSpanwer来自动在所有客户端上生成节点。

此节点需要配置两个参数。首先要配置Auto Spawn List。它的值是要自动生成的场景的路径。这个路径指的是它在资源文件系统中的路径。

第二个参数是Spawn Path,也就是要生成到场景树中的哪个位置。意思就是说,当配置到了Auto Spawn List中的场景实例化并作为子节点加入到了Spawn Path之下时,spawner就会自动在所有客户端中同步生成此节点。

使用这个节点,我们就可以删除spawn的RPC标记和相应的RPC调用。

属性复制和同步

如果你直接改造之前的代码会发现客户端虽然跟着服务器生成了玩家,但是位置不对。先别急。因为spawner只负责同步生成,不负责其他逻辑。由于我们现在没有RPC,因此设置位置的代码只在服务器上执行,所以位置就不对了。

现在要用到另一个神奇道具那就是MultiplayerSynchronizer。它可以在网络中复制(replicate,或者说再现)属性的变化并保持同步。synchronizer的粒度可以细一点,我们在需要同步的场景中加入它。例如,我们需要同步玩家的位置,那么我们就在玩家场景中加入这个节点。

加入synchronizer之后选中它,然后往下看,下面出现了一个Replication面板,我们将在这里配置要同步此场景中的哪些属性。对于玩家来说,我们同步一下它的位置(position)和前面的owner_peer_id

你可以点击加号按钮选择节点和属性,也可以直接在右边直接输入属性的路径。

Spawn属性指定是否要在节点生成时进行同步。Replicate属性指定同步时机,如果不需要总是去同步,可以选择OnChange仅在值发生变化时进行同步。

类似地,我们还需要对UI中的分数进行同步。得分这种关键性的数据自然应该由服务器来管理。当左右的边界发现和球发生碰撞时,如果自己是服务器就修改得分:

func _on_body_entered(body: Node2D) -> void: if multiplayer.is_server(): if left: GameManager.scores = [GameManager.scores[0], GameManager.scores[1] + 1] else: GameManager.scores = [GameManager.scores[0] + 1, GameManager.scores[1]]

这里为什么要大费周章地这样写呢?对数组scores进行同步时,实际上直接修改数组元素的值也会触发同步。我们也可以选择修改得分后通过RPC让服务器通知所有客户端发出scores_updated信号。但是客户端收到RPC发出信号时并不能保证客户端已经收到了新的scores值。当然也可以选择把新的分数作为参数通过RPC传递。前面GameManager的示例代码中,为scores手动编写了setter就是为了在设置新值时同时发射信号。客户端通过网络收到的值实际上总是一个新的数组所以setter会被触发,但是客户端只是单纯地设置了一个元素的值的话,setter不会被触发。因此这里简单地直接让服务器也直接更新整个数组。

聪明的你可能会问,使用MultiplayerSynchronizer不需要考虑客户端恶意修改值的问题吗?默认情况下,synchronizer以服务器(准确地说是authority)的数据为准,所以我们可以不考虑这一点。文档明确说了是from the multiplayer authority to the remote peers。

总之,MultiplayerSpawnerMultiplayerSynchronizer可以帮我们完成很多工作,再结合RPC的使用,就可以很不错地实现一些多人游戏特性了。

导出游戏

如果想切实在另一台设备上调试、游玩你刚做的多人游戏,但是又不想在每台设备上安装Godot、然后复制粘贴项目文件夹,那么就需要把它导出为真正的可以分发的游戏。

首次尝试导出时应该是会提示没有导出模板。前往官网下载页,下载下方的All Downloads部分最下面的Export Templates选项。下载完成后回到编辑器,打开Editor菜单中的Manage Export Templates(管理导出模板)窗口,选择你下载的模板即可。

最后在Project菜单中找到Export,弹出的窗口中需要编辑导出配置。默认情况下配置是空的,点击Add添加需要导出到的平台。参数这里保持默认即可,点击Export按钮即可导出你的游戏。

当然要注意的是你需要和朋友在同一个局域网内。如果没有做输入IP的UI,那么需要对IP部分进行相应的修改。

和朋友来一场酣畅淋漓的Pong吧。