UrhoX 联网游戏开发指南 (文档)

精华修改于04/22118 浏览开发心得
温馨提醒:本帖专业性较高,推荐专业人士直接食用,或喂给免费AI帮助食用。
你的嗒啦啦看到的就是下面这些内容,这篇帖子有助于你解决关于 服务端问题 和 引擎到底支持什么样的多人游戏。
horizontal linehorizontal line
概述
介绍如何使用 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 场景关联的时序问题(重要!)

规则
  1. 客户端先设置 serverConnection.scene = scene_
  2. 客户端发送 ClientReady 事件通知服务器
  3. 服务器收到 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() 回调。
horizontal linehorizontal line
因为内容超多,花了1500积分!找塔拉拉要的文档。因为Taptap发帖格式,不能直接复制(直接粘贴格式会乱),并且为了适配移动端窄屏,每一行都是我手动复制的改的格式,每一个"• 游戏实体节点"这种格式,都是我手动将复杂表格转化的。
纯做慈善了,看完点个赞[表情_+1],不过分吧[表情_吃瓜]
9
9
17