客户端/服务端代码怎么写 —— 多人游戏代码实战指南

05/0948 浏览开发心得
上一篇帖子讲了多人游戏的基本原理——服务端算逻辑、客户端画画面。
这篇帖子接着往下聊:代码到底怎么写。
每个文件该放什么内容?服务端怎么管理玩家?客户端怎么收集输入?它们之间怎么通信?
不用担心,你不需要手写网络同步代码。引擎已经帮你做了大部分脏活,你只需要按照正确的结构把逻辑放对位置就行。
这篇帖子会把每个文件的职责讲清楚,你也可以直接把提示词复制给嗒啦啦,让它帮你搭好整套代码框架。
难度:看过上一篇《如何做一个简单的多人对战游戏》
————————————————

第一章:四个文件,各管各的

多人游戏的代码分成四个文件,每个文件有明确的职责。
【main.lua —— 入口分发器】
这个文件很短,只做一件事:判断当前是什么模式,然后加载对应的模块。
引擎提供了 IsServerMode() 和 IsClientMode() 两个函数。
如果是服务端 → 加载 Server.lua。
如果是客户端 → 加载 Client.lua。
都不是 → 加载 Standalone.lua(单机模式,方便调试)。
【Shared.lua —— 公共约定】
客户端和服务端都要用到的东西放这里。最重要的是三样:
事件名称 —— 比如"客户端准备好了""分配角色""玩家死亡"这些事件叫什么名字。
节点变量名 —— 比如"血量""武器类型"这些自定义数据叫什么名字。
按键定义 —— 前进是哪个位、跳跃是哪个位、攻击是哪个位。
为什么要统一定义?因为服务端发一个事件叫 PlayerDied,客户端也要用一模一样的名字才收得到。只要有一个字母不同,事件就静默失败,不报错也不执行。
【Server.lua —— 游戏规则】
所有"裁判"逻辑都在这里:创建场景、管理玩家连接、处理移动和战斗、广播事件。
【Client.lua —— 画面和输入】
所有"玩家看到的东西"都在这里:渲染模型、收集按键、播放音效、显示 UI。
跟嗒啦啦说:
「帮我创建多人游戏的四个文件:main.lua(入口分发)、network/Shared.lua(公共常量)、network/Server.lua(服务端逻辑)、network/Client.lua(客户端逻辑),再加一个 network/Standalone.lua(单机模式)。先把框架搭好,每个文件里放好基础结构和注释。」
————————————————

第二章:Shared.lua 该写什么

Shared.lua 是客户端和服务端的"契约",定义了双方沟通用的所有名字。
【事件名称】
你需要给每种通信场景取一个名字。常用的有:
ClientReady —— 客户端告诉服务端"我准备好了"
AssignRole —— 服务端告诉客户端"你的角色是这个"
HealthUpdate —— 服务端广播"某个角色的血量变了"
PlayerDied —— 服务端广播"某个角色死了"
PlayerRespawn —— 服务端广播"某个角色复活了"
GameOver —— 服务端广播"游戏结束"
这些名字你随便取,但两边必须一模一样。所以统一放在 Shared.lua 里,两边都引用这个文件,就不会拼错。
【节点变量名】
角色身上携带的自定义数据也要统一命名:
Health —— 当前血量
WeaponType —— 武器类型
TeamId —— 队伍编号
PlayerId —— 玩家 ID
节点变量是自动同步的,服务端设了值,所有客户端都能读到。
【按键位定义】
玩家的操作用一个数字来表示,每一位代表一个按键:
前进 = 1(第 0 位)
后退 = 2(第 1 位)
左移 = 4(第 2 位)
右移 = 8(第 3 位)
跳跃 = 16(第 4 位)
奔跑 = 32(第 5 位)
攻击 = 64(第 6 位)
客户端把当前按下的键合并成一个数字发给服务端,服务端用位运算拆开来读。
【场景创建函数】
如果客户端和服务端的场景结构一样(地面、墙壁等),可以把创建场景的逻辑放在 Shared.lua 里,两边都调用。但服务端不需要灯光和渲染,所以这个函数通常接收一个"是否是服务端"的参数,服务端跳过灯光,客户端加上灯光。
跟嗒啦啦说:
「帮我在 Shared.lua 里定义好所有远程事件名称、节点变量名、按键位定义,再写一个通用的场景创建函数,服务端和客户端都能用。」
————————————————

第三章:Server.lua 的生命周期

服务端的代码可以分成四个阶段:启动、处理玩家连接、游戏循环、广播事件。
【阶段一:启动】
服务端启动时要做三件事:
1. 注册所有远程事件(不注册就收不到)
2. 创建游戏场景(地形、障碍物等)
3. 预创建角色池
角色池是什么?就是提前把所有可能的角色位置创建好。比如最多 4 个人,就预先创建 4 个角色节点,标记为"未分配"。有玩家进来了就分配一个给他,玩家走了就标记为"空闲"。
为什么不等玩家来了再创建?因为预创建更稳定。节点 ID 固定,不会因为动态创建删除而出现同步问题。
【阶段二:处理玩家连接】
有三个关键时刻:
玩家连进来 → 等他发 ClientReady 事件。
收到 ClientReady → 把场景绑定到这个连接(触发自动同步),从角色池分配一个角色,发 AssignRole 事件告诉他是几号。
玩家断开 → 回收角色,清理状态。
这里有个重要的顺序问题:服务端必须在收到 ClientReady 之后才绑定场景。如果反过来,客户端还没准备好就收到场景数据,会出错。
【阶段三:游戏循环】
服务端每帧执行的主循环非常清晰:
遍历所有已分配角色 → 读取这个玩家的按键状态 → 计算移动 → 检测攻击 → 处理伤害。
读按键的方式:每个连接有一个 controls 对象,客户端每帧把按键写进去,服务端每帧读出来。这个同步是引擎自动做的,不需要手动发消息。
【阶段四:广播事件】
当服务端计算出重要结果(有人掉血、有人死了、游戏结束),要通知所有客户端:
发给某一个客户端 → 用那个连接的 SendRemoteEvent
发给所有人 → 遍历所有连接逐个发送
跟嗒啦啦说:
「帮我写 Server.lua 的完整逻辑:启动时注册事件、创建场景和角色池。处理玩家连接(ClientReady 分配角色、断开时回收)。游戏主循环读取每个玩家的输入并计算移动和攻击。有人掉血或死亡时广播给所有客户端。」
————————————————

第四章:客户端和服务端的"握手"流程

这是多人游戏里最容易出错的地方,必须严格按顺序来。
1. 客户端加载完毕,先创建本地场景
2. 客户端把自己的场景赋给服务端连接(告诉引擎"同步的数据往这个场景里放")
3. 客户端发送 ClientReady 事件
4. 服务端收到 ClientReady,把服务端的场景赋给这个连接(触发同步)
5. 引擎自动把服务端场景里的所有节点同步到客户端
6. 服务端发送 AssignRole 事件告诉客户端"你是几号角色"
7. 客户端收到 AssignRole,根据节点 ID 找到自己的角色,绑定摄像机
为什么第 2 步要在第 3 步之前?
因为服务端收到 ClientReady 后会立即开始同步场景数据。如果客户端还没设好接收场景,数据就没地方放,会报错。
为什么第 6 步要延迟一帧?
因为节点同步不是瞬间完成的。服务端刚绑定场景的那一帧,客户端可能还没收到角色节点。等一帧再发 AssignRole,客户端就能确保找到自己的角色了。
这个流程你不需要自己实现,跟嗒啦啦说"帮我做客户端和服务端的握手流程",它会按正确的顺序来。但了解这个顺序,能帮你在出问题的时候快速定位原因。
————————————————

第五章:Client.lua 怎么收集输入

客户端最核心的工作就是收集玩家的操作,打包发给服务端。
【按键收集】
每帧检查所有需要的按键有没有按下,用位运算合并成一个数字。比如玩家同时按了 W(前进=1)和空格(跳跃=16),合并后是 17。把这个数字写到 controls.buttons 里。
同时把鼠标的方向(左右转的 yaw 和上下看的 pitch)也写到 controls 里,服务端需要这两个值来计算角色朝向和射击方向。
这整个过程不需要手动发网络消息,引擎每帧自动把 controls 的内容发给服务端。
【单帧按键的特殊处理】
移动键是持续按着的,丢一帧没关系。但跳跃和攻击是按一下就触发的,如果那一帧的数据丢了,玩家按了跳跃却没反应,体验很差。
引擎提供了 PulseButtonMask 机制:把跳跃和攻击按键标记为"重要",引擎会用可靠通道传输这些位,保证不丢。
跟嗒啦啦说:
「帮我在 Client.lua 里做输入收集:WASD 移动、空格跳跃、Shift 奔跑、鼠标左键攻击、鼠标控制方向。把按键状态写到 controls.buttons 里,方向写到 controls.yaw 和 controls.pitch 里。跳跃和攻击按键设为 PulseButtonMask。」
————————————————

第六章:Client.lua 怎么渲染角色

服务端创建的角色节点会自动同步到客户端,但只有位置和基本属性——没有 3D 模型、没有材质、没有动画。
这些视觉效果需要客户端自己添加,而且只在客户端本地存在(用 LOCAL 模式创建,不会同步回服务端)。
【在什么时机创建渲染组件?】
这里有一个重要的时机问题:节点到达客户端的时候,它身上的自定义变量(血量、角色类型、颜色)可能还没同步完。如果你这时候就去读变量来决定用什么模型,读到的是空值。
解决办法是用 DelayedStart 机制。服务端创建角色节点时,给它挂一个脚本组件。这个脚本的 DelayedStart 函数会在变量同步完毕之后才执行。
在 DelayedStart 里:
1. 先检查是不是服务端模式,如果是就跳过(服务端不需要渲染)
2. 读取角色类型、颜色等变量
3. 创建 3D 模型(LOCAL 模式)
4. 设置材质和颜色
5. 创建动画状态机(LOCAL 模式)
这样,服务端创建一个角色,客户端就自动生成对应的 3D 模型——不需要额外的同步代码。
跟嗒啦啦说:
「帮我做客户端的角色渲染:服务端创建角色节点时挂一个渲染脚本,在 DelayedStart 里读取角色变量(类型、颜色),然后用 LOCAL 模式创建 3D 模型、材质和动画。记得先判断是不是服务端模式,服务端跳过渲染。」
————————————————

第七章:服务端怎么处理战斗

战斗逻辑全部在服务端执行,客户端只看结果。
【攻击流程】
1. 服务端从 controls 里读到某个玩家按了攻击键
2. 检查冷却时间(防止连点太快)
3. 根据 controls 里的 yaw 和 pitch 算出射击方向
4. 做一次射线检测(从角色眼睛位置沿射击方向发一条射线)
5. 如果打中了另一个角色 → 计算伤害
6. 广播"在某个位置发生了击中效果"给所有客户端(客户端播放火花特效)
【伤害和死亡】
1. 扣血,更新血量
2. 广播血量变化(客户端更新血条显示)
3. 如果血量归零 → 广播死亡事件
4. 设定一个定时器,几秒后自动复活
【为什么攻击判定必须在服务端?】
如果让客户端自己判定"我打中了",作弊者可以让每次攻击都命中。服务端用射线检测来判定,结果由服务端说了算,客户端只能看到"你打中了"或者"你没打中"。
跟嗒啦啦说:
「帮我在 Server.lua 里实现战斗系统:读取攻击按键 → 射线检测判断命中 → 扣血 → 广播血量变化 → 血量归零时广播死亡 → 3 秒后自动复活。攻击有冷却时间防止连点。」
————————————————

第八章:客户端怎么响应服务端事件

服务端广播了一堆事件(血量变化、死亡、复活、击中特效),客户端收到后要做相应的表现。
【收到血量变化】
读取新血量,更新血条 UI。如果是自己的角色被扣血,可以加一个屏幕红闪效果。
【收到死亡事件】
如果是自己死了,显示死亡画面(灰屏+倒计时)。如果是别人死了,可以显示一条"XX 被击败"的提示。
【收到复活事件】
如果是自己复活了,关闭死亡画面,恢复正常视角。
【收到击中效果】
在击中位置播放一个火花粒子效果或者播放一声音效。
所有这些表现都是客户端本地的——你不需要把"我播放了一个特效"同步给服务端或其他玩家。每个客户端独立处理自己看到的画面和听到的声音。
跟嗒啦啦说:
「帮我在 Client.lua 里处理服务端发来的事件:血量变化时更新血条并闪红屏、死亡时显示灰屏倒计时、复活时恢复正常、击中位置播放火花特效和音效。」
————————————————

第九章:摄像机跟随和动画驱动

客户端还有两个重要的工作:让摄像机跟着自己的角色,以及驱动所有角色的动画。
【摄像机跟随】
收到 AssignRole 之后,客户端知道了自己是哪个角色。每帧把摄像机放到角色身后合适的位置,朝角色看的方向。
一个容易踩的坑:不要在摄像机跟随代码里加额外的平滑过渡(lerp)。引擎已经对网络同步的位置做了平滑处理(SmoothedTransform),你再加一层会导致两层平滑打架,摄像机抖动。直接用角色的位置来计算摄像机位置就行。
【动画驱动】
服务端不处理动画(服务端没有画面),动画完全由客户端本地驱动。
客户端怎么知道该播什么动画?读角色节点上的状态数据。引擎的 CharacterComponent 会自动同步移动速度、是否在地面等信息。客户端读取这些值,传给动画状态机,状态机自动播放对应的动画(站立、走路、跑步、跳跃)。
这意味着每个客户端独立运行动画,不占用网络带宽。100 个角色的动画,零网络开销。
跟嗒啦啦说:
「帮我在 Client.lua 里做第三人称摄像机跟随,不要加额外的 lerp 平滑。再做动画驱动,从 CharacterComponent 读取移动速度和地面状态来驱动动画状态机。」
————————————————

第十章:Standalone.lua 单机模式

Standalone.lua 是单机版本,用来在不联网的情况下测试游戏。
它的结构和 Client.lua 几乎一样,区别是:
不需要网络连接和握手流程
直接创建角色(不用等服务端分配)
输入直接控制角色(不用通过 controls 转发)
没有远程事件通信
为什么要做单机版本?
第一,调试方便。联机测试需要启动服务端和至少两个客户端,排查问题很慢。单机模式一键运行,快速验证游戏逻辑。
第二,离线体验。有些游戏需要支持离线玩(比如练习模式),单机模式正好覆盖这个场景。
第三,逻辑复用。核心的游戏逻辑(移动手感、攻击动画、UI 布局)在 Standalone.lua 里调好,再搬到 Client.lua 和 Server.lua 里就行。
跟嗒啦啦说:
「帮我写一个 Standalone.lua 单机模式,结构和 Client.lua 类似,但不需要网络。直接创建角色和场景,输入直接控制角色,用来方便调试。」
————————————————

常见坑

坑 1:事件名拼错了,消息发了但收不到
远程事件的名字必须两边完全一致。服务端写 PlayerDied,客户端写 playerDied,大小写不同就收不到。把所有名字定义在 Shared.lua 里,两边引用同一个变量,杜绝拼写问题。
坑 2:服务端创建了模型和材质
服务端是无画面的,不需要任何渲染组件。模型、材质、动画、音效、粒子都应该只在客户端创建(用 LOCAL 模式)。服务端加了这些不会报错,但会浪费内存。
坑 3:客户端直接修改角色位置
千万不要在客户端直接改角色的 position。下一帧服务端的同步数据到了,会把位置覆盖回去,角色就会闪回。客户端只管发送输入,让服务端来移动角色。
坑 4:握手顺序搞反了
客户端必须先设 serverConnection.scene,然后再发 ClientReady。服务端必须在收到 ClientReady 之后才设 connection.scene。反过来会导致场景同步失败,轻则节点丢失,重则客户端崩溃。
坑 5:在节点刚到达时就读变量
节点和变量不是同一时刻到达的。用 DelayedStart 读变量才安全。在节点刚出现的回调里读变量,大概率是空的。
坑 6:删除节点用 Remove 而不是 Dispose
网络同步的节点要用 Dispose() 删除,它会立即生效。Remove() 依赖垃圾回收,客户端可能过好几秒才看到节点消失,游戏体验很差。
坑 7:忘记注册远程事件
调用 SubscribeToEvent 之前,必须先调用 network:RegisterRemoteEvent 注册事件名。没注册的事件会被引擎静默忽略,不报错、不执行、不提示,排查起来非常痛苦。
————————————————

提示词·直接复制给嗒啦啦

【模板 1:搭建完整的多人游戏代码框架】
「帮我搭建一个多人对战游戏的完整代码框架:
- main.lua:入口分发,判断服务端/客户端/单机模式
- network/Shared.lua:定义远程事件名、节点变量名、按键位、通用场景创建函数
- network/Server.lua:创建场景和角色池,处理玩家连接和断开,游戏主循环读输入算移动
- network/Client.lua:设置场景和连接,收集输入发给服务端,渲染角色模型和动画,显示 UI
- network/Standalone.lua:单机模式,不联网直接玩
每个文件的基础结构和事件订阅都帮我写好。」
【模板 2:实现完整的战斗系统】
「帮我在多人游戏里加完整的战斗系统:
- 服务端:读取攻击按键,射线检测判断命中,扣血,广播血量变化,死亡后 3 秒复活
- 客户端:收到血量变化更新血条,受伤闪红屏,死亡显示灰屏倒计时,击中位置播火花特效
- 攻击有 0.15 秒冷却防连点
- 在 Shared.lua 里定义好 HealthUpdate、PlayerDied、PlayerRespawn、ShootHit 这些事件」
【模板 3:做客户端的角色渲染和动画】
「帮我做客户端的角色渲染系统:
- 服务端创建角色节点时挂上渲染脚本
- 客户端在 DelayedStart 里根据节点变量创建 3D 模型(LOCAL 模式)
- 用 CharacterComponent 的移动速度和地面状态驱动动画状态机
- 不同角色用不同颜色的材质区分
- 服务端模式自动跳过渲染」
【模板 4:调试多人游戏的连接问题】
「帮我检查多人游戏代码的连接流程:
- 所有远程事件是否都已经 RegisterRemoteEvent 注册了
- 客户端是否先设了 serverConnection.scene 再发 ClientReady
- 服务端是否在收到 ClientReady 后才设 connection.scene
- 渲染组件是否都用 LOCAL 模式创建
- 是否有在客户端直接修改角色位置的地方
- 节点变量是否在 DelayedStart 里读取的
帮我把发现的问题都列出来并修复。」
————————————————

总结

客户端/服务端代码的核心就是"各管各的":
Shared.lua 管约定 —— 事件叫什么名字、变量叫什么名字、按键怎么编码,双方都引用这一份,保证一致。
Server.lua 管规则 —— 创建世界、管理玩家、读输入算逻辑、广播结果。服务端不需要任何视觉效果。
Client.lua 管表现 —— 收集输入发给服务端,把同步过来的数据渲染成画面,播放音效和特效。客户端不做任何逻辑判定。
Standalone.lua 管调试 —— 和 Client 结构一样但不联网,用来快速测试。
记住握手顺序(客户端先设场景再发 Ready,服务端收到 Ready 再绑场景),记住渲染用 LOCAL 模式,记住变量在 DelayedStart 里读 —— 这三个规则避开了 80% 的多人游戏 Bug。
剩下的细节交给嗒啦啦,它会按正确的模式帮你写好代码。
猜你想搜
taptap 制造多人游戏代码实战
4
4