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

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

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

Инструменты

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

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

  • 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`

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

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

Читайте так же:

  • Как устроен звуковой чип легендарного синтезатораКак устроен звуковой чип легендарного синтезатора Энтузиаст изучил устройство аудиочипа Yamaha DX7 по фотографиям интегральной схемы. Далее, поговорим о его особенностях. Кстати, ранее мы уже рассказывали про карту Sound Blaster 1.0 и усилитель звука в Game Boy.Фотография: Avi Naim. Источник: unsplash.comЗвук известный многимЦифровой […]
  • Google может передавать сигналы ранжирования на новый URL без редиректаGoogle может передавать сигналы ранжирования на новый URL без редиректа Google может передавать ранжирующие сигналы на новый URL без 301 редиректа. Об этом 4 июня сообщил Джон Мюллер из Google во время еженедельной видеовстречи. Владелец одного сайта сообщил, что работает над сайтом. Где было изменено большое количество URL без добавления 301 редиректа. […]
  • Как документировать сервер и контролировать его управление, даже если у вас небольшой стартапКак документировать сервер и контролировать его управление, даже если у вас небольшой стартап Привет, Хабр! Меня зовут Даниил Воложинок, я инженер в группе виртуализации. Представьте себе ситуацию. У вас есть сервер с комплексом приложений и настроек, который несколько лет обслуживает админ — ”золотые руки”. Однажды “золотой” админ увольняется или уходит на длительный больничный. […]
  • Мигаем светодиодом: PC XT — styleМигаем светодиодом: PC XT — style На самом деле между PC XT и 48-ю (!) светодиодами притаился "рояль в кустах":Эта плата была спонтанно приобретена на ebay. Ясных планов её использования у меня не было, просто захотелось, да и сейчас не такой большой выбор старого ISA-железа, в особенности 8-битного.На этой плате […]