Original Sin 2 on Python

In this article we will try to program the logic of the surfaces from Divinity: Original Sin 2a turn-based combat role-playing game from the creators Baldur's Gate 3. The essence of the system is that a spell or an object can create a surface (a cloud of steam, ice) in the game world from beer, poison, oil, fire, etc. Each surface interacts with characters in its own way. Moreover, under the influence of other spells or objects, the surfaces will dynamically change – they can be blessed or cursed, heated or frozen, electrified or completely destroyed.

Surface examples

Surface examples

Act 1. Escape from Hardcoding

There are two problems to solve. First, formalize the rules for the transition of one surface to another by creating a Surface class and writing methods in it cool(), heat(), electricify(), bless(), curse(), set_base_surface() [например, на водяную поверхность разлили нефть].

Secondly, we need a convenient approach to describe the interaction of the surface with the player character. There are many combinations of actions, which leads to many different results. Of course, we want to avoid a titanic match-case of hundreds of lines. We also want to avoid writing actions for each possible surface. For example, for the damned electrified cloud of oil, we would like to leave some default logic.

I propose to highlight three main parameters of the surface – base substance (BaseSurface, fire / water / poison), state of aggregation (“liquid”, ice, vapor cloud), magical state (blessed, neutral, cursed).

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

An inquisitive reader might reasonably ask, what is this? EnumWithIndexing. This is a hand-written class that inherits from the standard enumerations. Enumbut supports index comparison and methods next_state() And 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()

Let's add default behavior for surfaces:

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

For BaseSurface we write a similar function apply, where we describe the operating principle of oil (reduces initiative), poisonous (causes damage) and other surfaces.

Act 2. Antagonist class

Constructor of the main Surface class (you can also use 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 # сколько раундов может существовать поверхность

The system_name method will generate a surface identifying name that we will need later.

@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()}'

Let's move on to methods of interaction with the surface.

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

Methods next(prev)_state controls movement along the state scale, but within the designated boundaries. For the method cool() We will especially describe the case when the surface is burning: in this case, the fire is extinguished, but the state of aggregation remains unchanged.

Let's move on to adding a new substance to an existing surface:

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:
                self.base_surface = BaseSurface.NEUTRAL
                self.aggregate_state = AggStates.VAPOR
                print('Огонь выпарил воду и кровь.')
            case BaseSurface.BEER:
                print('Пиво только усилило огонь и создало огненное облако!')
                self.base_surface = BaseSurface.FIRE
                self.aggregate_state = AggStates.VAPOR
            case BaseSurface.OIL | BaseSurface.VENOM:
                self.base_surface = BaseSurface.NEUTRAL
                print('Нефть и Яд взрывается при поджоге.')
                self.exploded = True
    elif self.base_surface == BaseSurface.FIRE:
        match new_base_state:
            case BaseSurface.OIL | BaseSurface.VENOM:
                print('Нефть и Яд взрывается при поджоге.')
                self.aggregate_state = AggStates.VAPOR
                self.exploded = True
    else:
        diff: int = new_base_state - self.base_surface
        self.base_surface = new_base_state if Chance(40 + diff * 15) else self.base_surface

Let's move on to the last scenario, since the first two are fairly obvious. Remember that in class EnumWithIndexing defined the dunder method __sub__ which returns the difference between the indices of the fields in the enumeration.

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

Let's explain with an example. Let's say we have an ordinary water surface (puddle). Some kind soul casts a spell “spill half a liter of beer” on your puddle. Water has an index of 2, beer – an index of 4. Beer dominates water by two points (variable diff), therefore the chance of displacement is 40 + 15 * 2 = 70% (Chance is a wrapper around randint). In the opposite direction, displacement of beer by water: 40 – 15 * 2 = 10% chance to get nonalcoholic beer water.

Act 3. Chocolate “abstract factory”

We have learned how to transform some surfaces into others under the influence of external factors. Now let's deal with the interaction of the surface and the player.

Let's define the dataclass first SurfaceSolutionwhich will control switching between the default logic and the custom logic:

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

Let's define the entry point to the function of applying the surface to the character:

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

Function applySurface(surface, unit) moved to a separate file and looks like this:

# 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()

Remembering the getter system_name from the class Surface. It depends on the states the surface is currently in. If the developer wants to write unique logic for a specific surface, then he creates a class with a name corresponding to 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)      

In the method solution() you will describe the interaction of the surface with the player and return an object of the SurfaceSolution class. In the last example, setting the parameters ag, bs, mg V False shows that for BlessedVaporOil (blessed oil vapors) No need apply steam by default (add dodge), apply oil (slow player) and heal him due to blessing.

Parameter kill In example 2 it shows that the cursed fire cloud should be destroyed after the explosion.

Epilogue

Here's how, for example, you can create a cursed fire surface for two rounds:

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

There were probably some mistakes in the design of the surface system, and it could have been made better and more intuitive. I'd be happy to read your suggestions in the comments.

Thanks for reading!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *