Игру обычно можно разбить на различные системы, которые являются строительными блоками архитектуры игры.
Одна система может управлять экранным HUD, другая — игроком, третья — врагами и так далее, и все эти системы должны взаимодействовать друг с другом тем или иным образом. Очень легко оказаться в кошмаре зависимостей между различными игровыми системами.
Кошмар зависимостей
Мы начнем с примера проблемной архитектуры, которой мы попытаемся избежать, используя игровые события для более модульного подхода.
Допустим, у нас есть враг, которого мы хотим протестировать изолированно, чтобы сэкономить время. Нет необходимости запускать весь игровой мир, чтобы увидеть анимацию, реакцию врага на определенные взаимодействия и тому подобные вещи. Поэтому мы будем использовать пустую сцену, в которую мы можем поместить врага и провести несколько быстрых тестов в живой среде.
Мы настроим «голую» сцену и добавим некоторый код инициализации для создания врага. Но он не скомпилируется; вражеская сущность зависит от существования EnemyManager
.
Поэтому мы также инстанцируем EnemyManager
в нашей тестовой сцене и попробуем еще раз. Снова не компилируется, EnemyManager
не может найти Player
, зависимость, которая используется для того, чтобы враги принимали свои AI решения.
Итак, мы тоже добавляем игрока в тестовую сцену. И запускаем ее.
Ай, Игроку нужен InventoryManager
. Хорошо, добавим и его. И поехали! Нееееет! Игроку также нужен HUDManager
.
В итоге мы практически перестроили всю архитектуру игры внутри того, что должно было стать нашей простой и удобной сценой отладки.
Игровые события
Что приводит нас к событиям. Используя события для связи между различными системами игры, мы получаем гораздо более развязанную архитектуру. Когда система поднимает событие, ей все равно, кто его слушает, а системе, которая слушает событие, все равно, кто его поднял.
Благодаря этому очень легко взять любой объект из игры и бросить его в изолированное окружение или сцену, и он практически сразу же заработает. И мы можем украсить нашу тестовую сцену триггерами событий, чтобы мы могли вызвать любое событие по требованию и наблюдать за поведением, без необходимости добавлять систему, которая вызвала бы событие в игре.
Такая архитектура помогает нам избавиться от жестких зависимостей между системами нашей игры.
И, конечно, когда дело доходит до собственно игровых сцен, чем более развязанными и изолированными будут наши системы, тем больше возможностей для повторного использования будет в будущих проектах, и, как бонус, их будет гораздо легче отлаживать и тестировать.
Наблюдатели против вещателей
Когда речь заходит об обработке событий, в первую очередь популярны два паттерна проектирования: использование наблюдателей или вещателей со слушателями.
Я реализовывал и использовал оба подхода в различных проектах, и хотя мне многое нравится в паттерне наблюдателя, он так или иначе опирается на зависимости между объектами. Именно этого мы и пытаемся избежать.
Я использовал решения, похожие на это.
class HealthComponent: GKComponent, Observable {
private(set) var health: Int {
didSet { notifyObservers(with: .entityHealthChanged) }
}
}
И тогда HUD может наблюдать за компонентом здоровья, чтобы мгновенно обновлять полоску здоровья всякий раз, когда здоровье игрока меняется. Но это требует, чтобы HUD знал об объекте игрока, чтобы зарегистрироваться в нем, что дает нам жесткую зависимость.
Поэтому лично я больше редко использую этот паттерн, и вместо этого большую часть времени полагаюсь на трансляцию событий и наличие слушателей, что обеспечивает гораздо более развязанную архитектуру.
Недостатком может быть то, что может быть сложнее читать и следить за потоком событий в коде, когда нет жестких связей между системами. Новичку придется потратить некоторое время на поиск и выяснение того, что слушает какие события, поскольку компилятор не может помочь в этом.
Сейчас я решаю эту проблему путем регистрации событий при запуске игры в режиме отладки, поэтому я могу в любой момент запросить журнал и посмотреть, какие события были вызваны, и какие объекты отреагировали на каждое событие. Это более или менее устраняет этот недостаток.
Реализация
Это было много предыстории и теории, но теперь, когда мы решили, что хотим транслировать события между системами, давайте посмотрим, как мы можем реализовать эту функциональность в Swift. Мы собираемся сделать нашу собственную реализацию, поскольку хотим добиться максимальной производительности. Мы не хотим полагаться на NotficationCenter
или другие встроенные решения, которые менее производительны.1 чем то, что мы можем создать сами.
Каналы событий
Мы собираемся использовать каналы событий, по которым мы ведем трансляцию и передаем все необходимые данные. Заинтересованные стороны могут прослушивать интересующие их каналы и реагировать на события.
Поэтому мы создадим класс Event, который будет инстанцировать каждый созданный нами канал событий и будет доступен для систем для вещания или прослушивания.
public class Event<T> {
}
Мы будем использовать дженерики Swift для этого класса, когда мы определяем канал событий, мы также определяем с помощью дженерика, какие данные мы будем передавать вместе с событием.
let enemyDestroyedEvent = Event<Enemy>()
Здесь мы создаем канал enemyDestroyedEvent
, в который мы будем передавать экземпляр объекта Enemy. Когда враг будет уничтожен, он будет транслировать и передавать себя по этому каналу непосредственно перед удалением из игры.
Ряд других систем могут слушать это событие; аудиосистема может принять объект Enemy и определить, какой звуковой эффект воспроизвести; система пользовательского интерфейса показывает плавающий счет за короткий промежуток времени, в течение которого враг был уничтожен. Система VFX создает анимацию взрыва на экране в последней позиции врага.
Наш класс Event
должен будет хранить коллекцию слушателей для каждого канала, чтобы канал знал, какие объекты должны быть уведомлены при возникновении события.
public typealias EventAction = (_ subject: T) -> Void
/// Listener wrapper to be able to use a weak reference to the listener.
private struct Listener {
/// Weak reference to the listener.
weak var listener: AnyObject?
/// Action closure provided by the listener.
var action: EventAction
}
private var listeners: [ObjectIdentifier: Listener] = [:]
Мы не хотим рисковать тем, что канал событий сохранит объект живым, если объект будет удален из игры, поэтому мы используем Listener
struct в качестве обертки вокруг объекта, который слушает, так что мы можем иметь слабую ссылку на слушателя. Мы используем ObjectIdentifier
в качестве ключа для слушателя, чтобы было легко найти слушателя в коллекции, если нам понадобится его удалить.
Синтаксис EventAction
typealias
дает нам облегченный синтаксис.
Теперь у нас есть место для хранения слушателей для канала событий, поэтому давайте добавим несколько методов, чтобы объекты могли сообщить каналу, что они хотят слушать события.
/// Register a new listener for the event.
public func addListener(_ listener: AnyObject, action: @escaping EventAction) {
let id = ObjectIdentifier(listener)
listeners[id] = Listener(listener: listener, action: action)
}
// Unregister a listener for the event.
public func removeListener(_ listener: AnyObject) {
let id = ObjectIdentifier(listener)
listeners.removeValue(forKey: id)
}
Благодаря надежной структуре, в которой мы храним слушателей, методы addListener()
и removeListener()
становятся очень простыми. По сути, нам просто нужно получить ObjectIdentifier
для слушателя и добавить/удалить его из коллекции.
И, наконец, нам понадобится метод для трансляции событий слушателям.
/// Raise the event to notify all registered listeners.
public func notify(_ subject: T) {
for (id, listener) in listeners {
// If the listening object is no longer in memory,
// we can clean up the listener for its ID.
if listener.listener == nil {
listeners.removeValue(forKey: id)
continue
}
listener.action(subject)
}
}
Здесь мы поднимаем событие для итерации по коллекции слушателей, чтобы оповестить их. Поскольку мы использовали слабую ссылку на слушателя, чтобы не сохранять его в случае удаления из игры, мы также проверяем здесь, что слушатель все еще рядом. Если это не так, мы используем возможность очистить коллекцию, удалив его.
Вот и все; этот класс дает нам все необходимое для трансляции и прослушивания событий.
Использование каналов событий
Остается вопрос о том, как использовать каналы событий, когда нам нужно взаимодействовать между различными системами игры. Существует множество подходов. Я предпочитаю использовать синглтон GameEvent
в качестве коммуникационного центра, где я определяю все каналы, которые использует игра.
class GameEvent {
static let shared = GameEvent()
private init() {}
// Event Channels.
let scoreChangedEvent = Event<GameData>()
let livesChangedEvent = Event<GameData>()
let enemyDestroyedEvent = Event<Enemy>()
let playerDamagedEvent = Event<HealthComponent>()
}
Это место, где я собираю и организую все каналы событий, которые понадобятся игре, и, раскрывая его как синглтон, я могу просто использовать его из любой точки кода игры.
Чтобы объект зарегистрировал себя в качестве слушателя, мы используем метод addListener()
в канале событий.
class AudioComponent: Component {
init() {
GameEvent.shared.playerDamagedEvent.addListener(self) { [weak self] _ in
self?.onPlayerDamaged()
}
}
func onPlayerDamaged() {
// Play the sound effect when player is taking damage.
}
}
У нас есть компонент, который обрабатывает воспроизведение звуковых эффектов. Если заставить его слушать playerDamageEvent
, он сможет воспроизводить соответствующий звуковой эффект каждый раз, когда игрок получает повреждения. Кроме того, индикатор здоровья в HUD будет слушать это событие, а также, возможно, компонент анимации, который будет воспроизводить анимацию повреждения и, возможно, испускать некоторые частицы.
Чтобы быть полезными, события также должны быть подняты.
class HealthComponent: Component {
func takeDamage() {
// Do some other damage related things...
GameEvent.shared.playerDamagedEvent.notify(self)
}
}
Это более или менее понятно. Это компонент здоровья игрока, который поднимает событие каждый раз, когда игрок получает урон, чтобы другие системы могли на него реагировать. Компонент передает сам себя вместе с событием, поэтому слушатели, которые полагаются на данные в компоненте здоровья, могут это сделать. HUD, скорее всего, будет проверять процент здоровья компонента при обновлении визуальной информации на экране.
Заключение
Это мой любимый способ передачи данных между игровыми системами, и я считаю его очень мощным и гибким. Полная развязка между объектами предоставляет возможности для очень интересных решений. Поскольку объект не знает, не заботится и даже не интересуется тем, что слушает его события, вы можете соединить практически что угодно с чем угодно.
Учитывая это, каждый игровой компонент может быть очень чистым и сосредоточенным только на одной вещи. Компонент здоровья должен только отслеживать здоровье; все остальное в игре, что зависит от здоровья, обрабатывается другими системами, более подходящими для отдельной цели, а события являются механизмом доставки между ними.
Пользовательский интерфейс управляется событиями от компонента здоровья; компонент уничтожения управляется теми же событиями. Аудиокомпонент также может прослушивать соответствующее событие. Все, что имеет смысл, может слушать события, без необходимости вводить зависимость между объектами.
Но не забывайте… С большой силой приходит большая ответственность.
-
Результаты производительности. Результаты тестирования производительности Центра уведомлений.