Я прочитал замечательную статью за авторством @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
  1. has — объявление свойства объекта.

  2. Int — опциональная типизация, здесь можно только целые числа.

  3. $ — это скалярная величина, а не массив или словарь.

  4. . — создаем публичные методы для доступа извне.

  5. duration — название свойства.

  6. is rw — свойство можно менять извне, а не только читать.

  7. = 1 — по умолчанию значение 1.

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

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, потому что в Raku bless метод уже существует у каждого класса.

Интересной особенностью миксинов или расширений (объявлено с помощью 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, и что я не стал в самом начале закрывать пути для расширения.

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

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


  1. rplacroix
    03.08.2024 14:20
    +1

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

    P.S. По поводу "побега от хардкодинга". Я согласен, что хардкодинг (особенно такой изящный, как здесь) более уместен при разработке игры, однако я создал под себя такую систему, чтобы можно было ограничиться эффектами по умолчанию. Фактически, это результат лени. Когда придёт вдохновение под какую-то особую поверхность (например, проклятое облако нефти снижает боевой дух и шанс походить), то я пропишу под нее логику отдельно.


    1. greenfork Автор
      03.08.2024 14:20

      Спасибо большое за ваш комментарий и за упоминание в вашей статье. Я рад, что Raku не стал слишком высоким барьером для понимания, я немного боялся, что он будет слишком отталкивать.

      По хардкодингу: что думаете о проблеме "невозможных состояний"? То есть, если какая-то комбинация из субстанция/агрегатное не будет существовать в игре. Похоже, что нужно будет при создании или изменении везде ставить проверки на то, что набор свойств позволителен. Например для electrify у вас такое условие:

      self.base_surface == BaseSurface.NEUTRAL and self.aggregate_state != AggStates.VAPOR) or self.aggregate_state == AggStates.SOLID

      Наверное нужно будет что-то придумать, если система будет расширяться. Какое-нибудь пост-условие, что конечный объект всегда валидный.


    1. greenfork Автор
      03.08.2024 14:20

      Спасибо большое за ваш комментарий и за упоминание в вашей статье. Я рад, что Raku не стал слишком высоким барьером для понимания, я немного боялся, что он будет слишком отталкивать.

      По хардкодингу: что думаете о проблеме "невозможных состояний"? То есть, если какая-то комбинация из субстанция/агрегатное не будет существовать в игре. Похоже, что нужно будет при создании или изменении везде ставить проверки на то, что набор свойств позволителен. Например для electrify у вас такое условие:

      self.base_surface == BaseSurface.NEUTRAL and self.aggregate_state != AggStates.VAPOR) or self.aggregate_state == AggStates.SOLID

      Наверное нужно будет что-то придумать, если система будет расширяться. Какое-нибудь пост-условие, что конечный объект всегда валидный.


  1. Deosis
    03.08.2024 14:20
    +1

    Очень интересно, насколько глубоко простирается взаимодействие элементов?

    Можно ли смешать яд и воду, чтобы получить отравленную воду, потом огнем испарить её и получить ядовитое облако и в конце ветром сдуть облако на клетку с противником?

    Как только говорят про взаимодействие элементов, сразу вспоминается Магика с её целебными булыжниками.


    1. greenfork Автор
      03.08.2024 14:20

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

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

      Испарить огнем для получения отравленного облака. Работает аналогично, к поверхности PoisonSurface применяется элемент FireElement и мы испаряем эту поверхность, превращая в ядовитое облако PoisonCloud. В текущей имплементации, следуя канону Divinity, когда огонь соединяется с ядом, то образуется взрыв. Но это можно легко поменять, сейчас эта логика находится в этом мульти методе:

      class PoisonSurface is Surface does Solution is export {
          # ...
          multi method apply (FireElement) { [StateChange.empty, ExplosionEnvironmentEffect.new] }
      }

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

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

      class PoisonCloud is Cloud is export {
          multi method apply (AirElement $e) {
              [StateChange.empty,
               MoveEnvironmentEffect.new(
                   source => $e.source-cell,
                   cell => StateChange.cloud(PoisonCloud.new))]
          }
          # ...
      }

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

      ***

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