Multiplayer Local
INICIALMENTE ESCRITO NA VERSÃO 0.9 DA BEVY
Primeiro passo para entendermos um jogo multiplayer é criarmos as regras para o jogo executar corretamente no modo multiplayer, podemos fazer isso adicionando mais um player de forma local. O suporte ao multiplayer exige uma pequena refatoração, que será por onde começaremos.
Refatorando
Com a atualização do Rust para versão 1.66
, o linter do Rust sugeriu algumas novas refatorações bem simples, mas muito bem observadas no módulo grid
. A primeira é na função translate_position
e na função scale_sprite
que possuíam um casting desnecessário, podendo-se remover o as f32
das chamadas de funções window.width()
e window.height()
:
// Grid.rs
// Antes
fn scale_sprite(transform: &mut Transform, sprite_size: &Size, window: &Window) {
transform.scale = Vec3::new(
sprite_size.width / GRID_WIDTH as f32 * window.width() as f32,
sprite_size.height / GRID_HEIGHT as f32 * window.height() as f32,
1.0,
);
}
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width() as f32, GRID_WIDTH as f32),
convert(pos.y as f32, window.height() as f32, GRID_HEIGHT as f32),
0.0,
);
}
// Depois
fn scale_sprite(transform: &mut Transform, sprite_size: &Size, window: &Window) {
transform.scale = Vec3::new(
sprite_size.width / GRID_WIDTH as f32 * window.width(),
sprite_size.height / GRID_HEIGHT as f32 * window.height(),
1.0,
);
}
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width(), GRID_WIDTH as f32),
convert(pos.y as f32, window.height(), GRID_HEIGHT as f32),
0.0,
);
}
A segunda refatoração é, no mesmo módulo, a simplificação da função convert
para utilizar a função mul_add
em vez de uma multiplicação seguida por uma adição. A vantagem do uso de mul_add
é que reduz os erros por arredondamento que poderiam ser causados pelo uso de f32
:
// grid.rs
// Antes
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
pos / grid_side_lenght * bound_window - (bound_window / 2.) + (tile_size / 2.)
}
// Depois
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
(pos / grid_side_lenght).mul_add(bound_window, -bound_window / 2.) + (tile_size / 2.)
}
Trait
MulAdd
equivale a representar(self * a) + b
e tem como sintaxefn mul_add<A: Self, B: Self>(self, a: A, b: B) -> Self
.
Outra refatoração que fiz foi para melhorar os resultados de testes em modo release
e em Windows, utilizando a biblioteca approx = "0.5.1"
. Esta biblioteca garante uma comparação mais correta de epsilons de f32. Assim, podemos mudar todos os testes que contém comparações de f32 para utilizar o assert_relative_eq
, que recebe um epsilon de valor de erro na comparação:
// grid.rs
mod test {
use super::*;
use approx::assert_relative_eq;
fn convert_position_x_for_grid_width() {
let x = convert(4., 400., GRID_WIDTH as f32);
assert_relative_eq!(x, -20., epsilon = 0.00001);
}
#[test]
fn convert_position_y_for_grid_height() {
let x = convert(5., 400., GRID_HEIGHT as f32);
assert_relative_eq!(x, 20., epsilon = 0.00001);
}
// ...
}
Por último, vamos atualizar os testes com múltiplas chamadas de app.update()
. Fazemos isso substituindo todas as linhas por um for
com range:
// Antes
app.update(); // 3 + 1
app.update(); // 3 + 2
app.update(); // 3 + 3
app.update(); // 3 + 4
app.update(); // 3 + 5
app.update(); // 3 + 6
// Depois
for _ in 0..6 {
app.update(); // 3 + _
}
Criando Outro Player
A primeira coisa que precisamos para começar a dar suporte para multiplayer local é adicionarmos o conceito de player, ou seja, um componente chamado Player
que possui um id
com o id do player, podemos fazer isso no módulo components.rs
. Além disso, vale a pena criar uma função constante para retornar o valor de id
:
#[derive(Component, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Player {
id: u8,
}
impl Player {
pub const fn id(&self) -> usize {
self.id as usize
}
}
Depois disso, podemos aumentar o tamanho do mapa para que duas cobras possam andar juntas sem se colidir constantemente, fazemos isso mudando o valor da janela gerada no WindowPlugin
para 1000f32
:
.add_plugins(DefaultPlugins.set(WindowPlugin {
window: WindowDescriptor {
title: "Snake Game".to_string(),
width: 1000.0,
height: 1000.0,
..default()
},
// ...
Somente esta mudança não garante mais espaço para duas cobras, assim, em grid.rs
aumentamos a quantidade de tiles de 10
para 20
, mas apenas em modo release. Definimos compilação em modo debug
com a flag de compilação #[cfg(debug_assertions)]
e modo release
com a flag de compilação #[cfg(not(debug_assertions))]
, um not
a mais:
#[cfg(debug_assertions)]
pub(crate) const GRID_WIDTH: u16 = 10;
#[cfg(not(debug_assertions))]
pub(crate) const GRID_WIDTH: u16 = 20;
#[cfg(debug_assertions)]
pub(crate) const GRID_HEIGHT: u16 = 10;
#[cfg(not(debug_assertions))]
pub(crate) const GRID_HEIGHT: u16 = 20;
Testando com uma janela maior
Essa mudança fará com que uma série de testes falhem em modo release
, necessário para windows, assim, as correções passam a ser utilizar asserts ou declarações diferentes em modo debug
e modo release
. Por exemplo:
#![allow(unused)] fn main() { // Grid.rs fn convert_position_x_for_grid_width() { let x = convert(4., 400., GRID_WIDTH as f32); #[cfg(debug_assertions)] // <-- DEBUG assert_relative_eq!(x, -20., epsilon = 0.00001); // <-- DEBUG #[cfg(not(debug_assertions))] // <-- RELEASE assert_relative_eq!(x, -110., epsilon = 0.00001); // <-- RELEASE } #[test] fn translate_position_to_window() { let position = Position { x: 2, y: 8 }; let mut default_transform = Transform::default(); let expected = Transform { #[cfg(debug_assertions)] // <-- DEBUG translation: Vec3::new(-100., 140., 0.), // <-- DEBUG #[cfg(not(debug_assertions))] // <-- RELEASE translation: Vec3::new(-150., -29.999996, 0.), // <-- RELEASE ..default() }; let mut descriptor = WindowDescriptor::default(); descriptor.height = 400.; descriptor.width = 400.; let window = Window::new(WindowId::new(), &descriptor, 400, 400, 1., None, None); translate_position(&mut default_transform, &position, &window); assert_eq!(default_transform, expected); } }
Mais exemplos podem ser encontrados no PR#14, inclusive as mudanças para os testes das próximas partes.
Utilizando Player
Primeira mudança que fiz foi algo bem simples, mas muito representativo, adicionar uma outra cor de segmentos de cobra, SNAKE_SEGMENT_COLOR
virou:
const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7);
const SNAKE1_SEGMENT_COLOR: Color = Color::rgb(0.8, 0.0, 0.8); // <--
const SNAKE2_SEGMENT_COLOR: Color = Color::rgb(0., 0.8, 0.8); // <--
Próximo passo é adicionarmos uma referência ao vetor de segmentos da segunda cobra, podemos fazer isso modificando a struct Segments
para pub struct Segments([Vec<Entity>; 2]);
, que significa que nossa struct Segments
possui um array de tamanho 2 do tipo Vetor de Entity
, assim o id do player 1 será 0
e o id do player 2 será 1
, conforme o sistema de indexação de arrays. Essa pequena mudança quebra todo nosso código para snake.rs
, mas vou tentar explicar como ajustar as funções do modo mais lógico possível.
Sistema de Geração de Cobras (Spawn)
Agora nosso sistema de spawn exige que segments seja um Array com dois vetores de entidades, para isso podemos refatorar o código dentro de spawn_system
para ser reutilizável. Fazemos isso criando a função privada spawn_entity_with_segment
, que recebe uma referência mutável de Commands
e um u8
que corresponderá ao id do Player. É importante que o segmento da cabeça da cobra seja ciente de qual seu player_id
, para isso adicionamos essa informação nos componentes da primeira entidade, .insert(components::Player { id: player_id })
, e que player 1 e player 2 não iniciem na mesma posição, para isso utilizamos um if/else
na posição X do componente Position
, definindo a posição do player 1 em x = 3
, e do player 2 em x = GRID_WIDTH - 3
, if player_id == 0 { 3 } else { (GRID_WIDTH - 3) as i16 }
:
fn spawn_entity_with_segment(commands: &mut Commands, player_id: u8) -> Vec<Entity> {
vec![
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(components::Player { id: player_id })
.insert(Head::default())
.insert(Segment)
.insert(Position {
x: if player_id == 0 {
3
} else {
(GRID_WIDTH - 3) as i16
},
y: 3,
})
.insert(Size::square(0.8))
.id(),
spawn_segment_system(
commands,
Position {
x: if player_id == 0 {
3
} else {
(GRID_WIDTH - 3) as i16
},
y: 2,
},
player_id,
),
]
}
Com essa mudança podemos atualizar a função spawn_system
para chamar a função spawn_entity_with_segment
para ambos os players:
pub fn spawn_system(mut commands: Commands, mut segments: ResMut<Segments>) {
*segments = Segments([
spawn_entity_with_segment(&mut commands, 0),
spawn_entity_with_segment(&mut commands, 1),
]);
}
Note que spawn_entity_with_segment
recebe como argumento commands: &mut Commands
e por isso, devemos modificar o tipo de commands
em spawn_segment_system
para corresponder a mesma referência mutável. É nesta função, spawn_segment_system
que vamos definir a cor dos segmentos de player que criamos anteriormente, com if player_id == 0 { SNAKE1_SEGMENT_COLOR } else { SNAKE2_SEGMENT_COLOR }
:
pub fn spawn_segment_system(commands: &mut Commands, position: Position, player_id: u8) -> Entity {
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: if player_id == 0 {
SNAKE1_SEGMENT_COLOR
} else {
SNAKE2_SEGMENT_COLOR
},
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Segment)
.insert(position)
.insert(Size::square(0.65))
.id()
}
Sistema de Movimento
A base para o sistema de movimento é fazer com que o wasd
mova o player 1 e as setas (arrows
) movam o player 2. Agora, nosso movement_input_system
não pode se restringir a iterar sobre heads
apenas com .next()
, já que sabemos que há pelo menos dois elementos em heads
. Além disso, precisamos de uma forma de determinar de qual player estamos falando, por isso adicionamos o componente Player
na Query
de heads
, mut heads: Query<(&mut Head, &Player)>
, e iteramos sobre todos os elementos com um iter_mut().for_each((mut head, player)| { ... })
. O resto do código segue a mesma lógica de antes:
pub fn movement_input_system(
keyboard_input: Res<Input<KeyCode>>,
mut heads: Query<(&mut Head, &Player)>,
) {
heads.iter_mut().for_each(|(mut head, player)| {
let dir: Direction = if player.id() == 0 {
if keyboard_input.pressed(KeyCode::A) {
Direction::Left
} else if keyboard_input.pressed(KeyCode::S) {
Direction::Down
} else if keyboard_input.pressed(KeyCode::W) {
Direction::Up
} else if keyboard_input.pressed(KeyCode::D) {
Direction::Right
} else {
head.direction
}
} else if player.id() == 1 {
if keyboard_input.pressed(KeyCode::Left) {
Direction::Left
} else if keyboard_input.pressed(KeyCode::Down) {
Direction::Down
} else if keyboard_input.pressed(KeyCode::Up) {
Direction::Up
} else if keyboard_input.pressed(KeyCode::Right) {
Direction::Right
} else {
head.direction
}
} else {
head.direction
};
if dir != head.direction.opposite() {
head.direction = dir;
}
});
}
A mudança no movement_system
é essencialmente a mesma, agora precisamos adicionar a informação de Player
na Query
de heads
, heads: Query<(Entity, &Head, &Player)>,
, e refatorar a iteração sobre heads
para ser um for
em vez de um .next()
, considerando que agora possuímos o id
de Player
, que causa colisão com o antigo id
, que mudei para entity_id
. Note que estamos destruturando o valor de Player
em id
ao utilizarmos Player { id }
no for
loop. A única outra grande mudança é que agora para acessarmos segments
, precisamos indicar qual o player_id
do segment, fazemos isso substituindo as referências a segments
por segments[player_id]
:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut game_end_writer: EventWriter<GameEndEvent>,
heads: Query<(Entity, &Head, &Player)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
game_end: Query<&GameEndEvent>,
) {
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
for (entity_id, head, Player { id }) in heads.iter() {
let player_id = (*id) as usize;
(*segments[player_id]).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Some(new_position) = positions_clone.get(&entity[0]) {
*position = new_position.clone();
}
};
});
if game_end.is_empty() {
let _ = positions.get_mut(entity_id).map(|(_, _segment, mut pos)| {
match &head.direction {
Direction::Left => {
pos.x -= 1;
}
Direction::Right => {
pos.x += 1;
}
Direction::Up => {
pos.y += 1;
}
Direction::Down => {
pos.y -= 1;
}
};
if pos.x < 0
|| pos.y < 0
|| pos.x as u16 >= GRID_WIDTH
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
if positions_clone
.iter()
.filter(|(k, _)| k != &&entity_id)
.map(|(_, v)| v)
.any(|segment_position| &*pos == segment_position)
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
*last_tail_position = LastTailPosition(Some(
positions_clone
.get(segments[player_id].last().unwrap())
.unwrap()
.clone(),
));
}
}
Sistema de crescimento
A única mudança realmente significativa no sistema de crescimento é que o evento GrowthEvent
precisa ter informação de qual player deve crescer, fazemos isso adicionando a informação de player ao GrowthEvent
:
pub struct GrowthEvent {
pub player_id: u8,
}
Agora, nosso eating_system
, precisa transmitir a informação do id
do Player
para o GrowthEvent
, fazemos isso adicionando Player
a query de head_positions
, head_positions: Query<(&Position, &Player), With<Head>>,
e definindo o id
destruturado como player_id
em GrowthEvent
:
pub fn eating_system(
mut commands: Commands,
mut growth_writer: EventWriter<GrowthEvent>,
food_positions: Query<(Entity, &Position), With<Food>>,
head_positions: Query<(&Position, &Player), With<Head>>,
) {
for (head_pos, Player { id }) in head_positions.iter() {
for (ent, food_pos) in food_positions.iter() {
if food_pos == head_pos {
commands.entity(ent).despawn();
growth_writer.send(GrowthEvent { player_id: *id });
}
}
}
}
A próxima mudança é fazer com que o sistema de crescimento reconheça as informações relativas a player_id
, contidas em GrowthEvent
. Como pode haver mais de um evento no EventBuffer
, precisamos iterar sobre o growth_reader
com um for_each
, em vez de um .next()
, além disso, para garantir um sistema robusto, fazemos uma checagem se o player_id
é menor que o tamanho do array segments
, já que vamos acessar o array utilizando indexação sem checagem, segments.[player_id]
. As outras mudanças simplesmente refletem as mudanças esperadas em spawn_segment_system
:
pub fn growth_system(
mut commands: Commands,
last_tail_position: Res<LastTailPosition>,
mut segments: ResMut<Segments>,
mut growth_reader: EventReader<GrowthEvent>,
) {
growth_reader.iter().for_each(|event| {
let player_id = event.player_id as usize;
if player_id < segments.len() {
segments[player_id].push(spawn_segment_system(
&mut commands,
last_tail_position.0.clone().unwrap(),
event.player_id,
));
}
});
}