主题
第 8 章:客户端与服务端通信
用 RemoteEvent 建立可校验的双端协议,让服务端保持玩法权威。
你会学到什么
- RemoteEvent 的通信方向和参数规则。
- 为什么事件定义建议放在
common/remote_events.lua。 - 客户端如何发请求,服务端如何校验并回传。
- 服务端防抖、payload 校验和常见安全错误。
RemoteEvent 通信方向
| 方法 | 调用端 | 接收端 | 说明 |
|---|---|---|---|
FireServer(payload?) | client | server | 客户端向服务端发送一个可选载荷 |
FireClient(player, payload?) | server | client | 服务端向指定客户端发送一个可选载荷 |
FireAllClients(payload?) | server | 所有 client | 服务端向所有客户端广播一个可选载荷 |
OnServerEvent | server | — | 监听客户端请求 |
OnClientEvent | client | — | 监听服务端消息 |
核心原则:服务端是权威方。客户端只能请求和显示,分数、血量、奖励、存档等关键状态必须由服务端决定。
当前 RemoteEvent 公开签名每次只接受一个可选的 Any 载荷。需要传多个字段时,把它们装进同一个 table;不要照搬支持任意位置参数的其他事件系统。
换句话说,客户端发来的不是“事实”,而是“我想做某件事”的请求。服务端收到以后,要重新判断这个请求是否允许发生。
| 校验层 | 要问的问题 | 示例 |
|---|---|---|
| 身份 | 这个请求是谁发来的? | 从 OnServerEvent(player, payload) 拿真实玩家,不让客户端传 player |
| 类型 | 参数类型是否符合预期? | 本例 payload 必须是 table,且字段类型正确 |
| 频率 | 是否刷得太快? | 同一玩家 0.5 秒内只处理一次 HUD 快照请求 |
| 业务条件 | 玩家是否真的满足条件? | 是否在回合中、是否碰到目标、是否有足够货币 |
| 结果下发 | 客户端应该看到什么? | 服务端读取权威状态后再 FireClient 或 FireAllClients |
第一轮教程用“请求当前 HUD 快照”演示类型、schema 和频率校验。快照同时包含分数、回合阶段和结束时间;请求是只读且幂等的。购买、奖励、伤害、传送、存档等写操作还必须补完整业务条件,不能套用这个只读示例后就直接放行。
推荐结构:统一事件定义
不要在 client/main.lua 和 server/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")期望结果
- 客户端先建立
HudSnapshot监听,再发送 ready 快照请求,不传player。 - 服务端收到
OnServerEvent(player, payload),检查 schema 和频率。 - 服务端读取既有权威分数,请求本身不加分。
- 服务端用
FireClient(player, snapshot)把单个 table 载荷发回该玩家。 - 客户端验证快照中的
score、phase、endTime、serverTime后再更新显示。 - 玩家按 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 是明确的只读快照握手:它在监听建立后发送,服务端幂等处理并做频率限制,不等于自动执行玩法行为。
练习任务
- 把
REQUEST_COOLDOWN改成 2 秒,观察快速按键时是否被忽略。 - 把服务端
roundState.phase改成playing,设置endTime,观察 client 收到的新快照。 - 在回合状态变化时,用同一个
HudSnapshot的 table schema 主动推送给相关客户端。 - 故意把客户端 payload 改成字符串,确认服务端会拒绝。
- 给自己的一个 RemoteEvent 写一张“身份 / 类型 / 频率 / 业务条件 / 结果下发”校验表。
本章验收标准
- [ ] 我知道 RemoteEvent 的通信方向。
- [ ] 我能把事件统一定义在
common/remote_events.lua。 - [ ] 我知道客户端
FireServer不传 player。 - [ ] 我能实现服务端防抖和 payload 校验。
- [ ] 我知道客户端只请求,服务端才判定。
- [ ] 我能把客户端请求拆成请求意图、服务端校验和结果下发三步。
本章产物
common/remote_events.lua:统一定义双端事件。- 服务端 schema、防抖和只读快照处理。
- 客户端先监听并请求、服务端读取权威状态、服务端回传的完整通信链路。
这些产物会在第 22 章整合为 RequestHudSnapshot / HudSnapshot 请求—快照链。真实发布前建议补一条“payload 类型错误被服务端拒绝”的失败复盘。
本章 API 对照
下一章预告
通信打通了。下一章我们将从 UI 编辑器过渡到 Lua 控制 UI:用 EUIManager 显示分数和倒计时,并用按钮触发 RemoteEvent 请求。
