CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

一个时间差值撑起全服秩序:TimeUtils时间工具精讲

2026-10-03 08:20 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

活动倒计时差三秒、Buff 提前过期、跨服战场两边时间对不上——这三个工单背后是同一个病灶:有人用了本地时间。996 引擎把时间封装在 util/TimeUtils.lua,一百来行的小文件,却是全服秩序的基石。今天把 GetServerTime、SecondToHMS 这几员大将挨个讲透,看完你就明白为什么老手对 os.time() 深恶痛绝。

时间工具这类代码有个特点:平时没人看它一眼,出事就是全服级事故。它和协议类型系统是一路货色——基础设施的最底层,改动成本极低、破坏力极大。所以这一篇的讲法跟别的篇不一样:重点不是"这段代码怎么写",是"这些封装为什么存在、绕过它们会发生什么"。每一行封装背后都对应一类真实事故,把事故记住了,封装就不用你操心。

一、一个差值撑起全服:GetServerTime

引擎的"当前时间"从来不是本地时间,是本地时间加一个差值:

lua
-- 获取服务器时间(根据服务器和本地的时间差,算出当前服务器的时间)
function GetServerTime()
    local sub = global.MMO.ServerTime_Sub or 0
    return os.time() + sub
end

ServerTime_Sub 是登录时从服务端下发的时间差,全引擎所有"什么时候过期""什么时候刷新"的判断,统一走这个函数。为什么这么较真?三笔账给你算清楚。

第一笔,玩家时钟不可信。客户端时钟是玩家可以随手改的——有人改时钟跳过活动等待,有人时区设置错误天生差八小时,还有笔记本主板电池没电天天归零。倒计时用的是 os.time(),改时钟就是白送的时光机。用 GetServerTime,差值锚在服务端,玩家改本地时钟只会让差值跟着变,服务器时间纹丝不动。

第二笔,倒计时的一致性。活动剩余时间由服务端按自己的钟算,客户端只负责展示。两端时间源不同,活动结束那一瞬间就会出现"客户端显示还有 30 秒,服务端已经发了结算邮件"的诡异场面,客服话术都编不圆。

第三笔,审计要能对账。Buff 那篇讲过 BuffEntity 的过期判断全是 GetServerTime() > self._endTime,两端用同一杆秤,日志拉出来两边时间线能对上,争议工单一查便知。时间源统一不是洁癖,是可运维性的前提。这条规矩的执行也简单:代码评审搜到 os.time() 直接打回,全项目只允许 TimeUtils 里的封装碰它。

差值机制还有一个衍生福利:服务器维护回档时,客户端只要重登一次拿到新差值,全场时间观感无缝衔接。要是客户端各自记绝对时间,回档后新旧时间戳混在一起,倒计时会出现"负数""超大数"两个极端。差值方案的容灾能力是白捡的——把"服务器时间"抽象成一个可重锚的引用点,比"各地记绝对时间"在每一次事故里都多一分从容。这也是为什么分布式系统里反复强调"不要信任任何节点的本地时钟",游戏服务端不过是这条铁律的一个小型战场。

二、SecondToHMS:一行显示规则,十年玩家习惯

倒计时的显示格式是个容易被低估的设计题。引擎的答案是一套递进规则:

lua
function SecondToHMS(sec, isToStr, isSimple)
    local d = math.floor(sec / 86400)
    local h = math.fmod(math.floor(sec / 3600), 24)
    local m = math.fmod(math.floor(sec / 60), 60)
    local s = math.fmod(sec, 60)
    ...
    if time.d > 0 then
        if isSimple then
            return sformat("%d天%d时", time.d, time.h)
        else
            return sformat("%d天%d时%d分%d秒", time.d, time.h, time.m, time.s)
        end
    else
        if time.h > 0 then
            return sformat("%d时%d分%d秒", time.h, time.m, time.s)
        else
            if time.m > 0 then
                return sformat("%d分%d秒", time.m, time.s)
            else
                return sformat("%d秒", time.s)
            end
        end
    end
end

注意分解用的三个函数各司其职:math.floor 负责除法取整,math.fmod 负责取余——有人图省事全用取模写,秒以上的段全错。86400、3600、60 三个除数是时间的度量衡,写死在函数里没毛病,但你要做"精确到 0.1 秒"的竞速倒计时,这套整数分解就不适用了,得换毫秒秤自己算。工具函数的适用边界心里要有数,超纲使用是 bug 的正规来源。

规则拆开看:有天不说时分秒以下的废话(完整模式四段全给,简洁模式只报天时),有时从时报起,有分从分报起,剩最后几十秒才报纯秒。这套递进值在哪?信息密度跟着紧迫度走。剩三天的东西,"3天2时"足够决策;剩三十秒的 Boss,每一秒都要看见。玩家二十年来默认了这种显示节奏,谁家倒计时从三天就开始跳秒,反而显得慌乱。

isSimple 简洁模式是给窄 UI 准备的:Buff 图标旁边就巴掌大,"2天13时"是极限,四段全上直接溢出。同一份数据两种渲染,调用方按容器大小选——又是数据与表现分离的惯用套路。

还有个 %02d 补零的兄弟函数 TimeFormatToString,输出 01:59:59 这种等宽格式:

lua
function TimeFormatToString(time)
    local day, h, m, s = TimeFormat(time)
    if day < 1 then
        return sformat("%02d:%02d:%02d", h, m, s)
    end
    return sformat(GET_STRING(2000), day, h, m)   -- 跨天走字符串表模板
end

补零的价值在视觉稳定性:数字宽度不变,倒计时跳动时界面不抖。跨天时走 GET_STRING(2000) 字符串表模板,多语言服直接换模板文案,代码不动。两个细节都是"显示层的国际化和防抖",抄作业直接抄。

GET_STRING(2000) 这个写法值得多说两句,它是引擎的字符串表机制——所有面向玩家的文案进字符串表按编号管理,代码里只出现编号。好处两头:多语言时翻译只动表不动码;改文案不用发版,热更一张表全服生效。代价是代码可读性打折,你看到 GET_STRING(2000) 是不知道它说什么的,所以编号旁必须写注释或用常量封装。字符串表和色号表(富文本那篇的数字色号)是同一个哲学:把会变的东西赶出代码,关进表里。表可以热更可以多人并行维护,代码不行——这两张表撑起了运营日更文案和美术调色的全部自由度,代价只是代码里多一层查表调用,这买卖怎么算都赚。

mermaid
flowchart TD
A[需要时间] --> B{干什么用?}
B -->|过期/倒计时/刷新| C[GetServerTime: 本地+ServerTime_Sub]
B -->|性能打点测帧耗时| D[GetTimeInMS: socket.gettime 毫秒级]
B -->|显示给玩家| E[SecondToHMS / TimeFormatToString]
C --> F{剩余多少?}
F -->|天| G[天时分秒 / 天时]
F -->|时| H[时分秒]
F -->|分| I[分秒]
F -->|秒| J[N秒]
E --> F

三、毫秒秤与秒秤:别用错称

文件里还有个不起眼的 GetTimeInMS:

lua
function GetTimeInMS()
    local socket = require "socket"
    return socket.gettime()      -- 毫秒级精度
end

os.time() 只有秒级精度,用它测帧耗时,一帧 16 毫秒测出来永远是 0——秤的量程不对。引擎里所有性能打点(动画缓存那篇的内存快照、帧时间统计)全走 socket 的毫秒时间。两杆秤的分工要背下来:跟服务器对账用秒秤(GetServerTime),自己家测性能用毫秒秤(GetTimeInMS)。混用的症状很典型:倒计时里出现小数点,或者性能统计全是整数 0——遇到先查秤。

毫秒秤还有个隐藏用法:客户端本地的短时序控制。双击窗口那篇的 0.3 秒判定,如果用秒秤采不到样;它属于纯客户端的本地时序,不走服务端对账,用毫秒秤正合适。什么时候用哪杆秤的判断标准就一条:这个时间要不要跟服务端或玩家对账?要对,秒秤走起;纯本地,毫秒秤伺候。

补一个两杆秤混用的真实翻车现场。有团队做技能冷却显示,用毫秒秤记开始时间、用秒秤的 GetServerTime 判结束,单位差了一千倍,冷却永远显示"还剩十几万秒"。排查半小时,修复一行,丢人程度满分。从此我们给两个函数改了名分:打点的起止必须同一杆秤,跨秤运算先换算再相减,评审时看到毫秒值和秒值出现在同一个表达式里直接拦下。单位一致性是时间代码的基本卫生,比任何高深设计都优先。

下面这个演示把两杆秤和递进规则全摆上了台面:拖"剩余秒数"跨过 60、3600、86400 三个门槛,看完整模式和简洁模式的格式逐级切换;再拖"服务器时差",看同一个本地时刻怎么换算出不同的服务器钟——这就是登录那一瞬间引擎做的事:

demo
skill-time-hms-1003c

四、实战案例:一次跨时区开服的时间灾难

某服在海外开新服,开服半小时客服炸锅:玩家看到的活动倒计时千奇百怪,有人显示 23 小时,有人显示 1 小时,还有人显示"已结束"。排查一晚上,根子是活动时间的配置环节用了本地时区——策划在国内填"20:00 开始",配置表存的是东八区的 20 点,海外玩家机器时区五花八门,拿本地时钟一换算,同一个时刻被读成了七八种样子。

修法两条,都是往"锚定服务器时间"上收。第一,活动配置表的时间统一存 UTC 绝对值,展示时经 GetServerTime 换算,跟玩家本地时区彻底脱钩。第二,登录时校验 ServerTime_Sub 的合理性:差值超过 48 小时直接视为时钟异常,按服务端时间硬覆盖。上线后跨时区工单归零,顺手还治了改时钟跳等待的老毛病。

这次事故的教训浓缩成一句话:时间一旦离开服务端,就必须带着差值走。 配置表里所有"几点开始"都要翻译成 UTC 存,展示层负责翻译回玩家的语言。时区问题不会在你测试时报错,它会在最远的那批玩家身上准时引爆——而那批玩家恰恰是新服最宝贵的。

顺带把那次改造的验收清单留给后来人,时间类改动上线前过一遍:一查全项目还有没有裸 os.time() 参与业务判断(全局搜,一个都不许留);二查配置表时间字段的存储格式是不是 UTC;三把测试机时钟拨到极限值(往前一年、往后一年、回 1970),倒计时不许出现负数和天文数字;四开一次跨时区 VPN 走完整活动流程。四条全绿才准上线——时间问题的特点是测的时候岁月静好,炸的时候满城尽知,验收清单就是唯一的安全网。

五、常见疑问

问:ServerTime_Sub 什么时候下发?玩家中途改时区怎么办?
登录握手时下发一次,之后持续对时(心跳捎带校准)。玩家中途改时区,本地 os.time() 的绝对值不变(Unix 时间戳与时区无关),所以差值机制天然免疫时区切换——它怕的只是改系统绝对时间,而心跳对时会周期性纠正这种偏移。

问:倒计时显示要不要在最后 10 秒变红加大?
要,这是全行业玩家的条件反射。实现上就是显示层的小把戏:SecondToHMS 给数据,UI 层按剩余秒数切样式,数据层永远不知道颜色是什么。把紧迫感做在展示层,别去改数据的节奏。

问:活动"每日刷新"用本地零点还是服务器零点?
跟服务器走,但向玩家解释清楚。引擎的做法是配置表填服务器时区的刷新点,客户端展示时换算成玩家本地时刻并标注"每日 X 点(服务器时间)"。偷懒用本地零点的服,第二天就会收到"凌晨五点领了奖励,晚上八点又能领"的循环领取工单——刷对新玩家是友好,刷错时段就是漏洞。

问:倒计时每秒刷新一次 UI,用什么驱动?
用剩余秒数的变化沿驱动,不是每帧刷新:每帧算一次剩余秒,和上帧比对,变了才 setText。一秒一次的刷新频率肉眼顺滑,比每帧重绘省 60 倍的 UI 开销。这在 UI 那篇的倒计时案例里出过场(商城秒杀的逐格 setText 事故),结合本篇的时间源纪律,倒计时的完整配方就齐了:数据用 GetServerTime,刷新用变化沿,格式用 SecondToHMS,最后 10 秒切红色。四件套齐活,倒计时相关的工单基本绝迹。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组