UrhoX 联网游戏开发指南 (文档)
精华修改于04/22118 浏览开发心得
温馨提醒:本帖专业性较高,推荐专业人士直接食用,或喂给免费AI帮助食用。
你的嗒啦啦看到的就是下面这些内容,这篇帖子有助于你解决关于 服务端问题 和 引擎到底支持什么样的多人游戏。


概述
介绍如何使用 UrhoX 引擎开发联网多人游戏,涵盖服务器-客户端架构、场景复制、节点同步、远程事件等核心概念。
1. 架构概述
1.1 服务器-客户端模型
UrhoX 联网游戏采用权威服务器架构:
┌────────────────────┐
│ 服务器(Headless)
│ • 游戏逻辑/碰撞/AI
│ • 纯计算,无渲染
│ • REPLICATED创建
│ • 读controls输入
└────────────────────┘
↕ 自动同步
┌────────────────────┐
│ 客户端
│ • 渲染+用户输入
│ • 发controls到服
│ • DelayedStart渲染
│ • 本地音效/粒子
└────────────────────┘
1.2 场景复制机制(Scene Replication)
引擎内置场景复制功能,自动同步:
- REPLICATED 节点的位置、旋转、缩放
- 节点变量(通过 SetVar 设置的数据)
- 节点创建和删除事件
1.3 推荐代码结构
scripts/
├── Main.lua # 入口文件(判断运行模式)
├── Network/
│ ├── Shared.lua # 共享代码(配置、事件名、工具函数)
│ ├── Server.lua # 服务器逻辑
│ └── Client.lua # 客户端逻辑
└── Modules/ # 可复用模块
1.4 运行模式判断
框架提供两个全局函数判断当前运行模式:
• IsServerMode() → boolean
是否服务器模式
• IsClientMode() → boolean
是否客户端模式
注意:这两个函数由框架层面提供,在脚本加载时即可使用,无需额外初始化。
1.5 服务器全局变量
服务器端 Lua 脚本启动时,框架会自动注入以下全局变量,可直接访问:
• SERVER_MAX_PLAYERS
int | 最大玩家数
(来自 settings.json)
• SERVER_TICK_RATE
int | 服务器 Tick 频率(帧率)
• SERVER_MODE
string | 多人模式
(如 "server_authoritative")
• SERVER_REGISTERED_PLAYERS
int | 本局实际玩家数量
(见下方说明)
使用示例:
```lua
-- Server.lua
function Start()
print("[Server] Starting...")
print(" Max players: " .. SERVER_MAX_PLAYERS)
print(" Actual players: " .. SERVER_REGISTERED_PLAYERS)
print(" Tick rate: " .. SERVER_TICK_RATE .. " FPS")
end
```
注意:
- 这些变量仅在服务器端可用,客户端脚本中不存在
- 变量值在脚本加载时确定,运行期间不会变化
SERVER_REGISTERED_PLAYERS 详解:
这个变量表示本局游戏实际参与的玩家数量,与 SERVER_MAX_PLAYERS(最大玩家数)的区别:
【快速匹配】8人局,6人+2AI
MAX: 8 | REG: 6(实际玩家)
【开房间】最大8人,进5人
MAX: 8 | REG: 5(房间实际)
【满员开局】
MAX: 8 | REG: 8
典型用法:
```lua
-- 根据实际玩家数初始化(而不是最大玩家数)
for i = 1, SERVER_REGISTERED_PLAYERS do
-- 为每个真实玩家预分配资源...
end
-- 判断是否需要填充 AI
local aiCount = SERVER_MAX_PLAYERS - SERVER_REGISTERED_PLAYERS
if aiCount > 0 then
-- 创建 AI 玩家...
end
```
注意:SERVER_REGISTERED_PLAYERS 是游戏开始时的玩家数量,不是当前在线玩家数。当前在线玩家数需要通过 network:GetClientConnections() 获取。
2. 节点创建模式
2.1 模式说明
• REPLICATED
常量: REPLICATED
同步到所有客户端(默认值)
• LOCAL
常量: LOCAL
只存在于当前端,不同步
重要:CreateChild() 和 CreateComponent() 的默认模式是 REPLICATED!
```lua
-- 这两行是等价的
local node = scene:CreateChild("Node")
local node = scene:CreateChild("Node", REPLICATED) -- 默认就是 REPLICATED
```
客户端必须显式指定 LOCAL,否则会创建 REPLICATED 节点,与服务器冲突!
2.2 完整对照表
• 游戏实体节点
服:REPLICATED 客:自动同步
位置自动同步
• StaticModel
服:× 客:LOCAL
客户端据节点变量创建
• 材质
服:× 客:LOCAL
避免材质同步问题
• 地面/墙壁
服:LOCAL 客:LOCAL
环境各端独立创建
• 光照/相机/音效
服:× 客:LOCAL
光照:服务器无渲染
相机:仅客户端需要
音效:远程事件通知播放
3. 节点变量同步(Node Vars)
3.1 基本用法
节点变量是附加在节点上的键值对数据,会随 REPLICATED 节点自动同步。
```lua
-- =======
-- 服务器端:设置节点变量
-- =======
local node = scene:CreateChild("Entity", REPLICATED)
-- 设置各种类型的变量
node:SetVar("EntityType", Variant("enemy")) -- 字符串
node:SetVar("EntityId", Variant(123)) -- 整数
-- 更新变量(会自动同步到客户端)
node:SetVar("Health", Variant(80.0))
```
```lua
-- =======
-- 客户端:读取节点变量
-- =======
-- 读取变量(需要检查是否为空)
local typeVar = node:GetVar("EntityType")
if typeVar and not typeVar:IsEmpty() then
local entityType = typeVar:GetString()
end
-- 读取不同类型
local id = node:GetVar("EntityId"):GetInt()
local health = node:GetVar("Health"):GetFloat()
```
3.2 推荐:定义变量名常量
在 Shared.lua 中统一定义变量名。
3.3 支持的 Variant 类型
• 整数
Variant(123) → GetInt()
• 浮点数
Variant(1.5) → GetFloat()
• 布尔值
Variant(true) → GetBool()
• 字符串
Variant("text") → GetString()
• Vector2
Variant(Vector2(x,y)) → GetVector2()
• Vector3
Variant(Vector3(x,y,z)) → GetVector3()
• Quaternion
Variant(Quaternion(...)) → GetQuaternion()
• Color
Variant(Color(r,g,b,a)) → GetColor()
4. 远程事件(Remote Events)
4.1 事件定义
在 Shared.lua 中统一定义事件名:
```lua
-- Shared.lua
Shared.EVENTS = {
-- 连接事件
CLIENT_READY = "ClientReady", -- 客户端准备就绪
-- 游戏事件
PLAYER_DIED = "PlayerDied", -- 玩家死亡
-- 特效事件(服务器通知客户端播放)
PLAY_SOUND = "PlaySound", -- 播放音效
}
```
4.2 事件注册(重要!)
远程事件必须先注册,才能被对端接收。 这是一个安全机制,防止未授权的事件被处理。
```lua
-- ========
-- 注册远程事件(接收方必须调用)
-- ========
-- 服务器要接收客户端事件,需要在服务器端注册
-- 客户端要接收服务器事件,需要在客户端注册
function Start()
-- 注册本端需要接收的远程事件
network:RegisterRemoteEvent(Shared.EVENTS.CLIENT_READY) -- 服务器接收
network:RegisterRemoteEvent(Shared.EVENTS.ASSIGN_PLAYER) -- 客户端接收
-- 然后再订阅事件
SubscribeToEvent(Shared.EVENTS.ASSIGN_PLAYER, "HandleAssignPlayer")
end
```
4.3 客户端 → 服务器
```lua
-- ========
-- 客户端发送事件到服务器
-- ========
local Shared = require("Network.Shared")
-- 获取服务器连接
local serverConnection = network:GetServerConnection()
-- 发送无数据事件
serverConnection:SendRemoteEvent(Shared.EVENTS.CLIENT_READY, true)
-- 发送带数据事件
local eventData = VariantMap()
eventData["PlayerName"] = Variant("Player1")
eventData["PositionX"] = Variant(100.0)
eventData["PositionZ"] = Variant(200.0)
serverConnection:SendRemoteEvent(Shared.EVENTS.CLIENT_READY, true, eventData)
```
4.4 服务器 → 单个客户端
```lua
-- =======
-- 服务器发送事件到特定客户端
-- =======
-- connection 是该客户端的连接对象
local eventData = VariantMap()
eventData["PlayerId"] = Variant(playerId)
eventData["SpawnX"] = Variant(spawnPos.x)
eventData["SpawnZ"] = Variant(spawnPos.z)
connection:SendRemoteEvent(Shared.EVENTS.ASSIGN_PLAYER, true, eventData)
```
4.5 服务器 → 所有客户端(广播)
```lua
-- =======
-- 服务器广播事件到所有客户端
-- =======
local eventData = VariantMap()
eventData["Message"] = Variant("Game Started!")
network:BroadcastRemoteEvent(Shared.EVENTS.GAME_START, true, eventData)
```
4.6 订阅远程事件
-- ========
-- 订阅远程事件
-- ========
function Start()
-- 客户端订阅来自服务器的事件
SubscribeToEvent(Shared.EVENTS.ASSIGN_PLAYER, "HandleAssignPlayer")
end
```
5. 玩家输入同步
5.1 Controls 结构
UrhoX 使用 connection.controls 在客户端和服务器之间同步玩家输入。
• yaw → float
水平旋转角度(移动方向)
• pitch → float
垂直旋转角度
• buttons → uint
按钮状态位标志
5.2 客户端发送输入
```lua
-- ======
-- 客户端:在 Update 中发送输入
-- ======
local serverConnection_ = nil
local targetYaw_ = 0.0
function Start()
serverConnection_ = network:GetServerConnection()
SubscribeToEvent("Update", "HandleUpdate")
end
function HandleUpdate(eventType, eventData)
if not serverConnection_ then return end
-- 处理输入,计算目标方向
if input:GetKeyDown(KEY_A) then
targetYaw_ = targetYaw_ + 3.0
end
if input:GetKeyDown(KEY_D) then
targetYaw_ = targetYaw_ - 3.0
end
-- 发送到服务器
serverConnection_.controls.yaw = targetYaw_
end
```
5.3 服务器读取输入
```lua
-- ========
-- 服务器:在 Update 中读取玩家输入
-- ========
-- 存储所有玩家连接
local playerConnections_ = {} -- { [connKey] = { connection, playerData, ... } }
function HandleUpdate(eventType, eventData)
local dt = eventData["TimeStep"]:GetFloat()
for connKey, playerInfo in pairs(playerConnections_) do
local conn = playerInfo.connection
local playerData = playerInfo.playerData
-- 读取玩家输入
local yaw = conn.controls.yaw
local buttons = conn.controls.buttons
-- 更新玩家状态...
end
end
```
5.4 脉冲按键可靠传输(PulseButtonMask)
controls.buttons 通过 unreliable 通道发送(UDP/KCP),存在丢包风险。对于持续状态按键(如加速),丢一帧没关系,下一帧会补上;但对于脉冲按键(如跳跃、技能释放),按下只持续一帧,丢包后服务器永远看不到这次输入。
此外,当服务器帧率较低时,多个客户端帧的输入在同一个服务器 tick 内到达,最后一个覆盖前面的,也会导致脉冲按键丢失。
解决方案:使用 SetPulseButtonMask 指定哪些 bit 是脉冲按键,引擎会自动通过 reliable 通道传输这些位,并通过排队机制保证每次状态变化至少被服务器游戏逻辑看到一个 tick。
工作原理:
| 类型 | 通道 | 延迟 | 可靠性 |
|-----------|-------------------|-----------|----------|
| pulse | MSG_BUTTON_STATE | 多状态+1 | 保证送达 |
| (如JUMP) | reliable+ordered | tick | |
| 非pulse | MSG_CONTROLS | 零延迟 | 可能丢包 |
| (如BOOST) | unreliable | | |
注意事项:
- SetPulseButtonMask 只需在服务端调用一次,引擎会自动同步给客户端
- 只把脉冲按键(按一下触发、持续一帧)放入 mask,不要把持续按键放进去
- WASM 平台使用 WebSocket (TCP),不存在丢包问题,引擎会自动跳过 pulse 机制
6. 客户端渲染组件创建
┌─────────┐
│ 服务器流程 │
├─────────┤
│ 1. 创建节点 │
│ 2. 设置 Vars │
│ + Script │
│ Object │
│ 3. 网络同步 │
│ (节点+Vars│
│ +Script) │
└────────┘
↓ 同步
┌─────────┐
│ 客户端流程 │
├─────────┤
│ 1. 收到数据 │
│ 2. 创建节点 │
│ 3. 同步 Vars │
│ 4. 加载 │
│ Script │
│ Object │
│ 5. Delayed │
│ Start │
│ (Vars就绪)│
│ 6. 创建本地 │
│ 渲染组件 │
└────────┘
7. 网络节点生命周期
7.1 创建节点
```lua
-- 服务器创建 REPLICATED 节点
local node = scene:CreateChild("Entity", REPLICATED)
node.position = spawnPosition
node:SetVar("EntityId", Variant(entityId))
```
7.2 移除节点(重要!)
必须使用 Dispose() 而不是 Remove():
```lua
-- ❌ 错误:直接调用 Remove()
-- node:Remove() -- 客户端可能长时间看不到节点消失
-- ✅ 正确:使用 Dispose()
node:Dispose()
```
原因详解:
引擎使用引用计数管理节点生命周期。Remove() 依赖 GC,删除时机不可控;Dispose() 立即生效,确保客户端同步。
7.3 客户端处理节点移除
```lua
function Start()
SubscribeToEvent(scene_, "NodeRemoved", "HandleNodeRemoved")
end
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
```
8. SmoothedTransform(位置插值)
8.1 工作原理
引擎为所有 REPLICATED 节点自动创建 SmoothedTransform 组件,在网络数据包之间平滑插值位置,避免卡顿感。
8.2 相机跟随注意事项
不要在相机跟随代码中添加额外的 lerp 平滑,否则会导致相机抖动:
```lua
-- ❌ 错误:额外的 lerp 会导致相机与物体的相对位置每帧都在变化
-- local newX = currentPos.x + (targetPos.x - currentPos.x) * smoothSpeed * dt
-- ✅ 正确:直接跟随目标位置,保持相对位置恒定
cameraNode_.position = Vector3(targetPos.x, currentPos.y, targetPos.z)
```
9. 兴趣管理(Interest Management)
9.1 NetworkPriority 组件
```lua
local priority = entityNode:CreateComponent("NetworkPriority", REPLICATED)
priority.basePriority = 100.0 -- 基础优先级
priority.distanceFactor = 0.5 -- 距离因子
priority.minPriority = 0.0 -- 最小优先级
priority.alwaysUpdateOwner = true -- 始终向拥有者更新
```
9.2 Observer Position(观察者位置)
```lua
function HandleUpdate(eventType, eventData)
if not serverConnection_ then return end
serverConnection_.position = cameraNode_.worldPosition
serverConnection_.rotation = cameraNode_.worldRotation
end
```
10. 网络统计与调试
10.1 连接统计 API
```lua
function GetClientStats(connection)
return {
rtt = connection.roundTripTime,
bytesIn = connection.bytesInPerSec,
bytesOut = connection.bytesOutPerSec,
}
end
```
10.2 Ban 机制
```lua
connection:Ban()
```
11. 连接管理
11.1 场景关联的时序问题(重要!)
规则:
- 客户端先设置 serverConnection.scene = scene_
- 客户端发送 ClientReady 事件通知服务器
- 服务器收到 ClientReady 后才设置 connection.scene = scene_
11.2 Scene 是网络同步的必要媒介(重要!)
即使是不需要 3D 场景的游戏,也必须创建一个 Scene 对象。
11.3 服务器端连接处理
```lua
local playerConnections_ = {}
function Start()
Shared.RegisterServerEvents()
SubscribeToEvent("ClientConnected", "HandleClientConnected")
SubscribeToEvent("ClientDisconnected", "HandleClientDisconnected")
SubscribeToEvent(Shared.EVENTS.CLIENT_READY, "HandleClientReady")
end
function HandleClientConnected(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local connKey = GetConnectionKey(connection)
playerConnections_[connKey] = {
connection = connection,
playerId = GeneratePlayerId(),
playerData = nil,
}
end
function HandleClientReady(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local connKey = GetConnectionKey(connection)
local playerInfo = playerConnections_[connKey]
if not playerInfo then return end
connection.scene = scene_
end
function HandleClientDisconnected(eventType, eventData)
local connection = eventData["Connection"]:GetPtr("Connection")
local connKey = GetConnectionKey(connection)
local playerInfo = playerConnections_[connKey]
if playerInfo then
if playerInfo.playerNode then
playerInfo.playerNode:Dispose()
end
playerConnections_[connKey] = nil
end
end
function GetConnectionKey(connection)
if connection then
return tostring(connection:GetAddress()) .. ":" .. tostring(connection:GetPort())
end
return nil
end
```
11.4 客户端连接处理
正常模式(默认)
```lua
local serverConnection_ = nil
function Start()
Shared.RegisterClientEvents()
serverConnection_ = network:GetServerConnection()
serverConnection_.scene = scene_
serverConnection_:SendRemoteEvent(Shared.EVENTS.CLIENT_READY, true)
SubscribeToEvent(Shared.EVENTS.ASSIGN_PLAYER, "HandleAssignPlayer")
SubscribeToEvent("ServerDisconnected", "HandleServerDisconnected")
end
```
后台匹配模式(Background Match)
```lua
local serverConnection_ = nil
function Start()
Shared.RegisterClientEvents()
SubscribeToEvent("ServerReady", "HandleServerReady")
SubscribeToEvent("ServerDisconnected", "HandleServerDisconnected")
end
function HandleServerReady(eventType, eventData)
serverConnection_ = network:GetServerConnection()
serverConnection_.scene = scene_
serverConnection_:SendRemoteEvent(Shared.EVENTS.CLIENT_READY, true)
end
```
常驻服模式(Persistent World)
服务器实例持续运行,玩家可以随时加入、随时离开。
a.服务器全局变量:
| 变量 | 类型 | 说明 |
|------|------|------|
| `PERSISTENT_WORLD_KEY` | string | 当前常驻服的房间标识 |
b.与普通匹配模式的关键编码差异:
| 要点 | 普通匹配 | 常驻服 |
|------|---------|--------|
| 玩家加入时 | 只在开局时处理 | 任何时刻都可能有新玩家加入 |
| 获取在线人数 | `SERVER_REGISTERED_PLAYERS` | `network:GetClientConnections()` 实时查询 |
11.5 获取玩家昵称
使用全局函数 GetUserNickname 批量查询玩家昵称。该接口服务端和客户端通用。
服务端时序要求:user_id 在 ClientIdentity 事件中才可用。
12. 常见问题与解决方案
12.1 Can not handle LoadScene message without an assigned scene
确保正确的初始化时序:客户端先设置 serverConnection.scene,再发送 ClientReady,服务器收到后才设置 connection.scene。
12.2 客户端看不到服务器创建的实体
检查:是否遵循正确的初始化时序、服务器是否附加了 ScriptObject、客户端是否错误使用了默认模式(REPLICATED)创建节点。
12.3 客户端创建的节点与服务器冲突
客户端所有节点和组件都必须用 LOCAL。
12.4 实体移动时抖动
移除相机跟随中的额外平滑,直接跟随目标位置。
12.5 节点删除后客户端还能看到
使用 Dispose() 而不是 Remove()。
12.6 音效没有播放
服务器发送远程事件,客户端本地播放。
12.7 远程事件数据为空
确保使用 Variant() 包装数据。
12.8 远程事件收不到
接收方必须调用 RegisterRemoteEvent 注册事件。
12.9 NodeAdded 中 GetVar 返回空值
使用 ScriptObject 的 DelayedStart() 回调。


因为内容超多,花了1500积分!找塔拉拉要的文档。因为Taptap发帖格式,不能直接复制(直接粘贴格式会乱),并且为了适配移动端窄屏,每一行都是我手动复制的改的格式,每一个"• 游戏实体节点"这种格式,都是我手动将复杂表格转化的。
纯做慈善了,看完点个赞
,不过分吧![[表情_吃瓜]](https://img-tc.tapimg.com/market/images/d07b262774c8a022a7dddbc39683da6b.png)
,不过分吧![[表情_吃瓜]](https://img-tc.tapimg.com/market/images/d07b262774c8a022a7dddbc39683da6b.png)

