Всем привет. Это небольшой гайд о том как создавать мультиплеерные игры. Я изучаю rust, так что некоторые моменты могут быть не совсем верны. Надеюсь что гуру rust поправят меня если увидят что-то не правильное.

Мы будем делать мультиплеерный пинг-понг. Исходный код доступен здесь.

Инструменты

  • Rust - язык программирования. Отличный язык программирования. Даже если вы не собираетесь на нем писать, рекомендую изучить базовые концепции языка.

  • gRPC - Фреймворк для удаленного вызова процедур. Здесь все просто. Представьте что вы хотите пообщаться с кем-то на заранее озвученные темы. Вот здесь то же самое - в Protocol Buffers (Protobuf) - формате описываются заранее оговоренные темы для общения клиента с сервером.

  • Tetra - игровой движок. Очень простой. Ничего сложного для первого проекта нам и не нужно.

Настройка проекта и gRPC

Начнем с создания проекта:

cargo new ping_pong_multiplayer

В папке src создаем два файла: client.rs и server.rs - один для клиента, другой для сервера.

В корне проекта создаем build.rs - для генерации gRPC кода.

main.rs удаляем.

Файл Cargo.toml будет выглядеть так:

[package] 
name = "ping_pong_multiplayer" 
version = "0.1.0" 
edition = "2018" 

[dependencies] 
prost = "^0.8.0" 
tonic = "^0.5.2" 
tetra = "^0.6.5" 
tokio = { version = "^1.12.0", features = ["macros", "rt-multi-thread"] }
rand = "0.8.4" 

[build-dependencies] 
tonic-build = "^0.5.2" 

#server binary 
[[bin]] 
name = "server" 
path = "src/server.rs" 

#client binary 
[[bin]] 
name = "client" 
path = "src/client.rs" 

Зависимости prost и tonik — для gRPC, tokio — для сервера, rand — для элемента случайности в игре и tetra — игровой движок. В build-dependencies упомянут tonic-build — нужен для кодогенерации из proto-файла.

Далее, в папке src создаем новую директорию proto, внутри нее файл game.proto. Тут мы будем описывать то, о чем будут общаться клиенты с сервером. Вообще, у gRPC есть много вариантов коммуникаций и стриминг и двунаправленный стриминг. Я не буду останавливаться на каждом. Мы возьмём самый простой вариант: клиент посылает запрос, сервер возвращает ответ.

Открываем файл game.proto и печатаем:

syntax = "proto3"; 

package game; 

service GameProto {   
	rpc PlayRequest (PlayGameRequest) returns (PlayGameResponse); 
} 

message PlayGameRequest {   
	FloatTuple windowSize = 1;   
  FloatTuple player1Texture = 2;   
  FloatTuple player2Texture = 3;   
  FloatTuple ballTexture = 4; 
} 
message PlayGameResponse {   
	FloatTuple player1Position = 1;   
  FloatTuple player2Position = 2;
	uint32 playersCount = 3;   
  uint32 currentPlayerNumber = 4;   
  Ball ball = 5; 
} 
message Ball {   
	FloatTuple position = 1;   
  FloatTuple velocity = 2; 
} 
message FloatTuple {   
	float x = 1;   
  float y = 2; 
}

В первой строчке мы указываем версию синтаксиса. Дальше идет инициация пакета. В строчке

rpc PlayRequest (PlayGameRequest) returns (PlayGameResponse);

описываем о чем будет клиент говорить с сервером. Здесь мы будем посылать запрос по имени PlayRequest с типом PlayGameRequest на сервер и получать в ответ тип данных PlayGameResponse. Что лежит в этих данных описано ниже:

message PlayGameRequest {
  FloatTuple windowSize = 1;
  FloatTuple player1Texture = 2;
  FloatTuple player2Texture = 3;
  FloatTuple ballTexture = 4;
}

При запросе к серверу от клиента на разрешение играть, мы высылаем размеры окна, размеры текстур игроков (в нашем случае - ракеток) и размеры мяча. Размеры игровых объектов можно было бы хранить на сервере чтобы не высылать их, но в этом случае у нас было бы два места, которые надо обновить если вдруг у нас поменялись текстуры — сервер и клиент.

В ответ с сервера мы отвечаем:

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

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

Типы данных

message Ball {
  FloatTuple position = 1;
  FloatTuple velocity = 2;
}
message FloatTuple {
  float x = 1;
  float y = 2;
}

вспомогательные.

Все они после кодогенерации превратятся в структуры.

В данном гайде я не буду паковать данные. Например,

  uint32 playersCount = 3;
  uint32 currentPlayerNumber = 4; 

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

Теперь мы удаляем main.rs, а в client.rs и server.rs прописываем:

 fn main(){}

build.rs будет выглядеть так:

 fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .compile(
            &["src/proto/game.proto"],
            &["src/proto"],
        ).unwrap();
    Ok(())
}

Чтобы сгенерировать код из proto файла, просто запускаем билд:

 cargo build

В результате в папке target\debug\build\ping_pong_multiplayer-tetra_check-e8cc5eb2d2c25880\out\ будет лежать файл game.rs. В вашем случае хэш-часть имени папки ping_pong_multiplayer-tetra_check-e8cc5eb2d2c25880 будет другой. Можете открыть этот файл - им мы будем пользоваться при написании и клиента и сервера. Мы можем регулировать куда будет сложен сгенерированный файл. Например, если мы создадим папку src\generated\ и укажем в build.rs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .out_dir("src/generated") 
        .compile(
            &["src/proto/game.proto"],
            &["src/proto"], 
        ).unwrap();
    Ok(())
} 

То сгенерированный файл будет в папке src\generated\.

Сервер

Чтобы сервер и клиент имели доступ с сгенерированному файлу, создадим в папке src файл generated_shared.rs со следующим содержимым:

tonic::include_proto!("game"); 

Теперь у нас есть все, чтобы начать писать сервер:

use tonic::transport::Server;
use generated_shared::game_proto_server::{GameProto, GameProtoServer};
use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};

mod generated_shared;

pub struct PlayGame {
}

impl PlayGame {
    fn new() -> PlayGame {
        PlayGame {
        }
    }
}

#[tonic::async_trait]
impl GameProto for PlayGame {
    async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        unimplemented!()
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse()?;
    let play_game = PlayGame::new();
    println!("Server listening on {}", addr);
    Server::builder()
        .add_service(GameProtoServer::new(play_game))
        .serve(addr)
        .await?;
    Ok(())
} 

Это пустой каркас. После запуска вы увидите несколько warning. Не обращайте на них пока что внимания:

% cargo run --bin server
   Compiling ping_pong_multiplayer v0.1.0 
warning: unused imports: `Ball`, `FloatTuple`
 --> src/server.rs:3:24
  |
3 | use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};
  |                        ^^^^  ^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: unused variable: `request`
  --> src/server.rs:20:9
   |
20 |         request: tonic::Request<PlayGameRequest>,
   |         ^^^^^^^ help: if this is intentional, prefix it with an underscore: `_request`
   |
   = note: `#[warn(unused_variables)]` on by default

warning: `ping_pong_multiplayer` (bin "server") generated 2 warnings
    Finished dev [unoptimized + debuginfo] target(s) in 1.70s
     Running `target/debug/server`
Server listening on [::1]:50051 

В этом коде эту часть:

async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        uninmplemented!();
    } 

мы взяли из сгенерированного файла. Именно тут мы получаем на вход PlayGameRequest и отвечать клиенту будем PlayGameResponse.

Сразу приведу готовый код и прокомментирую его:

use tonic::{transport::Server, Response};
use generated_shared::game_proto_server::{GameProto, GameProtoServer};
use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};
use std::sync::{Mutex, Arc};
use tetra::math::Vec2;
use rand::Rng;

mod generated_shared;

const BALL_SPEED: f32 = 5.0;

#[derive(Clone)]
struct Entity {
    texture_size: Vec2<f32>,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}

impl Entity {
    fn new(texture_size: Vec2<f32>, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(texture_size, position, Vec2::zero())
    }    
    fn with_velocity(texture_size: Vec2<f32>, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture_size, position, velocity }
    }
}

#[derive(Clone)]
struct World {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    world_size: Vec2<f32>,
    winner: u32,
}

pub struct PlayGame {
    world: Arc<Mutex<Option<World>>>,
    players_count: Arc<Mutex<u32>>,
}

impl PlayGame {
    fn new() -> PlayGame {
        PlayGame {
            world: Arc::new(Mutex::new(None)),
            players_count: Arc::new(Mutex::new(0u32)),
        }
    }
    fn init(&self, window_size: FloatTuple, player1_texture: FloatTuple,
            player2_texture: FloatTuple, ball_texture: FloatTuple) {
        let window_width = window_size.x;
        let window_height = window_size.y;
        let world = Arc::clone(&self.world);
        let mut world = world.lock().unwrap();
        let players_count = Arc::clone(&self.players_count);
        let players_count = players_count.lock().unwrap().clone();
        let mut ball_velocity = 0f32;
        if players_count >= 2 {
            let num = rand::thread_rng().gen_range(0..2);
            if num == 0 {
                ball_velocity = -BALL_SPEED;
            } else {
                ball_velocity = BALL_SPEED;
            }
        }
        *world =
            Option::Some(World {
                player1: Entity::new(
                    Vec2::new(player1_texture.x, player1_texture.y),
                    Vec2::new(
                        16.0,
                        (window_height - player1_texture.y) / 2.0,
                    ),
                ),
                player2: Entity::new(
                    Vec2::new(player2_texture.x, player2_texture.y),
                    Vec2::new(
                        window_width - player2_texture.y - 16.0,
                        (window_height - player2_texture.y) / 2.0,
                    ),
                ),
                ball: Entity::with_velocity(
                    Vec2::new(ball_texture.x, ball_texture.y),
                    Vec2::new(
                        window_width / 2.0 - ball_texture.x / 2.0,
                        window_height / 2.0 - ball_texture.y / 2.0,
                    ),
                    Vec2::new(
                        ball_velocity,
                        0f32,
                    ),
                ),
                world_size: Vec2::new(window_size.x, window_size.y),
                // No one win yet
                winner: 2,
            });
    }
    fn increase_players_count(&self) {
        let players_count = Arc::clone(&self.players_count);
        let mut players_count = players_count.lock().unwrap();
        *players_count += 1;
    }
}

#[tonic::async_trait]
impl GameProto for PlayGame {
    async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        let pgr: PlayGameRequest = request.into_inner();
        let window_size = pgr.window_size.unwrap();
        let player1_texture = pgr.player1_texture.unwrap();
        let player2_texture = pgr.player2_texture.unwrap();
        let ball_texture_height = pgr.ball_texture.unwrap();
        self.increase_players_count();
        self.init(window_size, player1_texture,
                  player2_texture, ball_texture_height);
        let world = Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();
        let current_players = Arc::clone(&self.players_count);
        let current_players = current_players.lock().unwrap();
        let reply = PlayGameResponse {
            player1_position: Option::Some(FloatTuple {
                x: world.player1.position.x,
                y: world.player1.position.y,
            }),
            player2_position: Option::Some(FloatTuple {
                x: world.player2.position.x,
                y: world.player2.position.y,
            }),
            current_player_number: current_players.clone(),
            players_count: current_players.clone(),
            ball: Option::Some(Ball {
                position: Option::Some(FloatTuple {
                    x: world.ball.position.x,
                    y: world.ball.position.y,
                }),
                velocity: Option::Some(FloatTuple {
                    x: world.ball.velocity.x,
                    y: world.ball.velocity.y,
                }),
            }),
        };
        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse()?;
    let play_game = PlayGame::new();
    println!("Server listening on {}", addr);
    Server::builder()
        .add_service(GameProtoServer::new(play_game))
        .serve(addr)
        .await?;
    Ok(())
} 

Наша "главная" структура - PlayGame. Здесь мы храним весь мир и текущее количество игроков. Оба поля обернуты в Arc<Mutex<>> потому что обращение к этим структурам будет многопоточным. Вообще, в rust просто рай для программирования многопоточных программ. Только слегка многословно получается.

Перво-наперво, мы получаем данные от клиента:

let pgr: PlayGameRequest = request.into_inner();  

Эту структуру(PlayGameRequest) мы можем найти в сгенерированном файле чтобы посмотреть какие там поля. Далее, из входных данных мы вытаскиваем:

let window_size = pgr.window_size.unwrap();
let player1_texture = pgr.player1_texture.unwrap();
let player2_texture = pgr.player2_texture.unwrap(); 
let ball_texture_height = pgr.ball_texture.unwrap();

При каждом новом клиенте, нам надо увеличить количество игроков:

fn increase_players_count(&self) {
	let players_count = Arc::clone(&self.players_count);
	let mut players_count = players_count.lock().unwrap();
	*players_count += 1;
}

Это обычное изменение данных, обернутых в Arc<Mutex<>>.

С данными от клиента, нам надо инициализировать мир. Для этого вызываем функцию self.init(). В общем-то здесь ничего примечательного кроме

let mut ball_velocity = 0f32;
if players_count >= 2 {
    let num = rand::thread_rng().gen_range(0..2);
    if num == 0 {
        ball_velocity = -BALL_SPEED;
    } else {
        ball_velocity = BALL_SPEED;
    }
} 

Если за столом только один игрок и второго еще нет, то мяч стоит на месте - его скорость 0. Если же пришел второй игрок, то игра начинается и мяч должен начать двигаться. Хотелось бы чтобы он начинал двигаться в случайную сторону. Потому генерируется либо 0 либо 1 и в зависимости от того что выпало, мяч движется влево или вправо.

После того как мы инициировали мир для клиента, нам надо его вернуть в ответе. Для этого мы должны ответить структурой PlayGameResponse - ее поля и "внутренности" можно тоже увидеть в сгенерированном game.rs файле. Компилируем, запускаем. Проверяем что все работает:

% cargo run --bin server
   Compiling ping_pong_multiplayer v0.1.0 (/Users/macbook/rust/IdeaProjects/ping_pong_multiplayer)
    Finished dev [unoptimized + debuginfo] target(s) in 5.62s
     Running `target/debug/server`
Server listening on [::1]:50051 

Обратите внимание что все warning пропали.

Клиент

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

Прежде чем писать клиент, надо загрузить ресурсы. Создаем папку resources в корне проекта. Загружаем туда картинки из репозитория.

Теперь мы можем написать каркас:

use tetra::graphics::{self, Color, Texture};
use tetra::math::Vec2;
use tetra::{TetraError};
use tetra::{Context, ContextBuilder, State};

mod generated_shared;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 720.0;

fn main() -> Result<(), TetraError> {
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(GameState::new)
}

struct Entity {
    texture: Texture,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}
impl Entity {
    fn new(texture: &Texture, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(&texture, position, Vec2::zero())
    }
    fn with_velocity(texture: &Texture, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture: texture.clone(), position, velocity }
    }
}

struct GameState {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    player_number: u32,
    players_count: u32,
}
impl GameState {
    fn new(ctx: &mut Context) -> tetra::Result<GameState> {
        let player1_texture = Texture::new(ctx, "./resources/player1.png")?;
        let player2_texture = Texture::new(ctx, "./resources/player2.png")?;
        let ball_texture = Texture::new(ctx, "./resources/ball.png")?;
        Ok(GameState {
            player1: Entity::new(&player1_texture, Vec2::new(16., 100.)),
            player2: Entity::new(&player2_texture, Vec2::new(116., 100.)),
            ball: Entity::with_velocity(&ball_texture, Vec2::new(52., 125.), Vec2::new(0., 0.)),
            player_number: 0u32,
            players_count: 0u32,
        })
    }
}
impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
    }
    fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        self.player1.texture.draw(ctx, self.player1.position);
        self.player2.texture.draw(ctx, self.player2.position);
        self.ball.texture.draw(ctx, self.ball.position);
        Ok(())
    }
} 

Можете заметить что на стороне клиента у нас тоже есть структура Entity и единственное её отличие от серверной структуры - тип данных для поля texture. Вообще, если реализовать трейт Send для типа данных Texture, то мы могли бы вынести эту структуру в общий для клиента и сервера файл. Но это слегка за рамками данного гайда.

Так же, можно обратить внимание на

impl State for GameState 

здесь у нас есть функции update и draw. Tetra для отображения и изменения игры, требует реализацию этих функций.

Можно запустить и посмотреть что рисуется окошко с голубым фоном, рисуются ракетки и мяч:

Чтобы общаться с сервером, напишем небольшую функцию:

async fn establish_connection() -> GameProtoClient<tonic::transport::Channel> {
    GameProtoClient::connect("http://[::1]:50051").await.expect("Can't connect to the server")
} 

Опять же, GameProtoClient объявлен в сгенерированном файле. Этот коннект мы будем использовать всю нашу игру. Так как это future, мы должны остановить выполнение программы для создания коннекта. Так же, мы должны его передать дальше в контекст игры. Потому функция main теперь выглядит так:

fn main() -> Result<(), TetraError> {
    let rt = tokio::runtime::Runtime::new().expect("Error runtime creation");
    let mut client = rt.block_on(establish_connection());
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(|ctx|GameState::new(ctx, &mut client))
} 

Тут типичная работа с future. Вообще, в rust есть целый отдельный crate для работы с future, но нам он не понадобится.

Итого, у нас есть коннект, мы знаем что от нас ждет сервер и что он ответит. Осталось только написать это:

use tetra::graphics::{self, Color, Texture};
use tetra::math::Vec2;
use tetra::{TetraError};
use tetra::{Context, ContextBuilder, State};
use generated_shared::game_proto_client::GameProtoClient;
use generated_shared::{FloatTuple, PlayGameRequest, PlayGameResponse};

mod generated_shared;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 720.0;

async fn establish_connection() -> GameProtoClient<tonic::transport::Channel> {
    GameProtoClient::connect("http://[::1]:50051").await.expect("Can't connect to the server")
}

fn main() -> Result<(), TetraError> {
    let rt = tokio::runtime::Runtime::new().expect("Error runtime creation");
    let mut client = rt.block_on(establish_connection());
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(|ctx|GameState::new(ctx, &mut client))
}

struct Entity {
    texture: Texture,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}
impl Entity {
    fn new(texture: &Texture, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(&texture, position, Vec2::zero())
    }
    fn with_velocity(texture: &Texture, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture: texture.clone(), position, velocity }
    }
}

struct GameState {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    player_number: u32,
    players_count: u32,
    client: GameProtoClient<tonic::transport::Channel>,
}
impl GameState {
        fn new(ctx: &mut Context, client : &mut GameProtoClient<tonic::transport::Channel>) -> tetra::Result<GameState> {
            let player1_texture = Texture::new(ctx, "./resources/player1.png")?;
            let ball_texture = Texture::new(ctx, "./resources/ball.png")?;
            let player2_texture = Texture::new(ctx, "./resources/player2.png")?;
            let play_request = GameState::play_request(&player1_texture, &player2_texture, &ball_texture, client);
            let ball = play_request.ball.expect("Cannot get ball's data from server");
            let ball_position = ball.position.expect("Cannot get ball position from server");
            let ball_position = Vec2::new(
                ball_position.x,
                ball_position.y,
            );
            let ball_velocity = ball.velocity.expect("Cannot get ball velocity from server");
            let ball_velocity = Vec2::new(
                ball_velocity.x,
                ball_velocity.y,
            );
            let player1_position = &play_request.player1_position
                .expect("Cannot get player position from server");
            let player1_position = Vec2::new(
                player1_position.x,
                player1_position.y,
            );
            let player2_position = &play_request.player2_position
                .expect("Cannot get player position from server");
            let player2_position = Vec2::new(
                player2_position.x,
                player2_position.y,
            );
            let player_number = play_request.current_player_number;
            Ok(GameState {
                player1: Entity::new(&player1_texture, player1_position),
                player2: Entity::new(&player2_texture, player2_position),
                ball: Entity::with_velocity(&ball_texture, ball_position, ball_velocity),
                player_number,
                players_count: player_number,
                client: client.clone(),
            })
        }
    #[tokio::main]
    async fn play_request(player1_texture: &Texture, player2_texture: &Texture, ball_texture: &Texture,
                          client : &mut GameProtoClient<tonic::transport::Channel>) -> PlayGameResponse {
        let request = tonic::Request::new(PlayGameRequest {
            window_size: Some(FloatTuple { x: WINDOW_WIDTH, y: WINDOW_HEIGHT }),
            player1_texture: Some(
                FloatTuple { x: player1_texture.width() as f32, y: player1_texture.height() as f32 }
            ),
            player2_texture: Some(
                FloatTuple { x: player2_texture.width() as f32, y: player2_texture.height() as f32 }
            ),
            ball_texture: Some(
                FloatTuple { x: ball_texture.width() as f32, y: ball_texture.height() as f32 }
            ),
        });
        client.play_request(request).await.expect("Cannot get Play Response the server").into_inner()
    }
}

impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
    }
    fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        self.player1.texture.draw(ctx, self.player1.position);
        self.player2.texture.draw(ctx, self.player2.position);
        self.ball.texture.draw(ctx, self.ball.position);
        Ok(())
    }
} 

Здесь нет чего-то нового для нас. Мы создали функцию для запроса на игру: play_request. В сгенерированном файле есть функция с таким же именем - там мы посмотрели что она ждет на вход и что возвращает.

Можно запустить сервер:

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

Запустить клиент. Не обращайте внимания на warning — нам эти поля понадобятся позже:

% cargo run --bin client
warning: unused variable: `ctx`
   --> src/client.rs:104:26
    |
104 |     fn update(&mut self, ctx: &mut Context) -> tetra::Result {
    |                          ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
    |
    = note: `#[warn(unused_variables)]` on by default

warning: field is never read: `velocity`
  --> src/client.rs:28:5
   |
28 |     velocity: Vec2<f32>,
   |     ^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

warning: field is never read: `player_number`
  --> src/client.rs:42:5
   |
42 |     player_number: u32,
   |     ^^^^^^^^^^^^^^^^^^

warning: field is never read: `players_count`
  --> src/client.rs:43:5
   |
43 |     players_count: u32,
   |     ^^^^^^^^^^^^^^^^^^

warning: field is never read: `client`
  --> src/client.rs:44:5
   |
44 |     client: GameProtoClient<tonic::transport::Channel>,
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

warning: `ping_pong_multiplayer` (bin "client") generated 5 warnings
    Finished dev [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/client`

И увидеть что ракетки и мяч расположились в правильных местах на экране:

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

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


  1. Druj
    28.10.2021 10:55
    +9

    Разрешите докопаться:

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

    Знаменитое удобство многопоточности в расте, ну вы знаете. Я не могу разобраться зачем вы так делаете, но наверное не стоит клонировать world просто чтобы взять параметры и использовать везде Option/Result без обработки ошибок

    Два
    ball: Option::Some(Ball {
        position: Option::Some(FloatTuple {
            x: world.ball.position.x,
            y: world.ball.position.y,
        }),
        velocity: Option::Some(FloatTuple {
            x: world.ball.velocity.x,
            y: world.ball.velocity.y,
        }),
    }),
    /* #### */
    let ball_position = Vec2::new(
        ball_position.x,
        ball_position.y,
    );
    

    Код на 40% состоит из преобразований FloatTuple <-> Vec2, просто реализуйте трейт From<...> для этих типов.
    Плюс Option уже есть в std::prelude, можно использовать Some(...).

    Три
    let mut ball_velocity = 0f32;
    if players_count >= 2 {
        let num = rand::thread_rng().gen_range(0..2);
        if num == 0 {
            ball_velocity = -BALL_SPEED;
        } else {
            ball_velocity = BALL_SPEED;
        }
    }
    

    Тут ball_velocity не должна быть мутабельной, вы просто инициализируете её одним из трёх значений, для этого есть специальный сахар, например:
    let velocity = { if ... { value1 } else { if ... { ... } else { ... } } };


    Ну а статья норм, приятно смотреть как другие изучают то что самому учить лень.


    1. roofroot Автор
      29.10.2021 20:49
      +1

      Спасибо за советы. Расскажите пожалуйста про первый побольше. Я не совсем понимаю как можно сделать по-другому.


      1. Druj
        04.11.2021 14:20
        +4

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

        Arc + Mutex используются чтобы раскидать ссылки на одну и ту же пямять по тредам и из каждого гарантировать владение этой памятью ровно одним тредом в конкретный момент. У вас же я не вижу ручного создания тредов, вы просто отдаете владение своим состоянием PlayGame в фреймворк а дальше из обработчика получаете &self этого состояния. Следовательно в self.world можете сразу хранить Mutex
        Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();

        Вы достаёте MutexGuard, через Deref вызываете as_ref преобразуя &Option в Option<&World> достаете &World и полностью клонируете чтобы получить World далее по коду вы просто читаете из него нужные вам поля. Лучшее что вы можете тут сделать без серьёзных изменений — убрать последний .clone(). Это архитектурная проблема возникающая из за того что Option используется как указатель в C постоянными проверками на NULL. Приведу пример, у вас количество игроков и сам мир находятся в двух независимых Option, хотя во время раунда очевидно что если есть игроки то они находятся в мире который не может быть None, однако вам придется делать unwrap. Вместо этого предлагаю заранее определить инварианты игры, выглядеть будет примерно так:
        struct RoundState {/* Игроки, позиции обьектов, etc */}
        
        struct RoundResult { /* Победители, очки, etc */ }
        
        enum Round {
          Lobby(/* кто в лобби и т.д*/),
          Game(RoundState),
          Finished(RoundResult),
        }
        
        struct Game {
          round: Round
          /* Тут всякие статические штуки которые не зависят от раунда
          *  Имя серевера, MOTD и прочее
          */
        }
        

        И когда вам придётся обработать запрос вам нужно просто проверить допустим ли он для этого инварианта, остальные проверки отпадут сами собой. Если раунд идёт то для него есть мир, если раунд закончился то есть статистика и т.д:
        async fn fetch_map(&self, ...) -> ... {
          ...
          match round {
            Round::Game(RoundState{players, map, ...}) => {
              /* Актуальные данные вы получили строкой выше
               * осталось бех всяких проверок обработать запрос
               */
            },
            _ => {
              /* Попытка получить карту когда игра не запущена */
            }
          }
        }
        


        1. roofroot Автор
          05.11.2021 07:55
          +1

          Большое спасибо!


  1. magic17neon
    29.10.2021 20:26
    +1

    Спасибо автору! Интересная для меня тема! Буду ждать продолжения!