Мультиплеерная игра на Rust + gRPC со спектатор модом. Часть 2

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

Мы остановились на том, что научили сервер и клиент общаться между собой. Давайте научим игровые объекты двигаться. Для этого зададим еще одну тему общения клиента с сервером. В файл game.proto добавим следующие строчки:

   rpc WorldUpdateRequest (ClientActions) returns (WorldStatus);

message ClientActions {
  uint32 playerNumber = 1;
  uint32 clickedButton = 2;
}

message WorldStatus {
  FloatTuple player1Position = 1;
  FloatTuple player2Position = 2;
  Ball ball = 3;
  uint32 playersCount = 4;
  uint32 winner = 5;
}

WorldUpdateRequest — на эту тему мы будет говорить с сервером каждый такт нашей игры. От клиента мы будем посылать два числа — clickedButton — будет принимать значения 0 или 1. В зависимости от того, какую кнопку нажал игрок — движение ракетки вверх или движение ракетки вниз. Вообще, этот параметр можно передавать и в типе bool . Но я хотел бы реализовать в будущем больше логики. Например, запрос на паузу от одного из игроков и ее подтверждение со стороны другого, возможность заменить себя кем-то из спектаторов и т.д. Потому для этого параметра сознательно выбрано число. Так же, мы передаем playerNumber — клиент получает свой уникальный номер при запросе играть, который мы реализовали в первой части. Это чтобы сервер понимал какой из клиентов нажал кнопку. Сервер будет на свой стороне высчитывать новое положение мяча, новое положение ракеток игроков и отвечать структурой WorldStatus. В WorldStatus все знакомо из первой части. Интересна только переменная winner. Вернее ее тип. Так как у нас только два игрока, то можно было бы выбрать тип bool, но если бы мы реализовали замену игроков на спектаторов или еще какую более сложную и не очевидную на данный момент логику, пришлось бы переделывать. Потому изначально выбран тип беззнаковое целое.

Прошу заметить, что посылать запрос на сервер каждый такт игры — это плохая идея для больших игр. Потому что в большинстве игровых движков, с которыми я знаком, дефолтный такт игры — это 1/60 секунды. То есть, функция update будет вызываться 60 раз в секунду. Для нашего примера вызывать сервер 60 раз в секунду — это нормально. Для более сложных проектов, лучше посмотреть в сторону стримов, которые поддерживаются gRPC. Это при условии, что вы разрабатываете что-то с медленным геймплеем. Например, пошаговую игру. Если же вы хотите разработать популярный шутер с большим количеством одновременно играющих игроков, то gRPC не лучший выбор.

Чтобы сгенерировать новые методы, надо применить небольшой трюк. К сожалению, я не нашел другого способа генерировать новые методы в уже существующем проекте. Надеюсь, что если кто-то знает, он напишет как это сделать. Итак, мы комментируем все что написали в cleint.rs и server.rs и вставляем в самый верх этих файлов:

 fn main() {}

Теперь пишем в консоли

 cargo build 

Новые структуры и методы в файле game.rs. Возвращаем cleint.rs и server.rs в изначальное состояние.

Теперь приступим к реализации.

Cервер

На стороне сервера нам надо реализовать метод

 async fn world_update_request(
        &self,
        request: tonic::Request,
    ) -> Result, tonic::Status>

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

В начале метода мы вытаскиваем данные из запроса клиента, чтобы обновить мир. Далее, мы его обновляем:

 let mut world = Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();
        if players_count >= 2 {
            PlayGame::update_world(&mut world, clicked_button, player_number,);
        }
        self.apply_new_world(&world);

Тут и происходят все вычисления и обновление мира. Для реализации физики игры, мы добавим несколько методов в структуру Entity:

 
    fn width(&self) -> f32 {
        self.texture_size.x
    }

    fn height(&self) -> f32 {
        self.texture_size.y
    }

    fn centre(&self) -> Vec2 {
        Vec2::new(
            self.position.x + (self.width() / 2.0),
            self.position.y + (self.height() / 2.0),
        )
    }

    fn bounds(&self) -> Rectangle {
        Rectangle::new(
            self.position.x,
            self.position.y,
            self.width(),
            self.height(),
        )
    }

Структура Rectangle импортируется из tetra::graphics::Rectangle .

Благодаря методу bounds мы сможем понять что мячик ударился об ракетку:

        let player1_bounds = world.player1.bounds();
        let player2_bounds = world.player2.bounds();
        let ball_bounds = world.ball.bounds();

        let paddle_hit = if ball_bounds.intersects(&player1_bounds) {
            Some(&world.player1)
        } else if ball_bounds.intersects(&player2_bounds) {
            Some(&world.player2)
        } else {
            None
        };

В зависимости от места удара об ракетку, вычисляем новый вектор движения мячика:

 if let Some(paddle) = paddle_hit {
            world.ball.velocity.x =
                -(world.ball.velocity.x + (BALL_ACC * world.ball.velocity.x.signum()));

            let offset = (paddle.centre().y - world.ball.centre().y) / paddle.height();

            world.ball.velocity.y += PADDLE_SPIN * -offset;
        }

        if world.ball.position.y <= 0.0
            || world.ball.position.y + world.ball.height() >= world.world_size.y
        {
            world.ball.velocity.y = -world.ball.velocity.y;
        }

Вообще, вся физика описана в гайде игрового движка tetra. Там же и написано о баге, который заложен в этом коде: если шаг мячика станет больше чем ширина ракетки, то мячик пролетит сквозь ракетку.

Дальше мы просто собираем структуру которой должны ответить и отправляем клиенту.

Билдим сервер, проверяем что все работает и нет предупреждений:

% cargo run --bin server
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/server`
Server listening on [::1]:50051

Клиент

На стороне клиента нам надо сделать две основные вещи: написать новый запрос к серверу и реализовать функцию обновления, которую мы оставили пустой в первой части:

 fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
 }

Для общения с сервером, имплементируем метод для GameState:

#[tokio::main]
    async fn world_update_request(&self, clicked_button_number: u32, player_number: u32) -> WorldStatus {
        let request = tonic::Request::new(ClientActions {
            player_number,
            clicked_button: clicked_button_number,
        });
        let mut client = self.client.clone();
        client.world_update_request(request)
            .await.expect("Cannot get World Update from the server").into_inner()
    } 

Именно эту функцию мы будем вызывать 60 раз в секунду:

fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        let mut clicked_button = 2;
        if input::is_key_down(ctx, Key::Up) {
            clicked_button = 0;
        }
        
        if input::is_key_down(ctx, Key::Down) {
            clicked_button = 1;
        }

        let world_update_request =
            self.world_update_request(clicked_button, self.player_number);
        self.set_updated_values(world_update_request);

        Ok(())
    }

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

Для отображения измений в игре, метод draw выглядит так:

 fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        // 0 - Player 1 won
        // 1 - Player 2 won
        if self.winner == 2 {
            self.player1.texture.draw(ctx, self.player1.position);
            self.ball.texture.draw(ctx, self.ball.position);
            self.player2.texture.draw(ctx, self.player2.position);
        } else {
            let text_offset: Vec2 = Vec2::new(16.0, 16.0);
            let mut message = format!("Winner is: Player ");
            if self.winner == 0 {
                message += "1";
            } else {
                message += "2";
            }
            let mut t: Text = Text::new(message,
                                        Font::vector(ctx, "./resources/DejaVuSansMono.ttf",
                                                     16.0)?,
            );
            t.draw(ctx, text_offset);
        }
        Ok(())
    }

Клиент готов. Давайте запустим и посмотрим.

Запускаем сервер:

 cargo run --bin server

Запускаем клиент:

cargo run --bin client 

И ничего не происходит. Даже нет реакции на нажатые клавиши. Это потому что сервер ждет второго игрока, чтобы мячик начал двигаться.

Запускаем второй клиент и играем:

image-loader.svg

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

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

© Habrahabr.ru