Система игрового ввода в Swift

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

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

Вот цели, которых я хотел достичь с помощью системы ввода.

  • Полностью отделена от кода игры.
  • Возможность многократного использования, желательно в виде отдельного пакета.
  • Работа с любым устройством ввода или экраном.
  • Работать как для пользовательского интерфейса, так и для игрового процесса.
  • Код игры не должен зависеть от того, какой вход используется в данный момент.
  • Обрабатывать игровые контроллеры с соответствующими событиями.
  • Не быть привязанным к конкретному игровому фреймворку.
  • Быть «поворотливым» при настройке игрового ввода.
  • Система компоновки для определения виртуального контроллера с сенсорным вводом.
  • Применять пользовательское оформление для стиков и кнопок виртуального контроллера.

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

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

И, конечно, мы также хотим убедиться, что система ввода обрабатывает все, что требуется для получения значка поддержки игровых контроллеров в Apple App Store.

Действия игрового ввода

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

Примерами действий могут быть «Прыжок» или «Перемещение». Игра должна заботиться только о том, произошло ли действие «Прыжок», и ей не нужно знать, какая кнопка или устройство вызвали это действие.

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

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

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

Группы игрового ввода

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

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

Далее мы хотим решить различные сценарии ввода. Стик большого пальца или D-pad на игровом контроллере может быть связан более чем с одним действием. Во время игры он ассоциируется с действием «Перемещение», в то время как при отображении пользовательского интерфейса для игрока мы хотим, чтобы он ассоциировался с действием «Навигация».

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

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

Мы можем иметь одну группу действий для пользовательского интерфейса и одну группу действий для игрового процесса. Тогда мы можем просто переключаться между группами UI и Gameplay в зависимости от того, в каком состоянии находится игра.

Мне нравится использовать подход, при котором у меня есть конечный автомат состояний, отслеживающий общее состояние игры. По умолчанию я устанавливаю систему ввода в UI, а затем использую переходы входа и выхода для состояния Gameplay, чтобы изменить группу ввода. Каждый раз, когда игра входит в состояние GameplayState, я включаю входную группу Gameplay, а когда она выходит из состояния GameplayState, я включаю входную группу UI.

Настройка системы ввода

Теперь у нас есть представление о том, как должна работать система ввода. Теперь пришло время разобраться с конфигурацией «Swifty». Для меня было очень важно разработать API, с которым я чувствовал себя комфортно. Возможно, это один из самых важных аспектов системы, поскольку я хотел, чтобы настройка ввода для каждой новой игры была логичной и простой.

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

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

final class GameManager {
  init() {
    // Configure input mapping.
    InputConfig()
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Будем считать, что все просто.

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

/// Registers all input actions for the game.
struct InputConfig {
    // `Input Group` identifiers.
    static let gameplay: String = "Gameplay"
    static let ui: String = "UI"

    // `Input Action` identifiers.
    static let moveAction = "move"
    static let fireAction = "fire"
    static let jumpAction = "jump"
    static let pauseAction = "pause"

    static let menuNavigate = "menu navigate"
    static let menuSelect = "menu select"
    static let menuExit = "menu exit"

    #if DEBUG
    static let debugKill = "debug kill"
    static let debugNextLevel = "debug next level"
    static let debugDropItem = "debug drop item"
    #endif
}
Вход в полноэкранный режим Выход из полноэкранного режима

Изначально я хотел использовать перечисление для идентификаторов, но не смог придумать надежного решения, когда конфигурация также должна быть доступна и разобрана отдельным пакетом ввода. Используя static let, мы преодолеваем эту проблему, сохраняя возможность ссылаться на них с помощью точечного синтаксиса, так что мы можем, например, слушать .moveAction.

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

Это охватывает все, что должна уметь эта игра.

Далее мы собираемся сопоставить наши действия с различными устройствами. Давайте проверим привязку .moveAction.

let moveAction = InputAction(name: Self.moveAction, type: .stick, bindings: [
    GamepadBinding(.leftStick),
    CompositeKeyBinding(keySet: .wasd)
])
Вход в полноэкранный режим Выход из полноэкранного режима

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

Определив его с помощью имени static let, которое мы определили ранее, мы получаем ассоциацию между действием ввода и этим именем, поэтому в дальнейшем мы всегда сможем ссылаться на него с помощью синтаксиса .moveAction. Затем нам нужно сообщить действию, что это за элемент управления вводом.

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

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

Привязка GamepadBinding имеет такие элементы, как .leftStick, .rightStick, .buttonNorth и так далее.

В то время как клавиатура имеет привязки для различных клавиш, таких как p или shift. Для удобства я добавил CompositeKeyBinding поверх KeyBinding, так как я обнаружил, что мне часто нужна такая настройка. Итак, выше я использую композитную привязку клавиш и передаю ключи .wasd.

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

let moveAction = InputAction(name: Self.moveAction, type: .stick, bindings: [
  GamepadBinding(.leftStick),
  CompositeKeyBinding(keySet: .wasd),
  CompositeKeyBinding(keySet: .cursor)
])
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть вторая составная привязка с клавишами курсора, поэтому игрок может использовать любую из этих клавиш, в зависимости от того, что он предпочитает. Я использую это и в других местах, например, в .pauseAction.

let pauseAction = InputAction(name: Self.pauseAction, type: .button, bindings: [
  GamepadBinding(.buttonMenu),
  KeyBinding(key: "p"),
  KeyBinding(key: Character(UnicodeScalar(0x1b)))
])
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы назначаем паузу как клавише p, так и клавише esc.

Другим примером с несколькими привязками может быть навигация пользовательского интерфейса.

let navigateAction = InputAction(name: Self.menuNavigate, type: .dpad, bindings: [
  GamepadBinding(.leftStick),
  GamepadBinding(.dpad),
  CompositeKeyBinding(keySet: .wasd),
  CompositeKeyBinding(keySet: .cursor)
])
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

// Assign the actions to an input group.
Input.shared.add(inputGroup:
  InputGroup(name: Self.gameplay, actions: [
    moveAction,
    jumpAction,
    fireAction,
    pauseAction
  ])
)
Вход в полноэкранный режим Выход из полноэкранного режима

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

Игре не нужно отслеживать собственный экземпляр для обработки ввода. Настройка конфигурации — это все, что нужно сделать игре.

Обработка сенсорного ввода

В предыдущем примере я не затронул тему назначения привязок для сенсорных элементов управления, без каламбура.

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

let fireAction = InputAction(name: Self.fireAction, type: .button, bindings: [
  TouchButtonBinding(
    position: Position(
        horizontal: .right,
        vertical: .bottom
    )
  )
])
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

Чтобы создать такую компоновку, структура TouchInputBinding имеет гораздо больше параметров, чем клавиатура и мышь.

Настройка предыдущих устройств оказалась довольно приятным и аккуратным куском кода конфигурации, который легко просматривать. Я не хотел визуально загрязнять код привязками для сенсорных устройств. Поэтому я добавил метод, с помощью которого я могу добавить дополнительные привязки к существующему действию ввода.

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

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

// Size shared by all buttons.
static let buttonSize = CGSize(width: 32, height: 32)

// Setup the texture reference
let fireButtonGlyph = TouchControllerTexture(
  name: "Fire Button", size: buttonSize,
  asset: .image(named: "Fire Button"))

  // Add the binding to an existing action.
  fireAction.add(
    binding: TouchButtonBinding(
      position: Position(
        horizontal: .right(buttonSize.width / 2),
        vertical: .top(buttonSize.height / 2)),
      button: TouchButton(
        symbol: fireButtonGlyph,
        background: TouchControllerTexture(
            name: "", size: .zero, asset: .none),
        pressedColor: .white
  )))
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

Каждый сенсорный элемент может принимать фоновую текстуру, которую я в данном примере не использую, так как элемент предоставил свою собственную.

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

Использование системы ввода в игре

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

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

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

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

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

Если взять в качестве примера тип ввода .button, то у нас есть 3 возможных состояния для запроса.

Опрос действий ввода

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

class InputComponent: Component {
  let fireAction = Input.shared.getAction(map: InputConfig.gameplay, name: InputConfig.fire)

  override func update(deltaTime seconds: TimeInterval) {
    if fireAction.triggered {
      // Do something...
    }
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

Если меня больше интересует получение аналогового значения для действия, я могу использовать метод getValue().

if let fireValue: CGFloat = fireAction.getValue() {
  print("fire:", fireValue)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Разные типы ввода возвращают разные типы значений. Вход .stick возвращает CGVector, а вход .button возвращает CGFloat. Метод getValue() является общим методом, поэтому мы должны привести его к тому типу, который, как мы ожидаем, вернет действие.

Прослушивание действий ввода

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

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

class SomeComponent: Component {
  init() {
    Input.shared.getAction(map: InputConfig.gameplay, name: InputConfig.moveAction).delegate = moveDidChange
  }

  func moveDidChange(_ inputAction: InputAction) {
    let movement: CGVector = inputAction.getValue()

    // Do something with the movement value.

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

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

Переключение группы входов

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

Я предпочитаю обрабатывать это с помощью машины состояний для состояний игры.

class PlayingState: GameSceneState {
  override func didEnter(from previousState: GKState?) {
    Input.shared.enableActionMap(name: InputConfig.gameplay)
  }

  override func willExit(to nextState: GKState) {
    Input.shared.enableActionMap(name: InputConfig.ui)
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

В этом примере мы меняем группу ввода каждый раз, когда входим и выходим из игрового состояния игры. Легко!

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

Под капотом

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

Фактическая внутренняя работа, обеспечивающая различные классы и типы значений, которые мы используем, содержится в моем Swift-пакете Game Input System. Погружение в суть моей библиотеки было бы слишком большим для такой статьи, мы бы превратили ее в книгу.

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

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

Основная структура

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

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

Эти четыре — основная функциональность системы. Поверх них находится синглтон Input, который упрощает взаимодействие с пакетом, и это практически все.

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

Заключение

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

Затем всегда остается управление состояниями пользовательского интерфейса… Но это уже история для другого дня.

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