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