Улучшенное управление временем в SpriteKit

Управление временем и использование дельта-времени — одна из самых важных и фундаментальных частей для большинства игровых проектов, поскольку она обеспечивает согласованность любых преобразований на экране независимо от частоты кадров устройства, на котором работает игра.

Более или менее любая форма движения, вращения или вычислений, связанных со временем, должна учитывать дельта-время, чтобы не зависеть от частоты кадров.

Без использования дельта-времени пуля в игре, например, будет двигаться с другой скоростью на устройстве с частотой 120 кадров в секунду по сравнению с устройством с частотой 60 кадров в секунду.

Дельта-время в SpriteKit

SpriteKit не дает нам значения дельта-времени из коробки, что очень жаль, учитывая, насколько оно полезно.

Не то чтобы инженеры игрового фреймворка Apple не знали о дельта-времени, поскольку GKComponent в GameplayKit требует дельта-время в своем методе update(deltaTime:). Но вы должны вычислить это значение самостоятельно.

В то же время, как жаль, что SpriteKit не предоставляет дельта-время из коробки, также понятно, что инженеры GameplayKit посчитали deltaTime требованием при вызове метода update, поскольку компоненту, скорее всего, так или иначе придется использовать дельта-время.

И конечно, если вы не используете GameplayKit, а только обычный SpriteKit, вам потребуется использовать дельта-время для экранных вычислений, чтобы обеспечить согласованность независимо от частоты кадров, на которой работает устройство.

SKScene предоставляет значение текущего времени, поэтому, скорее всего, вы используете или видели решения, похожие на это, чтобы получить значение дельта-времени.

class GameplayScene: SKScene {
  /// Keeps track of how much time has passed since last game loop update.
  var lastUpdateTime: TimeInterval = 0

  override func update(_ currentTime: TimeInterval) {
    super.update(currentTime)

    // Get delta time since last time `update` was called.
    let deltaTime = calculateDeltaTime(from: currentTime)
  }

  /// Calculates time passed from current time since last update time.
  private func calculateDeltaTime(from currentTime: TimeInterval) -> TimeInterval {
    // When the level is started or after the game has been paused, the last update time is reset to the current time.
    if lastUpdateTime.isZero {
      lastUpdateTime = currentTime
    }

    // Calculate delta time since `update` was last called.
    let deltaTime = currentTime - lastUpdateTime

    // Use current time as the last update time on next game loop update.
    lastUpdateTime = currentTime

    return deltaTime
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

В жизненном цикле этой SKScene мы получаем текущее время, предоставленное в методе update. Сохраняя значение текущего времени в свойстве класса lastUpdateTime, мы можем отслеживать, сколько времени прошло с момента последнего вызова метода update, и таким образом мы получаем значение дельта-времени.

Затем мы можем взять это значение и передавать его другим методам или системам, которым необходимо использовать дельта-время для своих вычислений.

Это может выглядеть примерно так.

override func update(_ currentTime: TimeInterval) {
  super.update(currentTime)
  let deltaTime = calculateDeltaTime(from: currentTime)

  // Update the bullet system that handle movement of all bullets on screen.
  bulletSystem.update(deltaTime: deltaTime)

  // Update each GameplayKit component system.
  for componentSystem in componentSystems {
    componentSystem.update(deltaTime: deltaTime)
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Каждая система, компонент или объект, использующий дельта-время, должен будет принять это значение в сигнатуре своего метода, а затем передать его дальше по цепочке своим собственным методам, которые полагаются на дельта-время. Скорее всего, вы не захотите хранить всю логику в методе update, так как она может стать огромной, а структурируете логику в специальных методах.

В итоге мы получим компоненты, подобные этому.

class MoveComponent: GKComponent {
  // ... Initialization and references to other components of the entity here ...

  override func update(deltaTime seconds: TimeInterval) {
    super.update(deltaTime: seconds)

    move(deltaTime: seconds)
  }

  private funct move(deltaTime seconds: TimeInterval) {
    entity.position += speed * direction * CGFloat(seconds)
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь у нас есть метод move в компоненте, который перемещает позицию объекта со скоростью и в направлении, которые не зависят от частоты кадров устройства, поскольку мы умножаем полученную скорость на наше значение дельта-времени. Поскольку дельта-время передается как TimeInterval, мы должны привести его к соответствующему формату, например CGFloat.

Большинство игр, конечно, будут использовать не только метод обновления в SKScene, но и другие методы в жизненном цикле, такие как didSimulatePhysics или didFinishUpdate.

Эти методы не получают текущее время, поэтому нам придется преобразовать свойство deltaTime в свойство класса, чтобы иметь возможность получить доступ и использовать дельта-время в этих сценариях.

class GameplayScene: SKScene {
  var deltaTime: TimeInterval = 0

  override func update(_ currentTime: TimeInterval) {
    deltaTime = calculateDeltaTime(from: currentTime)
  }

  override func didSimulatePhysics() {
    // Update camera movemement.
    camera?.entity?.update(deltaTime: deltaTime)

    // Update each 'after physics' component system.
    for componentSystem in afterPhysicsComponentSystems {
      componentSystem.update(deltaTime: deltaTime)
    }
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Несмотря на то, что такой подход справляется со своей задачей, а SpriteKit предоставляет нам необходимые данные для работы с дельта-временем, он довольно запутанный.

Мы смешиваем логику времени с логикой сцены. А поскольку каждый расчет, влияющий на то, что происходит на экране, а это очень много в большинстве игр, зависит от дельта-времени, нам придется постоянно передавать это значение между системами, классами и методами.

У нас также может быть несколько сцен, даже запущенных одновременно, что происходит при переходе между сценами, что создаст несколько вычислений дельта-времени одновременно.

Чем сложнее становится игра, с большим количеством систем и классов, тем больше будет чувствоваться, что этот подход начинает разваливаться. Давайте найдем лучший способ.

Игровые движки

Если взглянуть на современные игровые движки, такие как Unity и Unreal, то они вычисляют дельта-время как основную функциональность, и это значение доступно для использования там, где оно необходимо. Логично, что любой фреймворк, нацеленный на разработку игр, делает это доступным.

Если мы, например, посмотрим на Unity поближе, он предоставляет класс Time1 который имеет статические свойства, содержащие данные, связанные со временем.

Таким образом, вам никогда не придется передавать информацию о времени в коде вашей игры. Вместо этого вы можете в любой момент произвести расчет, подобный этому, просто статически обратившись к свойству deltaTime класса Time.

var position += speed * direction * Time.deltaTime;
Вход в полноэкранный режим Выход из полноэкранного режима

Очевидно, что они решили, что что-то настолько важное и часто используемое в разработке игр должно вычисляться под капотом и быть легко доступным в любое время, что и решает статическое свойство.

Класс time в Unity не только предоставляет deltaTime в качестве статического свойства, но и содержит массу другой полезной информации, связанной со временем.

Swift-класс для статического времени

Теперь мы рассмотрели обычный подход к работе с дельта-временем в SpriteKit, а также взглянули на то, как с этим справляются современные игровые движки. Поэтому давайте стремиться к аналогичному подходу в SpriteKit для лучшего способа легкого доступа к информации о времени через статические свойства.

Мы вдохновимся Unity и посмотрим, как мы можем иметь класс Time, доступный в SpriteKit со статическими свойствами, которые не привязаны к циклу обновления текущей сцены.

Статический класс должен содержать соответствующие свойства вместе с методом update() для поддержания актуальных значений.

/// Tracks the game's time-related information.
public enum Time {
  /// The time given at the beginning of this frame.
  public private(set) static var time = TimeInterval(0.0)

  /// The interval in seconds from the last frame to the current one.
  public private(set) static var deltaTime = TimeInterval(0.0)

  /// Called on the device's frame update to track time properties.
  static func update(at currentTime: TimeInterval) {
    //  If `time` is 0.0 the game has just started, so set it to `currentTime`.
    if time.isZero {
      time = currentTime
    }

    // Calculate delta time since `update(at:)` was last called.
    deltaTime = currentTime - time

    // Set `time` to `currentTime for next game loop update.
    time = currentTime
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Это дает нам класс Time со статическими свойствами и методом для поддержания их в актуальном состоянии. Теперь нам просто нужно место для вызова метода update(). Первой мыслью может быть вызов его из update() в SKScene. Это сработает в большинстве случаев.

Но если мы сделаем это из SKScene, возникнет одна проблема: в некоторые моменты у нас может быть запущено более одной сцены, например, при переходе между двумя сценами. Это вызовет состояние гонки, когда они обе будут обновлять Time, что даст нам неправильное deltaTime для одной из сцен.

Мы также хотим сохранить наш код чистым и не помнить о вызове Time.update() в каждом классе сцены, который мы создаем, а также мы хотим избежать загрязнения класса сцены кодом, который не должен там находиться.

К счастью, у SKView есть свойство делегата, которое мы можем использовать для этого. Свойство делегата принимает SKViewDelegate.2 что дает нам крючок, необходимый для вычисления значений времени вне класса сцены.

Давайте создадим наш собственный класс Ticker, который является SKViewDelegate, чтобы мы могли обновлять наш класс Time. Метод view() в SKViewDelegate вызывается непосредственно перед методом update() в SKScene, поэтому это идеальное место для обновления свойств времени.

/// Assign to the `SKView` to update game time properties for each frame.
public class Ticker: NSObject, SKViewDelegate {
  public func view(_ view: SKView, shouldRenderAtTime time: TimeInterval) -> Bool {
    // Update time properties.
    Time.update(at: time)

    // By returning true the game runs at the full frame rate specified with preferredFramesPerSecond.
    return true
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

И, наконец, нам нужно назначить делегат SKView во время процесса инициализации игры.

Я предпочитаю иметь класс GameManager, в котором я делаю всю свою раннюю инициализацию. Я инстанцирую свой GameManager непосредственно из своего ViewController, куда я передаю SKView, который будет представлять мои различные игровые сцены.

Это идеальное место для назначения пользовательского делегата, который будет следить за тем, чтобы свойства времени оставались актуальными без необходимости управлять ими для каждой отдельной сцены.

class GameManager {
  /// Ticker that updates game time properties.
  let ticker = Ticker()

  init(view: SKView) {
    // Assign the game time ticker to the view.
    view.delegate = ticker

    // ... Other game setup code would go here...
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

И вот оно, надежное многоразовое решение, которое дает нам легкий доступ к свойствам времени, таким как дельта-время, из любой точки нашего игрового кода.

Теперь, когда нам нужно произвести вычисления на основе дельта-времени, мы можем в любой момент использовать Time.deltaTime как часть вычислений.

Дальнейшие улучшения

С центральным местоположением для свойств времени, оно не обязательно должно заканчиваться дельта-временем. Существует множество свойств, связанных со временем, которые полезны для разработки игр. Если мы рассмотрим класс Unity Time в качестве эталона, мы сможем повторить больше свойств из него и продолжить украшать наш собственный класс Time.

Допустим, мы также хотим иметь доступ к подсчету кадров в нашем игровом коде.

public enum Time {
  /// The total number of frames since the start of the game.
  public private(set) static var frameCount = 0

  static func update(at currentTime: TimeInterval) {
    // ... previous code in the update method here ...

    // Increase frame count since game started.
    frameCount += 1
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем в любой момент обратиться к Time.frameCount, если нам понадобится прочитать это значение.

Последний твик, давайте расширим наши операторы типов значений для работы с TimeInterval.

Ранее в этом посте мы умножали скорость на дельта-время и должны были приводить значение времени к CGFloat, CGFloat(seconds). Поскольку мы собираемся использовать значение дельта-времени во многих местах, было бы удобно не приводить его каждый раз. Вместо этого мы можем расширить типы, которые будем использовать с дельта-временем, такие как CGFloat и CGVector, чтобы использовать TimeInterval.

Вот пример расширения CGFloat, чтобы мы могли умножать значение CGFloat непосредственно на TimeInterval без необходимости приводить его каждый раз.

public extension CGFloat {
  static func * (lhs: CGFloat, rhs: TimeInterval) -> CGFloat {
    return lhs * CGFloat(rhs)
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Таким образом, мы можем написать чистый игровой код, например, такой.

movement = speed * Time.deltaTime
Вход в полноэкранный режим Выход из полноэкранного режима

Какая красота!


  1. Unity API: Время. Статическое свойство, предоставляющее информацию о времени в Unity. 

  2. SKViewDelegate. Получение пользовательского контроля над скоростью рендеринга представления. 

Оцените статью
Procodings.ru
Добавить комментарий