Часть вступительная, не обязательна к прочтению, не несёт в себе ценной информации
Немного людей которые никогда не играли в настольные экономические игры, такие как монополия, рынок, миллионер. Мы с друзьями играли в них дни на пролёт. Со временем, после зазубривания всех правил, и десятков сыгранных партий, хотелось чего-то большего. И мы начали рисовать игры сами. Сначала маленькие, и в большей степени копирующие возможности тех игр, что мы выдели раньше, но потом приходили и свои идеи. В конце доходило до того, что игра располагалась на 9 листах формата А4, а её правила были настолько нетерпимыми к новичкам, что кроме нас никто не мог научиться в неё играть (хотя в монополию со мной играли родители). Там было много всего, строительство, экономика, игровое взаимодействие (например подставы или взаимопомощь). Десятки видов оружия, машин. Чтобы стрелять нужны были патроны. С некоторыми ранениями можно было продолжать играть, с другими путь в больницу, и т.п.
Игра длилась многие часы, и если пора было по домам, мы выходили с комнаты, а я не разрешал никому приближаться к игре, дабы никто не перепутал все наши предметы и фишки.
В данный момент я задал себе вопрос, почему не попробовать воссоздать, хоть малую часть тех возможностей, но не на бумаге, а в виде компьютерной программы.
И в этой статье я хочу поговорить о действиях, то есть о неких способностях игроков, которые изменяют различные свойства игры (правила). От этого и будем отталкиваться.
Часть техническая
Что из себя представляет цикл игры? Игра случайно выстраивает последовательность игроков, а в дальнейшем передаёт ход циклично. Игрок выбирают доступные действия. Заканчивает ход, и т.д.
В чем проблема с действиями
С каждой игрой ассоциирован объект игры (game-object). Действия изменяют его, тем самым открывают или закрывают возможность выполнения других действий. Например: покупка участка позволяет на этом участке заводы возводить (пока не учитываем различные нюансы).
Какие возможности нужно получить? В общем то, только одно: Разрешать или запрещать выполнения действий по мере изменения игровой ситуации.
Кому давать возможность выполнять действия?
В первом варианте, возможность выполнения действий была только у игрока, которому принадлежит право хода. Что несколько упрощает механизм, но исключает вмешательство других игроков на ситуацию и делает невозможным применение очень ценных игровых механик.
Пример:
Игрок вступает на клетку с земельным участком, но не покупает его. В этом случает нужно выставить участок на аукцион, что это значит? А это значит следующее: дать каждому игроку два действия — повысить ставку и отказаться от аукциона. Вышеописанная архитектура не позволяет такого.
Приходим к выводу, что действия должны генерироваться для всех (но для каждого свои).
Когда обновлять список действий?
Один из вариантов, перед ходом игрока. Но тут мы опять ограничиваем себя. Если у игрока не хватало денег купить участок и он применил карту (Добавить деньги. Да, да, так банально), игре нужно дать возможность ему купить.
Приходим к выводу, что создание действий для игроков, должно происходить заново, после каждого выполненного, потому как мы не знаем какие ресурсы изменились, и что будет доступно именно в данный момент.
Программная реализация создания действий
Самый простой вариант — полотно 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=(<function add_money_action at 0x7f94728a1730>, ({'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) разработчик, вы можете написать мне)
Автор: RokkerRuslan