Осваиваем ECS evolved в Defold | Часть 2 | Запросы и Системы

Эту тему может дополнить видео:
ECS evolved in Defold | Part 2 | Архетипы, Чанки, Запросы, Системы
[YouTube]
[VK Видео]

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


В этом уроке мы создадим простую систему движения, а затем применим эту систему к выбранной сущности:

ECS_MOVING

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

Системы обрабатывают данные из компонентов массово, без привязки к объектно-ориентированным методам, что позволяет работать с ними чисто на уровне памяти и запросов, независимо от визуализации. Сущности и компоненты существуют в “невидимой” архитектуре движка, например, в Defold можно хранить их в таблицах Lua и обновлять системами без немедленного рендера.


В вашей игре может быть множество игровых объектов = сущностей.
Допустим, у вас имеется сущность — Шар.
У этой сущности есть компоненты:

  • позиция
  • скорость

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

Система не знает про конкретный Шар
↓
Фильтр находит всех подходящих Шаров
↓
Система применяет код к релевантным данным
[мир всех сущностей]     →     [Фильтр]     →      [Чанк: только Шары]     →    [Система: обработать всех]
         1000                      ↓                        5 Шаров                       движение
       сущностей            position+velocity

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


Что такое архетип?

Архетип — это уникальное сочетание типов компонентов (наборы фрагментов). Ключ для чанков.


Что такое чанк?

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



Аналогия

Вы находитесь в библиотеке и в этой библиотеке множество шкафов с книгами.
Чанком являются шкафы с книгами.
Этикектой к шкафу являются архетипы.
Книги — это сущности.

Например:

Этикетка к шкафу #1 "Игровая разработка. Defold" (архетип — набор фрагментов).
Шкаф(чанк) содержит книги(сущности): "Игровая разработка. Defold(фрагменты)".

Этикетка к шкафу #2 "Игровая разработка. Figma. Photoshop" (архетип — набор фрагментов).
Шкаф(чанк) содержит книги(сущности): "Игровая разработка. Figma. Photoshop(фрагменты)".

Этикетка к шкафу #3 "Программирование. Java" (архетип — набор фрагментов).
Шкаф(чанк) содержит книги(сущности): "Программирование. Java(фрагменты)".

В коде это выглядело бы вот так:

local game_dev, defold = evolved.id(2)
local chunk1 = evolved.chunk(game_dev, defold)  ← Шкаф #1
local book1 = evolved.id()                      ← Книга #1
evolved.set(book1, game_dev, true)              ← Положили в шкаф
evolved.set(book1, defold, "tutorial")          ← + фрагмент Defold

Вернёмся к шару

Когда вы вызываете :spawn() — сущность попадает в архетип (группу с position+velocity) и становится частью мира и чанка по этому архетипу.

local ball = evolved.builder()
    :set(position, vmath.vector3(0,0,0))
    :set(velocity, vmath.vector3(100,0,0))
    :spawn()  -- ← добавляется в глобальный мир автоматически

Системы видят мир через фильтры — запросы

evolved.process(system) → сканирует ВЕСЬ мир
         ↓
  фильтр query → только нужные сущности
         ↓
    чанк данных → массив для обработки

Мир существует один на весь скрипт/игру. Все :spawn() добавляют в него сущности, все системы через него же их фильтруют.


Что такое запросы?

Запросы (queries) — это фильтры для поиска сущностей по компонентам.

Запрос = "дай мне всех сущностей с этими компонентами"
local movement_query = evolved.builder()
    :include(self.position, self.velocity)  -- ← фильтр
    :build()

Анатомия запроса:

evolved.INCLUDES = {position, velocity}  -- ДОЛЖНЫ быть
evolved.EXCLUDES = {ui_tag}              -- НЕ должны быть (опционально)
:build() -- создаёт запрос

Зачем нужны запросы?

БЕЗ запроса: система сканирует ВСЕ 1000 сущностей → 990 проверок впустую
С запросом: система получает ГОТОВЫЙ список из 10 Шаров

Что такое системы?

Системы — это просто сущности с особыми фрагментами QUERY и EXECUTE, которые выполняют логику обработки по запросам. ​

Давайте создадим систему для движения нашей сущности шара по диагонали вправо с фрагментами: {позиция}, {скорость} и {url игрового объекта}.

Код в init():

    -- Создаём компоненты
	self.position = evolved.id()
	self.velocity = evolved.id()
	self.go_url = evolved.id()

	-- Создаём сущность игрока
	self.player_e = evolved.builder()
	:set(self.position, vmath.vector3(0, 0, 0))
	:set(self.velocity, vmath.vector3(150, 150, 0))
	:set(self.go_url, factory.create("#factory", vmath.vector3(0, 0, 0)))
	:spawn()

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

Моя структура коллекции выглядит так:
image

Хорошо, у нас теперь создана сущность с необходимыми компонентами.

Мы вспомним то, что система должна обрабатывать сущности с фрагментами: “позиция”, “скорость”, “url игрового объекта”.

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

Давайте напишем код, также в init():

-- Создаём запрос для сущностей с position, velocity, go_url
local movement_query = evolved.builder()
:include(self.position, self.velocity, self.go_url)
:build()

Ещё раз, запрос — это фильтр, который “пропускает” в систему именно те сущности, которые указаны в запросе.

Теперь создадим систему (также в init()):

	-- Создаём систему движения
	self.movement_system = evolved.builder()
	:query(movement_query)
	:execute(function (chunk, entity_list, entity_count)
		-- Получаем массивы компонентов
		local positions = chunk:components(self.position)
		local velocities = chunk:components(self.velocity)
		local go_urls = chunk:components(self.go_url)

		-- Обрабатываем все сущности в чанке
		for i = 1, entity_count do
			-- Обновляем позицию (используя delta-time из замыкания)
			local dt = self.dt or 0
			local new_position = vmath.vector3(
				positions[i].x + velocities[i].x * dt,
				positions[i].y + velocities[i].x * dt,
				0
			)
			-- Обновляем позицию в массиве компонентов
			positions[i] = new_position
			-- Синхронизируем с Game Object
			go.set_position(positions[i], go_urls[i])
		end
	end)
	:build()

Возвращаемся к аналогии с библиотекой.
В каждой библиотеке есть библиотекарь, который выдаёт тебе книги.
Ты приходишь в библиотеку и говоришь ему, мне нужны книги на тему: “Defold. Игровая разработка” — твои слова. Это некий фильтр для него.
Библиотекарь идёт за книгами и пользуется этим фильтром, сравнивая его с этикетками (архетипами).
Он подходит к нужному шкафу и смотрит на этикетки, находит несколько книг с соответствующими этикеткой(архетипу). Приносит тебе несколько книг.
А потом ты уже с этими книгами можешь делать что хочешь.

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

Может быть полезно:
defolder/forum/lessons/defold_without_pain/delta_time at main · dprogrb/defolder · GitHub
defolder/forum/lessons/defold_without_pain/vectors at main · dprogrb/defolder · GitHub

В update() мы получаем значение dt и запускаем нашу систему, чтобы она работала каждый кадр.

function update(self, dt)
	-- Сохраняем dt для использования в системе
	self.dt = dt
	-- Выполняем систему через evolved.process
	evolved.process(self.movement_system)
end

Полный код:

local evolved = require("modules.evolved")

function init(self)
	-- Создаём компоненты
	self.position = evolved.id()
	self.velocity = evolved.id()
	self.go_url = evolved.id()

	-- Создаём сущность игрока
	self.player_e = evolved.builder()
	:set(self.position, vmath.vector3(0, 0, 0))
	:set(self.velocity, vmath.vector3(150, 150, 0))
	:set(self.go_url, factory.create("#factory", vmath.vector3(0, 0, 0)))
	:spawn()

	-- Создаём запрос для сущностей с position, velocity, go_url
	local movement_query = evolved.builder()
	:include(self.position, self.velocity, self.go_url)
	:build()

	-- Создаём систему движения
	self.movement_system = evolved.builder()
	:query(movement_query)
	:execute(function (chunk, entity_list, entity_count)
		-- Получаем массивы компонентов
		local positions = chunk:components(self.position)
		local velocities = chunk:components(self.velocity)
		local go_urls = chunk:components(self.go_url)

		-- Обрабатываем все сущности в чанке
		for i = 1, entity_count do
			-- Обновляем позицию (используя delta-time из замыкания)
			local dt = self.dt or 0
			local new_position = vmath.vector3(
				positions[i].x + velocities[i].x * dt,
				positions[i].y + velocities[i].x * dt,
				0
			)
			-- Обновляем позицию в массиве компонентов
			positions[i] = new_position
			-- Синхронизируем с Game Object
			go.set_position(positions[i], go_urls[i])
		end
	end)
	:build()
	
end

function update(self, dt)
	-- Сохраняем dt для использования в системе
	self.dt = dt
	-- Выполняем систему через evolved.process
	evolved.process(self.movement_system)
end

На этом всё.
Если остались вопросы, задавайте или в тг или под темой.
Всем спасибо за внимание!