Идея language oriented programming (LOP), состоит в том, что во время разработки программы, постоянно создаются миниязыки. Они могут как расширять основной язык разработки, так и быть отдельными языками. Лучшим языком для LOP является Common Lisp с его макросами, но здесь речь пойдёт не о нём. Примеры использования LOP с Common Lisp советую посмотреть в замечательной книге Peter Seibel Practical Common Lisp. Я считаю, что LOP один из самых простых и эффективных способов программирования. Мы описываем задачу и предметную область на самом подходящем для этого языке, а потом стараемся его реализовать.
Я разрабатываю браузерные игры на Ruby, поэтому часто использую LOP, как для расширения языка и встроенных DSL (Ruby позволяет делать это очень хорошо), так и для создания миниязыков связанных со сложной игровой механикой. В этой статье я рассмотрю простое расширение основного языка, встроенный мини-DSL и два не встроенных языка. Буду приводить примеры в близкой мне тематике, надеюсь они будут вполне понятны.
Статью написал хабраюзер CrazyPit, но ему не хватает кармы для опубликования.
Мне часто необходимы классы, которые выполняют определё��ный алгоритм, работая над объектом. Конечно можно все методы занести в основной класс, но это будет излишнее засорение. К тому же, алгоритм может использовать несколько методов и исключений, которые совершенно не нужны в основном классе.
У игрока есть несколько им составленных дек (набора карт, которыми игрок использует в коллекционной карточной игре). Нужно выбрать одну из дек — активировать. Для этого я создаю отдельный класс, реализующий алгоритм активации.
Можно этот модуль использовать так:
Но гораздо красивее:
Для этого нужно добавить метод в класс Deck:
Но поскольку таких алгоритмов достаточно много, я сделал небольшое улучшение, это класс-метод strategy (не имеет прямого отношение к одноимённому паттерну). Теперь я делаю так:
или:
Имя класса алгоритма по умолчанию это <имя класса где вызывается метод>::<имя задаваемое в strategy>. Но может быть задано вручную (:class => BrainDestructor).
Таких расширений много, как в самом руби, так и в RoR. Думаю каждый, кто много программировал на Ruby, делал что-то подобное.
В игре есть различные комнаты, куда пускают не всех, а только если игрок соответствует определённым правилам. Например его уровень больше 10, или кол-во карт в деке не больше 8. Такие ограничению комбинируются. Есть виды ограничений, например «Уровень игрока >= N» и есть конкретные ограничения «Уровень игрока >= 13».
Для задания видов ограничений можно использовать DSL define_constraint, и потом хранить ограничение и их комбинации в базе данных.
В каждом виде ограничение мы задаём его имя (deck_sum_between), описание «Сумма уровней карт между %N и %M», из которого потом, на основание параметров, получается описание конкретного ограничения. И конечно реализацию ограничения, которая должна возвращать true, если игрок или другой объект подходит под ограничение. Система универсальна поэтому не user а context.
В итоге ограничения можно записать так deck_sum_between(N=>10, M=> 20) или хранить имя и параметры в разных свойствах объекта.
В геймдеве часто необходимо, чтобы какие то правила, на основе которых формируется алгоритм, хранились динамически, например в БД, таким образом игровые алгоритмы можно поменять буквально из формы-админки. Этот и следующий пример описывает два таких языка.
Иногда нам нужны не простые ограничения, которые можно реализовать просто списком базовых ограничений, а более сложное логическое выражение.
Например: уровень игрока > 10 И размер деки <= 8 карт И (карты игроки из кланов 1,2,3 ИЛИ карты игрока из кланов 4,5,).
Создан язык выражений, которое это задаёт (тут использован слегка другой вид базовых ограничений, чем в предыдущем разделе):
Я использовал немного lisp-style чтобы было удобнее со списками ограничений включающих большой список И.
Далее сохраняем это строчку в модели Room (room.restrictions_string). Во время, когда нам нужно вычислить ограничение парсим строку, вычисляем все базовые ограничения а также общий результат и отдаём клиенту. Игрок видит, необходимые условия и какие из них он не прошёл.
Бустер — это продаваемый набор карт в коллекционной карточной игре, в которой входят несколько случайных карт по определённым правилам. Например 5 средних карт жителей болот и одна хорошая.
Каждое из правил генерации карт можно описать текстом:
rarity(1) — плохая карта (кол-во задаётся вне правила, об этом позже)
rarity(1)|clans(6,7,8) — одна плохая карта из кланов 6,7,8. "|" здесь символизирует конвейер unix, а не логическое или.
Также возможны правила с вероятностью:
clans(1,2,3)|expectance(1,60,2,38,3,2) — карты из клана 1, 2 или 3; с вероятностью 60% — плохая, с вероятностью 38% — средняя, с вероятностью 3% — хорошая.
Каждое правило реализуются на основе ActiveRecord механизма scope примерно так:
Кстати, здесь тоже можно было расширить язык и сделать описание чуть более лаконичным.
Правил объединяются reduce:
В итоге у нас получается один запрос на одну карту, в не зависимости от сложности правила.
Остаётся вопрос как генерировать скажем 5 карт по одному правилу и 2 по другому. Есть несколько вариантов, я использовал такое класс Booster has_many generators. В объекте класса Generator храниться кол-во карт и правило, по которому каждая из карт генерируются. Но можно усложнить базовый язык и записывать все правила бустера одной строкой:
Я привёл примеры использования LOP в повседневной практике. Многие используют DSL даже не подозревая об этом (XML-задание интерфейсов например). Но создают свои DSL только небольшое кол-во разработчиков. Надеюсь эта статья подтолкнёт вас к подробному изучению вопроса.
Я разрабатываю браузерные игры на Ruby, поэтому часто использую LOP, как для расширения языка и встроенных DSL (Ruby позволяет делать это очень хорошо), так и для создания миниязыков связанных со сложной игровой механикой. В этой статье я рассмотрю простое расширение основного языка, встроенный мини-DSL и два не встроенных языка. Буду приводить примеры в близкой мне тематике, надеюсь они будут вполне понятны.
Статью написал хабраюзер CrazyPit, но ему не хватает кармы для опубликования.
Простое расширение языка
Мне часто необходимы классы, которые выполняют определё��ный алгоритм, работая над объектом. Конечно можно все методы занести в основной класс, но это будет излишнее засорение. К тому же, алгоритм может использовать несколько методов и исключений, которые совершенно не нужны в основном классе.
У игрока есть несколько им составленных дек (набора карт, которыми игрок использует в коллекционной карточной игре). Нужно выбрать одну из дек — активировать. Для этого я создаю отдельный класс, реализующий алгоритм активации.
Deck::Activator = Struct.new(:deck)
class Deck::Activator
def activate!
......
end
private
<some helper methods and exceptions>
end
Можно этот модуль использовать так:
Deck::Activator.new(some_deck).activate!
Но гораздо красивее:
deck.activator.activate! # или вот так deck.activate!
Для этого нужно добавить метод в класс Deck:
class Deck
def activate!
Deck::Activator.new(some_deck).activate!
end
end
Но поскольку таких алгоритмов достаточно много, я сделал небольшое улучшение, это класс-метод strategy (не имеет прямого отношение к одноимённому паттерну). Теперь я делаю так:
class Deck < ActiveRecord::Base strategy :activator end Deck.find(:first).activator.activate!
или:
class Deck < ActiveRecord::Base strategy :activator, :delegate => [:activate!] end Deck.find(:first).activate!
Имя класса алгоритма по умолчанию это <имя класса где вызывается метод>::<имя задаваемое в strategy>. Но может быть задано вручную (:class => BrainDestructor).
Таких расширений много, как в самом руби, так и в RoR. Думаю каждый, кто много программировал на Ruby, делал что-то подобное.
Встроенный DSL для обозначения ограничений
В игре есть различные комнаты, куда пускают не всех, а только если игрок соответствует определённым правилам. Например его уровень больше 10, или кол-во карт в деке не больше 8. Такие ограничению комбинируются. Есть виды ограничений, например «Уровень игрока >= N» и есть конкретные ограничения «Уровень игрока >= 13».
Для задания видов ограничений можно использовать DSL define_constraint, и потом хранить ограничение и их комбинации в базе данных.
define_constraint "deck_sum_between", "Сумма уровней карт между %N и %M" do
(arguments['N'].to_i..arguments['M'].to_i).include?(context.deck.sum_of_card_levels)
end
define_constraint "deck_without_duplicates", "Дека без дублей" do
!context.deck.has_duplicates?
end
define_constraint "user_level_ge", "Уровень игрока %X или выше" do
context.level >= arguments['X'].to_i
end
В каждом виде ограничение мы задаём его имя (deck_sum_between), описание «Сумма уровней карт между %N и %M», из которого потом, на основание параметров, получается описание конкретного ограничения. И конечно реализацию ограничения, которая должна возвращать true, если игрок или другой объект подходит под ограничение. Система универсальна поэтому не user а context.
В итоге ограничения можно записать так deck_sum_between(N=>10, M=> 20) или хранить имя и параметры в разных свойствах объекта.
Язык логическое выражение из ограничений
В геймдеве часто необходимо, чтобы какие то правила, на основе которых формируется алгоритм, хранились динамически, например в БД, таким образом игровые алгоритмы можно поменять буквально из формы-админки. Этот и следующий пример описывает два таких языка.
Иногда нам нужны не простые ограничения, которые можно реализовать просто списком базовых ограничений, а более сложное логическое выражение.
Например: уровень игрока > 10 И размер деки <= 8 карт И (карты игроки из кланов 1,2,3 ИЛИ карты игрока из кланов 4,5,).
Создан язык выражений, которое это задаёт (тут использован слегка другой вид базовых ограничений, чем в предыдущем разделе):
(AND user_level_ge(12)
deck_size_le(8)
(OR deck_has_only_clans(1,2,3)
deck_has_only_clans(4,5,6)))
Я использовал немного lisp-style чтобы было удобнее со списками ограничений включающих большой список И.
Далее сохраняем это строчку в модели Room (room.restrictions_string). Во время, когда нам нужно вычислить ограничение парсим строку, вычисляем все базовые ограничения а также общий результат и отдаём клиенту. Игрок видит, необходимые условия и какие из них он не прошёл.
Язык описания правил бустера
Бустер — это продаваемый набор карт в коллекционной карточной игре, в которой входят несколько случайных карт по определённым правилам. Например 5 средних карт жителей болот и одна хорошая.
Каждое из правил генерации карт можно описать текстом:
rarity(1) — плохая карта (кол-во задаётся вне правила, об этом позже)
rarity(1)|clans(6,7,8) — одна плохая карта из кланов 6,7,8. "|" здесь символизирует конвейер unix, а не логическое или.
Также возможны правила с вероятностью:
clans(1,2,3)|expectance(1,60,2,38,3,2) — карты из клана 1, 2 или 3; с вероятностью 60% — плохая, с вероятностью 38% — средняя, с вероятностью 3% — хорошая.
Каждое правило реализуются на основе ActiveRecord механизма scope примерно так:
def rule_clans(scope, ids)
scope.scoped_by_clan_id(ids)
end
def rule_expectance(scope, params)
scope.scoped_by_rarity(Expectance.for_expectance(Hash[*params.map(&:to_i)]))
end
Кстати, здесь тоже можно было расширить язык и сделать описание чуть более лаконичным.
Правил объединяются reduce:
def generate_card_original
rules_scope = rules.reduce(Card::Original) do |scope, rule|
rule.add_scope(scope)
end
rules_scope.randomly.find(:first)
end
В итоге у нас получается один запрос на одну карту, в не зависимости от сложности правила.
Остаётся вопрос как генерировать скажем 5 карт по одному правилу и 2 по другому. Есть несколько вариантов, я использовал такое класс Booster has_many generators. В объекте класса Generator храниться кол-во карт и правило, по которому каждая из карт генерируются. Но можно усложнить базовый язык и записывать все правила бустера одной строкой:
5[rarity(1,2)], 2[clans(1,2)|expectance(1,60,2,40)]
Заключение
Я привёл примеры использования LOP в повседневной практике. Многие используют DSL даже не подозревая об этом (XML-задание интерфейсов например). Но создают свои DSL только небольшое кол-во разработчиков. Надеюсь эта статья подтолкнёт вас к подробному изучению вопроса.