Это моя первая статья и серия из статьей о написании мультиплеерной игры на Python с использованием библиотеки Pygame. В ходе этих "серий" мы напишем небольшую 2Д игру-стрелялку с мультиплеерным режимом.

Как работает мультиплеер? Введение

В мультиплеерных играх клиенты игры взаимодействуют с сервером, получая данные от него и отправляя новые(свои). Request - отправка своих данных, response - полученные данные от сервера. Клиент может отправить данные о своем передвижении, сервер проверяет это и меняет позицию игрока. А сервер постоянно делится данными с клиентами, отправляя им общие данные о позиции, полете и прочей игровой информацией.

Пишем базовую часть игры

Для начала напишем код для игры, чтобы создавалось окно, и локально выводился спрайт игрока. Использовать для этого как говорил ранее мы будем библиотеку Pygame. Она предназначена для создания легких игр в 2Д пространстве, и используется в частности программистами начального и среднего уровней, которые заинтересованы в создании игр и улучшении собственных навыков кодирования на Python.

import pygame
from player import Player
 
pygame.init() # Инициализируем pygame
 
sсreen = pygame.display.set_mode((800, 600)) # Создаем окно с разрешением 800x600
 
clock = pygame.time.Clock() # Создаем объект для работы со временем внутри игры

player = Player()
 
while True:
    for event in pygame.event.get(): # Перебираем все события которые произошли с программой
        if event.type == pygame.QUIT: # Проверяем на выход из игры
            exit()

    sсreen.fill((0, 0, 0)) # Заполняем экран черным
    sсreen.blit(player.image, player.rect) # Рисуем игрока
    pygame.display.update() # Обновляем дисплей
 
    clock.tick(60) # Ограничиваем частоту кадров игры до 60

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

import pygame

# Создаем класс, который взаимствован от класса Sprite внутри pygame
class Player(pygame.sprite.Sprite):

    # Инициализация
    def __init__(self):
        pygame.sprite.Sprite.__init__(self)

        # Загружаем спрайт игрока
        self.image = pygame.image.load("player.png").convert_alpha()
        # (400, 300) размеры экрана / 2, не стал делать в переменной
        self.rect = self.image.get_rect(center=(400, 300))

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

Спрайт игрока взял у своего давнего друга fisash, ссылка на его ВК - vk.com/fisash
Спрайт игрока взял у своего давнего друга fisash, ссылка на его ВК - vk.com/fisash

Сервер

Напишем сервер для того чтобы игроки (клиенты) могли взаимодействовать друг с другом через него, данные будут приниматься и отправлятся в формате JSON.

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

import socket
from threading import Thread
import json

HOST, PORT = 'localhost', 8080 # Адрес сервера
MAX_PLAYERS = 2 # Максимальное кол-во подключений

class Server:

    def __init__(self, addr, max_conn):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind(addr) # запускаем сервер от заданного адреса

        self.max_players = max_conn
        self.players = [] # создаем массив из игроков на сервере
        
        self.sock.listen(self.max_players) # устанавливаем максимальное кол-во прослушиваний на сервере
        self.listen() # вызываем цикл, который отслеживает подключения к серверу

    def listen(self):
        while True:
            if not len(self.players) >= self.max_players: # проверяем не превышен ли лимит
                 # одобряем подключение, получаем взамен адрес и другую информацию о клиенте
                conn, addr = self.sock.accept()

                print("New connection", addr)

                Thread(target=self.handle_client, args=(conn,)).start() # Запускаем в новом потоке проверку действий игрока

    def handle_client(self, conn):

        # Настраиваем стандартные данные для игрока
        self.player = {
            "id": len(self.players),
            "x": 400,
            "y": 300
        }
        self.players.append(self.player) # добавляем его в массив игроков

        while True:
            try:
                data = conn.recv(1024) # ждем запросов от клиента

                if not data: # если запросы перестали поступать, то отключаем игрока от сервера
                    print("Disconnect")
                    break

                # загружаем данные в json формате
                data = json.loads(data.decode('utf-8'))

                # запрос на получение игроков на сервере
                if data["request"] == "get_players":
                    conn.sendall(bytes(json.dumps({
                        "response": self.players
                    }), 'UTF-8'))

                # движение
                if data["request"] == "move":

                    if data["move"] == "left":
                        self.player["x"] -= 1
                    if data["move"] == "right":
                        self.player["x"] += 1
                    if data["move"] == "up":
                        self.player["y"] -= 1
                    if data["move"] == "down":
                        self.player["y"] += 1
            except Exception as e:
                print(e)
                break

        self.players.remove(self.player) # если вышел или выкинуло с сервера - удалить персонажа

if __name__ == "__main__":
    server = Server((HOST, PORT), MAX_PLAYERS)

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

Клиент. Отображение всех игроков

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

import socket
import json
from threading import Thread

class Client:

    def __init__(self, addr):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.connect(addr) # подключаемся к айпи адресу сервера

        self.players = [] # Создаем массив для хранения данных об игроках
        Thread(target=self.get_players).start() # Делаем новый поток с циклом, в которым берем данные об игроках

    def get_players(self):
        while True:
            self.sock.sendall(bytes(json.dumps({
                "request": "get_players"
            }), 'UTF-8')) # Отправляем серверу запрос для получения игроков

            received = json.loads(self.sock.recv(1024).decode('UTF-8'))

            self.players = received["response"] # сохраняем результат запроса в переменную

    def move(self, side):
        self.sock.sendall(bytes(json.dumps({
            "request": "move",
            "move": side
        }), 'UTF-8')) # Отправляем серверу запрос для получения игроков

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

Добавляем в класс Player возможность задать координаты.

# Создаем класс, который взаимствован от класса Sprite внутри pygame
class Player(pygame.sprite.Sprite):

    # Инициализация
    def __init__(self, pos):
        pygame.sprite.Sprite.__init__(self)

        # Загружаем спрайт игрока
        self.image = pygame.image.load("player.png").convert_alpha()
        self.rect = self.image.get_rect(center=pos)

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

import pygame
from player import Player
from client import Client
 
pygame.init() # Инициализируем pygame

HOST, PORT = "localhost", 8080 # Адрес сервера

client = Client((HOST, PORT)) # Создаем объект клиента
 
sсreen = pygame.display.set_mode((800, 600)) # Создаем окно с разрешением 800x600
 
clock = pygame.time.Clock() # Создаем объект для работы со временем внутри игры

while True:
    for event in pygame.event.get(): # Перебираем все события которые произошли с программой
        if event.type == pygame.KEYDOWN:
            if event.key == ord('a'):
                client.move("left")
            if event.key == ord('d'):
                client.move("right")
            if event.key == ord('w'):
                client.move("up")
            if event.key == ord('s'):
                client.move("down")

        if event.type == pygame.QUIT: # Проверяем на выход из игры
            client.sock.close()
            exit()

    sсreen.fill((0, 0, 0)) # Заполняем экран черным

    for i in client.players:
        print(i)
        player = Player((i["x"], i["y"]))
        sсreen.blit(player.image, player.rect) # Рисуем игрока

    pygame.display.update() # Обновляем дисплей
 
    clock.tick(60) # Ограничиваем частоту кадров игры до 60

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

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

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


  1. ivankudryavtsev
    22.08.2023 15:40
    +5

    Не надо заискивать перед сообществом Хабра, тут и хорошую статью заговнить могут, а могут и бездарную залайкать… Статья нормальная, особенно для первой.


  1. dixaka
    22.08.2023 15:40
    -2

    имба


  1. Fisash
    22.08.2023 15:40

    Для первого раза очень хорошая статья


  1. cher11
    22.08.2023 15:40
    +3

    Для примеров общего концепта - неплохо.

    Но явно стоило бы отметить, что в реальных и клиентах и серверах и while true кода поменьше, и данные, скорее всего, гоняются и не в виде JSON, а в бинарном формате (трафик), и не в виде полного дампа (предположу, что летят изменения + есть какой-то механизм синхронизации, детекта её сбоев и доотправки потерянных пакетов).


    Было бы интересно про такие оптимизации почитать. А то сейчас, на первый взгляд, сама отрисовка честно выдает 60 кадров, а вот класс клиентов от души терзает сервер настолько быстро, насколько вообще может :)


    1. ivankudryavtsev
      22.08.2023 15:40

      Дело не только (и возможно не столько) в трафике (JSON), но и в том, что кодирование, декодирование JSON обычно медленнее, чем имеющиеся форматы типа Protobuf. Что важно для игр с низкой задержкой.


  1. kovserg
    22.08.2023 15:40
    -1

    Дурацкий вопрос: Зачем использовать pygame если есть pyglet?