azalea_client/plugins/inventory/
mod.rs

1pub mod equipment_effects;
2
3use azalea_chat::FormattedText;
4use azalea_core::tick::GameTick;
5use azalea_entity::{PlayerAbilities, inventory::Inventory as Inv};
6use azalea_inventory::operations::ClickOperation;
7pub use azalea_inventory::*;
8use azalea_protocol::packets::game::{
9    s_container_click::{HashedStack, ServerboundContainerClick},
10    s_container_close::ServerboundContainerClose,
11    s_set_carried_item::ServerboundSetCarriedItem,
12};
13use azalea_registry::builtin::MenuKind;
14use azalea_world::{InstanceContainer, InstanceName};
15use bevy_app::{App, Plugin};
16use bevy_ecs::prelude::*;
17use indexmap::IndexMap;
18use tracing::{error, warn};
19
20use crate::{
21    Client,
22    inventory::equipment_effects::{collect_equipment_changes, handle_equipment_changes},
23    packet::game::SendGamePacketEvent,
24};
25
26// TODO: when this is removed, remove the Inv alias above (which just exists to
27// avoid conflicting with this pub deprecated type)
28#[doc(hidden)]
29#[deprecated = "moved to `azalea_entity::inventory::Inventory`."]
30pub type Inventory = azalea_entity::inventory::Inventory;
31
32pub struct InventoryPlugin;
33impl Plugin for InventoryPlugin {
34    fn build(&self, app: &mut App) {
35        app.add_systems(
36            GameTick,
37            (
38                ensure_has_sent_carried_item.after(super::mining::handle_mining_queued),
39                collect_equipment_changes
40                    .after(super::interact::handle_start_use_item_queued)
41                    .before(azalea_physics::ai_step),
42            ),
43        )
44        .add_observer(handle_client_side_close_container_trigger)
45        .add_observer(handle_menu_opened_trigger)
46        .add_observer(handle_container_close_event)
47        .add_observer(handle_set_container_content_trigger)
48        .add_observer(handle_container_click_event)
49        // number keys are checked on tick but scrolling can happen outside of ticks, therefore
50        // this is fine
51        .add_observer(handle_set_selected_hotbar_slot_event)
52        .add_observer(handle_equipment_changes);
53    }
54}
55
56#[derive(Clone, Debug, Eq, Hash, PartialEq, SystemSet)]
57pub struct InventorySystems;
58
59impl Client {
60    /// Return the menu that is currently open, or the player's inventory if no
61    /// menu is open.
62    pub fn menu(&self) -> Menu {
63        self.query_self::<&Inv, _>(|inv| inv.menu().clone())
64    }
65
66    /// Returns the index of the hotbar slot that's currently selected.
67    ///
68    /// If you want to access the actual held item, you can get the current menu
69    /// with [`Client::menu`] and then get the slot index by offsetting from
70    /// the start of [`azalea_inventory::Menu::hotbar_slots_range`].
71    ///
72    /// You can use [`Self::set_selected_hotbar_slot`] to change it.
73    pub fn selected_hotbar_slot(&self) -> u8 {
74        self.query_self::<&Inv, _>(|inv| inv.selected_hotbar_slot)
75    }
76
77    /// Update the selected hotbar slot index.
78    ///
79    /// This will run next `Update`, so you might want to call
80    /// `bot.wait_updates(1)` after calling this if you're using `azalea`.
81    ///
82    /// # Panics
83    ///
84    /// This will panic if `new_hotbar_slot_index` is not in the range 0..=8.
85    pub fn set_selected_hotbar_slot(&self, new_hotbar_slot_index: u8) {
86        assert!(
87            new_hotbar_slot_index < 9,
88            "Hotbar slot index must be in the range 0..=8"
89        );
90
91        let mut ecs = self.ecs.lock();
92        ecs.trigger(SetSelectedHotbarSlotEvent {
93            entity: self.entity,
94            slot: new_hotbar_slot_index,
95        });
96    }
97}
98
99/// A Bevy trigger that's fired when our client should show a new screen (like a
100/// chest or crafting table).
101///
102/// To watch for the menu being closed, you could use
103/// [`ClientsideCloseContainerEvent`]. To close it manually, use
104/// [`CloseContainerEvent`].
105#[derive(Clone, Debug, EntityEvent)]
106pub struct MenuOpenedEvent {
107    pub entity: Entity,
108    pub window_id: i32,
109    pub menu_type: MenuKind,
110    pub title: FormattedText,
111}
112fn handle_menu_opened_trigger(event: On<MenuOpenedEvent>, mut query: Query<&mut Inv>) {
113    let mut inventory = query.get_mut(event.entity).unwrap();
114    inventory.id = event.window_id;
115    inventory.container_menu = Some(Menu::from_kind(event.menu_type));
116    inventory.container_menu_title = Some(event.title.clone());
117}
118
119/// Tell the server that we want to close a container.
120///
121/// Note that this is also sent when the client closes its own inventory, even
122/// though there is no packet for opening its inventory.
123#[derive(EntityEvent)]
124pub struct CloseContainerEvent {
125    pub entity: Entity,
126    /// The ID of the container to close. 0 for the player's inventory.
127    ///
128    /// If this is not the same as the currently open inventory, nothing will
129    /// happen.
130    pub id: i32,
131}
132fn handle_container_close_event(
133    close_container: On<CloseContainerEvent>,
134    mut commands: Commands,
135    query: Query<(Entity, &Inv)>,
136) {
137    let (entity, inventory) = query.get(close_container.entity).unwrap();
138    if close_container.id != inventory.id {
139        warn!(
140            "Tried to close container with ID {}, but the current container ID is {}",
141            close_container.id, inventory.id
142        );
143        return;
144    }
145
146    commands.trigger(SendGamePacketEvent::new(
147        entity,
148        ServerboundContainerClose {
149            container_id: inventory.id,
150        },
151    ));
152    commands.trigger(ClientsideCloseContainerEvent {
153        entity: close_container.entity,
154    });
155}
156
157/// A Bevy event that's fired when our client closed a container.
158///
159/// This can also be triggered directly to close a container silently without
160/// sending any packets to the server. You probably don't want that though, and
161/// should instead use [`CloseContainerEvent`].
162///
163/// If you want to watch for a container being opened, you should use
164/// [`MenuOpenedEvent`].
165#[derive(Clone, EntityEvent)]
166pub struct ClientsideCloseContainerEvent {
167    pub entity: Entity,
168}
169pub fn handle_client_side_close_container_trigger(
170    event: On<ClientsideCloseContainerEvent>,
171    mut query: Query<&mut Inv>,
172) {
173    let mut inventory = query.get_mut(event.entity).unwrap();
174
175    // copy the Player part of the container_menu to the inventory_menu
176    if let Some(inventory_menu) = inventory.container_menu.take() {
177        // this isn't the same as what vanilla does. i believe vanilla synchronizes the
178        // slots between inventoryMenu and containerMenu by just having the player slots
179        // point to the same ItemStack in memory, but emulating this in rust would
180        // require us to wrap our `ItemStack`s as `Arc<Mutex<ItemStack>>` which would
181        // have kinda terrible ergonomics.
182
183        // the simpler solution i chose to go with here is to only copy the player slots
184        // when the container is closed. this is perfectly fine for vanilla, but it
185        // might cause issues if a server modifies id 0 while we have a container
186        // open...
187
188        // if we do encounter this issue in the wild then the simplest solution would
189        // probably be to just add logic for updating the container_menu when the server
190        // tries to modify id 0 for slots within `inventory`. not implemented for now
191        // because i'm not sure if that's worth worrying about.
192
193        let new_inventory = inventory_menu.slots()[inventory_menu.player_slots_range()].to_vec();
194        let new_inventory = <[ItemStack; 36]>::try_from(new_inventory).unwrap();
195        *inventory.inventory_menu.as_player_mut().inventory = new_inventory;
196    }
197
198    inventory.id = 0;
199    inventory.container_menu_title = None;
200}
201
202#[derive(Debug, EntityEvent)]
203pub struct ContainerClickEvent {
204    pub entity: Entity,
205    pub window_id: i32,
206    pub operation: ClickOperation,
207}
208pub fn handle_container_click_event(
209    container_click: On<ContainerClickEvent>,
210    mut commands: Commands,
211    mut query: Query<(Entity, &mut Inv, Option<&PlayerAbilities>, &InstanceName)>,
212    instance_container: Res<InstanceContainer>,
213) {
214    let (entity, mut inventory, player_abilities, instance_name) =
215        query.get_mut(container_click.entity).unwrap();
216    if inventory.id != container_click.window_id {
217        error!(
218            "Tried to click container with ID {}, but the current container ID is {}. Click packet won't be sent.",
219            container_click.window_id, inventory.id
220        );
221        return;
222    }
223
224    let Some(instance) = instance_container.get(instance_name) else {
225        return;
226    };
227
228    let old_slots = inventory.menu().slots();
229    inventory.simulate_click(
230        &container_click.operation,
231        player_abilities.unwrap_or(&PlayerAbilities::default()),
232    );
233    let new_slots = inventory.menu().slots();
234
235    let registry_holder = &instance.read().registries;
236
237    // see which slots changed after clicking and put them in the map the server
238    // uses this to check if we desynced
239    let mut changed_slots: IndexMap<u16, HashedStack> = IndexMap::new();
240    for (slot_index, old_slot) in old_slots.iter().enumerate() {
241        let new_slot = &new_slots[slot_index];
242        if old_slot != new_slot {
243            changed_slots.insert(
244                slot_index as u16,
245                HashedStack::from_item_stack(new_slot, registry_holder),
246            );
247        }
248    }
249
250    commands.trigger(SendGamePacketEvent::new(
251        entity,
252        ServerboundContainerClick {
253            container_id: container_click.window_id,
254            state_id: inventory.state_id,
255            slot_num: container_click
256                .operation
257                .slot_num()
258                .map(|n| n as i16)
259                .unwrap_or(-999),
260            button_num: container_click.operation.button_num(),
261            click_type: container_click.operation.click_type(),
262            changed_slots,
263            carried_item: HashedStack::from_item_stack(&inventory.carried, registry_holder),
264        },
265    ));
266}
267
268/// Sent from the server when the contents of a container are replaced.
269///
270/// Usually triggered by the `ContainerSetContent` packet.
271#[derive(EntityEvent)]
272pub struct SetContainerContentEvent {
273    pub entity: Entity,
274    pub slots: Vec<ItemStack>,
275    pub container_id: i32,
276}
277pub fn handle_set_container_content_trigger(
278    set_container_content: On<SetContainerContentEvent>,
279    mut query: Query<&mut Inv>,
280) {
281    let mut inventory = query.get_mut(set_container_content.entity).unwrap();
282
283    if set_container_content.container_id != inventory.id {
284        warn!(
285            "Got SetContainerContentEvent for container with ID {}, but the current container ID is {}",
286            set_container_content.container_id, inventory.id
287        );
288        return;
289    }
290
291    let menu = inventory.menu_mut();
292    for (i, slot) in set_container_content.slots.iter().enumerate() {
293        if let Some(slot_mut) = menu.slot_mut(i) {
294            *slot_mut = slot.clone();
295        }
296    }
297}
298
299/// An ECS message to switch our hand to a different hotbar slot.
300///
301/// This is equivalent to using the scroll wheel or number keys in Minecraft.
302#[derive(EntityEvent)]
303pub struct SetSelectedHotbarSlotEvent {
304    pub entity: Entity,
305    /// The hotbar slot to select. This should be in the range 0..=8.
306    pub slot: u8,
307}
308pub fn handle_set_selected_hotbar_slot_event(
309    set_selected_hotbar_slot: On<SetSelectedHotbarSlotEvent>,
310    mut query: Query<&mut Inv>,
311) {
312    let mut inventory = query.get_mut(set_selected_hotbar_slot.entity).unwrap();
313    inventory.selected_hotbar_slot = set_selected_hotbar_slot.slot;
314}
315
316/// The item slot that the server thinks we have selected.
317///
318/// See [`ensure_has_sent_carried_item`].
319#[derive(Component)]
320pub struct LastSentSelectedHotbarSlot {
321    pub slot: u8,
322}
323/// A system that makes sure that [`LastSentSelectedHotbarSlot`] is in sync with
324/// [`Inv::selected_hotbar_slot`].
325///
326/// This is necessary to make sure that [`ServerboundSetCarriedItem`] is sent in
327/// the right order, since it's not allowed to happen outside of a tick.
328pub fn ensure_has_sent_carried_item(
329    mut commands: Commands,
330    query: Query<(Entity, &Inv, Option<&LastSentSelectedHotbarSlot>)>,
331) {
332    for (entity, inventory, last_sent) in query.iter() {
333        if let Some(last_sent) = last_sent {
334            if last_sent.slot == inventory.selected_hotbar_slot {
335                continue;
336            }
337
338            commands.trigger(SendGamePacketEvent::new(
339                entity,
340                ServerboundSetCarriedItem {
341                    slot: inventory.selected_hotbar_slot as u16,
342                },
343            ));
344        }
345
346        commands.entity(entity).insert(LastSentSelectedHotbarSlot {
347            slot: inventory.selected_hotbar_slot,
348        });
349    }
350}