Pull to refresh

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

Level of difficultyMedium
Reading time30 min
Views5.1K

В данной серии практических руководств мы подробно рассмотрим создание несложных 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. перейти в подкаталог src проекта (это важно для того, чтобы код игры смог найти все нужные ему файлы ресурсов, такие, как шрифты, изображения и т.д.);

  2. запустить в нём sbcl;

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

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

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

Для запуска проекта из IDE может потребоваться вручную выставить рабочий каталог, это можно сделать, передав в REPL код вида (uiop:chdir "/path/to/src"). Под Windows в пути к каталогу src нужно также использовать прямые слэши, /, вместо обратных.

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

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

Прежде всего, если вы никогда не имели дела с 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) (попробуйте!), мы получим ошибку вида The variable CL-FAST-ECS:*STORAGE* is unbound. Она происходит не потому, что автор забыл определить в коде фреймворка переменную *storage*, а потому, что она пока не связана (bound) ни с каким значением. Чтобы связать её с новосозданным объектом хранилища данных ECS, нужно вызвать функцию bind-storage. Наиболее логичное место для этого в коде игры — функция init:

(defun init ()
  (ecs:bind-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 миллионов миль от Земли.

Итак, теперь мы готовы определить компоненты, которые будет использовать наша игровая симуляция. Мы будем моделировать ньютоновскую физику большого количества небесных тел. Для этого нам непременно понадобятся компоненты для позиции и скорости объектов, устроенные сходным образом. Добавим перед функцией 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 после вызова bind-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 (rest (assoc :move ecs::*system-registry*)))

В результате мы можем увидеть (для релизного билда, собираемого для нас скриптом 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:bind-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, сразу после вызова bind-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 за помощь с вычиткой текста.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 17: ↑17 and ↓0+17
Comments19

Articles