aboutsummaryrefslogtreecommitdiff
path: root/azalea-client/src/plugins
diff options
context:
space:
mode:
authormat <27899617+mat-1@users.noreply.github.com>2025-12-09 13:29:59 -0600
committerGitHub <noreply@github.com>2025-12-09 13:29:59 -0600
commit26d619c9a329087a23d6577ee74bd764f50cd773 (patch)
tree8020fe902257764a23a445c6ed9987ea4848189d /azalea-client/src/plugins
parent84cd261118c9d1e3145d4d1751c0d22098cd8cd8 (diff)
downloadazalea-drasl-26d619c9a329087a23d6577ee74bd764f50cd773.tar.xz
Enchantments (#286)
* start implementing enchants * store parsed registries * more work on enchants * implement deserializer for some entity effects * mostly working definitions for enchants * fix tests * detect equipment changes * fix errors * update changelog * fix some imports * remove outdated todo * add basic test for enchants applying attributes * use git simdnbt
Diffstat (limited to 'azalea-client/src/plugins')
-rw-r--r--azalea-client/src/plugins/interact/mod.rs89
-rw-r--r--azalea-client/src/plugins/inventory.rs1022
-rw-r--r--azalea-client/src/plugins/inventory/equipment_effects.rs191
-rw-r--r--azalea-client/src/plugins/inventory/mod.rs349
-rw-r--r--azalea-client/src/plugins/mining.rs41
-rw-r--r--azalea-client/src/plugins/packet/game/mod.rs96
6 files changed, 662 insertions, 1126 deletions
diff --git a/azalea-client/src/plugins/interact/mod.rs b/azalea-client/src/plugins/interact/mod.rs
index 4022f931..47ada08a 100644
--- a/azalea-client/src/plugins/interact/mod.rs
+++ b/azalea-client/src/plugins/interact/mod.rs
@@ -17,6 +17,7 @@ use azalea_entity::{
},
clamp_look_direction,
indexing::EntityIdIndex,
+ inventory::Inventory,
};
use azalea_inventory::{ItemStack, ItemStackData, components};
use azalea_physics::{
@@ -29,6 +30,7 @@ use azalea_protocol::packets::game::{
s_swing::ServerboundSwing,
s_use_item_on::ServerboundUseItemOn,
};
+use azalea_registry::Item;
use azalea_world::Instance;
use bevy_app::{App, Plugin, Update};
use bevy_ecs::prelude::*;
@@ -39,7 +41,7 @@ use crate::{
Client,
attack::handle_attack_event,
interact::pick::{HitResultComponent, update_hit_result_component},
- inventory::{Inventory, InventorySystems},
+ inventory::InventorySystems,
local_player::{LocalGameMode, PermissionLevel},
movement::MoveEventsSystems,
packet::game::SendGamePacketEvent,
@@ -516,47 +518,7 @@ fn update_attributes_for_held_item(
for (mut attributes, inventory) in &mut query {
let held_item = inventory.held_item();
- use azalea_registry::Item;
- let added_attack_speed = match held_item.kind() {
- Item::WoodenSword => -2.4,
- Item::WoodenShovel => -3.0,
- Item::WoodenPickaxe => -2.8,
- Item::WoodenAxe => -3.2,
- Item::WoodenHoe => -3.0,
-
- Item::StoneSword => -2.4,
- Item::StoneShovel => -3.0,
- Item::StonePickaxe => -2.8,
- Item::StoneAxe => -3.2,
- Item::StoneHoe => -2.0,
-
- Item::GoldenSword => -2.4,
- Item::GoldenShovel => -3.0,
- Item::GoldenPickaxe => -2.8,
- Item::GoldenAxe => -3.0,
- Item::GoldenHoe => -3.0,
-
- Item::IronSword => -2.4,
- Item::IronShovel => -3.0,
- Item::IronPickaxe => -2.8,
- Item::IronAxe => -3.1,
- Item::IronHoe => -1.0,
-
- Item::DiamondSword => -2.4,
- Item::DiamondShovel => -3.0,
- Item::DiamondPickaxe => -2.8,
- Item::DiamondAxe => -3.0,
- Item::DiamondHoe => 0.0,
-
- Item::NetheriteSword => -2.4,
- Item::NetheriteShovel => -3.0,
- Item::NetheritePickaxe => -2.8,
- Item::NetheriteAxe => -3.0,
- Item::NetheriteHoe => 0.0,
-
- Item::Trident => -2.9,
- _ => 0.,
- };
+ let added_attack_speed = added_attack_speed_for_item(held_item.kind());
attributes
.attack_speed
.insert(azalea_entity::attributes::base_attack_speed_modifier(
@@ -565,6 +527,49 @@ fn update_attributes_for_held_item(
}
}
+fn added_attack_speed_for_item(item: Item) -> f64 {
+ match item {
+ Item::WoodenSword => -2.4,
+ Item::WoodenShovel => -3.0,
+ Item::WoodenPickaxe => -2.8,
+ Item::WoodenAxe => -3.2,
+ Item::WoodenHoe => -3.0,
+
+ Item::StoneSword => -2.4,
+ Item::StoneShovel => -3.0,
+ Item::StonePickaxe => -2.8,
+ Item::StoneAxe => -3.2,
+ Item::StoneHoe => -2.0,
+
+ Item::GoldenSword => -2.4,
+ Item::GoldenShovel => -3.0,
+ Item::GoldenPickaxe => -2.8,
+ Item::GoldenAxe => -3.0,
+ Item::GoldenHoe => -3.0,
+
+ Item::IronSword => -2.4,
+ Item::IronShovel => -3.0,
+ Item::IronPickaxe => -2.8,
+ Item::IronAxe => -3.1,
+ Item::IronHoe => -1.0,
+
+ Item::DiamondSword => -2.4,
+ Item::DiamondShovel => -3.0,
+ Item::DiamondPickaxe => -2.8,
+ Item::DiamondAxe => -3.0,
+ Item::DiamondHoe => 0.0,
+
+ Item::NetheriteSword => -2.4,
+ Item::NetheriteShovel => -3.0,
+ Item::NetheritePickaxe => -2.8,
+ Item::NetheriteAxe => -3.0,
+ Item::NetheriteHoe => 0.0,
+
+ Item::Trident => -2.9,
+ _ => 0.,
+ }
+}
+
#[allow(clippy::type_complexity)]
fn update_attributes_for_gamemode(
query: Query<(&mut Attributes, &LocalGameMode), (With<LocalEntity>, Changed<LocalGameMode>)>,
diff --git a/azalea-client/src/plugins/inventory.rs b/azalea-client/src/plugins/inventory.rs
deleted file mode 100644
index c167917b..00000000
--- a/azalea-client/src/plugins/inventory.rs
+++ /dev/null
@@ -1,1022 +0,0 @@
-use std::{cmp, collections::HashSet};
-
-use azalea_chat::FormattedText;
-use azalea_core::tick::GameTick;
-use azalea_entity::PlayerAbilities;
-pub use azalea_inventory::*;
-use azalea_inventory::{
- item::MaxStackSizeExt,
- operations::{
- ClickOperation, CloneClick, PickupAllClick, PickupClick, QuickCraftKind, QuickCraftStatus,
- QuickCraftStatusKind, QuickMoveClick, ThrowClick,
- },
-};
-use azalea_protocol::packets::game::{
- s_container_click::{HashedStack, ServerboundContainerClick},
- s_container_close::ServerboundContainerClose,
- s_set_carried_item::ServerboundSetCarriedItem,
-};
-use azalea_registry::MenuKind;
-use azalea_world::{InstanceContainer, InstanceName};
-use bevy_app::{App, Plugin};
-use bevy_ecs::prelude::*;
-use indexmap::IndexMap;
-use tracing::{error, warn};
-
-use crate::{Client, packet::game::SendGamePacketEvent};
-
-pub struct InventoryPlugin;
-impl Plugin for InventoryPlugin {
- fn build(&self, app: &mut App) {
- app.add_systems(
- GameTick,
- ensure_has_sent_carried_item.after(super::mining::handle_mining_queued),
- )
- .add_observer(handle_client_side_close_container_trigger)
- .add_observer(handle_menu_opened_trigger)
- .add_observer(handle_container_close_event)
- .add_observer(handle_set_container_content_trigger)
- .add_observer(handle_container_click_event)
- // number keys are checked on tick but scrolling can happen outside of ticks, therefore
- // this is fine
- .add_observer(handle_set_selected_hotbar_slot_event);
- }
-}
-
-#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
-pub struct InventorySystems;
-
-impl Client {
- /// Return the menu that is currently open, or the player's inventory if no
- /// menu is open.
- pub fn menu(&self) -> Menu {
- self.query_self::<&Inventory, _>(|inv| inv.menu().clone())
- }
-
- /// Returns the index of the hotbar slot that's currently selected.
- ///
- /// If you want to access the actual held item, you can get the current menu
- /// with [`Client::menu`] and then get the slot index by offsetting from
- /// the start of [`azalea_inventory::Menu::hotbar_slots_range`].
- ///
- /// You can use [`Self::set_selected_hotbar_slot`] to change it.
- pub fn selected_hotbar_slot(&self) -> u8 {
- self.query_self::<&Inventory, _>(|inv| inv.selected_hotbar_slot)
- }
-
- /// Update the selected hotbar slot index.
- ///
- /// This will run next `Update`, so you might want to call
- /// `bot.wait_updates(1)` after calling this if you're using `azalea`.
- ///
- /// # Panics
- ///
- /// This will panic if `new_hotbar_slot_index` is not in the range 0..=8.
- pub fn set_selected_hotbar_slot(&self, new_hotbar_slot_index: u8) {
- assert!(
- new_hotbar_slot_index < 9,
- "Hotbar slot index must be in the range 0..=8"
- );
-
- let mut ecs = self.ecs.lock();
- ecs.trigger(SetSelectedHotbarSlotEvent {
- entity: self.entity,
- slot: new_hotbar_slot_index,
- });
- }
-}
-
-/// A component present on all local players that have an inventory.
-#[derive(Component, Debug, Clone)]
-pub struct Inventory {
- /// The player's inventory menu. This is guaranteed to be a `Menu::Player`.
- ///
- /// We keep it as a [`Menu`] since `Menu` has some useful functions that
- /// bare [`azalea_inventory::Player`] doesn't have.
- pub inventory_menu: azalea_inventory::Menu,
-
- /// The ID of the container that's currently open.
- ///
- /// Its value is not guaranteed to be anything specific, and it may change
- /// every time you open a container (unless it's 0, in which case it
- /// means that no container is open).
- pub id: i32,
- /// The current container menu that the player has open, or `None` if no
- /// container is open.
- pub container_menu: Option<azalea_inventory::Menu>,
- /// The custom name of the menu that's currently open.
- ///
- /// This can only be `Some` when `container_menu` is `Some`.
- pub container_menu_title: Option<FormattedText>,
- /// The item that is currently held by the cursor, or `Slot::Empty` if
- /// nothing is currently being held.
- ///
- /// This is different from [`Self::selected_hotbar_slot`], which is the
- /// item that's selected in the hotbar.
- pub carried: ItemStack,
- /// An identifier used by the server to track client inventory desyncs.
- ///
- /// This is sent on every container click, and it's only ever updated when
- /// the server sends a new container update.
- pub state_id: u32,
-
- pub quick_craft_status: QuickCraftStatusKind,
- pub quick_craft_kind: QuickCraftKind,
- /// A set of the indexes of the slots that have been right clicked in
- /// this "quick craft".
- pub quick_craft_slots: HashSet<u16>,
-
- /// The index of the item in the hotbar that's currently being held by the
- /// player. This must be in the range 0..=8.
- ///
- /// In a vanilla client this is changed by pressing the number keys or using
- /// the scroll wheel.
- pub selected_hotbar_slot: u8,
-}
-
-impl Inventory {
- /// Returns a reference to the currently active menu.
- ///
- /// If a container is open then it'll return [`Self::container_menu`],
- /// otherwise [`Self::inventory_menu`].
- ///
- /// Use [`Self::menu_mut`] if you need a mutable reference.
- pub fn menu(&self) -> &azalea_inventory::Menu {
- match &self.container_menu {
- Some(menu) => menu,
- _ => &self.inventory_menu,
- }
- }
-
- /// Returns a mutable reference to the currently active menu.
- ///
- /// If a container is open then it'll return [`Self::container_menu`],
- /// otherwise [`Self::inventory_menu`].
- ///
- /// Use [`Self::menu`] if you don't need a mutable reference.
- pub fn menu_mut(&mut self) -> &mut azalea_inventory::Menu {
- match &mut self.container_menu {
- Some(menu) => menu,
- _ => &mut self.inventory_menu,
- }
- }
-
- /// Modify the inventory as if the given operation was performed on it.
- pub fn simulate_click(
- &mut self,
- operation: &ClickOperation,
- player_abilities: &PlayerAbilities,
- ) {
- if let ClickOperation::QuickCraft(quick_craft) = operation {
- let last_quick_craft_status_tmp = self.quick_craft_status.clone();
- self.quick_craft_status = last_quick_craft_status_tmp.clone();
- let last_quick_craft_status = last_quick_craft_status_tmp;
-
- // no carried item, reset
- if self.carried.is_empty() {
- return self.reset_quick_craft();
- }
- // if we were starting or ending, or now we aren't ending and the status
- // changed, reset
- if (last_quick_craft_status == QuickCraftStatusKind::Start
- || last_quick_craft_status == QuickCraftStatusKind::End
- || self.quick_craft_status != QuickCraftStatusKind::End)
- && (self.quick_craft_status != last_quick_craft_status)
- {
- return self.reset_quick_craft();
- }
- if self.quick_craft_status == QuickCraftStatusKind::Start {
- self.quick_craft_kind = quick_craft.kind.clone();
- if self.quick_craft_kind == QuickCraftKind::Middle && player_abilities.instant_break
- {
- self.quick_craft_status = QuickCraftStatusKind::Add;
- self.quick_craft_slots.clear();
- } else {
- self.reset_quick_craft();
- }
- return;
- }
- if let QuickCraftStatus::Add { slot } = quick_craft.status {
- let slot_item = self.menu().slot(slot as usize);
- if let Some(slot_item) = slot_item
- && let ItemStack::Present(carried) = &self.carried
- {
- // minecraft also checks slot.may_place(carried) and
- // menu.can_drag_to(slot)
- // but they always return true so they're not relevant for us
- if can_item_quick_replace(slot_item, &self.carried, true)
- && (self.quick_craft_kind == QuickCraftKind::Right
- || carried.count as usize > self.quick_craft_slots.len())
- {
- self.quick_craft_slots.insert(slot);
- }
- }
- return;
- }
- if self.quick_craft_status == QuickCraftStatusKind::End {
- if !self.quick_craft_slots.is_empty() {
- if self.quick_craft_slots.len() == 1 {
- // if we only clicked one slot, then turn this
- // QuickCraftClick into a PickupClick
- let slot = *self.quick_craft_slots.iter().next().unwrap();
- self.reset_quick_craft();
- self.simulate_click(
- &match self.quick_craft_kind {
- QuickCraftKind::Left => {
- PickupClick::Left { slot: Some(slot) }.into()
- }
- QuickCraftKind::Right => {
- PickupClick::Left { slot: Some(slot) }.into()
- }
- QuickCraftKind::Middle => {
- // idk just do nothing i guess
- return;
- }
- },
- player_abilities,
- );
- return;
- }
-
- let ItemStack::Present(mut carried) = self.carried.clone() else {
- // this should never happen
- return self.reset_quick_craft();
- };
-
- let mut carried_count = carried.count;
- let mut quick_craft_slots_iter = self.quick_craft_slots.iter();
-
- loop {
- let mut slot: &ItemStack;
- let mut slot_index: u16;
- let mut item_stack: &ItemStack;
-
- loop {
- let Some(&next_slot) = quick_craft_slots_iter.next() else {
- carried.count = carried_count;
- self.carried = ItemStack::Present(carried);
- return self.reset_quick_craft();
- };
-
- slot = self.menu().slot(next_slot as usize).unwrap();
- slot_index = next_slot;
- item_stack = &self.carried;
-
- if slot.is_present()
- && can_item_quick_replace(slot, item_stack, true)
- // this always returns true in most cases
- // && slot.may_place(item_stack)
- && (
- self.quick_craft_kind == QuickCraftKind::Middle
- || item_stack.count() >= self.quick_craft_slots.len() as i32
- )
- {
- break;
- }
- }
-
- // get the ItemStackData for the slot
- let ItemStack::Present(slot) = slot else {
- unreachable!("the loop above requires the slot to be present to break")
- };
-
- // if self.can_drag_to(slot) {
- let mut new_carried = carried.clone();
- let slot_item_count = slot.count;
- get_quick_craft_slot_count(
- &self.quick_craft_slots,
- &self.quick_craft_kind,
- &mut new_carried,
- slot_item_count,
- );
- let max_stack_size = i32::min(
- new_carried.kind.max_stack_size(),
- i32::min(
- new_carried.kind.max_stack_size(),
- slot.kind.max_stack_size(),
- ),
- );
- if new_carried.count > max_stack_size {
- new_carried.count = max_stack_size;
- }
-
- carried_count -= new_carried.count - slot_item_count;
- // we have to inline self.menu_mut() here to avoid the borrow checker
- // complaining
- let menu = match &mut self.container_menu {
- Some(menu) => menu,
- _ => &mut self.inventory_menu,
- };
- *menu.slot_mut(slot_index as usize).unwrap() =
- ItemStack::Present(new_carried);
- }
- }
- } else {
- return self.reset_quick_craft();
- }
- }
- // the quick craft status should always be in start if we're not in quick craft
- // mode
- if self.quick_craft_status != QuickCraftStatusKind::Start {
- return self.reset_quick_craft();
- }
-
- match operation {
- // left clicking outside inventory
- ClickOperation::Pickup(PickupClick::Left { slot: None }) => {
- if self.carried.is_present() {
- // vanilla has `player.drop`s but they're only used
- // server-side
- // they're included as comments here in case you want to adapt this for a server
- // implementation
-
- // player.drop(self.carried, true);
- self.carried = ItemStack::Empty;
- }
- }
- ClickOperation::Pickup(PickupClick::Right { slot: None }) => {
- if self.carried.is_present() {
- let _item = self.carried.split(1);
- // player.drop(item, true);
- }
- }
- &ClickOperation::Pickup(
- // lol
- ref pickup @ (PickupClick::Left { slot: Some(slot) }
- | PickupClick::Right { slot: Some(slot) }),
- ) => {
- let slot = slot as usize;
- let Some(slot_item) = self.menu().slot(slot) else {
- return;
- };
-
- if self.try_item_click_behavior_override(operation, slot) {
- return;
- }
-
- let is_left_click = matches!(pickup, PickupClick::Left { .. });
-
- match slot_item {
- ItemStack::Empty => {
- if self.carried.is_present() {
- let place_count = if is_left_click {
- self.carried.count()
- } else {
- 1
- };
- self.carried =
- self.safe_insert(slot, self.carried.clone(), place_count);
- }
- }
- ItemStack::Present(_) => {
- if !self.menu().may_pickup(slot) {
- return;
- }
- if let ItemStack::Present(carried) = self.carried.clone() {
- let slot_is_same_item_as_carried = slot_item
- .as_present()
- .is_some_and(|s| carried.is_same_item_and_components(s));
-
- if self.menu().may_place(slot, &carried) {
- if slot_is_same_item_as_carried {
- let place_count = if is_left_click { carried.count } else { 1 };
- self.carried =
- self.safe_insert(slot, self.carried.clone(), place_count);
- } else if carried.count
- <= self
- .menu()
- .max_stack_size(slot)
- .min(carried.kind.max_stack_size())
- {
- // swap slot_item and carried
- self.carried = slot_item.clone();
- let slot_item = self.menu_mut().slot_mut(slot).unwrap();
- *slot_item = carried.into();
- }
- } else if slot_is_same_item_as_carried
- && let Some(removed) = self.try_remove(
- slot,
- slot_item.count(),
- carried.kind.max_stack_size() - carried.count,
- )
- {
- self.carried.as_present_mut().unwrap().count += removed.count();
- // slot.onTake(player, removed);
- }
- } else {
- let pickup_count = if is_left_click {
- slot_item.count()
- } else {
- (slot_item.count() + 1) / 2
- };
- if let Some(new_slot_item) =
- self.try_remove(slot, pickup_count, i32::MAX)
- {
- self.carried = new_slot_item;
- // slot.onTake(player, newSlot);
- }
- }
- }
- }
- }
- &ClickOperation::QuickMove(
- QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot },
- ) => {
- // in vanilla it also tests if QuickMove has a slot index of -999
- // but i don't think that's ever possible so it's not covered here
- let slot = slot as usize;
- loop {
- let new_slot_item = self.menu_mut().quick_move_stack(slot);
- let slot_item = self.menu().slot(slot).unwrap();
- if new_slot_item.is_empty() || slot_item.kind() != new_slot_item.kind() {
- break;
- }
- }
- }
- ClickOperation::Swap(s) => {
- let source_slot_index = s.source_slot as usize;
- let target_slot_index = s.target_slot as usize;
-
- let Some(source_slot) = self.menu().slot(source_slot_index) else {
- return;
- };
- let Some(target_slot) = self.menu().slot(target_slot_index) else {
- return;
- };
- if source_slot.is_empty() && target_slot.is_empty() {
- return;
- }
-
- if target_slot.is_empty() {
- if self.menu().may_pickup(source_slot_index) {
- let source_slot = source_slot.clone();
- let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
- *target_slot = source_slot;
- }
- } else if source_slot.is_empty() {
- let target_item = target_slot
- .as_present()
- .expect("target slot was already checked to not be empty");
- if self.menu().may_place(source_slot_index, target_item) {
- // get the target_item but mutable
- let source_max_stack_size = self.menu().max_stack_size(source_slot_index);
-
- let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
- let new_source_slot =
- target_slot.split(source_max_stack_size.try_into().unwrap());
- *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
- }
- } else if self.menu().may_pickup(source_slot_index) {
- let ItemStack::Present(target_item) = target_slot else {
- unreachable!("target slot is not empty but is not present");
- };
- if self.menu().may_place(source_slot_index, target_item) {
- let source_max_stack = self.menu().max_stack_size(source_slot_index);
- if target_slot.count() > source_max_stack {
- // if there's more than the max stack size in the target slot
-
- let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
- let new_source_slot =
- target_slot.split(source_max_stack.try_into().unwrap());
- *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
- // if !self.inventory_menu.add(new_source_slot) {
- // player.drop(new_source_slot, true);
- // }
- } else {
- // normal swap
- let new_target_slot = source_slot.clone();
- let new_source_slot = target_slot.clone();
-
- let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
- *target_slot = new_target_slot;
-
- let source_slot = self.menu_mut().slot_mut(source_slot_index).unwrap();
- *source_slot = new_source_slot;
- }
- }
- }
- }
- ClickOperation::Clone(CloneClick { slot }) => {
- if !player_abilities.instant_break || self.carried.is_present() {
- return;
- }
- let Some(source_slot) = self.menu().slot(*slot as usize) else {
- return;
- };
- let ItemStack::Present(source_item) = source_slot else {
- return;
- };
- let mut new_carried = source_item.clone();
- new_carried.count = new_carried.kind.max_stack_size();
- self.carried = ItemStack::Present(new_carried);
- }
- ClickOperation::Throw(c) => {
- if self.carried.is_present() {
- return;
- }
-
- let (ThrowClick::Single { slot: slot_index }
- | ThrowClick::All { slot: slot_index }) = c;
- let slot_index = *slot_index as usize;
-
- let Some(slot) = self.menu_mut().slot_mut(slot_index) else {
- return;
- };
- let ItemStack::Present(slot_item) = slot else {
- return;
- };
-
- let dropping_count = match c {
- ThrowClick::Single { .. } => 1,
- ThrowClick::All { .. } => slot_item.count,
- };
-
- let _dropping = slot_item.split(dropping_count as u32);
- // player.drop(dropping, true);
- }
- ClickOperation::PickupAll(PickupAllClick {
- slot: source_slot_index,
- reversed,
- }) => {
- let source_slot_index = *source_slot_index as usize;
-
- let source_slot = self.menu().slot(source_slot_index).unwrap();
- let target_slot = self.carried.clone();
-
- if target_slot.is_empty()
- || (source_slot.is_present() && self.menu().may_pickup(source_slot_index))
- {
- return;
- }
-
- let ItemStack::Present(target_slot_item) = &target_slot else {
- unreachable!("target slot is not empty but is not present");
- };
-
- for round in 0..2 {
- let iterator: Box<dyn Iterator<Item = usize>> = if *reversed {
- Box::new((0..self.menu().len()).rev())
- } else {
- Box::new(0..self.menu().len())
- };
-
- for i in iterator {
- if target_slot_item.count < target_slot_item.kind.max_stack_size() {
- let checking_slot = self.menu().slot(i).unwrap();
- if let ItemStack::Present(checking_item) = checking_slot
- && can_item_quick_replace(checking_slot, &target_slot, true)
- && self.menu().may_pickup(i)
- && (round != 0
- || checking_item.count != checking_item.kind.max_stack_size())
- {
- // get the checking_slot and checking_item again but mutable
- let checking_slot = self.menu_mut().slot_mut(i).unwrap();
-
- let taken_item = checking_slot.split(checking_slot.count() as u32);
-
- // now extend the carried item
- let target_slot = &mut self.carried;
- let ItemStack::Present(target_slot_item) = target_slot else {
- unreachable!("target slot is not empty but is not present");
- };
- target_slot_item.count += taken_item.count();
- }
- }
- }
- }
- }
- _ => {}
- }
- }
-
- fn reset_quick_craft(&mut self) {
- self.quick_craft_status = QuickCraftStatusKind::Start;
- self.quick_craft_slots.clear();
- }
-
- /// Get the item in the player's hotbar that is currently being held in its
- /// main hand.
- pub fn held_item(&self) -> ItemStack {
- let inventory = &self.inventory_menu;
- let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()];
- hotbar_items[self.selected_hotbar_slot as usize].clone()
- }
-
- /// TODO: implement bundles
- fn try_item_click_behavior_override(
- &self,
- _operation: &ClickOperation,
- _slot_item_index: usize,
- ) -> bool {
- false
- }
-
- fn safe_insert(&mut self, slot: usize, src_item: ItemStack, take_count: i32) -> ItemStack {
- let Some(slot_item) = self.menu_mut().slot_mut(slot) else {
- return src_item;
- };
- let ItemStack::Present(mut src_item) = src_item else {
- return src_item;
- };
-
- let take_count = cmp::min(
- cmp::min(take_count, src_item.count),
- src_item.kind.max_stack_size() - slot_item.count(),
- );
- if take_count <= 0 {
- return src_item.into();
- }
- let take_count = take_count as u32;
-
- if slot_item.is_empty() {
- *slot_item = src_item.split(take_count).into();
- } else if let ItemStack::Present(slot_item) = slot_item
- && slot_item.is_same_item_and_components(&src_item)
- {
- src_item.count -= take_count as i32;
- slot_item.count += take_count as i32;
- }
-
- src_item.into()
- }
-
- fn try_remove(&mut self, slot: usize, count: i32, limit: i32) -> Option<ItemStack> {
- if !self.menu().may_pickup(slot) {
- return None;
- }
- let mut slot_item = self.menu().slot(slot)?.clone();
- if !self.menu().allow_modification(slot) && limit < slot_item.count() {
- return None;
- }
-
- let count = count.min(limit);
- if count <= 0 {
- return None;
- }
- // vanilla calls .remove here but i think it has the same behavior as split?
- let removed = slot_item.split(count as u32);
-
- if removed.is_present() && slot_item.is_empty() {
- *self.menu_mut().slot_mut(slot).unwrap() = ItemStack::Empty;
- }
-
- Some(removed)
- }
-}
-
-fn can_item_quick_replace(
- target_slot: &ItemStack,
- item: &ItemStack,
- ignore_item_count: bool,
-) -> bool {
- let ItemStack::Present(target_slot) = target_slot else {
- return false;
- };
- let ItemStack::Present(item) = item else {
- // i *think* this is what vanilla does
- // not 100% sure lol probably doesn't matter though
- return false;
- };
-
- if !item.is_same_item_and_components(target_slot) {
- return false;
- }
- let count = target_slot.count as u16
- + if ignore_item_count {
- 0
- } else {
- item.count as u16
- };
- count <= item.kind.max_stack_size() as u16
-}
-
-fn get_quick_craft_slot_count(
- quick_craft_slots: &HashSet<u16>,
- quick_craft_kind: &QuickCraftKind,
- item: &mut ItemStackData,
- slot_item_count: i32,
-) {
- item.count = match quick_craft_kind {
- QuickCraftKind::Left => item.count / quick_craft_slots.len() as i32,
- QuickCraftKind::Right => 1,
- QuickCraftKind::Middle => item.kind.max_stack_size(),
- };
- item.count += slot_item_count;
-}
-
-impl Default for Inventory {
- fn default() -> Self {
- Inventory {
- inventory_menu: Menu::Player(azalea_inventory::Player::default()),
- id: 0,
- container_menu: None,
- container_menu_title: None,
- carried: ItemStack::Empty,
- state_id: 0,
- quick_craft_status: QuickCraftStatusKind::Start,
- quick_craft_kind: QuickCraftKind::Middle,
- quick_craft_slots: HashSet::new(),
- selected_hotbar_slot: 0,
- }
- }
-}
-
-/// A Bevy trigger that's fired when our client should show a new screen (like a
-/// chest or crafting table).
-///
-/// To watch for the menu being closed, you could use
-/// [`ClientsideCloseContainerEvent`]. To close it manually, use
-/// [`CloseContainerEvent`].
-#[derive(EntityEvent, Debug, Clone)]
-pub struct MenuOpenedEvent {
- pub entity: Entity,
- pub window_id: i32,
- pub menu_type: MenuKind,
- pub title: FormattedText,
-}
-fn handle_menu_opened_trigger(event: On<MenuOpenedEvent>, mut query: Query<&mut Inventory>) {
- let mut inventory = query.get_mut(event.entity).unwrap();
- inventory.id = event.window_id;
- inventory.container_menu = Some(Menu::from_kind(event.menu_type));
- inventory.container_menu_title = Some(event.title.clone());
-}
-
-/// Tell the server that we want to close a container.
-///
-/// Note that this is also sent when the client closes its own inventory, even
-/// though there is no packet for opening its inventory.
-#[derive(EntityEvent)]
-pub struct CloseContainerEvent {
- pub entity: Entity,
- /// The ID of the container to close. 0 for the player's inventory.
- ///
- /// If this is not the same as the currently open inventory, nothing will
- /// happen.
- pub id: i32,
-}
-fn handle_container_close_event(
- close_container: On<CloseContainerEvent>,
- mut commands: Commands,
- query: Query<(Entity, &Inventory)>,
-) {
- let (entity, inventory) = query.get(close_container.entity).unwrap();
- if close_container.id != inventory.id {
- warn!(
- "Tried to close container with ID {}, but the current container ID is {}",
- close_container.id, inventory.id
- );
- return;
- }
-
- commands.trigger(SendGamePacketEvent::new(
- entity,
- ServerboundContainerClose {
- container_id: inventory.id,
- },
- ));
- commands.trigger(ClientsideCloseContainerEvent {
- entity: close_container.entity,
- });
-}
-
-/// A Bevy event that's fired when our client closed a container.
-///
-/// This can also be triggered directly to close a container silently without
-/// sending any packets to the server. You probably don't want that though, and
-/// should instead use [`CloseContainerEvent`].
-///
-/// If you want to watch for a container being opened, you should use
-/// [`MenuOpenedEvent`].
-#[derive(EntityEvent, Clone)]
-pub struct ClientsideCloseContainerEvent {
- pub entity: Entity,
-}
-pub fn handle_client_side_close_container_trigger(
- event: On<ClientsideCloseContainerEvent>,
- mut query: Query<&mut Inventory>,
-) {
- let mut inventory = query.get_mut(event.entity).unwrap();
-
- // copy the Player part of the container_menu to the inventory_menu
- if let Some(inventory_menu) = inventory.container_menu.take() {
- // this isn't the same as what vanilla does. i believe vanilla synchronizes the
- // slots between inventoryMenu and containerMenu by just having the player slots
- // point to the same ItemStack in memory, but emulating this in rust would
- // require us to wrap our `ItemStack`s as `Arc<Mutex<ItemStack>>` which would
- // have kinda terrible ergonomics.
-
- // the simpler solution i chose to go with here is to only copy the player slots
- // when the container is closed. this is perfectly fine for vanilla, but it
- // might cause issues if a server modifies id 0 while we have a container
- // open...
-
- // if we do encounter this issue in the wild then the simplest solution would
- // probably be to just add logic for updating the container_menu when the server
- // tries to modify id 0 for slots within `inventory`. not implemented for now
- // because i'm not sure if that's worth worrying about.
-
- let new_inventory = inventory_menu.slots()[inventory_menu.player_slots_range()].to_vec();
- let new_inventory = <[ItemStack; 36]>::try_from(new_inventory).unwrap();
- *inventory.inventory_menu.as_player_mut().inventory = new_inventory;
- }
-
- inventory.id = 0;
- inventory.container_menu_title = None;
-}
-
-#[derive(EntityEvent, Debug)]
-pub struct ContainerClickEvent {
- pub entity: Entity,
- pub window_id: i32,
- pub operation: ClickOperation,
-}
-pub fn handle_container_click_event(
- container_click: On<ContainerClickEvent>,
- mut commands: Commands,
- mut query: Query<(
- Entity,
- &mut Inventory,
- Option<&PlayerAbilities>,
- &InstanceName,
- )>,
- instance_container: Res<InstanceContainer>,
-) {
- let (entity, mut inventory, player_abilities, instance_name) =
- query.get_mut(container_click.entity).unwrap();
- if inventory.id != container_click.window_id {
- error!(
- "Tried to click container with ID {}, but the current container ID is {}. Click packet won't be sent.",
- container_click.window_id, inventory.id
- );
- return;
- }
-
- let Some(instance) = instance_container.get(instance_name) else {
- return;
- };
-
- let old_slots = inventory.menu().slots();
- inventory.simulate_click(
- &container_click.operation,
- player_abilities.unwrap_or(&PlayerAbilities::default()),
- );
- let new_slots = inventory.menu().slots();
-
- let registry_holder = &instance.read().registries;
-
- // see which slots changed after clicking and put them in the map the server
- // uses this to check if we desynced
- let mut changed_slots: IndexMap<u16, HashedStack> = IndexMap::new();
- for (slot_index, old_slot) in old_slots.iter().enumerate() {
- let new_slot = &new_slots[slot_index];
- if old_slot != new_slot {
- changed_slots.insert(
- slot_index as u16,
- HashedStack::from_item_stack(new_slot, registry_holder),
- );
- }
- }
-
- commands.trigger(SendGamePacketEvent::new(
- entity,
- ServerboundContainerClick {
- container_id: container_click.window_id,
- state_id: inventory.state_id,
- slot_num: container_click
- .operation
- .slot_num()
- .map(|n| n as i16)
- .unwrap_or(-999),
- button_num: container_click.operation.button_num(),
- click_type: container_click.operation.click_type(),
- changed_slots,
- carried_item: HashedStack::from_item_stack(&inventory.carried, registry_holder),
- },
- ));
-}
-
-/// Sent from the server when the contents of a container are replaced.
-///
-/// Usually triggered by the `ContainerSetContent` packet.
-#[derive(EntityEvent)]
-pub struct SetContainerContentEvent {
- pub entity: Entity,
- pub slots: Vec<ItemStack>,
- pub container_id: i32,
-}
-pub fn handle_set_container_content_trigger(
- set_container_content: On<SetContainerContentEvent>,
- mut query: Query<&mut Inventory>,
-) {
- let mut inventory = query.get_mut(set_container_content.entity).unwrap();
-
- if set_container_content.container_id != inventory.id {
- warn!(
- "Got SetContainerContentEvent for container with ID {}, but the current container ID is {}",
- set_container_content.container_id, inventory.id
- );
- return;
- }
-
- let menu = inventory.menu_mut();
- for (i, slot) in set_container_content.slots.iter().enumerate() {
- if let Some(slot_mut) = menu.slot_mut(i) {
- *slot_mut = slot.clone();
- }
- }
-}
-
-/// An ECS message to switch our hand to a different hotbar slot.
-///
-/// This is equivalent to using the scroll wheel or number keys in Minecraft.
-#[derive(EntityEvent)]
-pub struct SetSelectedHotbarSlotEvent {
- pub entity: Entity,
- /// The hotbar slot to select. This should be in the range 0..=8.
- pub slot: u8,
-}
-pub fn handle_set_selected_hotbar_slot_event(
- set_selected_hotbar_slot: On<SetSelectedHotbarSlotEvent>,
- mut query: Query<&mut Inventory>,
-) {
- let mut inventory = query.get_mut(set_selected_hotbar_slot.entity).unwrap();
- inventory.selected_hotbar_slot = set_selected_hotbar_slot.slot;
-}
-
-/// The item slot that the server thinks we have selected.
-///
-/// See [`ensure_has_sent_carried_item`].
-#[derive(Component)]
-pub struct LastSentSelectedHotbarSlot {
- pub slot: u8,
-}
-/// A system that makes sure that [`LastSentSelectedHotbarSlot`] is in sync with
-/// [`Inventory::selected_hotbar_slot`].
-///
-/// This is necessary to make sure that [`ServerboundSetCarriedItem`] is sent in
-/// the right order, since it's not allowed to happen outside of a tick.
-pub fn ensure_has_sent_carried_item(
- mut commands: Commands,
- query: Query<(Entity, &Inventory, Option<&LastSentSelectedHotbarSlot>)>,
-) {
- for (entity, inventory, last_sent) in query.iter() {
- if let Some(last_sent) = last_sent {
- if last_sent.slot == inventory.selected_hotbar_slot {
- continue;
- }
-
- commands.trigger(SendGamePacketEvent::new(
- entity,
- ServerboundSetCarriedItem {
- slot: inventory.selected_hotbar_slot as u16,
- },
- ));
- }
-
- commands.entity(entity).insert(LastSentSelectedHotbarSlot {
- slot: inventory.selected_hotbar_slot,
- });
- }
-}
-
-#[cfg(test)]
-mod tests {
- use azalea_registry::Item;
-
- use super::*;
-
- #[test]
- fn test_simulate_shift_click_in_crafting_table() {
- let spruce_planks = ItemStack::new(Item::SprucePlanks, 4);
-
- let mut inventory = Inventory {
- inventory_menu: Menu::Player(azalea_inventory::Player::default()),
- id: 1,
- container_menu: Some(Menu::Crafting {
- result: spruce_planks.clone(),
- // simulate_click won't delete the items from here
- grid: SlotList::default(),
- player: SlotList::default(),
- }),
- container_menu_title: None,
- carried: ItemStack::Empty,
- state_id: 0,
- quick_craft_status: QuickCraftStatusKind::Start,
- quick_craft_kind: QuickCraftKind::Middle,
- quick_craft_slots: HashSet::new(),
- selected_hotbar_slot: 0,
- };
-
- inventory.simulate_click(
- &ClickOperation::QuickMove(QuickMoveClick::Left { slot: 0 }),
- &PlayerAbilities::default(),
- );
-
- let new_slots = inventory.menu().slots();
- assert_eq!(&new_slots[0], &ItemStack::Empty);
- assert_eq!(
- &new_slots[*Menu::CRAFTING_PLAYER_SLOTS.start()],
- &spruce_planks
- );
- }
-}
diff --git a/azalea-client/src/plugins/inventory/equipment_effects.rs b/azalea-client/src/plugins/inventory/equipment_effects.rs
new file mode 100644
index 00000000..4294cc2f
--- /dev/null
+++ b/azalea-client/src/plugins/inventory/equipment_effects.rs
@@ -0,0 +1,191 @@
+//! Support for enchantments and items with attribute modifiers.
+
+use std::collections::HashMap;
+
+use azalea_core::{
+ data_registry::ResolvableDataRegistry, identifier::Identifier,
+ registry_holder::value::AttributeEffect,
+};
+use azalea_entity::{Attributes, inventory::Inventory};
+use azalea_inventory::{
+ ItemStack,
+ components::{self, AttributeModifier, EquipmentSlot},
+};
+use bevy_ecs::{
+ component::Component,
+ entity::Entity,
+ event::EntityEvent,
+ observer::On,
+ query::With,
+ system::{Commands, Query},
+};
+use tracing::{debug, error, warn};
+
+use crate::local_player::InstanceHolder;
+
+/// A component that contains the equipment slots that we had last tick.
+///
+/// This is used by [`collect_equipment_changes`] for applying enchantments.
+#[derive(Component, Debug, Default)]
+pub struct LastEquipmentItems {
+ pub map: HashMap<EquipmentSlot, ItemStack>,
+}
+
+pub fn collect_equipment_changes(
+ mut commands: Commands,
+ mut query: Query<(Entity, &Inventory, Option<&LastEquipmentItems>), With<Attributes>>,
+) {
+ for (entity, inventory, last_equipment_items) in &mut query {
+ let last_equipment_items = if let Some(e) = last_equipment_items {
+ e
+ } else {
+ commands
+ .entity(entity)
+ .insert(LastEquipmentItems::default());
+ continue;
+ };
+
+ let mut changes = HashMap::new();
+
+ for equipment_slot in EquipmentSlot::values() {
+ let current_item = inventory
+ .get_equipment(equipment_slot)
+ .unwrap_or(&ItemStack::Empty);
+ let last_item = last_equipment_items
+ .map
+ .get(&equipment_slot)
+ .unwrap_or(&ItemStack::Empty);
+
+ if current_item == last_item {
+ // item hasn't changed, nothing to do
+ continue;
+ }
+
+ changes.insert(
+ equipment_slot,
+ EquipmentChange {
+ old: last_item.clone(),
+ new: current_item.clone(),
+ },
+ );
+ }
+
+ if changes.is_empty() {
+ continue;
+ }
+ commands.trigger(EquipmentChangesEvent {
+ entity,
+ map: changes,
+ });
+ }
+}
+
+#[derive(EntityEvent, Debug)]
+pub struct EquipmentChangesEvent {
+ pub entity: Entity,
+ pub map: HashMap<EquipmentSlot, EquipmentChange>,
+}
+#[derive(Debug)]
+pub struct EquipmentChange {
+ pub old: ItemStack,
+ pub new: ItemStack,
+}
+
+pub fn handle_equipment_changes(
+ equipment_changes: On<EquipmentChangesEvent>,
+ mut query: Query<(&InstanceHolder, &mut LastEquipmentItems, &mut Attributes)>,
+) {
+ let Ok((instance_holder, mut last_equipment_items, mut attributes)) =
+ query.get_mut(equipment_changes.entity)
+ else {
+ error!(
+ "got EquipmentChangesEvent with unknown entity {}",
+ equipment_changes.entity
+ );
+ return;
+ };
+
+ if !equipment_changes.map.is_empty() {
+ debug!("equipment changes: {:?}", equipment_changes.map);
+ }
+
+ for (&slot, change) in &equipment_changes.map {
+ if change.old.is_present() {
+ // stopLocationBasedEffects
+
+ for (attribute, modifier) in
+ collect_attribute_modifiers_from_item(slot, &change.old, instance_holder)
+ {
+ if let Some(attribute) = attributes.get_mut(attribute) {
+ attribute.remove(&modifier.id);
+ }
+ }
+
+ last_equipment_items.map.remove(&slot);
+ }
+
+ if change.new.is_present() {
+ // see ItemStack.forEachModifier in vanilla
+
+ for (attribute, modifier) in
+ collect_attribute_modifiers_from_item(slot, &change.new, instance_holder)
+ {
+ if let Some(attribute) = attributes.get_mut(attribute) {
+ attribute.remove(&modifier.id);
+ attribute.insert(modifier);
+ }
+ }
+
+ // runLocationChangedEffects
+
+ last_equipment_items.map.insert(slot, change.new.clone());
+ }
+ }
+}
+
+fn collect_attribute_modifiers_from_item(
+ slot: EquipmentSlot,
+ item: &ItemStack,
+ instance_holder: &InstanceHolder,
+) -> Vec<(azalea_registry::Attribute, AttributeModifier)> {
+ let mut modifiers = Vec::new();
+
+ // handle the attribute_modifiers component first
+ let attribute_modifiers = item
+ .get_component::<components::AttributeModifiers>()
+ .unwrap_or_default();
+ for modifier in &attribute_modifiers.modifiers {
+ modifiers.push((modifier.kind, modifier.modifier.clone()));
+ }
+
+ // now handle enchants
+ let enchants = item
+ .get_component::<components::Enchantments>()
+ .unwrap_or_default();
+ if !enchants.levels.is_empty() {
+ let registry_holder = &instance_holder.instance.read().registries;
+ for (enchant, &level) in &enchants.levels {
+ let Some((_enchant_id, enchant_definition)) = enchant.resolve(registry_holder) else {
+ warn!(
+ "Got equipment with an enchantment that wasn't in the registry, so it couldn't be resolved to an ID"
+ );
+ continue;
+ };
+
+ let effects = enchant_definition.get::<AttributeEffect>();
+ for effect in effects.unwrap_or_default() {
+ // TODO: check if the effect definition allows the slot
+
+ let modifier = AttributeModifier {
+ id: Identifier::new(format!("{}/{slot}", effect.id)),
+ amount: effect.amount.calculate(level) as f64,
+ operation: effect.operation,
+ };
+
+ modifiers.push((effect.attribute, modifier));
+ }
+ }
+ }
+
+ modifiers
+}
diff --git a/azalea-client/src/plugins/inventory/mod.rs b/azalea-client/src/plugins/inventory/mod.rs
new file mode 100644
index 00000000..f0d4a9ce
--- /dev/null
+++ b/azalea-client/src/plugins/inventory/mod.rs
@@ -0,0 +1,349 @@
+pub mod equipment_effects;
+
+use azalea_chat::FormattedText;
+use azalea_core::tick::GameTick;
+use azalea_entity::{PlayerAbilities, inventory::Inventory as Inv};
+use azalea_inventory::operations::ClickOperation;
+pub use azalea_inventory::*;
+use azalea_protocol::packets::game::{
+ s_container_click::{HashedStack, ServerboundContainerClick},
+ s_container_close::ServerboundContainerClose,
+ s_set_carried_item::ServerboundSetCarriedItem,
+};
+use azalea_registry::MenuKind;
+use azalea_world::{InstanceContainer, InstanceName};
+use bevy_app::{App, Plugin};
+use bevy_ecs::prelude::*;
+use indexmap::IndexMap;
+use tracing::{error, warn};
+
+use crate::{
+ Client,
+ inventory::equipment_effects::{collect_equipment_changes, handle_equipment_changes},
+ packet::game::SendGamePacketEvent,
+};
+
+// TODO: when this is removed, remove the Inv alias above (which just exists to
+// avoid conflicting with this pub deprecated type)
+#[deprecated = "moved to `azalea_entity::inventory::Inventory`."]
+pub type Inventory = azalea_entity::inventory::Inventory;
+
+pub struct InventoryPlugin;
+impl Plugin for InventoryPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ GameTick,
+ (
+ ensure_has_sent_carried_item.after(super::mining::handle_mining_queued),
+ collect_equipment_changes
+ .after(super::interact::handle_start_use_item_queued)
+ .before(azalea_physics::ai_step),
+ ),
+ )
+ .add_observer(handle_client_side_close_container_trigger)
+ .add_observer(handle_menu_opened_trigger)
+ .add_observer(handle_container_close_event)
+ .add_observer(handle_set_container_content_trigger)
+ .add_observer(handle_container_click_event)
+ // number keys are checked on tick but scrolling can happen outside of ticks, therefore
+ // this is fine
+ .add_observer(handle_set_selected_hotbar_slot_event)
+ .add_observer(handle_equipment_changes);
+ }
+}
+
+#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
+pub struct InventorySystems;
+
+impl Client {
+ /// Return the menu that is currently open, or the player's inventory if no
+ /// menu is open.
+ pub fn menu(&self) -> Menu {
+ self.query_self::<&Inv, _>(|inv| inv.menu().clone())
+ }
+
+ /// Returns the index of the hotbar slot that's currently selected.
+ ///
+ /// If you want to access the actual held item, you can get the current menu
+ /// with [`Client::menu`] and then get the slot index by offsetting from
+ /// the start of [`azalea_inventory::Menu::hotbar_slots_range`].
+ ///
+ /// You can use [`Self::set_selected_hotbar_slot`] to change it.
+ pub fn selected_hotbar_slot(&self) -> u8 {
+ self.query_self::<&Inv, _>(|inv| inv.selected_hotbar_slot)
+ }
+
+ /// Update the selected hotbar slot index.
+ ///
+ /// This will run next `Update`, so you might want to call
+ /// `bot.wait_updates(1)` after calling this if you're using `azalea`.
+ ///
+ /// # Panics
+ ///
+ /// This will panic if `new_hotbar_slot_index` is not in the range 0..=8.
+ pub fn set_selected_hotbar_slot(&self, new_hotbar_slot_index: u8) {
+ assert!(
+ new_hotbar_slot_index < 9,
+ "Hotbar slot index must be in the range 0..=8"
+ );
+
+ let mut ecs = self.ecs.lock();
+ ecs.trigger(SetSelectedHotbarSlotEvent {
+ entity: self.entity,
+ slot: new_hotbar_slot_index,
+ });
+ }
+}
+
+/// A Bevy trigger that's fired when our client should show a new screen (like a
+/// chest or crafting table).
+///
+/// To watch for the menu being closed, you could use
+/// [`ClientsideCloseContainerEvent`]. To close it manually, use
+/// [`CloseContainerEvent`].
+#[derive(EntityEvent, Debug, Clone)]
+pub struct MenuOpenedEvent {
+ pub entity: Entity,
+ pub window_id: i32,
+ pub menu_type: MenuKind,
+ pub title: FormattedText,
+}
+fn handle_menu_opened_trigger(event: On<MenuOpenedEvent>, mut query: Query<&mut Inv>) {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+ inventory.id = event.window_id;
+ inventory.container_menu = Some(Menu::from_kind(event.menu_type));
+ inventory.container_menu_title = Some(event.title.clone());
+}
+
+/// Tell the server that we want to close a container.
+///
+/// Note that this is also sent when the client closes its own inventory, even
+/// though there is no packet for opening its inventory.
+#[derive(EntityEvent)]
+pub struct CloseContainerEvent {
+ pub entity: Entity,
+ /// The ID of the container to close. 0 for the player's inventory.
+ ///
+ /// If this is not the same as the currently open inventory, nothing will
+ /// happen.
+ pub id: i32,
+}
+fn handle_container_close_event(
+ close_container: On<CloseContainerEvent>,
+ mut commands: Commands,
+ query: Query<(Entity, &Inv)>,
+) {
+ let (entity, inventory) = query.get(close_container.entity).unwrap();
+ if close_container.id != inventory.id {
+ warn!(
+ "Tried to close container with ID {}, but the current container ID is {}",
+ close_container.id, inventory.id
+ );
+ return;
+ }
+
+ commands.trigger(SendGamePacketEvent::new(
+ entity,
+ ServerboundContainerClose {
+ container_id: inventory.id,
+ },
+ ));
+ commands.trigger(ClientsideCloseContainerEvent {
+ entity: close_container.entity,
+ });
+}
+
+/// A Bevy event that's fired when our client closed a container.
+///
+/// This can also be triggered directly to close a container silently without
+/// sending any packets to the server. You probably don't want that though, and
+/// should instead use [`CloseContainerEvent`].
+///
+/// If you want to watch for a container being opened, you should use
+/// [`MenuOpenedEvent`].
+#[derive(EntityEvent, Clone)]
+pub struct ClientsideCloseContainerEvent {
+ pub entity: Entity,
+}
+pub fn handle_client_side_close_container_trigger(
+ event: On<ClientsideCloseContainerEvent>,
+ mut query: Query<&mut Inv>,
+) {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+
+ // copy the Player part of the container_menu to the inventory_menu
+ if let Some(inventory_menu) = inventory.container_menu.take() {
+ // this isn't the same as what vanilla does. i believe vanilla synchronizes the
+ // slots between inventoryMenu and containerMenu by just having the player slots
+ // point to the same ItemStack in memory, but emulating this in rust would
+ // require us to wrap our `ItemStack`s as `Arc<Mutex<ItemStack>>` which would
+ // have kinda terrible ergonomics.
+
+ // the simpler solution i chose to go with here is to only copy the player slots
+ // when the container is closed. this is perfectly fine for vanilla, but it
+ // might cause issues if a server modifies id 0 while we have a container
+ // open...
+
+ // if we do encounter this issue in the wild then the simplest solution would
+ // probably be to just add logic for updating the container_menu when the server
+ // tries to modify id 0 for slots within `inventory`. not implemented for now
+ // because i'm not sure if that's worth worrying about.
+
+ let new_inventory = inventory_menu.slots()[inventory_menu.player_slots_range()].to_vec();
+ let new_inventory = <[ItemStack; 36]>::try_from(new_inventory).unwrap();
+ *inventory.inventory_menu.as_player_mut().inventory = new_inventory;
+ }
+
+ inventory.id = 0;
+ inventory.container_menu_title = None;
+}
+
+#[derive(EntityEvent, Debug)]
+pub struct ContainerClickEvent {
+ pub entity: Entity,
+ pub window_id: i32,
+ pub operation: ClickOperation,
+}
+pub fn handle_container_click_event(
+ container_click: On<ContainerClickEvent>,
+ mut commands: Commands,
+ mut query: Query<(Entity, &mut Inv, Option<&PlayerAbilities>, &InstanceName)>,
+ instance_container: Res<InstanceContainer>,
+) {
+ let (entity, mut inventory, player_abilities, instance_name) =
+ query.get_mut(container_click.entity).unwrap();
+ if inventory.id != container_click.window_id {
+ error!(
+ "Tried to click container with ID {}, but the current container ID is {}. Click packet won't be sent.",
+ container_click.window_id, inventory.id
+ );
+ return;
+ }
+
+ let Some(instance) = instance_container.get(instance_name) else {
+ return;
+ };
+
+ let old_slots = inventory.menu().slots();
+ inventory.simulate_click(
+ &container_click.operation,
+ player_abilities.unwrap_or(&PlayerAbilities::default()),
+ );
+ let new_slots = inventory.menu().slots();
+
+ let registry_holder = &instance.read().registries;
+
+ // see which slots changed after clicking and put them in the map the server
+ // uses this to check if we desynced
+ let mut changed_slots: IndexMap<u16, HashedStack> = IndexMap::new();
+ for (slot_index, old_slot) in old_slots.iter().enumerate() {
+ let new_slot = &new_slots[slot_index];
+ if old_slot != new_slot {
+ changed_slots.insert(
+ slot_index as u16,
+ HashedStack::from_item_stack(new_slot, registry_holder),
+ );
+ }
+ }
+
+ commands.trigger(SendGamePacketEvent::new(
+ entity,
+ ServerboundContainerClick {
+ container_id: container_click.window_id,
+ state_id: inventory.state_id,
+ slot_num: container_click
+ .operation
+ .slot_num()
+ .map(|n| n as i16)
+ .unwrap_or(-999),
+ button_num: container_click.operation.button_num(),
+ click_type: container_click.operation.click_type(),
+ changed_slots,
+ carried_item: HashedStack::from_item_stack(&inventory.carried, registry_holder),
+ },
+ ));
+}
+
+/// Sent from the server when the contents of a container are replaced.
+///
+/// Usually triggered by the `ContainerSetContent` packet.
+#[derive(EntityEvent)]
+pub struct SetContainerContentEvent {
+ pub entity: Entity,
+ pub slots: Vec<ItemStack>,
+ pub container_id: i32,
+}
+pub fn handle_set_container_content_trigger(
+ set_container_content: On<SetContainerContentEvent>,
+ mut query: Query<&mut Inv>,
+) {
+ let mut inventory = query.get_mut(set_container_content.entity).unwrap();
+
+ if set_container_content.container_id != inventory.id {
+ warn!(
+ "Got SetContainerContentEvent for container with ID {}, but the current container ID is {}",
+ set_container_content.container_id, inventory.id
+ );
+ return;
+ }
+
+ let menu = inventory.menu_mut();
+ for (i, slot) in set_container_content.slots.iter().enumerate() {
+ if let Some(slot_mut) = menu.slot_mut(i) {
+ *slot_mut = slot.clone();
+ }
+ }
+}
+
+/// An ECS message to switch our hand to a different hotbar slot.
+///
+/// This is equivalent to using the scroll wheel or number keys in Minecraft.
+#[derive(EntityEvent)]
+pub struct SetSelectedHotbarSlotEvent {
+ pub entity: Entity,
+ /// The hotbar slot to select. This should be in the range 0..=8.
+ pub slot: u8,
+}
+pub fn handle_set_selected_hotbar_slot_event(
+ set_selected_hotbar_slot: On<SetSelectedHotbarSlotEvent>,
+ mut query: Query<&mut Inv>,
+) {
+ let mut inventory = query.get_mut(set_selected_hotbar_slot.entity).unwrap();
+ inventory.selected_hotbar_slot = set_selected_hotbar_slot.slot;
+}
+
+/// The item slot that the server thinks we have selected.
+///
+/// See [`ensure_has_sent_carried_item`].
+#[derive(Component)]
+pub struct LastSentSelectedHotbarSlot {
+ pub slot: u8,
+}
+/// A system that makes sure that [`LastSentSelectedHotbarSlot`] is in sync with
+/// [`Inv::selected_hotbar_slot`].
+///
+/// This is necessary to make sure that [`ServerboundSetCarriedItem`] is sent in
+/// the right order, since it's not allowed to happen outside of a tick.
+pub fn ensure_has_sent_carried_item(
+ mut commands: Commands,
+ query: Query<(Entity, &Inv, Option<&LastSentSelectedHotbarSlot>)>,
+) {
+ for (entity, inventory, last_sent) in query.iter() {
+ if let Some(last_sent) = last_sent {
+ if last_sent.slot == inventory.selected_hotbar_slot {
+ continue;
+ }
+
+ commands.trigger(SendGamePacketEvent::new(
+ entity,
+ ServerboundSetCarriedItem {
+ slot: inventory.selected_hotbar_slot as u16,
+ },
+ ));
+ }
+
+ commands.entity(entity).insert(LastSentSelectedHotbarSlot {
+ slot: inventory.selected_hotbar_slot,
+ });
+ }
+}
diff --git a/azalea-client/src/plugins/mining.rs b/azalea-client/src/plugins/mining.rs
index b3880c00..73f2733d 100644
--- a/azalea-client/src/plugins/mining.rs
+++ b/azalea-client/src/plugins/mining.rs
@@ -1,7 +1,8 @@
use azalea_block::{BlockState, BlockTrait, fluid_state::FluidState};
use azalea_core::{direction::Direction, game_type::GameMode, position::BlockPos, tick::GameTick};
use azalea_entity::{
- ActiveEffects, FluidOnEyes, Physics, PlayerAbilities, Position, mining::get_mine_progress,
+ ActiveEffects, Attributes, FluidOnEyes, Physics, PlayerAbilities, Position,
+ inventory::Inventory, mining::get_mine_progress,
};
use azalea_inventory::ItemStack;
use azalea_physics::{PhysicsSystems, collision::BlockWithShape};
@@ -18,7 +19,7 @@ use crate::{
BlockStatePredictionHandler, SwingArmEvent, can_use_game_master_blocks,
check_is_interaction_restricted, pick::HitResultComponent,
},
- inventory::{Inventory, InventorySystems},
+ inventory::InventorySystems,
local_player::{InstanceHolder, LocalGameMode, PermissionLevel},
movement::MoveEventsSystems,
packet::game::SendGamePacketEvent,
@@ -248,13 +249,16 @@ pub fn handle_mining_queued(
&ActiveEffects,
&FluidOnEyes,
&Physics,
+ &Attributes,
Option<&mut Mining>,
&mut BlockStatePredictionHandler,
- &mut MineDelay,
- &mut MineProgress,
- &mut MineTicks,
- &mut MineItem,
- &mut MineBlockPos,
+ (
+ &mut MineDelay,
+ &mut MineProgress,
+ &mut MineTicks,
+ &mut MineItem,
+ &mut MineBlockPos,
+ ),
)>,
) {
for (
@@ -266,13 +270,16 @@ pub fn handle_mining_queued(
active_effects,
fluid_on_eyes,
physics,
+ attributes,
mut mining,
mut sequence_number,
- mut mine_delay,
- mut mine_progress,
- mut mine_ticks,
- mut current_mining_item,
- mut current_mining_pos,
+ (
+ mut mine_delay,
+ mut mine_progress,
+ mut mine_ticks,
+ mut current_mining_item,
+ mut current_mining_pos,
+ ),
) in query
{
trace!("handle_mining_queued {mining_queued:?}");
@@ -360,9 +367,9 @@ pub fn handle_mining_queued(
&& get_mine_progress(
block.as_ref(),
held_item.kind(),
- &inventory.inventory_menu,
fluid_on_eyes,
physics,
+ attributes,
active_effects,
) >= 1.
{
@@ -380,7 +387,7 @@ pub fn handle_mining_queued(
trace!("inserting mining component {mining:?} for entity {entity:?}");
commands.entity(entity).insert(mining);
**current_mining_pos = Some(mining_queued.position);
- **current_mining_item = held_item;
+ **current_mining_item = held_item.clone();
**mine_progress = 0.;
**mine_ticks = 0.;
mine_block_progress_events.write(MineBlockProgressEvent {
@@ -430,7 +437,7 @@ fn is_same_mining_target(
current_mining_item: &MineItem,
) -> bool {
let held_item = inventory.held_item();
- Some(target_block) == current_mining_pos.0 && held_item == current_mining_item.0
+ Some(target_block) == current_mining_pos.0 && held_item == &current_mining_item.0
}
/// A component bundle for players that can mine blocks.
@@ -601,6 +608,7 @@ pub fn continue_mining_block(
&ActiveEffects,
&FluidOnEyes,
&Physics,
+ &Attributes,
&Mining,
&mut MineDelay,
&mut MineProgress,
@@ -621,6 +629,7 @@ pub fn continue_mining_block(
active_effects,
fluid_on_eyes,
physics,
+ attributes,
mining,
mut mine_delay,
mut mine_progress,
@@ -673,9 +682,9 @@ pub fn continue_mining_block(
**mine_progress += get_mine_progress(
block.as_ref(),
current_mining_item.kind(),
- &inventory.inventory_menu,
fluid_on_eyes,
physics,
+ attributes,
active_effects,
);
diff --git a/azalea-client/src/plugins/packet/game/mod.rs b/azalea-client/src/plugins/packet/game/mod.rs
index 8879d028..7446a506 100644
--- a/azalea-client/src/plugins/packet/game/mod.rs
+++ b/azalea-client/src/plugins/packet/game/mod.rs
@@ -10,6 +10,7 @@ use azalea_entity::{
ActiveEffects, Dead, EntityBundle, EntityKindComponent, HasClientLoaded, LoadedBy, LocalEntity,
LookDirection, Physics, PlayerAbilities, Position, RelativeEntityUpdate,
indexing::{EntityIdIndex, EntityUuidIndex},
+ inventory::Inventory,
metadata::{Health, apply_metadata},
};
use azalea_protocol::{
@@ -29,9 +30,7 @@ use crate::{
connection::RawConnection,
disconnect::DisconnectEvent,
interact::BlockStatePredictionHandler,
- inventory::{
- ClientsideCloseContainerEvent, Inventory, MenuOpenedEvent, SetContainerContentEvent,
- },
+ inventory::{ClientsideCloseContainerEvent, MenuOpenedEvent, SetContainerContentEvent},
local_player::{Hunger, InstanceHolder, LocalGameMode, TabList},
movement::{KnockbackEvent, KnockbackType},
packet::{as_system, declare_packet_handlers},
@@ -245,26 +244,30 @@ impl GamePacketHandler<'_> {
.insert(InstanceName(new_instance_name.clone()));
}
- let Some((_dimension_type, dimension_data)) = p
- .common
- .dimension_type(&instance_holder.instance.read().registries)
- else {
- return;
- };
+ let weak_instance;
+ {
+ let client_registries = &instance_holder.instance.read().registries;
- // add this world to the instance_container (or don't if it's already
- // there)
- let weak_instance = instance_container.get_or_insert(
- new_instance_name.clone(),
- dimension_data.height,
- dimension_data.min_y,
- &instance_holder.instance.read().registries,
- );
- instance_loaded_events.write(InstanceLoadedEvent {
- entity: self.player,
- name: new_instance_name.clone(),
- instance: Arc::downgrade(&weak_instance),
- });
+ let Some((_dimension_type, dimension_data)) =
+ p.common.dimension_type(client_registries)
+ else {
+ return;
+ };
+
+ // add this world to the instance_container (or don't if it's already
+ // there)
+ weak_instance = instance_container.get_or_insert(
+ new_instance_name.clone(),
+ dimension_data.height,
+ dimension_data.min_y,
+ client_registries,
+ );
+ instance_loaded_events.write(InstanceLoadedEvent {
+ entity: self.player,
+ name: new_instance_name.clone(),
+ instance: Arc::downgrade(&weak_instance),
+ });
+ }
// set the partial_world to an empty world
// (when we add chunks or entities those will be in the
@@ -279,12 +282,10 @@ impl GamePacketHandler<'_> {
Some(self.player),
);
{
- let map = instance_holder.instance.read().registries.map.clone();
- let new_registries = &mut weak_instance.write().registries;
+ let client_registries = instance_holder.instance.read().registries.clone();
+ let shared_registries = &mut weak_instance.write().registries;
// add the registries from this instance to the weak instance
- for (registry_name, registry) in map {
- new_registries.map.insert(registry_name, registry);
- }
+ shared_registries.extend(client_registries);
}
instance_holder.instance = weak_instance;
@@ -1432,26 +1433,29 @@ impl GamePacketHandler<'_> {
.insert(InstanceName(new_instance_name.clone()));
}
- let Some((_dimension_type, dimension_data)) = p
- .common
- .dimension_type(&instance_holder.instance.read().registries)
- else {
- return;
- };
+ let weak_instance;
+ {
+ let client_registries = &instance_holder.instance.read().registries;
+ let Some((_dimension_type, dimension_data)) =
+ p.common.dimension_type(client_registries)
+ else {
+ return;
+ };
- // add this world to the instance_container (or don't if it's already
- // there)
- let weak_instance = instance_container.get_or_insert(
- new_instance_name.clone(),
- dimension_data.height,
- dimension_data.min_y,
- &instance_holder.instance.read().registries,
- );
- events.write(InstanceLoadedEvent {
- entity: self.player,
- name: new_instance_name.clone(),
- instance: Arc::downgrade(&weak_instance),
- });
+ // add this world to the instance_container (or don't if it's already
+ // there)
+ weak_instance = instance_container.get_or_insert(
+ new_instance_name.clone(),
+ dimension_data.height,
+ dimension_data.min_y,
+ client_registries,
+ );
+ events.write(InstanceLoadedEvent {
+ entity: self.player,
+ name: new_instance_name.clone(),
+ instance: Arc::downgrade(&weak_instance),
+ });
+ }
// set the partial_world to an empty world
// (when we add chunks or entities those will be in the