Метка: python

3D-бродилка с трассировкой лучей в 184 строки на Python

184 строки – это еще с комментариями! И без использования сторонних движков! На самом деле все будет очень просто, в качестве средства отрисовки мы будем использовать любимую ASCII графику в консоли через библиотеку curses. Изображение будет строиться по принципам игры Wolfenstein 3D.

Готовая бродилка - иллюстрация
Что получим в итоге!

Библиотека curses – это интерфейс к Ncurses, консольной библиотеки для отрисовки всяческих квадратно-текстовых окошек, кнопок и прочих элементов управления в стиле Turbo Pascal или QBasic, кто помнит… Из нее нам понадобится только способность получать размеры терминала и рисовать в нужном месте символ нужного цвета.

Пользователи Windows, к сожалению, в вашей версии Python скорее всего не встроен модуль curses, поэтому вам придется установить пакет. Я использовал такой вариант:

pip install windows-curses

К сожалению, у мне не удалось добиться поддержки цвета на Windows, но я не слишком старался. Это не беда, потому что код был изначально заточен под имитацию оттенков серого через выбор символов разной плотности закраски. На Linux и Macos все должно работать на голом Python и сразу в цвете.

Код, который я вам представлю, является моим портом проекта 3D-Walk, что в свою очередь является портом проекта CommandLineFPS (по ссылке – видео) от javidx9 aka OneLoneCoder. Я внес в код небольшие модификации, исправления и поддержку цвета.

Итак, побежали по коду. Все что, нам надо импортировать:

import curses
import locale
from math import pi, cos, sin

Затем идут некоторые константы:

POS_X, POS_Y, POS_A = 2, 2, 0  # Положение и поворот игрока на карте (начальные)
ROTATION_SPEED = 0.1  # скорость поворота игрока в радианах
SPEED = 0.3  # Скорость игрока вперед назад за одно нажатие

FOV = pi / 2  # Ширина угла обзор в радинах
RESOLUTION = 0.1  # разрешение шага луча
DEPTH = 16  # Максимальная глубина прорисовки

# Наша карта строчкой
MAP = """
################
#..............#
#..............#
#...########...#
#.......#..#...#
#..........#...#
#..............#
#...########...#
#.......#..#...#
#.......####...#
#..............#
#....##..##....#
#...#...#..#...#
#....###...#...#
#..............#
################
"""

Карта представляет собой двумерную схему лабиринта, вид сверху. Карта записана многострочной строкой, где символом «решетка» обозначены непроходимые стены, а «точками» – пустое пространство. Позиция игрока – число блоков (вероятно, дробное) от угла карты. Угол поворота игрока измеряется в радианах, а широта взгляда по горизонтали FOV – это 90 градусов от левого края экрана до правого. В этим числом будет весело поиграть. Графически выглядит примерно так. Правда, здесь ошибка в точке отсчета, думаю, что внимательный читатель заметит.

Координаты и поворот игрока относительно карты
Положение игрока на карте, вид сверху

Главная функция получает параметром объект экрана (рабочего окна) и запускается через curses.wrapper:

def main_3dwalk(screen):
   ...

curses.wrapper(main_3dwalk)

В начале ее работы мы настраиваем curses:

# для корректного отображение юникода
locale.setlocale(locale.LC_ALL, '')

curses.noecho()  # нажатые клавиши не печатаются на экране
curses.curs_set(0)  # курсор убран
curses.start_color()  # цветной режим
curses.use_default_colors()  # стандартная палитра
# инициализация всех цветов!
for i in range(0, curses.COLORS):
    curses.init_pair(i, i, -1)

Следующая функция немного преобразует карту MAP, удаляя все переносы строк и считая количество строк и столбцов:

def make_map(string_map):
    rows = string_map.strip().split('\n')
    h = len(rows)
    w = len(rows[0])
    return string_map.replace('\n', ''), w, h

# форматируем карту и получаем ее размеры
level_map, map_width, map_height = make_map(MAP)

На выходе будет:

('#################..............##..............##...########...##.......#..#...##..........#...##..............##...########...##.......#..#...##.......####...##..............##....##..##....##...#...#..#...##....###...#...##..............#################', 16, 16)

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

def get_block(x, y):
    x, y = int(x), int(y)
    if 0 <= x < map_width and 0 <= y < map_height:
        return level_map[y * map_width + x]
    else:
        return '#'

Если координаты попадут по какой-либо причине за пределы карты, что считается, что там всегда глухая стена непроходимого вещества, иными словами – символ '#'. Больше от карты ничего не требуется, только знание, есть ли стена в этой точке пространства или нет!

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

# текущие положение и угол
pos_x, pos_y, pos_a = POS_X, POS_Y, POS_A

exit_flag = False  # флаг выхода
while not exit_flag:
   # получаем размер экрана в каждом кадре, чтобы не глючить, если юзер изменил размер терминала
   screen_height, screen_width = screen.getmaxyx()
   ...

Изображение строится по столбцам. Пока номеру столбца мы находим угол отклонения луча от прямого взгляда. Левая колонка соответствует минимальному углу, то есть pos_a - FOV / 2, а самая правая колонка – pos_a + FOV / 2. Таким образом, вычисляя синус и косинус того угла, мы получаем вектор направления взгляда.

for col in range(screen_width):
    # 1. определим направление луча
    # угол сканирует от pos_a - FOV / 2 до pos_a + FOV / 2
    ray_angle = (pos_a - FOV / 2) + (col / screen_width) * FOV

    # вектор, куда смотрит луч на карте
    eye_x, eye_y = sin(ray_angle), cos(ray_angle)

Вдоль этого направления испускается луч, стартуя с позиции игрока pos_x, pos_y по направлению eye_x, eye_y. Небольшими шажками (размер шага – константа RESOLUTION), мы продвигаемся вдоль луча и проверяем, пользуясь картой, нет ли в этой точке стены. Как только луч натыкается на стену, в этот момент фиксируется дистанция, цикл прерывается, и алгоритм переходит к следующей колонке. Правильнее было бы называть его не трассировкой лучшей, а чем-то сродни ray casting. Вот код для определения дистанции:

# 2. Ищем ближайшую стену и дистанцию до нее
distance = 0.0
# пока не достигли стены и дистанция менее предельной
while distance < DEPTH:
    # луч делает шаг вперед
    distance += RESOLUTION
    
    # "текущее" положение на луче
    test_x = int(pos_x + eye_x * distance)
    test_y = int(pos_y + eye_y * distance)
    
    # смотрим карту, есть ли там стена или край
    if get_block(test_x, test_y) == '#':
        break  # стена. расстояние в distance

Что дает знание расстояния до стены? Многое. Во-первых, чем дальше от нас этот кусочек стены, тем меньше он будет занимать вертикального расстояния. Во-вторых, тем темнее будет его оттенок и слабее заливка.

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

ceiling = int(screen_height / 2 - screen_height / distance)  # высота потолка
floor = int(screen_height - ceiling)  # высота пола
Сканирование колонки

Теперь остается только заполнить колонку сверху вниз. Потолок мы заливаем символом одиночной кавычки красного цвета. А ниже потолка идет серая стена. Цвет и символ заливки стены зависит от дистанции. Сюда прекрасно подойдут квадраты разной плотности, благо они есть в Unicode. Под стеной начинается пол зеленого цвета. Для выразительности символ, который образует пол, тоже зависит от дистанции от нижней кромки экрана, что косвенно отвечает дистанции этого кусочка пола от игровой камеры.

# рисуем вертикальную линию
for row in range(screen_height):
    if row <= ceiling:  # Ряд выше или равен границе потолка
        shade = '`'
        color = curses.COLOR_RED
    elif floor >= row > ceiling:  # Кусок стены
        if distance <= DEPTH / 4:  # совсем близко
            shade = "█"
        elif distance <= DEPTH / 3:  # ближе
            shade = "▓"
        elif distance <= DEPTH / 2:  # дальше
            shade = "▒"
        elif distance <= DEPTH:  # еще дальше
            shade = "░"
        else:
            shade = " "  # совсем далеко
        # оттенок цвета, нормированный на предельную дистанцию
        color = color_by_distance(1 - (distance / DEPTH))
    else:
        # Оттенок пола, чем ближе к низу экрана, тем гуще заливка
        b = 1 - (row - screen_height / 2) / (screen_height / 2)
        if b < 0.25:
            shade = '#'
        elif b < 0.5:
            shade = "x"
        elif b < 0.75:
            shade = "."
        else:
            shade = ' '
        color = curses.COLOR_GREEN
    # заменяем символ в row/col на shade с цветом color
    screen.insstr(row, col, shade, curses.color_pair(color))

После циклов остается только отрисовать все изменения на экране:

screen.refresh()  # отрисуем все на экране

Дальше мы ожидаем нажатия игроком клавиш управления. Для перемещения используются клавиши WASD, W/S – вперед и назад, A/D – повороты влево и вправо. Esc – выход.

Не забудьте переключиться на английскую раскладку. На русской раскладке игра не будет реагировать!

Преобразовав код клавиши в символ, мы поворачиваем камеру либо смещаем игрока назад или вперед вдоль направления взгляда. Если новое положение игрока оказалось «внутри» стены, то такое движение отменяется. Так просто происходит обработка столкновений со стенами. Вот код, решающий задачу движения игрока:

key_code = screen.getch()  # ждем клавишу и обрабатываем
key = chr(key_code) if 0 < key_code < 256 else 0
if key in ('w', 's'):
    # шаг вперед или назад
    dx, dy = sin(pos_a) * SPEED, cos(pos_a) * SPEED
    if key == 's':  # назад - обратим вектор
        dx, dy = -dx, -dy

    # сдвинем игрока в направлении
    pos_x += dx
    pos_y += dy
    if get_block(pos_x, pos_y) == '#':  # упс, мы в стене
        # отменим движение
        pos_x -= dx
        pos_y -= dy
elif key == 'a':  # поворот налево
    pos_a -= ROTATION_SPEED
elif key == 'd':  # поворот направо
    pos_a += ROTATION_SPEED
elif key_code == 27:  # esc
    break  # выход из игры

В конце не забудем завершить работу curses корректно, восстановив все настройки терминала:

curses.endwin()

Вот и все! Наша бродилка готова! Теперь мы знаем немного больше про 3D графику!

Код программы я залил на gist.github.com. Там два файла: цветная версия для macOS и Linux, и черно-белая для Windows. Наслаждайтесь. Возможно, кто-то из читателей модифицирует этот код, добавив больше цветов, текстур, возможно, противников 🙂

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Циклы и замыкания Python

Когда вы определяете функцию внутри другой функции и используете локальные переменные внешней функции во вложенной, вы создаете замыкание. Время жизни этих переменных «продляется» в особой области видимости enclosing даже после завершения работы внешней функции. Пример: make_adder возвращает функцию-прибавлятор. Объект из переменной a будет жить и работать даже после выхода из make_adder:

def make_adder(a):
    def adder(x):
        return a + x
    return adder

plus_5 = make_adder(5)
print(plus_5(3))  # 8

Здесь я хочу коснуться одной популярной проблемы. Дело в том, что если мы создадим несколько функций внутри одного контекста, то они будут разделять одну область видимости enclosing. Рассмотрим пример создания трех функций в цикле:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x):
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 4 4 4

Вместо функций прибавляющих разные числа от 0 до 2, мы получили 3 одинаковых функции, потому что внутри себя они поддерживают ссылку на одну и ту же переменную a, значение которой останется равным 2 после выполнения всего цикла целиком.

Есть простой прием, помогающий «зафиксировать» значения переменной в моменте: достаточно добавить во вложенную функцию дополнительный аргумент со значением по умолчанию, равным нужной переменной a=a:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x, a=a):  # FIX!
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Еще лучше переименовать аргумент, чтобы избежать конфликтов имен и замечаний IDE, например, так:

def adder(x, that_a=a):  # FIX!
    return that_a + x

yield

Пока писал код для этого поста, я наткнулся на одну обманку. Люблю оформлять функции, возвращающие коллекции, как генераторы с ключевым словом yield. Вот так:

def make_adders():
    for a in range(3):
        def adder(x):
            return a + x
        yield adder

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Видите, тут нет фикса a=a! Казалось бы, что код должен также содержать в себе баг и выводить «4 4 4», но он работает, как задумано изначально.

Однако, если мы применим list к генератору, извлекая все значения разом, то баг вернется:

adders = list(make_adders())
for adder in adders:
    print(adder(2))  # 4 4 4

Разгадка. В первом случае происходят следующие действия:

  • a = 0
  • yield функцию (a + x), make_adders становится на паузу
  • печать adder(2) = 0 + 2 = 2
  • make_adders запускается
  • a = 1
  • yield функцию (a + x), пауза
  • печать adder(2) = 1 + 2 = 2
  • … и так далее…

То есть мы запускаем adder только один раз в тот момент, пока переменная a еще равна нужному значению. 

Во втором код list прокручивает make_adders до конца, оставляя a = 2, и все функции выдают одинаковый результат.

Вывод мы должны сделать такой: yield не создает нового замыкания с отдельной переменной a и не освобождает нас от ответственности следить за переменными.

Еще кое-что. 

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

next(adders)  # StopIteration

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

Генератор – вещь одноразовая.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

MRO – порядок разрешения методов в Python

Погорим о наследовании. Если один класс унаследован от другого, то он от него перенимает себе методы и атрибуты своего родителя. Вы, конечно, можете переопределить некоторые из них или добавить свою новую функциональность – в этом и есть смысл наследования. Но те методы, которые вы не переопределяли, так и останутся родительскими. Таким образом, когда вы вызываете метод экземпляра класса, Python должен посмотреть, есть ли в нем этот метод. Если есть – он и будет вызван, а если его нет, то ему придется проверить классы-родители данного класса. Вдруг, у них есть?

class Parent:
    def earn_money(self):
        print('Родитель зарабатывает')

class Child(Parent):
    def play(self):
        print('Ребенок играет')

c = Child()
c.play()  # Ребенок играет
c.earn_money()  # Родитель зарабатывает

В коде выше ребенок играет, играть – это ему присущий метод. Но зарабатывать деньги он пока не умеет, но его родитель вполне может с этим справиться. Поэтому метод earn_money будет взят от родителя. Думаю, тут все ясно.

Сложнее ситуация становится, когда иерархия классов разрастается. Не будем забывать, что Python поддерживает множественное наследование, что сделает граф отношений между классами весьма запутанным. Методы с одинаковыми именами могут быть определены в любых классах из всей иерархии. И если ответ на вопрос «где искать?» довольно прост: сначала посмотри в самом классе, а потом в его родителях; то ответ на вопрос «в каком порядке искать?» не такой тривиальный. Например, взгляните на такую иерархия классов:

class O: ...
class A(O): ...
class B(O): ...
class C(O): ...
class D(O): ...
class E(O): ...

class K1(A, B, C): ...
class K2(B, D): ...
class K3(C, D, E): ...

class Z(K1, K2, K3): ...
Простой пример иерархии классов.
«Простой» пример

Сможет сходу назвать порядок поиска методов в этой иерархии? Я вот ошибся с первой попытки, и даже со второй. Узнаем правду методом mro():

print(Z.mro())
# [<class '__main__.Z'>, <class '__main__.K1'>, <class '__main__.A'>, <class '__main__.K2'>, <class '__main__.B'>, <class '__main__.K3'>, <class '__main__.C'>, <class '__main__.D'>, <class '__main__.E'>, <class '__main__.O'>, <class 'object'>]

# сделаем понагляднее вывод, печатая только имена классов со стрелочками:
def print_mro(T):
    print(*[c.__name__ for c in T.mro()], sep=' -> ')

print_mro(Z)
# Z -> K1 -> A -> K2 -> B -> K3 -> C -> D -> E -> O -> object

Что же такое этот MRO?

Аббревиатура MRO – method resolution order. А по-русски это переводится как «порядок разрешения методов». Но! Тоже самое относится не только к методам, но и к прочим атрибутам класса, так как методы – это частный случай более общего понятия «атрибут».

Метод класса Z.mro() возвращает нам список классов ровно в том порядке, в котором Python будет искать методы в иерархии классов пока не найдет нужный или не выдаст ошибку.

Конечный класс в цепочке всегда – object; от него неявно наследуются все объекты в Python 3. Поэтому любое множественное наследование (когда у класса более одного непосредственного родителя) порождает ромбовидные структуры, потому что все цепочки в конечном счете сходятся в object.

Для простого ромбовидного наследования MRO будет следующим: C -> A -> B -> object. Сначала методы ищутся в C, потом в A и B (потому что class C(A, B):), в конце, естественно object.

Ромбовидное наследование
Ромбовидное наследование

В более сложных иерархиях потребуется специальный алгоритм.

Алгоритм C3-линеаризации

Какие критерии должны быть для алгоритма разрешения методов?

  1. Каждый класс должен входить в список ровно 1 раз.
  2. Если какой-то класс D наследуется от нескольких классов, допустим, A, B, C (class D(A, B, C):), в таком же порядке они должны появиться в MRO. D -> ... -> A -> ... -> B -> ... -> C -> ... Между ними могут оказаться и другие классы, но исходный порядок должен быть соблюден.
  3. Родители данного класса должны появляться по порядку старшинства. Сначала идут непосредственные родители, потом дедушки и бабушки, но не наоборот.

Алгоритм, который удовлетворяет этим условиям был предложен в 1996 года и называется C3 superclass linearization. Линеаризация в данном случае – это процесс превращения графа наследования в плоский список. А С3 он называется из-за наличия трех основных свойств. Важнейшее свойство здесь – это монотонность – это свойство, которое требует соблюдения в линеаризации класса-потомка того же порядка следования классов-прародителей, что и в линеаризации класса-родителя.

В Python данный алгоритм появился еще в далекой версии 2.3.

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

Почему именно так?

Вернемся к исходному примеру.

class O: ...
class A(O): ...
class B(O): ...
class C(O): ...
class D(O): ...
class E(O): ...

class K1(A, B, C): ...
class K2(B, D): ...
class K3(C, D, E): ...

class Z(K1, K2, K3): ...

Почему Z -> K1 -> A -> K2 -> B -> K3 -> C -> D -> E -> O -> object, а не, например, Z -> K1 -> K2 -> K3 -> A -> B -> C -> D -> E -> O -> object? На самом деле обе из них имею место быть, но по реализации алгоритма получается именно первый вариант. Графически MRO на диаграмме выглядит так:

Нумерация MRO в примере

Попробуем обосновать такой порядок. C начала, конечно же, положим в MRO-список оконечный класс Z. Потом класс K1, так как он идет первым в списке наследования Z. Далее, видим, что идет класс A. Этот класс больше никому не является родителем, кроме как K1, следовательно алгоритм добавляет A сразу после K1, не нарушив никаких правил. После A непосредственно не может идти класс B, так как за ним пришлось бы где-то еще воткнуть K2, и получилось бы так, что K2 будет позже B, что запрещено. Нет! Ставим тогда сначала K2, потом только B. Далее, по схожей причине нужно поставить K3, дабы он не оказался после своего родителя C. Дополняем список классами D и E в их порядке. И остается только завершить список классами O, который общий родитель для всех прочих классов, и object, который родитель для O. Как видите никакой родитель не стоит перед стоим потомком (но может стоять перед чужим). А также порядок следования классов в MRO согласован с порядком наследования.

Вариант реализации алгоритма и его работу на этом примере я разместил здесь (можно запустить и поиграться прямо браузере. Автор реализации – не я. Нашел на Github.

Когда нельзя линеаризовать?

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

class X: ...
class Y: ...
class A(X, Y): ...
class B(Y, X): ...
class G(A, B): ...

Для A порядок X -> Y, а для B – обратный Y -> X. Класс G обязан удовлетворить обоим порядкам наследования, что невозможно, так как они противоречат друг другу. Возникнет ошибка в строке объявления класса G:

    class G(A, B): ...
TypeError: Cannot create a consistent method resolution
order (MRO) for bases X, Y

Или вот второй пример:

class X: ...
class Y(X): ...
class A(X, Y): ...

Здесь класс X наследуется дважды, и куда мы его не поместили в цепочке MRO, он либо нарушит правило старшинства (A -> X -> Y -> object), либо порядка наследования (A -> Y -> X -> object).

Как задать свой порядок MRO?

Это возможно, используя метаклассы. Для «конфликтного» класса мы определим особый метакласс, который переопределяет явно метод mro(), указывая вручную, какой именно должен быть порядок разрешения методов. На первом «неразрешимом» примере решение будет такое:

class X: ...
class Y: ...
class A(X, Y): ...
class B(Y, X): ...

class MyMRO(type):  # наследование type = это метакласс
    def mro(cls):
        return (cls, A, B, X, Y, object)  # явно задаем порядок!

class G(A, B, metaclass=MyMRO):  
    ...

print_mro(G)  # G -> A -> B -> X -> Y -> object
# никаких ошибок!

Ты super()!

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

class C(A, B):
    def __init__(self):
        A.__init__(self)
        B.__init__(self)

Гораздо удобнее обратиться к следующему в цепочка MRO классу-родителю через super().

super() – это особенный прокси-класс к нужному родительскому классу. Вот так правильно можно обратиться к родительскому классу:

class C(B, A):
    def __init__(self):
        super().__init__()

В родительских классах тоже используется super(), поэтому все инициализаторы сработают в порядке MRO.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Дурак по сети на Python: часть 4 – GUI

Кажется, что прошла вечность, но я все-таки довел до ума код игры в Дурака по сети, добавив в него поддержку GUI. В этой статье я затрону основные моменты, которые мне показались интересны во время разработки или вызвали некоторые трудности, дабы дать ответ, как эти трудности преодолеть. Короче, получился мини-гайд траблшутинга для Kivy.

Должен заметить, что с момента последней части (3) код сетевого взаимодействия и логики игры слегка изменился. В основном это были баг-фиксы и вспомогательные метода.

Кстати, весь код по-прежнему находится в репозитории на Github.

Библиотека для GUI

Для графического интерфейса я выбрал библиотеку Kivy. Сейчас она модна и активно развивается. Меня подкупила прежде всего возможность легко собрать приложения под мобильные устройства, включая Андроид, что я и осуществил в итоге. Установка проста до безобразия.

pip isntall Kivy==1.11.1

Но! С этим вы сможете запускать приложение только на компьютере. Чтобы собрать и запустить на телефоне потребуется еще несколько действий и минут ожидания. Но об этом в конце статьи.

Структура проекта

Структура папок и файлов проекта
Файлы проекта

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

main.py подключает модуль net_game.py, где описана сетевая модель игры (класс DurakNetGame). Этот класс обеспечивает сетевое взаимодействие двух клиентов во время игры, также различает, чей сейчас ход, какой клиент победил и тому подобное. Короче, сопоставляет сетевые сущности игровым. А еще он делает проверки на допустимость тех или иных действий. Например, отбивающийся игрок не может сказать «Бито!», так и атакующий не может призвать взять карты, пока другой сам не примет это решение. Класс использует для хранения и обработки игрового состояния другой класс: DurakSerialized из serialization.py.

DurakSerialized в свою очередь наследован от класса Durak, добавив к нему слой функциональности по сохранению игрового состояния в формат JSON и по обратной загрузке в полей класса из JSON строки.

Durak из durak.py занимается исключительно игровой логикой и хранением состояния игры, беспристрастно относительно того, какого именно из клиентов сейчас ход. В нем просто есть понятия «атакующий игрок» и «отбивающийся игрок». Этот класс содержит правила, по которым карты раздаются игрокам, кладутся на стол, бьют друг друга и тому подобное. Он определяет методы атаки, защиты и завершения хода (бито или взял карты, если не побил). В этом класс много полезных свойств для анализа игровой ситуации.

discovery_protocol.py был описан ранее, он занимается поиском соперников по UDP в локальной сети. Он пользуется Networking из network.py – оберткой над сокетами, как впрочем и DurakNetGame также использует пару Networking (пара портов – вынужденная мера для сетевого взаимодействия двух клиентов на одной машине).

durak.kv – разметка виджетов для GUI в Kivy в формате, похожим на YAML. Описывает разные элементы игры, такие как параметры карт, размер шрифта, фон. Он НЕ определяет взаиморасположение элементов на экране, так как все это определяется программно системой анимации, как которую сделать средствами kv-файла, я даже отдаленно не могу вообразить. Зато я придумал супер-простую и эффективную систему для произвольных плавных анимаций, которая и контроллирует положение карт и их вращение. Эта система в файле gui/animation.py.

Файл gui/game_layout.py как раз содержит целевые положения и повороты для всех элементов игры, а именно положения карт в руках игрока и соперника, положение карт на столе, колоду и козырь. Еще он обеспечивает некоторые много-этапные анимации, такие как раздача нескольких карт подряд или разлет карт в стороны в конце матча.

В gui/card.py лежит класс Card, описывающий виджет игральной карты и ее свойства. А в классе, gui/gm_label.py – класс для информационных надписей и ошибок.

main.kv

Файл типа «kv» похож по формату на YAML, но отличается от него в деталях. Он содержит разметку для GUI проекта и определяет свойства графических элементов. Он даже может содержать кусочки Python-кода для обработчиков событий и условного форматирования. Первым делом, мы указываем версию Kivy, для которой написан файл. Также мы можем импортировать нужные символы из основного Python-кода. Все это делается через комментарии:

#:kivy 1.11.1
#:import SPADES durak.SPADES
#:import HEARTS durak.HEARTS
#:import DIAMS durak.DIAMS
#:import CLUBS durak.CLUBS

На верхнем уровне у меня идут описания классов для виджетов, они представляют собой имена классов в треугольных скобках. Соответсвенно, каждый виджет-экземпляр будет иметь свойства, описанные для него в kv-файле после его загрузки. Если название идет без <>, то это значит непосредственное создание виджета, как дочернего элемента.

Например, класс MainLayout (главная наша раскладка игры), наследован от FloatLayout (раскладка, на которой положения всех элементов свободны и задаются программистом, на не вычисляются движком, как в других раскладках). На любом из созданных MainLayout будет создан виджет GameMessageLabel как дочерний элемент. А еще там будет GameButton и так далее.

<MainLayout@FloatLayout>:
  GameMessageLabel:
    id: game_label
    pos: 0, self.width * 0.26
  GameButton:
    text: 'Бито!'
    id: finish_turn_button
    pos_hint: {"center_x":0.33,"center_y":0.32}
    on_press: app.on_finish_button()

А вот и первый код в свойстве on_press: вызывается app.on_finish_button() при нажатии на кнопку. Переменная app автоматически соответствует экземпляру класса приложения DurakFloatApp. Еще есть полезные заранее связанные переменные: self здесь отвечала бы за доступ к этому экземпляру GameButton, а root – к корневому классу описания – MainLayout.

Взглянем на описание класса виджета карты – Card.

<Card>:
  font_name: 'resources/Arial.ttf'
  halign: 'center'
  font_size: '32dp'
  width: '64dp'
  height: '120dp'
  size_hint: None, None

Размер карт я указал в абсолютных величинах. Он зависит только от разрешения экрана (dp = density pixel). Также мне потребовалось скачать и положить в папку ресурсов обыкновенный шрифт Arial, потому что встроенный шрифт даже на последней версии Андроид не поддерживал значки игральных карт ♠♥♣♦ (вот сюрприз в 2020 году!)

Не забывайте снабжать проект всеми нужными шрифтами, особенно если используете редкие символы!

Мне хотелось, чтобы карты имели красивые закругленные углы, поэтому я нашел в одном из старых проектов текстуру с закругленными углами и тенями, добавил ее в проект. Однако, он по размеру не подходил под задуманный размер карты. Поэтому здесь мы применим технику 9-патч, то есть 2 горизонтальными и 2 вертикальными линиями текстура режется на 9 частей, уголки и края остаются без изменений, а белая серединка растягивается до нужных размеров, чтобы с целиком спрайт был величиной как раз с игральную карту.

9-патч для карт
9-patch нарезка.

В Kivy эта техника уже встроена и включается очень просто через параметр border, что содержит кортеж с отступами (порядок их не помню, здесь у нас с каждого края до линии разреза отступы равные – 24 пикселя).

  background_normal: 'resources/rounded_corners.png'
  background_down: 'resources/rounded_corners.png'
  border: (24, 24, 24, 24)

Вот еще пример кода в kv-файле: условный дизайн. Меняю цвет карты, если она выделена (при выборе карты на столе, чтобы покрыть). И меняю прозрачность, если она в состоянии нажатия мышью или пальцем. Каждый раз, когда root.selected меняется, выражение для root.selected будет пересчитано.

  background_color: (1, 1, 1, 1) if not root.selected else (0.9, 0.9, 0.9, 1)
  opacity: 1 if root.state == 'normal' else .8

Внимание! Здесь переменная root уже относится к классу Card, а не к MainLayout.

С поворотом игральной карты оказалось немного интереснее. Оказывается, в Kivy нет свойства с названием rotation, поэтому пришлось колхозить его через матрицы. Перед отрисовкой (блок canvas.before) мы сохраняем старую матрицу в стек, домножаем ее на матрицу поворота, рисуем объект и после (в блоке canvas.after:) восстанавливаем прежнюю матрицу из стека. Вот так это делается:

 canvas.before:
    PushMatrix
    Rotate:
      angle: self.rotation
      origin: self.center
  canvas.after:
    PopMatrix

Еще откровением для меня стал тот факт, что параметры pos_hint и size_hint задаются в долях от единицы (0.2 – это 20% полной ширины, например). А width, height и pos – уже в абсолютных единицах, например, пикселях.

По разметке все, теперь к основному коду.

Конфигурация окна

Вы могли бы заменить в самом верху main.py код вида:

from kivy.config import Config
from util import debug_start

debug_start()

Config.set('graphics', 'width', '480')
Config.set('graphics', 'height', '640')
Config.set('graphics', 'resizable', False)

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

Функция debug_start():

def debug_start():
    import os
    from kivy.config import Config

    x = os.environ.get('x', 50)
    y = os.environ.get('y', 50)

    Config.set('graphics', 'position', 'custom')
    Config.set('graphics', 'left', x)
    Config.set('graphics', 'top', y)

Она задает положение окна при запуске на компьютере и нужна только для отладки. Положение окна читается из переменных среды. Дело в том, что у меня есть скрипт run_two.sh, который запускает сразу два экземпляра игры на одном компьютере (игра то многопользовательская!) и располагает их бок о бок:

#!/usr/bin/env sh
x=50 python main.py &
x=550 python main.py,
Бок о бок два клиента
Бок о бок два клиента

Удобно тестировать и отлаживать проект таким образом!

Загрузка

Вообще в Kivy kv-файл должен загружаться автоматически. Фреймворк ищет файл в папке проекта с названием, совпадающим с именем класса приложения и загружает его. Однако, я столкнулся с проблемой, что на Andorid этого не происходит! Я убедился, что файл добавляется в APK, но Kivy не может его найти и загрузить. Долго бился на этой проблемой, но не найдя решения в интернете, решил пойти в лоб и загрузить kv-файл вручную во время исполнения метода build. Еще прошлось для этого создать пустой класс для MainLayout, так как метод должен вернуть сконструированный корневой виджет приложения.

class MainLayout(FloatLayout):
    ...

class DurakFloatApp(App):  
  def build(self):
        Builder.load_file('durak.kv')
        return MainLayout()

Kivy и многопоточность

Глюки многопоточности

Обработка GUI занимает все рабочее время основного потока игры, и этот поток нельзя блокировать, так как интерфейс тоже заморозится. Поэтому, чтобы ожидать сообщений из сети я создаю отдельный поток. Решение с потоками – не единственное, и вероятно, не самое элегантное, но достаточно простое для реализации и понимания. Однако, и здесь мне встретились подводные камни. При получении пакета из сети сетевая подсистема должна вызвать некоторые изменения в GUI. Поначалу я просто вызывал методы Kivy из дополнительного сетевого потока, что привело к регулярным графическим глюкам, которые вы можете видеть на скриншоте.

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

Да, и это не смотря даже на GIL! Дело в том, что контекст графической библиотеки OpenGL издревле очень не любит, когда его трогают из чужого потока; это приводит к случайным и иногда сложно-уловимым визуальным глюкам.

Как это исправить? Воспользоваться декоратором mainthread, который поставит при вызове оригинальной функции поставит ее выполнение в следующий тик главного цикла приложения в главном потоке. 

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

from kivy.clock import mainthread

class DurakFloatApp(App):
 ...
   @mainthread  # <--- (!)
    def on_found_peer(self, addr, peer_id):
        print(f'Найден соперник {peer_id}@{addr}')
        # делать что-то с GUI!
    ...
    self.discovery = DiscoveryProtocol(self.my_pid, PORT_NO)
    self.discovery.run_in_background(self.on_found_peer)

class DiscoveryProtocol:
    def run_in_background(self, callback: callable):
        def await_with_callback():
            results = self.run()
            callback(*results)
        threading.Thread(target=await_with_callback, daemon=True).start()

Система анимации

Разработку интерфейса я начал со стандартных раскладок, используя BoxLayout, StackLayout и подобные инструменты. Вид игры выходил скучно и топорно, как вы можете заменить на скриншоте из предыдущего поста. На мои вопросы не находилось никаких ответов. Об анимациях даже не приходилось и думать в таком положении вещей, ибо Layout берут на себя контроль над положением и размером виджетов. Вот так выглядела игра в первой редакции:

Плохой дизайн игры.
Не привлекательно. Правда ведь?

Как давнего игродела, меня это совершенно не устраивало, и я решил радикально все переделать. Все игровые объекты будут располагаться на FloatLayout, который дает программисту полный контроль над положением и размерами виджетов. Далее я начертил на бумаге чертеж, где вычислил координаты каждой карты на экране. Например, карты текущего игрока располагаются на дуге окружности радиусом в 0.9 от ширины экрана и центром ниже нижней кромки экрана. Угловые границы дуги: от -30º до 30º относительно вертикали. Также, добавил вращение карт путем матричных преобразований, дабы карты выстраивались в традиционный веер.

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

Чертеж на бумаге

Далее каждый виджет карты я наделил атрибутами target_position и target_rotation – это позиция и угол поворота, куда стремиться карта со временем. Задал такой периодический интервал обновления:

Clock.schedule_interval(self.update, 1.0 / 60.0)

Условно каждый кадр (1/60 долю секунды), реальное положение pos карты становится чуть ближе к ее целевому положению target_position. Аналогично с поворотом. Движение получается экспоненциально затухающим: чем ближе карта к цели, тем она медленнее к ней движется, поэтому анимации получились вполне естественные и приятные.

Код, отвечающий за анимации:

EXP_ATT = 5.0  # регулирует скорость

def update(self, dt):
    df = self.EXP_ATT * dt
    for child in self.root.children:
        if hasattr(child, 'target_position'):
            x, y = child.pos
            # компенсируем положение точки, смещая ее из нижнего левого угла в середину виджета
            x += child.size[0] / 2
            y += child.size[1] / 2
            tx, ty = child.target_position
            if fast_dist(x, y, tx, ty) >= 0.1:
                x += (tx - x) * df
                y += (ty - y) * df
                # возвращаем обратно из середины точку к углу
                child.pos = (x - child.size[0] / 2, y - child.size[1] / 2)
        if hasattr(child, 'target_rotation'):
            tr, r = child.target_rotation, child.rotation
            if abs(tr - r) >= 0.1:
                child.rotation += (tr - r) * df

Видео анимаций:

Все целевые положения карт определяются методами в gui/game_layout.py. К примеру, карты в руке расположены по дуге (веером). Положение карты и ее поворот зависят от количества карт в руке и номера карты, считая с 0 слева направо.

class GameLayout:
    def pos_of_hand(self, i, n, is_my):
        r = 0.9 * self.width
        cx = self.width * 0.5
        cy = -0.8 * r if is_my else self.height + 0.8 * r

        d_ang = 10
        max_ang = min(30, d_ang * n / 2)
        min_ang = -max_ang

        ang = min_ang + (max_ang - min_ang) / (n + 1) * (i + 1)
        ang_r = ang / 180 * pi
        m = 1 if is_my else -1
        return cx + r * sin(ang_r), cy + m * r * cos(ang_r), -m * ang

Использование:

    def update_cards_in_hand(self, is_my, real_cards):
        """ Сортирует карты в руке игрока или соперника сообразно порядку в игре
        После сортировки устанавливаются для каждой карты ее позиция и поворот"""
        # реальный порядок карт в руке задается внутри класса Durak, нам при сортировке карт в руке нужно его соблюсти
        n = len(real_cards)
        for i, card in enumerate(real_cards):
            wcard = self.card2widget.get(card, None)
            if wcard:
                wcard.bring_to_front()  # чтобы каждая следующая была поверх предыдущей
                wcard.set_animated_targets(*self.pos_of_hand(i, n, is_my))

class Card:
    ...
    def set_animated_targets(self, x, y, ang):
        self.target_position = x, y
        self.target_rotation = ang

    def bring_to_front(self):
        parent = self.parent
        parent.remove_widget(self)
        parent.add_widget(self)

Метод bring_to_front переносит виджет карты на передний план, удаляя ее от родителя и вновь добавляя ее поверх прочих виджетов. Методов для управление z-order (порядком отрисовки) виджетов в Kivy я не нашел, пришлось изобретать из синей изоленты…

Текст на картах

Еще один затык произошел к текстом на карте. Я то думал, что текст на метке (Label) должен обновляться реактивно при изменении параметров, если такое поведение задано в kv-файле. Например, если карта открывается, то знак вопроса должен превратиться в масть и достоинство карты. Но этого не происходит автоматически, хотя вот для цвета кнопок это работает нормально. Поэтому в итоге пришлось создать метод, обновляющий текст на карте и привязать его события обновления (self.bind) некоторых переменных. Вдруг кому пригодится такое знание. Код:

class Card(Button):
    nominal = StringProperty()
    suit = StringProperty()
    opened = BooleanProperty(True)
    selected = BooleanProperty(False)
    counter = NumericProperty(-1)

    def update_text(self, *_):
        if self.counter >= 0:
            self.text = str(int(self.counter))
            self.color = (0, 0, 0, 1)
        elif not self.opened:
            self.text = '?'
            self.color = (0, 0.5, 0, 1)
        else:
            s, n = self.suit, self.nominal
            self.text = f'{s}{n}\n\n{n}{s}'
            self.color = (0.8, 0, 0, 1) if self.suit in (DIAMS, HEARTS) else (0, 0, 0, 1)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bind(counter=self.update_text)  # <-- Привязка здесь
        self.bind(opened=self.update_text)

Обновление игрового состояния

Когда один из игроков совершает действие (например, ходит картой), он должен уведомить другого по сети. Изначально я планировал посылать целиком состояние игры, но тогда бы возникла необходимость сравнивать прошлое и новое состояния и на основе их различий перемещать карты. Это не очень тривиально для реализации и отладки. Почему бы просто не снабжать игровое состояние напрямую информацией о том, что изменилось? Так и сделал, модифицировав durak.py. При каждом действии добавляю его описание словарем в last_update:

# при атаке кодируем изменение: 
self.last_update = {
            'action': UpdateAction.ATTACK,
            'card': card,
            'player': self.attacker_index
        }

Декодирование self.game.state.last_update на принимающем клиенте в файле main.py (код сокращен):

@mainthread
    def on_game_state_update(self, *_):
        ...
            up = self.game.state.last_update
            action = up.get('action')
            if action == UpdateAction.ATTACK:
                card = up['card']
                self.layout.put_card_to_field(card)
            elif action == UpdateAction.DEFEND:
                att_card = up['attacking_card']
                def_card = up['defending_card']
                self.layout.put_card_to_field(def_card, att_card)
            elif action == UpdateAction.FINISH_TURN:
                ...
            self.update_hands()
            self.toggle_buttons()
            self.display_whose_turn(delay=0)

Сборка на Андроид

Вам потребуется дополнительная подготовка. Надеюсь, у вас Linux или macOS? Можно ли собрать из-под Windows, я не знаю. Еще недавно было нельзя. По крайней мере вы можете просто установить виртуальную машину с образом Linux, например, Ubuntu и продолжить сборку с виртуалки.

Для сборки нам потребуется buildozer.

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

wget https://github.com/HeaTTheatR/KivyMD-data/raw/master/install-kivy-buildozer-dependencies.sh

chmod +x install-kivy-buildozer-dependencies.sh

./install-kivy-buildozer-dependencies.sh

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

Настройки Бульдозер для сборки игры я уже произвел. Они лежат в файле buildozer.spec. Основные настройки, которые я изменил:

  • Название игры: title = Durak UDP
  • Положение файлов, там же, где и этот файл: source.dir = .
  • Имя пакета: package.name = durak_kivy_lan
  • Префикс пакета: package.domain = ru.tirinox
  • Расширения файлов, включаемых в пакет: source.include_exts = py,png,jpg,kv,atlas,ttf
  • Ориентация – портретная: orientation = portrait
  • Полный экран: fullscreen = 1
  • Обязательно! Разрешение на доступ к сети: android.permissions = INTERNET

Подключите устройство по USB к компьютеру. Включите режим для разработчиков. В меню настроек разработчика включите режим отладки по USB. Когда, телефон спросит подтвердить, доверяете ли вы этому компьютеру, согласитесь. Иногда это окно не выходит, если вы при подключении кабеля выбрали «Только Зарядка», правильный выбор – «Передача файлов».

Теперь выполните из папки проекта команду:

 buildozer android debug deploy run

Она соберет проект и запустит его на подключенном смартфоне!

Дурак уже на телефоне

Вот так это выглядит у меня.

Заключение

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

В итоге Kivy показался мне достаточно интересным и удобным фреймворком, но в нем очень много неясных моментов. Имеется довольно скудная документация и недостаточность примеров. Поэтому подобными статьями попытаюсь чуть-чуть улучшить ситуацию, решая проблемы при создании реальных приложений. Разработчикам Kivy и для Kivy – респект и удачи на вашем пути!

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

Спасибо за чтение! Буду рад вашим звездочкам на Github.

Предыдущие части:

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway  

Дурак по сети на Python: часть 3 – игра

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

Да, номера портов вы выбираете сами. Старайтесь выбирать порты так, чтобы они не перекрывались с существующими сервисами (список популярных портов). Порты 0-1023 системные, их вы не займете просто так без особых прав. Берите любые порты из диапазона 1024-49151, лучше ближе к концу, так меньше вероятности конфликтов.

Если для протокола обнаружения нам нужен был только один порт 37020, то для обмена сообщениями между клиентами я возьму 2 порта: 37020 и 37021. Почему два? Потому что мы с вами отлаживаем код обычно на одной машине. А двум программам слушать один и тот же порт на одной машине не получится, даже переиспользуя порт. Сообщения просто будут приходить только на один из клиентов во всех случаях, а второй не получит ничего. Следовательно делатем так: один клиент слушает 37020 и отправляет сообщения на 37021, а другой – наоборот слушает 37021 и отправляет сообщения на 37020. Такая схема будет работать и на одной машине, и на разных машинах.

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

Потоки данных между игроками

Итак, костяк нашей игры будет лежать в модуле main.py:

from render import ConsoleRenderer
from net_game import DurakNetGame
from discovery_protocol import DiscoveryProtocol
from util import rand_id

PORT_NO = 37020
PORT_NO_AUX = 37021

def main():
    my_pid = rand_id()  # создадим себе ID случайно

    discovery = DiscoveryProtocol(my_pid, port_no=PORT_NO)
    print('Сканирую локальную сеть...')
    (remote_addr, _port), remote_pid = discovery.run()
    del discovery

    renderer = ConsoleRenderer()
    game = DurakNetGame(renderer, my_pid, remote_pid, remote_addr, [PORT_NO, PORT_NO_AUX])
    game.start()


if __name__ == '__main__':
    main()

Класс DiscoveryProtocol был описан в части 2, а класс ConsoleRenderer аж в первой части. Мы же сейчас обратимся к классу DurakNetGame, код которого я поместил в файл net_game.py.

Класс требует ID локального игрока, а также только что найденные ID удаленного игрока и его IP адрес в сети. Еще ему нужна пара номеров портов для коммуникации.

Так как у каждого есть свой ID и это просто числа, то мы выбираем того, кто ходит первый просто сравнивая эти числа: у кого меньше, тот и первый, а другой про себя решит точно также, что он второй. Аналогично идет выбор портов. Если я первый (мой ID меньше твоего), то слушаю, допустим, порт 37020. Другой клиент также совершенно однозначно решит, слушать порт 37021, сравнив свой ID с моим и поняв, что он больше. Вот такой элементарный алгоритм консенсуса. Так инициализируется класс сетевой игры:

class DurakNetGame:
    def __init__(self, renderer: GameRenderer, my_id, remote_id, remote_addr, ports):
        self._renderer = renderer

        self._game = DurakSerialized()  # геймплей

        self._my_id = int(my_id)
        self._remote_id = int(remote_id)
        self._remote_addr = remote_addr

        # проверка на адекватность ID
        assert self._my_id != 0 and self._remote_id != 0 and self._my_id != self._remote_id

        # кто ходит первый выбираем просто сравнивая ID (они же случайные)!
        me_first = self._my_id < self._remote_id
        # мой индекс 0 если я первый, и 1 иначе. у соперника наоборот
        self._my_index = 0 if me_first else 1

        # две сетевых примочки на разны портах
        network1 = Networking(port_no=ports[0])
        network2 = Networking(port_no=ports[1])

        # кто слушает какой порт выбираем также на базе сравнения ID как чисел
        self._receiver = network1 if me_first else network2
        self._receiver.bind("")

        self._sender = network2 if me_first else network1

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

    def _new_game(self):
        self._game = DurakSerialized()        # игрок с индексом 0 создает игру!
        self._send_game_state()        # и отсылает ее сопернику
        self._renderer.render_game(self._game, self._my_index)

Сериализация состояния игры происходит в классе DurakSerialized – наследнике класса Durak. Она очень простая, почти без ухищрений: просто собираем в словарь разные аспекты игры. Код сериализации находится в файле serialization.py. Пример состояния игры после сериализации:

{'trump': '♦', 'attacker_index': 0, 'deck': [('J', '♠'), ('10', '♦'), ('8', '♣'), ('10', '♠'), ('A', '♦'), ('6', '♣'), ('7', '♣'), ('6', '♠'), ('Q', '♥'), ('J', '♣'), ('K', '♣'), ('6', '♦'), ('9', '♣'), ('6', '♥'), ('7', '♦'), ('9', '♠'), ('A', '♣'), ('7', '♥'), ('10', '♣'), ('A', '♠'), ('J', '♦'), ('8', '♥'), ('A', '♥'), ('7', '♠')], 'winner': None, 'field': [(('8', '♠'), None), (('8', '♦'), ('Q', '♦'))], 'players': [{'index': 0, 'cards': [('9', '♥'), ('J', '♥'), ('Q', '♠'), ('Q', '♣')]}, {'index': 1, 'cards': [('9', '♦'), ('10', '♥'), ('K', '♠'), ('K', '♥'), ('K', '♦')]}]}

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

    def start(self):
        print(f'Мой ID #{self._my_id}, мой индекс {self._my_index}')
        print(f'Удаленный адрес {self._remote_addr}')
        self._renderer.help()

        if self._my_index == 0:
            # игрок с индексом 0 создает игру!
            self._new_game()

        self._receiver.run_reader_thread(self._on_remote_message)
        self._game_loop()

Первая часть – отдельный поток, который слушает порт на прием. На этот порт приходят сообщения от клиента соперника, содержащие закодированное состояние игры, если оно изменится в результате действий и ходов соперника. Ведь игра в дурака – синхронная: пока я отбиваюсь, ты можешь подкидывать мне еще карт того же достоинства, что уже лежат на столе. В networking.py определен метод запуска потока чтения, который вызывает стороннюю функцию-обработчик callback.

 def run_reader_thread(self, callback):
        """
        Запускает отдельный поток, чтобы получать данные из сокета
        :param callback: функция, которая вызывается, если получены данные
        """
        def reader_job():
            while True:
                data, _ = self.recv_json()
                if data:
                    callback(data)
        # daemon=True, чтобы не зависал, если выйдет основной поток
        threading.Thread(target=reader_job, daemon=True).start()

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

    def _on_remote_message(self, data):
        action = data['action']
        if action == 'state':
            self._game = DurakSerialized(data['state'])  # обновить остояние
            print('Пришел ход от соперника!')
            self._renderer.render_game(self._game, self._my_index)
        elif action == 'quit':
            print('Соперник вышел!')
            exit(0)

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

    def _game_loop(self):
        while True:
            q = input('Ваш ход (q = выход)? ')

            parts = q.lower().split(' ')
            if not parts:
                continue

            command = parts[0]

            good_move = False  # флаг, удачный ли был ход после ввода команды
            g = self._game
            try:
                if command == 'f':
                    good_move = self._handle_finish()
                elif command == 'a' and g.attacker_index == self._my_index:
                    good_move = self._handle_attack(parts)
                elif command == 'd' and g.attacker_index != self._my_index:
                    good_move = self._handle_defence(parts)
                elif command == 'q':
                    print('Вы вышли из игры!')
                    self._send_quit()
                    break
                else:
                    print('Неизвестная команда.')
            except IndexError:
                print('ОШИБКА! Неверный выбор карты')
            except ValueError:
                print('Введите число через пробел после команды')

            if good_move:
                # если ход удачный, пошлем копию состояния игры 
                self._send_game_state()
                self._renderer.render_game(g, self._my_index)

                # проверка на окончание игры (если есть победитель, в g.winner - этог индекс)
                if g.winner is not None:
                    outcome = 'Вы победили!' if g.winner == self._my_index else 'Вы проиграли!'
                    print(f'Игра окончена! {outcome}')
                    break

Как и в случае локальной игры из первой части, мы читает строку с клавиатуры, где первый символ команда, а второй через пробел – опциональный аргумент. Команды будут такие:

  • f – завершить ход (сказав «Бито!» или забрав карты, зависит от того, кто вызвал команду)
  • a 2 – атакующий ход (аргумент – номер карты, с которой ходить или подбросить)
  • d 1 – защитный ход (аргумент – номер карты, которой отбиться). Если выбор карты для покрытия неоднозначен, то спросим пользователя о том, какую именно карту следует отбить.
  • q – выход из игры, уведомив соперника, чтобы тот тоже завершил цикл и вышел из программы.
    def _game_loop(self):
        while True:
            q = input('Ваш ход (q = выход)? ')

            parts = q.lower().split(' ')
            if not parts:
                continue

            command = parts[0]

            good_move = False  # флаг, удачный ли был ход после ввода команды
            g = self._game
            try:
                if command == 'f':
                    good_move = self._handle_finish()
                elif command == 'a' and g.attacker_index == self._my_index:
                    good_move = self._handle_attack(parts)
                elif command == 'd' and g.attacker_index != self._my_index:
                    good_move = self._handle_defence(parts)
                elif command == 'q':
                    print('Вы вышли из игры!')
                    self._send_quit()
                    break
                else:
                    print('Неизвестная команда.')
            except IndexError:
                print('ОШИБКА! Неверный выбор карты')
            except ValueError:
                print('Введите число через пробел после команды')

            if good_move:
                # если ход удачный, пошлем копию состояния игры
                self._send_game_state()
                self._renderer.render_game(g, self._my_index)

                # проверка на окончание игры (если есть победитель, в g.winner - этог индекс)
                if g.winner is not None:
                    outcome = 'Вы победили!' if g.winner == self._my_index else 'Вы проиграли!'
                    print(f'Игра окончена! {outcome}')
                    break

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

    def _send_game_state(self):
        self._sender.send_json({
            'action': 'state',
            'state': self._game.serialized()
        }, self._remote_addr)

    def _send_quit(self):
        self._sender.send_json({
            'action': 'quit'
        }, self._remote_addr)

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

Атака. Атаковать, как понятно, можно только в свой ход. Возвращает True, если атака успешна, иными словами, если игрок сходил с одной из правильных карт (нельзя подкинуть десятку, если на столе только, например, семерки, валеты и дамы).

    def _handle_attack(self, parts):
        g = self._game
        index = int(parts[1]) - 1
        card = g.current_player.cards[index]
        if not g.attack(card):
            print('ОШИБКА! Вы не можете ходить этой картой!')
            return False
        else:
            return True

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

    def _handle_defence(self, parts):
        g = self._game
        index = int(parts[1]) - 1
        new_card = g.opponent_player.cards[index]
        if g.field:
            variants = g.defend_variants(new_card)

            print(f'variants {variants} - {new_card}')
            if len(variants) == 1:
                def_index = variants[0]
            elif len(variants) >= 2:
                max_pos = len(g.field)
                def_index = int(input(f'Какую позицию отбить {new_card} (1-{max_pos})? ')) - 1
            else:
                print('Вам придется взять карты!')
                return False

            old_card = list(g.field.keys())[def_index]
            if not g.defend(old_card, new_card):
                print('ОШИБКА! Нельзя так отбиться!')
            else:
                return True
        else:
            print('Пока нечего отбивать!')
            return False

Завершение хода. Если защищающийся игрок не отбил все карты, то завершение хода вынудит его забрать со стола все карты себе в руку. Ход не переходит. Если же он успешно отбился, то завершение хода – это «Бито!», ход перейдет, а все карты со стола уйдут из игры в стопку «бито».

    def _handle_finish(self, my_turn):
        g = self._game
        if g.field:
            if my_turn and g.any_unbeaten_cards:
                print('Не можете вынудить соперника взять карты!')
                return False
            elif not my_turn and not g.any_unbeaten_cards:
                print('Только атакующий может сказать "Бито!"')
                return False
            else:
                r = g.finish_turn()
                if r == g.GAME_OVER:
                    r_str = 'игра окончена!'
                elif r == g.TOOK_CARDS:
                    r_str = 'взяли карты.'
                else:
                    r_str = 'бито.'
                print(f'Ход завершен: {r_str}')
                return True
        else:
            print('Пока ход не сделал, чтобы его завершить!')
            return False

Вот и весь код игры! Теперь можно запустить пару клиентов игры на одном или разных компьютерах и играть в дурака!

Напомню, что весь код в репозитории на Github. Ставьте звездочки, мне будет очень приятно.

Надеюсь, вам понравилось. Может быть прикрутим к игре графический интерфейс и нормальное управление?

Предыдущие части:

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈