В данной серии практических руководств мы подробно рассмотрим создание несложных 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 — довольно нехитрый паттерн организации хранения и обработки данных в игровых приложениях, который позволяет достигнуть сразу две важные концептуальные цели:
гибкость в определении и изменении структуры игровых объектов,
производительность за счёт эффективной утилизации кэшей центрального процессора.
Гибкость, интерактивность и возможность переопределять поведение программы "на ходу" — краеугольные камни большинства Lisp-подобных языков. Мы вернёмся к этому вопросу позднее, а пока остановимся чуть подробнее на второй цели, которая часто преподносится как основное преимущество паттерна ECS. Начнём издалека.
В фон-неймановской архитектуре, используемой на данный момент в большинстве вычислительных устройств, существует фундаментальная проблема, называемая "бутылочным горлышком фон Неймана". Суть её состоит в том, что насколько быстро данные ни обрабатывались бы центральным процессором, скорость их обработки ограничена производительностью памяти. Более того, производительность системы "CPU-память" ограничена сверху пропускной способностью шины, по которой данные узлы обмениваются информацией, и эта величина не может расти бесконечно или хотя бы с той же скоростью, с которой растёт производительность CPU. Проблему ярко иллюстрирует следующий график:
![источник: Aras Pranckevičius, Entity Component Systems & Data Oriented Design](https://habrastorage.org/webt/wz/9i/4e/wz9i4e4mvjvcu70xl8ojmsea5cu.jpeg)
Кривая, помеченная "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 не только запрошенный, но также последующих элементов, и на следующих 15 итерациях цикла получение элемента будет занимать 1 нс вместо 100 нс. Получается, благодаря кэшу наш цикл, вне зависимости от длины массива, работает в
раз быстрее! На этом примере видно, как важно с точки зрения производительности обрабатывать данные так, чтобы они оставались "горячими" в кэше.
Чтобы понять, как архитектурный паттерн ECS способствует утилизации кэша, давайте рассмотрим его основные составляющие:
entity (сущность) — составной игровой объект;
component (компонент) — данные, описывающие некоторую логическую грань объекта;
system (система) — код, обрабатывающий объекты определённой структуры.
Давайте сначала разберёмся с сущностями и компонентами на конкретном примере:
![источник: Mick West, Cowboy Programming. Evolve Your Hierarchy](https://habrastorage.org/webt/dp/mz/ce/dpmzcezisz7iaqft3p2zvctg7y0.png)
По горизонтали у нас отображены разноцветными прямоугольниками компоненты: Position
, Movement
, Render
и так далее. Обратите внимание, что каждый из этих компонентов может содержать несколько полей с данными, например, Position
и Movement
почти наверняка будут содержать поля x
и y
. Далее, по вертикали в скобках подписаны сущности — Alien
, Player
и т.д. Каждая сущность имеет определённый набор компонентов. Что более важно, к любой сущности мы можем "на ходу", в рантайме, добавить или удалить некоторые компоненты, чем мы поменяем её структуру и, как следствие, поведение и статус в игровом мире, и всё это без перекомпиляции кода игры! Этим достигается первая концептуальная цель ECS, указанная выше — гибкость структуры игровых объектов.
Вышеприведённая иллюстрация, если взглянуть на неё слегка прищурившись, сильно напоминает обычную экселевскую таблицу. И по своей сути, ECS таковой и является ?
![источник: Maxim Zaks, Entity Component System - A Different Approach to Game / Application Development](https://habrastorage.org/webt/7i/bc/py/7ibcpyval4tlktqlpfvqcisk8zq.png)
С концептуальной точки зрения сущности и компоненты образуют строки и столбцы таблицы, в ячейках которой хранятся данные компонента, либо значения отсутствуют. Такое представление игровых данных позволяет провернуть ряд трюков, связанных с их расположением в памяти. В свою очередь, эти трюки позволяют наиболее плотно утилизировать кэш CPU при обработке данных, подобно вышеприведённому примеру с циклом по float
'ам, и таким образом выжать максимум производительности из системы "процессор-память".
Собственно, обработка игровых данных при использовании шаблона ECS возлагается на т.н. системы — циклы, которые проходят по всем сущностям, обладающими определёнными компонентами, и выполняющими операции над этими сущностями одинаковым образом. Например, система, обсчитывающая передвижение объектов, будет обрабатывать сущности с компонентами Position
и Movement
, система, отрисовывающая объекты на экране, будет заинтересована в сущностях с компонентами Position
и Render
и так далее. Визуально системы можно проиллюстрировать следующим примером:
![источник: Антон Григорьев, Как и почему мы написали свой ECS](https://habrastorage.org/webt/eh/m1/k1/ehm1k1bxgndc6py4uhq82qlrmr8.png)
Так, в этом примере система MoveSystem
по сути является циклом, последовательно проходящим по всем сущностям, имеющим компоненты Transform
и Movement
, и вычисляющим новые значения позиции для каждой сущности в соответствии с её скоростью. Большинство реализаций паттерна ECS устроено таким образом, что данные полей компонентов (например, полей x
и y
компонента Movement
) так или иначе хранятся в плоских одномерных массивах, а сущности являются банальными целочисленными индексами в этих массивах. Системы, в свою очередь, попросту итерируют по массивам с данными компонентов, чем достигается пространственная локальность кэша CPU, в точности как в примере с циклом по float
'ам выше.
На этом краткий обзор архитектурного паттерна Entity-Component-System завершён. Для более глубокого погружения в тему рекомендуются следующие материалы:
Entity component system (англ.): описание паттерна в Википедии.
ECS FAQ (англ.): ответы на часто задаваемые вопросы по ECS от автора C-шного фреймворка Flecs Сандера Мертенса.
Открытый урок Паттерн Entity-Component-System в играх на C, проведённый вашим покорным слугой и презентация к нему.
awesome-entity-component-system (англ.): подборка библиотек и ресурсов по ECS.
Кэш процессора: довольно подробная статья о процессорных кэшах в Википедии.
Ulrich Drepper, What Every Programmer Should Know About Memory (англ.): подробнейший труд одного из разработчиков GNU libc, Ульриха Дреппера, об устройстве и оптимизации производительности памяти.
Теперь мы готовы использовать библиотеку 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)
![](https://habrastorage.org/webt/rl/_7/gu/rl_7guuo93aaka2dbtba4xu36iw.png)
Чтобы писать код на 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).
Однако непревзойдённым лидером в качестве 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
Наконец, после многочисленных предварительных приготовлений, мы можем запустить наш проект, для этого нужно:
перейти в подкаталог
src
проекта (это важно для того, чтобы код игры смог найти все нужные ему файлы ресурсов, такие, как шрифты, изображения и т.д.);запустить в нём
sbcl
;выполнить в SBCL код вида
(ql:quickload :ecs-tutorial-1)
для загрузки пакета с проектом;дождавшись приглашения ввода в виде звёздочки после загрузки, вызвать точку входа в проект, функцию
main
, выполнив код вида(ecs-tutorial-1:main)
Если всё пройдёт без проблем, мы увидим пустое окно со счётчиком FPS:
![](https://habrastorage.org/webt/5i/z0/za/5iz0za2btdyvdfxlr3a3sjlaey8.png)
Для запуска проекта из 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 миллионов миль от Земли.
![](https://habrastorage.org/webt/iw/te/qq/iwteqqtxfqcfkogqp4wofszidia.png)
Итак, теперь мы готовы определить компоненты, которые будет использовать наша игровая симуляция. Мы будем моделировать ньютоновскую физику большого количества небесных тел. Для этого нам непременно понадобятся компоненты для позиции и скорости объектов, устроенные сходным образом. Добавим перед функцией 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)"):
![](https://habrastorage.org/webt/y2/4q/gr/y24qgr7cd5nzxeyrbihkoizgzee.png)
Распакуем каталог 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)
, мы можем наблюдать следующую картину:
![](https://habrastorage.org/webt/sa/pw/9r/sapw9rsosfctq9reajdheukq5we.png)
Физика
Теперь давайте добавим немного ньютоновской физики. У нас есть компонент 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-exists-p :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: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
, мы увидим планету:
![](https://habrastorage.org/webt/zv/6y/3_/zv6y3_msbl8ot35bkq_oblczddi.png)
Больше физики
Теперь давайте добавим ещё реалистичности — пусть объекты, соприкоснувшись с поверхностью планеты, будут разрушаться; в расчётах будем считать планету эллипсом. Реализуем для обсчёта столкновений очередную систему, назвав её 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
, позволяющую единожды, в начале работы системы, определить некие локальные переменные, доступные в теле системы — нам будут необходимы размеры полуосей планеты, чтобы подставить их в уравнение эллипса
и понять, сталкивается ли очередной объект с планетой. Если это условие истинно, мы удаляем объект, вызывая функцию 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*
. Путём нехитрых алгебраических манипуляций выведем выражения для ускорения из закона всемирного тяготения и второго закона Ньютона:
где
— угол между планетой и астероидом,
— расстояние между ними.
Предполагая, что гравитационная постоянная уже включена в качестве множителя в переменную
*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 за помощь с вычиткой текста.