Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.2 Tick与时间管理

游戏Tick是游戏世界的时间单位,决定了游戏逻辑更新的频率。Tick设计直接影响游戏体验、性能和网络同步。

核心问题:如何定义游戏的时间?

问题场景

// 场景:不同玩家的帧率不同
// 玩家A:60fps,每帧16.6ms
// 玩家B:144fps,每帧6.9ms
// 玩家C:30fps,每帧33.3ms

// 问题:如何保证所有玩家看到相同的游戏速度?
type Player struct {
    ID       uint64
    FPS      int
    FrameTime time.Duration
}

// ❌ 错误:直接使用帧时间
func updateWithFrameTime(player *Player, deltaTime time.Duration) {
    // 不同玩家的deltaTime不同
    player.Position += player.Velocity * deltaTime
    // 结果:高帧率玩家移动更快
}

游戏Tick设计

1. 固定Tick(Fixed Tick)

核心思想:游戏逻辑以固定频率更新,独立于渲染帧率

// FixedTick 固定Tick
type FixedTick struct {
    tickRate       int           // Tick速率(Hz)
    tickInterval   time.Duration // Tick间隔
    accumulator    time.Duration // 时间累加器
    lastTime       time.Time     // 上次更新时间
}

// 游戏主循环
func (ft *FixedTick) GameLoop() {
    ft.lastTime = time.Now()

    for {
        // 1. 计算帧时间
        currentTime := time.Now()
        deltaTime := currentTime.Sub(ft.lastTime)
        ft.lastTime = currentTime

        // 2. 累加时间
        ft.accumulator += deltaTime

        // 3. 消耗累加器(固定Tick更新)
        for ft.accumulator >= ft.tickInterval {
            ft.updateGameLogic()
            ft.accumulator -= ft.tickInterval
        }

        // 4. 渲染(可以以不同帧率)
        ft.render()

        // 控制帧率(避免CPU占用过高)
        time.Sleep(1 * time.Millisecond)
    }
}

// 更新游戏逻辑
func (ft *FixedTick) updateGameLogic() {
    // 固定时间步长
    dt := ft.tickInterval

    // 更新所有对象
    ft.updatePlayers(dt)
    ft.updateBullets(dt)
    ft.updatePhysics(dt)
}

// 示例:60Hz固定Tick
func fixedTickExample() {
    ft := &FixedTick{
        tickRate:     60,
        tickInterval: time.Second / 60,  // 16.6ms
    }

    ft.GameLoop()
}

固定Tick的优势

// 优点
type FixedTickPros struct {
    Consistency    bool   // 所有玩家逻辑一致
    Determinism    bool   // 确定性计算
    Physics        bool   // 物理模拟稳定
    Network        bool   // 网络同步简单
}

// 缺点
type FixedTickCons struct {
    Responsiveness bool   // 低帧率玩家响应慢
    Complexity     bool   // 需要时间累加器
}

2. 可变Tick(Variable Tick)

核心思想:根据实际帧时间更新逻辑

// VariableTick 可变Tick
type VariableTick struct {
    lastTime       time.Time
    maxDeltaTime   time.Duration  // 最大帧时间(防止螺旋死亡)
}

// 游戏主循环
func (vt *VariableTick) GameLoop() {
    vt.lastTime = time.Now()

    for {
        // 1. 计算帧时间
        currentTime := time.Now()
        deltaTime := currentTime.Sub(vt.lastTime)
        vt.lastTime = currentTime

        // 2. 限制最大帧时间
        if deltaTime > vt.maxDeltaTime {
            deltaTime = vt.maxDeltaTime
        }

        // 3. 更新游戏逻辑(使用实际帧时间)
        vt.updateGameLogic(deltaTime)

        // 4. 渲染
        vt.render()
    }
}

// 更新游戏逻辑
func (vt *VariableTick) updateGameLogic(deltaTime time.Duration) {
    // 使用实际帧时间
    vt.updatePlayers(deltaTime)
    vt.updateBullets(deltaTime)
    vt.updatePhysics(deltaTime)
}

// 示例:可变Tick
func variableTickExample() {
    vt := &VariableTick{
        maxDeltaTime: 100 * time.Millisecond,  // 限制100ms
    }

    vt.GameLoop()
}

可变Tick的问题

// ❌ 问题:不同玩家结果不一致
func variableTickProblem() {
    // 玩家A:60fps,每帧16.6ms
    playerA := &Player{Velocity: 100}
    for i := 0; i < 60; i++ {
        playerA.Position += playerA.Velocity * 0.016  // 100 * 0.016 = 1.6
    }
    // 结果:1.6 * 60 = 96

    // 玩家B:30fps,每帧33.3ms
    playerB := &Player{Velocity: 100}
    for i := 0; i < 30; i++ {
        playerB.Position += playerB.Velocity * 0.033  // 100 * 0.033 = 3.3
    }
    // 结果:3.3 * 30 = 99

    // 问题:96 != 99,不同玩家结果不同
}

3. 混合Tick(Hybrid Tick)

核心思想:逻辑固定Tick,渲染可变帧率

// HybridTick 混合Tick
type HybridTick struct {
    // 逻辑层:固定Tick
    logicTickRate     int
    logicTickInterval time.Duration
    logicAccumulator  time.Duration

    // 渲染层:可变帧率
    lastTime          time.Time
    renderFrameRate   int
}

// 游戏主循环
func (ht *HybridTick) GameLoop() {
    ht.lastTime = time.Now()

    for {
        // 1. 计算帧时间
        currentTime := time.Now()
        deltaTime := currentTime.Sub(ht.lastTime)
        ht.lastTime = currentTime

        // 2. 逻辑更新:固定Tick
        ht.logicAccumulator += deltaTime
        for ht.logicAccumulator >= ht.logicTickInterval {
            ht.updateGameLogic()
            ht.logicAccumulator -= ht.logicTickInterval
        }

        // 3. 渲染:可变帧率
        ht.render()
    }
}

// 逻辑更新(固定Tick)
func (ht *HybridTick) updateGameLogic() {
    dt := ht.logicTickInterval
    ht.updatePlayers(dt)
    ht.updatePhysics(dt)
}

// 渲染(可变帧率)
func (ht *HybridTick) render() {
    // 插值渲染(平滑显示)
    t := float64(ht.logicAccumulator) / float64(ht.logicTickInterval)
    ht.renderWithInterpolation(t)
}

// 示例:混合Tick
func hybridTickExample() {
    ht := &HybridTick{
        logicTickRate:     30,  // 逻辑30Hz
        logicTickInterval: time.Second / 30,
        renderFrameRate:   60,  // 渲染60fps
    }

    ht.GameLoop()
}

时间同步机制

1. 客户端-服务器时间同步

// TimeSync 时间同步
type TimeSync struct {
    clientTime     int64
    serverTime     int64
    timeDifference int64
    rtt            time.Duration  // 往返时间
    latency        time.Duration  // 单向延迟
}

// NTP算法(Network Time Protocol)
func (ts *TimeSync) SyncWithServer() {
    // 1. 记录客户端时间t1
    t1 := time.Now().UnixNano()

    // 2. 发送到服务器
    response := ts.sendTimeRequest(t1)

    // 3. 记录收到响应的时间t4
    t4 := time.Now().UnixNano()

    // 4. 计算RTT(Round Trip Time)
    rtt := t4 - t1 - (response.ServerTime - response.OriginalTime)

    // 5. 计算时间偏移
    // Offset = ((t2 - t1) + (t3 - t4)) / 2
    // 其中:
    //   t1 = 客户端发送时间
    //   t2 = 服务器接收时间
    //   t3 = 服务器发送时间
    //   t4 = 客户端接收时间
    offset := ((response.ReceiveTime - t1) + (response.SendTime - t4)) / 2

    // 6. 调整客户端时间
    ts.clientTime = ts.serverTime + offset
    ts.latency = time.Duration(rtt / 2)
}

// 时间同步请求
type TimeRequest struct {
    OriginalTime int64  // 客户端发送时间
}

type TimeResponse struct {
    OriginalTime int64  // 原始请求时间
    ReceiveTime  int64  // 服务器接收时间
    SendTime     int64  // 服务器发送时间
    ServerTime   int64  // 服务器当前时间
}

// 多次同步(提高精度)
func (ts *TimeSync) SyncMultipleTimes(count int) {
    offsets := make([]int64, count)

    for i := 0; i < count; i++ {
        ts.SyncWithServer()
        offsets[i] = ts.timeDifference
        time.Sleep(10 * time.Millisecond)
    }

    // 使用中位数(过滤异常值)
    sort.Slice(offsets, func(i, j int) bool {
        return offsets[i] < offsets[j]
    })

    medianOffset := offsets[count/2]
    ts.timeDifference = medianOffset
}

2. 帧同步时间同步

// FrameSyncTime 帧同步时间
type FrameSyncTime struct {
    frameNum       uint32
    frameRate      int
    serverFrameNum uint32
}

// 同步帧号
func (fst *FrameSyncTime) SyncFrameNum(serverFrame uint32) {
    // 客户端落后,加速追赶
    if fst.frameNum < serverFrame {
        diff := serverFrame - fst.frameNum
        if diff > 10 {
            // 落后太多,跳帧
            fst.frameNum = serverFrame
        } else {
            // 稍微落后,加速(每帧执行多次)
            for i := 0; i < int(diff); i++ {
                fst.updateFrame()
            }
        }
    }

    // 客户端超前,等待
    if fst.frameNum > serverFrame {
        // 等待服务器
        time.Sleep(10 * time.Millisecond)
    }
}

// Lockstep等待
func (fst *FrameSyncTime) LockstepWait(playerCount int) {
    // 等待所有玩家到达这一帧
    for {
        readyCount := fst.getReadyPlayerCount(fst.frameNum)
        if readyCount >= playerCount {
            break
        }
        time.Sleep(1 * time.Millisecond)
    }
}

时间膨胀技术

1. 慢动作(Slow Motion)

// TimeScale 时间缩放
type TimeScale struct {
    scale          float64  // 时间缩放因子(1.0 = 正常,0.5 = 慢动作)
    accumulator    time.Duration
}

// 更新游戏逻辑(考虑时间缩放)
func (ts *TimeScale) Update(deltaTime time.Duration) {
    // 应用时间缩放
    scaledDelta := time.Duration(float64(deltaTime) * ts.scale)

    // 更新游戏逻辑
    ts.updateGameLogic(scaledDelta)
}

// 慢动作示例
func slowMotionExample() {
    ts := &TimeScale{
        scale: 0.5,  // 50%速度(慢动作)
    }

    // 正常速度
    ts.scale = 1.0

    // 激活慢动作
    ts.scale = 0.5

    // 恢复正常
    ts.scale = 1.0
}

2. 服务器时间膨胀(防止螺旋死亡)

// TimeDilation 时间膨胀
type TimeDilation struct {
    normalTickRate    int
    currentTickRate   int
    targetTickRate    int
    lastAdjustTime    time.Time
}

// 检测服务器负载
func (td *TimeDilation) DetectServerLoad() {
    // 1. 测量Tick时间
    tickTime := td.measureTickTime()

    // 2. 如果Tick时间超过阈值,降低Tick率
    if tickTime > time.Second/time.Duration(td.currentTickRate) {
        // 服务器过载,降低Tick率
        td.targetTickRate = td.normalTickRate / 2
    } else {
        // 服务器正常,恢复Tick率
        td.targetTickRate = td.normalTickRate
    }

    // 3. 平滑过渡
    td.adjustTickRate()
}

// 调整Tick率
func (td *TimeDilation) adjustTickRate() {
    // 每5秒调整一次
    if time.Since(td.lastAdjustTime) < 5*time.Second {
        return
    }

    // 平滑过渡
    if td.currentTickRate < td.targetTickRate {
        td.currentTickRate++
    } else if td.currentTickRate > td.targetTickRate {
        td.currentTickRate--
    }

    td.lastAdjustTime = time.Now()
}

// 时间膨胀示例
func timeDilationExample() {
    td := &TimeDilation{
        normalTickRate:  60,
        currentTickRate: 60,
        targetTickRate:  60,
    }

    // 游戏主循环
    for {
        // 检测服务器负载
        td.DetectServerLoad()

        // 根据当前Tick率更新
        tickInterval := time.Second / time.Duration(td.currentTickRate)
        td.update(tickInterval)
    }
}

真实案例分析

案例1:《CS:GO》的Tick系统

背景

  • Tick率:64Hz(官方)/ 128Hz(竞技)
  • 延迟要求:<100ms
  • 特点:射击游戏,精确命中判定

技术方案

// CSGO Tick系统
type CSGOTick struct {
    tickRate          int
    tickInterval      time.Duration
    commandNum        uint32  // 命令号
    serverTickNum     uint32  // 服务器Tick号
}

// 客户端命令
type ClientCommand struct {
    CommandNum    uint32
    TickNum       uint32
    ViewAngles    Vector3
    Input         ButtonState
    WeaponID      int
}

// 服务器Tick更新
func (ct *CSGOTick) ServerTick() {
    ticker := time.NewTicker(ct.tickInterval)
    defer ticker.Stop()

    for range ticker.C {
        // 1. 收集客户端命令
        commands := ct.collectClientCommands()

        // 2. 执行客户端命令
        for _, cmd := range commands {
            ct.processCommand(cmd)
        }

        // 3. 更新游戏世界
        ct.updateWorld()

        // 4. 命中判定
        ct.performHitDetection()

        // 5. 广播状态
        ct.broadcastState()

        // 6. Tick计数递增
        ct.serverTickNum++
    }
}

// 命中判定(使用历史状态)
func (ct *CSGOTick) performHitDetection() {
    for _, shot := range ct.pendingShots {
        // 回溯到客户端射击时的Tick
        targetState := ct.stateHistory.GetState(shot.TickNum)

        // 进行命中判定
        if ct.checkHit(shot, targetState) {
            // 命中,造成伤害
            ct.applyDamage(shot.TargetID, shot.Damage)
        }
    }
}

// 状态历史
type StateHistory struct {
    states    map[uint32]GameState
    maxAge    int  // 保留最近1秒的状态
}

// 保存状态
func (sh *StateHistory) SaveState(tickNum uint32, state GameState) {
    sh.states[tickNum] = state

    // 清理旧状态
    if len(sh.states) > sh.maxAge {
        oldTick := tickNum - uint32(sh.maxAge)
        delete(sh.states, oldTick)
    }
}

// 获取状态
func (sh *StateHistory) GetState(tickNum uint32) GameState {
    return sh.states[tickNum]
}

优化技巧

// 优化1:命令插值(平滑输入)
func (ct *CSGOTick) InterpolateCommands(cmd1, cmd2 ClientCommand, t float64) ClientCommand {
    return ClientCommand{
        ViewAngles: lerpVector3(cmd1.ViewAngles, cmd2.ViewAngles, t),
        Input:      cmd1.Input,  // 输入不插值
        WeaponID:   cmd1.WeaponID,
    }
}

// 优化2:Tick压缩(减少带宽)
func (ct *CSGOTick) CompressTicks() {
    // 只发送变化的实体
    delta := ct.calculateDelta()

    // 使用增量编码
    compressed := ct.deltaEncode(delta)

    // 发送压缩后的数据
    ct.broadcast(compressed)
}

// 优化3:客户端预测(减少延迟感)
func (ct *CSGOTick) ClientPrediction() {
    // 客户端立即执行命令
    predicted := ct.localPlayer.ExecuteCommand(ct.lastCommand)

    // 保存预测状态
    ct.predictedStates[ct.lastCommand.CommandNum] = predicted

    // 渲染预测状态
    ct.render(predicted)

    // 等待服务器确认
    // 如果服务器确认,删除预测
    // 如果服务器纠正,应用纠正
}

效果

  • 延迟:P50 < 50ms
  • 命中准确率:96%(64Tick)/ 99%(128Tick)
  • 带宽:< 15KB/s/玩家

案例2:《虚幻竞技场》的时间管理

背景

  • Tick率:可配置(30-120Hz)
  • 延迟要求:<50ms
  • 特点:高速移动,火箭跳

技术方案

// UnrealEngine Tick系统
type UnrealTick struct {
    // 服务器Tick率
    serverTickRate   int

    // 客户端渲染帧率
    clientFrameRate  int

    // 时间平滑
    timeSmooth       bool
}

// 服务器Tick
func (ut *UnrealTick) ServerTick() {
    ticker := time.NewTicker(time.Second / time.Duration(ut.serverTickRate))
    defer ticker.Stop()

    for range ticker.C {
        // 1. 处理客户端输入
        ut.processClientInputs()

        // 2. 更新物理
        ut.updatePhysics()

        // 3. 更动AI
        ut.updateAI()

        // 4. 广播更新
        ut.broadcastUpdates()
    }
}

// 客户端渲染
func (ut *UnrealTick) ClientRender() {
    // 客户端可以以更高帧率渲染
    ticker := time.NewTicker(time.Second / time.Duration(ut.clientFrameRate))
    defer ticker.Stop()

    for range ticker.C {
        // 插值渲染
        ut.renderInterpolated()
    }
}

// 插值渲染
func (ut *UnrealTick) renderInterpolated() {
    // 获取最近两个服务器状态
    state1 := ut.getServerState(ut.lastServerTick)
    state2 := ut.getServerState(ut.lastServerTick - 1)

    // 计算插值系数
    t := ut.calculateInterpolationT()

    // 插值
    interpolated := ut.interpolate(state1, state2, t)

    // 渲染
    ut.render(interpolated)
}

效果

  • 延迟:P50 < 40ms
  • 画面流畅度:144fps渲染
  • 一致性:98%

踩坑经验

❌ 错误1:直接使用浮点数做时间累加

// ❌ 错误:浮点数累加误差
func wrongAccumulator() {
    accumulator := 0.0
    for i := 0; i < 1000; i++ {
        accumulator += 0.1  // 累计误差
    }
    // 结果:99.99999999999(不是100.0)
}

// ✅ 正确:使用整数累加
func correctAccumulator() {
    accumulator := int64(0)
    for i := 0; i < 1000; i++ {
        accumulator += 100  // 毫秒为单位
    }
    // 结果:100000(精确)
}

❌ 错误2:不限制最大帧时间

// ❌ 错误:不限制帧时间
func wrongUpdateTime() {
    deltaTime := time.Since(lastTime)

    // 如果卡顿,deltaTime可能很大
    updateGameLogic(deltaTime)  // 导致螺旋死亡
}

// ✅ 正确:限制最大帧时间
func correctUpdateTime() {
    deltaTime := time.Since(lastTime)

    // 限制最大帧时间
    if deltaTime > 100*time.Millisecond {
        deltaTime = 100 * time.Millisecond
    }

    updateGameLogic(deltaTime)
}

❌ 错误3:时间同步使用单次测量

// ❌ 错误:单次时间同步
func wrongTimeSync() {
    offset := measureTimeOffsetOnce()
    // 问题:网络抖动导致不准确
}

// ✅ 正确:多次同步取中位数
func correctTimeSync() {
    offsets := make([]int64, 10)
    for i := 0; i < 10; i++ {
        offsets[i] = measureTimeOffsetOnce()
    }

    // 使用中位数
    sort.Slice(offsets, func(i, j int) bool {
        return offsets[i] < offsets[j]
    })
    offset := offsets[5]  // 中位数
}

性能优化

优化1:Tick合并

// Tick合并:多个对象共享Tick
func (ft *FixedTick) BatchUpdate() {
    // 批量更新同类对象
    for _, player := range ft.players {
        player.Update(ft.tickInterval)
    }

    for _, bullet := range ft.bullets {
        bullet.Update(ft.tickInterval)
    }
}

优化2:对象池

// 对象池:减少GC
var tickEventPool = sync.Pool{
    New: func() interface{} {
        return &TickEvent{}
    },
}

func getTickEvent() *TickEvent {
    return tickEventPool.Get().(*TickEvent)
}

func releaseTickEvent(event *TickEvent) {
    event.Reset()
    tickEventPool.Put(event)
}

小结

Tick与时间管理的核心要点:

  1. 固定Tick:保证确定性,适合帧同步
  2. 可变Tick:响应更灵活,适合单机游戏
  3. 混合Tick:逻辑固定,渲染可变

时间同步

  • NTP算法:多次测量取中位数
  • 帧同步:同步帧号,Lockstep等待

时间膨胀

  • 慢动作:时间缩放
  • 服务器负载:动态调整Tick率

真实案例

  • 《CS:GO》:64/128Hz Tick,历史状态回溯
  • 《虚幻竞技场》:可配置Tick率,插值渲染

踩坑经验

  • ❌ 不要用浮点数累加时间
  • ❌ 不要忘记限制最大帧时间
  • ❌ 不要单次时间同步

下一节(4.3)我们将学习:确定性计算,深入了解如何保证所有客户端计算一致。