Skip to content

06 把服务端结果告诉客户端 ​

建立双端消息通道,客户端先监听再请求,用日志核对自己的积分和回合状态。

服务端已经知道分数,但画面属于客户端。这课先让客户端在日志里收到自己的分数、当前局号和结束时间,下一课再显示到 HUD。

开始前检查 ​

第 05 课的服务端个人计分正确。本课不新增场景和 UI。你将第一次创建 common/remote_events.lua,让两端用同一份名字定义消息通道。

1. 安装完整的三文件工程 ​

文件:common/remote_events.lua、server/main.lua、client/main.lua。操作:下载第 06 课检查点,新增 common 下的文件,另外两个入口整文件替换,全部保存同步后试玩。

预期:启动时客户端就收到 [推球][快照]。命中后,服务端有“得分”,自己的客户端随后有相同分数的“快照”。

2. 看懂共同的通道名 ​

文件:common/remote_events.lua。操作:确认完整内容如下;两端不要各自拼写另一个名字。

lua
-- @runtime client
-- ============================================================================
-- 模块职责:定义双端通信所需的 RemoteEvent 单例
-- 说明:本文件由 client 和 server 分别 require 加载;首行标记仅用于静态类型检查。
--   - Request:  客户端向服务端发送的操作请求(如准备就绪 ready、请求重开 restart)
--   - Snapshot: 服务端向客户端单播下发的状态快照数据
-- ============================================================================
return {
    Request = RemoteEvent.New("PushBallGuide.Request"),
    Snapshot = RemoteEvent.New("PushBallGuide.Snapshot"),
}

require("common.remote_events") 意思是加载工程里的 common/remote_events.lua,并得到它 return 返回的 table。这个文件由两端各自加载;首行 client 是示例编译验证声明,不表示 server 不能加载。

Request 用来发请求,Snapshot 用来收服务端当前结果。每次消息只传一个 table,多项内容放进它的字段里。

3. 由服务端提供事实 ​

文件:server/main.lua 的 snapshot 区域。操作:阅读解释片段,不另行追加。

lua
-- [单播快照] 将当前权威状态组装为快照,发送给指定单个玩家
local function syncPlayer(player)
    RemoteEvents.Snapshot:FireClient(player, {
        version = 1,
        roundId = roundId,
        revision = revision,
        phase = phase,
        endTime = endTime,
        score = scores[player.UserId] or 0,  -- 仅包含该玩家自己的得分
    })
end

-- [全员广播] 遍历所有在线玩家,逐个单播快照
local function syncAll()
    for _, player in ipairs(Players:GetPlayers()) do
        syncPlayer(player)
    end
end

这是“快照”:把现在的状态装在一起发送。首次收到快照就知道当前情况,不必等待下一次得分。score 只取目标玩家的表项;客户端不传分数或目标玩家。

revision 是服务端状态变化时增加的编号,客户端丢弃比已收到结果更旧的快照,避免旧结果覆盖新结果。

4. 先监听,再请求,并处理慢启动 ​

文件:client/main.lua 的 ready 区域。操作:阅读这一段的顺序。

lua
-- [监听建立与请求发起] 遵循「先订阅、后请求」原则
-- 1. 先建立快照事件监听,确保服务端响应时回调已准备好
table.insert(connections, RemoteEvents.Snapshot.OnClientEvent:Connect(receiveSnapshot))

-- 2. 本地玩家离开时主动清理客户端资源
table.insert(connections, Players.PlayerRemoving:Connect(function(player)
    if player == localPlayer then destroy() end
end))

-- 3. 心跳循环中的定时请求驱动:
--    - 尚未拿到首个快照时:每 1 秒重试一次 ready 请求(解决服务端未初始化完的慢启动)
--    - 已拿到快照后:放缓至每 5 秒保活一次,保证断线或丢包后能够自动恢复
local function update()
    if not alive then return end
    localPlayer = Players.LocalPlayer or localPlayer
    local now = World:GetServerTime()
    if now >= nextRequestTime then
        nextRequestTime = now + (snapshot and 5 or 1)
        RemoteEvents.Request:FireServer({ kind = "ready", version = 1 })
    end
end

table.insert(connections, RunService.Heartbeat:Connect(update))
update()

先建立 OnClientEvent 监听,再发送 ready。第一次请求可能早于服务端完成启动,因此没收到状态时每秒重试;收到后每 5 秒补一次当前快照,用来恢复偶发遗漏。服务端也按玩家和请求种类限频。

客户端在本地玩家离开时调用 destroy(),断开消息和更新监听。停止整个试玩会结束这次 Lua 运行环境;如果在仍运行的游戏中主动卸载入口,加载它的代码必须显式调用返回的 Destroy()。return { Destroy = ... } 只是提供这个入口,不会自动执行它。服务端使用的 BindToClose 是服务器关闭钩子,不把它当作客户端退出事件。

双端状态同步时序 ​

客户端与服务端的典型握手与快照同步流程如下:

text
[ 客户端 Client ]                                [ 服务端 Server ]
       │                                                 │
  1. 先订阅 Snapshot 事件                                │
       │                                                 │
  2. 发送 ready 请求 ────── FireServer({kind="ready"}) ───>│
       │                                                 │ 3. 校验玩家合法性
       │                                                 │ 4. 组装该玩家的独享快照
       │<--- FireClient(player, snapshot) ----------------┘    (包含该玩家真实得分)
       │
  5. receiveSnapshot(snapshot)
     更新本地分数与倒计时状态

为什么通信协议必须放在 common/ 目录下?

  • RemoteEvent.New("PushBallGuide.Request") 的通道名称在客户端和服务端必须完全一致。
  • 若两端各自手写字符串,哪怕大小写或多一个空格,都会导致双端断联、消息丢失。把通道声明在 common/remote_events.lua 并由双端同时 require,能从根源保证单一真源、永不打架。

运行并核对 ​

文件:无需修改。操作:启动后观察首次快照,再推球一次,最后等到结算。

预期:快照中的局号正确;自己的客户端分数与服务端一致;结算后 phase 为 waiting,新局开始变回 running 且分数清零。若有真实第二客户端,让它在本局中途进入,应该直接收到正在进行这一局的状态。

只有服务端日志时,检查 common 文件路径和大小写→两端通道名是否相同→客户端是否保存同步成功。不要从服务器复制分数到客户端变量来“修复显示”。

自己改一处 ​

文件:client/main.lua。操作:在 receiveSnapshot 的 print 文字里,只把“我的分数:”改为“我本局获得:”。

预测:是否会改变积分?提示:print 只输出文字,计分仍在服务器。答案:客户端日志措辞改变,积分不变。改回原文并保存。不要改 table 字段 score,它是两端约定的数据名。

保存进度 ​

下载第 06 课完整代码。下一课:把分数和时间显示在画面上。

API:RemoteEvent、Players、World、RunService。深读:客户端与服务端通信。