Метка: problem

Дурак по сети на 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