Преимущества и навыки, полученные при использовании Common Lisp в разработке игр



Разработка игр является увлекательной задачей. Игры требуют быстрого цикла разработки, высокой интерактивности и задают ограничения мягкого реального времени. Хотя в настоящее время небольшие игры разрабатываются на таких динамических языках, как Python или Lua, традиционно игровые движки пишутся на статических языках вроде C++ и C с каким-либо скриптовым языком поверх для обработки геймплейных механик. Common Lisp предоставляет среду разработки, одновременно являющуюся динамичной и достаточно производительной, что позволяет построить с её помощью полноразмерную систему разработки игр, сильно способствующую быстрым итерациям разработки и модульному дизайну.


Введение


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


Типичным подходом к данному набору ограничений является комбинирование нескольких языков. Обычно используется язык более низкого уровня, такой как C++ или C, для создания "ядра движка", и интегрированный скриптовый язык, например Lua, для обработки геймплейной логики. Однако у такого похода есть свои проблемы: сложно определить, какие части должны входить в ядро, а какие — нет. Скриптинг не может быть интегрирован со всем, что предлагает движок, так как требуется разработать интерфейс, способный работать с типами данных и процедурами скриптового языка. По причинам, связанным с производительностью, высокодинамичная часть также может потребовать переноса на уровень статического языка, что замедляет и усложняет интеграцию.


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


В данной статье мы рассматриваем другой, целостный подход, использующий Common Lisp в полной мере как для ядра движка, так и для геймплейных инструментов и механик. Common Lisp является высокодинамичным языком, позволяющим во время выполнения переопределять функции, переменные и классы, вплоть до полной перезагрузки или изменения нижележащей библиотеки или системы в то время, пока работает программа. Тем не менее, несмотря на такую динамичность, Common Lisp является компилируемым языком, всесторонне поддерживающим написание эффективного кода. Эффективно оптимизирующие компиляторы навроде SBCL позволяют писать быстрый код без необходимости прибегать к другим языкам.


Далее мы подробно рассмотрим некоторые аспекты Common Lisp, которые делают его особенно приспособленным для разработки игр, а также обсудим некоторые проблемы, с которыми мы столкнулись, и способы их преодоления.


Работы по теме


Вы также можете ознакомиться с нашими предыдущими работами, освещающими использование Common Lisp в разработке игр и компьютерной графики реального времени:



Данная работа в большей степени является обзором возможностей Common Lisp и нашего опыта.


Модульность через mixin'ы


Объектная система Common Lisp (CLOS) имеет ряд особенностей, которые редко встречаются в современных языках программирования, но великолепно показывают себя в геймдеве. В данном разделе мы обсудим упорядоченное множественное наследование и комбинирование методов обобщённых функций. Эти возможности могут использоваться для реализации системы, напоминающей ECS, но также предоставляют некоторые преимущества, делающие комбинирование поведений более естественным.


В CLOS методы не привязаны к классам, а являются частью обобщённых функций. Метод "специализируeтся" на одном или нескольких аргументах функции по их классам. При вызове обобщённой функции система должна сначала определить набор "применимых методов". Этот набор зависит от комбинатора методов, используемых обобщённой функцией. Для ясности мы здесь рассмотрим только стандартный комбинатор методов.


Стандартный комбинатор методов предоставляет четыре варианта методов: основной, :before, :after и :around. Эти методы группируются, затем отбираются применимые методы путём проверки соответствия аргументов, переданных обобщённой функции, классам, на которых специализируются методы, и, наконец, методы сортируются по степени специфичности специализаций для переданных аргументов. После формирования такого набора, методы вызываются в порядке, показанном на следующей иллюстрации:



В качестве простого примера давайте рассмотрим следующий листинг:


(defgeneric handle (event object))

(defmethod handle         ((ev tick) (object player))) ; 1
(defmethod handle :before ((ev tick) (object player))) ; 2
(defmethod handle         ((ev tick) (object enemy)))  ; 3

В нём мы определяем обобщенную функцию от двух аргументов handle и три метода. Каждый метод требует, чтобы первый аргумент был подклассом класса tick. Методы 1 и 2 также требуют, чтобы второй аргумент был подклассом player, а метод 3 — подклассом enemy.


Когда handle вызывается с экземплярами tick и player, сначала выполняется метод 2, а затем метод 1. Метод 3 игнорируется, так как он не соответствует аргументам.


Данный механизм комбинирования методов великолепно проявляет себя, когда мы рассматриваем наследование, в особенности множественное наследование и сопутствующие ему "классы-примеси". Давайте определим классы, использованные в предыдущем примере. В следующем листинге показано определение пяти классов, при этом tick является подклассом event, а player и enemy — подклассами physics-object.


(defclass event () ())
(defclass tick (event) ())

(defclass physics-object () ())
(defclass player (physics-object) ())
(defclass enemy (physics-object) ())

Теперь мы также изменим второй метод, чтобы он специализировался на physics-object вместо player. Например, по той причине, что как player, так и enemy движутся, мы можем обрабатывать в нём разрешение коллизий. Если мы снова вызовем обобщённую функцию, методы 2 и 1 по-прежнему будут вызваны. Однако, если мы вызовем функцию с tick и enemy, теперь будут вызваны методы 2 и 3, а не только метод 3, как раньше.


В других парадигмах ООП аналогичное поведение обычно достигается с помощью вызова метода суперкласса, но здесь стоит заметить, что поведение метода 2 не требует изменения других методов или подклассов. Таким образом, он остаётся полностью инкапсулированным в своём собственном поведении.


Пока всё идёт по плану. Теперь представим, будто мы решили, что враги должны излучать свет, чтобы всегда быть видимыми. Для реализации этого поведения мы определим новый класс emitter, и определим поведение излучения в новом методе handle:


(defclass emitter () ())

(defmethod handle :before ((ev tick) (object emitter)))

Для того, чтобы враг взял на вооружение это новое поведение, всё, что нам нужно сделать — это добавить emitter в список суперклассов класса enemy. Благодаря комбинатору методов, вызовы handle теперь будут включать в себя метод emitter'а, и мы достигли сочетания двух видов поведения:


(defclass enemy (physics-object emitter) ())

Здесь важен порядок следования суперклассов. Классы, которые появляются раньше в списке имеют "приоритет" и считаются "более специфичными", когда определяется набор применимых методов. Помещая emitter после physics-object, мы гарантируем, что :before метод emitter'а будет выполняться после метода physics-object, обеспечивая правильное разрешение коллизий до обновления освещения.


Тот факт, что emitter не является подклассом physics-object, гарантирует, что мы также можем использовать его в качестве суперкласса для других вещей, например, для полностью статических фонарей, которые никоим образом не интерактивны.


В качестве примера из нашей реальной кодовой базы, на момент написания у класса player есть 8 прямых суперклассов, поведение которых комбинируется с собственным поведением player. Такое комбинирование поведения позволяет инкапсулировать разные части и повторно использовать их во многих случаях. Кроме того, следует помнить, что все эти классы и методы могут быть переопределены во время выполнения с целью изменения, добавления и удаления поведения объекта, пока игра запущена.


Мы также разработали систему, позволяющую связать шейдеры с моделью наследования CLOS, что позвояет присоединять различные виды рендеринга на GPU к классам и комбинировать их аналогичным образом.


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


Хотя стандартный комбинатор методов и примеси предоставляют много гибкости сами по себе, следует проявлять осторожность при разработке общей иерархии классов и протокола функций. В противном случае различные виды поведения не будут оптимально сочетаться, что приведёт к странным багам, или вовсе не будут сочетаться верно.


Условия, обработчики и перезапуски


Отладка, безусловно, является важным аспектом программирования, но она становится особенно актуальной, когда система может быть переопределена и изменена во время выполнения. К счастью, Common Lisp предлагает невероятно мощную систему для работы с ошибками времени выполнения. Эта система состоит из трёх частей, из которых только одна обычно присутствует в других языках.


Первая часть называется "условия", или, как это более принято в других языках, "исключения". Условия "сигнализируются" (кидаются) и могут быть "обработаны" (перехвачены) кодом, находящимся ниже по стеку. Когда условие обрабатывается стандартным обработчиком, стек раскручивается до места обработчика, и вызывается соответствующая функция для разрешения проблемы. В контексте Common Lisp доступны два типа условий: предупреждения и ошибки. Если предупреждение сигнализируется и не обрабатывается дальше по стеку, оно просто исчезает, и выполнение продолжается с места сигнализирования. Если ошибка остаётся необработанной, вызывается динамический отладчик, и поток, в котором произошло сигнализирование, приостанавливается. И здесь проявляется уникальная возможность.


Перезапуски на первый взгляд похожи на условия и обработчики. Когда перезапуск установлен через restart-case, он выставляет точку раскрутки стека для восстановления с указанным именем и функцией, которая будет вызвана при активации этого перезапуска. Перезапуски могут быть программно активированы с помощью функции invoke-restart, которая также может передавать аргументы для функции восстановления. Идея перезапусков состоит в предоставлении одного или нескольких способов продолжения выполнения или безопасного восстановления после ошибки. Когда отладчик вызывается вследствие необработанной ошибки, у него есть доступ к списку активных перезапусков на стеке, и возможность динамически вызвать один из них по усмотрению пользователя.


Это означает, что в отличие от традиционных языков, в которых ошибка вызывает трассировку стека и затем попытку продолжения либо попросту аварийный выход, в Common Lisp выполнение останавливается в ожидании, пока вы проанализируете и, возможно, исправите ошибку. Как только вы поймёте, как поступить дальше или решите проблему, вы сможете выбрать один из нескольких способов продолжить выполнение после ошибки. Таким образом, программа продолжает работу всё время, и вам не нужно волноваться о долгой перекомпиляции или инициализации для того, чтобы воспроизвести ошибку. Вы можете сразу же продолжить с того места, где остановились.


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


Наконец, последний фрагмент головоломки — существование handler-bind. Это вид обработчика для условий, но в отличие от обычных обработчиков, которые вызывают раскрутку стека, эти обработчики вызываются на верхушке стека, где условие было просигнализировано. Таким образом, они имеют полный доступ к динамическому окружению в месте ошибки. Фактически, таким же образом вызывается отладчик верхнего уровня. Однако с помощью handler-bind вы можете автоматизировать разрешение ошибки и избежать нужды в пользовательском ввводе.


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


(defmethod render ((scene scene))
  (for ((object in scene))
    (restart-case
        (render object)
      (continue ()))))

(defmethod render ((main main))
  (handler-bind ((error (lambda (condition)
                          (invoke-restart 'continue))))
    (render (scene main))))

В примере выше показаны два простых метода: первый отображает scene, для чего просто проходит по всем объектам сцены и устанавливает перезапуск continue вокруг вызова render на объекте. Когда этот перезапуск вызывается, стек раскручивается до точки внутри for и затем просто продолжает работу со следующим объектом.


Если теперь мы вызовем render напрямую на scene и произойдёт ошибка, как обычно, появится отладчик. Но на этот раз мы сможем вызвать из отладчика перезапуск continue, чтобы пропустить отображение объекта. Мы также при желании могли бы определить перезапуск retry, чтобы просто повторить попытку отображения объекта.


Тем не менее важно отметить, что для метода render у main этот перезапуск вызывается автоматически, чем гарантирует, что любые возможные сбои во время выполнения игры будут безопасно игнорироваться. Этот обработчик также может быть определён выше по стеку, или вызывать перезапуск только при определённых условиях. Это возможно по той причине, что lambda из handler-bind вызывается в месте возникновения ошибки на верхушке стека, где виден перезапуск continue.


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


Оптимизация


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


Для решения этих проблем Common Lisp включает ряд инструментов в виде "гарантий для компилятора". Вы можете использовать декларации для гарантии компилятору, что значение переменной всегда будет определённого типа. Благодаря этой гарантии компилятор может осуществить вывод типов и устранить диспетчеризацию времени выполнения. SBCL бо́льшую часть времени неплохо выводит типы самостоятельно в целях устранения диспетчеризации, но для высокооптимизированных функций ручные объявления типов всё равно могут быть очень полезными.


Кроме того, Common Lisp предлагает несколько деклараций для тонкой настройки компилятора. Например, установка настройки speed предлагает компилятору попытаться максимально оптимизировать код. Компилятор SBCL в этом случае будет также выводить предупреждения о случаях, когда ему недостаёт информации для устранения диспетчеризации или когда требуется выделение памяти на куче. Используя данную информацию, программист может отрефакторить код или добавить дополнительные гарантии, чтобы обеспечить генерацию эффективного кода.


Ещё одна настройка, safety, в случае, когда установлена в минимальное значение, говорит компилятору избегать вставки проверок безопасности, таких, как проверки типов времени выполнения, проверки границ массивов и другие, обычно помогающие обнаруживать ошибки. Само собой, это рискованная опция, поскольку генерируемый код становится таким же небезопасным, как код на C или ассемблере, и неправильное использование функций, определённых с такой опцией, может привести к повреждению памяти.


Вы также можете объявить тип функции, чтобы гарантировать, что компилятор сможет выввести типы для возвращаемого значения и аргументов в рамках других функций. Режим блочной компиляции SBCL, будучи активированным, также позволяет проводить вывод типов за пределами функции внутри того же блока компиляции.


Наконец, вы также можете объявить функции как inline, позволяя компилятору встраивать их определение в местах их вызова. Эта декларация помогает с выводом типов и устраненением накладных расходов на вызов, но ограничивает динамичность, так как изменение встраиваемой функции требует повторной компиляции мест её вызова.


При использовании SBCL существуют и другие потенциальные оптимизации. SBCL предоставляет полный доступ к своим возможностям оптимизации кода, и даже к ассемблерным процедурам. Приложив достаточно усилий, можно оптимизировать код на уровне ассемблера, и даже генерировать специальные ассемблерные инструкции при необходимости. Некоторые библиотеки уже используют эти возможности для повышения производительности, но мы не касались этой темы из-за недостатка времени. Тем не менее, мы используем функцию disassemble для проверки сгенерированного ассемблерного кода функций, в качестве дополнительного средства оптимизации. Мы также используем встроенный статистический профилировщик для наблюдения за работой игры и определения узких мест.


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


Такую проблему можно побороть традиционными методами — упрощением сигнатур методов, разделением обобщённых функций или преобразованием их в статически вызываемые функции, которые могут быть объявлены inline для получения преимуществ от вывода типов. Мы также исследуем альтернативный метод диспетчеризации, описанный Робертом Страндом, который должен значительно улучшить время на диспетчеризацию обобщённых функций, и, возможно, даже превзойти традиционные подходы на основе таблиц виртуальных методов, используемые в C++ или Java.


Хотя все эти возможности означают, что с использованием SBCL можно написать быстрый код, по умолчанию всё же безопасность предпочитается производительности, что мы считаем положительной чертой. Полная оптимизация кода до уровня C, C++ или ассемблера требует значительной, но не непреодолимой работы.


Сборка мусора


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


Мы используем умолчальный сборщик мусора SBCL, который является поколенческим, компактифицирующим однопоточным сборщиком с остановкой мира. Мы надеемся, что в SBCL однажды будет реализован более эффективный сборщик мусора, такой, как, например, Memory Pool System, но пока мы сосредоточены на минимизации создания мусора в нашем коде, чтобы избежать частых и долгих сборок.


прим. перев.

Через два года после выхода данной статьи, на European Lisp Symposium 2023 была представлена работа по реализации высокоэффективного параллельного сборщика мусора в SBCL.


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


Например, мы можем попросить компилятор выделить некоторые объекты на стеке, используя декларацию вида (declare (dynamic-extent x)). Однако это работает только в том случае, когда компилятор может заранее знать размер объекта, из чего следует, что на стеке могут быть размещены только массивы фиксированного размера и известные структуры. Кроме того, размер стека довольно сильно ограничен, поэтому большие аллокации всё равно должны происходить на куче.


В случаях, когда объект является экземпляром класса, просто слишком большим для стека, или должен покидать стек, мы всё равно можем легко закэшировать экземпляр локально с помощью load-time-value. load-time-value — это особая форма, которая заставляет помещённую внутрь неё форму выполняться при первой загрузке кода в систему, а затем помещает результат выполнения этой формы в место в коде, где находится load-time-value. Другими словами, создаётся анонимная глобальная переменная, в которой хранится объект, который затем помещается вместо оригинального load-time-value.


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


Также полезно использовать load-time-value в случаях, когда объекты считаются иммутабельными, а параметры их создания известны на этапе компиляции. В этом случае мы можем определить т.н. "макрос компилятора" — макрос, выполняемый для замены вызова конкретной функции. Макросы компилятора могут анализировать аргументы функции на этапе компиляции и выбрать замену вызова на форму load-time-value, если все аргументы опеределены статически. Такая замена позволяет избежать создания и аллокации объекта, или даже вызова функции во время выполнения, полностью прозрачным для пользователя образом.


В Alloy, нашем наборе инструментов UI, мы широко используем описанную методику, чтобы избежать аллокации значений цвета, размеров и единиц измерения.


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


Заключение


Выразительность Common Lisp Object System повзволяет быстро изменять поведение игровых объектов, а также создавать повторно используемые блоки поведения, которые часто можно без проблем комбинировать вместе. Благодаря расширениям Meta Object Protocol, эта гибкость может быть расширена даже на процедуры отрисовки графики и шейдеры.


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


прим. перев.

Аналогичные возможности интерактивной разработки существуют и в некоторых других диалектах Lisp, что также делает их более удобными в разработке игр; см. мою заметку по теме.


Хотя эти возможности предоставляют большу́ю гибкость, приходится платить за неё ценой производительности. Многие из возникающих проблем с производительностью можно избежать или минимизировать с помощью тщательного планирования и проектирования, но зачастую при этом теряется какая-то часть динамичности. Кроме того, такие оптимизации также связаны с бо́льшими затратами на разработку.


Мы считаем, что раз такие оптимизации могут быть выполнены постепенно с течением времени и нет ограничений по возможностям оптимизации на фундаментальном уровне, Common Lisp является отличным выбором для разработки игр.


Хоть экосистема Common Lisp содержит массу тонкостей, мы надеемся, что данная статья даст некоторое представление о преимуществах и трудностях, с которыми сталкиваются разработчики на этом великолепном языке.


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


Хотелось бы поблагодарить Роберта Странда, Флориана Ханке, Тобиаса Мансфилда-Уильямса и Сельвина Симсека за их обратную связь по статье.


прим. перев.

Если статья вдохновила вас на более тесное знакомство с Common Lisp, вот уже в эту пятницу, 26.05.2023, начнётся проходящий дважды в год на площадке для инди-игр itch.io Lisp Game Jam, этакий хакатон длиной в неделю, на котором можно попробовать свои силы в геймдеве и получить обратную связь от коллег. Приглашаю вас присоединиться, а чтобы было проще начать, я сделал cookiecutter-шаблон для игрового проекта на Common Lisp. Не стесняйтесь задавать вопросы, а также подписываться на мой авторский telegram-канал, посвящённый геймдеву на лиспах ????

Комментарии (6)


  1. dyadyaSerezha
    25.05.2023 01:06

    Все это прекрасно, но существует ли хоть одна коммерчески успешная игра, полностью написанная на CLOS? Или хотя бы сравнимая по скорости графики и общему геймплею с классическими играми на движках? Если нет, то это "чистое фуфло", то есть, чистая теория и не более.


    1. prefrontalCortex Автор
      25.05.2023 01:06
      +1

      Навскидку Kandria, разработанная теми же людьми из Shirakumo games. Я, конечно, свечку не держал, но господин Хафнер через пару дней после релиза как-то засветил в одной из соцсетей скриншот админки стима, где в графе "revenue" значилась сумма где-то в районе $26k. Со скоростью там тоже всё прекрасно, можете заценить по бесплатной дёмке. Ну и по исходникам игры видно, что CLOS в ней используется в хвост и в гриву.


      1. Ipashelovo
        25.05.2023 01:06
        +1

        Крайне маленькая сумма для успешного проекта даже для первых дней (в них же происходят основные покупки). Я, правда, не считаю, что успех надо сводить к деньгам, но пример все равно неудачный. По сути месяц-два для маленькой команды разработки. Что сложно назвать успехом


    1. Zara6502
      25.05.2023 01:06
      +1

      Мне кажется тут вопрос не столько в языках, сколько в совпадении двух составляющих: "знаю язык Х" + "хочу написать игру". Коммерческая успешность тут не показатель. Кстати если уж смотреть на игровые проекты через призму языков, то наверное весьма популярным будет C#.


    1. sofa3376
      25.05.2023 01:06
      +1

      Вроде как для TLOU использовался некий Game Oriented Assembly Lisp для привлечения всяких дизайнеров к разработке


      1. prefrontalCortex Автор
        25.05.2023 01:06

        Всё так, этот же GOAL, реализованный на Allegro Common Lisp, и в Jak and Daxter использовался. Его предшественником был проект с похожим названием Game Oriented Object LISP, применявшийся для создания Crash Bandicoot.