Я прочитал замечательную статью за авторством @rplacroixи загорелся идеей воплотить механики окружающей среды, подобные линейке игр Divinity: разлитую нефть можно поджечь, огонь можно потушить водой, а яд неожиданно взрывается от огня. Здесь я буду больше обращать внимание на реализацию в коде, чем на достоверную копию механик из игр Divinity. Я покажу некоторые кусочки кода с пояснениями, а в конце будет небольшая демонстрация прототипа игры с этой системой.
Сегодня я программирую на Raku. Raku — это молодой язык с длинной историей, сестринский язык к языку Perl. Я хочу продемонстрировать самые сильные стороны этого языка в контексте прототипирования игры и частично сравнить их с оригинальной статьей, языком имплементации которой был выбран Python. В течение статьи я буду оставлять раскрывающиеся блоки с объяснением тех или иных особенностей языка Raku, если вам интересно.
Концепты
Для начала концептуально определим, какие движущиеся части будут присутствовать. Для самой системы окружающей среды я нашел три составные части:
-
Элементы — суть воздействия. Например, элемент воды, воздействуя на землю, просто наполнит ее лужей, а воздействуя на игрока, сделает его одежду мокрой. Из менее очевидных, благословение и проклятие тоже определены как элементы.
-
Эффекты — действующие статусы у игрока. Например, попав в воду, игрок получает статус «мокрый», а обжегшись огнем, он получит статус «горящий».
-
Эффекты окружающей среды — реакции во внешнем мире, которые затрагивают какую-то площадь, а не одну клетку. Например, взрыв или распространение огня из-за разлитой нефти.
Мир Divinity разделен на клетки, на которых может находиться какой-то объект, например, бочка; может быть какое-то существо, например, сам игрок; и на этой клетке может быть что-то разлито, например, нефть; либо может что-то парить в воздухе, например, пар. Это очень удобная система для реализации взаимодействия окружающей среды, потому что не нужно симулировать полную физику и, например, высчитывать, насколько сильно разольется лужа воды, исходя из объема пролитой воды и рельефа поверхности.
Также стоит учитывать, что игра проходит в пошаговом режиме. В игре есть концепты «очков действий» и «очков передвижения», адаптированных из настольных ролевых игр. При движении и действиях эти очки тратятся, и нужно завершить ход, чтобы их восстановить.
Код
Составные окружающей среды
Так представлены элементы:
class Element is export {} class FireElement is Element is export {} class WaterElement is Element is export {} class IceElement is Element is export {} class PoisonElement is Element is export {} class OilElement is Element is export {} class ElectricityElement is Element is export {} class BlessElement is Element is export {} class CurseElement is Element is export {}
Это родительский класс Element
и наследуемые классы конкретных элементов. В данном случае мне наследование пока не нужно, поэтому можно сделать через простую нумерацию, используя enum
. Я сделал классы, чтобы иметь возможность добавить свойства, если они вдруг потребуются. На данной стадии прототипа я пока не знаю, какое представление данных будет самым кратким, поэтому использую классы, потому что их легко расширить.
Так представлены эффекты, со значением длительности (duration
):
class Effect is export { has Int $.duration is rw = 1; } class WetEffect is Effect is export {} class BurningEffect is Effect is export {} class ChilledEffect is Effect is export {} class WarmEffect is Effect is export {} class FrozenEffect is Effect is export {} class MagicArmorEffect is Effect is export {} class PoisonedEffect is Effect is export {}
Разберем строку со свойством duration
-
has
— объявление свойства объекта. -
Int
— опциональная типизация, здесь можно только целые числа. -
$
— это скалярная величина, а не массив или словарь. -
.
— создаем публичные методы для доступа извне. -
duration
— название свойства. -
is rw
— свойство можно менять извне, а не только читать. -
= 1
— по умолчанию значение 1. -
;
— наверное, вы уже догадались. В конце выражений ставится точка с запятой, с некоторыми исключениями вроде закрывающейся фигурной скобки.
Raku очень выразителен!
И, наконец, эффекты окружающей среды, пока что только эффект взрыва:
class EnvironmentEffect is export {} class ExplosionEnvironmentEffect is EnvironmentEffect is export {}
Как клетки поля взаимодействуют с окружающей средой
Сами клетки представлены классом Cell
(опущены методы отрисовки и взаимодействие с облаками):
class Cell does OnBoard { has Surface:D $.surface is rw = EmptySurface.instance; has Cloud:D $.cloud is rw = EmptyCloud.instance; has Object $.object is rw; method apply (Element:D $e, World:D $w) { for $!surface.apply: $e { when Element { self.apply($_, $w) } when StateChange { self.apply-state-change($_) } when EnvironmentEffect { $w.apply-environment-effect($_, self) } } } method apply-state-change (StateChange:D $sc) { $!surface = $sc.to-surface; $!cloud = $sc.to-cloud; } }
Что тут происходит
-
does OnBoard
— это миксин, расширение, мы примешивает дополнительный функционал, при этом не создавая иерархию наследования. -
Surface:D
— это опциональная типизация: название классаSurface
и суффикс:D
. В Raku просто типSurface
может принимать как сам объект классаSurface
, так и его экземплярSurface.new
. Суффикс:D
разрешает только экземпляр класса. -
$!surface.apply: $e
— это обращение к объявленному выше свойству$.surface
, вызов методаapply
и передача аргумента$e
. Вы уже, наверное, заметили, почти все переменные в Raku требуют «сигил», символьную приставку (часто знак доллара$
), для обращения к ним. -
for
— цикл, который проходится по всем элементам массива, который возвращается из$!surface.apply: $e
. -
when Element
— сравниваем каждый элемент массива изfor
на принадлежность классуElement
. -
self.apply($_, $w)
—self.apply
вызывает методapply
на себя же (здесь он определен в дочернем классе). То же самое можно записать формой выше:self.apply: $_, $w
. Просто тут со скобками «лучше подходит», я художник, я так вижу. -
$_
— это «переменная темы». Каждый раз, когда у нас есть «текущая» переменная, будь то проход по массиву с помощьюfor
или сравнение с помощьюwhen
, эта переменная записывается в$_
. На самом деле само выражениеwhen
уже использует эту переменную, оно сравнивает переменную темы, которая была назначена в циклеfor
, с классомElement
.
Я потратил две недели, чтобы просто привыкнуть к синтаксису Raku. Дальше — проще.
Каждая клетка может «применить» (apply
) на себя элемент, и в зависимости от результата мы либо получим еще один элемент, и применим его снова, либо поменяем поверхность или облако клетки (разлитая лужа выпарится от огня и станет облаком пара), либо вызовем какой-то эффект окружения (взрыв!). Далее эта клетка во-первых может иметь какой-то объект, как условную бочку, так и игрока или противника, а во-вторых она является родительским классом для типов клетки, например, «пол», «стена», «дверь» и так далее. Здесь я выбрал разделение по классам, так как скорее всего будет сильно меняться поведение на этих разных типах клеток.
Поверхность клетки представлена родительским классом Surface
:
class Surface is export { has Numeric $.duration is rw = ∞; submethod new { ... } method draw { ... } method time-out { StateChange.surface(EmptySurface) } proto method apply (Element) { * } multi method apply (Element $e) { die "Calling {self.^name}#apply($e)"; } }
Немного синтаксиса
-
∞
— бесконечность! ASCII альтернативаInf
. Raku имеет какое-то множество переменных и операторов за пределами ASCII из Юникода. -
...
— обычно так обозначается метод, который должен быть переопределен в дочернем классе или который не должен быть вызван. Если вызовется этот метод, то вылетит ошибка. -
submethod
— приватный метод, недоступный дочерним классам. Мы запрещаем создание экземпляра родительского класса. -
proto method apply (Element) { * }
— определяем прототип для будущих мульти-методов. -
multi method apply (Element $e)
— специализация на родительском классеElement
«по умолчанию», чтобы все дочерные элементы обязательно переприсвоили. -
die
— добровольная смерть программы, вылетает с указанным сообщением.
Пару слов о мульти-методах: можно определять сколько угодно методов с одинаковым названием, Raku выберет из них мульти-метод с самым строгим ограничением по параметрам, включая количество и типы параметров.
И далее мы можем определять дочерние классы с разными типами поверхностей, например, поверхность, где горит огонь:
class FireSurface is Surface is export { has Numeric $.duration is rw = 3; method draw { "f" } method time-out { StateChange.cloud(SmokeCloud.new) } multi method apply (WaterElement) { StateChange.cloud(SteamCloud.new) } multi method apply (IceElement) { StateChange.cloud(SteamCloud.new) } multi method apply (PoisonElement) { [StateChange.empty, ExplosionEnvironmentEffect.new] } multi method apply (FireElement) {} }
Определяем длительность как 3 раунда, после которых огонь прогорит и останется только дым (метод time-out
). И заодно взаимодействие с другими элементами: вода (WaterElement
) или лед (IceElement
) его затушит и будет облако пара, а яд (PoisonElement
) во-первых сделает поверхность чистой, а во вторых вызовет взрыв. Для каждого следующего типа поверхности мы просто добавим еще один класс, система очень удобна в расширении.
Как игрок взаимодействует с окружающей средой
Здесь, говоря «игрок», подразумевается любое живое и неживое существо, так как они все принадлежат родительскому классу Creature
(опущены отрисовка и обращение с очками движения и действий):
class Creature is Object does EffectsOnCreature does Movable { has Int $.health; has Int $.move-points; has Int $.action-points; has Int $!max-health; has Int $!max-move-points; has Int $!max-action-points; submethod TWEAK (:$health, :$move-points, :$action-points) { $!max-health = $health; $!max-move-points = $move-points; $!max-action-points = $action-points; } proto method damage (Int :$) { * } multi method damage (:$fire!) { if self.find-effect(MagicArmorEffect) { say "Magic armor blocks $fire fire damage!"; return; } say "Getting $fire fire damage!"; $!health -= $fire; } multi method damage (:$poison!) { say "Getting poisoned by $poison points!"; $!health -= $poison; } multi method damage (:$blast!) { say "Getting $blast points of damage from a blast!"; $!health -= $blast; } }
Пара моментов
Здесь есть приватные свойства для класса, такие как $!max-health
, которые нужно инициализировать, что и делает метод TWEAK
, он вызывается сразу после создания экземпляра класса.
Синтаксис :$health
в параметрах метода означает именованный параметр, мы его можем передать как health => 10
или :health(10)
— обе формы равнозначны. А восклицательный знак в конце делает параметр обязательным: :$fire!
.
Существо имеет запасы очков движения и действий, и также очки здоровья. По этим очкам здоровья можно получить урон разного типа, при этом огенный урон можно заблокировать магической броней.
Этот класс Creature
имеет расширение EffectsOnCreature
, которое определяет, как игрок страдает от уже приобретенных эффектов, и как он взаимодействует с окружающей средой. По порядку, сначала существующие эффекты:
role EffectsOnCreature is export { proto method apply-effect (Effect:D) { * } multi method apply-effect (BurningEffect:D) { self.damage: :3fire } multi method apply-effect (PoisonedEffect:D) { self.damage: :3poison } multi method apply-effect (WarmEffect:D) {} multi method apply-effect (WetEffect:D) {} multi method apply-effect (ChilledEffect:D) {} multi method apply-effect (FrozenEffect:D) { self.exhaust-move-points } multi method apply-effect (MagicArmorEffect:D) {} # ... }
Что за :3fire
self.damage:
— это вызов метода damage
на самого себя. А :3fire
это способ передачи именованной переменной, еще один вариант, равносильный fire => 10
или :fire(10)
.
Иногда мне становится страшно от разнообразия, но когда привыкаешь, то как творец своего кода выбираешь самый подходящий вариант. Если это, например, опенсорс проект с количеством контрибьюторов ровно один человек (большинство), то не такая уж большая разница, если у остальных людей будет другое чувство прекрасного.
Когда игрок горит (BurningEffect
) или отравлен (PoisonedEffect
), начиная свой ход, он получит три урона огнем и ядом соответственно. Если же игрок заморожен (FrozenEffect
), то он теряет все свои очки передвижения. Во всех остальных случаях в конце хода ничего не происходит, но эти эффекты могут комбинироваться с другими воздействями! Например, эффект магической брони (MagicArmorEffect
) защищает от урона огнем.
И теперь влияние поверхности клетки, начнем с горящей клетки (FireSurface
):
role EffectsOnCreature is export { has Effect:D @.effects = []; multi method effect-from-surface (FireSurface:D $s) { self.add-effect: BurningEffect.new(:3duration); self.remove-effect: WetEffect; self.damage: :3fire; self.comment-on-effect: BurningEffect; self.exhaust-move-points: 10; } }
Сигил @
Ранее всегда был сигил $
, куда он подевался у переменной @.effects
? Все дело в том, что сигил $
это не просто обращение к переменной, а обращение к переменной как к скалярной величине, чему-то «единственному». Так, даже к массиву можно обращаться как к «единственной» величине, в таком случае этот массив считается черным ящиком, и мы, например, не можем пройтись по его элементам с помощью for
. На помощь приходит сигил @
, который обозначает, что переменная содержит «порядковую» величину, где есть несколько элементов, идущих один за другим.
Когда игрок оказывается на горящей клетке, он приобретает эффект горения на три хода (BurningEffect.new(:3duration)
), перестает быть мокрым (WetEffect
), получает три очка урона огнем, о чем-то ругается, и теряет часть очков движения.
Более сложная ситуация, когда игрок находится на клетке с водой:
role EffectsOnCreature is export { has Effect:D @.effects = []; multi method effect-from-surface (WaterSurface:D $s) { with self.find-effect(BurningEffect) { self.remove-effect: BurningEffect; self.add-effect: WarmEffect.new; self.comment-on-effect: WarmEffect; } orwith self.find-effect(ChilledEffect) { self.remove-effect: ChilledEffect; self.add-effect: FrozenEffect.new(:2duration); self.comment-on-effect: ChilledEffect; } else { self.add-effect: WetEffect.new(:3duration); self.comment-on-effect: WetEffect; } } }
Если игрок горит, то перестает гореть, заместо этого просто чувствует себя в тепле (WarmEffect
), если же игрок зябнет (ChilledEffect
), то в воде он совсем замерзает (FrozenEffect
), во всех остальных случаях становится просто мокрым.
Подобным же образом работают эффекты, когда, находясь на клетке с паром, игрок получает прибавку к уклонению, но как только он ее покидает, то теряет этот эффект. Это еще один мульти-метод, effect-while-on-surface
, который в отдельный массив дополняет все эффекты, действующие только для текущей клетки.
Проклятия и благословения
Предположим, что есть некоторая характеристика — «магическое зачарование», причем каждый элемент может быть либо нейтральным, либо благословленным, либо проклятым. При этом некоторые взаимодействия с магическим зачарованием немного меняются (увеличивается вероятность), а некоторые совсем перестают работать. Если идти уже проторенной дорогой и добавить новый класс на каждое магическое зачарование, например, WaterSurface
, CursedWaterSurface
, BlessedWaterSurface
(«blessed» — благословленный, «cursed» — проклятый), то между ними неизбежно возникнет много повторений.
В наших упражнениях ментальной гимнастики можно сказать, что раньше менялся именно «тип» поверхности или элемента, потому что разлитая нефть по своему поведению значительно отличается от разлитой воды. Однако в случае с магическим зачарованием меняется лишь «свойство». Все эти ментальные упражнения являются условностями, но они помогают заиметь представление о том, как структурировать код.
enum MagicState <Cursed Neutral Blessed>; role Enchantable { has MagicState $.magic-state = Neutral; method is-cursed { $!magic-state eqv Cursed } method is-blessed { $!magic-state eqv Blessed } method is-magical { $.is-cursed or $.is-blessed } method curse { given $!magic-state { when * eqv Cursed {} when * eqv Neutral { $!magic-state = Cursed } when * eqv Blessed { $!magic-state = Neutral } } } method bless_ { given $!magic-state { when * eqv Cursed { $!magic-state = Cursed } when * eqv Neutral { $!magic-state = Blessed } when * eqv Blessed {} } } }
Интересный энумчик
-
Угловые скобки
<
>
обозначают «строковый массив», внутри них не нужно использовать кавычки. Получается перечисление из трех строковых значений. -
$.is-cursed
— то же, чтоself.is-cursed
. -
given/when
— то же, чтоcase/when
илиswitch
в других языках. -
Звездочкой
*
обозначается текущий элемент, который сравнивается с перечислением. -
eqv
— аналогично сравнению на равенство==
, но сравнивает по немного другим условиям. -
bless_
а неbless
, потому что в Rakubless
метод уже существует у каждого класса.
Интересной особенностью миксинов или расширений (объявлено с помощью role
) является то, что они могут в себе также нести свойства, здесь magic-state
, которое сольется с основным объектом.
Воспользуемся перечислением всех возможных магических состояний объекта и, добавив к объекту кусочек кода в Enchantable
, мы получим весь необходимый интерфейс для чтения и записи магического состояния объекта. Эта роль будет применена к родительским классам Element
, Surface
, Cloud
, поэтому все более специфичные типы автоматически получат возможность быть наполненными магией.
Теперь привнесем немного магического зачарования к нашей огненной поверхности:
class FireSurface is Surface is export { has Numeric $.duration is rw = 3; method draw { $.is-cursed ?? "F" !! "f" } method time-out { StateChange.cloud(SmokeCloud.new: :magic-state($.magic-state)) } multi method apply (Element:D $e where $e ~~ WaterElement | IceElement) { if (self & $e).is-blessed { StateChange.cloud(SteamCloud.new: :magic-state(Blessed)) } elsif (self & $e).is-cursed { StateChange.cloud(SteamCloud.new: :magic-state(Cursed)) } elsif all(self, $e).is-magical or none(self, $e).is-magical { StateChange.cloud(SteamCloud.new) } } multi method apply (PoisonElement) { [StateChange.empty, ExplosionEnvironmentEffect.new] } multi method apply (FireElement) {} }
Ну сейчас будет разнос
-
$.is-cursed ?? "F" !! "f"
— это использование тернарного оператора, в других языках обычно этоcondition ? one : two
. -
Element:D $e where $e ~~ WaterElement | IceElement
— а это уже интересно, мы на типElement:D
переменной$e
накладываем дополнительное условие (where
), что эта переменная должна быть (~~
) либо классаWaterElement
, либо классаIceElement
. -
where
— позволяет выполнить любой код для дальнейшей проверки типа, можно даже делать HTTP запросы. -
~~
— это «умное сравнение», здесь оно понимает, что надо узнать принадлежность к классу. -
|
— и другие товарищи вроде&
,^
,none
, это т.н. junctions (перекрестки?). Они позволяют одновременно одну и ту же операцию провести сразу с несколькими элементами.|
означает, что хотя бы одно сравнение должно быть успешным.$e ~~ WaterElement | IceElement
равносильно$e ~~ WaterElement or $e ~~ IceElement
. -
(self & $e).is-blessed
— одновременно вызываем методis-blessed
на себя и на переменную$e
. После вызоваis-blessed
, каждое из них вернет булевое значение, и если оба успешны, то все выражение вернетTrue
. Выражение равносильноself.is-blessed and $e.is-blessed
. -
all/none
— те же конструкторы перекрестков, в контексте условия работают так: либо все условия должны выполниться, либо ни одно не должно выполниться.
Для кого-то Raku — благословение, для кого-то это проклятие. Но только так можно написать код для благословений и проклятий соответственно.
Огонь при выгорании (time-out
) передаст свое зачарование и дыму, дым может быть благословленным или проклятым. Огонь по-прежнему можно погасить, но теперь сделать это гораздо сложнее: для магического огня нужна магическая вода. Причем если оба благословлены, то и пар тоже благословленный, то же с проклятием, а если они магические с разными полярностями, то уравновешивают друг друга и превращаются в обычное мирское облако пара.
Если возникают специфичные эффекты при взаимодействии с проклятыми субстанциями, это легко добавить, например, для проклятого пламени:
role EffectsOnCreature is export { has Effect:D @.effects = []; multi method effect-from-surface (FireSurface:D $s) { # ... } multi method effect-from-surface (FireSurface:D $s where $s.is-cursed) { self.add-effect: CursedEffect.new(:3duration); nextsame; }
Мульти-выбор мульти-методов
Raku старается умно подумать и выбрать самый узко обозначенный тип, здесь при проклятом огне работает нижний мульти-метод. При этом существуют механизмы управлять этим выбором, в данном случае nextsame
означает: выбрать следующего подходящего претендента и покинуть текущий метод. Так как наш проклятый огонь все еще является огнем, после нижнего мульти-метода отработает верхний.
Если пламя проклято (where $s.is-cursed
), то мы сначала накладываем эффект проклятия (CursedEffect
), а потом делаем то же, что и при обычном пламени (nextsame
).
Как это работает все вместе
В общем-то кода для склейки довольно много, это все-таки игра, поэтому могу лишь отослать вас на репозиторий с работающим кодом. Вам только потребуется установить Raku и пару библиотек к нему.
https://github.com/greenfork/denv.raku
Сравнение с реализацией на Python
В замечательной статье от @rplacroix есть реализация подобной системы на языке Python. Я бы хотел обратить ваше внимание на несколько интересных моментов (оригинальную статью для этого читать не нужно):
-
Заместо «побега от хардкодинга», где реализация поверхностей клетки представлена как набор свойств «базовая субстанция», «агрегатное состояние» и «магическое состояние» (например, лед — это вода в твердом состоянии), я бегу к хардкодингу и объявляю по одному классу на каждую комбинацию «базовая субстанция»/»агрегатное состояние». Мне нравится здесь хардкодинг, потому что не возникает ситуация с «невозможными состояниями» (раз, два, три), например, если «жидкий» огонь еще можно отнести к лаве, то с «твердым» огнем уже начинается игра интерпретаций.
-
Магическое состояние в моей версии применимо ко всем возможным элементам, при этом оно не отличается разительно другим поведением, поэтому здесь мы все-таки бежим от хардкодинга.
-
Наличие мульти-методов в языке очень помогает избегать длинных методов с бесконечными
if/else
ветками. Добавляя новое поведение, мы пишем новый код, не трогая работающий. -
В замечательной статье используется датакласс
SurfaceSolution
и в отдельном файле вынесены особенные классы вродеCursedVaporFire
, которые в тандеме помогают определить особые свойства некоторых комбинаций базового набора (субстанция, агрегатное, магическое состояния). Хотя Raku вполне способен творить такие чудеса, мы смогли обойтись без них, используяwhere
для создания более узких подтипов для мульти-методов.
Демонстрация
Ссылка на видео, если не воспроизводится.
Заключение
Я постарался показать все возможные механики окружающей среды, которые смог придумать, пытаясь не пасть ниже той изощренности деталей, которую демонстрируют игры линейки Divinity. В качестве языка имплементации для прототипа был выбран Raku, как очень гибкий (мало костылей) и выразительный (мало строк кода). Для меня Raku очень удобен, но пока трудно его советовать для разработки игр, потому что нет кейсов успешного применения и экосистемы для игр.
В процессе написания игры самым сложным было определиться с основными концептами — какие разные типы объектов существуют, и как они будут взаимодействовать. Был еще один интересный момент, где я попробовал воплотить механику благословений и проклятий следующим способом: а) взять имя текущего класса; б) добавить/убрать к нему в начало строку «Blessed»/»Cursed»; в) вытащить все атрибуты текущего объекта со значениями; и г) создать новый объект модифицированного класса; — но я подумал, что это будет уже слишком «проклятая» реализация, и отказался в пользу перечисления enum
. Также до самого последнего момента я думал, что «элементы» (например, элемент воды) не будут обладать свойствами, их можно сделать просто через перечисление в настоящей игре, а потом оказалось, что у них есть свойство «магическое зачарование» — в такие моменты я благодарю судьбу за то, что пишу на гибком языке Raku, и что я не стал в самом начале закрывать пути для расширения.
Надеюсь, что вам понравилось, буду рад вопросам и вашим мыслям.
ссылка на оригинал статьи https://habr.com/ru/articles/833540/
Добавить комментарий