
В разделе про архитектуру мы разбирали пример: InputManager обрабатывает нажатие клавиши и каким-то образом передаёт эту информацию Player. Было сказано, что для этого используются события. Но как именно это работает?
Допустим, у нас есть InputManager, Player и EnemyAI. InputManager знает, какие клавиши были нажаты. Player должен двигаться при нажатии W, а EnemyAI должен перейти в состояние атаки при нажатии определённой клавиши (для дебага, к примеру).
Наивный подход: InputManager напрямую вызывает методы у Player и EnemyAI.
class InputManager:
def __init__(self, player, enemy_ai, inventory, menu):
self.player = player
self.enemy_ai = enemy_ai
self.inventory = inventory
self.menu = menu
def process_input(self, keys):
if keys[pygame.K_w]:
self.player.move_up()
if keys[pygame.K_e]:
self.inventory.open()
if keys[pygame.K_ESCAPE]:
self.menu.toggle()
if keys[pygame.K_F1]:
self.enemy_ai.debug_attack()
public class InputManager
{
private Player player;
private EnemyAI enemyAI;
private Inventory inventory;
private Menu menu;
public InputManager(Player player, EnemyAI enemyAI,
Inventory inventory, Menu menu)
{
this.player = player;
this.enemyAI = enemyAI;
this.inventory = inventory;
this.menu = menu;
}
public void ProcessInput(KeyboardState keys)
{
if (keys.IsKeyDown(Keys.W))
player.MoveUp();
if (keys.IsKeyDown(Keys.E))
inventory.Open();
if (keys.IsKeyDown(Keys.Escape))
menu.Toggle();
if (keys.IsKeyDown(Keys.F1))
enemyAI.DebugAttack();
}
}
Что здесь не так? InputManager знает обо всех системах в игре. Он хранит ссылки на Player, EnemyAI, Inventory, Menu. Добавили новую систему - полезли в InputManager, добавили ещё одну ссылку и ещё один if. Убрали систему - снова лезем в InputManager. Он становится "узлом", через который проходит всё и который зависит от всего.
А если не только InputManager должен оповещать другие системы? Если Player при получении урона должен оповестить UI (обновить полоску здоровья), звуковую систему (проиграть звук) и систему частиц (показать кровь)? Тогда и Player тоже начнёт хранить ссылки на кучу систем. И каждая система будет знать обо всех остальных.
Это сильная связанность (tight coupling). Системы не независимы - они переплетены ссылками друг на друга. Изменение одной системы может сломать (сломает, не переживайте) другие.
В архитектуре было сказано: "Упрощённо рассмотрим событие как список методов для вызова." Давайте разберём эту фразу.
Ну, идея такая: вместо того, чтобы InputManager напрямую вызывал player.move_up(), он говорит: "Была нажата клавиша W. Кому интересно - вот." И все, кому это интересно, реагируют самостоятельно.
Т.е. InputManager не знает, кто будет слушать. Он просто объявляет: "Произошло событие X." А другие системы заранее говорят: "Мне интересно событие X, вот метод, который нужно вызвать."
Это и есть событийная модель (она же паттерн Observer, она же pub/sub - publish/subscribe).
Три роли:
Ок. Самый базовый вариант: событие - это объект, который хранит список функций (от подписчиков) и может их все вызвать.
class Event:
def __init__(self):
self.listeners = []
def subscribe(self, listener):
self.listeners.append(listener)
def unsubscribe(self, listener):
self.listeners.remove(listener)
def fire(self, *args):
for listener in self.listeners:
listener(*args)
В C# события встроены в язык через event и delegate. Но для наглядности посмотрим ручную реализацию, а потом - встроенную.
public class GameEvent
{
private List<Action> listeners = new List<Action>();
public void Subscribe(Action listener)
{
listeners.Add(listener);
}
public void Unsubscribe(Action listener)
{
listeners.Remove(listener);
}
public void Fire()
{
foreach (var listener in listeners)
listener();
}
}
// with data:
public class GameEvent<T>
{
private List<Action<T>> listeners = new List<Action<T>>();
public void Subscribe(Action<T> listener)
{
listeners.Add(listener);
}
public void Unsubscribe(Action<T> listener)
{
listeners.Remove(listener);
}
public void Fire(T data)
{
foreach (var listener in listeners)
listener(data);
}
}
Вот и всё. Три метода: подписаться, отписаться, активировать. Теперь посмотрим, как это меняет наш InputManager.
class InputManager:
def __init__(self):
self.on_move_up = Event()
self.on_move_down = Event()
self.on_interact = Event()
self.on_pause = Event()
def process_input(self, keys):
if keys[pygame.K_w]:
self.on_move_up.fire()
if keys[pygame.K_s]:
self.on_move_down.fire()
if keys[pygame.K_e]:
self.on_interact.fire()
if keys[pygame.K_ESCAPE]:
self.on_pause.fire()
# setup
input_manager = InputManager()
input_manager.on_move_up.subscribe(player.move_up)
input_manager.on_move_up.subscribe(camera_shake.on_player_move)
input_manager.on_interact.subscribe(inventory.try_open)
input_manager.on_pause.subscribe(menu.toggle)
public class InputManager
{
public GameEvent OnMoveUp = new GameEvent();
public GameEvent OnMoveDown = new GameEvent();
public GameEvent OnInteract = new GameEvent();
public GameEvent OnPause = new GameEvent();
public void ProcessInput(KeyboardState keys)
{
if (keys.IsKeyDown(Keys.W))
OnMoveUp.Fire();
if (keys.IsKeyDown(Keys.S))
OnMoveDown.Fire();
if (keys.IsKeyDown(Keys.E))
OnInteract.Fire();
if (keys.IsKeyDown(Keys.Escape))
OnPause.Fire();
}
}
// setup
var inputManager = new InputManager();
inputManager.OnMoveUp.Subscribe(player.MoveUp);
inputManager.OnMoveUp.Subscribe(cameraShake.OnPlayerMove);
inputManager.OnInteract.Subscribe(inventory.TryOpen);
inputManager.OnPause.Subscribe(menu.Toggle);
Что изменилось? InputManager больше не знает ни о Player, ни о Menu, ни о чём другом. Он просто объявляет события. Кто на них подпишется - его не касается. Добавили новую систему? Подписали её на нужное событие. Убрали систему? Отписали. InputManager не трогаем.
Рассмотрим ещё один пример. Игрок получает урон. На это должны отреагировать:
Без событий Player должен знать обо всех этих системах. С событиями:
class Player:
def __init__(self, health):
self.health = health
self.on_damage = Event()
self.on_death = Event()
def take_damage(self, amount):
self.health -= amount
self.on_damage.fire(self.health, amount)
if self.health <= 0:
self.on_death.fire()
# setup: each system subscribes to what it cares about
player.on_damage.subscribe(lambda hp, dmg: ui.update_health(hp))
player.on_damage.subscribe(lambda hp, dmg: sound.play("hit"))
player.on_damage.subscribe(lambda hp, dmg: camera.shake(intensity=dmg))
player.on_death.subscribe(lambda: ui.show_game_over())
player.on_death.subscribe(lambda: sound.play("death"))
player.on_death.subscribe(lambda: game.end_session())
public class Player
{
public int Health { get; private set; }
public GameEvent<(int health, int damage)> OnDamage = new GameEvent<(int, int)>();
public GameEvent OnDeath = new GameEvent();
public Player(int health)
{
Health = health;
}
public void TakeDamage(int amount)
{
Health -= amount;
OnDamage.Fire((Health, amount));
if (Health <= 0)
OnDeath.Fire();
}
}
// setup
player.OnDamage.Subscribe(data => ui.UpdateHealth(data.health));
player.OnDamage.Subscribe(data => sound.Play("hit"));
player.OnDamage.Subscribe(data => camera.Shake(intensity: data.damage));
player.OnDeath.Subscribe(() => ui.ShowGameOver());
player.OnDeath.Subscribe(() => sound.Play("death"));
player.OnDeath.Subscribe(() => game.EndSession());
Player знает только о своём здоровье и о том, что произошло событие. Кто отреагирует - да какая разница.
Если вы работаете с pygame, то уже сталкивались с pygame.event.get() в игровом цикле и в конечных автоматах. Это встроенная очередь событий pygame - она собирает действия пользователя (нажатия клавиш, движение мыши, закрытие окна) и отдаёт их вам списком.
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
# do something
pass
Как это связано с нашим классом Event? pygame.event - это механизм получения системных событий от ОС и оборудования: клавиатура, мышь, окно. Наш Event - это механизм передачи игровых событий между системами: "игрок получил урон", "враг умер", "предмет подобран".
Т.е. они работают на разных уровнях. На практике это выглядит так: InputManager получает системные события через pygame.event.get(), интерпретирует их и публикует игровые события через наш Event:
class InputManager:
def __init__(self):
self.on_jump = Event()
self.on_interact = Event()
def process_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
self.on_jump.fire()
elif event.key == pygame.K_e:
self.on_interact.fire()
return True
Можно также создавать свои события в очереди pygame через pygame.event.post(), но для внутриигрового общения между системами наш подход с классом Event проще и понятнее. Да и лезть во внутренности чужой очереди не стоит.
pygame.eventотвечает за "что нажал пользователь". НашEventотвечает за "что произошло в игре". Это разные задачи.
В C# есть встроенный механизм событий через ключевые слова event и delegate. Если вы работаете на C#, можно не писать свой класс GameEvent, а использовать стандартный синтаксис:
public class Player
{
public int Health { get; private set; }
// declare events with delegates
public event Action<int, int> OnDamage; // health, damage
public event Action OnDeath;
public Player(int health)
{
Health = health;
}
public void TakeDamage(int amount)
{
Health -= amount;
OnDamage?.Invoke(Health, amount);
if (Health <= 0)
OnDeath?.Invoke();
}
}
// subscribing
player.OnDamage += (health, damage) => ui.UpdateHealth(health);
player.OnDamage += (health, damage) => camera.Shake(damage);
player.OnDeath += () => ui.ShowGameOver();
// unsubscribing
player.OnDamage -= someMethod;
?.Invoke() проверяет, есть ли подписчики, и только тогда вызывает событие. Без этого, если подписчиков нет, будет NullReferenceException.
Оба варианта (свой GameEvent и встроенный event) работают. Встроенный - лаконичнее и идиоматичнее для C#. Свой класс - нагляднее и проще для понимания, что происходит "под капотом".
(aka диспетчер событий/сигналы-слоты/медиатор/центр оповещений и т.д.)
В примерах выше подписчик должен знать конкретного издателя: player.on_damage.subscribe(...). Т.е. мы подписываемся на событие конкретного объекта. А что, если мы хотим реагировать на событие "любой враг умер"? Подписываться на каждого врага по отдельности?
Можно ввести событийную шину - центральный объект, через который проходят все события. Издатели отправляют события в шину, подписчики слушают шину.
class EventBus:
def __init__(self):
self.listeners = {}
def subscribe(self, event_name, listener):
if event_name not in self.listeners:
self.listeners[event_name] = []
self.listeners[event_name].append(listener)
def fire(self, event_name, *args):
for listener in self.listeners.get(event_name, []):
listener(*args)
# global bus
bus = EventBus()
# enemy fires event when it dies
class Enemy:
def die(self):
bus.fire("enemy_died", self)
# score system listens
bus.subscribe("enemy_died", lambda enemy: score.add(100))
bus.subscribe("enemy_died", lambda enemy: sound.play("kill"))
public static class EventBus
{
private static Dictionary<string, List<Action<object>>> listeners
= new Dictionary<string, List<Action<object>>>();
public static void Subscribe(string eventName, Action<object> listener)
{
if (!listeners.ContainsKey(eventName))
listeners[eventName] = new List<Action<object>>();
listeners[eventName].Add(listener);
}
public static void Fire(string eventName, object data = null)
{
if (listeners.ContainsKey(eventName))
foreach (var listener in listeners[eventName])
listener(data);
}
}
// enemy fires event
public class Enemy
{
public void Die()
{
EventBus.Fire("enemy_died", this);
}
}
// score system listens
EventBus.Subscribe("enemy_died", data => score.Add(100));
EventBus.Subscribe("enemy_died", data => sound.Play("kill"));
Шина удобна, но у неё есть свои минусы: все события проходят через одну точку, типизация слабая (строковые имена событий легко опечатать), и при большом количестве событий становится сложно отслеживать, кто что слушает.
Хм, а можно ли избавиться от строковых имён? Можно. Вместо строки "enemy_died" используем класс события как ключ. Каждое событие - отдельный класс с нужными данными:
class EnemyDied:
def __init__(self, enemy, position):
self.enemy = enemy
self.position = position
class PlayerDamaged:
def __init__(self, health, amount):
self.health = health
self.amount = amount
class TypedEventBus:
def __init__(self):
self.listeners = {}
def subscribe(self, event_type, listener):
if event_type not in self.listeners:
self.listeners[event_type] = []
self.listeners[event_type].append(listener)
def fire(self, event):
for listener in self.listeners.get(type(event), []):
listener(event)
bus = TypedEventBus()
# subscriber gets a typed object, not raw args
bus.subscribe(EnemyDied, lambda e: score.add(100))
bus.subscribe(EnemyDied, lambda e: particles.spawn_at(e.position))
bus.subscribe(PlayerDamaged, lambda e: ui.update_health(e.health))
# publisher creates an event object
bus.fire(EnemyDied(enemy=self, position=(self.x, self.y)))
// event classes
public class EnemyDied
{
public Enemy Enemy;
public Vector2 Position;
}
public class PlayerDamaged
{
public int Health;
public int Amount;
}
public static class TypedEventBus
{
static Dictionary<Type, List<Action<object>>> listeners = new();
public static void Subscribe<T>(Action<T> listener)
{
var type = typeof(T);
if (!listeners.ContainsKey(type))
listeners[type] = new List<Action<object>>();
listeners[type].Add(obj => listener((T)obj));
}
public static void Fire<T>(T evt)
{
if (listeners.TryGetValue(typeof(T), out var list))
foreach (var listener in list)
listener(evt);
}
}
// subscriber gets a typed object
TypedEventBus.Subscribe<EnemyDied>(e => score.Add(100));
TypedEventBus.Subscribe<EnemyDied>(e => particles.SpawnAt(e.Position));
TypedEventBus.Subscribe<PlayerDamaged>(e => ui.UpdateHealth(e.Health));
// publisher creates an event object
TypedEventBus.Fire(new EnemyDied { Enemy = this, Position = pos });
Теперь опечатка в имени события - это ошибка компиляции (C#) или отсутствие класса (Python). Подписчик получает типизированный объект с конкретными полями вместо object или *args. А каждый класс-событие - ещё и документация: видно, какие данные несёт событие, без чтения кода издателя.
События - не панацея. Используйте их там, где это оправдано:
Не стоит использовать события для всего подряд. Если один объект вызывает метод другого объекта, и это всегда один конкретный вызов - обычный вызов метода проще и понятнее.
События усложняют отладку. Когда что-то пошло не так, сложнее отследить цепочку вызовов, потому что связь между издателем и подписчиком - неявная. Не используйте события без необходимости.
Событийная модель хорошо ложится на MVC: Controller обрабатывает ввод и публикует события, Model слушает их и обновляет данные, View подписывается на изменения Model и перерисовывает интерфейс. Именно так работает "оповещение представления об изменениях", упомянутое в разделе про MVC.
В ECS события тоже полезны: System может публиковать событие при определённых условиях (столкновение, смерть), а другие System'ы - реагировать на них.
В конечных автоматах переходы между состояниями можно реализовать через события: вместо проверки клавиш внутри каждого состояния, состояние подписывается на нужные события от InputManager.
Т.е. событийная модель - это не отдельная архитектура, это механизм связи между частями любой архитектуры.
| Что | Где подробнее |
|---|---|
| Архитектура и разделение ответственности | Архитектура |
| Model-View-Controller и оповещение View | MVC |
| Конечные автоматы и переходы по событиям | Конечные автоматы |
| ECS и межсистемное общение | ECS |
| Тесная связность как типичная ошибка | Типичные ошибки |