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

в 17:35, , рубрики: crate.io, Gamedev, Rust, udp, Программирование, разработка игр, Сетевые технологии

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

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

Ссылки

-> Исходники
-> Библиотека на 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);
    }

Автор: VanquisherWinbringer

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js