Сделаем самую примитивную реализацию клиент-серверной игры для тестирования архитектуры.
- Стало однозначно понятно, на каком языке писать. На Питоне, потому что тогда игру можно назвать Пирог (PyRogue).
Если вам нужно освежить знания по ООП в Питоне:
Нам понадобятся следующие структуры данных: GameState, Request, Response, Command.
Это краеугольные камни, на которых будет громоздиться вся немыслимая сложность игры. Поэтому разобраться с ними надо хорошенько.
GameState
Хранит состояние игры. В дальнейшем там будет куча всего, но сейчас нужен максимальный примитив. Атрибут map – карта уровня в виде строки, где @ обозначает позицию игрока:
--------@--------
Ходить можно только влево и вправо. Это частный случай двумерной карты, так что всё в порядке.
У GameState есть метод set_data(), чтобы обновлять данные. Это делается без затей – map просто переприсваивается.
Request
Чтобы совершить действие, клиент должен послать серверу запрос на выполнение действия. Запрос оформляется в виде класса Request, который должен быть понятен серверу. В текущем виде он имеет два атрибута: type сообщает серверу, какого действия от него хотят, а params, если необходимо, предоставляет дополнительные параметры.
Клиенту сейчас доступны только два действия – ходить вправо и ходить влево. Поэтому у Request есть две константы для type: MOVE_L и MOVE_R.
Клиент может сразу генерировать класс Request, но это не обязательно. Транслировать любой запрос клиента в класс Request – задача прокси.
Response
Сделав обновления в GameState, сервер возвращает ответ в виде списка объектов класса Response.
У Response тоже есть тип (type) и структура дополнительных данных (data). Пока что сервер возвращает всего два типа ответов: INIT в самом начале и UPDATE в дальнейшем, а data у них это просто карта из GameState.
Класс Response должен быть понятен клиенту. Хотя сервер может сразу генерировать его, это не обязательно. Переделывать ответы сервера в объекты класса Response – также задача прокси.
Зачем нужны Response?
При клиент-серверном взаимодействии ответы сервера естественны, так как это единственный способ получить обновления.
Но даже в локальном варианте они будут нужны.
Рассмотрим такой случай. Игрок перешёл из клетки в клетку, а рядом оказались крыса, снеговик и сонная тетеря. За один ход произошли следущие события:
- крыса укусила игрока и заразила его чумой
- игрок задавил крысу
- сонная тетеря усыпила игрока
- снеговик заморозил игрока
Конечный итог, который присутствует в GameState:
- крыса исчезла с карты
- изменились статусы игрока – у него уменьшилось здоровье, он болеет чумой, спит и заморожен
Если просто отрисовать текущее состояние игры, оно будет корректным, но будет непонятно, что и почему произошло.
Другое дело, когда у нас есть список из объектов Response. Первый описывает, как игрока укусила крыса. Второй описывает, как игрок задавил крысу. И так далее. Если мы отрисуем финальное состояние и также выведем все полученные Response в лог, то игрок сможет по логу понять, что случилось.
При этом трактовать Response можно очень широко. Например, клиент может не выводить сообщения в лог, а изображать в анимированном виде, как мышь атакует игрока. Все необходимые для этого данные должны быть в Response – кто атаковал, кого атаковал, каким способом атаковал, с каким результатом. Даже тексты для лога могут или приходить от сервера, или генерироваться клиентом самостоятельно на основе данных в Response.
Command
Если Request это запрос на выполнение действия, то Command – это непосредственно действие.
Класс Command делается в соответствии с шаблоном проектирования Команда, то есть инкапсулирует в себе всё необходимое, чтобы совершить какие-то изменения в GameState.
Почему сервер, получив Request, не может самостоятельно совершить эти изменения без внедрения команды? Может, конечно. Но ещё раз повторим – команды инкапсулируют необходимые действия и данные, и поэтому мы можем разрабатывать каждую команду так, чтобы она была автономной и независимой от других обстоятельств.
Клиент сейчас может послать два типа Request: MOVE_L и MOVE_R. Оба типа запросов транслируются в класс MoveCommand, отнаследованный от Command:
В качестве ресивера, то есть того объекта, над которым производятся изменения, используется атрибут game_data. Было бы вернее назвать его именно receiver, так как команды могут работать с чем угодно. Но сейчас они работают с только с game_data.
Метод execute() исполняет команду. Команда смотрит на свой дополнительный параметр: если он равен 'L', то смещает игрока влево. Если 'R', то вправо. И обновляет game_state.map.
Теперь мы можем написать ещё тысячу команд, и все они будут работать, и нам не надо будет каждый раз лезть в код сервера и что-то там ломать по пути.
Кроме того, каждая команда после работы будет возвращать Response, и у нас будет чёткий механизм получения результатов.
В продолжении будет разработка непосредственно клиента и сервера, которые уже написаны и работают:
Читайте дальше: