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

Мы остановились на том, что научили сервер и клиент общаться между собой. Давайте научим игровые объекты двигаться. Для этого зададим еще одну тему общения клиента с сервером. В файл 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<ClientActions>,
    ) -> Result<tonic::Response<WorldStatus>, 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<f32> {
        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<f32> = 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 

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

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

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

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

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


  1. Zalechi
    30.10.2021 13:48
    +1

    Может с "режимом наблюдателя" таки?


    1. roofroot Автор
      30.10.2021 16:59
      +1

      Это хорошее замечание. Когда я думал над названием, у меня был такой вариант. Но то ли из-за профессиональной деформации то ли еще по какой причине, когда я читаю "наблюдатель", первым в голову приходит слово observer. И паттерн наблюдателя. Я хотел чтобы название сразу было понятно людям. Потому решил взять слово "спектатор". Возможно, я не прав.


      1. Zalechi
        30.10.2021 17:16
        +2

        Возможно Вы правы.


        1. VXP
          31.10.2021 14:00

          Что, по-вашему, не так со словом "спектатор"? Это постоянно используемый игровой сленг


          1. Zalechi
            31.10.2021 14:36

            Чрезмерное, неоправданное, афонетичное(нелепое на слух) использование заимствований, в данном случае англицизмов. Послушайте, я не борюсь за чистоту русс яза, я лишь озвучил в данном конкретном случае, то что мне показалось излишним и нелепым. Я не вмду смыла бороться с импортными словами: банкрот, шлагбаум и тд, но в конкретном случае мой мозг зацепился. Я не могу сказать, что это плохо, но этот выглядит вульгарно, как кальки некотрых русских рэп исполнителей, где они бездарно и бездумно вставляют импортные слова. Бывают случаи обратные, где англицизмы очень органично вписываются в текст русской песни. Поэтому я обратил внимание в данном конкретном случае. Даже слово mode, давно импортировано в русс яз, как модификация и тд, оно фонетичеси звучит нелепо в сравнение со словом "режим", описывающем работу системы в определенной модификации.

            Мой вопрос нёс под собой консультативную подаплеку, рекоментадетльную, а не категоричную и к чему обязывающую. Я спосил: может так лучше?..


  1. Sabubu
    30.10.2021 22:06
    +7

    Мне кажется, качество этой статьи невысокое. Она учит лишь умению бездумно копипастить код. Потому, что вы не аргументируете принятые решения, не объясняете ничего, а просто говорите: "скопируй этот код". Вы не учите.


    Ну например, вы используете библиотеку tonic, но не объясняете даже, что она делает, а уж тем более, почему она выбрана из десятков других библиотек. А я не поленился и погуглил: это HTTP/2 сервер. Такую важную вещь вы не упомянули. А просто предложили бездумно ее использовать, не зная, что она делает.


    Пойдем дальше. Почему вы выбрали протокол HTTP/2 для игры? Какие преимущества он дает? На мой взгляд, это плохой выбор, так как он использует TCP, а тот при потере пакета может подвиснуть на десятки секунд. В играх обычно используют UDP, и используют такой протокол, который выдерживает потерю пакетов.


    Так как правильно использовать UDP, и с ним у нас возможна потеря пакетов, то посылать на сервер надо не события вроде "кнопка вверх нажата", а "ракетка переместилась в позицию Y". Иначе при потере пакета все рассинхронизируется.


    Или, например, возьмем другой пример кода из первой части статьи:


    [tokio::main]

    async fn main() -> Result<(), Box<dyn std::error::Error>>

    В статье нет объяснения, ни что значит первая аннотация, ни почему у main такой сложный тип возврата, что значит Box и что значит dyn. Ваша статья для новичков? Тогда надо объяснить. Или она для профессионалов, которые все это наизусть знают? Им она вряд ли нужна.


    Кстати, странно, что main вообще что-то возвращает. Это же главная функция программы, и непонятно, кто будет обрабатывать этот dyn Box после ее завершения.


    Или, например, вы пишете:


    Оба поля обернуты в Arc<Mutex<>> потому что обращение к этим структурам будет многопоточным.

    Зачем мьютекс в многопоточности — это понятно. Но зачем тут Arc (атомарно изменяющийся счетчик ссылок)? Опять же, объяснений нету.


    Или, опять же, приведен сложный код без малейших объяснений:


    let world = Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();

    А точно так много функций нужно вызывать? И что они делают?


    Далее, вы в клиенте и в сервере вы скопировали структуру Entity. Почему вы используете копипаст, а не вынесли общую часть в отдельный файл? Нелогично.


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


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


    И в заключение. Вы тут пытаетесь создать у не разбирающихся людей впечатление, что Раст — простой и удобный язык, и любой новичок, прочтя вашу статью, легко сможет написать игру. Как бы не так. Раст — сложный язык для профессионалов, а не для новичков. Если вы не знаете Си, Си++ (и всех его тонкостей с RAII, шаблонами, умными указателями), основ ассемблера, то лучше не лезьте в Раст. Раст рассчитан на опытных Си++ программистов, а не на новичков. Документация Раста очень многие вещи не объясняет или объясняет вскользь, рассчитывая что вы их уже знаете из опыта работы с Си++. Например, вы не найдете в документации нормального объяснения "умных указателей" для начинающих — ожидается, что вы знакомы с этой вещью, прежде чем браться за Раст.


    Если вы новичок, вам больше подойдет Питон или Яваскрипт. Даже Го (который тоже не для новичков) будет проще Раста.


    Также хочу воспользоваться возможностью, чтобы отметить, что документация Раста очень не полна, не логична и не дает ответов на самые простые вопросы. Например, попробуйте найти ответ в документации, что такое str (не &str, а просто str без амперсанда)?


    Страница говорит:


    The str type, also called a ‘string slice’,...

    Из этого можно только понять, что str — это некий тип, который по какой-то непонятной причине называют "string slice", хотя тут написано:


    The slice type is written as [T]

    Вот это я понять не могу. Если слайс обозначается как [T], то почему str — это слайс? Я не вижу в str квадратных скобок. Две документации просто противоречат друг другу. Одна утверждает, что у слайса есть квадратные скобки, другая — что str без квадратных скобок это тоже слайс. Как такое возможно?


    Далее:


    It is usually seen in its borrowed form, &str

    Обычно str используется с амперсандом. А что будет, если мы используем его без амперсанда? Слово "обычно" подразумевает, что возможен и такой вариант. Где найти ответ?


    Читаем далее:


    A &str is made up of two components: a pointer to some bytes, and a length.

    Хорошо, &str состоит из двух частей. Это понятно. А из чего состоит и что такое просто str? Где искать ответ на этот вопрос?


    Или вернемся к обычным слайсам. Документация говорит:


    Slice types are generally used through pointer types.
    &[T]
    Box<[T]>

    В общем случае слайсы используются с указателями. А что будет, если использовать его без амперсанда? Слово "в общем случае" подразумевает, что возможны и частные случаи, когда мы используем его без амперсанда.


    &[T] это тип слайса. А что обозначает [T] без амперсанда? Это слайс или нет? Ответа нету.


    Аналогично, документация не объясняет, почему в Box<[T]> амперсанда нету. Получается, что в каких-то случаях амперсанд нужен, а в каких-то нет, но какие тут действуют правила — непонятно.


    Или возьмем тот же амперсанд. Сколько у него разных значений, и нигде это толком не объясняется. Например:


    • &a — берет указатель и иммутабельно "одалживает" значение a
    • &T — обозначает тип указателя на определенный тип
    • &str — обозначает отнюдь не указатель на тип str, а специальную структуру из указателя и длины
    • &[T] — это я сам не понимаю, что такое: тип-слайс или тип указателя на слайс? В документации написано "'shared slice', often just called a 'slice'". Непонятно.
    • &[1, 2, 3] — это то ли указатель на массив, то ли слайс, я так и не понял. Знаю только, что его можно передать туда, где требуется &[T]

    Надеюсь, эти примеры доказывают, что Раст не для новичков. Новичок, очевидно, не сможет разобраться, что значит str без амперсанда, и не сможет осознанно писать код с его использованием. Максимум бездумно копипастить куски кода из Интернета, не понимая, что в них значит str и почему слайс иногда используется с амперсандом, а иногда без. Раст это очень сложный язык, и документация не описывает даже основы.


    1. roofroot Автор
      30.10.2021 23:24
      +3

      Большое спасибо за развернутый комментарий. Я хочу показать интересующимся людям что изучать что-то сложное, например, язык программирования rust - легко, создавая что-то свое. Потому что это интересно. Пусть там есть неясные моменты. Объяснять каждый шаг - медвежья услуга. Люди должны заинтересоваться. Я не пишу в этом гайде ААА игру и указываю что есть баги и разные подходы к сетевому коду.