Skip to content

第 8 章:客户端与服务端通信

用 RemoteEvent 建立可校验的双端协议,让服务端保持玩法权威。

你会学到什么

  • RemoteEvent 的通信方向和参数规则。
  • 为什么事件定义建议放在 common/remote_events.lua
  • 客户端如何发请求,服务端如何校验并回传。
  • 服务端防抖、payload 校验和常见安全错误。

RemoteEvent 通信方向

方法调用端接收端说明
FireServer(payload?)clientserver客户端向服务端发送一个可选载荷
FireClient(player, payload?)serverclient服务端向指定客户端发送一个可选载荷
FireAllClients(payload?)server所有 client服务端向所有客户端广播一个可选载荷
OnServerEventserver监听客户端请求
OnClientEventclient监听服务端消息

核心原则:服务端是权威方。客户端只能请求和显示,分数、血量、奖励、存档等关键状态必须由服务端决定。

当前 RemoteEvent 公开签名每次只接受一个可选的 Any 载荷。需要传多个字段时,把它们装进同一个 table;不要照搬支持任意位置参数的其他事件系统。

换句话说,客户端发来的不是“事实”,而是“我想做某件事”的请求。服务端收到以后,要重新判断这个请求是否允许发生。

校验层要问的问题示例
身份这个请求是谁发来的?OnServerEvent(player, payload) 拿真实玩家,不让客户端传 player
类型参数类型是否符合预期?本例 payload 必须是 table,且字段类型正确
频率是否刷得太快?同一玩家 0.5 秒内只处理一次 HUD 快照请求
业务条件玩家是否真的满足条件?是否在回合中、是否碰到目标、是否有足够货币
结果下发客户端应该看到什么?服务端读取权威状态后再 FireClientFireAllClients

第一轮教程用“请求当前 HUD 快照”演示类型、schema 和频率校验。快照同时包含分数、回合阶段和结束时间;请求是只读且幂等的。购买、奖励、伤害、传送、存档等写操作还必须补完整业务条件,不能套用这个只读示例后就直接放行。

推荐结构:统一事件定义

不要在 client/main.luaserver/main.lua 里各写一遍事件名。推荐放到 common/remote_events.lua

运行端:common
文件:common/remote_events.lua

lua
return {
    RequestHudSnapshot = RemoteEvent.New("RequestHudSnapshot"),
    HudSnapshot = RemoteEvent.New("HudSnapshot"),
}

这样 client 和 server 都 require 同一个表,事件名只写一次,不容易拼错。

通信流程图

text
client/main.lua                 server/main.lua
  |                                   |
  |  先监听 HudSnapshot               |
  |  RequestHudSnapshot(payload) ───► |
  |                                   |  OnServerEvent(player)
  |                                   |  schema + 防抖检查
  |                                   |  读取服务端权威分数
  |                                   |  FireClient(player, snapshot)
  |  ◄─────────────────────────────── |
  |  OnClientEvent(snapshot)          |
  |  更新 UI / 打印分数               |

服务端代码

运行端:server
文件:server/main.lua

lua
local World = game:GetService("World")
local Players = game:GetService("Players")
local RemoteEvents = require("common.remote_events")

if not World or not Players then
    print("[SE Lua Guide][ERROR] World 或 Players 服务获取失败")
    return
end

-- 分数只由服务端玩法逻辑修改;本章的客户端请求不会改变它。
local scores = {}
local roundState = {
    phase = "waiting",
    endTime = 0,
}
local lastRequestTime = {}
local REQUEST_COOLDOWN = 0.5

RemoteEvents.RequestHudSnapshot.OnServerEvent:Connect(function(player, payload)
    -- 1. schema 校验:只接受明确的 table 请求。
    if type(payload) ~= "table"
        or payload.kind ~= "hud_snapshot"
        or type(payload.reason) ~= "string" then
        print("[SE Lua Guide][WARN] 非法快照请求:", player:GetName())
        return
    end

    -- 2. 频率校验:同一玩家不能无限连发。
    local now = World:GetServerTime()
    local userId = player.UserId
    local last = lastRequestTime[userId]
    if last and now - last < REQUEST_COOLDOWN then
        return
    end
    lastRequestTime[userId] = now

    -- 3. 只读取权威状态,再把多字段装入一个 table 回传。
    RemoteEvents.HudSnapshot:FireClient(player, {
        score = scores[userId] or 0,
        phase = roundState.phase,
        endTime = roundState.endTime,
        serverTime = now,
    })
end)

Players.PlayerRemoving:Connect(function(player)
    lastRequestTime[player.UserId] = nil
    scores[player.UserId] = nil
end)

OnServerEvent 的第一个参数永远是 player,由引擎自动注入。客户端调用 FireServer 时不要把玩家对象作为参数传上来。

客户端代码

运行端:client
文件:client/main.lua

lua
local UserInputService = game:GetService("UserInputService")
local RemoteEvents = require("common.remote_events")

if not UserInputService then
    print("[SE Lua Guide][ERROR] UserInputService 获取失败")
    return
end

-- 先监听服务端结果,再发任何请求,避免响应先到而监听尚未建立。
RemoteEvents.HudSnapshot.OnClientEvent:Connect(function(snapshot)
    if type(snapshot) ~= "table"
        or type(snapshot.score) ~= "number"
        or type(snapshot.phase) ~= "string"
        or type(snapshot.endTime) ~= "number"
        or type(snapshot.serverTime) ~= "number" then
        print("[SE Lua Guide][WARN] 收到非法 HUD 快照")
        return
    end
    print("[SE Lua Guide] HUD 快照:", snapshot.phase, snapshot.score)
end)

local function RequestHudSnapshot(reason)
    RemoteEvents.RequestHudSnapshot:FireServer({
        kind = "hud_snapshot",
        reason = reason,
    })
end

UserInputService.InputBegan:Connect(function(inputObject, gameProcessedEvent)
    if gameProcessedEvent then return end
    if inputObject.UserInputType == Enums.UserInputType.Keyboard
        and inputObject.KeyCode == Enums.KeyCode.B then
        -- B 键只请求刷新显示,不直接要求服务端加分。
        RequestHudSnapshot("key_b")
    end
end)

-- 监听完成后进行一次 ready/snapshot 握手,覆盖迟加入和重连场景。
RequestHudSnapshot("client_ready")

期望结果

  1. 客户端先建立 HudSnapshot 监听,再发送 ready 快照请求,不传 player
  2. 服务端收到 OnServerEvent(player, payload),检查 schema 和频率。
  3. 服务端读取既有权威分数,请求本身不加分。
  4. 服务端用 FireClient(player, snapshot) 把单个 table 载荷发回该玩家。
  5. 客户端验证快照中的 scorephaseendTimeserverTime 后再更新显示。
  6. 玩家按 B 键时可以再次刷新;0.5 秒内的重复请求会被忽略。

进阶:广播给所有玩家

如果所有客户端都要看到同一个状态,例如“本局开始”或“全场倒计时”,server 可以调用 FireAllClients({ phase = "playing", endTime = endTime })。这仍然是一个 table 载荷;其中的 endTime 必须来自服务端权威状态。

广播也应由服务端触发,客户端不要自己决定全局状态。

常见错误

错误:客户端传 player

错误写法是客户端把 Players.LocalPlayer 塞进请求,试图告诉服务端“我是谁”。

正确做法是服务端从 OnServerEvent 的第一个参数拿到真实玩家。

错误:事件名两端各写一遍

两端分别维护 RemoteEvent.New("RequestHudSnapshot") 会让名称和事件集合容易改漏。统一放在 common/remote_events.lua,让两端 require 同一份定义。

错误:服务端不校验

客户端是不可信的。所有奖励、扣血、购买、传送、存档请求都必须在服务端校验参数、频率和业务条件。

错误:脚本一加载就 FireServer

玩法动作的 FireServer 应由按键、按钮或交互触发,不能靠脚本加载自动伪造一次动作。本章的 client_ready 是明确的只读快照握手:它在监听建立后发送,服务端幂等处理并做频率限制,不等于自动执行玩法行为。

练习任务

  1. REQUEST_COOLDOWN 改成 2 秒,观察快速按键时是否被忽略。
  2. 把服务端 roundState.phase 改成 playing,设置 endTime,观察 client 收到的新快照。
  3. 在回合状态变化时,用同一个 HudSnapshot 的 table schema 主动推送给相关客户端。
  4. 故意把客户端 payload 改成字符串,确认服务端会拒绝。
  5. 给自己的一个 RemoteEvent 写一张“身份 / 类型 / 频率 / 业务条件 / 结果下发”校验表。

本章验收标准

  • [ ] 我知道 RemoteEvent 的通信方向。
  • [ ] 我能把事件统一定义在 common/remote_events.lua
  • [ ] 我知道客户端 FireServer 不传 player。
  • [ ] 我能实现服务端防抖和 payload 校验。
  • [ ] 我知道客户端只请求,服务端才判定。
  • [ ] 我能把客户端请求拆成请求意图、服务端校验和结果下发三步。

本章产物

  • common/remote_events.lua:统一定义双端事件。
  • 服务端 schema、防抖和只读快照处理。
  • 客户端先监听并请求、服务端读取权威状态、服务端回传的完整通信链路。

这些产物会在第 22 章整合为 RequestHudSnapshot / HudSnapshot 请求—快照链。真实发布前建议补一条“payload 类型错误被服务端拒绝”的失败复盘。

本章 API 对照

下一章预告

通信打通了。下一章我们将从 UI 编辑器过渡到 Lua 控制 UI:用 EUIManager 显示分数和倒计时,并用按钮触发 RemoteEvent 请求。