├── .gitignore ├── .cargo └── config.toml ├── renovate.json ├── README.md ├── src ├── args.rs ├── fps.rs ├── field │ ├── next.rs │ ├── timer.rs │ ├── block.rs │ ├── mod.rs │ ├── blocks.rs │ └── local.rs ├── position.rs ├── mino │ ├── mod.rs │ ├── t_spin.rs │ ├── shape.rs │ └── event.rs ├── state.rs ├── input.rs ├── main.rs ├── net.rs └── movement.rs ├── Cargo.toml └── .github ├── workflows └── check.yml └── actions └── install-linux-deps └── action.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | dev = "run --features bevy/dynamic_linking" 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>shun-shobon/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Betris 2 | 3 | Multiplayer Tetris clone written in Rust using the Bevy game engine. 4 | This is a learning project for me to get familiar with Bevy and Rust. 5 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use clap::Parser; 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Clone, Parser, Deserialize, Resource)] 6 | #[clap(name = "Betris")] 7 | pub struct Args { 8 | #[clap(long, default_value = "ws://localhost:3536")] 9 | pub matchbox: String, 10 | #[clap(short, long, default_value = "1")] 11 | pub players: usize, 12 | } 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "betris" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [profile.dev] 7 | opt-level = 1 8 | 9 | [profile.dev.package."*"] 10 | opt-level = 3 11 | 12 | [dependencies] 13 | bevy_matchbox = "0.10.0" 14 | bincode = "1.3.3" 15 | clap = { version = "4.5.23", features = ["derive"] } 16 | if_chain = "1.0.2" 17 | once_cell = "1.20.2" 18 | rand = "0.8.5" 19 | serde = { version = "1.0.217", features = ["derive"] } 20 | 21 | [dependencies.bevy] 22 | version = "0.13.2" 23 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check code 2 | on: 3 | push: 4 | 5 | jobs: 6 | clippy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/cache@v4 11 | with: 12 | path: | 13 | ~/.cargo/bin/ 14 | ~/.cargo/registry/index/ 15 | ~/.cargo/registry/cache/ 16 | ~/.cargo/git/db/ 17 | target/ 18 | key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.toml') }} 19 | - uses: dtolnay/rust-toolchain@stable 20 | with: 21 | components: clippy 22 | 23 | - name: Install deps 24 | uses: ./.github/actions/install-linux-deps 25 | 26 | - run: cargo clippy --all-targets --all-features -- -D warnings 27 | 28 | fmt: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: dtolnay/rust-toolchain@stable 33 | with: 34 | components: rustfmt 35 | 36 | - run: cargo fmt --all -- --check 37 | -------------------------------------------------------------------------------- /src/fps.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}, 3 | prelude::*, 4 | }; 5 | 6 | const FPS_TEXT_COLOR: Color = Color::GREEN; 7 | 8 | #[derive(Component)] 9 | pub struct FpsText; 10 | 11 | pub fn setup_fps(mut commands: Commands) { 12 | let text_style = TextStyle { 13 | font_size: 20., 14 | color: FPS_TEXT_COLOR, 15 | ..default() 16 | }; 17 | 18 | commands 19 | .spawn( 20 | TextBundle::from_sections([ 21 | TextSection::new("FPS: ", text_style.clone()), 22 | TextSection::from_style(text_style), 23 | ]) 24 | .with_style(Style { 25 | position_type: PositionType::Absolute, 26 | top: Val::Px(5.), 27 | left: Val::Px(5.), 28 | ..default() 29 | }), 30 | ) 31 | .insert(FpsText); 32 | } 33 | 34 | pub fn fps_system(diagnostic: Res, mut query: Query<&mut Text, With>) { 35 | let Some(fps) = diagnostic.get(&FrameTimeDiagnosticsPlugin::FPS) else { 36 | return; 37 | }; 38 | let Ok(mut fps_text) = query.get_single_mut() else { 39 | return; 40 | }; 41 | 42 | fps_text.sections[1].value = format!("{:.2}", fps.average().unwrap_or_default()); 43 | } 44 | -------------------------------------------------------------------------------- /src/field/next.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | 3 | use crate::mino::shape::Shape; 4 | use rand::prelude::*; 5 | 6 | pub const QUEUE_SIZE: usize = 6; 7 | 8 | pub struct NextQueue { 9 | queue: VecDeque, 10 | bag: RandomBag, 11 | } 12 | 13 | impl Default for NextQueue { 14 | fn default() -> Self { 15 | let mut bag = RandomBag::new(); 16 | let queue = (0..QUEUE_SIZE).map(|_| bag.pop()).collect(); 17 | 18 | Self { queue, bag } 19 | } 20 | } 21 | 22 | impl NextQueue { 23 | pub fn pop(&mut self) -> Shape { 24 | self.queue.push_back(self.bag.pop()); 25 | 26 | self.queue.pop_front().unwrap() 27 | } 28 | 29 | pub fn queue(&self) -> &VecDeque { 30 | &self.queue 31 | } 32 | } 33 | 34 | struct RandomBag(Vec); 35 | 36 | impl RandomBag { 37 | pub fn new() -> Self { 38 | let mut bag = Self(Vec::with_capacity(Shape::COUNT)); 39 | bag.fill(); 40 | 41 | bag 42 | } 43 | 44 | pub fn pop(&mut self) -> Shape { 45 | if self.0.is_empty() { 46 | self.fill(); 47 | } 48 | 49 | self.0.pop().unwrap() 50 | } 51 | 52 | fn fill(&mut self) { 53 | let mut rng = thread_rng(); 54 | 55 | self.0 = vec![ 56 | Shape::I, 57 | Shape::J, 58 | Shape::L, 59 | Shape::O, 60 | Shape::S, 61 | Shape::T, 62 | Shape::Z, 63 | ]; 64 | self.0.shuffle(&mut rng); 65 | } 66 | } 67 | 68 | impl Default for RandomBag { 69 | fn default() -> Self { 70 | Self::new() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/position.rs: -------------------------------------------------------------------------------- 1 | use crate::field::{block::BLOCK_SIZE, FIELD_HEIGHT, FIELD_WIDTH}; 2 | use bevy::prelude::*; 3 | use serde::{Deserialize, Serialize}; 4 | use std::ops::{Add, AddAssign, Sub, SubAssign}; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 7 | pub struct Position { 8 | pub x: i8, 9 | pub y: i8, 10 | } 11 | 12 | #[macro_export] 13 | macro_rules! pos { 14 | ($(($x:expr, $y:expr)),*) => { 15 | [$(pos!($x, $y)),*] 16 | }; 17 | ($x:expr, $y:expr $(,)?) => { 18 | $crate::position::Position::new($x, $y) 19 | }; 20 | } 21 | 22 | impl Position { 23 | pub fn translation(self) -> Vec3 { 24 | Vec3::new( 25 | (self.x as f32 - FIELD_WIDTH as f32 / 2.) * BLOCK_SIZE, 26 | (self.y as f32 - FIELD_HEIGHT as f32 / 2.) * BLOCK_SIZE, 27 | 0.0, 28 | ) 29 | } 30 | } 31 | 32 | impl Add for Position { 33 | type Output = Self; 34 | 35 | fn add(self, rhs: Self) -> Self::Output { 36 | Self { 37 | x: self.x + rhs.x, 38 | y: self.y + rhs.y, 39 | } 40 | } 41 | } 42 | 43 | impl AddAssign for Position { 44 | fn add_assign(&mut self, rhs: Self) { 45 | *self = *self + rhs; 46 | } 47 | } 48 | 49 | impl Sub for Position { 50 | type Output = Self; 51 | 52 | fn sub(self, rhs: Self) -> Self::Output { 53 | Self { 54 | x: self.x - rhs.x, 55 | y: self.y - rhs.y, 56 | } 57 | } 58 | } 59 | 60 | impl SubAssign for Position { 61 | fn sub_assign(&mut self, rhs: Self) { 62 | *self = *self - rhs; 63 | } 64 | } 65 | 66 | impl Position { 67 | pub const fn new(x: i8, y: i8) -> Self { 68 | Self { x, y } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/mino/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod event; 2 | pub mod shape; 3 | pub mod t_spin; 4 | 5 | use self::shape::Shape; 6 | use crate::{ 7 | field::{Field, FIELD_HEIGHT, FIELD_WIDTH}, 8 | pos, 9 | position::Position, 10 | }; 11 | use bevy::prelude::*; 12 | use serde::{Deserialize, Serialize}; 13 | 14 | #[derive(Debug, Clone, Copy, Component, Serialize, Deserialize)] 15 | pub struct Mino { 16 | pub pos: Position, 17 | pub angle: Angle, 18 | pub shape: Shape, 19 | } 20 | 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 22 | pub enum Angle { 23 | #[default] 24 | Deg0, 25 | Deg90, 26 | Deg180, 27 | Deg270, 28 | } 29 | 30 | impl Mino { 31 | pub fn new(shape: Shape, field: &Field) -> Option { 32 | (0..=2) 33 | .rev() 34 | .map(|offset_y| { 35 | pos!( 36 | (FIELD_WIDTH - shape.width()) / 2, 37 | FIELD_HEIGHT - offset_y - shape.offset_y(), 38 | ) 39 | }) 40 | .find(|&pos| field.blocks.can_place_mino(pos, shape, Angle::default())) 41 | .map(|pos| Self { 42 | pos, 43 | angle: Angle::default(), 44 | shape, 45 | }) 46 | } 47 | 48 | pub fn spawn(self, commands: &mut Commands) -> Entity { 49 | commands.spawn((SpatialBundle::default(), self)).id() 50 | } 51 | 52 | pub fn is_landed(&self, field: &Field) -> bool { 53 | !field 54 | .blocks 55 | .can_place_mino(self.pos + pos!(0, -1), self.shape, self.angle) 56 | } 57 | } 58 | 59 | impl From for usize { 60 | fn from(angle: Angle) -> Self { 61 | match angle { 62 | Angle::Deg0 => 0, 63 | Angle::Deg90 => 1, 64 | Angle::Deg180 => 2, 65 | Angle::Deg270 => 3, 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/actions/install-linux-deps/action.yml: -------------------------------------------------------------------------------- 1 | # ref: https://github.com/bevyengine/bevy/blob/77ed72bc161a561eee5cacbb9d126cdd93fc44b3/.github/actions/install-linux-deps/action.yml 2 | 3 | # This action installs a few dependencies necessary to build Bevy on Linux. By default it installs 4 | # alsa and udev, but can be configured depending on which libraries are needed: 5 | # 6 | # ``` 7 | # - uses: ./.github/actions/install-linux-deps 8 | # with: 9 | # alsa: false 10 | # wayland: true 11 | # ``` 12 | # 13 | # See the `inputs` section for all options and their defaults. Note that you must checkout the 14 | # repository before you can use this action. 15 | # 16 | # This action will only install dependencies when the current operating system is Linux. It will do 17 | # nothing on any other OS (MacOS, Windows). 18 | 19 | name: Install Linux dependencies 20 | description: Installs the dependencies necessary to build Bevy on Linux. 21 | inputs: 22 | alsa: 23 | description: Install alsa (libasound2-dev) 24 | required: false 25 | default: true 26 | udev: 27 | description: Install udev (libudev-dev) 28 | required: false 29 | default: true 30 | wayland: 31 | description: Install Wayland (libwayland-dev) 32 | required: false 33 | default: false 34 | xkb: 35 | description: Install xkb (libxkbcommon-dev) 36 | required: false 37 | default: false 38 | runs: 39 | using: composite 40 | steps: 41 | - name: Install Linux dependencies 42 | shell: bash 43 | if: ${{ runner.os == 'linux' }} 44 | run: > 45 | sudo apt-get update 46 | 47 | sudo apt-get install --no-install-recommends 48 | ${{ fromJSON(inputs.alsa) && 'libasound2-dev' || '' }} 49 | ${{ fromJSON(inputs.udev) && 'libudev-dev' || '' }} 50 | ${{ fromJSON(inputs.wayland) && 'libwayland-dev' || '' }} 51 | ${{ fromJSON(inputs.xkb) && 'libxkbcommon-dev' || '' }} 52 | -------------------------------------------------------------------------------- /src/mino/t_spin.rs: -------------------------------------------------------------------------------- 1 | use crate::{field::Field, pos, position::Position}; 2 | 3 | use super::{shape::Shape, Mino}; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 6 | pub enum TSpin { 7 | #[default] 8 | None, 9 | Mini, 10 | Full, 11 | } 12 | 13 | impl TSpin { 14 | pub fn update(&mut self, mino: &Mino, field: &Field, delta: Position) { 15 | *self = if Self::is_t_spin(mino, field) { 16 | if Self::is_t_spin_mini(mino, field, delta) { 17 | Self::Mini 18 | } else { 19 | Self::Full 20 | } 21 | } else { 22 | Self::None 23 | }; 24 | } 25 | 26 | // Tミノであり,Tミノの四隅が3箇所以上埋まっているとT-Spin 27 | // 壁や床は埋まっている扱い 28 | fn is_t_spin(mino: &Mino, field: &Field) -> bool { 29 | if mino.shape != Shape::T { 30 | return false; 31 | } 32 | 33 | let fullfilled = T_SPIN_CHECK_POSITIONS 34 | .iter() 35 | .map(|&pos| pos + mino.pos) 36 | .filter(|&pos| { 37 | field 38 | .blocks 39 | .get(pos) 40 | .map_or(true, |block| block.is_filled()) 41 | }) 42 | .count(); 43 | 44 | fullfilled >= 3 45 | } 46 | 47 | // T-Spinであり,回転補正が(±1, ±2)ではなく,Tミノの凸側の隅2箇所が埋まっていないとT-Spin Mini 48 | fn is_t_spin_mini(mino: &Mino, field: &Field, delta: Position) -> bool { 49 | if delta.x.abs() == 1 && delta.y.abs() == 2 { 50 | false 51 | } else { 52 | let angle_idx: usize = mino.angle.into(); 53 | 54 | !T_SPIN_MINI_CHECK_POSITIONS[angle_idx] 55 | .iter() 56 | .map(|&pos| pos + mino.pos) 57 | .all(|pos| { 58 | field 59 | .blocks 60 | .get(pos) 61 | .map_or(true, |block| block.is_filled()) 62 | }) 63 | } 64 | } 65 | } 66 | 67 | static T_SPIN_CHECK_POSITIONS: [Position; 4] = pos![(0, 0), (2, 0), (0, 2), (2, 2)]; 68 | static T_SPIN_MINI_CHECK_POSITIONS: [[Position; 2]; 4] = [ 69 | pos![(0, 2), (2, 2)], 70 | pos![(2, 2), (2, 0)], 71 | pos![(2, 0), (0, 0)], 72 | pos![(0, 0), (0, 2)], 73 | ]; 74 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | field::{local::LocalField, Field}, 3 | net::{broadcast_state, PlayerId, PlayerState, Players, Socket}, 4 | }; 5 | use bevy::prelude::*; 6 | use if_chain::if_chain; 7 | 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, States)] 9 | pub enum AppState { 10 | #[default] 11 | MatchMaking, 12 | Playing, 13 | Finished, 14 | } 15 | 16 | #[derive(Event)] 17 | pub struct GameOverEvent; 18 | 19 | #[derive(Event)] 20 | pub struct StateChangeEvent { 21 | pub player_id: PlayerId, 22 | pub state: PlayerState, 23 | } 24 | 25 | pub fn handle_gameover( 26 | mut events: EventReader, 27 | mut state: ResMut>, 28 | mut socket: ResMut, 29 | players: Res, 30 | mut field_query: Query<&mut Field, With>, 31 | ) { 32 | if events.read().next().is_none() { 33 | return; 34 | } 35 | let Ok(mut field) = field_query.get_single_mut() else { 36 | return; 37 | }; 38 | 39 | field.player.state = PlayerState::GameOver; 40 | broadcast_state(&mut socket, &players, PlayerState::GameOver); 41 | 42 | state.set(AppState::Finished); 43 | } 44 | 45 | pub fn handle_state_change( 46 | mut events: EventReader, 47 | mut state: ResMut>, 48 | mut socket: ResMut, 49 | mut players: ResMut, 50 | mut field_query: Query<&mut Field, Without>, 51 | mut my_field_query: Query<&mut Field, With>, 52 | ) { 53 | for event in events.read() { 54 | if_chain! { 55 | if let Some(mut field) = field_query.iter_mut().find(|field| field.player.id == event.player_id); 56 | if let Some(player) = players.0.iter_mut().find(|player| player.id == event.player_id); 57 | then { 58 | field.player.state = event.state; 59 | player.state = event.state; 60 | } 61 | } 62 | 63 | if players 64 | .0 65 | .iter() 66 | .all(|player| player.state == PlayerState::GameOver) 67 | { 68 | let Ok(mut my_field) = my_field_query.get_single_mut() else { 69 | return; 70 | }; 71 | 72 | my_field.player.state = PlayerState::Win; 73 | broadcast_state(&mut socket, &players, PlayerState::Win); 74 | 75 | state.set(AppState::Finished); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/mino/shape.rs: -------------------------------------------------------------------------------- 1 | use super::Angle; 2 | use crate::{field::block::Block, pos, position::Position}; 3 | use bevy::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 7 | pub enum Shape { 8 | I, 9 | J, 10 | L, 11 | O, 12 | S, 13 | T, 14 | Z, 15 | } 16 | 17 | impl Shape { 18 | pub const COUNT: usize = 7; 19 | 20 | pub const fn width(&self) -> i8 { 21 | match self { 22 | Self::I => 4, 23 | Self::J | Self::L | Self::S | Self::T | Self::Z => 3, 24 | Self::O => 2, 25 | } 26 | } 27 | pub fn offset_y(&self) -> i8 { 28 | match self { 29 | Self::O => 0, 30 | Self::I => 2, 31 | Self::J | Self::L | Self::S | Self::T | Self::Z => 1, 32 | } 33 | } 34 | 35 | pub fn color(&self) -> Color { 36 | Block::from(*self).color() 37 | } 38 | 39 | pub fn blocks(&self, angle: Angle) -> &[Position] { 40 | let angle_idx: usize = angle.into(); 41 | 42 | match self { 43 | Shape::I => &I_SHAPES[angle_idx], 44 | Shape::J => &J_SHAPES[angle_idx], 45 | Shape::L => &L_SHAPES[angle_idx], 46 | Shape::O => &O_SHAPES[angle_idx], 47 | Shape::S => &S_SHAPES[angle_idx], 48 | Shape::T => &T_SHAPES[angle_idx], 49 | Shape::Z => &Z_SHAPES[angle_idx], 50 | } 51 | } 52 | } 53 | 54 | macro_rules! define_shape { 55 | ($shape:expr; $(($x:expr, $y:expr)),*) => { 56 | [ 57 | [$(pos!($x, $y)),*], 58 | [$(pos!($y, 1 - ($x - ($shape.width() - 2)))),*], 59 | [$(pos!(($shape.width() - 1) - $x, ($shape.width() - 1) - $y)),*], 60 | [$(pos!(1 - ($y - ($shape.width() - 2)), $x)),*], 61 | ] 62 | }; 63 | } 64 | 65 | type MinoShapes = [[Position; 4]; 4]; 66 | 67 | static I_SHAPES: MinoShapes = define_shape!(Shape::I; (0, 2), (1, 2), (2, 2), (3, 2)); 68 | static J_SHAPES: MinoShapes = define_shape!(Shape::J; (0, 2), (0, 1), (1, 1), (2, 1)); 69 | static L_SHAPES: MinoShapes = define_shape!(Shape::L; (2, 2), (0, 1), (1, 1), (2, 1)); 70 | static O_SHAPES: MinoShapes = define_shape!(Shape::O; (0, 1), (1, 1), (0, 0), (1, 0)); 71 | static S_SHAPES: MinoShapes = define_shape!(Shape::S; (1, 2), (2, 2), (0, 1), (1, 1)); 72 | static T_SHAPES: MinoShapes = define_shape!(Shape::T; (1, 2), (0, 1), (1, 1), (2, 1)); 73 | static Z_SHAPES: MinoShapes = define_shape!(Shape::Z; (0, 2), (1, 2), (1, 1), (2, 1)); 74 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::prelude::*; 4 | 5 | use crate::{ 6 | field::local::HoldEvent, 7 | movement::{Direction, MoveEvent}, 8 | }; 9 | 10 | const MOVE_REPLEAT_DELAY: Duration = Duration::from_millis(300); 11 | const MOVE_REPLEAT_INTERVAL: Duration = Duration::from_millis(30); 12 | 13 | #[derive(Resource)] 14 | pub struct KeyboardRepeatTimer(Timer); 15 | 16 | impl Default for KeyboardRepeatTimer { 17 | fn default() -> Self { 18 | Self(Timer::from_seconds( 19 | MOVE_REPLEAT_DELAY.as_secs_f32(), 20 | TimerMode::Repeating, 21 | )) 22 | } 23 | } 24 | 25 | pub fn keyboard_input_system( 26 | keyboard_input: Res>, 27 | time: Res