Как стать автором
Обновить

Геймдев на Lisp. Часть 1: ECS и металингвистическая абстракция

Уровень сложностиСредний
Время на прочтение30 мин
Количество просмотров7.5K

В данной серии практических руководств мы подробно рассмотрим создание несложных 2D-игр на Common Lisp. Результатом первой части станет настроенная среда разработки и простая симуляция, отображающая двумерную сцену с большим количеством физических объектов. Предполагается, что читатель владеет некоторым языком программирования высокого уровня, в общих чертах представляет, как на экране компьютера отображается графика, и заинтересован в расширении своего кругозора.

Common Lisp — язык программирования с богатой историей, предоставляющий эффективные инструменты для разработки комплексных интерактивных приложений, каковыми являются видеоигры. Данная серия руководств ставит перед собой задачу наглядно продемонстрировать ряд возможностей CL, отлично вписывающихся в контекст разработки игровых приложений. Общий обзор таковых возможностей и особенностей Common Lisp приводится в статье Юкари Хафнер "Использование высокодинамичного языка для разработки".

Многие возможности, впервые появившиеся в Lisp, такие, как условный оператор if/then/else, функции как объекты первого класса, сборка мусора и другие давно перекочевали в мейнстримные языки программирования, однако есть одна уникальная возможность, которую мы рассмотрим сегодня, и это — металингвистическая абстракция.

Металингвистическая абстракция

Чтобы вникнуть в данную концепцию, обратимся к известному фундаментальному учебнику "Структура и интерпретация компьютерных программ":

...однако по мере того, как мы сталкиваемся со все более сложными задачами, мы обнаруживаем, что Лиспа, да и любого заранее заданного языка программирования, недостаточно для наших нужд. Чтобы эффективнее выражать свои мысли, постоянно приходится обращаться к новым языкам. Построение новых языков является мощной стратегией управления сложностью в инженерном проектировании; часто оказывается, что можно расширить свои возможности работы над сложной задачей, приняв новый язык, позволяющий нам описывать (а следовательно, и обдумывать) задачу новым способом, используя элементы, методы их сочетания и механизмы абстракции, специально подогнанные под стоящие перед нами проблемы.
Метаязыковая абстракция (metalinguistic abstraction), то есть построение новых языков, играет важную роль во всех отраслях инженерного проектирования.
...c этой мыслью приходит и новое представление о себе самих: мы начинаем видеть в себе разработчиков языков, а не просто пользователей языков, придуманных другими.

Итак, важный механизм, предоставляемый практически любым диалектом Лиспа, включая, конечно, один из самых мощных из них Common Lisp, — это возможность создания собственных языковых конструкций внутри уже данного нам языка. Данная концепция также известна под названием DSL (Domain Specific Languages), однако лишь в диалектах Лиспа она невероятно тесно интегрирована в их суть. В большинстве из них механизм металингвистической абстракции выстраивается вокруг т.н. макросов, специальных функций, определяемых программистом, которые вызываются на этапе компиляции и возвращают небольшие фрагменты кода программы для подстановки компилятором в место, где они встречаются; особенность Лиспов состоит в том, что код на них по сути является обычным вложенным списком, что позволяет легко и эффективно генерировать и обрабатывать фрагменты кода программы.

Есть масса различных способов креативно использовать и эксплуатировать эту возможность. Я хотел бы рассказать о созданной мной библиотеке макросов cl-fast-ecs, которая предоставляет мини-язык для описания игровых объектов и процессов их обработки с использованием паттерна Entity-Component-System, часто используемого в разработке игр.

Entity Component System

ECS — довольно нехитрый паттерн организации хранения и обработки данных в игровых приложениях, который позволяет достигнуть сразу две важные концептуальные цели:

  1. гибкость в определении и изменении структуры игровых объектов,

  2. производительность за счёт эффективной утилизации кэшей центрального процессора.

Гибкость, интерактивность и возможность переопределять поведение программы "на ходу" — краеугольные камни большинства Lisp-подобных языков. Мы вернёмся к этому вопросу позднее, а пока остановимся чуть подробнее на второй цели, которая часто преподносится как основное преимущество паттерна ECS. Начнём издалека.

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

источник: Aras Pranckevičius, Entity Component Systems & Data Oriented Design
источник: Aras Pranckevičius, Entity Component Systems & Data Oriented Design

Кривая, помеченная "Processor", отображает количество запросов к памяти, которое CPU может сделать за единицу времени; кривая "Memory" отображает количество запросов, которое RAM способна обработать за единицу времени (оба значения отнормированы к средним значениям за 1980 г.). Даже поверхностно проанализировав график, можно прийти к неутешительному выводу — большую часть времени процессор простаивает в ожидании данных от памяти, и со временем разрыв между производительностью процессора и памяти становится всё больше.

Уже в начале девяностых, с выходом Intel 486, распространённым решением данной проблемы в железе широкого потребления стал кэш, находящийся на одном кристалле с процессором — небольшая, но крайне быстрая память, в которой хранятся данные, запрошенные процессором раннее, что позволяет сократить длительность последующих запросов к этим же данным, получая их из кэша вместо более медленной главной памяти. Ближе к концу девяностых кэш стал ещё и разделяться на несколько уровней (L1, L2 и т.д.), каждый последующий уровень имеет бо́льший объём, но также и бо́льшую латентность, впрочем, сильно уступающую латентности доступа к основной RAM. Типичные тайминги взаимодействия десктопного железа с памятью на момент 2020 г. выглядят следующим образом:

  • регистр процессора: <1 нс

  • L1 кэш: 1 нс

  • L2 кэш: 3 нс

  • L3 кэш: 13 нс

  • RAM: 100 нс

(источник: dzone.com)

Кэш CPU здорово помогает в оптимизации последовательного доступа к ячейкам памяти: даже когда процессор обращается к RAM за одним-единственным байтом, ему в ответ приходит и "оседает" в кэше целая кэш-линия, на современных x86 имеющая длину 64 байта (512 бит). Таким образом, если мы в коде последовательно обрабатываем элементы некоторого массива, хранящего, скажем, числа с плавающей запятой одинарной точности длиной в 32 бита (более известные как float), при обращении к первому элементу мы получим от RAM не только запрошенный, но также (512 - 32) / 32 = 15 последующих элементов, и на следующих 15 итерациях цикла получение элемента будет занимать 1 нс вместо 100 нс. Получается, благодаря кэшу наш цикл, вне зависимости от длины массива, работает в 16 * 100 / (100 + 15 * 1) ≈ 14 раз быстрее! На этом примере видно, как важно с точки зрения производительности обрабатывать данные так, чтобы они оставались "горячими" в кэше.

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

  • entity (сущность) — составной игровой объект;

  • component (компонент) — данные, описывающие некоторую логическую грань объекта;

  • system (система) — код, обрабатывающий объекты определённой структуры.

Давайте сначала разберёмся с сущностями и компонентами на конкретном примере:

источник: Mick West, Cowboy Programming. Evolve Your Hierarchy
источник: Mick West, Cowboy Programming. Evolve Your Hierarchy

По горизонтали у нас отображены разноцветными прямоугольниками компоненты: Position, Movement, Render и так далее. Обратите внимание, что каждый из этих компонентов может содержать несколько полей с данными, например, Position и Movement почти наверняка будут содержать поля x и y. Далее, по вертикали в скобках подписаны сущности — Alien, Player и т.д. Каждая сущность имеет определённый набор компонентов. Что более важно, к любой сущности мы можем "на ходу", в рантайме, добавить или удалить некоторые компоненты, чем мы поменяем её структуру и, как следствие, поведение и статус в игровом мире, и всё это без перекомпиляции кода игры! Этим достигается первая концептуальная цель ECS, указанная выше — гибкость структуры игровых объектов.

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

источник: Maxim Zaks, Entity Component System - A Different Approach to Game / Application Development
источник: Maxim Zaks, Entity Component System - A Different Approach to Game / Application Development

С концептуальной точки зрения сущности и компоненты образуют строки и столбцы таблицы, в ячейках которой хранятся данные компонента, либо значения отсутствуют. Такое представление игровых данных позволяет провернуть ряд трюков, связанных с их расположением в памяти. В свою очередь, эти трюки позволяют наиболее плотно утилизировать кэш CPU при обработке данных, подобно вышеприведённому примеру с циклом по float'ам, и таким образом выжать максимум производительности из системы "процессор-память".

Собственно, обработка игровых данных при использовании шаблона ECS возлагается на т.н. системы — циклы, которые проходят по всем сущностям, обладающими определёнными компонентами, и выполняющими операции над этими сущностями одинаковым образом. Например, система, обсчитывающая передвижение объектов, будет обрабатывать сущности с компонентами Position и Movement, система, отрисовывающая объекты на экране, будет заинтересована в сущностях с компонентами Position и Render и так далее. Визуально системы можно проиллюстрировать следующим примером:

источник: Антон Григорьев, Как и почему мы написали свой ECS
источник: Антон Григорьев, Как и почему мы написали свой ECS

Так, в этом примере система MoveSystem по сути является циклом, последовательно проходящим по всем сущностям, имеющим компоненты Transform и Movement, и вычисляющим новые значения позиции для каждой сущности в соответствии с её скоростью. Большинство реализаций паттерна ECS устроено таким образом, что данные полей компонентов (например, полей x и y компонента Movement) так или иначе хранятся в плоских одномерных массивах, а сущности являются банальными целочисленными индексами в этих массивах. Системы, в свою очередь, попросту итерируют по массивам с данными компонентов, чем достигается пространственная локальность кэша CPU, в точности как в примере с циклом по float'ам выше.

На этом краткий обзор архитектурного паттерна Entity-Component-System завершён. Для более глубокого погружения в тему рекомендуются следующие материалы:

Теперь мы готовы использовать библиотеку cl-fast-ecs для создания минимального игрового проекта с ECS-архитектурой. Но перед этим нам понадобится настроенное рабочее окружение для разработки на Common Lisp.

Рабочее окружение

Первым делом для построения рабочего окружения нам потребуется компилятор, и общепризнанным лидером среди опенсорсных компиляторов Common Lisp является Steel Bank Common Lisp, он же SBCL. Его можно установить с помощью вашего пакетного менеджера командой в терминале вида

sudo apt-get install sbcl  # для Ubuntu/Debian и их производных

sudo dnf install sbcl  # для Fedora

brew install sbcl  # для MacOS с Homebrew

...либо скачать готовый инсталлятор с официального сайта (обратите внимание на архитектуру CPU, для которой вы качаете файл, в большинстве случаев вам нужна AMD64).

После установки компилятора понадобится установить Quicklisp, являющийся де-факто стандартным пакетным менеджером Common Lisp. Для этого скачайте файл установки по адресу https://beta.quicklisp.org/quicklisp.lisp, а затем загрузите его, запустив SBCL в каталоге с файлом следующей командой:

sbcl --load quicklisp.lisp

После загрузки этого файла SBCL перейдёт в режим т.н. REPL (Read-Eval-Print Loop), в котором он выведет приглашение ввода, состоящее из одной звёздочки, и будет ожидать от нас код, который необходимо выполнить, а выполнив его и выведя результат, снова вернётся к ожиданию ввода. Отдадим SBCL на выполнение три фрагмента кода: для установки Quicklisp, подключения дополнительного репозитория LuckyLambda с самыми свежими версиями пакетов для геймдева, и для добавления поддержки Quicklisp в конфиг SBCL:

(quicklisp-quickstart:install)

(ql-dist:install-dist "http://dist.luckylambda.technology/releases/lucky-lambda.txt" :prompt nil)

(ql:add-to-init-file)

Нажав Enter по просьбе последнего вызова, можно выйти из SBCL, нажав Ctrl-D или набрав в приглашении (exit)

Чтобы писать код на Common Lisp с комфортом, по-прежнему имея возможность взаимодействовать с REPL, остаётся выбрать IDE по вкусу:

  • VScode с установленным расширением Alive; см. руководство по его использованию в Common Lisp cookbook. Для его корректного взаимодействия с установленным нами SBCL будет необходимо предварительно доустановить ряд пакетов, выполнив следующий код в REPL SBCL:

    (ql:quickload '(:bordeaux-threads :cl-json :flexi-streams :usocket))
    
  • IntelliJ IDEA с установленным плагином SLT; см. руководство пользователя по нему.

  • Sublime Text с установленным плагином Slyblime (к сожалению, на данный момент плагин не работает под Windows).

  • Для Vim и Neovim существует плагин Vlime.

  • Однако непревзойдённым лидером в качестве IDE для Lisp-подобных языков является Emacs. Если вы уже бывалый пользователь Emacs, вам будет достаточно установить плагин Sly. А если вы не хотите заморачиваться с настройкой данной среды, можно воспользоваться готовой кроссплатформенной сборкой Portacle, заточенной под Common Lisp, и ознакомиться с введением в Emacs из Common Lisp cookbook. Для того, чтобы наш проект заработал в Portacle, в его REPL потребуется запустить следующий код:

    (ql-dist:install-dist "http://dist.luckylambda.technology/releases/lucky-lambda.txt" :prompt nil)
    (ql:quickload :deploy)
    

Кроме того, под такую экзотическую ОС, как Windows, для полноценной разработки не обойтись без инструмента в духе MSYS2.

Шаблон игрового проекта на Common Lisp

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

cookiecutter gh:lockie/cookiecutter-lisp-game

В ответ cookiecutter задаст нам ряд вопросов о создаваемом проекте, ответим на них следующим образом:

full_name (Your Name): Alyssa P. Hacker
email (your@e.mail): alyssa@domain.tld
project_name (The Game): ECS Tutorial 1
project_slug (ecs-tutorial-1):
project_short_description (A simple game.): cl-fast-ecs framework tutorial.
version (0.0.1):
Select backend
    1 - liballegro
    2 - raylib
    3 - SDL2
    Choose from [1/2/3] (1): 1

cookiecutter создаст для нас скелет проекта в каталоге ecs-tutorial-1. Нам понадобится добавить этот каталог в наш локальный репозиторий пакетов Quicklisp следующей командой:

ln -s $(pwd)/ecs-tutorial-1 $HOME/quicklisp/local-projects/  # для UNIX-подобных ОС

mklink /j %USERPROFILE%\quicklisp\local-projects\ecs-tutorial-1 ecs-tutorial-1  # для Windows
mklink /j %USERPROFILE%\portacle\projects\ecs-tutorial-1 ecs-tutorial-1  # для Windows при использовании Portacle

В качестве бэкэнда мы выбрали умолчальный вариант №1, liballegro, т.к. на данный момент это наиболее беспроблемный графический фреймворк для использования в Common Lisp. Нам также понадобится его установить, либо командой терминала

sudo apt-get install liballegro-acodec5-dev liballegro-audio5-dev \
    liballegro-dialog5-dev liballegro-image5-dev liballegro-physfs5-dev \
    liballegro-ttf5-dev liballegro-video5-dev  # для Ubuntu/Debian и их производных

sudo dnf install allegro5-addon-acodec-devel allegro5-addon-audio-devel \
    allegro5-addon-dialog-devel allegro5-addon-image-devel allegro5-addon-physfs-devel \
    allegro5-addon-ttf-devel allegro5-addon-video-devel  # для Fedora

brew install allegro  # для MacOS с Homebrew

pacman -S mingw-w64-x86_64-allegro  # для Windows с MSYS2

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

sudo apt-get install gcc pkg-config make  # для Ubuntu/Debian и их производных

sudo dnf install gcc pkg-config make redhat-rpm-config  # для Fedora

brew install pkg-config  # для MacOS с Homebrew

pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-pkg-config make  # для Windows с MSYS2

Под Windows с MSYS2 также необходимо выставить переменную окружения MSYS2_PATH_TYPE в значение inherit и добавить в начало переменной окружения PATH следующее: C:\msys64\usr\bin;C:\msys64\mingw64\bin;

Кроме того, нам понадобится библиотека libffi для взаимодействия Common Lisp с кодом на C; её можно установить командой вида

sudo apt-get install libffi-dev  # для Ubuntu/Debian и их производных

sudo dnf install libffi-devel  # для Fedora

brew install libffi  # для MacOS с Homebrew

pacman -S mingw-w64-x86_64-libffi  # для Windows с MSYS2

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

  1. запустить sbcl;

  2. выполнить в SBCL код вида (ql:quickload :ecs-tutorial-1) для загрузки пакета с проектом;

  3. дождавшись приглашения ввода в виде звёздочки после загрузки, вызвать точку входа в проект, функцию main, выполнив код вида (ecs-tutorial-1:main)

Если всё пройдёт без проблем, мы увидим пустое окно со счётчиком FPS:

Теперь мы можем перейти к добавлению к полученному скелету "мяса" компонентов и систем.

Добавляем компоненты и системы

Прежде всего, если вы никогда не имели дела с Common Lisp или другими языками Lisp-семейства, рекомендую обратиться к краткому руководству Изучите X за Y минут, где X=Common Lisp (достаточно будет изучить его первые шесть пунктов). Для более глубокого погружения замечательно подойдёт "Практическое использование Common Lisp".

Первым делом нам понадобится подключить библиотеку cl-fast-ecs к нашему проекту, для этого нужно открыть файл ecs-tutorial-1.asd в корневом каталоге проекта. Его расширение — не результат случайного аккорда на клавиатуре, а аббревиатура "Another System Definition": на данный момент asd — де-факто стандартный формат описания пакетов Common Lisp. В данном файле нам нужно добавить в список, являющийся значением именованного параметра :depends-on элемент #:cl-fast-ecs, чтобы он выглядел следующим образом:

  ;; ...
  :license "MIT"
  :depends-on (#:alexandria
               #:cl-fast-ecs
               #:cl-liballegro
               #:cl-liballegro-nuklear
               #:livesupport)
  :serial t
  ;; ...

После этого следует (пере)загрузить пакет с нашей будущей игрой в REPL уже известной нам командой (ql:quickload :ecs-tutorial-1). Теперь мы готовы занырнуть в исходный код.

Итак, откроем файл src/main.lisp. Не стоит пугаться кода внутри формы, начинающейся с символов cffi:defcallback %main — это стандартный бойлерплейт, аналог которого можно найти в любой программе, использующей liballegro, например, в коде демо под названием "skater" с официального сайта этой библиотеки. Этот бойлерплейт занимается инициализацией и финализацией liballegro и её аддонов, необходимых для функционирования игры, обработкой ошибок, отрисовкой уже виденного нами счётчика FPS, но его центральная часть — это главный игровой цикл, последовательно отрисовывающий кадры игры на экране. Подробнее о том, что такое главный игровой цикл, можно прочитать здесь. Мы не будем вмешиваться в код коллбэка %main, вместо этого будем расширять функции init и update, которые им вызываются для инициализации игровой логики и обновления внутреннего состояния игры на каждом кадре соответственно.

Начнём правку кода с инициализации фреймворка cl-fast-ecs. Если мы попытаемся без инициализации использовать его функции, например, прямо сейчас передадим в REPL код для создания новой сущности (ecs:make-entity) (попробуйте!), мы получим ошибку вида ECS storage is not initialized с возможностью вручную проинициализировать хранилище данных ECS, выбрав первый интерактивный рестарт INITIALIZE-STORAGE. Чтобы сделать это из кода, нужно вызвать функцию make-storage. Наиболее логичное место для этого в коде игры — функция init:

(defun init ()
  (ecs:make-storage))

Написав данный код, мы должны превратить его в часть нашей программы. Тут вступает в игру обсуждавшееся нами ранее важное свойство Lisp, редко встречающееся в других мейнстримных языках, а именно — интерактивность. Необязательно закрывать запущенный в данный момент процесс SBCL, достаточно поставить курсор на код функции и воспользоваться клавиатурной комбинацией вашей IDE, посылающей код в запущенный REPL — например, в Emacs это двойное нажатие Ctrl-C (или C-c C-c на его жаргоне); в других IDE соответствующий пункт контекстного меню будет называться "Inline eval", "Evaluate This S-expression", "Evaluate form at cursor" или аналогичным образом. Более того, при использовании библиотеки livesupport (которая включена в наш шаблон) мы можем переопределять фрагменты кода или целые функции не только в тот момент, когда Lisp ждёт от нас ввода, но и в произвольный момент работы программы, что открывает поистине безграничные возможности по модификации и отладке кода "на горячую". Известен пример, когда интерактивность Lisp была использована для приведения в чувство космического аппарата, находящегося за 150 миллионов миль от Земли.

источник: NASA/JPL
источник: NASA/JPL

Итак, теперь мы готовы определить компоненты, которые будет использовать наша игровая симуляция. Мы будем моделировать ньютоновскую физику большого количества небесных тел. Для этого нам непременно понадобятся компоненты для позиции и скорости объектов, устроенные сходным образом. Добавим перед функцией init на верхнем уровне следующий код, использующий макрос define-component из фреймворка cl-fast-ecs:

(ecs:define-component position
  "Determines the location of the object, in pixels."
  (x 0.0 :type single-float :documentation "X coordinate")
  (y 0.0 :type single-float :documentation "Y coordinate"))

(ecs:define-component speed
  "Determines the speed of the object, in pixels/second."
  (x 0.0 :type single-float :documentation "X coordinate")
  (y 0.0 :type single-float :documentation "Y coordinate"))

Каждый вызов данного макроса принимает на вход название компонента, необязательную строку с документацией и набор полей компонента, или слотов, как принято называть в CL поля структур, и для каждого слота, подобно слотам структур, мы указываем в скобках:

  • имя,

  • значение по умолчанию,

  • именованный параметр :type, определяющий тип поля,

  • необязательный именованный параметр :documentation, добавляющий к слоту строку с документацией.

Вызов define-component содержит минимум избыточной информации и предельно ясен, однако на деле для поддержки работы с компонентом макрос генерирует довольно внушительное количество кода; на него можно посмотреть, передав в REPL в стандартную функцию macroexpand заквотированный код вызова макроса:

(macroexpand
 '(ecs:define-component position
   "Determines the location of the object, in pixels."
   (x 0.0 :type single-float :documentation "X coordinate")
   (y 0.0 :type single-float :documentation "Y coordinate")))

Предупреждаю сразу, результат не для слабонервных 😅 Может показаться, что компилятор НА ВАС КРИЧИТ, потому что весь сгенерированный код набран заглавными буквами, но на самом деле автоматическая конвертация символов в верхний регистр — следствие исторических причин, которое можно отключить настройкой readtable-case; обычно этой возможностью не пользуются. На сгенерированный код также можно полюбоваться по этой ссылке.

Генерируемый макросом код включает в себя не только описание структуры, хранящей в себе данные компонента position для всех сущностей и автоматически добавляемой в общее хранилище данных объектов, но и ряд вспомогательных функций и макросов (да-да, макросы могут определять другие макросы 🤯):

  • для получения и установки значений слотов x и y,

  • для добавления и удаления компонента position у заданной сущности,

  • для копирования данных компонента position из одной сущности в другую,

  • для проверки существования компонента position у заданной сущности,

  • для удобного доступа к слотам по именам.

В рамках данного цикла туториалов мы рано или поздно попробуем их все.

Давайте добавим перед init ещё один компонент, который позволит нам отрисовывать на экране изображения, соответствующие нашим небесным телам:

(ecs:define-component image
  "Stores ALLEGRO_BITMAP structure pointer, size and scaling information."
  (bitmap (cffi:null-pointer) :type cffi:foreign-pointer)
  (width 0.0 :type single-float)
  (height 0.0 :type single-float)
  (scale 1.0 :type single-float))

Помимо C-шного указателя на структуру изображения ALLEGRO_BITMAP из liballegro, этот компонент также будет хранить информацию о размерах изображения и его масштабе.

Теперь давайте реализуем нашу первую систему, которая будет отображать на экране объекты. Добавим после определений компонентов следующий код:

(ecs:define-system draw-images
  (:components-ro (position image)
   :initially (al:hold-bitmap-drawing t)
   :finally (al:hold-bitmap-drawing nil))
  (let ((scaled-width (* image-scale image-width))
        (scaled-height (* image-scale image-height)))
    (al:draw-scaled-bitmap image-bitmap 0 0 image-width image-height
                           (- position-x (* 0.5 scaled-width))
                           (- position-y (* 0.5 scaled-height))
                           scaled-width scaled-height 0)))

Определение системы чуть сложнее, чем определение компонента, так как включает в себя непосредственный код обработки сущностей. Аргументами макроса define-system являются: название системы, набор поименованных опций в скобках, и далее формы, составляющие тело системы — код, выполняемый для каждой сущности, в которой заинтересована система. Эту заинтересованность мы передаём опцией :components-ro, где "ro" означает "read-only": мы будем обрабатывать все сущности, обладающие компонентами position и image, но при этом не будем их модифицировать. В теле системы мы для каждой такой сущности подсчитываем отмасштабированные размеры изображения и кладём их в переменные scaled-width и scaled-height с помощью особой формы let, а затем вызываем функцию al_draw_scaled_bitmap из liballegro для выведения изображения в нужной позиции в соответствии с заданным масштабом. Обратите внимание, что для доступа к слотам интересующих нас компонентов обрабатываемой сущности мы используем переменные вида компонент-слот, например, image-width или position-y — они создаются для нас макросом define-system автоматически. Кроме того, мы используем опции системы :initially и :finally, подобные аналогичным ключевым словам в стандартной конструкции для организации циклов loop: выражения из этих опций будут выполняться в самом начале и в самом конце работы системы соответственно. Мы вызываем в эти моменты функцию al_hold_bitmap_drawing для активации встроенного в liballegro sprite batching'а — с ним все нужные для отрисовки вызовы графических API произойдут лишь после того, как мы пройдёмся по всем объектам, что сэкономит дорогостоящие взаимодействия по шине между CPU и GPU.

Далее, чтобы увидеть результат наших трудов на экране, необходимы две вещи:

  • создать некоторое количество объектов со случайными позициями,

  • и каждый кадр вызывать нашу систему.

Начнём с первого пункта.

Сначала нам понадобятся изображения наших небесных тел. Скачаем их с данной страницы сайта OpenGameArt (по ссылке "File(s)"):

Распакуем каталог small из скачанного архива в наш каталог Resources, так, чтобы png-файлы были доступны нашему приложению по путям вида Resources/a10000.png. Затем, не мудрствуя лукаво, просто захардкодим нужные нам изображения в качестве константного списка перед функцией init:

(define-constant asteroid-images
    '("../Resources/a10000.png" "../Resources/a10001.png"
      "../Resources/a10002.png" "../Resources/a10003.png"
      "../Resources/a10004.png" "../Resources/a10005.png"
      "../Resources/a10006.png" "../Resources/a10007.png"
      "../Resources/a10008.png" "../Resources/a10009.png"
      "../Resources/a10010.png" "../Resources/a10011.png"
      "../Resources/a10012.png" "../Resources/a10013.png"
      "../Resources/a10014.png" "../Resources/a10015.png"
      "../Resources/b10000.png" "../Resources/b10001.png"
      "../Resources/b10002.png" "../Resources/b10003.png"
      "../Resources/b10004.png" "../Resources/b10005.png"
      "../Resources/b10006.png" "../Resources/b10007.png"
      "../Resources/b10008.png" "../Resources/b10009.png"
      "../Resources/b10010.png" "../Resources/b10011.png"
      "../Resources/b10012.png" "../Resources/b10013.png"
      "../Resources/b10014.png" "../Resources/b10015.png")
  :test #'equalp)
Пользователям MacOS

Примечание: в liballegro есть пока не исправленный баг, из-за которого она некорректно отображает PNG-изображения с 16-битным цветом под MacOS, поэтому под этой ОС необходимо также сконвертировать их в 8-битный формат командой вида

mogrify -depth 8 *.png

предварительно установив imagemagick из Homebrew. Спасибо Маркусу за отчёт об ошибке!

Затем в саму функцию init после вызова make-storage добавим следующий код:

  (let ((asteroid-bitmaps
          (map 'list
               #'(lambda (filename) (al:ensure-loaded #'al:load-bitmap filename))
               asteroid-images)))
    (dotimes (_ 1000)
      (ecs:make-object `((:position :x ,(float (random +window-width+))
                                    :y ,(float (random +window-height+)))
                         (:image
                          :bitmap ,(alexandria:random-elt asteroid-bitmaps)
                          :width 64.0 :height 64.0)))))

В нём мы загружаем все захардкоженные изображения в список asteroid-bitmaps с помощью функции al_load_bitmap и вспомогательной лисповской функции al:ensure-loaded, а затем в цикле на тысячу итераций пользуемся функцией ECS-фреймворка make-object, которая конструирует сущность с компонентами, определяемыми переданной ей спецификацией — списком вида

'((:компонент1 :слот1 "значение1" :слот2 "значение2")
  (:компонент2 :слот :значение)
  ;; ...
 )

Кроме того, мы используем отличительную фичу Common Lisp, стирающую тонкую грань между данными и кодом, и часто встречаемую при написании макросов, т.н. квазиквотирование, которое позволяет сконструировать список произвольной вложенности, вставляя в нужные нам места списка результаты выполнения некоторого кода — в нашем случае это вызовы стандартной функции random, возвращающей случайное число в заданном диапазоне, и float, конвертирующей свой аргумент в число с плавающей запятой (т.к. liballegro использует float в качестве координат). Кроме того, для случайного выбора изображения мы используем функцию random-elt из библиотеки alexandria, включающей в себя массу полезных функций (по сути, эта библиотека для Common Lisp — то же, что GLib для C или boost для C++).

Теперь второй пункт: вызов системы. Об этом за нас позаботится функция из ECS-фреймворка run-systems, т.к. она запускает все зарегистрированные через define-system системы. Интересным обстоятельством здесь является тот факт, что, хоть наш шаблон и разделяет шаги update и render в главном игровом цикле, с использованием ECS нам необязательно явно прописывать отдельную функцию, в которой происходит всё обновление мира и функцию, в которой происходит вся отрисовка. В ECS код игры сконцентрирован в системах, и в наших силах произвольным образом определять порядок выполнения систем друг относительно друга, поэтому просто добавим вызов run-systems в функцию update в нашем шаблоне, после кода для подсчёта FPS:

(defun update (dt)
  (unless (zerop dt)
    (setf *fps* (round 1 dt)))
  (ecs:run-systems))

Функцию render оставим как есть, несмотря на призывающий TODO-комментарий внутри неё о добавлении кода отрисовки. У нас эта функция будет заниматься лишь счётчиком FPS.

Отправив новый код — константу asteroid-images, новый код функций init и update, определения компонентов position, speed и image, а также системы draw-images, в запущенный Lisp-процесс клавишами C-c C-c (или аналогичными для вашей IDE) и запустив (ecs-tutorial-1:main), мы можем наблюдать следующую картину:

Физика

Теперь давайте добавим немного ньютоновской физики. У нас есть компонент speed, имеет смысл задействовать его для вычисления текущей позиции объекта. Создадим для этого отдельную систему по имени move:

(ecs:define-system move
  (:components-ro (speed)
   :components-rw (position)
   :arguments ((:dt single-float)))
  (incf position-x (* dt speed-x))
  (incf position-y (* dt speed-y)))

На этот раз мы будем модифицировать компонент position у интересующих нас сущностей, поэтому указываем его в списке, соответствующем опции components-rw. Кроме того, нашей системе потребуется в качестве аргумента реальное время, прошедшее с предыдущего кадра, чтобы то, что происходит на экране, было физически корректно. Этот аргумент для простоты также будет числом с плавающей запятой с одинарной точностью, single-float; мы называем его dt и указываем вместе с типом в опции arguments. Наконец, код системы попросту увеличивает значения позиции с помощью стандартного макроса incf, аналогичного оператору += из C-подобных языков, на значение dt, умноженное на соответствующую компоненту скорости.

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

  (let ((asteroid-bitmaps
          (map 'list
               #'(lambda (filename) (al:ensure-loaded #'al:load-bitmap filename))
               asteroid-images)))
    (dotimes (_ 1000)
      (ecs:make-object `((:position :x ,(float (random +window-width+))
                                    :y ,(float (random +window-height+)))
                         (:speed :x ,(- (random 100.0) 50.0)
                                 :y ,(- (random 100.0) 50.0))
                         (:image
                          :bitmap ,(alexandria:random-elt asteroid-bitmaps)
                          :width 64.0 :height 64.0)))))

Однако заново запустив функцию ecs-tutorial-1:main после отправки системы move и нового кода функции init в Lisp-процесс средствами нашей IDE, мы получаем следующую ошибку прямо в функции move-system:

The value
  NIL
is not of type
  NUMBER
   [Condition of type TYPE-ERROR]

Давайте прекратим выполнение функции main, выбрав умолчальный рестарт ABORT из списка Restarts и попытаемся понять, что пошло не так.

Приглядевшись к новому коду, можно заметить, что мы забыли передать в систему move параметр dt. Он уже вычисляется за нас в шаблоне и передаётся в функцию update, всё, что нам остаётся сделать — это сконвертировать его из двойной точности, double-float, в одинарную и передать в функцию ecs:run-system, вызываемую в update, которая принимает произвольное число именованных параметров и по именам же передаёт их в системы при необходимости:

(defun update (dt)
  (unless (zerop dt)
    (setf *fps* (round 1 dt)))
  (ecs:run-systems :dt (float dt 0.0)))

Запустив main после отправки нового определения функции update в Lisp-процесс, мы наблюдаем неспешно разлетающиеся в стороны астероиды:

Заметим, что наше демо по-прежнему вписывается в разумные значения FPS. Более того, мы из интереса можем взглянуть на машинный код, сгенерированный компилятором для нашей последней системы, move, воспользовавшись стандартной функцией CL disassemble:

(disassemble (ecs:system-ref :move))

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

; disassembly for ECS-TUTORIAL-1::MOVE-SYSTEMG5
; Size: 210 bytes. Origin: #x538B2811                         ; ECS-TUTORIAL-1::MOVE-SYSTEMG5
; 11:       488B0508FFFFFF   MOV RAX, [RIP-248]               ; 'CL-FAST-ECS:*STORAGE*
; 18:       8B48F5           MOV ECX, [RAX-11]
; 1B:       4A8B0C29         MOV RCX, [RCX+R13]
; 1F:       4883F9FF         CMP RCX, -1
; 23:       480F444801       CMOVEQ RCX, [RAX+1]
; 28:       488B4125         MOV RAX, [RCX+37]
; 2C:       488B4801         MOV RCX, [RAX+1]
; 30:       488B712D         MOV RSI, [RCX+45]
; 34:       488B5935         MOV RBX, [RCX+53]
; 38:       488B4009         MOV RAX, [RAX+9]
; 3C:       4C8B582D         MOV R11, [RAX+45]
; 40:       4C8B7035         MOV R14, [RAX+53]
; 44:       498B42F9         MOV RAX, [R10-7]
; 48:       4C8B52F9         MOV R10, [RDX-7]
; 4C:       488BD0           MOV RDX, RAX
; 4F:       EB35             JMP L2
; 51:       660F1F840000000000 NOP
; 5A:       660F1F440000     NOP
; 60: L0:   4D8B41F9         MOV R8, [R9-7]
; 64:       488BCA           MOV RCX, RDX
; 67:       48D1F9           SAR RCX, 1
; 6A:       488BC1           MOV RAX, RCX
; 6D:       48C1E806         SHR RAX, 6
; 71:       498B44C001       MOV RAX, [R8+RAX*8+1]
; 76:       480FA3C8         BT RAX, RCX
; 7A:       7217             JB L3
; 7C: L1:   488BCA           MOV RCX, RDX
; 7F:       4883C102         ADD RCX, 2
; 83:       488BD1           MOV RDX, RCX
; 86: L2:   4C39D2           CMP RDX, R10
; 89:       7ED5             JLE L0
; 8B:       BA17010050       MOV EDX, #x50000117              ; NIL
; 90:       C9               LEAVE
; 91:       F8               CLC
; 92:       C3               RET
; 93: L3:   488BC2           MOV RAX, RDX
; 96:       F3410F10544301   MOVSS XMM2, [R11+RAX*2+1]
; 9D:       66480F6ECF       MOVQ XMM1, RDI
; A2:       0FC6C9FD         SHUFPS XMM1, XMM1, #4r3331
; A6:       F30F59D1         MULSS XMM2, XMM1
; AA:       F30F104C4601     MOVSS XMM1, [RSI+RAX*2+1]
; B0:       F30F58CA         ADDSS XMM1, XMM2
; B4:       F30F114C4601     MOVSS [RSI+RAX*2+1], XMM1
; BA:       488BC2           MOV RAX, RDX
; BD:       F3410F104C4601   MOVSS XMM1, [R14+RAX*2+1]
; C4:       66480F6EDF       MOVQ XMM3, RDI
; C9:       0FC6DBFD         SHUFPS XMM3, XMM3, #4r3331
; CD:       F30F59D9         MULSS XMM3, XMM1
; D1:       F30F10544301     MOVSS XMM2, [RBX+RAX*2+1]
; D7:       F30F58DA         ADDSS XMM3, XMM2
; DB:       F30F115C4301     MOVSS [RBX+RAX*2+1], XMM3
; E1:       EB99             JMP L1

И это действительно впечатляющий результат — машинный код, вычисляющий положение произвольного числа объектов в соответствии с физическими соображениями, не вызывает никаких сторонних функций и занимает всего лишь 210 байт! Более того, обладая базовым навыком чтения ассемблера, можно разглядеть тело цикла, обрабатывающего наши объекты — оно начинается с метки L3 и включает в себя всего 17 (!) машинных инструкций, которые к тому же аккуратно расчёсывают кэш процессора вдоль шёрстки, что гарантирует высокую производительность.

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

Будем использовать следующий контент с OpenGameArt: Space Background — помимо симпатичной планеты, в архиве также есть приятные космические фоны. Распакуем каталог layers из скачанного архива в наш каталог Resources, так, чтобы png-файлы были доступны нашему приложению по путям вида Resources/parallax-space-big-planet.png.

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

(declaim
 (type single-float
       *planet-x* *planet-y* *planet-width* *planet-height* *planet-mass*))
(defvar *planet-x*)
(defvar *planet-y*)
(defvar *planet-width*)
(defvar *planet-height*)
(defvar *planet-mass* 500000.0)

Обратите внимание, в Common Lisp принято в имена глобальных переменных добавлять "ушки" — звёздочки в начале и в конце, чтобы подчеркнуть, что они являются особенными в том смысле, что используют динамическую область видимости вместо лексической. Кроме того, перед определением переменных через стандартный макрос defvar мы объявляем их тип, single-float — число с плавающей запятой с одинарной точностью, через макрос declaim с параметром type. Это необязательно, т.к. в Common Lisp последовательная типизация, однако это положительно скажется на производительности кода, использующего эти переменные.

Теперь создадим сущность планеты следующим новым фрагментом кода в функции init после вызова ecs:make-storage:

  (let ((planet-bitmap (al:ensure-loaded
                        #'al:load-bitmap
                        "../Resources/parallax-space-big-planet.png")))
    (setf *planet-width* (float (al:get-bitmap-width planet-bitmap))
          *planet-height* (float (al:get-bitmap-height planet-bitmap))
          *planet-x* (/ +window-width+ 2.0)
          *planet-y* (/ +window-height+ 2.0))
    (ecs:make-object `((:position :x ,*planet-x* :y ,*planet-y*)
                       (:image :bitmap ,planet-bitmap
                               :width ,*planet-width*
                               :height ,*planet-height*))))

Здесь мы загружаем картинку с планетой с помощью уже знакомых нам функций al_load_bitmap и al:ensure-loaded, а затем, пользуясь нехитрыми арифметическими соображениями и функциями al_get_bitmap_width и al_get_bitmap_height, создаём сущность с картинкой точно в середине экрана, записав её координаты и размеры в соответствующих глобальных переменных с помощью макроса setf.

Отправив в Lisp-процесс новый код — определения глобальных переменных через defvar и изменённую функцию init, и перезапустив функцию main, мы увидим планету:

Больше физики

Теперь давайте добавим ещё реалистичности — пусть объекты, соприкоснувшись с поверхностью планеты, будут разрушаться; в расчётах будем считать планету эллипсом. Реализуем для обсчёта столкновений очередную систему, назвав её crash-asteroids:

(ecs:define-system crash-asteroids
  (:components-ro (position)
   :with ((planet-half-width planet-half-height)
          :of-type (single-float single-float)
          := (values (/ *planet-width* 2.0)
                     (/ *planet-height* 2.0))))
  (when (<= (+ (expt (/ (- position-x *planet-x*) planet-half-width) 2)
               (expt (/ (- position-y *planet-y*) planet-half-height) 2))
            1.0)
    (ecs:delete-entity entity)))

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

\left(\frac{x}{\text{w}/2}\right)^2 + \left(\frac{y}{\text{h}/2}\right)^2 \leqslant 1

и понять, сталкивается ли очередной объект с планетой. Если это условие истинно, мы удаляем объект, вызывая функцию delete-entity с переменной entity, автоматически создаваемой для нас макросом define-system для текущей обрабатываемой сущности.

Обратите внимание, что нам необязательно даже закрывать окно симуляции и перезапускать функцию main — отправив определение crash-asteroids в Lisp-процесс, мы тут же изменим поведение нашей симуляции в соответствии с правилами, закодированными в новой системе. Однако, воспользовавшись этой возможностью, мы сразу столкнёмся с неожиданным эффектом — планета перестаёт отображаться!

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

Для того, чтобы исправить этот недосмотр, воспользуемся таким приёмом, часто используемым в приложениях с ECS-архитектурой, как компонент-тег: создадим пустой компонент без единого слота, который будет служить некоей "меткой" — будучи добавленным к сущности, он будет сигнализировать некоторый бинарный признак, в данном случае — признак того, является ли объект планетой:

(ecs:define-component planet
  "Tag component to indicate that entity is a planet.")

Затем модифицируем функцию init так, чтобы новосозданный компонент был добавлен к сущности планеты:

    (ecs:make-object `((:planet)
                       (:position :x ,*planet-x* :y ,*planet-y*)
                       (:image :bitmap ,planet-bitmap
                               :width ,*planet-width*
                               :height ,*planet-height*)))

Кроме того, пока мы здесь, давайте в виде космического косметического штриха сделаем астероиды внутри нашего цикла на 1000 элементов случайного размера:

      (ecs:make-object `((:position :x ,(float (random +window-width+))
                                    :y ,(float (random +window-height+)))
                         (:speed :x ,(- (random 100.0) 50.0)
                                 :y ,(- (random 100.0) 50.0))
                         (:image
                          :bitmap ,(alexandria:random-elt asteroid-bitmaps)
                          :scale ,(+ 0.1 (random 0.9))
                          :width 64.0 :height 64.0)))

Наконец, модифицируем систему crash-asteroids так, чтобы она пропускала сущности с компонентом planet; для этого воспользуемся опцией макроса define-system под названием :components-no, в котором мы можем указать список компонентов, которых не должно быть у сущностей, обрабатываемых системой:

(ecs:define-system crash-asteroids
  (:components-ro (position)
   :components-no (planet)
   :with ((planet-half-width planet-half-height)
          :of-type (single-float single-float)
          := (values (/ *planet-width* 2.0)
                     (/ *planet-height* 2.0))))
  (when (<= (+ (expt (/ (- position-x *planet-x*) planet-half-width) 2)
               (expt (/ (- position-y *planet-y*) planet-half-height) 2))
            1.0)
    (ecs:delete-entity entity)))

Отправив в Lisp-процесс новые определения (компонент planet, функцию init и систему crash-asteroids) и перезапустив ecs-tutorial-1:main, мы можем наблюдать следующий виртуальный шар со снегом:

Ещё больше физики

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

(ecs:define-component acceleration
  "Determines the acceleration of the object, in pixels/second^2."
  (x 0.0 :type single-float :documentation "X coordinate")
  (y 0.0 :type single-float :documentation "Y coordinate"))

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

(ecs:define-system accelerate
  (:components-ro (acceleration)
   :components-rw (speed)
   :arguments ((:dt single-float)))
  (incf speed-x (* dt acceleration-x))
  (incf speed-y (* dt acceleration-y)))

Однако главным персонажем в истории о силе притяжения будет влияние массы планеты на наши астероиды. У нас уже есть глобальная переменная с массой планеты, *planet-mass*. Путём нехитрых алгебраических манипуляций выведем выражения для ускорения из закона всемирного тяготения и второго закона Ньютона:

F = G\frac{mM}{r^2},\quad F=ma \quad\Rightarrow\quad ma = G\frac{mM}{r^2},\quad a = G\frac{M}{r^2},\left\{\begin{array}{rcl}a_x &=& G\frac{M}{r^2} \cos \alpha, \\a_y &=& G\frac{M}{r^2} \sin \alpha, \\\end{array}\right.

где

  • \alpha = \arctan\frac{Y-y}{X-x} — угол между планетой и астероидом,

  • r = \sqrt{\left(X-x\right)^2+\left(Y-y\right)^2} — расстояние между ними.

Предполагая, что гравитационная постоянная G уже включена в качестве множителя в переменную *planet-mass*, создадим новую систему по имени pull для расчёта ускорения астероидов по вышеприведённым формулам:

(ecs:define-system pull
  (:components-ro (position)
   :components-rw (acceleration))
  (let* ((distance-x (- *planet-x* position-x))
         (distance-y (- *planet-y* position-y))
         (angle (atan distance-y distance-x))
         (distance-squared (+ (expt distance-x 2) (expt distance-y 2)))
         (acceleration (/ *planet-mass* distance-squared)))
    (setf acceleration-x (* acceleration (cos angle))
          acceleration-y (* acceleration (sin angle)))))

Наконец, в функции init добавим компонент acceleration к нашим астероидам:

      (ecs:make-object `((:position :x ,(float (random +window-width+))
                                    :y ,(float (random +window-height+)))
                         (:speed :x ,(- (random 100.0) 50.0)
                                 :y ,(- (random 100.0) 50.0))
                         (:acceleration)
                         (:image
                          :bitmap ,(alexandria:random-elt asteroid-bitmaps)
                          :scale ,(+ 0.1 (random 0.9))
                          :width 64.0 :height 64.0)))

Отправив определения компонента acceleration, а также функции init и систем accelerate и pull в Lisp-процесс, мы получим сходную картину шара со снегом, только теперь астероиды охотнее роятся вокруг планеты.

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

  (let ((asteroid-bitmaps
          (map 'list
               #'(lambda (filename)
                   (al:ensure-loaded #'al:load-bitmap filename))
               asteroid-images)))
    (dotimes (_ 5000)
      (let ((r (random 20.0))
            (angle (float (random (* 2 pi)) 0.0)))
        (ecs:make-object `((:position :x ,(+ 200.0 (* r (cos angle)))
                                      :y ,(+ *planet-y* (* r (sin angle))))
                           (:speed :x ,(+ -5.0 (random 15.0))
                                   :y ,(+ 30.0 (random 30.0)))
                           (:acceleration)
                           (:image
                            :bitmap ,(alexandria:random-elt asteroid-bitmaps)
                            :scale ,(+ 0.1 (random 0.9))
                            :width 64.0 :height 64.0))))))

Кроме того, в виде последнего космического штриха давайте используем звёздные фоны из наших ресурсов, добавив следующий код в функцию init, сразу после вызова make-storage:

  (let ((background-bitmap-1 (al:ensure-loaded
                              #'al:load-bitmap
                              "../Resources/parallax-space-stars.png"))
        (background-bitmap-2 (al:ensure-loaded
                              #'al:load-bitmap
                              "../Resources/parallax-space-far-planets.png")))
    (ecs:make-object
     `((:position :x 400.0 :y 200.0)
       (:image :bitmap ,background-bitmap-1
               :width ,(float (al:get-bitmap-width background-bitmap-1))
               :height ,(float (al:get-bitmap-height background-bitmap-1)))))
    (ecs:make-object
     `((:position :x 100.0 :y 100.0)
       (:image :bitmap ,background-bitmap-2
               :width ,(float (al:get-bitmap-width background-bitmap-2))
               :height ,(float (al:get-bitmap-height background-bitmap-2))))))

Такие вводные данные приведут к следующему завораживающему поведению симуляции:

Обратите внимание, что физическая симуляция пяти тысяч объектов легко вписывается в лимит 60 кадров в секунду, что лишний раз подтверждает быстродействие кода, выстроенного по паттерну Entity-Component-System, а количество написанного нами кода в размере 250 строк (включая бойлерплейт и хардкод) указывает на высочайшую экспрессивность языка и мощь металингвистической абстракции.

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

Заключение

В этом руководстве мы построили двумерную физическую симуляцию на языке Common Lisp, а также рассмотрели основные возможности ECS-фреймворка cl-fast-ecs. Полный код симуляции можно найти на github.

На момент написания статьи версия фреймворка cl-fast-ecs — 0.4.0, что означает, что он бурно развивается и в нём пока ещё возможны изменения, ломающие обратную совместимость. Однако функциональность, рассмотренная нами сегодня, — макросы для создания компонентов и систем, функции для создания сущностей, — являются фундаментальными и вряд ли претерпят большие изменения в будущем.

В следующей части мы добавим интерактивности, а также перейдём от космического жанра к фентезийному и попробуем написать простенький dungeon crawler. Подпишитесь на мой telegram-канал о разработке видеоигр на лиспе, чтобы не пропустить следующую часть.

Благодарности

Хотелось бы поблагодарить моего товарища Сергея @ViruScD за поддержку и помощь в написании статьи, а так же Артёма из телеграм-сообщества Lisp Forever за помощь с вычиткой текста.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 17: ↑17 и ↓0+17
Комментарии20

Публикации

Работа

Ближайшие события