├── .gitignore ├── src ├── main.rs ├── game │ ├── camera.rs │ ├── components.rs │ ├── living_beings.rs │ ├── monsters.rs │ ├── mod.rs │ ├── monster_ai.rs │ ├── bullets.rs │ ├── map.rs │ └── player.rs ├── lib.rs └── menus │ ├── common.rs │ └── mod.rs ├── assets └── fonts │ ├── FiraMono-Medium.ttf │ └── FiraSans-Bold.ttf ├── Cargo.toml ├── readme.md ├── index.html ├── LICENSE.md ├── .vscode └── launch.json └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use platformer::run; 2 | 3 | fn main() { 4 | run(); 5 | } 6 | -------------------------------------------------------------------------------- /assets/fonts/FiraMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/belzile/platformer/HEAD/assets/fonts/FiraMono-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/FiraSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/belzile/platformer/HEAD/assets/fonts/FiraSans-Bold.ttf -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "platformer" 3 | version = "0.1.0" 4 | edition = "2021" 5 | resolver = "2" 6 | 7 | [dependencies] 8 | wasm-bindgen = "0.2" 9 | bevy_rapier2d = { version = "0.12.1", features = [ "wasm-bindgen" ] } 10 | rand = "0.8.4" 11 | getrandom = { version = "0.2", features = ["js"] } 12 | bevy = "0.6" 13 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Platformer 2 | 3 | Rust platformer written in Rust and using Bevy. 4 | 5 | [Play it here!](https://belzile.github.io/platformer/) 6 | 7 | ## Running Locally 8 | 9 | ```rs 10 | cargo run 11 | ``` 12 | 13 | ## Building and Running for the Web 14 | 15 | ```rs 16 | wasm-pack build --target web --release 17 | npx serve . 18 | ``` -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Bevy Platformer 4 | 5 | 8 | 9 | 15 |

16 | Left-Right Arrows: Moves Player 17 |
18 | Up Arrow: Jump 19 |
20 | Space Bar: Shoot 21 |

22 | 23 | -------------------------------------------------------------------------------- /src/game/camera.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | math::Vec3, 3 | prelude::OrthographicCameraBundle, 4 | render::camera::{DepthCalculation, OrthographicProjection, ScalingMode}, 5 | }; 6 | 7 | pub fn new_camera_2d() -> OrthographicCameraBundle { 8 | let far = 1000.0; 9 | let mut camera = OrthographicCameraBundle::new_2d(); 10 | camera.orthographic_projection = OrthographicProjection { 11 | far, 12 | depth_calculation: DepthCalculation::ZDifference, 13 | scaling_mode: ScalingMode::FixedHorizontal, 14 | ..Default::default() 15 | }; 16 | camera.transform.scale = Vec3::new(10., 10., 1.); 17 | return camera; 18 | } 19 | -------------------------------------------------------------------------------- /src/game/components.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::{Component, Color}; 2 | 3 | pub struct Materials { 4 | pub player_material: Color, 5 | pub floor_material: Color, 6 | pub monster_material: Color, 7 | pub bullet_material: Color, 8 | pub winning_zone_material: Color, 9 | } 10 | 11 | #[derive(Copy, Clone)] 12 | pub enum GameDirection { 13 | Left, 14 | Right, 15 | } 16 | 17 | #[derive(Component)] 18 | pub struct Player { 19 | pub speed: f32, 20 | pub facing_direction: GameDirection, 21 | } 22 | 23 | #[derive(Component)] 24 | pub struct Enemy; 25 | 26 | #[derive(Component)] 27 | pub struct Monster { 28 | pub speed: f32, 29 | pub facing_direction: GameDirection, 30 | } 31 | 32 | #[derive(Component)] 33 | pub struct Bullet; 34 | 35 | #[derive(Component)] 36 | pub struct Jumper { 37 | pub jump_impulse: f32, 38 | pub is_jumping: bool, 39 | } 40 | 41 | #[derive(Component)] 42 | pub struct WinningZone; 43 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use bevy::{prelude::*, window::WindowMode}; 2 | use wasm_bindgen::prelude::*; 3 | 4 | mod game; 5 | use game::GamePlugin; 6 | 7 | mod menus; 8 | use menus::MenusPlugin; 9 | 10 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 11 | pub enum AppState { 12 | MainMenu, 13 | InGame, 14 | GameOver, 15 | BetweenLevels, 16 | } 17 | 18 | #[wasm_bindgen] 19 | pub fn run() { 20 | let mut app = App::new(); 21 | 22 | #[cfg(target_arch = "wasm32")] 23 | app.add_plugin(bevy_webgl2::WebGL2Plugin); 24 | 25 | app.insert_resource(WindowDescriptor { 26 | title: "Platformer!".to_string(), 27 | width: 640.0, 28 | height: 400.0, 29 | vsync: true, 30 | mode: WindowMode::Windowed, 31 | ..Default::default() 32 | }) 33 | .add_plugins(DefaultPlugins) 34 | .add_state(AppState::MainMenu) 35 | .insert_resource(ClearColor(Color::rgb(0.04, 0.04, 0.04))) 36 | .add_plugin(MenusPlugin) 37 | .add_plugin(GamePlugin) 38 | .run(); 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, sbelzile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'platformer'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=platformer", 15 | "--package=platformer" 16 | ], 17 | "filter": { 18 | "name": "platformer", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'platformer'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=platformer", 34 | "--package=platformer" 35 | ], 36 | "filter": { 37 | "name": "platformer", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /src/game/living_beings.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_rapier2d::prelude::{RigidBodyPositionComponent}; 3 | 4 | use crate::AppState; 5 | 6 | use super::Player; 7 | 8 | #[derive(Component)] 9 | pub struct LivingBeing; 10 | 11 | pub struct LivingBeingHitEvent { 12 | pub entity: Entity, 13 | } 14 | 15 | pub struct LivingBeingDeathEvent { 16 | pub entity: Entity, 17 | } 18 | 19 | pub fn on_living_being_hit( 20 | mut living_being_hit_events: EventReader, 21 | mut send_living_being_death: EventWriter, 22 | ) { 23 | for event in living_being_hit_events.iter() { 24 | send_living_being_death.send(LivingBeingDeathEvent { 25 | entity: event.entity, 26 | }) 27 | } 28 | } 29 | 30 | pub fn on_living_being_dead( 31 | mut living_being_death_events: EventReader, 32 | player_query: Query>, 33 | mut commands: Commands, 34 | mut app_state: ResMut>, 35 | ) { 36 | for event in living_being_death_events.iter() { 37 | let player_id = player_query.get_single(); 38 | commands.entity(event.entity).despawn_recursive(); 39 | match player_id { 40 | Ok(player) if event.entity == player => app_state.set(AppState::GameOver).unwrap(), 41 | _ => (), 42 | }; 43 | } 44 | } 45 | 46 | pub fn death_by_height( 47 | mut send_death_event: EventWriter, 48 | living_being: Query<(Entity, &RigidBodyPositionComponent), With>, 49 | ) { 50 | for (entity, position) in living_being.iter() { 51 | if position.position.translation.y < -1. { 52 | send_death_event.send(LivingBeingDeathEvent { entity }) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/game/monsters.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_rapier2d::prelude::*; 3 | 4 | use super::{Enemy, GameDirection, Jumper, LivingBeing, Materials, Monster}; 5 | 6 | pub fn insert_monster_at(commands: &mut Commands, x: usize, y: usize, materials: &Res) { 7 | let rigid_body = RigidBodyBundle { 8 | position: Vec2::new(x as f32, y as f32).into(), 9 | mass_properties: RigidBodyMassPropsFlags::ROTATION_LOCKED.into(), 10 | activation: RigidBodyActivation::cannot_sleep().into(), 11 | forces: RigidBodyForces { 12 | gravity_scale: 3., 13 | ..Default::default() 14 | }.into(), 15 | ..Default::default() 16 | }; 17 | 18 | let collider = ColliderBundle { 19 | shape: ColliderShape::round_cuboid(0.35, 0.35, 0.1).into(), 20 | flags: ColliderFlags { 21 | active_events: ActiveEvents::CONTACT_EVENTS, 22 | ..Default::default() 23 | }.into(), 24 | ..Default::default() 25 | }; 26 | 27 | let sprite = SpriteBundle { 28 | sprite: Sprite { 29 | color: materials.monster_material.clone(), 30 | custom_size: Vec2::new(0.9, 0.9).into(), 31 | ..Default::default() 32 | }, 33 | ..Default::default() 34 | }; 35 | 36 | commands 37 | .spawn_bundle(sprite) 38 | .insert_bundle(rigid_body) 39 | .insert_bundle(collider) 40 | .insert(RigidBodyPositionSync::Discrete) 41 | .insert(LivingBeing) 42 | .insert(Enemy) 43 | .insert(Monster { 44 | speed: 3., 45 | facing_direction: GameDirection::Right, 46 | }) 47 | .insert(Jumper { 48 | jump_impulse: 14., 49 | is_jumping: false, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/game/mod.rs: -------------------------------------------------------------------------------- 1 | mod components; 2 | pub use components::*; 3 | mod camera; 4 | pub use camera::*; 5 | mod player; 6 | pub use player::*; 7 | mod map; 8 | pub use map::*; 9 | mod monsters; 10 | pub use monsters::*; 11 | mod bullets; 12 | pub use bullets::*; 13 | mod living_beings; 14 | pub use living_beings::*; 15 | mod monster_ai; 16 | pub use monster_ai::*; 17 | 18 | use super::AppState; 19 | use bevy::prelude::*; 20 | use bevy_rapier2d::prelude::*; 21 | 22 | pub struct GamePlugin; 23 | 24 | impl Plugin for GamePlugin { 25 | fn build(&self, app: &mut App) { 26 | app.add_system_set(SystemSet::on_enter(AppState::InGame).with_system(spawn_floor.system())) 27 | .add_system_set( 28 | SystemSet::on_update(AppState::InGame) 29 | .with_system(back_to_main_menu_controls.system()), 30 | ) 31 | .add_plugin(RapierPhysicsPlugin::::default()) 32 | .add_plugin(PlayerPlugin) 33 | .add_plugin(MonsterAiPlugin) 34 | .add_system(on_level_success.system()) 35 | .add_startup_system(setup.system()); 36 | } 37 | } 38 | 39 | fn setup(mut commands: Commands) { 40 | commands.insert_resource(Materials { 41 | player_material: Color::rgb(0.969, 0.769, 0.784).into(), 42 | floor_material: Color::rgb(0.7, 0.7, 0.7).into(), 43 | monster_material: Color::rgb(0.8, 0., 0.).into(), 44 | bullet_material: Color::rgb(0.8, 0.8, 0.).into(), 45 | winning_zone_material: Color::rgb(0., 0.75, 1.).into(), 46 | }); 47 | } 48 | 49 | fn back_to_main_menu_controls( 50 | mut keys: ResMut>, 51 | mut app_state: ResMut>, 52 | ) { 53 | if *app_state.current() == AppState::InGame { 54 | if keys.just_pressed(KeyCode::Escape) { 55 | app_state.set(AppState::MainMenu).unwrap(); 56 | keys.reset(KeyCode::Escape); 57 | } 58 | } 59 | } 60 | 61 | fn on_level_success( 62 | mut app_state: ResMut>, 63 | players: Query>, 64 | winning_zones: Query>, 65 | mut contact_events: EventReader, 66 | ) { 67 | for contact_event in contact_events.iter() { 68 | if let ContactEvent::Started(h1, h2) = contact_event { 69 | let player = players.get_single(); 70 | let winning_zone = winning_zones.get_single(); 71 | if player.is_ok() && winning_zone.is_ok() { 72 | let p = player.unwrap(); 73 | let w = winning_zone.unwrap(); 74 | if (h1.entity() == p && h2.entity() == w) || (h1.entity() == w && h2.entity() == p) 75 | { 76 | app_state.set(AppState::BetweenLevels).unwrap(); 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/game/monster_ai.rs: -------------------------------------------------------------------------------- 1 | use bevy::core::FixedTimestep; 2 | use bevy::prelude::*; 3 | use bevy_rapier2d::prelude::*; 4 | use rand::{thread_rng, Rng}; 5 | 6 | use super::super::AppState; 7 | use super::{GameDirection, Jumper, Monster}; 8 | 9 | struct MonsterWalkedIntoWallEvent { 10 | entity: Entity, 11 | } 12 | 13 | pub struct MonsterAiPlugin; 14 | 15 | impl Plugin for MonsterAiPlugin { 16 | fn build(&self, app: &mut App) { 17 | app.add_event::() 18 | .add_system_set( 19 | SystemSet::on_update(AppState::InGame) 20 | .with_system(monster_walking_system.system()) 21 | .with_system(monster_wall_contact_detection.system()) 22 | .with_system(monster_change_direction_on_contact.system()), 23 | ) 24 | .add_system_set( 25 | SystemSet::new() 26 | .with_run_criteria(FixedTimestep::step(2.0)) 27 | .with_system(monster_jumps.system()), 28 | ); 29 | } 30 | } 31 | 32 | fn monster_walking_system(mut monsters: Query<(&Monster, &mut RigidBodyVelocityComponent)>) { 33 | for (monster, mut velocity) in monsters.iter_mut() { 34 | let speed = match monster.facing_direction { 35 | GameDirection::Left => -monster.speed, 36 | GameDirection::Right => monster.speed, 37 | }; 38 | 39 | velocity.linvel = Vec2::new(speed, velocity.linvel.y).into(); 40 | } 41 | } 42 | 43 | fn monster_wall_contact_detection( 44 | monsters: Query>, 45 | mut contact_events: EventReader, 46 | mut send_monster_walked_into_wall: EventWriter, 47 | ) { 48 | for contact_event in contact_events.iter() { 49 | if let ContactEvent::Started(h1, h2) = contact_event { 50 | for monster in monsters.iter() { 51 | if h1.entity() == monster || h2.entity() == monster { 52 | send_monster_walked_into_wall 53 | .send(MonsterWalkedIntoWallEvent { entity: monster }) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | fn monster_change_direction_on_contact( 61 | mut events: EventReader, 62 | mut monster_query: Query<&mut Monster>, 63 | ) { 64 | for event in events.iter() { 65 | // bullet contacts may destroy monster before running this system. 66 | if let Ok(mut monster) = monster_query.get_mut(event.entity) { 67 | monster.facing_direction = match monster.facing_direction { 68 | GameDirection::Left => GameDirection::Right, 69 | GameDirection::Right => GameDirection::Left, 70 | } 71 | } 72 | } 73 | } 74 | 75 | fn monster_jumps(mut monsters: Query<(&mut Jumper, &mut RigidBodyVelocityComponent), With>) { 76 | for (monster, mut velocity) in monsters.iter_mut() { 77 | if should_jump() { 78 | velocity.linvel = Vec2::new(0., monster.jump_impulse).into(); 79 | } 80 | } 81 | } 82 | 83 | fn should_jump() -> bool { 84 | let mut rng = thread_rng(); 85 | rng.gen_bool(0.1) 86 | } 87 | -------------------------------------------------------------------------------- /src/game/bullets.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_rapier2d::prelude::*; 3 | 4 | use super::{Bullet, GameDirection, LivingBeing, LivingBeingHitEvent, Materials}; 5 | 6 | pub struct BulletFiredEvent { 7 | pub position: Vec2, 8 | pub direction: GameDirection, 9 | } 10 | 11 | pub fn on_bullet_fired( 12 | mut commands: Commands, 13 | materials: Res, 14 | mut bullet_fired_events: EventReader, 15 | ) { 16 | for event in bullet_fired_events.iter() { 17 | insert_bullet_at(&mut commands, &materials, event) 18 | } 19 | } 20 | 21 | pub fn insert_bullet_at( 22 | commands: &mut Commands, 23 | materials: &Res, 24 | options: &BulletFiredEvent, 25 | ) { 26 | let speed = match options.direction { 27 | GameDirection::Left => -14.0, 28 | _ => 14.0, 29 | }; 30 | 31 | let x = match options.direction { 32 | GameDirection::Left => options.position.x - 1., 33 | _ => options.position.x + 1., 34 | }; 35 | let rigid_body = RigidBodyBundle { 36 | position: Vec2::new(x, options.position.y).into(), 37 | velocity: RigidBodyVelocity { 38 | linvel: Vec2::new(speed, 0.0).into(), 39 | ..Default::default() 40 | }.into(), 41 | mass_properties: RigidBodyMassPropsFlags::ROTATION_LOCKED.into(), 42 | activation: RigidBodyActivation::cannot_sleep().into(), 43 | forces: RigidBodyForces { 44 | gravity_scale: 0., 45 | ..Default::default() 46 | }.into(), 47 | ..Default::default() 48 | }; 49 | 50 | let collider = ColliderBundle { 51 | shape: ColliderShape::cuboid(0.25, 0.05).into(), 52 | flags: ColliderFlags { 53 | active_events: ActiveEvents::CONTACT_EVENTS, 54 | ..Default::default() 55 | }.into(), 56 | ..Default::default() 57 | }; 58 | 59 | let sprite = SpriteBundle { 60 | sprite: Sprite { 61 | color: materials.bullet_material.clone(), 62 | custom_size: Vec2::new(0.5, 0.1).into(), 63 | ..Default::default() 64 | }, 65 | ..Default::default() 66 | }; 67 | 68 | commands 69 | .spawn_bundle(sprite) 70 | .insert_bundle(rigid_body) 71 | .insert_bundle(collider) 72 | .insert(RigidBodyPositionSync::Discrete) 73 | .insert(Bullet); 74 | } 75 | 76 | pub fn destroy_bullet_on_contact( 77 | mut commands: Commands, 78 | bullets: Query>, 79 | mut contact_events: EventReader, 80 | ) { 81 | for contact_event in contact_events.iter() { 82 | if let ContactEvent::Started(h1, h2) = contact_event { 83 | for bullet in bullets.iter() { 84 | if h1.entity() == bullet || h2.entity() == bullet { 85 | commands.entity(bullet).despawn_recursive(); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | pub fn kill_on_contact( 93 | mut send_living_being_hit: EventWriter, 94 | bullets: Query>, 95 | living_being: Query>, 96 | mut contact_events: EventReader, 97 | ) { 98 | for contact_event in contact_events.iter() { 99 | if let ContactEvent::Started(h1, h2) = contact_event { 100 | for bullet in bullets.iter() { 101 | for enemy in living_being.iter() { 102 | if (h1.entity() == bullet && h2.entity() == enemy) 103 | || (h1.entity() == enemy && h2.entity() == bullet) 104 | { 105 | send_living_being_hit.send(LivingBeingHitEvent { entity: enemy }); 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/menus/common.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | 3 | pub struct MenuMaterials { 4 | pub root: UiColor, 5 | pub border: UiColor, 6 | pub menu: UiColor, 7 | pub button: UiColor, 8 | pub button_hovered: UiColor, 9 | pub button_pressed: UiColor, 10 | pub button_text: Color, 11 | } 12 | 13 | impl FromWorld for MenuMaterials { 14 | fn from_world(_: &mut World) -> Self { 15 | MenuMaterials { 16 | root: Color::NONE.into(), 17 | border: Color::rgb(0.65, 0.65, 0.65).into(), 18 | menu: Color::rgb(0.15, 0.15, 0.15).into(), 19 | button: Color::rgb(0.15, 0.15, 0.15).into(), 20 | button_hovered: Color::rgb(0.25, 0.25, 0.25).into(), 21 | button_pressed: Color::rgb(0.35, 0.75, 0.35).into(), 22 | button_text: Color::WHITE, 23 | } 24 | } 25 | } 26 | 27 | pub fn button_system( 28 | materials: Res, 29 | mut buttons: Query< 30 | (&Interaction, &mut UiColor), 31 | (Changed, With