Как я свою онлайн игру создавал. Часть 1: Работа с сетью



Привет всем! У меня недавно был отпуск, и появилось время спокойно попрограммировать свои домашние проекты. Захотел я, значит, свою простенькую онлайн игру сделать на Rust. Точнее, простенькую 2D стрелялку. Решил сначала сделать сетевую часть, а там уже видно будет, что да как. Так как жанр предполагает экшен во все поля, поэтому решил использовать протокол UDP. Начал проектировать архитектуру сетевой части. Понял что можно это все вынести в отдельную библиотеку. Получившуюся библиотеку я еще и на crates.io залил, под лицензией MIT, потому, что: а) Мне самому будет ее потом удобнее оттуда в свои проекты подключать. б) Может она еще кому-то пригодится и принесет пользу. За подробностями добро пожаловать под кат.

Ссылки


-> Исходники
-> Библиотека на crates.io
-> Документация

Пример использования


Клиент


//Подключаем нашу библиотеку
use victorem;

fn main() -> Result<(), victorem::Exception> {
//Создаем сокет, который слушает порт 11111 и отправляет данные на адрес 127.0.0.1:22222 
    let mut socket = victorem::ClientSocket::new("11111", "127.0.0.1:22222")?;
    loop {
//Отправляем байты на сервер
        socket.send(b"Client!".to_vec());
//Пытаемся прочитать данные от сервера. В случаем успеха преобразуем байты в строку и выводим результат в консоль
        socket.recv().map(|v| String::from_utf8(v).map(|s| println!("{}",s)));
    }
}

Сервер


//Подключаем нашу библиотеку
use victorem;
use std::time::Duration;
use std::net::SocketAddr;

//Собственно, наша игра. В ней будут храниться все данные нашей игры и вся ее логика.
struct ClientServerGame;

//Реализуем для нашей игры протокол Game, чтобы ее можно было запустить на нашем сервере
impl victorem::Game for ClientServerGame {
//Вызывается, когда от клиента приходит команда. Возвращает булево значение и если возвращает false, то сервер останавливается.
    fn handle_command(&mut self, delta_time: Duration, commands: Vec<Vec<u8>>, from: SocketAddr) -> bool {
        for command in commands {
            String::from_utf8(command).map(|s| println!("{}",s));
        }
        true
    }
//Вызывается сервером автоматически раз в 30 миллисекунд. Если вернуть пустой массив байт, то он не будет отправлен. Если же в векторе есть данные, то отправляем их на сервер.
    fn draw(&mut self, delta_time: Duration) -> Vec<u8> {
        b"Server!".to_vec()
    }
}

fn main() -> Result<(), victorem::Exception> {
//Создаем сервер, который будет передавать данные нашей ClientServerGame и будет слушать порт 22222
    let mut server = victorem::GameServer::new(ClientServerGame, "22222")?;
//Запускает бесконечный цикл игры и блокирует текущий поток.
    server.run();
    Ok(())
}

Внутренне устройство


Вообще, если бы я использовал для сетевой части Laminar а не сырые UDP сокеты, то код можно было раз в 100 сократить, а так я использую алгоритм описанный в этой серии статей — Сетевое программирование для разработчиков игр.
Архитектура сервера предполагает получение от клиентов команд (например, нажатие клавиши мыши или какой-нибудь кнопки на клавиатуре) и отправка им состояния (например, текущую позицию юнитов и направления куда они смотрят) с помощью которого клиент сможет отобразить картинку игроку.

На сервере


//Возвращает айди последнего полученного пакета и кодирует в битах u32 числа последовательность пакетов до него, где 0 - пакет получен, 1 - пакет не получен, и клиент должен отправить его повторно.
 pub fn get_lost(&self) -> (u32, u32) {
        let mut sequence: u32 = 0;
        let mut x = 0;
        let mut y = self.last_received_packet_id;
        while x < 32 && y > 1 {
            y -= 1;
            if !self.received.contains(&y) {
                let mask = 1u32 << x;
                sequence |= mask;
            }
            x += 1;
        }
        (sequence, self.last_received_packet_id)
    }

На клиенте


//Декодирует из айди последнего полученного пакета (max_id) и закодированной в битах последовательности пакетов после него (sequence) потерянные пакеты. Извлекает их из кеша и возвращает в качестве своего результата
fn get_lost(&mut self, max_id: u32, sequence: u32) -> Vec<CommandPacket> {
        let mut x = max_id;
        let mut y = 0;
        let mut ids = Vec::<u32>::new();
 //Если сервер не получил последний отправленный и, следовательно, последний добавленный в кеш пакет, то его тоже нужно повторно отправить.
        let max_cached = self.cache.get_max_id();
        if max_cached != max_id {
            ids.push(max_cached);
        }
        while x > 0 && y < 32 {
            x -= 1;
            let mask = 1u32 << y;
            y += 1;
            let res = sequence & mask;
            if res > 0 {
                ids.push(x);
            }
        }
        self.cache.get_range(&ids)
    }

Эпилог


Вообще-то, можно было алгоритм доставки команд проще сделать. На сервере принимать только тот пакет у которого айди больше айди последнего полученного пакета на +1, а остальные отбрасывать. Отсылать клиенту айди последнего полученного пакета. На клиенте держать кеш всех команд, которые пользователь пытался оправить серверу. Каждый раз, когда от сервера приходит новое состояние с айди, последнего полученного сервером пакета, удалять из кеша его и все пакеты с айди меньше, чем у него. Все оставшиеся пакеты снова отправляем на сервер.
Далее, когда буду делать уже саму игру, в процессе использования буду дальше улучшать и оптимизировать либу. Возможно, найду еще какие-то баги.

Нашел тут проект игрового сервера на C# — Networker + на Rust есть leaf вроде, как аналог гейм сервера на Go — leaf. Только там разработка в процессе.

P.S. Дорогой друг, если ты новичок и решил почитать мой код к этому проекту и увидишь там тесты, которые я написал. То вот тебе мой совет — не делай так как я. Я там все в кучу в тестах намешал и не соблюдал шаблон «ААА» (погугли что это). Так делать не надо в продакшене. Нормальный тест должен проверять что-то одно, а не несколько условий сразу и должен состоять из этапов:

  1. Ты устанавливаешь свои переменные;
  2. Ты выполняешь действие, которое хочешь протестировать;
  3. Ты сравниваешь получившийся результат с ожидаемым.

Например,

 fn add_one(x:usize) -> usize {
        x+1
    }

    #[test]
    fn add_one_fn_should_add_one_to_it_argument(){
        let x = 2;
        let expected = x+1;
        /////////////////////////
        let result = add_one(x);
        //////////////////////////////////
        assert_eq!(expected,result);
    }
Поделиться публикацией

Комментарии 13

    –11
    Чуть не забыл — Настя, милая, спасибо за помощь с орфографией и пунктуацией. <3
      +11
      Так. Код вижу. Статьи не вижу. Или это такой вариант принципа «код должен самодокументироваться»?
        –6

        Что бы вы хотели ещё увидеть в статье? Чего конкретно вам в ней не хватает?

          –7

          Старая мудрость про то что обычно люди судят по себе — Я сам когда читаю статьи игнорирую текст, быстро просматриваю код который представлен в статье и иду читать комментарии поэтому сам больше предпочитаю код в статью добавлять чем слова.

            +4
            Мне кажется, вы не совсем правильно понимаете что такое статья. Статья — не исходник, в ней должно быть описание задачи словами и желательно детальное.

            Или где вы такие «статьи» видели в которых один код, на гитхабе?
              0

              Понял — принял. Таки да, сейчас думаю что надо было написать про то зачем вообще там какой то алгоритм для пакетов. Почему UDP. Почему всем разсылаю раз в 30 миллисекунд состояние почему вместо этого просто не отвечаю на запросы от клиента как это делают веб сервера с http. Это из того что я надумал. Что бы вы лично хотели бы ещё увидеть в статье?

                +6
                Я думаю, если бы вы все расписали как описали в коментарии, было бы намного лучше. С пояснениями и объяснениями, что зачем и почему. Тогда и читалось бы все легче и было бы еще интересней. Все таки чужой код читать с нуля (пусть и с коментами) не всем нравится, надо ведь потратить время, что бы в нем разобраться. Ну а если все разжевать словами и уже потом приводить примеры кода, будет понятней и человек возьмет только то, что ему надо из статьи, и не будет тратить время на разбор чужого кода.
          +6
          Назвал библиотеку Victorem что в переводе с латыни значит победоносный, приносящий победу
          Off-topic: «victorem» это винительный падеж от «victor» («победитель»).

          P.S.: С пунктуацией всё как-то грустно. И у Насти тоже.
            –2

            Какие именно ошибки вы нашли в статье? Что именно Настя там пропустила?

              0
              Спасибо про информацию по victorem. Как тогда правильно на латыни будет «Победоносный». Я у мамы спрашивал, она сказала victorem. Мама не лингвист а хирург. Такие дела.
                +2
                Как тогда правильно на латыни будет «Победоносный»?
                Дословно «победоносный» (т.е. «несущий победу») не переводится. Ближайшее слово по смыслу — просто «victor» («победитель», «победный», «победивший» и т.д.). Ну, или «victoriosus», если хочется подлиннее. Например, в названии "VI Победоносный легион" использовано слово «victrix» (тот же «victor», только в женском роде, потому что на латыни слово «легион» женского рода).

                Я у мамы спрашивал, она сказала victorem
                Мама права, слово правильное, но почему-то не в именительном падеже.
            –1

            Так — у меня там в статье состояние рассылается всем ели никам сразу потому что игра которую я делаю предполагает что все игроки буду видеть одно и тоже одновременно. Если бы у каждого игрока была бы своя собственная Камера то разумнее было бы каждому игроку своё собственное состояние отсылать для отрисовки клиентом.

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

            Самое читаемое