В этой статье мы попробуем запрограммировать логику работы поверхностей из Divinity: Original Sin 2, ролевой игры с пошаговой боевой системой от создателей Baldur's Gate 3. Суть системы в том, что заклинание или предмет может создать в игровом мире поверхность (облако пара, лёд) из пива, яда, нефти, огня и т. д. Каждая поверхность по‑своему взаимодействует с персонажами. Более того, под воздействием других заклинаний или предметов поверхности будут динамически меняться — их можно благословить или проклясть, прогреть или заморозить, наэлектризовать или полностью уничтожить.

Примеры поверхностей
Примеры поверхностей

Акт 1. Побег от хардкодинга

Необходимо решить две задачи. Во-первых, формализовать правила перехода одной поверхности в другую, создав класс Surface и прописав в нём методы cool(), heat(), electricify(), bless(), curse(), set_base_surface() [например, на водяную поверхность разлили нефть].

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

Предлагаю выделить три основных параметра поверхности — базовая субстанция (BaseSurface, огонь / вода / яд), агрегатное состояние («жидкое», лёд, облако пара), магическое состояние (благословленное, нейтральное, проклятое).

class BaseSurface(EnumWithIndexing):
    NEUTRAL = 'Ничего'
    FIRE = 'Огонь'
    WATER = 'Вода'
    BLOOD = 'Кровь'
    BEER = 'Пиво'
    OIL = 'Нефть'
    VENOM = 'Яд'
    
class AggStates(EnumWithIndexing):
    SOLID = 'Лёд'
    LIQUID = 'Жидкость'
    VAPOR = 'Пар'
    
class MagicStates(EnumWithIndexing):
    CURSED = 'Проклятый'
    NEUTRAL = 'Обычный'
    BLESSED = 'Благословенный'

Пытливый читатель резонно спросит, что за EnumWithIndexing. Это вручную написанный класс, унаследованный от стандартных перечислений Enum, но поддерживающий сравнение по индексу и методы next_state() и prev_state().

class EnumWithIndexing(Enum):
    def indexing(self):
        return list(self.__class__).index(self)

    def getByIndex(self, index: int):
        return list(self.__class__)[index]

    def __sub__(self, other):
        return self.indexing() - other.indexing()

    def next_state(self):
        new_index = self.indexing() + 1
        if new_index == len(self.__class__):
            return self
        return self.getByIndex(new_index)

    def prev_state(self):
        new_index = self.indexing() - 1
        if new_index == -1:
            return self
        return self.getByIndex(new_index)

    def __gt__(self, other):
        return self.indexing() > other.indexing()

Добавим поведение по умолчанию для поверхностей:

class AggStates(EnumWithIndexing):
    ...
    def apply(self, unit):
        match self:
            case self.__class__.VAPOR:
                unit.talk('Окруженный паром, вы получаете +20 Уклонения.')
                unit.addEffect(EFF.CHAMELEON, power=[20], rounds=1)
            case self.__class__.SOLID:
                unit.addEffect(EFF.COWONICE, 1)
                # проскальзывание на льду при попытке уйти с клетки.
            case _:
                pass

class MagicStates(EnumWithIndexing):
    ...
    def apply(self, unit):
        match self:
            case self.__class__.CURSED:
                unit.talk('Проклятая поверхность отнимает 50% от получаемого лечения.')
                unit.addEffect(EFF.INTERDICT, power=[50], rounds=1)
            case self.__class__.BLESSED:
                unit.talk(f'Благословенная поверхность восстанавливает {unit.heal(18 + unit.lvl * 2)} ОЗ.')
            case _:
                pass

Для BaseSurface пишем аналогичную функцию apply, где прописываем принцип работы нефтяной (уменьшает инициативу), ядовитой (наносит урон) и других поверхностей.

Акт 2. Класс-антагонист

Конструктор основного класса Surface (можно воспользоваться и dataclasses):

class Surface:
    def __init__(self,
                 base_surface: BaseSurface = BaseSurface.NEUTRAL,
                 agg_state: AggStates = AggStates.LIQUID,
                 magic_state: MagicStates = MagicStates.NEUTRAL,
                 electricity: bool = False,
                 rounds: int = None,
                 ):
        self.base_surface: BaseSurface = base_surface
        self.aggregate_state: AggStates = agg_state
        self.magic_state: MagicStates = magic_state
        self.electrified: bool = electricity
        self.exploded: bool = False # если поверхность взорвалась, то она должна исчезнуть на след раунд
        self.rounds = rounds # сколько раундов может существовать поверхность

Метод system_name сформирует индентифицирующее поверхность имя, которое нам понадобится в дальнейшем.

@property
def system_name(self):
    return f'{"El" if self.electrified else ""}{self.magic_state.name.capitalize()}{self.aggregate_state.name.capitalize()}{self.base_surface.name.capitalize()}'

Передём к методам интеракции с поверхностью.

Hidden text
def bless(self):
    self.magic_state = self.magic_state.next_state()
    return self

def curse(self):
    self.magic_state = self.magic_state.prev_state()
    return self

def heat(self):
    self.electrified = False
    self.aggregate_state = self.aggregate_state.next_state()
    return self

def cool(self):
    self.electrified = False
    if self.base_surface == BaseSurface.FIRE:
        self.base_surface = BaseSurface.NEUTRAL
        return self
    self.aggregate_state = self.aggregate_state.prev_state()
    return self

def elecricify(self):
    if (self.base_surface == BaseSurface.NEUTRAL and self.aggregate_state != AggStates.VAPOR) or self.aggregate_state == AggStates.SOLID:
        print('Наэлектризовать пустую поверхность или лёд невозможно.')
        return self
    self.electrified = True
    return self

Методы next(prev)_state управляет перемещением по шкале состояний, но в обозначенных границах. Для метода cool() особо пропишем тот случай, когда поверхность горит: в таком случае огонь тушится, но агрегатное состояние останется неизменным.

Перейдём к добавлению новой субстанции в существующую поверхность:

def set_base_surface(self, new_base_state: BaseSurface):
    assert new_base_state != BaseSurface.NEUTRAL, 'Используй метод turn_neutral() для данного действия.'
    if new_base_state == BaseSurface.FIRE:
        match self.base_surface:
            case BaseSurface.WATER | BaseSurface.BLOOD:
                if self.magic_state == MagicStates.NEUTRAL:
                    self.base_surface = BaseSurface.NEUTRAL
                    self.aggregate_state = AggStates.VAPOR
                    print('Огонь выпарил воду и кровь.')
                else:
                    print('Магическую Воду и Кровь невозможно испарить обычным Огнём! Вы создали только небольшое облако пара.')
                    self.aggregate_state = AggStates.VAPOR
                return self
            case BaseSurface.BEER:
                print('Пиво только усилило огонь и создало огненное облако!')
                self.base_surface = BaseSurface.FIRE
                self.aggregate_state = AggStates.VAPOR
                return self
            case BaseSurface.OIL | BaseSurface.VENOM:
                self.base_surface = BaseSurface.NEUTRAL
                print('Нефть и Яд взрывается при поджоге.')
                self.exploded = True
                return self
    if self.base_surface == BaseSurface.FIRE:
        match new_base_state:
            case BaseSurface.OIL | BaseSurface.VENOM:
                print('Нефть и Яд взрывается при поджоге.')
                self.aggregate_state = AggStates.VAPOR
                self.exploded = True
                return self
    diff: int = new_base_state - self.base_surface
    self.base_surface = new_base_state if Chance(40 + diff * 20 - 50 * (self.magic_state != MagicStates.NEUTRAL)) else self.base_surface
    return self

Перейдём к последнему варианту развитию событий, поскольку первые два достаточно очевидны. Помним, что в классе EnumWithIndexing определили дандер-метод __sub__ , возвращающий разность индексов полей в перечислении.

Примечание. Спасибо пользователю Linefire за дельный комментарий, который привел к изменению метода set_base_surface. Если магическое состояние поверхности не нейтральное, то любое вытеснение будет проходить с 50% штрафом к вытеснению. Также магическую Воду и Кровь нельзя выпарить полностью Огнём. Остальные изменения на Ваше усмотрение.

class BaseSurface(EnumWithIndexing):
    NEUTRAL = 'Ничего'
    FIRE = 'Огонь'
    WATER = 'Вода' # idx = 2
    BLOOD = 'Кровь'
    BEER = 'Пиво' # idx = 4
    OIL = 'Нефть'
    VENOM = 'Яд'

Поясним на примере. Допустим, мы имеем обычную водяную поверхность (лужа). Некая добрая душа применяет заклинание «разлить пол‑литра пива» на вашу лужу. Вода имеет индекс 2, пиво — индекс 4. Пиво доминирует над водой на два пункта (переменная diff), следовательно шанс вытеснения равен 40 + 15 * 2 = 70% (Chance это обертка над randint). В обратную сторону, вытеснение пива водой: 40 - 15 * 2 = 10% шанс получить безалкогольное пиво воду.

Акт 3. Шоколадная «абстрактная фабрика»

Мы научились превращать одни поверхности в другие под воздействием внешних факторов. Теперь займёмся взаимодействием поверхности и игрока.

Давайте сначала определим датакласс SurfaceSolution, который будет управлять переключением между логикой по умолчанию и логикой, настроенной отдельно:

@dataclass
class SurfaceSolution:
    ag: bool = True # применять агрегатное состояние
    mg: bool = True # применять магическое состояние
    bs: bool = True # применять эффекты, прописанные для этой субстанции
    el: bool = True # применять эффекты, связанные с электричеством?
    kill: bool = False # уничтожить поверхность после ее применения

Определим точку входа в функцию применения поверхности на персонажа:

class Cell:
    def __init__(self):
        self.surface: Surface = Surface()

    def __str__(self):
        return f'{self.surface}'

    def entry(self, unit):
        """
        unit заходит в клетку и получает эффект от surface и state.
        """
        applySurface(self.surface, unit)

    def stand(self, unit):
        """
        unit стоит на ячейке, вызывается на момент его хода
        """
        applySurface(self.surface, unit)

    def exit(self, unit):
        """
        unit уходит из ячейки,
        """
        pass

Функция applySurface(surface, unit) вынесена в отдельный файл и выглядит следующим образом:

# surfabric.py

from surfaces.special import *
'''
Здесь должны быть импортированы все специальные поверхности (из-за eval)
'''


def initByName(name: str):
    surf = Surface()
    try:
        surf = eval(name)()
        print(surf.system_name)
    except NameError:
        print('Не найдено поверхности с таким именем!')
        '''
        Уловка, чтобы не дублировать классы спецповерхностей,
        если в системном имени появится El
        '''
        if name.startswith('El'):
            surf = initByName(name[2:])
    return surf


def applySurface(surf: Surface, unit):
    surface = initByName(surf.system_name)

    surface.pass_all_states(surf)
    sol = surface.solution(unit)

    if surf.aggregate_state == AggStates.LIQUID and sol.bs:
        surf.base_surface.apply(unit)
    if sol.ag:
        surf.aggregate_state.apply(unit)
    if sol.mg:
        surf.magic_state.apply(unit)
    if surf.electrified and sol.el:
        unit.talk('оглушен током от наэлектризованной поверхности!')
    if surf.exploded:
        unit.talk('получает Х урона от подрыва поверхности!')
        unit.surface.exploded = False
    if sol.kill:
        surf.turn_neutral()
    if surf.rounds is not None:
        surf.rounds -= 1
        if surf.rounds == 0:
            unit.talk(f'Срок жизни поверхности {surf} закончился!')
            surf.turn_neutral()

Вспоминаем геттер system_name из класса Surface. Он зависит от того, в каких состояниях сейчас находится поверхность. Если разработчик хочет прописать уникальную логику для определённой поверхности, то он создаёт класс с именем, соответствующий system_name:

# special.py
class BlessedLiquidBeer(Surface):
    def solution(self, unit):
        unit.addEffect(EFF.LUCKY, 1, [12])
        unit.addEffect(EFF.CHAMELEON, 1, [12])
        unit.talk(f'Благословенное пиво увеличивает Удачу и Уклонение на 12 пт.')
        return SurfaceSolution(bs=False)

class CursedVaporFire(Surface):
    def solution(self, unit):
        unit.talk(f'Взрыв облака проклятого огня на Х урона.')
        return SurfaceSolution(bs=False, kill=True)

class BlessedVaporOil(Surface):
    def solution(self, unit):
        unit.talk(f'{self} усиливает сопротивление к Земле на 50%.')
        return SurfaceSolution(ag=False, bs=False, mg=False)      

В методе solution() вы опишете взаимодействие поверхности с игроком и вернете объект класса SurfaceSolution. В последнем примере установка параметров ag, bs, mg в False показывает, что для BlessedVaporOil (благословленные пары нефти) не надо применять действие пара по умолчанию (добавить уклонение), применять нефть (замедление игрока) и лечить его из-за благословения.

Параметр kill в примере 2 показывает, что проклятое огненное облако должно уничтожиться после взрыва.

Эпилог

Вот так, например, можно создать проклятую огненную поверхность на два раунда:

...
enemy.surface.set_rounds(2)
enemy.surface.set_base_surface(BaseSurface.FIRE)
enemy.surface.curse()
...

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

Благодарю за прочтение!

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


  1. Linefire
    19.07.2024 06:11

    В игре система поверхностей всё же сложнее показанного.

    1) Поверхность пара создаётся над жидкостью и продолжает существование независимо. То есть под паром может быть новая жидкость с другими свойствами. Не увидел этого в коде

    2) Благословенные и проклятые жидкости имеют приоритет над обычными. Нельзя погасить проклятое пламя обычной водой, сперва его надо благословить

    3) Также не нашел, возможно ли существование проклятого огненного облака, которое есть в игре

    Интересная статься, но в готовой игре система всё такие более углублённая со сложными для программирования нюансами


    1. rplacroix Автор
      19.07.2024 06:11

      Спасибо, вы совершенно правы, что я учёл не все нюансы из оригинальной игры.
      1) да, комбинация "поверхность + пар" в текущей версии невозможна. Мы действуем в парадигме "одной ячейке Cell - одна поверхность Surface". Однако в Cell можно создать дополнительный атрибут cloud (помимо surface), который должен быть в газообразном состоянии (если на него подействовали охлаждением, то произойдёт конденсация. Тогда субстанция облака вступит в реакцию с поверхностью согласно правилу set_base_surface).

      2) я забыл об этой игровой особенности, спасибо, что напомнили. В set_base_surface можно добавить проверку - "если магическое состояние не нейтральное, то текущая базовая субстанция будет устойчива к вытеснениям". С огненными поверхностями чуть сложнее, например, нет же разницы между поджиганием благословленной и обычной нефти?

      3) да, возможно. Для этого нужно применить цепочку преобразований:
      s = Surface()

      s.set_base_surface(BaseSurface.FIRE)

      print(s.heat().curse())


    1. rplacroix Автор
      19.07.2024 06:11

      По поводу пункта 2): я внес изменения в код метода set_base_surface в статье, который поддерживает приоритет магических поверхностей.