├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md └── workflows │ ├── rust.yml │ └── typos.yml ├── .gitignore ├── .typos.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── shop.json └── src ├── base ├── README.md ├── armor.rs ├── armor_right_click_equip.rs ├── bow.rs ├── break_blocks.rs ├── build.rs ├── chat.rs ├── chests.rs ├── combat.rs ├── death.rs ├── drop_items.rs ├── enchantments.rs ├── fall_damage.rs ├── item_pickup.rs ├── mod.rs ├── physics.rs ├── regeneration.rs ├── scoreboard.rs ├── utils │ ├── debug.rs │ └── mod.rs └── void_death.rs ├── bedwars_config.rs ├── colors.rs ├── commands ├── bedwars_admin.rs ├── mod.rs └── utils.rs ├── edit.rs ├── items ├── consumable.rs ├── effects │ ├── mod.rs │ └── potion.rs ├── ender_pearl.rs ├── laser_bow.rs ├── mod.rs ├── port_a_fort.rs └── tnt.rs ├── lobby.rs ├── main.rs ├── match.rs ├── menu.rs ├── resource_spawners.rs ├── shop.rs ├── spectator.rs └── utils ├── aabb.rs ├── block.rs ├── despawn_timer.rs ├── direction.rs ├── inventory.rs ├── item_kind.rs ├── item_stack.rs ├── mod.rs └── ray_cast.rs /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: [pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Build 16 | run: cargo build --verbose 17 | - name: Run tests 18 | run: cargo test --verbose 19 | 20 | - name: Rustfmt 21 | run: cargo fmt --all -- --check 22 | 23 | - name: Clippy 24 | run: cargo clippy ${{ matrix.flags }} --all-targets --all -- -D warnings 25 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | name: Typos 2 | on: [pull_request] 3 | 4 | jobs: 5 | run: 6 | name: typos 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Actions Repository 10 | uses: actions/checkout@v3 11 | 12 | - name: Check spelling 13 | uses: crate-ci/typos@master 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /world 3 | 4 | /bw-world.json -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [type.rust.extend-words] 2 | aand = "aand" -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bedwa-rs" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | 8 | bevy_ecs = "0.14.2" 9 | bevy_state = "0.14.2" 10 | bevy_time = "0.14.2" 11 | color-eyre = "0.6.3" 12 | ordermap = { version = "0.5.3", features = ["serde"] } 13 | rand = "0.8.5" 14 | rayon = "1.10.0" 15 | serde = { version = "1.0.214", features = ["derive"] } 16 | serde_json = "1.0.132" 17 | thiserror = "1.0.68" 18 | tracing = "0.1.40" 19 | valence = { git = "https://github.com/valence-rs/valence/" } 20 | # valence = { path = "../valence" } 21 | # item-stack-serialize 22 | # valence = { git = "https://github.com/maxomatic458/valence/", branch = "item-stack-serialize"} 23 | # valence_spatial = "=0.2.0-alpha.1" 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 maxomatic458 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > Parts of the server logic will be separated into [this](https://github.com/maxomatic458/valence-extra) repository (to make them more reusable). 3 | > So a rewrite will probably happen before larger features can be added. 4 | 5 | # Bedwa-rs 6 | A minecraft bedwars server written entirely in rust with [valence](https://github.com/valence-rs/valence) 7 | 8 | # Current/Future Features 9 | - [X] Mostly vanilla combat system, that supports most pvp enchants and also bows & arrows 10 | - [X] Use custom bedwars maps 11 | - [X] Configurable shops 12 | - [X] Configurable resource spawners 13 | - [X] Chests & Enderchets (still some bugs with that) 14 | - [ ] Potions 15 | - [ ] Custom Items 16 | 17 | # Getting started 18 | 19 | * Download the binary from the release page or build it yourself with `cargo build --release`. 20 | * Setup the directory as shown below (the world folder should be a 1.20.1 bedwars map with all blocks, beds and chests placed already) 21 | ``` 22 | world/ 23 | shop.json 24 | ``` 25 | * Run the ``bedwa-rs`` binary in the directory. 26 | 27 | # Configuring the server 28 | When you run the server for the first time, you will be placed in an edit mode. 29 | Now you can use the items in your hotbar and the chat commands to configure the server. 30 | 31 | ## Chat commands 32 | * `/bwa arenabounds `: Set the arena bounds. 33 | 34 | * `/bwa team add `: Add a team. 35 | 36 | * `/bwa team remove `: Remove a team. 37 | 38 | * `/bwa team spawn `: Set the spawn of a team. 39 | 40 | * `/bwa team bed `: Set the bed of a team. 41 | 42 | * `/bwa shop `: Place a shop (team is optional, when it is set, then the shop will only spawn if the team is in the match). 43 | 44 | * `/bwa shop remove `: Remove a shop. 45 | 46 | * `/bwa spawner add `: Add a resource spawner, `resource` is the minecraft item id, like `iron_ingot`, `interval` is the time in seconds between spawns, `amount` is the amount of items spawned, `team` is optional, when it is set, then the spawner will only spawn if the team is in the match. 47 | 48 | * `/bwa spawner remove `: Remove a resource spawner. 49 | 50 | * `/bwa lobby spawn `: Set the lobby spawn. 51 | 52 | * `/bwa spectator spawn `: Set the spectator spawn. 53 | 54 | * `/bwa summary`: Print a summary of the current configuration. 55 | 56 | * ~~`/bwa help`: Print a list of all commands.~~ (not implemented yet) 57 | 58 | * `/bwa save`: Save the configuration to disk, then you can restart the server to go into play mode. 59 | 60 | ## Shop configuration 61 | The shop configuration is stored in the `shop.json` file in the server directory. 62 | The file has this structure: 63 | ```jsonc 64 | { 65 | "shop_items": { 66 | "BlockCategory": [ // Name of the category in the shop 67 | { 68 | "item": "white_wool", // Item for that category 69 | "count": 1, 70 | "nbt": null, 71 | }, 72 | [ // List of items that can be bought 73 | { 74 | "offer": { 75 | "item": "white_wool", 76 | "count": 4, 77 | "nbt": null, 78 | }, 79 | "price": { 80 | "item": "iron_ingot", 81 | "count": 1, 82 | "nbt": null, 83 | } 84 | } 85 | ] 86 | ] 87 | } 88 | } 89 | ``` 90 | 91 | This is how an enchanted item would look like: 92 | ```jsonc 93 | { 94 | "offer": { 95 | "item": "diamond_sword", 96 | "count": 1, 97 | "nbt": { 98 | "display": { 99 | "Lore": [ 100 | "{\"text\":\"10 Gold\", \"italic\": \"false\", \"bold\": \"true\", \"color\": \"gold\"}'}}" 101 | ] 102 | }, 103 | "Enchantments": [ 104 | { 105 | "id": "minecraft:sharpness", 106 | "lvl": 1 107 | }, 108 | { 109 | "id": "minecraft:fire_aspect", 110 | "lvl": 1 111 | }, 112 | { 113 | "id": "minecraft:knockback", 114 | "lvl": 1 115 | } 116 | ] 117 | } 118 | }, 119 | "price": { 120 | "item": "gold_ingot", 121 | "count": 10, 122 | "nbt": null 123 | } 124 | } 125 | ``` 126 | 127 | -------------------------------------------------------------------------------- /src/base/README.md: -------------------------------------------------------------------------------- 1 | # this module contains mechanics that are in base mc, such as building, mining, etc. -------------------------------------------------------------------------------- /src/base/armor.rs: -------------------------------------------------------------------------------- 1 | use valence::{prelude::Equipment, protocol::Sound, ItemKind, ItemStack}; 2 | 3 | use super::enchantments::{protection_reduction, Enchantment, ItemStackExtEnchantments}; 4 | 5 | pub trait ItemKindExtArmor { 6 | /// The armor points of that item 7 | fn armor_points(&self) -> f32; 8 | /// The toughness of that item 9 | fn toughness(&self) -> f32; 10 | /// Whether the item is a helmet 11 | fn is_helmet(&self) -> bool; 12 | /// Whether the item is a chestplate 13 | fn is_chestplate(&self) -> bool; 14 | /// Whether the item is leggings 15 | fn is_leggings(&self) -> bool; 16 | /// Whether the item is boots 17 | fn is_boots(&self) -> bool; 18 | /// Whether the item is armor 19 | fn is_armor(&self) -> bool; 20 | /// The sound when the item is equipped 21 | fn equip_sound(&self) -> Option; 22 | /// The knockback resistance of the item 23 | fn knockback_resistance(&self) -> f32; 24 | } 25 | 26 | impl ItemKindExtArmor for ItemKind { 27 | fn armor_points(&self) -> f32 { 28 | match self { 29 | ItemKind::LeatherHelmet => 1.0, 30 | ItemKind::LeatherChestplate => 3.0, 31 | ItemKind::LeatherLeggings => 2.0, 32 | ItemKind::LeatherBoots => 1.0, 33 | 34 | ItemKind::ChainmailHelmet => 2.0, 35 | ItemKind::ChainmailChestplate => 5.0, 36 | ItemKind::ChainmailLeggings => 4.0, 37 | ItemKind::ChainmailBoots => 1.0, 38 | 39 | ItemKind::IronHelmet => 2.0, 40 | ItemKind::IronChestplate => 6.0, 41 | ItemKind::IronLeggings => 5.0, 42 | ItemKind::IronBoots => 2.0, 43 | 44 | ItemKind::GoldenHelmet => 2.0, 45 | ItemKind::GoldenChestplate => 5.0, 46 | ItemKind::GoldenLeggings => 3.0, 47 | ItemKind::GoldenBoots => 1.0, 48 | 49 | ItemKind::DiamondHelmet => 3.0, 50 | ItemKind::DiamondChestplate => 8.0, 51 | ItemKind::DiamondLeggings => 6.0, 52 | ItemKind::DiamondBoots => 3.0, 53 | 54 | ItemKind::NetheriteHelmet => 3.0, 55 | ItemKind::NetheriteChestplate => 8.0, 56 | ItemKind::NetheriteLeggings => 6.0, 57 | ItemKind::NetheriteBoots => 3.0, 58 | _ => 0.0, 59 | } 60 | } 61 | 62 | fn toughness(&self) -> f32 { 63 | match self { 64 | ItemKind::DiamondHelmet => 2.0, 65 | ItemKind::DiamondChestplate => 2.0, 66 | ItemKind::DiamondLeggings => 2.0, 67 | ItemKind::DiamondBoots => 2.0, 68 | 69 | ItemKind::NetheriteHelmet => 3.0, 70 | ItemKind::NetheriteChestplate => 3.0, 71 | ItemKind::NetheriteLeggings => 3.0, 72 | ItemKind::NetheriteBoots => 3.0, 73 | _ => 0.0, 74 | } 75 | } 76 | 77 | fn is_helmet(&self) -> bool { 78 | matches!( 79 | self, 80 | ItemKind::LeatherHelmet 81 | | ItemKind::ChainmailHelmet 82 | | ItemKind::IronHelmet 83 | | ItemKind::GoldenHelmet 84 | | ItemKind::DiamondHelmet 85 | | ItemKind::NetheriteHelmet 86 | ) 87 | } 88 | 89 | fn is_chestplate(&self) -> bool { 90 | matches!( 91 | self, 92 | ItemKind::LeatherChestplate 93 | | ItemKind::ChainmailChestplate 94 | | ItemKind::IronChestplate 95 | | ItemKind::GoldenChestplate 96 | | ItemKind::DiamondChestplate 97 | | ItemKind::NetheriteChestplate 98 | ) 99 | } 100 | 101 | fn is_leggings(&self) -> bool { 102 | matches!( 103 | self, 104 | ItemKind::LeatherLeggings 105 | | ItemKind::ChainmailLeggings 106 | | ItemKind::IronLeggings 107 | | ItemKind::GoldenLeggings 108 | | ItemKind::DiamondLeggings 109 | | ItemKind::NetheriteLeggings 110 | ) 111 | } 112 | 113 | fn is_boots(&self) -> bool { 114 | matches!( 115 | self, 116 | ItemKind::LeatherBoots 117 | | ItemKind::ChainmailBoots 118 | | ItemKind::IronBoots 119 | | ItemKind::GoldenBoots 120 | | ItemKind::DiamondBoots 121 | | ItemKind::NetheriteBoots 122 | ) 123 | } 124 | 125 | fn is_armor(&self) -> bool { 126 | self.is_helmet() || self.is_chestplate() || self.is_leggings() || self.is_boots() 127 | } 128 | 129 | fn equip_sound(&self) -> Option { 130 | match self { 131 | ItemKind::LeatherBoots 132 | | ItemKind::LeatherChestplate 133 | | ItemKind::LeatherHelmet 134 | | ItemKind::LeatherLeggings => Some(Sound::ItemArmorEquipLeather), 135 | ItemKind::ChainmailBoots 136 | | ItemKind::ChainmailChestplate 137 | | ItemKind::ChainmailHelmet 138 | | ItemKind::ChainmailLeggings => Some(Sound::ItemArmorEquipChain), 139 | ItemKind::IronBoots 140 | | ItemKind::IronChestplate 141 | | ItemKind::IronHelmet 142 | | ItemKind::IronLeggings => Some(Sound::ItemArmorEquipIron), 143 | ItemKind::GoldenBoots 144 | | ItemKind::GoldenChestplate 145 | | ItemKind::GoldenHelmet 146 | | ItemKind::GoldenLeggings => Some(Sound::ItemArmorEquipGold), 147 | ItemKind::DiamondBoots 148 | | ItemKind::DiamondChestplate 149 | | ItemKind::DiamondHelmet 150 | | ItemKind::DiamondLeggings => Some(Sound::ItemArmorEquipDiamond), 151 | ItemKind::NetheriteBoots 152 | | ItemKind::NetheriteChestplate 153 | | ItemKind::NetheriteHelmet 154 | | ItemKind::NetheriteLeggings => Some(Sound::ItemArmorEquipNetherite), 155 | _ => None, 156 | } 157 | } 158 | 159 | fn knockback_resistance(&self) -> f32 { 160 | match self { 161 | ItemKind::NetheriteBoots 162 | | ItemKind::NetheriteChestplate 163 | | ItemKind::NetheriteHelmet 164 | | ItemKind::NetheriteLeggings => 0.1, 165 | _ => 0.0, 166 | } 167 | } 168 | } 169 | 170 | /// Calculates the final damage 171 | fn calculate_damage_armor(damage: f32, armor_points: f32, toughness: f32) -> f32 { 172 | // damage after armor points 173 | let damage = damage 174 | * (1.0 175 | - (20.0_f32.min( 176 | ((armor_points) / 5.0).max(armor_points - (4.0 * damage / (toughness + 8.0))), 177 | ) / 25.0)); 178 | 179 | damage.max(0.0) 180 | } 181 | 182 | pub trait EquipmentExtReduction { 183 | /// Calculate the real damage the player will receive after 184 | /// accounting for armor points, toughness, and enchantments. 185 | fn received_damage(&self, damage: f32) -> f32; 186 | /// Get the armor points of the equipment 187 | fn armor_points(&self) -> f32; 188 | /// Get the toughness of the equipment 189 | fn toughness(&self) -> f32; 190 | /// Get the reduction of protection enchantments 191 | fn protection_reduction(&self) -> f32; 192 | /// Knockback resistance 193 | fn knockback_resistance(&self) -> f32; 194 | } 195 | 196 | impl EquipmentExtReduction for Equipment { 197 | fn received_damage(&self, damage: f32) -> f32 { 198 | let armor_points = self.armor_points(); 199 | let toughness = self.toughness(); 200 | 201 | let after_armor = calculate_damage_armor(damage, armor_points, toughness); 202 | let protection_reduction = self.protection_reduction(); 203 | 204 | after_armor * (1.0 - protection_reduction) 205 | } 206 | 207 | fn armor_points(&self) -> f32 { 208 | self.head().item.armor_points() 209 | + self.chest().item.armor_points() 210 | + self.legs().item.armor_points() 211 | + self.feet().item.armor_points() 212 | } 213 | 214 | fn toughness(&self) -> f32 { 215 | self.head().item.toughness() 216 | + self.chest().item.toughness() 217 | + self.legs().item.toughness() 218 | + self.feet().item.toughness() 219 | } 220 | 221 | fn protection_reduction(&self) -> f32 { 222 | self.head().protection_reduction() 223 | + self.chest().protection_reduction() 224 | + self.legs().protection_reduction() 225 | + self.feet().protection_reduction() 226 | } 227 | 228 | fn knockback_resistance(&self) -> f32 { 229 | self.head().item.knockback_resistance() 230 | + self.chest().item.knockback_resistance() 231 | + self.legs().item.knockback_resistance() 232 | + self.feet().item.knockback_resistance() 233 | } 234 | } 235 | 236 | pub trait ItemStackExtArmor { 237 | /// Get the damage reduction (in %) caused by the protection enchantment. 238 | fn protection_reduction(&self) -> f32; 239 | } 240 | 241 | impl ItemStackExtArmor for ItemStack { 242 | fn protection_reduction(&self) -> f32 { 243 | if let Some(protection_lvl) = self.enchantments().get(&Enchantment::Protection) { 244 | protection_reduction(*protection_lvl) 245 | } else { 246 | 0.0 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/base/armor_right_click_equip.rs: -------------------------------------------------------------------------------- 1 | use valence::{ 2 | interact_item::InteractItemEvent, 3 | inventory::{player_inventory::PlayerInventory, HeldItem}, 4 | prelude::*, 5 | protocol::sound::SoundCategory, 6 | }; 7 | 8 | use super::armor::ItemKindExtArmor; 9 | 10 | pub struct ArmorRightClickEquipPlugin; 11 | 12 | impl Plugin for ArmorRightClickEquipPlugin { 13 | fn build(&self, app: &mut App) { 14 | app.add_systems(Update, equip_armor_on_right_click); 15 | } 16 | } 17 | 18 | fn equip_armor_on_right_click( 19 | mut query: Query<(&mut Client, &Position, &mut Inventory, &HeldItem)>, 20 | mut events: EventReader, 21 | ) { 22 | for event in events.read() { 23 | let Ok((mut client, position, mut inventory, held_item)) = query.get_mut(event.client) 24 | else { 25 | continue; 26 | }; 27 | 28 | // TODO: handle readonly inventory 29 | 30 | let stack = inventory.slot(held_item.slot()).clone(); 31 | if !stack.item.is_armor() { 32 | continue; 33 | } 34 | 35 | let target_slot = if stack.item.is_helmet() { 36 | PlayerInventory::SLOT_HEAD 37 | } else if stack.item.is_chestplate() { 38 | PlayerInventory::SLOT_CHEST 39 | } else if stack.item.is_leggings() { 40 | PlayerInventory::SLOT_LEGS 41 | } else if stack.item.is_boots() { 42 | PlayerInventory::SLOT_FEET 43 | } else { 44 | continue; 45 | }; 46 | 47 | // in vanilla this also plays when equipping it normally 48 | if let Some(equip_sound) = stack.item.equip_sound() { 49 | client.play_sound(equip_sound, SoundCategory::Player, position.0, 1.0, 1.0); 50 | } 51 | 52 | inventory.swap_slot(held_item.slot(), target_slot); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/base/bow.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use valence::{ 3 | entity::{ 4 | arrow::ArrowEntityBundle, entity::NoGravity, living::LivingFlags, OnGround, Velocity, 5 | }, 6 | event_loop::PacketEvent, 7 | interact_item::InteractItemEvent, 8 | inventory::{HeldItem, PlayerAction}, 9 | math::Aabb, 10 | prelude::*, 11 | protocol::{packets::play::PlayerActionC2s, sound::SoundCategory, Sound}, 12 | }; 13 | 14 | use crate::{ 15 | base::{ 16 | combat::{EYE_HEIGHT, SNEAK_EYE_HEIGHT}, 17 | enchantments::{Enchantment, ItemStackExtEnchantments}, 18 | physics::TerminalVelocity, 19 | }, 20 | utils::{despawn_timer::DespawnTimer, inventory::InventoryExt}, 21 | }; 22 | 23 | use super::{ 24 | combat::CombatState, 25 | enchantments::power_extra_dmg, 26 | physics::{ 27 | CollidesWithBlocks, CollidesWithEntities, Drag, EntityBlockCollisionEvent, 28 | GetsStuckOnCollision, Gravity, PhysicsMarker, 29 | }, 30 | }; 31 | 32 | pub struct BowPlugin; 33 | 34 | const CAN_SHOOT_AFTER_MS: u64 = 150; 35 | const ARROW_GRAVITY_MPSS: f32 = 20.0; 36 | const PROJECTILE_INACCURACY: f64 = 0.0172275; 37 | pub const ARROW_BASE_DAMAGE: f32 = 2.0; 38 | const ARROW_TERMINAL_VELOCITY: f32 = 100.0; 39 | 40 | /// The owner of the arrow 41 | #[derive(Debug, Component)] 42 | pub struct ArrowOwner(pub Entity); 43 | 44 | /// The bow the arrow was shot from 45 | #[derive(Debug, Component)] 46 | pub struct BowUsed(pub ItemStack); 47 | 48 | #[derive(Component)] 49 | pub struct ArrowPower(pub f64); 50 | 51 | impl ArrowPower { 52 | pub fn is_critical(&self) -> bool { 53 | self.0 >= 3.0 54 | } 55 | 56 | pub fn damage(&self, velocity_mps: Vec3, power_level: u32) -> f32 { 57 | let base = ARROW_BASE_DAMAGE + power_extra_dmg(power_level); 58 | 59 | let velocity_tps_len = velocity_mps.length() / 20.0; // m/s to m/tick 60 | let mut damage = (velocity_tps_len * base).clamp(0.0, i32::MAX as f32).ceil() as i32; 61 | 62 | if self.is_critical() { 63 | let crit_extra_damage: i32 = rand::thread_rng().gen_range(0..damage / 2 + 1); 64 | damage += crit_extra_damage; 65 | } 66 | 67 | damage as f32 68 | } 69 | 70 | pub fn knockback_extra(&self, mut velocity_mps: Vec3, punch_level: u32) -> Vec3 { 71 | velocity_mps.y = 0.0; 72 | velocity_mps.normalize() * punch_level as f32 * 0.6 73 | } 74 | } 75 | 76 | #[derive(Component)] 77 | struct BowState { 78 | pub start_draw_tick: std::time::Instant, 79 | } 80 | 81 | impl BowState { 82 | pub fn new(start_draw: std::time::Instant) -> Self { 83 | Self { 84 | start_draw_tick: start_draw, 85 | } 86 | } 87 | } 88 | 89 | impl Plugin for BowPlugin { 90 | fn build(&self, app: &mut App) { 91 | app.add_systems( 92 | Update, 93 | ( 94 | on_bow_draw, 95 | on_bow_release, 96 | on_shoot, 97 | on_hit_block, 98 | on_arrow_fly, 99 | ), 100 | ) 101 | .add_event::(); 102 | } 103 | } 104 | 105 | fn on_bow_draw( 106 | mut commands: Commands, 107 | mut clients: Query<(&Inventory, &HeldItem, &mut LivingFlags)>, 108 | mut events: EventReader, 109 | ) { 110 | for event in events.read() { 111 | let Ok((inventory, held_item, mut flags)) = clients.get_mut(event.client) else { 112 | continue; 113 | }; 114 | flags.set_using_item(false); 115 | 116 | let stack = inventory.slot(held_item.slot()).clone(); 117 | if !inventory.check_contains_stack(&ItemStack::new(ItemKind::Arrow, 1, None), true) 118 | || stack.item != ItemKind::Bow 119 | { 120 | continue; 121 | } 122 | 123 | flags.set_using_item(true); 124 | 125 | let now = std::time::Instant::now(); 126 | commands.entity(event.client).insert(BowState::new(now)); 127 | } 128 | } 129 | 130 | #[derive(Debug, Event)] 131 | pub struct BowShootEvent { 132 | pub client: Entity, 133 | pub position: Position, 134 | pub look: Look, 135 | pub ms_drawn: u64, 136 | pub bow_used: ItemStack, 137 | } 138 | 139 | fn on_bow_release( 140 | clients: Query<(&BowState, &Position, &Look, &Inventory, &HeldItem)>, 141 | mut packet_events: EventReader, 142 | mut event_writer: EventWriter, 143 | ) { 144 | for packet in packet_events.read() { 145 | let Some(player_action) = packet.decode::() else { 146 | continue; 147 | }; 148 | 149 | let Ok((bow_state, position, look, inventory, held_item)) = clients.get(packet.client) 150 | else { 151 | continue; 152 | }; 153 | 154 | let selected = inventory.slot(held_item.slot()).clone(); 155 | if player_action.action != PlayerAction::ReleaseUseItem || selected.item != ItemKind::Bow { 156 | continue; 157 | } 158 | 159 | let ms_drawn = bow_state.start_draw_tick.elapsed().as_millis() as u64; 160 | let stack = inventory.slot(held_item.slot()).clone(); 161 | 162 | event_writer.send(BowShootEvent { 163 | client: packet.client, 164 | position: *position, 165 | look: *look, 166 | ms_drawn, 167 | bow_used: stack, 168 | }); 169 | } 170 | } 171 | 172 | fn on_shoot( 173 | mut shooter: Query<(&mut Inventory, &EntityLayerId, &CombatState)>, 174 | mut commands: Commands, 175 | mut shoot_events: EventReader, 176 | mut layer: Query<&mut ChunkLayer>, 177 | ) { 178 | for event in shoot_events.read() { 179 | if event.ms_drawn < CAN_SHOOT_AFTER_MS { 180 | continue; 181 | } 182 | 183 | let mut layer = layer.single_mut(); 184 | let sound_pitch = rand::thread_rng().gen_range(0.75..=1.125); 185 | 186 | layer.play_sound( 187 | Sound::EntityArrowShoot, 188 | SoundCategory::Neutral, 189 | event.position.0, 190 | sound_pitch, 191 | 1.0, 192 | ); 193 | 194 | let yaw = event.look.yaw.to_radians(); 195 | let pitch = event.look.pitch.to_radians(); 196 | 197 | let direction = Vec3::new( 198 | -yaw.sin() * pitch.cos(), 199 | -pitch.sin(), 200 | yaw.cos() * pitch.cos(), 201 | ); 202 | 203 | let Ok((mut shooter_inv, layer_id, combat_state)) = shooter.get_mut(event.client) else { 204 | continue; 205 | }; 206 | 207 | if !event 208 | .bow_used 209 | .enchantments() 210 | .contains_key(&Enchantment::Infinity) 211 | { 212 | shooter_inv.try_remove_all(&ItemStack::new(ItemKind::Arrow, 1, None)); 213 | } 214 | 215 | let arrow_power = get_bow_power_for_draw_ticks(event.ms_drawn / 50); 216 | let velocity = calculate_projectile_velocity(direction, arrow_power as f32, 1.0); 217 | 218 | let direction = velocity.normalize(); 219 | 220 | let mut position = event.position.0; 221 | 222 | position.y += if combat_state.is_sneaking { 223 | SNEAK_EYE_HEIGHT 224 | } else { 225 | EYE_HEIGHT 226 | } as f64 227 | - 0.1; 228 | position += direction.as_dvec3() * 0.1; 229 | 230 | // Separate hitbox for arrow-block and arrow-entity collision 231 | let block_hitbox = Aabb::new( 232 | position - DVec3::new(0.001, 0.001, 0.001), 233 | position + DVec3::new(0.001, 0.001, 0.001), 234 | ); 235 | 236 | commands 237 | .spawn(ArrowEntityBundle { 238 | position: Position(position), 239 | velocity: Velocity(velocity), 240 | layer: *layer_id, 241 | entity_no_gravity: NoGravity(true), 242 | 243 | ..Default::default() 244 | }) 245 | .insert(PhysicsMarker) 246 | .insert(GetsStuckOnCollision::all()) 247 | .insert(CollidesWithBlocks(Some(block_hitbox))) 248 | .insert(CollidesWithEntities(None)) 249 | .insert(DespawnTimer::from_secs(50.0)) 250 | .insert(ArrowOwner(event.client)) 251 | .insert(BowUsed(event.bow_used.clone())) 252 | .insert(Drag(0.99 / 20.0)) 253 | .insert(ArrowPower(arrow_power)) 254 | .insert(TerminalVelocity(ARROW_TERMINAL_VELOCITY)) 255 | .insert(Gravity(ARROW_GRAVITY_MPSS)); 256 | } 257 | } 258 | 259 | #[allow(clippy::type_complexity)] 260 | fn on_arrow_fly( 261 | query: Query<(&Position, &OnGround, &ArrowPower), (With, With)>, 262 | mut layer: Query<&mut ChunkLayer>, 263 | ) { 264 | for (position, on_ground, power) in query.iter() { 265 | if on_ground.0 { 266 | continue; 267 | } 268 | 269 | let mut layer = layer.single_mut(); 270 | 271 | if power.is_critical() { 272 | layer.play_particle(&Particle::Crit, true, position.0, Vec3::ZERO, 0.1, 1); 273 | } 274 | } 275 | } 276 | 277 | fn on_hit_block( 278 | mut arrows: Query<&mut OnGround, (With, With)>, 279 | mut events: EventReader, 280 | mut layer: Query<&mut ChunkLayer>, 281 | ) { 282 | for event in events.read() { 283 | let Ok(mut on_ground) = arrows.get_mut(event.entity) else { 284 | continue; 285 | }; 286 | 287 | if **on_ground { 288 | continue; 289 | } 290 | 291 | on_ground.0 = true; 292 | 293 | let mut layer = layer.single_mut(); 294 | 295 | layer.play_sound( 296 | Sound::EntityArrowHit, 297 | SoundCategory::Neutral, 298 | event.collision_pos, 299 | 1.0, 300 | rand::thread_rng().gen_range(1.0909..1.3333), 301 | ); 302 | } 303 | } 304 | 305 | pub fn calculate_projectile_velocity(direction: Vec3, arrow_power: f32, inaccuracy: f64) -> Vec3 { 306 | let direction = direction.normalize(); 307 | 308 | // We multiply by 20, because our velocity is m/s, 309 | // minecraft uses m/tick 310 | 311 | (direction 312 | + Vec3 { 313 | x: random_triangle(0.0, inaccuracy * PROJECTILE_INACCURACY) as f32, 314 | y: random_triangle(0.0, inaccuracy * PROJECTILE_INACCURACY) as f32, 315 | z: random_triangle(0.0, inaccuracy * PROJECTILE_INACCURACY) as f32, 316 | }) 317 | * arrow_power 318 | * 20.0 319 | } 320 | 321 | fn random_triangle(a: f64, b: f64) -> f64 { 322 | a + b * (rand::thread_rng().gen::() - rand::thread_rng().gen::()) 323 | } 324 | 325 | fn get_bow_power_for_draw_ticks(ticks: u64) -> f64 { 326 | let power = ticks as f64 / 20.0; 327 | let power = (power * power + power * 2.0) / 3.0; 328 | 329 | power.clamp(0.1, 1.0) * 3.0 330 | } 331 | -------------------------------------------------------------------------------- /src/base/break_blocks.rs: -------------------------------------------------------------------------------- 1 | use action::{DiggingEvent, DiggingState}; 2 | use app::{App, Plugin, Update}; 3 | use bevy_state::prelude::in_state; 4 | use client::Username; 5 | use entity::{ 6 | entity::NoGravity, 7 | item::{ItemEntityBundle, Stack}, 8 | EntityLayerId, Position, Velocity, 9 | }; 10 | use math::{DVec3, Vec3}; 11 | use prelude::{Commands, Entity, Event, EventReader, EventWriter, IntoSystemConfigs, Query, Res}; 12 | use rand::Rng; 13 | use valence::*; 14 | 15 | use crate::{bedwars_config::WorldConfig, utils::despawn_timer::DespawnTimer, GameState, Team}; 16 | 17 | use super::{ 18 | build::PlayerPlacedBlocks, 19 | item_pickup::PickupMarker, 20 | physics::{CollidesWithBlocks, GetsStuckOnCollision, Gravity, PhysicsMarker}, 21 | }; 22 | /// Strength of random velocity applied to the dropped item after breaking a block 23 | const BLOCK_BREAK_DROP_STRENGTH: f32 = 0.05 * 20.0; 24 | 25 | pub struct BlockBreakPlugin; 26 | 27 | #[derive(Debug, Event, PartialEq)] 28 | pub struct BedDestroyedEvent { 29 | pub attacker: Entity, 30 | pub team: Team, 31 | } 32 | 33 | impl Plugin for BlockBreakPlugin { 34 | fn build(&self, app: &mut App) { 35 | app.add_systems(Update, (break_blocks,).run_if(in_state(GameState::Match))) 36 | .add_event::(); 37 | } 38 | } 39 | 40 | #[allow(clippy::too_many_arguments)] 41 | fn break_blocks( 42 | mut commands: Commands, 43 | clients: Query<(&Username, &Team)>, 44 | mut events: EventReader, 45 | mut layer: Query<(Entity, &mut ChunkLayer)>, 46 | player_placed_blocks: Res, 47 | bedwars_config: Res, 48 | // match_state: ResMut, 49 | mut event_writer: EventWriter, 50 | ) { 51 | for event in events.read() { 52 | let (layer, mut layer_mut) = layer.single_mut(); 53 | if event.state != DiggingState::Stop { 54 | continue; 55 | } 56 | 57 | let block_pos = event.position; 58 | 59 | let Ok((_player_name, player_team)) = clients.get(event.client) else { 60 | continue; 61 | }; 62 | 63 | let mut broke_bed = false; 64 | 65 | for (team_name, bed_block_set) in &bedwars_config.beds { 66 | if *team_name == player_team.name { 67 | continue; 68 | } 69 | 70 | let block_pos_vec = crate::bedwars_config::ConfigVec3 { 71 | x: block_pos.x, 72 | y: block_pos.y, 73 | z: block_pos.z, 74 | }; 75 | 76 | if bed_block_set.iter().any(|(pos, _)| pos == &block_pos_vec) { 77 | // set bed to broken 78 | for (pos, _block) in bed_block_set { 79 | layer_mut.set_block(BlockPos::new(pos.x, pos.y, pos.z), BlockState::AIR); 80 | } 81 | 82 | let (victim_team, victim_color) = 83 | bedwars_config.teams.get_key_value(team_name).unwrap(); 84 | 85 | event_writer.send(BedDestroyedEvent { 86 | attacker: event.client, 87 | team: Team { 88 | name: victim_team.clone(), 89 | color: *victim_color, 90 | }, 91 | }); 92 | 93 | broke_bed = true; 94 | } 95 | } 96 | 97 | if let Some(block_state) = player_placed_blocks.0.get(&block_pos) { 98 | if broke_bed { 99 | continue; 100 | } 101 | 102 | let item_stack = ItemStack { 103 | item: block_state.to_kind().to_item_kind(), 104 | count: 1, 105 | nbt: None, 106 | }; 107 | 108 | let mut rng = rand::thread_rng(); 109 | 110 | let position = DVec3 { 111 | x: block_pos.x as f64 + 0.5 + rng.gen_range(-0.1..0.1), 112 | y: block_pos.y as f64 + 0.5 + rng.gen_range(-0.1..0.1), 113 | z: block_pos.z as f64 + 0.5 + rng.gen_range(-0.1..0.1), 114 | }; 115 | 116 | let item_velocity = Vec3 { 117 | x: rng.gen_range(-1.0..1.0), 118 | y: rng.gen_range(-1.0..1.0), 119 | z: rng.gen_range(-1.0..1.0), 120 | } * BLOCK_BREAK_DROP_STRENGTH; 121 | 122 | commands 123 | .spawn(ItemEntityBundle { 124 | item_stack: Stack(item_stack), 125 | position: Position(position), 126 | velocity: Velocity(item_velocity), 127 | 128 | layer: EntityLayerId(layer), 129 | entity_no_gravity: NoGravity(true), 130 | ..Default::default() 131 | }) 132 | .insert(PickupMarker::default()) 133 | .insert(Gravity::items()) 134 | .insert(PhysicsMarker) 135 | .insert(CollidesWithBlocks(None)) 136 | .insert(GetsStuckOnCollision::ground()) 137 | .insert(DespawnTimer::items()); 138 | 139 | layer_mut.set_block(block_pos, BlockState::AIR); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/base/build.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use bevy_state::prelude::in_state; 4 | use valence::{ 5 | entity::living::LivingEntity, interact_block::InteractBlockEvent, inventory::HeldItem, 6 | math::Aabb, prelude::*, 7 | }; 8 | 9 | use crate::GameState; 10 | 11 | pub struct BuildPlugin; 12 | 13 | #[derive(Debug, Default, Resource)] 14 | pub struct PlayerPlacedBlocks(pub HashMap); 15 | 16 | impl Plugin for BuildPlugin { 17 | fn build(&self, app: &mut App) { 18 | app.add_systems( 19 | FixedPreUpdate, 20 | (place_blocks,).run_if(in_state(GameState::Match)), 21 | ) 22 | .insert_resource(PlayerPlacedBlocks::default()); 23 | } 24 | } 25 | 26 | fn place_blocks( 27 | mut clients: Query<(&mut Inventory, &HeldItem)>, 28 | entities: Query<&Hitbox, With>, 29 | mut layers: Query<&mut ChunkLayer>, 30 | mut events: EventReader, 31 | // bedwars_config: Res, 32 | mut player_placed_blocks: ResMut, 33 | ) { 34 | for event in events.read() { 35 | let Ok((mut inventory, held)) = clients.get_mut(event.client) else { 36 | continue; 37 | }; 38 | 39 | if event.hand != Hand::Main { 40 | continue; 41 | } 42 | 43 | let mut layer = layers.single_mut(); 44 | // get the held item 45 | let slot_id = held.slot(); 46 | let stack = inventory.slot(slot_id); 47 | if stack.is_empty() { 48 | continue; 49 | }; 50 | 51 | let Some(block_kind) = BlockKind::from_item_kind(stack.item) else { 52 | continue; 53 | }; 54 | 55 | let block_state = BlockState::from_kind(block_kind); 56 | // TODO: check for bedwars arena bounds 57 | let block_hitboxes = block_state.collision_shapes(); 58 | let real_pos = event.position.get_in_direction(event.face); 59 | 60 | if let Some(block) = layer.block(real_pos) { 61 | if !block.state.is_air() { 62 | return; 63 | } 64 | } 65 | 66 | for mut block_hitbox in block_hitboxes { 67 | let tolerance = DVec3::new(0.0, 0.01, 0.0); 68 | 69 | block_hitbox = Aabb::new( 70 | block_hitbox.min() 71 | + DVec3::new(real_pos.x as f64, real_pos.y as f64, real_pos.z as f64) 72 | + tolerance, 73 | block_hitbox.max() 74 | + DVec3::new(real_pos.x as f64, real_pos.y as f64, real_pos.z as f64) 75 | - tolerance, 76 | ); 77 | 78 | // TODO: also very inefficient 79 | for entity_hitbox in entities.iter() { 80 | if block_hitbox.intersects(**entity_hitbox) { 81 | return; 82 | } 83 | } 84 | } 85 | 86 | if stack.count > 1 { 87 | let amount = stack.count - 1; 88 | inventory.set_slot_amount(slot_id, amount); 89 | } else { 90 | inventory.set_slot(slot_id, ItemStack::EMPTY); 91 | } 92 | 93 | let state = block_kind.to_state().set( 94 | PropName::Axis, 95 | match event.face { 96 | Direction::Down | Direction::Up => PropValue::Y, 97 | Direction::North | Direction::South => PropValue::Z, 98 | Direction::West | Direction::East => PropValue::X, 99 | }, 100 | ); 101 | 102 | player_placed_blocks.0.insert(real_pos, state); 103 | layer.set_block(real_pos, state); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/base/chat.rs: -------------------------------------------------------------------------------- 1 | use bevy_ecs::query::QueryData; 2 | use bevy_state::prelude::in_state; 3 | use valence::prelude::*; 4 | 5 | use crate::{ 6 | r#match::{EndMatch, POST_MATCH_TIME_SECS}, 7 | GameState, Team, 8 | }; 9 | 10 | use super::{ 11 | break_blocks::BedDestroyedEvent, 12 | death::{PlayerDeathEvent, PlayerEliminatedEvent}, 13 | }; 14 | 15 | pub struct ChatPlugin; 16 | 17 | impl Plugin for ChatPlugin { 18 | fn build(&self, app: &mut App) { 19 | app.add_systems( 20 | Update, 21 | ( 22 | (handle_death_message, handle_bed_destroyed).run_if(in_state(GameState::Match)), 23 | handle_match_end, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | #[derive(QueryData)] 30 | #[query_data(mutable)] 31 | struct ChatQuery { 32 | client: &'static mut Client, 33 | username: &'static Username, 34 | team: &'static Team, 35 | } 36 | 37 | fn handle_death_message( 38 | mut chat_query: Query, 39 | mut death_events: EventReader, 40 | mut eliminated_events: EventReader, 41 | ) { 42 | for event in death_events.read() { 43 | let Ok(victim) = chat_query.get(event.victim) else { 44 | continue; 45 | }; 46 | 47 | let msg = if let Some(attacker) = event.attacker { 48 | let Ok(attacker) = chat_query.get(attacker) else { 49 | continue; 50 | }; 51 | 52 | format!( 53 | "{}§n{}§r §awas killed by {}{}", 54 | victim.team.color.text_color(), 55 | victim.username, 56 | attacker.team.color.text_color(), 57 | attacker.username 58 | ) 59 | } else { 60 | format!( 61 | "{}§n{}§r §adied", 62 | victim.team.color.text_color(), 63 | victim.username 64 | ) 65 | }; 66 | 67 | for mut client in &mut chat_query { 68 | client.client.send_chat_message(&msg); 69 | } 70 | } 71 | 72 | for event in eliminated_events.read() { 73 | let Ok(victim) = chat_query.get(event.victim) else { 74 | continue; 75 | }; 76 | 77 | let msg = if let Some(attacker) = event.attacker { 78 | let Ok(attacker) = chat_query.get(attacker) else { 79 | continue; 80 | }; 81 | 82 | format!( 83 | "{}§n{}§r §awas eliminated by {}{}", 84 | victim.team.color.text_color(), 85 | victim.username, 86 | attacker.team.color.text_color(), 87 | attacker.username 88 | ) 89 | } else { 90 | format!( 91 | "{}§n{}§r §aeliminated", 92 | victim.team.color.text_color(), 93 | victim.username 94 | ) 95 | }; 96 | 97 | for mut client in &mut chat_query { 98 | client.client.send_chat_message(&msg); 99 | } 100 | } 101 | } 102 | 103 | fn handle_bed_destroyed(mut clients: Query, mut events: EventReader) { 104 | for event in events.read() { 105 | let team = &event.team; 106 | let msg = format!( 107 | "§aBed of team {}§n{}§r §awas destroyed!", 108 | team.color.text_color(), 109 | team.name 110 | ); 111 | 112 | for mut client in &mut clients { 113 | client.client.send_chat_message(&msg); 114 | } 115 | } 116 | } 117 | 118 | fn handle_match_end(mut clients: Query, mut events: EventReader) { 119 | for event in events.read() { 120 | let team = &event.winner; 121 | 122 | let msg = format!( 123 | "§bTeam {}§n{}§r §bwon the match!", 124 | team.color.text_color(), 125 | team.name 126 | ); 127 | 128 | let return_to_lobby_msg = format!( 129 | "§eReturning to lobby in {} seconds...", 130 | POST_MATCH_TIME_SECS.round() as i32 131 | ); 132 | 133 | for mut client in &mut clients { 134 | tracing::debug!("Sending chat message to {}", client.username.0); 135 | client.client.send_chat_message(&msg); 136 | client.client.send_chat_message(&return_to_lobby_msg); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/base/chests.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use rand::Rng; 4 | use valence::{ 5 | interact_block::InteractBlockEvent, 6 | layer::chunk::IntoBlock, 7 | prelude::*, 8 | protocol::{packets::play::BlockEventS2c, sound::SoundCategory, Sound, WritePacket}, 9 | Layer, 10 | }; 11 | 12 | pub struct ChestPlugin; 13 | 14 | impl Plugin for ChestPlugin { 15 | fn build(&self, app: &mut App) { 16 | app.add_systems(Update, on_chest_open) 17 | .insert_resource(ChestState::default()) 18 | .observe(chest_close); 19 | } 20 | } 21 | 22 | #[derive(Resource, Default)] 23 | pub struct ChestState { 24 | /// Position of chest -> (Inventory, number of players looking into the chest) 25 | pub chests: HashMap, 26 | pub enderchests: HashMap, 27 | } 28 | 29 | #[derive(Debug, Component)] 30 | struct PlayerChestState { 31 | pub open_chest: BlockPos, 32 | pub is_ender_chest: bool, 33 | } 34 | 35 | // right-click chest 36 | fn on_chest_open( 37 | mut commands: Commands, 38 | mut events: EventReader, 39 | mut players: Query<(Entity, &Username)>, 40 | mut layer: Query<&mut ChunkLayer>, 41 | mut chest_state: ResMut, 42 | ) { 43 | for event in events.read() { 44 | let mut layer = layer.single_mut(); 45 | let Some(block) = layer.block(event.position) else { 46 | continue; 47 | }; 48 | 49 | let block = block.into_block(); 50 | 51 | let Ok((player, player_name)) = players.get_mut(event.client) else { 52 | continue; 53 | }; 54 | 55 | let default_inv = Inventory::new(InventoryKind::Generic9x3); 56 | 57 | let ((inventory, mut players_looking_into_chest), sound) = match block.state.to_kind() { 58 | BlockKind::Chest => ( 59 | chest_state 60 | .chests 61 | .get(&event.position) 62 | .unwrap_or(&(default_inv, 0)) 63 | .clone(), 64 | Sound::BlockChestOpen, 65 | ), 66 | BlockKind::EnderChest => ( 67 | chest_state 68 | .enderchests 69 | .get(&player_name.0) 70 | .unwrap_or(&(default_inv, 0)) 71 | .clone(), 72 | Sound::BlockEnderChestOpen, 73 | ), 74 | _ => continue, 75 | }; 76 | 77 | players_looking_into_chest += 1; 78 | 79 | let inv_id = commands.spawn(inventory.clone()).id(); 80 | commands.entity(player).insert(OpenInventory::new(inv_id)); 81 | 82 | commands.entity(player).insert(PlayerChestState { 83 | open_chest: event.position, 84 | is_ender_chest: block.state.to_kind() == BlockKind::EnderChest, 85 | }); 86 | 87 | if block.state.to_kind() == BlockKind::EnderChest { 88 | chest_state.enderchests.insert( 89 | player_name.0.clone(), 90 | (inventory.clone(), players_looking_into_chest), 91 | ); 92 | } else { 93 | chest_state.chests.insert( 94 | event.position, 95 | (inventory.clone(), players_looking_into_chest), 96 | ); 97 | } 98 | 99 | layer 100 | .view_writer(event.position) 101 | .write_packet(&BlockEventS2c { 102 | position: event.position, 103 | action_id: 1, 104 | action_parameter: players_looking_into_chest as u8, 105 | block_type: block.state.to_kind(), 106 | }); 107 | 108 | layer.play_sound( 109 | sound, 110 | SoundCategory::Block, 111 | DVec3::new( 112 | event.position.x as f64, 113 | event.position.y as f64, 114 | event.position.z as f64, 115 | ), 116 | 0.5, 117 | rand::thread_rng().gen_range(0.9..=1.), 118 | ); 119 | } 120 | } 121 | 122 | fn chest_close( 123 | trigger: Trigger, 124 | mut commands: Commands, 125 | players: Query<(Entity, &PlayerChestState, &OpenInventory, &Username)>, 126 | inventories: Query<&Inventory, Without>, 127 | mut chest_state: ResMut, 128 | mut layer: Query<&mut ChunkLayer>, 129 | ) { 130 | let Ok((player_ent, player_chest_state, open_inventory, player_name)) = 131 | players.get(trigger.entity()) 132 | else { 133 | return; 134 | }; 135 | 136 | let Ok(chest_inventory) = inventories.get(open_inventory.entity) else { 137 | return; 138 | }; 139 | 140 | let mut layer = layer.single_mut(); 141 | 142 | let (res, block_kind, close_sound) = if player_chest_state.is_ender_chest { 143 | ( 144 | chest_state.enderchests.get_mut(&player_name.0), 145 | BlockKind::EnderChest, 146 | Sound::BlockEnderChestClose, 147 | ) 148 | } else { 149 | ( 150 | chest_state.chests.get_mut(&player_chest_state.open_chest), 151 | BlockKind::Chest, 152 | Sound::BlockChestClose, 153 | ) 154 | }; 155 | 156 | let Some((inv, players_looking_into_chest)) = res else { 157 | return; 158 | }; 159 | 160 | *inv = chest_inventory.clone(); 161 | *players_looking_into_chest -= 1; 162 | 163 | layer 164 | .view_writer(player_chest_state.open_chest) 165 | .write_packet(&BlockEventS2c { 166 | position: player_chest_state.open_chest, 167 | action_id: 1, 168 | action_parameter: *players_looking_into_chest as u8, 169 | block_type: block_kind, 170 | }); 171 | 172 | layer.play_sound( 173 | close_sound, 174 | SoundCategory::Block, 175 | DVec3::new( 176 | player_chest_state.open_chest.x as f64, 177 | player_chest_state.open_chest.y as f64, 178 | player_chest_state.open_chest.z as f64, 179 | ), 180 | 0.5, 181 | rand::thread_rng().gen_range(0.9..=1.), 182 | ); 183 | 184 | commands.entity(player_ent).remove::(); 185 | } 186 | -------------------------------------------------------------------------------- /src/base/combat.rs: -------------------------------------------------------------------------------- 1 | use bevy_ecs::query::QueryData; 2 | use bevy_state::prelude::in_state; 3 | use bevy_time::{Time, Timer, TimerMode}; 4 | use rand::Rng; 5 | use valence::{ 6 | entity::{entity::Flags, living::StuckArrowCount, EntityId, EntityStatuses, Velocity}, 7 | inventory::HeldItem, 8 | prelude::*, 9 | protocol::{sound::SoundCategory, Sound}, 10 | }; 11 | 12 | use crate::{ 13 | base::enchantments::{Enchantment, ItemStackExtEnchantments}, 14 | utils::item_stack::ItemStackExtWeapons, 15 | GameState, Team, 16 | }; 17 | 18 | use super::{ 19 | armor::EquipmentExtReduction, 20 | bow::{ArrowOwner, ArrowPower, BowUsed}, 21 | death::{IsDead, PlayerHurtEvent}, 22 | fall_damage::FallingState, 23 | physics::EntityEntityCollisionEvent, 24 | }; 25 | 26 | pub const EYE_HEIGHT: f32 = 1.62; 27 | pub const SNEAK_EYE_HEIGHT: f32 = 1.54; 28 | 29 | // const ATTACK_COOLDOWN_TICKS: i64 = 10; 30 | pub const ATTACK_COOLDOWN_MILLIS: u64 = 500; 31 | const CRIT_MULTIPLIER: f32 = 1.5; 32 | 33 | const FRIENLDY_FIRE: bool = false; 34 | 35 | const DEFAULT_KNOCKBACK: f32 = 0.4; 36 | 37 | const BURN_DAMAGE_PER_SECOND: f32 = 1.0; 38 | 39 | #[derive(Component)] 40 | pub struct Burning { 41 | pub timer: Timer, // second timer 42 | pub repeats_left: u32, 43 | pub attacker: Option, 44 | } 45 | 46 | impl Burning { 47 | pub fn new(seconds: f32, attacker: Option) -> Self { 48 | Self { 49 | timer: Timer::from_seconds(1.0, TimerMode::Repeating), 50 | repeats_left: (seconds as u32).saturating_sub(1), 51 | attacker, 52 | } 53 | } 54 | } 55 | 56 | /// Attached to every client. 57 | #[derive(Component)] 58 | pub struct CombatState { 59 | pub last_attack: std::time::Instant, 60 | pub is_sprinting: bool, 61 | pub is_sneaking: bool, 62 | /// The last time the player was hit by an entity. 63 | pub last_hit: std::time::Instant, 64 | } 65 | 66 | impl Default for CombatState { 67 | fn default() -> Self { 68 | Self { 69 | last_attack: std::time::Instant::now(), 70 | is_sprinting: false, 71 | is_sneaking: false, 72 | last_hit: std::time::Instant::now(), 73 | } 74 | } 75 | } 76 | 77 | pub struct CombatPlugin; 78 | 79 | impl Plugin for CombatPlugin { 80 | fn build(&self, app: &mut App) { 81 | app.add_systems( 82 | Update, 83 | (combat_system, arrow_hits, apply_fire_damage, on_start_burn) 84 | .run_if(in_state(GameState::Match)), 85 | ) 86 | .observe(on_remove_burn); 87 | } 88 | } 89 | 90 | #[derive(QueryData)] 91 | #[query_data(mutable)] 92 | pub struct CombatQuery { 93 | pub client: &'static mut Client, 94 | pub entity_id: &'static EntityId, 95 | pub position: &'static Position, 96 | pub velocity: &'static mut Velocity, 97 | pub state: &'static mut CombatState, 98 | pub statuses: &'static mut EntityStatuses, 99 | pub inventory: &'static Inventory, 100 | pub held_item: &'static HeldItem, 101 | pub falling_state: &'static FallingState, 102 | pub equipment: &'static Equipment, 103 | pub team: &'static Team, 104 | pub entity: Entity, 105 | pub stuck_arrow_count: &'static mut StuckArrowCount, 106 | } 107 | 108 | fn xy_knockback(damage_pos: DVec3, victim_pos: DVec3) -> (f32, f32) { 109 | let mut x = (damage_pos.x - victim_pos.x) as f32; 110 | let mut z = (damage_pos.z - victim_pos.z) as f32; 111 | 112 | while x * x + z * z < 1.0e-4 { 113 | x = (rand::random::() - rand::random::()) * 0.01; 114 | z = (rand::random::() - rand::random::()) * 0.01; 115 | } 116 | 117 | (x, z) 118 | } 119 | 120 | fn receive_knockback( 121 | victim: &mut CombatQueryItem<'_>, 122 | mut strength: f32, 123 | x: f32, 124 | z: f32, 125 | extra_knockback: Vec3, 126 | ) { 127 | strength *= 1.0 - victim.equipment.knockback_resistance(); 128 | if strength <= 0.0 { 129 | return; 130 | } 131 | 132 | let movement = victim.velocity.0; 133 | let knockback = Vec3::new(x, 0.0, z).normalize() * strength; 134 | let y_knockback = if !victim.falling_state.falling { 135 | 0.4_f32.min(movement.y / 2.0 + strength) 136 | } else { 137 | movement.y 138 | }; 139 | let knockback = Vec3::new( 140 | movement.x / 2.0 - knockback.x, 141 | y_knockback, 142 | movement.z / 2.0 - knockback.z, 143 | ); 144 | 145 | victim 146 | .client 147 | .set_velocity((knockback * 20.0) + extra_knockback); // ticks per sec to mps 148 | } 149 | 150 | fn combat_system( 151 | mut commands: Commands, 152 | mut clients: Query>, 153 | mut sprinting: EventReader, 154 | mut sneaking: EventReader, 155 | mut interact_entity_events: EventReader, 156 | mut event_writer: EventWriter, 157 | ) { 158 | for &SprintEvent { client, state } in sprinting.read() { 159 | if let Ok(mut client) = clients.get_mut(client) { 160 | client.state.is_sprinting = state == SprintState::Start; 161 | } 162 | } 163 | 164 | for &SneakEvent { client, state } in sneaking.read() { 165 | if let Ok(mut client) = clients.get_mut(client) { 166 | client.state.is_sneaking = state == SneakState::Start; 167 | } 168 | } 169 | 170 | for &InteractEntityEvent { 171 | client: attacker_ent, 172 | entity: victim_ent, 173 | interact, 174 | .. 175 | } in interact_entity_events.read() 176 | { 177 | if !matches!(interact, EntityInteraction::Attack) { 178 | continue; 179 | } 180 | let Ok([mut attacker, mut victim]) = clients.get_many_mut([attacker_ent, victim_ent]) 181 | else { 182 | continue; 183 | }; 184 | 185 | if attacker.team == victim.team && !FRIENLDY_FIRE { 186 | continue; 187 | } 188 | 189 | if (attacker.state.last_attack.elapsed().as_millis() as u64) < ATTACK_COOLDOWN_MILLIS { 190 | continue; 191 | } 192 | 193 | attacker.state.last_attack = std::time::Instant::now(); 194 | victim.state.last_hit = std::time::Instant::now(); 195 | 196 | let dir = (victim.position.0 - attacker.position.0) 197 | .normalize() 198 | .as_vec3(); 199 | 200 | let attack_weapon = attacker.inventory.slot(attacker.held_item.slot()); 201 | 202 | let burn_time = attack_weapon.burn_time(); 203 | 204 | if burn_time > 0.0 { 205 | commands 206 | .entity(victim.entity) 207 | .insert(Burning::new(burn_time, Some(attacker.entity))); 208 | } 209 | 210 | let extra_knockback = attack_weapon.knockback_extra() * dir; 211 | let (x, z) = xy_knockback(attacker.position.0, victim.position.0); 212 | receive_knockback(&mut victim, DEFAULT_KNOCKBACK, x, z, extra_knockback); 213 | 214 | victim.client.trigger_status(EntityStatus::PlayAttackSound); 215 | victim.statuses.trigger(EntityStatus::PlayAttackSound); 216 | 217 | let weapon_damage = attack_weapon.damage(); 218 | let damage = weapon_damage 219 | * if attacker.falling_state.falling { 220 | CRIT_MULTIPLIER 221 | } else { 222 | 1.0 223 | }; 224 | 225 | let damage_after_armor = victim.equipment.received_damage(damage); 226 | 227 | event_writer.send(PlayerHurtEvent { 228 | attacker: Some(attacker.entity), 229 | victim: victim.entity, 230 | damage: damage_after_armor, 231 | position: victim.position.0, 232 | }); 233 | } 234 | } 235 | 236 | fn arrow_hits( 237 | mut commands: Commands, 238 | mut events: EventReader, 239 | arrows: Query< 240 | (&Velocity, &ArrowPower, &ArrowOwner, &BowUsed, &OldPosition), 241 | Without, 242 | >, 243 | mut clients: Query, 244 | mut event_writer: EventWriter, 245 | mut layer: Query<&mut ChunkLayer>, 246 | ) { 247 | for event in events.read() { 248 | let Ok((arrow_velocity, arrow_power, arrow_owner, bow_used, old_pos)) = 249 | arrows.get(event.entity1) 250 | else { 251 | continue; 252 | }; 253 | 254 | let Ok(mut attacker) = clients.get_mut(arrow_owner.0) else { 255 | continue; 256 | }; 257 | 258 | attacker.client.play_sound( 259 | Sound::EntityArrowHitPlayer, 260 | SoundCategory::Player, 261 | attacker.position.0, 262 | 1.0, 263 | 1.0, 264 | ); 265 | 266 | let Ok(mut victim) = clients.get_mut(event.entity2) else { 267 | continue; 268 | }; 269 | 270 | let mut layer = layer.single_mut(); 271 | 272 | layer.play_sound( 273 | Sound::EntityArrowHit, 274 | SoundCategory::Neutral, 275 | victim.position.0, 276 | 1.0, 277 | rand::thread_rng().gen_range(1.0909..1.3333), 278 | ); 279 | 280 | let power_level = bow_used 281 | .0 282 | .enchantments() 283 | .get(&Enchantment::Power) 284 | .copied() 285 | .unwrap_or(0); 286 | 287 | let punch_level = bow_used 288 | .0 289 | .enchantments() 290 | .get(&Enchantment::Punch) 291 | .copied() 292 | .unwrap_or(0); 293 | 294 | let burn_time = bow_used.0.burn_time(); 295 | 296 | if burn_time > 0.0 { 297 | commands 298 | .entity(victim.entity) 299 | .insert(Burning::new(burn_time, Some(arrow_owner.0))); 300 | } 301 | 302 | let damage = arrow_power.damage(**arrow_velocity, power_level); 303 | let damage_after_armor = victim.equipment.received_damage(damage); 304 | 305 | let extra_knockback = arrow_power.knockback_extra(**arrow_velocity, punch_level); 306 | let (x, z) = xy_knockback(old_pos.get(), victim.position.0); 307 | receive_knockback(&mut victim, DEFAULT_KNOCKBACK, x, z, extra_knockback); 308 | 309 | event_writer.send(PlayerHurtEvent { 310 | attacker: Some(arrow_owner.0), 311 | victim: victim.entity, 312 | damage: damage_after_armor, 313 | position: victim.position.0, 314 | }); 315 | 316 | commands.entity(event.entity1).insert(Despawned); 317 | } 318 | } 319 | 320 | fn apply_fire_damage( 321 | mut commands: Commands, 322 | mut burning: Query<(Entity, &mut Flags, &Position, &mut Burning)>, 323 | mut event_writer: EventWriter, 324 | time: Res