下载 App
10/16案例分享
【案例分享】联网对战为什么"没反应":服务端权威、状态同步与远程事件的 10 个静默失效点
一句话结论
联网游戏里那些"代码明明写了却没生效"的问题,大半不是逻辑写错,而是时序和注册顺序写错——远程事件没注册会被静默丢弃(连报错都没有)、节点删除用 Remove() 要等 Lua GC 才通知客户端、客户端建节点默认是 REPLICATED 直接和服务器撞 ID。这三类吃掉了我大半个调试周期。
这是《别停下》开发实录系列的一篇。我把 UrhoX 联网踩过的坑按"错误写法 / 正确写法"整理出来,能直接抄。
一、服务器跑逻辑,客户端只做表现
UrhoX 是权威服务器架构。服务器 Headless,不渲染,跑完整游戏逻辑:移动、碰撞、AI、得分。客户端只做两件事——收输入、放画面。
某个逻辑放哪边,我的判断标准只有一条:这个结果客户端能不能自己改?能改就必须放服务端。
内容:服务器 客户端 原因
游戏实体节点:REPLICATED 自动同步 位置/旋转/缩放引擎自己同步
StaticModel、材质:不创建 LOCAL 服务器没渲染器,建了白建
光照、相机:不创建 LOCAL 只有客户端需要
地面、墙壁:LOCAL LOCAL 各端独立建,不需要同步
音效、粒子:不创建 LOCAL 服务器发事件,客户端本地播
得分、血量、命中判定:服务器算 只读显示 客户端算就是给外挂开门
音效那条我印象最深。服务器上写 soundSource:Play() 永远不会响,Headless 模式根本没有音频设备,而且它不报错。正确做法是服务器发远程事件,客户端在本地节点上播:
-- 服务器:只发事件
local eventData = VariantMap()
eventData["SoundName"] = Variant("explosion.wav")
eventData["Position"] = Variant(position)
connection:SendRemoteEvent("PlaySound", true, eventData)
-- 客户端:本地建节点、本地播、播完自删
function HandlePlaySound(eventType, eventData)
local soundName = eventData["SoundName"]:GetString()
local position = eventData["Position"]:GetVector3()
local soundNode = scene_:CreateChild("Sound", LOCAL)
soundNode.position = position
local soundSource = soundNode:CreateComponent("SoundSource3D", LOCAL)
soundSource:Play(cache:GetResource("Sound", "Sounds/" .. soundName))
soundSource.autoRemoveMode = REMOVE_NODE
end
粒子同理,ParticleEmitter 也是客户端 LOCAL 建,服务器只广播 EffectName + Position。
服务器端有几个全局变量得认清:SERVER_MAX_PLAYERS 是上限,SERVER_REGISTERED_PLAYERS 是开局那一刻的快照。中途进来的玩家不会更新它。常驻服模式(PERSISTENT_WORLD_KEY 非空)下这个值完全不可信,要实时人数只能 network:GetClientConnections()。
二、状态同步:三个不报错的坑
坑 1:NodeAdded 里读 GetVar 拿到的是空
这是个纯时序问题:
1. 服务器创建节点,SetVar
2. 客户端收到 NodeAdded ← 此刻 Vars 还没同步过来
3. 客户端收到 Vars 同步数据 ← 现在才可用
-- ❌ 错误:NodeAdded 里直接读
function HandleNodeAdded(eventType, eventData)
local node = eventData["Node"]:GetPtr("Node")
local typeVar = node:GetVar("EntityType") -- 返回空,不报错
end
正确做法是让服务器在创建节点时挂一个 ScriptObject,客户端在 DelayedStart() 里建渲染组件:
回调:触发时机 Vars 状态
Start():组件创建时 可能未同步
DelayedStart():节点完全初始化后 已同步
-- 服务器:创建实体时挂脚本,脚本会随节点复制到客户端
function CreatePlayerEntity(playerId, color, spawnPos)
local node = scene:CreateChild("Player", REPLICATED)
node.position = spawnPos
node:SetVar("EntityType", Variant("player"))
node:SetVar("PlayerId", Variant(playerId))
node:SetVar("Color", Variant(color))
node:CreateScriptObject("Scripts/EntityRenderer.lua", "EntityRenderer")
return node
end
-- Scripts/EntityRenderer.lua:客户端侧
EntityRenderer = ScriptObject()
function EntityRenderer:DelayedStart()
if IsServerMode() then return end -- 服务器 Headless,跳过
local node = self.node
local typeVar = node:GetVar("EntityType")
if not typeVar or typeVar:IsEmpty() then return end
if typeVar:GetString() == "player" then
self:CreatePlayerVisuals()
end
end
function EntityRenderer:CreatePlayerVisuals()
local colorVar = self.node:GetVar("Color")
local color = colorVar and not colorVar:IsEmpty()
and colorVar:GetVector3() or Vector3(1, 1, 1)
local model = self.node:CreateComponent("StaticModel", LOCAL)
model:SetModel(cache:GetResource("Model", "Models/Sphere.mdl"))
local mat = Material:new()
mat:SetTechnique(0, cache:GetResource("Technique", "Techniques/PBR/PBRNoTexture.xml"))
mat:SetShaderParameter("MatDiffColor", Variant(Color(color.x, color.y, color.z, 1.0)))
model:SetMaterial(mat)
model.castShadows = true
end
-- 不需要 Update 就别定义。引擎只看"有没有定义",空函数体照样每帧调用。
比队列方案好在哪:不用维护队列、没有每帧遍历开销、时序由引擎保证而不是靠手动重试。
坑 2:删节点用 Remove(),客户端可能几秒后才看见消失
引擎用引用计数管节点。Remove() 之后 Lua 变量还持有引用,计数不归零,节点不真删,客户端也收不到 Remove 协议——要一直等到 Lua GC 回收那个变量。GC 什么时候跑?不确定。
-- ❌ 错误:删除时机不可控
node:Remove()
-- ✅ 正确:立即断开 Lua userdata 与 C++ 对象的关联,计数立即归零
node:Dispose()
-- 封装一下,全项目统一走这个
function Shared.RemoveNetworkNode(node)
if node then node:Dispose() end
end
客户端清理缓存时监听 NodeRemoved:
function HandleNodeRemoved(eventType, eventData)
local node = eventData["Node"]:GetPtr("Node")
local entityId = node:GetVar(Shared.VARS.ENTITY_ID)
if entityId and not entityId:IsEmpty() then
localEntityCache_[entityId:GetInt()] = nil
end
end
坑 3:相机跟着同步节点抖
引擎给所有 REPLICATED 节点自动挂了 SmoothedTransform 做插值。如果相机跟随代码里再加一层 lerp,两套插值参数不同,每帧的相对位置都在变,看起来就是抖。
-- ❌ 抖动:额外的 lerp 与 SmoothedTransform 打架
local newX = currentPos.x + (targetPos.x - currentPos.x) * smoothSpeed * dt
-- ✅ 直接吃插值后的位置,相对位置恒定
cameraNode_.position = Vector3(targetPos.x, currentPos.y, targetPos.z)
真需要额外平滑(比如死亡回放),去调 SmoothedTransform 本身:
local smoothed = node:GetComponent("SmoothedTransform")
if smoothed then
smoothed.smoothingMask = SMOOTH_NONE -- 关掉平滑
-- 或 smoothed.smoothingMask = SMOOTH_POSITION -- 只平滑位置
end
三、网络消息:定义、注册、收发
事件名和节点变量名统一放 Shared.lua,别在两边各写一遍字符串。
-- Shared.lua
local Shared = {}
Shared.EVENTS = {
CLIENT_READY = "ClientReady",
ASSIGN_PLAYER = "AssignPlayer",
PLAYER_DIED = "PlayerDied",
PLAY_SOUND = "PlaySound",
PLAY_PARTICLE = "PlayParticle",
}
Shared.SERVER_EVENTS = { Shared.EVENTS.CLIENT_READY } -- 客户端发给服务器
Shared.CLIENT_EVENTS = { -- 服务器发给客户端
Shared.EVENTS.ASSIGN_PLAYER,
Shared.EVENTS.PLAYER_DIED,
Shared.EVENTS.PLAY_SOUND,
Shared.EVENTS.PLAY_PARTICLE,
}
function Shared.RegisterServerEvents()
for _, name in ipairs(Shared.SERVER_EVENTS) do
network:RegisterRemoteEvent(name)
end
end
function Shared.RegisterClientEvents()
for _, name in ipairs(Shared.CLIENT_EVENTS) do
network:RegisterRemoteEvent(name)
end
end
return Shared
这是整个联网模块里最容易静默失效的一处:接收方没调 RegisterRemoteEvent,事件就被丢掉,没有报错、没有日志、没有异常。
function Start()
network:RegisterRemoteEvent("ScoreChanged") -- 先注册
SubscribeToEvent("ScoreChanged", "HandleScoreChanged") -- 再订阅
end
发送有三条路:客户端发给服务器用 network:GetServerConnection():SendRemoteEvent(name, true, data);服务器发给单个客户端用那个 connection 自己的 SendRemoteEvent;服务器广播用 network:BroadcastRemoteEvent(name, true, data)。
数据一律 VariantMap,值一律用 Variant() 包一层。不包不会报错,收到就是空:
-- ❌ 错误:客户端收到空值
eventData["Value"] = 123
-- ✅ 正确
eventData["Value"] = Variant(123)
支持的 Variant 类型就这 8 种,别塞表:
Lua 类型:构造 读取
整数:Variant(123) GetInt()
浮点:Variant(1.5) GetFloat()
布尔:Variant(true) GetBool()
字符串:Variant("text") GetString()
Vector2 / Vector3:Variant(Vector3(x,y,z)) GetVector2() / GetVector3()
Quaternion:Variant(Quaternion(...)) GetQuaternion()
Color:Variant(Color(r,g,b,a)) GetColor()
服务器读客户端输入走的是另一条通道:connection.controls,字段只有 yaw(float)、pitch(float)、buttons(uint 位标志)。
-- 客户端:每帧写进去就行
serverConnection_.controls.yaw = targetYaw_
local buttons = 0
if input:GetKeyDown(KEY_SPACE) then buttons = buttons | 1 end -- bit 0: 跳跃
if input:GetKeyDown(KEY_SHIFT) then buttons = buttons | 2 end -- bit 1: 加速
serverConnection_.controls.buttons = buttons
-- 服务器:每帧读
local yaw = conn.controls.yaw
local buttons = conn.controls.buttons
local isJumping = (buttons & 1) ~= 0
local isBoosting = (buttons & 2) ~= 0
四、脉冲按键会丢,必须单独走 reliable
controls.buttons 走 unreliable 通道。持续按键(加速)丢一帧无所谓,下一帧补上;脉冲按键(跳跃、放技能)只持续一帧,丢了服务器永远看不到这次输入。服务器帧率低的时候,多个客户端帧的输入挤进同一个 tick,后一个覆盖前一个,同样丢。
引擎给的解法是 SetPulseButtonMask,把脉冲位挑出来,这些位自动走 reliable 通道,并且排队保证每次状态变化至少被服务端逻辑看到一个 tick:
local CTRL = {
JUMP = 1, -- bit 0:脉冲,按一下触发
BOOST = 2, -- bit 1:持续,按住生效
}
function HandleClientIdentity(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
connection:SetPulseButtonMask(CTRL.JUMP) -- 只服务端调一次,引擎自动同步给客户端
end
按键类型:通道 延迟 可靠性
pulse 位(JUMP):MSG_BUTTON_STATE,reliable + ordered 同 tick 多状态时 +1 tick 保证送达
非 pulse 位(BOOST):MSG_CONTROLS,unreliable 零额外延迟 可能丢包
只把脉冲键放进去。持续键放进去等于白白多等一个 tick。WASM 平台走 WebSocket(TCP),不存在丢包,引擎会自动跳过这套机制。
五、兴趣管理:用 NetworkPriority 砍带宽
NetworkPriority 按距离动态调节点的更新频率,优先级 = basePriority - distance * distanceFactor,低于 minPriority 就不发了。
-- 玩家自己:高优先级,始终更新给本人
local p = playerNode:CreateComponent("NetworkPriority", REPLICATED)
p.basePriority = 200.0
p.distanceFactor = 0.1
p.alwaysUpdateOwner = true
-- 远处 NPC:低优先级,远了少发
local n = npcNode:CreateComponent("NetworkPriority", REPLICATED)
n.basePriority = 50.0
n.distanceFactor = 1.0
n.minPriority = 10.0
-- 环境道具:最低
local r = propNode:CreateComponent("NetworkPriority", REPLICATED)
r.basePriority = 20.0
r.distanceFactor = 2.0
r.minPriority = 5.0
距离从哪来?客户端每帧把相机位置报上去当观察者位置:
serverConnection_.position = cameraNode_.worldPosition
serverConnection_.rotation = cameraNode_.worldRotation -- 可选,做更精细的朝向剔除
调延迟直接读这几个字段,单位是毫秒和字节/秒:
local stats = {
rtt = conn.roundTripTime, -- 往返毫秒
bytesIn = conn.bytesInPerSec, -- 入站
bytesOut = conn.bytesOutPerSec, -- 出站
}
检测到作弊用 connection:Ban() 封 IP。封禁列表服务器重启就清空。
六、踩过的坑:错误写法 vs 正确写法
现象:错误写法 正确写法
远程事件收不到,无报错:只 SubscribeToEvent 接收侧先 network:RegisterRemoteEvent 再订阅
事件数据全是空:eventData["V"] = 123 eventData["V"] = Variant(123)
客户端实体和服务器撞 ID:scene:CreateChild("Camera") scene:CreateChild("Camera", LOCAL)
NodeAdded 里读 Var 是空:node:GetVar("EntityType"):GetString() 挂 ScriptObject,在 DelayedStart() 里读
节点删了客户端还看得见:node:Remove() node:Dispose()
相机抖:相机跟随里再 lerp 一次 直接吃 SmoothedTransform 的插值结果
客户端看不到服务器实体:在 ClientConnected 里设 connection.scene 等 ClientReady 再设
服务器拿不到 user_id:在 ClientConnected 里读 identity 在 ClientIdentity 里读
跳跃偶尔丢:全部按键走 controls.buttons 脉冲键进 SetPulseButtonMask
别人的分数写到自己头上:收到广播直接应用 比对 UserId 再应用
CreateChild() 和 CreateComponent() 的默认模式是 REPLICATED,不是 LOCAL。服务器上游戏实体用默认值没问题,客户端上所有节点和组件都必须显式写 LOCAL,否则就是给服务器添乱。
连接时序必须按这个顺序走,反了就是 Can not handle LoadScene message without an assigned scene:
客户端 服务器
│ 设置 serverConnection.scene = scene_ ✅ │
│ ──── 发 ClientReady 远程事件 ───────────→ │
│ │ 收到后才设 connection.scene
│ ←──── LoadScene + 全量同步 ───────────── │
服务器端完整的事件顺序是 ClientConnected → ClientIdentity(user_id 此刻才可用)→ ClientReady。这三个别混。
还有个纯性能的坑:ScriptObject 别定义用不到的回调。引擎只看有没有定义,空函数体照样每帧调用。
-- ❌ 每帧都进一次空函数
function EntityRenderer:Update(timeStep) end
-- ✅ 不需要就不定义
广播必须带归属字段,这条我踩过。服务器广播 ScoreChanged 给所有在线玩家,客户端收到后不加判断直接 ApplyScore,结果别人的分数串到自己显示上:
-- 服务器:userId 上 wire 用字符串,避免 int64 在 Lua/JSON 边界丢精度
payload["UserId"] = Variant(tostring(entry.userId))
-- 客户端:先过滤归属
if eventData["UserId"]:GetString() ~= PlayerSessions.Client.GetUserId() then return end
ApplyScore(eventData["Score"]:GetInt())
玩家身份的主键一律用认证后的 user_id。地址 + 端口不是身份:NAT 下会碰撞,断线重连后会变,按它建的表在重连后必然丢。
重连这块引擎给了一组会话事件,语义互斥:PlayerSessionJoined(首次进入,建状态)、PlayerSessionDisconnected(掉线,进宽限期,不要销毁状态)、PlayerSessionReconnected(宽限期内回来,重绑连接并重发全量快照)、PlayerSessionLeft(超时/主动退出/被踢,最终清理)。顶号(新连接替换同一用户的活跃连接)也会触发 PlayerSessionReconnected。用 players + disconnectedPlayers 双表的话,顶号会静默跳过重绑,判据必须是"表里有没有这个 userId",不是"它在不在断线集合里"。
客户端那边的 IsReconnectSession() 只是大厅的本地推断,只配驱动 UI 文案。局面到底是恢复还是重建,以服务端事件为准,两边同时读并假定一致会写出自相矛盾的逻辑。
七、适用边界
适合联网的:
- 强交互对抗、需要统一判定的玩法。谁先打到谁这种事不能交给客户端。
- IO 类休闲对战、沙盒创造、大厅社交这类玩家随时进出的场景,用常驻服(persistent_world),服务器持续跑,场上玩家走空后启动空闲计时,超时自动关闭。
- 需要断线重连保状态的局。宽限期 + 全量快照这套机制现成的。
不适合的:
- 单机体验、纯本地玩法。上权威服务器只换来延迟和一堆同步 bug。
- 依赖大量物理模拟且对帧间一致性极敏感的设计。SmoothedTransform 是插值,不是确定性的。
- 需要中途加入的局却选了普通匹配模式。普通匹配凑齐 N 人才开局,不支持中途加入;persistent_world 和 background_match 互斥,同时开会以常驻服优先。
- 初始化依赖玩家数量的逻辑。常驻服 Start() 时可能一个玩家都没有。
还有一条边界容易被忽略:即使不做 3D,也必须创建 Scene。纯 NanoVG 2D、卡牌、棋盘游戏都要。connection.scene 必须是个有效 Scene 对象,它是网络同步的媒介,不是渲染的附属品。
结尾:一条可迁移的判断方法
每条要过网络的数据,先问两个问题。
客户端能不能自己算出来? 能,就别发。音效、粒子、渲染组件都属于这类,服务器发个名字和坐标就够。
这条数据是谁的? 归属字段必须上 wire。广播不带 UserId,接收端就分不清是"我得分了"还是"别人得分了"。
第一个问题决定发不发,第二个问题决定收到的人要不要理。我遇到的同步 bug,基本都能归到这两条上。
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复