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

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

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

Пару слов перед началом

На мой взгляд самое противное для пошагового геймплея, это то, что вся графика обновляется не так, как логика. Если делать иначе, получится некрасивая и дерганная игра, по сути лишенная визуала. Этой проблемы нет только, пожалуй, у олдскульных консольных рогаликов. Поэтому в числе главных задач будет подружить эти два мира - дискретный и непрерывный (ну, условно непрерывный).

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

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

Так что будем держать в голове какую-то такую условную схему:

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

Начну с реализации на python 3.10 + pygame 2.1.2 для проработки идеи с нуля, затем перейду в движок Godot Engine v3.4.4 для отдаления от технических деталей и проработки только модели, а не реализации.

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

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

Действия - всему голова

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

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

Давайте назовем такую очередь ActionProcessor, а действие - Action.

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

Так как в python функция - это тоже объект, будем просто передавать его в конструктор вместе с аргументами. Для начала получаем что-то вроде:

# action.py
class Action:

    def __init__(self, function, args:list):
      	# двойное подчеркивание - локальная переменная
        self.__function = function
        self.__args = args.copy()

И сразу добавим метод выполнения действия, назвав его просто act:

  ...  
  
  def act(self):
      # синтаксис *list распаковывает список в функцию так, будто его написали вручную
      # это не указатель.
      self.__function(*self.__args)

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

# actionProcessor.py
class ActionProcessor:

    def __init__(self):
        self.__actions = []


    def add_action(self, action):
        self.__actions.append(action)


    def act_first(self): # да, будет ошибка при пустом списке. Оставим на потом
        self.__actions.pop(0).act()
        
        
    def has_actions_in_queue(self):
        return len(self.__actions) > 0

Теперь у нас есть зачатки логики, давайте перейдем к главному циклу.

Главный цикл

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

# main.py
import pygame
import sys

from action import *
from actionProcessor import *

pygame.init()

screen = pygame.display.set_mode((700, 700)) # создаем окно размером 700 на 700

А затем перейдем к сути:

...
action_processor = ActionProcessor()


while True:
    for event in pygame.event.get(): # инпуты
        if event.type == pygame.QUIT: # закрытие окна при тыке на крестик
            pygame.quit()
            sys.exit()


    if action_processor.has_actions_in_queue(): # очередь
        action_processor.act_first()

 
    pygame.display.flip() # обновление экрана

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

Поэтому не будем же с этим медлить. Создадим переменную hand_mode, отвечающую за включенность ручного режима, и allow_next, показывающую, можно ли брать действие:

...
hand_mode = True
allow_next = False
...

Теперь немного перепишем код взятия нового действия:

...  
    if action_processor.has_actions_in_queue():
          # если автоматический режим или можно двигать очередь
          if not(hand_mode) or allow_next: 
              action_processor.act_first()
              allow_next = False # сбрасываем, а то автоматически пойдет дальше
 ...

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

...   
        if event.type == pygame.KEYDOWN: # внутри цикла for
            if event.key == pygame.K_f:
                allow_next = True
...

Для проверки добавим несколько Action:

...
for i in range(1, 11):
    a = Action(lambda x: print(x*x), [i])
    action_processor.add_action(a)
...

И работает прекрасно. Нажимаем f, получаем в консоли квадрат очередного числа, пока очередь не иссякнет.

Полный код на данный момент.

Ради чего все затевалось

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

# actor.py
import pygame

from action import *

class Actor(pygame.sprite.Sprite):

    group = pygame.sprite.Group() # собираем все экземпляры в кучу

    def __init__(self, position):
        pygame.sprite.Sprite.__init__(self)

        Actor.group.add(self) # автоматическое добавление при создании
        
        # переменная image используется pygame для рендера
        self.image = pygame.Surface([32, 32]) 
        self.image.fill((0, 255, 0, 255))
        
        # а rect для определения места рендера
        self.rect = self.image.get_rect()
        self.rect.move_ip(*position) # перемещаем на начальную позицию

        self.__target = pygame.math.Vector2(0, 0)
        self.__in_moving = False
        self.__progress = 0.0 # прогресс интерполяции
        self.__move_start_point = pygame.math.Vector2(0, 0)


    def init_moving(self, to): # движение до точки
        self.__target = pygame.math.Vector2(to)
        self.__in_moving = True
        self.__progress = 0.0
        self.__move_start_point = pygame.math.Vector2(self.rect.center)


    def get_init_move_action(self, to): # получение действия движения
        a1 = Action(self.init_moving, [to])

        return a1


    def update(self): # метод будет вызываться каждый кадр
        if self.__in_moving:
            if self.__progress > 1.0:
                self.__in_moving = False
                return

            self.rect.center = self.__move_start_point.lerp(self.__target, self.__progress).xy

            self.__progress += 0.001

Останавливаться на нем подробно я не буду, поэтому сразу перейду к главному циклу. Тут все просто: создаем экземпляр класса с названием player, а при нажатии ЛКМ добавляем в очередь действие движения. Ну и про обновление не забываем:

...
from actor import *
...
player = Actor((137, 541))

...

        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                a = player.get_init_move_action(event.pos)

                action_processor.add_action(a)
            
    ...
    
    screen.fill((0, 0, 0, 255)) # очищаем экран

    Actor.group.update() # pygame.Group делает все автоматически
    Actor.group.draw(screen)

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

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

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

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

# trigger.py
class Trigger:
    def __init__(self):
        self.__triggered = True


    def trigger(self): # сигнал того, что что-то произошло
        self.__triggered = True


    def refresh(self): # сигнал того, что событие началось
        self.__triggered = False


    def triggered(self): # было ли завершено событие
        return self.__triggered

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

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

Давайте в Action обозначим переменную, хранящую триггер его завершения, и метод для проверки:

		...
    def __init__(self, function, args:list, done_trigger=None):
        ...
        self.__done_trigger = done_trigger
    
    ...
    
    def completed(self): # если триггера нет, значит ожидать нечего
        return self.__done_trigger == None or self.__done_trigger.triggered()

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

    ...
  	def __init__(self):
        ...
        self.__current_action = None
        
    ...
    
    def current_in_process(self):
        return self.__current_action != None and not self.__current_action.completed()

Собственно само запоминание:

		  ...
  
      def act_first(self):
        self.__current_action = self.__actions.pop(0) # запоминаем последнее выполненное

        self.__current_action.act()

Теперь просто добавим триггер завершения анимации движения в Actor. Будем обнулять его при старте движения и триггерить по завершении:

...
from trigger import * 
  
  ...
    def __init__(self, position):
				...
        self.__move_completed_trigger = Trigger()
        
    ...
        
    def init_moving(self, to):
        ...
        self.__move_completed_trigger.refresh() # анимация началась, => она не завершена
    
    ...
    
    def update(self):
        if self.__in_moving:
            if self.__progress > 1.0:
                ...
                self.__move_completed_trigger.trigger() # анимация завершена
								...

А при создании действия передавать в Action:

    ...
    def get_init_move_action(self, to):
        a1 = Action(self.init_moving, [to], self.__move_completed_trigger)

        return a1

В главном цикле остается только добавить условие для продвижения очереди ( текущее действие уже закончено):

		...
    if action_processor.has_actions_in_queue():
        if not(hand_mode) or allow_next:
            if not action_processor.current_in_process(): # добавили это
                action_processor.act_first()
                allow_next = False

Ну и я еще добавил визуализацию очереди.

Подробности

Во-первых, я написал методы, чтобы классы Action и Trigger нормально выглядели в текстовом представлении:

# action.py
		...
    def __str__(self): # питоновский магический метод
        return "Action: " + self.__function.__name__ + " " + str(self.__args) + " " + str(self.__done_trigger)
      
# trigger.py

		...
  	def __str__(self):
        return str(self.__triggered)

Затем добавил пару геттеров для ActionProcessor:

		...
    def get_actions(self):
        return self.__actions.copy()


    def get_current_action(self):
        return self.__current_action

Скачал открытый шрифт (есть в репозитории) и создал объект шрифта:

# main.py
...
font = pygame.font.Font("kenney-pixel.ttf", 32)

Ну и написал собственно сам рендер очереди. Сделано топорно, но работает:

		... 
  	# в главном цикле
    
    screen.fill((0, 0, 0, 255))
    
    # Drawing action queue
    if action_processor.current_in_process():
        surf = font.render(str(action_processor.get_current_action()), False, (255, 255, 255))
        screen.blit(surf, (0, 0, 0, 0))

    n = 2
    for i in action_processor.get_actions():

        surf = font.render(str(i), False, (255, 255, 255))

        screen.blit(surf, (0, 20*n, 0, 0))

        n += 1
    # End drawing aciton queue

И наконец получаем нормальное движение (ручной режим убран):

Как было бы без триггеров

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

start_animation_action = Action(object.start_move_animation, [to], object.move_animation_trigger)

Именно это вы и могли видеть в моем примере с зеленым квадратиком. Вот вам и некоторый best-practice.

Полный код на данный момент.

Много действий - тоже действие

Давайте прикинем. Двигаться из комнаты до двери - это действие. Двигаться от двери к остановке - действие. А двигаться от комнаты до остановки, можно ли это назвать действием? Почему нет. Если спроецировать action-логику на реальный мир, то встать со стула такое же действие, как слетать в Испанию в отпуск. А каждое из таких можно разбить на более мелкие, подобно матрешке:

Понимаете, к чему я клоню? На самом деле каждое из действий на схеме можно разбить еще на несколько, а те в свою очередь еще...

Давайте ближе к сути. Нам нужно сделать так, чтобы если функция в объекте действия возвращала другие действия, то мы как бы заменяли текущее ими. Для начала определим класс, представляющий набор действий. Назовем его ActionList и для простоты унаследуем от питоновского list:

# actionList.py
class ActionList(list):
    # тут синтаксис со звездочкой означает любое кол-во аргументов
    def __init__(self, *items):
        for i in items:
            self.append(i)

Этот класс нужен только для того, чтобы отличать просто список от набора действий в возвращаемом функцией значении. А что бы такое вообще было реализуемо, нужно иметь возможность это значение посмотреть. Давайте скажем, что метод act возвращает то же значение, что и функция внутри действия:

    ...

    def act(self):
        return self.__function(*self.__args)
      
    ...

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

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

Получим такой код:

# actionProcrssor.py
from actionList import *  
  
...

    def add_action(self, action, urgent=False):
        if urgent: # добавление в начало очереди
            self.__actions.insert(0, action)
        else:
            self.__actions.append(action)
        
        
    def act_first(self):
        self.__current_action = self.__actions.pop(0)

        result = self.__current_action.act()
				
        if isinstance(result, ActionList):
            for i in range(len(result)): # добавляем в обратном порядке
                self.add_action(result[len(result) - 1 - i], True)
    
...

Не уверен, что конструкция типа isinstance(result, ActionList) соответствует концепции динамически типизированных языков, но пусть будет.

Для теста напишем следующую функцию:

...
def opers(a, b):
    a1 = Action(lambda x, y: print(x+y), [a, b])
    a2 = Action(lambda x, y: print(x-y), [a, b])
    a3 = Action(lambda x, y: print(x/y), [a, b])
    a4 = Action(lambda x, y: print(x*y), [a, b])

    return ActionList(a1, a2, a3, a4)
...

И получим, что нужно:

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

  • Выйти из дома.

  • Пройтись до ларька.

  • Прийти обратно.

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

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

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

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

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

А в качестве бонуса покажу реализацию последовательности Фибоначчи:

def fib(a, b): # да, уйдет в бесконечность
    print(a+b)

    return ActionList(Action(fib, [a+b, a]))

Очень похоже на рекурсию, не находите?

Полный код на данный момент.

Случается всякое

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

На языке действий это будет выглядеть как-то так:

  1. Двигаться к точке.

  2. Самоуничтожиться, если есть коллизия с красной зоной.

  3. Двигаться обратно.

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

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

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

    ...
    def __init__(self, function, args:list, done_trigger=None, non_actual_trigger=None):
        ...
        self.__non_actual_trigger = non_actual_trigger
      
      
    def actual(self):
        return self.__non_actual_trigger == None or not self.__non_actual_trigger.triggered()
      
     
    def act(self):
        if not self.actual():
            return

        return self.__function(*self.__args)
      
      
    def __str__(self): # метод был добавлен при визуализации очереди
        if not self.actual():
            return "NOT ACTUAL"
   	...

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

Для этого каждый раз в ActionProcessor, перед тем как брать очередное действие, будем проходиться по всему списку (а ля очереди) и удалять лишние действия. Обозначим для такой "чистки" отдельный метод:

...
    def clear_non_actual(self):
        i = 0
        while i < len(self.__actions): # условие динамически меняется
            action = self.__actions[i]

            if not action.actual():
                self.__actions.pop(i)
            else:
                i += 1

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

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

Дальше просто применяем этот метод перед обработкой очередного действия:

...
    def act_first(self):
        self.clear_non_actual()
      
        # не забываем проверить, остались ли действия после очистки
        if not self.has_actions_in_queue(): 
            return
      
       ...

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

Открыть

Во-первых, я написал сам класс страшной красной области:

# redKiller.py
import pygame

class RedKiller(pygame.sprite.Sprite):

    group = pygame.sprite.Group()

    def __init__(self, pos):
        pygame.sprite.Sprite.__init__(self)

        RedKiller.group.add(self)

        self.image = pygame.Surface([128, 128])
        self.image.fill((255, 0, 0, 255))

        self.rect = self.image.get_rect()

        self.rect.move_ip(*pos)

Затем добавил в Actor триггер удаления, чтобы передавать его в действия в качестве триггера неактуальности:

    ...
    def __init__(self, position):
				...
        self.__deleted_trigger = Trigger()
        self.__deleted_trigger.refresh() # изначально триггер поднят

После расширил метод kill() и добавил __deleted_trigger в действие движения:

    ...
    def kill(self):
      	# Этот метод от pygame.sprite.Sprite и он просто убирает объект из всех групп
        super().kill()

        self.__deleted_trigger.trigger()
    ...
    
    def get_init_move_action(self, to):
        a1 = Action(self.init_moving, [to], self.__move_completed_trigger, self.__deleted_trigger)

        return a1
    ...

И написал класс, реализующий логику перемещения из начала раздела:

# actor.py
...
from actionList import *
from redKiller import *
...

class SkittishGreen(Actor):

    def __init__(self, pos):
        Actor.__init__(self, pos)


    def check_collision(self):
        for i in RedKiller.group.sprites():
            if i.rect.colliderect(self.rect):
                self.kill()
                return


    def get_move_action(self, to):
        return Action(self.get_actions, [to])


    def get_actions(self, to):
        a1 = self.get_init_move_action(to)
        a2 = Action(self.check_collision, [])
        a3 = self.get_init_move_action(self.rect.center)

        return ActionList(a1, a2, a3)

А под конец чутка поменял запуск игры и главный цикл:

# main.py
...
from redKiller import *

...

player = SkittishGreen((137, 541))

RedKiller((200, 200))

...

        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                a = player.get_move_action(event.pos) # поменялось тут

                action_processor.add_action(a)
            
        ...
        
    RedKiller.group.update()
    RedKiller.group.draw(screen)

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

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

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

Полный код проекта.

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

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

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

Структура объектов

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

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

  • Part - это части, которые содержат в себе данные и из которых состоит игровой объект;

  • GameObject - собственно сам объект, ака контейнер частей;

  • ObjectsHandler - класс контроля объектов. Реализует интерфейсы создания, удаления и фильтрации игровых объектов.

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

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

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

extends Reference

class_name GameObject

var _parts:Dictionary = {}

var name:String = "" # для присвоения имени в ObjectsHandler

# триггер удаления, в основном для создания действий с триггером неактуальности
var delete_trigger:Trigger = Trigger.new()


func _init():
	delete_trigger.refresh()


func add_part(part_type, part_instance:Reference):
	_parts[part_type] = part_instance


func has_part(part_type):
	return _parts.has(part_type)


func remove_part(part_type):
	if has_part(part_type):
		_parts.erase(part_type)


func get_part(part_type):
	return _parts.get(part_type)


func match_filter(filter:Array):
  # можно еще добавить правило отрицания
  # например, "!" перед строковой константой
  # это будет значить, что мы не хотим объект с этой частью
	for part in filter:
			if not has_part(part):
				return false
	
	return true

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

extends Reference

class_name ObjectsHandler

var _objects:Dictionary = {}


# "регистрируем" объект с присваиванием имени. Прям как получение паспорта
func register_object(with_name:String, instance:GameObject):
	_objects[with_name] = instance
	instance.name = with_name


func get_object_by_name(object_name:String):
	return _objects.get(object_name)


func delete_object(object:GameObject):
	object.delete_trigger.trigger()
	
	_objects.erase(object.name)
	# метод call_deferred откладывает вызов функции до следующего кадра
	object.call_deferred("free")


func get_filtered_objects(filter:Array):
	var res:Array = []
	# простейший полный перебор
	for i in _objects.values():
		if i.match_filter(filter):
			res.append(i)
	
	return res

И тут вроде бы ничего сложного. Теперь самое интересное.

Способности позволяют действовать

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

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

Таким образом, уже наклевывается класс, который должен делать вот что:

  • Предоставлять интерфейс применения способности:

    способность_ехать.применить(машина, на Берлин)

  • Предоставлять интерфейс обновления способности:

    способность_ехать.заправить(машина)

  • Иметь набор частей (фильтр), необходимых для определения объекта как способного:

    [колеса, двигатель, топливная система]

А в купе с понятием действия, получится:

способность_ехать.дай_действие_применить(машина, на Берлин)

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

func применить(машина, куда):
	return ActionList(Action(ехать, [машина, 200 км]), 
                    Action(повернуть, [машина, направо]), ...)

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

Давайте способности назовем Ability и напишем примерный класс:

extends Reference

class_name Ability


# функция для внешнего использования
func get_apply_ability_action(go:GameObject, to:Vector2) -> Action:
	return Action.new("_apply_acition", [go, to], self, null, go.delete_trigger)

# "промежуточный" слой для валидации
func _apply_action(go:GameObject, to:Vector2) -> ActionList:
	if not _validate(go):
		return ActionList.new()
	
	return _get_apply_ability_action_list(go, to)

# вся логика реализуется тут. В том числе проверка на возможность применения способности
func _get_apply_ability_action_list(go:GameObject, to:Vector2) -> ActionList:
	return ActionList.new()




# аналогично и для этих трех
func get_update_ability_action(go:GameObject):
	return Action.new("_update_ability", [go], self, null, go.delete_trigger)


func _update_ability(go:GameObject):
	if not _validate(go):
		return ActionList.new()
	
	return _get_update_ability_action_list(go)


func _get_update_ability_action_list(go:GameObject) -> ActionList:
	return ActionList.new()



func get_filter(): # "виртуальный" метод
	return []


func _validate(go:GameObject):
	return go.match_filter(get_filter())

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

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

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

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

И теперь, кстати, появляется отдельный подкласс Parts, которые могут ответить на вопрос, может ли объект действовать. Их назовем AbilityPart:

extends Reference

class_name AbilityPart


func can_act() -> bool: # метод для внешнего использования
	return _can_act_condition()

# "виртуальный" метод. тут содержится код проверки
func _can_act_condition() -> bool:
	return true

Ничего сложного, правда? Вся прелесть в том, что объекты полностью обособлены от того, что происходит на сцене. Им все равно, что делает пользователь, как меняются их части и из-за чего. Но еще было бы удобно знать, какие способности объект имеет.

Для этого можно сделать специальную часть, например, AblePart. У нее будет единственная переменная abilities, не что иное как список с именами способностей:

extends Reference

class_name AblePart

var abilities:Array = []

func _init(abils:Array):
	abilities = abils.duplicate()

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

Массовый пинок

Часто в играх бывает необходимо обрабатывать сразу группу объектов, удовлетворяющих какому-то условию. Так, собственно, работает ECS. Это, например, будет полезно для обновления способности всех объектов группы А после очередного хода.

Или, если говорить о ботах, можно сразу добавить в очередь пачку действий по типу:

искуственный_интеллект_ботов.дай_действие(бот)

Давайте, как со способностями, сразу определим, что такой обработчик должен делать:

  • Предоставлять интерфейс обработки группы объектов.

  • Иметь фильтр нужных объектов.

  • Предоставлять интерфейс обработки одного объекта (например какой-то объект собирает бонус "восстановить все способности". Тут то и пригодится точечная обработка).

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

extends Reference

class_name ObjectProcessor

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


# внешний метод
func get_process_objects_action(objects_handler:ObjectsHandler, params:Array=[], additional_filter:Array=[]):
	var full_filter:Array = get_filter() + additional_filter
	
	return Action.new("_get_process_objects_action_list", [objects_handler, params, full_filter], self)

# отбор и получение действий обработки
func _get_process_objects_action_list(objects_handler:ObjectsHandler, params:Array, filter:Array):
	var objects:Array = objects_handler.get_filtered_objects(filter)
	
	var res:ActionList = ActionList.new()
	
	for go in objects:
		res.append(
			Action.new("_get_process_object_action_list", [go, params], self, null, go.delete_trigger)
		)
	
	return res



# внешний метод
func get_process_solo_object_action(go:GameObject, params:Array=[]):
	return Action.new("_process_object", [go], self, null, go.delete_trigger)

# валидация только при обработке единичного объекта
func _process_object(go:GameObject, params:Array) -> ActionList:
	if not _validate(go):
		return ActionList.new()
	
	return _get_process_object_action_list(go, params)



# все методы в конце концов сходятся сюда
# именно этот метод реализует нужную программисту логику
func _get_process_object_action_list(go:GameObject, params:Array) -> ActionList:
	return ActionList.new()


func get_filter(): # "виртуальный" метод
	return []


func _validate(go:GameObject):
	return go.match_filter(get_filter())

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

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

Визуальные объекты

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

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

Также, разделяя визуал и геймплей, мы получаем полную независимость игры от графики. То есть, если вы написали 2d игру, ничто не помешает перейти на 3d, почти (или совсем) не трогая игровые механики и баланс.

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

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

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

extends Reference

class_name Behaviour

var visual:Node

var animation_completed_trigger:Trigger = Trigger.new()

func _init(ivisual:Node2D):
	visual = ivisual


func start_animation():
	animation_completed_trigger.refresh()


func end_animation():
	animation_completed_trigger.trigger()

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

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

extends Node2D # обратите внимание, что визуальный объект - это объект сцены

class_name VisualObject


var _behs:Dictionary = {}


func _initialise(go:GameObject): # инициализируем для игрового объекта
	pass # "виртуальный" метод


func add_behaviour(beh_type, beh_instance:Behaviour):
	_behs[beh_type] = beh_instance


func has_behaviour(beh_type):
	return _behs.has(beh_type)


func get_behaviour(beh_type):
	return _behs.get(beh_type)


func get_move_action(to:Vector2): # действие перемещения
  # в целом, никто не мешает передвать позицию в метод инициализации (не путать с конструктором)
	return Action.new("set", ["global_position", to], self)

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

  • Давать действие начала анимации.

  • Иметь название поведения, которое он обрабатывает.

Назовем такой обработчик BehaviourProcessor и вместе с уже ставшей типичной валидацией получаем:

extends Node # обработчик - тоже объект сцены
# В основном для того, чтобы иметь доступ к другим нодам и методам, по типу _process

class_name BehaviourProcessor

# метод для внешнего использование
func get_process_object_action(vo:VisualObject, args=[]):
	return Action.new("_process_object", [vo, args], self)


func _process_object(vo:VisualObject, args:Array):
	if not vo.has_behaviour(get_behaviour_name()):
		return ActionList.new()
	
	var beh = vo.get_behaviour(get_behaviour_name())
	
	return _process_behaviour(beh, args)

# метод, где содержится вся логика анимации
func _process_behaviour(beh:Behaviour, args:Array) -> ActionList:
	return ActionList.new()


func get_behaviour_name() -> String:
	return "" # "виртуальный" метод

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

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

extends Reference

class_name VisualPart

var visual_instance:VisualObject
var visual_name:String

func _init(name:String):
  visual_name = name

func set_instance(instance:VisualObject):
	visual_instance = instance

Ничего более.

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

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

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

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

Заключение

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

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

  • Расширяемость. Отвечает за то, насколько трудно в выстроенную систему добавлять функционал.

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

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

  • Тестируемость. Отвечает за то, насколько в программе легко отслеживать баги и проводить ниточки до их источников.

  • Гибкость. Отвечает за то, насколько трудно менять уже созданный функционал. Менять что-то тут непросто.

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

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

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

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

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

Комментарии (3)


  1. BellaLugoshi
    28.07.2022 11:16

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

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

    "Большинство игровых движков заточены под real-time игры" - как там в кино со шварцем - какие ваши доказательства?

    Если я на Unity напишу платформер, где просто персонаж собирает звезды, то какая разница по шагам будет происходить игра или нет? В пошаговом режиме у вас просто добавится стоп-фактор для персонажа, но чем это отличается например от супер-оружия платформера которое например останавливает время или замораживает объекты?


    1. Areso
      28.07.2022 11:30
      +2

      В пошаговом режиме у вас просто добавится стоп-фактор для персонажа, но
      чем это отличается например от супер-оружия платформера которое например
      останавливает время или замораживает объекты?

      Смотрите, я делаю игры. Приведу простой пример.

      Игра 1, в которой мы управляем объектом стрелочками или WASD. Мы вешаем ивентхэндлер на клавиатуру, проверяем код клавиши, по клавише - перемещаем объект, в зависимости от нажатой клавиши. Да, у нас есть гравиатация, боксы для коллайдеров (столкновений), и т.п., но в целом - это так. По мере движения движок сразу рисует анимацию.

      Игра 2, в которой мы управляем объектом пошагово. Мы говорим, куда мы хотим пойти, после этого программа проверяет - а достижимо ли это место в целом или нет? Строит условную wavemap и делает чек. Если да, то показывает - да, чувак, сюда можно ходить (в продвинутом случае - сразу записывает и потом рисует маршрут). Когда делается "ход", то этот гипотеческий маршрут превращается в реальный, объект перемещается заменой значений переменных, а вот рендеру отдается определенная траектория перемещений объекта, которую нужно правильно захэндлить и отрисовать. И тут кроется основная неожиданность - не все движки готовы хэндлить расхождение между состоянием объекта и его анимацией\рендером.

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


    1. Goerging Автор
      28.07.2022 16:11
      +1

      Пошаговая игра ничем не отличается от любой другой.

      Это как это? Она не отличается только рендером, а внутри принципиально разные подходы. В пошаговых "тик" происходит не каждый кадр рендера, получается некий рассинхрон, поэтому невозможно обновлять объекты, как все привыкли. Отсюда и пост. Сравните "worms 3" и "Into the breach". Они и правда не отличаются принципиально ничем?

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

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

      Причём тут питон и гадот?

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

      я так и не увидел информации которая бы соответствовала заголовку "Архитектура пошаговых игр"

      А что по вашему соответствует этому заголовку?