[Из песочницы] Реализация правил (действий) в карточной онлайн игре

422240bb7f2145d0bef1047770cb4bcc.png

Часть вступительная, не обязательна к прочтению, не несёт в себе ценной информации


Немного людей которые никогда не играли в настольные экономические игры, такие как монополия, рынок, миллионер. Мы с друзьями играли в них дни на пролёт. Со временем, после зазубривания всех правил, и десятков сыгранных партий, хотелось чего-то большего. И мы начали рисовать игры сами. Сначала маленькие, и в большей степени копирующие возможности тех игр, что мы выдели раньше, но потом приходили и свои идеи. В конце доходило до того, что игра располагалась на 9 листах формата А4, а её правила были настолько нетерпимыми к новичкам, что кроме нас никто не мог научиться в неё играть (хотя в монополию со мной играли родители). Там было много всего, строительство, экономика, игровое взаимодействие (например подставы или взаимопомощь). Десятки видов оружия, машин. Чтобы стрелять нужны были патроны. С некоторыми ранениями можно было продолжать играть, с другими путь в больницу, и т.п.

Игра длилась многие часы, и если пора было по домам, мы выходили с комнаты, а я не разрешал никому приближаться к игре, дабы никто не перепутал все наши предметы и фишки.

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

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

Часть техническая


Что из себя представляет цикл игры? Игра случайно выстраивает последовательность игроков, а в дальнейшем передаёт ход циклично. Игрок выбирают доступные действия. Заканчивает ход, и т.д.

В чем проблема с действиями


С каждой игрой ассоциирован объект игры (game-object). Действия изменяют его, тем самым открывают или закрывают возможность выполнения других действий. Например: покупка участка позволяет на этом участке заводы возводить (пока не учитываем различные нюансы).
ae14aa3b165c40569bea8e505cae5114.png

Какие возможности нужно получить? В общем то, только одно: Разрешать или запрещать выполнения действий по мере изменения игровой ситуации.

Кому давать возможность выполнять действия?


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

Пример:

Игрок вступает на клетку с земельным участком, но не покупает его. В этом случает нужно выставить участок на аукцион, что это значит? А это значит следующее: дать каждому игроку два действия — повысить ставку и отказаться от аукциона. Вышеописанная архитектура не позволяет такого.

Приходим к выводу, что действия должны генерироваться для всех (но для каждого свои).

Когда обновлять список действий?


f39beed7638a4980b218dd69410493cb.png

Один из вариантов, перед ходом игрока. Но тут мы опять ограничиваем себя. Если у игрока не хватало денег купить участок и он применил карту (Добавить деньги. Да, да, так банально), игре нужно дать возможность ему купить.

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

Программная реализация создания действий


Самый простой вариант — полотно if-ов. Т.е. после каждого действия выполняются проверки и если они проходят, действие добавляется в список. Пример. На клетке мафия происходит следующие:
  • Если мафия не подмята ни под кого, её можно подмять
  • Если мафия не подмята ни под кого и у вас не получается её подмять, вы отдаёте завод на одном из ваших участков
  • Если мафия подмята под вас, ничего не происходит
  • Если мафия подмята под другого игрока, вы отдаёте n денег

Как это выглядит с if-ми:
        if sector.is_mafia():
            # Если мафия подмята под игрока
            if sector.mafia.is_has_owner():
                # Проверка что подмята под другого игрока
                if sector.mafia.owner != player:
                    # если у игрока есть завод (любой)
                    if player.is_have_factory():
                        self._actions.append(
                            Action(
                                text='Заплатить мафии',
                                # other args
                            )
                        )
            else:
                self._actions.append(
                    Action(
                        text='Отдать завод мафии',
                        # other args
                    )
                )
                self._actions.append(
                    Action(
                        text='Подмять мафию',
                        # other args
                    )
                )

Проблемы очевидны. В блоке else сложно понять условия возникновения действия (нужно проследить всю вышестоящую цепочку). Такой код сложно менять, чтобы изменить правило нужно переместить Action () в нужную часть дерева if-ов. Очень длинная функция и итоге.

Неплохо было бы что-нибудь в таком духе:

@some_constraint # Ограничение
def some_action():
    pass # Изменение игровых данных 

Пример с мафией при этом выглядит так:
@when((is_mafia ,), (is_mafia_has_alien_owner, ))
def paid_mafia(game):
    sum = game.calc_paid_mafia(game.active_player)  
    game.active_player.cash -= sum                # Выполняем сами действия
    game.active_sector.land.owner.cash += sum            

Условия это обычные функции, но есть два ограничения: они принимают один обязательный аргумент — объект игры и вернуть условие должно значение типа bool. Функции можно вызывать, самостоятельно, чтобы их комбинировать (is_mafia использует другое условие — sector_detect).
def sector_detect(game, sector_type: str) -> bool:
    return game.active_sector.land.type == sector_type

def is_mafia(game) -> bool:
    return sector_detect(game, MAFIA_SECTOR)

def is_mafia_has_alien_owner(game) -> bool:
    return game.active_sector.mafia.is_has_owner() and not game.active_sector.mafia.is_owner(game.active_player)

На самом деле, хочется чтобы регистрация действий так же была в декораторе.
@am.action('Заплатить мафии', ) # and other option)
@am.when((is_mafia ,), (is_mafia_has_alien_owner, ))
def paid_mafia(game):
    pass

Декоратор action регистрирует действие, плюс добавляет различные свойства (например, название для пользовательского интерфейса)

am — объект управляющий действиями (ActionManager)

Посмотрим на реализацию when и action. Я убрал некоторые моменты проверок, не критичные для самой логики. В целом это выглядит так:

class ActionManager: 
    # ...
    def when(self, *conditions):
        def decorator(function):
            @wraps(function)
            def wrapper(*args, **kwargs):
                for condition, *arguments in conditions:
                    if not condition(self.game, *arguments):
                        break
                else:
                    return function, args, kwargs
            return wrapper
        return decorator

Единственное что делает when это проверяет все условия, если хоть одно из них не выполнилось, действие игроку недоступно.

function — Это одно из действий, то есть изменение игровых данных (paid_mafia). Оно не должно выполнятся на момент их создания для вывода на интерфейс и предоставление выбора игроку. Поэтому просто возвращаем действие со всеми аргументами.

Регистрация действий:

class ActionManager: 
    # ...
    def action(self, name):
        def decorator(function):
            @wraps(function)
            def wrapper(*args, **kwargs):
                pass
            self.actions.append(Action(name, function)) # Action = namedtuple('Action', ['verbose_name', 'exec'])
            return wrapper
        return decorator

Тут стоит заметить, что action на момент выполнения заносит действие в список. На самом деле без action можно обойтись (да и такая реализация приносит кое-какие проблемы), явно регистрируя действие:
game.register_action(some_action)

Чтобы вывести действия пользователю их надо подготовить:
class ActionManager: 
    # ...
    def prepare(self):
        for action in self.actions:
            result = action.exec(self.game) # Вызовем действие (оно обвёрнуто в декоратор when)
            if result is None: # Это действие не может произойти, when вернуло None
                continue
            # Просто сохраним действие с аргументами, на случай если пользователь выберет его
            self.prepared_actions.append(action._replace(exec=result))

Ну и рабочий мини-пример, убрал все комментарии, добавил вывод отладки, организовал, что-то вроде игрового мини-цикла, ничего выбирать не надо, просто запустить:
Исходный код
from collections import namedtuple
from functools import wraps

BALANCE_MIN_LIMIT = 1000
MATERIAL_AID = 10000
FACTORY_COST = 900

Action = namedtuple('Action', ['verbose_name', 'exec'])
DEBUG = True

class ActionManager:

    def __init__(self):
        self.actions = []
        self.game = {
            'active_player': {
                'cash': 300,
                'factory': False
            },
        }

        self.prepare_actions = []

    def prepare(self):
        for action in self.actions:
            result = action.exec(self.game)

            if result is None:
                continue

            self.prepare_actions.append(
                action._replace(exec=result)
            )

    def execute(self):
        
        print("PREPARE ACTION", self.prepare_actions)

        for action in self.prepare_actions:
            act, args, kwargs = action.exec
            act(*args, **kwargs)
            self.prepare()
            

    def action(self, name):
        def decorator(function):

            @wraps(function)
            def wrapper(*args, **kwargs):
                pass

            if DEBUG:
                print('ADD ACTION --', function.__name__)

            self.actions.append(Action(name, function))

            return wrapper
        return decorator

    def when(self, *conditions):
        def decorator(function):
            @wraps(function)
            def wrapper(*args, **kwargs):
                for condition, *arguments in conditions:
                    result = condition(self.game, *arguments)
                    if DEBUG:
                        print("CONDITION: {} ARGS: {} RESULT: {}".format(condition.__name__, arguments, result))
                    if not result:
                        break
                else:
                    return function, args, kwargs
            return wrapper
        return decorator

am = ActionManager()

def money_detect(game, limit: float) -> bool:
    return game.get('active_player').get('cash') < limit

def money_more(game, limit: float) -> bool:
    return game.get('active_player').get('cash') > limit

def is_player_havnt_factory(game) -> bool:
    return not game.get('active_player').get('factory')

@am.action('Add money')
@am.when((money_detect, BALANCE_MIN_LIMIT))
def add_money_action(game):

    game['active_player']['cash'] += MATERIAL_AID

    if DEBUG:
        print("Action", add_money_action.__name__, "SUCCESS")

@am.action('Build factory')
@am.when((money_more, FACTORY_COST), (is_player_havnt_factory, ))
def build_factory(game):

    game['active_player']['factory'] = True

    if DEBUG:
        print("Action", build_factory.__name__, "SUCCESS")

if __name__ == '__main__':

    print("BEFORE ACTION", am.game['active_player'])

    am.prepare()

    print("INTO THE GAME")

    am.execute()

    print("AFTER ACTION", am.game['active_player'])


Вывод программы:
Заголовок спойлера
ADD ACTION -- add_money_action
ADD ACTION -- build_factory
BEFORE ACTION {'factory': False, 'cash': 300}
CONDITION: money_detect ARGS: [1000] RESULT: True # У игрока критично мало денег
CONDITION: money_more ARGS: [900] RESULT: False # Завод он построить не может
INTO THE GAME

PREPARE ACTION [Action(verbose_name='Add money', exec=(, ({'active_player': {'factory': False, 'cash': 300}},), {}))]

Action add_money_action SUCCESS
CONDITION: money_detect ARGS: [1000] RESULT: False
CONDITION: money_more ARGS: [900] RESULT: True
CONDITION: is_player_havnt_factory ARGS: [] RESULT: True # Строительство возможно, так как фхщавода еще нет
Action build_factory SUCCESS
CONDITION: money_detect ARGS: [1000] RESULT: False
CONDITION: money_more ARGS: [900] RESULT: True
CONDITION: is_player_havnt_factory ARGS: [] RESULT: False
AFTER ACTION {'factory': True, 'cash': 10300}



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

Заключение:


Да, есть еще куча вариантов как это можно было сделать. Например вынести логику в конфигурационные файлы или написать мини-язык. Но это добавляет дополнительных проблем с взаимодействием и манипулированием игровыми данными. Да и цель была не избавиться от программирования Python, а сделать реализацию действий более целостной и, конечно, более лёгкой в изменении.

P.S. Если вам нужен начинающий Python (Django) разработчик, вы можете написать мне)

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

  • 26 декабря 2016 в 12:51

    0

    Rule Engine

© Habrahabr.ru