主题
第 3 章:统一世界、双端模型与逻辑帧
理解统一世界中的双端职责、服务端权威与逻辑帧更新边界。
你会学到什么
- 世界版"统一运转的世界"在脚本里的含义。
- server 和 client 的职责分工。
- 逻辑帧时序和 Heartbeat 的使用。
- 如何用
World:GetServerTime()做倒计时。
统一世界
在世界版中,所有玩家连入同一个持续运转的世界。这意味着:
- 服务端(server):权威状态。计分、倒计时、结算、存储、命中判定。
- 客户端(client):本地表现。UI 显示、输入响应、相机、音效。
- common:双端共享的常量、数据定义和纯工具函数;不要在这里保存权威的可变状态。
如果你把分数放在客户端,玩家可以修改本地数据来作弊。所以权威状态必须在服务端。
从编辑器经验看,你可以把它理解成:触发器、计时器、积分板这些可视化配置在单人调试时看起来都在“本地发生”,但一旦进入多人同场地图,就必须拆成两类问题:谁来判定事实,谁来显示结果。Lua 中的 server / client 分层,就是把这个边界写清楚。
先问三句话
初学双端模型时,不要先背 API。每写一段逻辑,先问三句话:
| 问题 | 通常答案 | 例子 |
|---|---|---|
| 谁判定事实? | server | 是否得分、是否结算、是否保存最高分 |
| 谁显示结果? | client | HUD 文本、按钮状态、音效、相机反馈 |
| 失败时谁兜底? | server 保留权威状态,client 做降级提示 | UI 节点没找到时打印 WARN,但回合仍继续 |
这个判断比“代码放哪个文件夹”更重要。目录只是结果:判定事实的逻辑自然进入 server/,本地表现自然进入 client/,两端都要读的常量和纯工具才进入 common/。
逻辑帧时序
SE 的逻辑帧通常每秒约 30 次,按以下顺序执行。实际间隔可能波动,因此需要按时间推进的逻辑应读取事件给出的 deltaTime,不要把“一帧”硬编码成固定秒数。
text
PreSimulation → 物理模拟 → PostSimulation → Heartbeat → FrameUpdate → PostFrameUpdate最常用的是 Heartbeat,每帧触发一次,适合周期性检查。
最小示例:服务端倒计时
运行端:server
文件:server/main.lua
lua
local RunService = game:GetService("RunService")
local World = game:GetService("World")
-- 设定 10 秒倒计时
local startTime = World:GetServerTime()
local duration = 10
local nextLogTime = 0
local heartbeatConnection
-- Heartbeat:每个逻辑帧触发一次
heartbeatConnection = RunService.Heartbeat:Connect(function()
local now = World:GetServerTime()
local remaining = duration - (now - startTime)
if remaining <= 0 then
print("[SE Lua Guide] 倒计时结束!")
-- 不再需要逐帧检查时,主动断开连接。
heartbeatConnection:Disconnect()
heartbeatConnection = nil
return
end
-- 节流:每秒只打印一次,避免每帧刷屏
if now >= nextLogTime then
nextLogTime = now + 1
print("[SE Lua Guide] 剩余时间:", math.ceil(remaining), "秒")
end
end)进阶:用 IsServer 区分运行端
在 common 代码中,可以用 RunService 判断当前运行端:
lua
local RunService = game:GetService("RunService")
if RunService:IsServer() then
print("这是服务端")
elseif RunService:IsClient() then
print("这是客户端")
endHeartbeat 的参数
lua
RunService.Heartbeat:Connect(function(deltaTime, maxTaskCount)
-- deltaTime:本次逻辑帧的时间长度(秒)
-- maxTaskCount:本帧可执行的最大任务数,入门阶段通常不用
end)大部分玩法只需要 deltaTime。Lua 回调允许少接参数,所以不使用 maxTaskCount 时,也可以写成 function(deltaTime) ... end。
客户端如何显示服务端倒计时
上面的倒计时完全在服务端运行,只在服务端日志中输出。但客户端的 UI 也需要显示剩余时间,怎么做?
核心思路分三步:
- 客户端先完成监听并向服务端报告“我已准备好”,避免服务端在客户端订阅前就把开局消息发掉。
- 服务端保存权威的回合状态,并把“状态快照”发给已准备好的客户端。快照至少包含当前阶段和结束时间。
- 客户端缓存快照,用自己的
Heartbeat刷新显示;新加入或重连时再次请求最新快照。
这里需要用到 RemoteEvent(远程事件)。它是客户端和服务端之间通信的桥梁。当前公开契约中,每次发送只有一个可选的 Any 参数;需要传多个字段时,应把它们装进同一个 table,而不是写成多个位置参数。
第 8 章会给出完整的“先监听、再握手、后同步”代码。本章先记住数据流和权威边界,避免复制一个存在启动竞态的半成品。
数据流向
text
服务端 客户端
| |
| ◄──────── 客户端已准备好 ──────── |
| |
| 回合快照 { phase, endTime } |
| ───────────────────────────────► |
| | 缓存快照
| | Heartbeat: 计算 remaining 并显示
| |
| Heartbeat: 倒计时结束 |
| 新的权威状态快照 |
| ───────────────────────────────► |
| | 停止显示倒计时关键理解:客户端的倒计时只是显示。游戏是否真正结束,由服务端的 Heartbeat 判定。即使客户端的显示有误差,也不会影响游戏逻辑。
常见错误
错误:客户端决定游戏结算
客户端 UI 可以显示倒计时,但游戏是否结束应由服务端决定。否则玩家可以伪造本地状态。
错误:Heartbeat 里每帧打印
Heartbeat 每秒触发约 30 次,如果不节流,日志会被刷屏。用 nextLogTime 控制打印频率。
错误:Heartbeat 里做重活
不要在 Heartbeat 里创建对象、频繁查找或做复杂计算。它适合轻量级的状态检查。
练习任务
- 把倒计时改成 5 秒,观察日志变化。
- 在
client/main.lua中打印RunService:IsClient()的结果。 - 思考:为什么不应在客户端直接写入玩家分数?
本章验收标准
- [ ] 我理解 server 是权威方,client 负责表现。
- [ ] 我知道 Heartbeat 每帧触发,需要节流。
- [ ] 我能用
World:GetServerTime()做倒计时。 - [ ] 我能用
RunService:IsServer()区分运行端。
本章产物
本章产物是服务端权威倒计时。第 22 章 server/round_manager.lua 中的 updateRound() 用 World:GetServerTime() 判断回合结束,client 只显示服务端下发的结束时间。
本章 API 对照
下一章预告
你学会了 Heartbeat 倒计时和服务端权威。下一章我们将正式学习 game 和 Services——如何获取世界、玩家、运行时等服务。
