В данной серии практических руководств мы подробно рассмотрим создание несложных 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. Проблему ярко иллюстрирует следующий график:
Кривая, помеченная "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 (система) — код, обрабатывающий объекты определённой структуры.
Давайте сначала разберёмся с сущностями и компонентами на конкретном примере:
По горизонтали у нас отображены разноцветными прямоугольниками компоненты: Position
, Movement
, Render
и так далее. Обратите внимание, что каждый из этих компонентов может содержать несколько полей с данными, например, Position
и Movement
почти наверняка будут содержать поля x
и y
. Далее, по вертикали в скобках подписаны сущности — Alien
, Player
и т.д. Каждая сущность имеет определённый набор компонентов. Что более важно, к любой сущности мы можем "на ходу", в рантайме, добавить или удалить некоторые компоненты, чем мы поменяем её структуру и, как следствие, поведение и статус в игровом мире, и всё это без перекомпиляции кода игры! Этим достигается первая концептуальная цель ECS, указанная выше — гибкость структуры игровых объектов.
Вышеприведённая иллюстрация, если взглянуть на неё слегка прищурившись, сильно напоминает обычную экселевскую таблицу. И по своей сути, ECS таковой и является ????
С концептуальной точки зрения сущности и компоненты образуют строки и столбцы таблицы, в ячейках которой хранятся данные компонента, либо значения отсутствуют. Такое представление игровых данных позволяет провернуть ряд трюков, связанных с их расположением в памяти. В свою очередь, эти трюки позволяют наиболее плотно утилизировать кэш CPU при обработке данных, подобно вышеприведённому примеру с циклом по float
'ам, и таким образом выжать максимум производительности из системы "процессор-память".
Собственно, обработка игровых данных при использовании шаблона ECS возлагается на т.н. системы — циклы, которые проходят по всем сущностям, обладающими определёнными компонентами, и выполняющими операции над этими сущностями одинаковым образом. Например, система, обсчитывающая передвижение объектов, будет обрабатывать сущности с компонентами Position
и Movement
, система, отрисовывающая объекты на экране, будет заинтересована в сущностях с компонентами Position
и Render
и так далее. Визуально системы можно проиллюстрировать следующим примером:
Так, в этом примере система 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
...либо скачать готовый инсталлятор с официального сайта (обратите внимание на архитектуру 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).
-
Однако непревзойдённым лидером в качестве 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
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
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
pacman -S mingw-w64-x86_64-libffi # для Windows с MSYS2
Наконец, после многочисленных предварительных приготовлений, мы можем запустить наш проект, для этого нужно:
перейти в подкаталог
src
проекта (это важно для того, чтобы код игры смог найти все нужные ему файлы ресурсов, такие, как шрифты, изображения и т.д.);запустить в нём
sbcl
;выполнить в SBCL код вида
(ql:quickload :ecs-tutorial-1)
для загрузки пакета с проектом;дождавшись приглашения ввода в виде звёздочки после загрузки, вызвать точку входа в проект, функцию
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)
Затем в саму функцию 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 'ecs-tutorial-1::move-systemg5)
;; прим.: имя функции с кодом системы генерируется автоматически, но
;; всегда начинается с move-system
В результате мы можем увидеть (для релизного билда, собираемого для нас скриптом 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
, позволяющую единожды, в начале работы системы, определить некие локальные переменные, доступные в теле системы — нам будут необходимы размеры полуосей планеты, чтобы подставить их в уравнение эллипса
и понять, сталкивается ли очередной объект с планетой. Если это условие истинно, мы удаляем объект, вызывая функцию 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 за помощь с вычиткой текста.
Комментарии (12)
axe_chita
19.10.2023 16:04+1Как же тут не вспомнить шутер Abuse, в котором высокоуровневая логика игры была реализована на LISP.
И кстати, когда я искал Abuse, поиск вывалил результат что в середине 2023 года на портале dev.to прошел джем по созданию игр на Common Lisp.
prefrontalCortex Автор
19.10.2023 16:04Да, у меня давно чешутся руки изучить Abuse поподробнее.
По поводу геймджемов, на itch.io дважды в год проводится Lisp Game Jam, в котором можно участвовать на любом диалекте, хоть на Common Lisp, хоть на Clojure, хоть на Fennel с Löve2D. По совпадению, осенний Lisp Game Jam 2023 начался буквально сегодня утром, к нему ещё можно присоединиться ????
zloddey
19.10.2023 16:04+1Выглядит прикольно, спасибо!
Замечу только, что у меня при попытке воспроизвести пример возникли проблемы с системой
pull
. Пока не переименовал биндингacceleration
внутриlet
вaccel-value
, получал только здоровый стектрейс при запуске. Я не специалист по CL, и в теории кажется, чтоlet
должен позволять "перекрывать" внешний биндинг локальным, - но всё же у меня тут возникал конфликт имён, кажется. Стоило бы проверить это место в статье.Заинтересовали также такие вопросы:
Видно, что код отдельных компонентов и систем довольно хорошо отделён друг от друга. Это неплохо. Однако остаётся здоровенная склейка в функции
init
, где мы вынуждены инициализировать все объекты. Чем больше разных типов объектов появляется здесь, тем монструознее станет код. Как обычно решается эта проблема? Наверняка есть более-менее стандартные решения.Запускать код из REPL или редактора во время разработки весело и удобно, а как потом эту радость упаковывать в дистрибутив для конечного пользователя? Теоретически-то оно понятно, конечно: собираем в одно место сишечные либы, собираем рядышком лисповые либы (или даже компилируем?), делаем какой-то лаунчер для запуска этой радости. Существуют ли готовые решения для подобной упаковки?
В процессе работы над примером я несколько раз ловил стектрейсы из-за собственных ошибок (неправильно указал имя файла, не там скобочку поставил в коде и т.п.). Каждый раз это приводило к показу здоровенного диалогового окошка с кучей страшных надписей. Понятно, что конечному пользователю это показывать не следует. Как обычно бывает устроена обработка ошибок в "пользовательской сборке" продукта?
prefrontalCortex Автор
19.10.2023 16:04+1Пожалуйста ????
По биндингу — вы правы,
let
"перекрывает" внешние биндинги локальными, однако, парадоксальным образом, для компонента по имениacceleration
не создаётся ни одного биндинга по имениacceleration
???? Если не трудно и есть время, скиньте куда-нибудь ругань SBCL с непереименованным биндингом, попробуем разобраться, что там не так.Да,
init
в коде похож на небольшую свалку ???? Обычно способ, которым решают эту проблему — это что-то в духе префабов, т.е. параметры компонентов, сериализованные в файл/файлы. На самом деле тут можно заабьюзить возможность Лиспа мешать код с данными и загружать списки-спецификации объектов из файлов с помощью стандартной функции load, а затем скармливать их вecs:make-object
; примерно так я поступил в одном из проектов на базе cl-fast-ecs. В первой части руководства я ради простоты захардкодил инициализацию всех объектов.Да, есть целый ряд решений. Более того, в мой шаблон
cookiecutter-lisp-game
входит машинерия по сборке проекта (точка входа в неё — скрипт package.sh), на данный момент поддерживаются Windows и Linux (над MacOS буду работать в ближайшие пару недель). Более того, эта машинерия автоматически запускается при пуше в репозиторий тэга и радует вас готовыми бинарниками в секции Releases ^_^Скорее всего, вы не пользовались IDE. В том же самом Emacs ошибка приводит к появлению интерфейса интерактивной обработки ошибки, который выглядит примерно так. Опять-таки, одна из уникальных фичей CL — невероятно мощная система обработки ошибок, про неё целая книга недавно вышла. Можно программно выбирать обработку конкретных ошибок в зависимости от текущих условий, можно пытаться повторять выполнить тот же код, повторно вызывать сбойнувшие функции с другими аргументами, игнорировать ошибки и т.д. Я просто выбрал самое примитивное — показать окошко с бэктрейсом и аварийно завершиться, но можно легко закодировать более продвинутые варианты, например, отправить отчёт об ошибке, перезапустить текущий уровень, буквально всё, что душа пожелает)
kavalex
19.10.2023 16:04+1Спасибо, отличный туториал
Пытаюсь запустить на macos sonoma 14.0 и у меня ошибка )
При первом вызове (ecs-tutorial-1:main)
в консоли:
CORRUPTION WARNING in SBCL pid 23700 pthread 0x7000058be000: Memory fault at 0x0 (pc=0x5adfcfa, fp=0x7000058bd4d0, sp=0x7000058bd4b0) pthread 0x7000058be000 The integrity of this image is possibly compromised. Continuing with fingers crossed.
Единственное, что сделал не по инструкции - загрузил allegro
brew install allegro
и libffi
brew install libffi
и соответственно ничего не компилировал
prefrontalCortex Автор
19.10.2023 16:04Пожалуйста!
Судя по всему, вы всё сделали правильно, но совместимость с MacOS я пока не проверял из-за отсутсвия соответствующего железа. Однако она у меня находится в ближайших планах ????
Выглядит ошибка странно, ничего криминального в вызове
al_load_config_file
с несуществующим конфигом нет. А попробуйте в каталоге проекта создать пустой текстовый файлconfig.cfg
, возможно, поможет.kavalex
19.10.2023 16:04Спасибо за ответ, не помогло )
Здесь вроде есть решение, только я не понимаю, что нужно сделать в моем случае
https://stackoverflow.com/questions/74054993/sbcl-executable-crash-when-running-a-hunchentoot-server
prefrontalCortex Автор
19.10.2023 16:04Скорее всего, у вас какая-то другая ошибка, там что-то хтоническое всплывает из недр самого SBCL, а у вас совершенно определённо крэш в вызове сишной функции al_load_config_file, хотя чему там крэшиться, мне совершенно неясно ????
Остаётся только порекомендовать запустить из-под Virtualbox с какой-нибудь Ubuntu, у меня в виртуалке с VMSVGA драйвером всё вполне себе бодро бегало (собственно, скриншоты в статье как раз из-под этой виртуалки). Ну или подождать пару недель, пока у меня дойдут руки до облачной виртуалки с MacOS, чтобы зафиксить все проблемы совместимости)
Svetlyak
Последняя демка выглядит особенно круто.
Жду продолжения!
prefrontalCortex Автор
Спасибо! Над продолжением уже начал работу ????