Skip to content

第 3 章:统一世界、双端模型与逻辑帧 ​

理解统一世界中的双端职责、服务端权威与逻辑帧更新边界。

开始前与本章路线 ​

  • 准备:先完成第 1 章两端日志实验;倒计时不依赖球体、UI 或其他模块。
  • 安装范围:三个小节分别运行,不拼接到同一入口;倒计时示例放 server/main.lua。
  • 本次要看到:10 秒后只打印一次结束,再等 2 秒没有新的倒计时日志。

停止试玩后再换入口;这些章节是能力实验,第 22 章才提供统一的六文件项目。每次只改变一个条件,并保留本次输出用于对照。

你会学到什么 ​

  • 世界版"统一运转的世界"在脚本里的含义。
  • server 和 client 的职责分工。
  • 逻辑帧时序和 Heartbeat 的使用。
  • 如何用 World:GetServerTime() 做倒计时。

统一世界 ​

在世界版中,所有玩家连入同一个持续运转的世界。这意味着:

  • 服务端(server):权威状态。计分、倒计时、结算、存储、命中判定。
  • 客户端(client):本地表现。UI 显示、输入响应、相机、音效。
  • common:双端共享的常量、数据定义和纯工具函数;不要在这里保存权威的可变状态。

如果你把分数放在客户端,玩家可以修改本地数据来作弊。所以权威状态必须在服务端。

从编辑器经验看,你可以把它理解成:触发器、计时器、积分板这些可视化配置在单人调试时看起来都在“本地发生”,但一旦进入多人同场地图,就必须拆成两类问题:谁来判定事实,谁来显示结果。Lua 中的 server / client 分层,就是把这个边界写清楚。

先问三句话 ​

初学双端模型时,不要先背 API。每写一段逻辑,先问三句话:

问题通常答案例子
谁判定事实?server是否得分、是否结算、是否保存最高分
谁显示结果?clientHUD 文本、按钮状态、音效、相机反馈
失败时谁兜底?server 保留权威状态,client 做降级提示UI 节点没找到时打印 WARN,但回合仍继续

这个判断比“代码放哪个文件夹”更重要。目录只是结果:判定事实的逻辑自然进入 server/,本地表现自然进入 client/,两端都要读的常量和纯工具才进入 common/。

逻辑帧时序 ​

SE 的逻辑帧通常每秒约 30 次,按以下顺序执行。实际间隔可能波动,因此需要按时间推进的逻辑应读取事件给出的 deltaTime,不要把“一帧”硬编码成固定秒数。

text
PreSimulation → 物理模拟 → PostSimulation → Heartbeat → FrameUpdate → PostFrameUpdate

最常用的是 Heartbeat,每帧触发一次,适合周期性检查。

最小示例:服务端倒计时 ​

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

示例 1|独立实验;整体替换 server/main.lua。 本次演示端:server。

lua
-- @runtime server
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 判断当前运行端:

示例 2|独立实验;整体替换 server/main.lua。 本次演示端:server。

lua
-- @runtime server
local RunService = game:GetService("RunService")

if RunService:IsServer() then
    print("这是服务端")
elseif RunService:IsClient() then
    print("这是客户端")
end

Heartbeat 的参数 ​

示例 3|独立实验;整体替换 server/main.lua。 本次演示端:server。

lua
-- @runtime server
local RunService = game:GetService("RunService")
RunService.Heartbeat:Once(function(deltaTime)
    -- deltaTime:本次逻辑帧的时间长度(秒)
    print("[SE Lua Guide] 一帧耗时:", deltaTime)
end)

当前公开契约只要求接收 deltaTime。它表示本次逻辑帧的时间长度,可用于按时间更新玩法状态。

客户端如何显示服务端倒计时 ​

上面的倒计时完全在服务端运行,只在服务端日志中输出。但客户端的 UI 也需要显示剩余时间,怎么做?

核心思路分三步:

  1. 客户端先完成监听并向服务端报告“我已准备好”,避免服务端在客户端订阅前就把开局消息发掉。
  2. 服务端保存权威的回合状态,并把“状态快照”发给已准备好的客户端。快照至少包含当前阶段和结束时间。
  3. 客户端缓存快照,用自己的 Heartbeat 刷新显示;新加入或重连时再次请求最新快照。

这里需要用到 RemoteEvent(远程事件)。它是客户端和服务端之间通信的桥梁。当前公开契约中,每次发送只有一个可选的 Any 参数;需要传多个字段时,应把它们装进同一个 table,而不是写成多个位置参数。

第 8 章会给出完整的“先监听、再握手、后同步”代码。本章先记住数据流和权威边界,避免复制一个存在启动竞态的半成品。

数据流向 ​

text
服务端                              客户端
  |                                   |
  | ◄──────── 客户端已准备好 ──────── |
  |                                   |
  |  回合快照 { phase, endTime }      |
  |  ───────────────────────────────► |
  |                                   |  缓存快照
  |                                   |  Heartbeat: 计算 remaining 并显示
  |                                   |
  |  Heartbeat: 倒计时结束            |
  |  新的权威状态快照                 |
  |  ───────────────────────────────► |
  |                                   |  停止显示倒计时

关键理解:客户端的倒计时只是显示。游戏是否真正结束,由服务端的 Heartbeat 判定。即使客户端的显示有误差,也不会影响游戏逻辑。

常见错误 ​

错误:客户端决定游戏结算 ​

客户端 UI 可以显示倒计时,但游戏是否结束应由服务端决定。否则玩家可以伪造本地状态。

错误:Heartbeat 里每帧打印 ​

Heartbeat 每秒触发约 30 次,如果不节流,日志会被刷屏。用 nextLogTime 控制打印频率。

错误:Heartbeat 里做重活 ​

不要在 Heartbeat 里创建对象、频繁查找或做复杂计算。它适合轻量级的状态检查。

练习任务 ​

  1. 把倒计时改成 5 秒,观察日志变化。
  2. 在 client/main.lua 中打印 RunService:IsClient() 的结果。
  3. 思考:为什么不应在客户端直接写入玩家分数?

练习参考与复核 ​

先完成练习,再展开对照;没有运行条件的项目填写“未验证”和缺少的条件。

参考思路与应观察的结果

把 duration 改成 5,只会改变本轮时长;Heartbeat 仍逐帧触发,日志每秒最多一次。客户端可以显示剩余秒数,但是否结束由 server 的当前时间和截止时间决定。

  • [ ] 我已完成本章选定实验,记录了上面的预期现象。
  • [ ] 我能故意改变一个前置条件,解释失败发生在哪一步,并在恢复后重跑。

本章验收标准 ​

  • [ ] 我理解 server 是权威方,client 负责表现。
  • [ ] 我知道 Heartbeat 每帧触发,需要节流。
  • [ ] 我能用 World:GetServerTime() 做倒计时。
  • [ ] 我能用 RunService:IsServer() 区分运行端。

本章产物 ​

本章产物是服务端权威倒计时。第 22 章 server/round_manager.lua 中的 updateRound() 用 World:GetServerTime() 判断回合结束,client 只显示服务端下发的结束时间。

本章 API 对照 ​

下一章预告 ​

你学会了 Heartbeat 倒计时和服务端权威。下一章我们将正式学习 game 和 Services——如何获取世界、玩家、运行时等服务。