Найти в Дзене
ZDG

Дневник разработки игры Pengu5, 16.02.26

В рамках данных себе обещаний я продолжаю делать долгострой Pengu5, которому уже было посвящено несколько статей. Задача – сделать систему событий, которая должна обрабатывать действия пользователя в интерфейсе, но также любые внутриигровые события, так что тут польза будет сразу на двух направлениях. Хотя в JаvaScript есть собственная реализация событий и её можно прикрутить к произвольным объектам, я не хочу опираться на конкретную платформу. Разработка игры начиналась ещё на Flash, и кто знает, куда ещё её придётся портировать. Система событий для Pengu5 уже была написана и работала, но сейчас я хочу переписать её с нуля, потому что текущая реализация устарела и не очень нравится. Начну с базы. Это собственно событие, происходящее в системе, даже не знаю, как лучше сказать. У события есть тип, и какие-то данные, соответствующие данному типу. Например, событие движения мыши имеет тип, который можно назвать MouseMove и данные x, y для координат курсора. Больше события ничем не выделяю
Оглавление

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

Игра Pengu5 | ZDG | Дзен

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

Хотя в JаvaScript есть собственная реализация событий и её можно прикрутить к произвольным объектам, я не хочу опираться на конкретную платформу. Разработка игры начиналась ещё на Flash, и кто знает, куда ещё её придётся портировать.

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

Начну с базы.

1. Событие

Это собственно событие, происходящее в системе, даже не знаю, как лучше сказать. У события есть тип, и какие-то данные, соответствующие данному типу. Например, событие движения мыши имеет тип, который можно назвать MouseMove и данные x, y для координат курсора. Больше события ничем не выделяются, это просто структуры данных.

2. Слушатель

-2

Слушатель – тот, кто слушает события. Событие похоже на падающее в лесу дерево – кто-то должен его услышать, а если никто не услышал, то его считай и не было вовсе.

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

-3

3. Обработчик (коллбэк)

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

Слушатель делает это с помощью подпрограммы-обработчика, которую также называют коллбэком (callback – обратный вызов). Таким образом, каждому слушателю можно назначить произвольный обработчик.

-4

4. Целевой объект

Это в принципе необязательно, но зачастую у события есть объект, на который оно направлено. Например, на экране нарисована кнопка, и мы её нажимаем. Возникает событие "нажата кнопка мыши", в результате которого нужно перерисовать кнопку в нажатом виде. То есть объект кнопки является целевым для события, и с ним можно произвести какие-то манипуляции.

Целевой объект определяется в момент обработки события.

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

5. Предикат

Представим следующую ситуацию: на экране находятся несколько кнопок, и у каждой кнопки есть свой слушатель, который слушает событие "кнопка мыши нажата" (пусть это будет тип MouseDown).

Мы нажимаем на кнопку мыши, тем самым вызывая событие MouseDown, и система начинает его обрабатывать.

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

Что произойдёт в результате? Все кнопки одновременно обработают одно и то же событие и нажмутся. Это очевидно не то, что мы хотели бы иметь в нормальном случае.

Нажаться должна только та кнопка, на которую наведён курсор, а если он вдруг никуда не наведён, то и вообще ни одной.

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

Предикат это выражение, которое возвращает результат "истина" или "ложь".

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

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

Общий механизм

Рассмотрим пример для кнопки. Нам нужно, чтобы при нажатии на кнопку она рисовалась как нажатая, и также например на экране писалось 'Hello'.

  1. Создаём функцию-обработчик, которая пишет на экране 'Hello', и называем её CallbackHello.
  2. Создаём класс объекта-кнопки Button, и дописываем к нему метод predicate(), который должен возвращать true, если курсор попадает в него.
  3. Регистрируем в системе слушатель события MouseDown (кнопка мыши нажата) с коллбэком CallbackHello.
  4. В систему поступает внешнее событие. Оно может прийти из внешней среды или от нас самих.
  5. Система по типу события находит список слушателей, слушающих этот тип события, и начинает им всем раздавать это событие. Слушатель кнопки получает событие, проверяет предикат, и в случае успеха вызывает назначенный обработчик CallbackHello, который выводит на экран 'Hello'.

Но есть нюанс

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

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

Доработанный механизм

Очевидно, что слушателю события MouseDown нужно выполнять две задачи: заставлять кнопку перерисовываться и вызывать коллбэк.

Слушатель не должен знать, как перерисовать кнопку, тем более что это может быть и не кнопка, а например чекбокс. Вместо этого мы добавляем в слушатель свойство-поведение (behaviour), которое будет отвечать за перерисовку или другие обновления целевого объекта. Его мы будем назначать извне.

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

Произвольно выбранный предикат можно также добавить как свойство слушателя (predicate).

Итого, я могу написать конструктор слушателя Listener:

-5

И сконструировать конкретный слушатель для кнопки.

Сделаю класс кнопки:

-6

У него есть атрибут frame, который соответствует определённому кадру отрисовки: отжатый, нажатый или выделенный. Методы up(), down() и hover() банально меняют номер кадра. Рисовать будет рендерер, поэтому прямо здесь больше ничего не делается.

Сделаю обработчик поведения кнопки (для краткости только для нажатия):

-7

И наконец, сделаю предикат для обработки попадания в кнопку:

-8

Теперь с этим всем можно создать кнопку и экземпляр слушателя:

-9

Смотрим: слушатель имеет доступ к целевому объекту (кнопке), к коллбэку (CallbackHello), к поведению целевого объекта (BehaviourButtonMouseDown) и к предикату (PredicateMouseIn). Так что он может полностью обработать событие, при этом не имея понятия, что именно он обрабатывает.

Теперь слушатель надо зарегистрировать в системе на определённое событие.

Сделаю минимально необходимую систему обработки событий:

-10

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

Обработка происходит так:

-11

В систему поступает внешнее событие evt. По его типу она находит список слушателей и перебирает его, предлагая событие каждому слушателю. Слушатель через предикат predicate() определяет, будет ли объект обрабатывать событие, и если да, то вызывает обработчик behaviour() и затем callback().

Атрибут status слушателя здесь служит дополнительным фильтром, чтобы предотвратить множественную отработку коллбэка в случае, когда слушатель уже активен (например, мышь навели на кнопку и водят по ней, кнопка уже активна и повторно активировать её не надо).

Дальше в лес

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

-12

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

События, которые всё ломают

Вышеописанная схема рабочая, но спотыкается об одну проблему. Извне поступает сырое событие, например "движение мыши" (MouseMove). Но для обработки кнопок нужны более рафинированные события: "курсор мыши зашёл в периметр кнопки" (MouseIn) и "курсор мыши покинул периметр кнопки" (MouseOut). В отличие от события MouseMove, которое происходит постоянно при движении мыши, это события-триггеры, которые происходят только один раз при пересечении курсором границы кнопки.

Поэтому мы делаем слушатели на события MouseIn и MouseOut, но вызываем их по оригинальному событию MouseMove.

Чтобы событие MouseIn работало как триггер, слушатель должен помнить своё предыдущее состояние: был ли курсор уже в пределах кнопки? Если был и сейчас есть, то ничего делать не надо – триггер уже сработал. Если не был, и сейчас есть, значит, именно сейчас случилось событие MouseIn и его надо обработать. Если был, а теперь нет – значит, курсор покинул кнопку и случилось событие MouseOut.

Добавить текущий статус в слушатель не составляет проблем – как видно по листингу, он уже добавлен.

Однако теперь мы делаем ещё один слушатель для события нажатия на кнопку (MouseDown), и вот что происходит:

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

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

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

Дальше – хуже

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

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

Варианты решений

  • Каким-то образом поделить слушатели на имеющие статус и не имеющие
  • Перед каждой обработкой события сбрасывать статусы всех слушателей, которые в них не нуждаются
  • Хранить статус не в слушателе, а в целевом объекте, к которому имеют доступ все слушатели. Например, сделать у объекта атрибут listenerStatus. Тогда все связанные с объектом слушатели будут иметь доступ к общему статусу. Но тогда возникнут и проблемы общего доступа – сложная логика, вероятность перезаписи одного статуса другим и др. Кроме того, все объекты, работающие с событиями, должны будут иметь атрибут listenerStatus.

Пока что я выбрал первый способ и переделал константы статусов:

-13

И изменил обработку статусов слушателей, просто добавив отдельную ветку для статуса LSN_STATUS_NONE:

-14

Не сильно красиво, так что добавлю методы в Listener:

-15

И теперь перепишу с ними:

-16

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

Итого

Я свёл всё, что здесь написано, в один пример (с небольшими изменениями и дополнениями), который можно посмотреть живьём на этой странице, а чтобы увидеть код, нажмите Ctrl-U.

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