Skip to content

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

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

你会学到什么

  • 世界版"统一运转的世界"在脚本里的含义。
  • 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

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("这是客户端")
end

Heartbeat 的参数

lua
RunService.Heartbeat:Connect(function(deltaTime, maxTaskCount)
    -- deltaTime:本次逻辑帧的时间长度(秒)
    -- maxTaskCount:本帧可执行的最大任务数,入门阶段通常不用
end)

大部分玩法只需要 deltaTime。Lua 回调允许少接参数,所以不使用 maxTaskCount 时,也可以写成 function(deltaTime) ... end

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

上面的倒计时完全在服务端运行,只在服务端日志中输出。但客户端的 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. 思考:为什么不应在客户端直接写入玩家分数?

本章验收标准

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

本章产物

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

本章 API 对照

下一章预告

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