Skip to main content

steel_core/inventory/menu/kinds/
inventory_menu.rs

1//! Player inventory menu.
2//!
3//! Slot layout (46 total):
4//! - Slot 0: Crafting result
5//! - Slots 1-4: 2x2 grid
6//! - Slots 5-8: Armor (head, chest, legs, feet)
7//! - Slots 9-35: Main inventory (27)
8//! - Slots 36-44: Hotbar (9)
9//! - Slot 45: Offhand
10
11use steel_registry::item_stack::ItemStack;
12use steel_utils::locks::{IntoShared, Shared};
13
14use crate::inventory::container::{CraftingContainer, ResultContainer};
15use crate::inventory::prelude::*;
16use crate::inventory::slots::{ArmorSlot, CraftingHandler};
17use crate::player::Player;
18use crate::player::player_inventory::{PlayerInventory, armor_equipment};
19
20/// Container ID for the player inventory (always 0).
21pub const INVENTORY_MENU_CONTAINER_ID: u8 = 0;
22
23/// Builds the player inventory menu, always open when no other menu is.
24///
25/// The inventory container should contain:
26/// - Slots 0-35: Main inventory (hotbar 0-8, main 9-35)
27/// - Slots 36-39: Armor (feet, legs, chest, head)
28/// - Slot 40: Offhand
29#[must_use]
30pub fn inventory_menu(inventory: Shared<PlayerInventory>) -> Menu {
31    let crafting_container = CraftingContainer::new(2, 2).into_shared();
32    let result_container = ResultContainer::new().into_shared();
33
34    let handler = CraftingHandler::new(crafting_container.clone(), result_container.clone(), 2);
35
36    let mut builder = MenuBuilder::new(None, INVENTORY_MENU_CONTAINER_ID);
37
38    let result = builder.result_slot(handler.clone());
39    let grid = builder.section_all(crafting_container);
40    let armor = builder.section_at(
41        &inventory,
42        PlayerInventory::ARMOR_TOP_DOWN,
43        SectionKind::custom(|container, index| {
44            Box::new(ArmorSlot::new(
45                container.clone(),
46                index,
47                armor_equipment(index),
48            ))
49        }),
50    );
51    let player = builder.player_inventory(&inventory);
52    let offhand = builder.section_at(
53        &inventory,
54        [PlayerInventory::SLOT_OFFHAND],
55        SectionKind::Normal,
56    );
57
58    // No routes: quick_move is a custom override. The grid drains on close.
59    builder.drain(grid);
60
61    builder.build(InventoryKind {
62        result_container,
63        handler,
64        result,
65        grid,
66        armor,
67        inv: player.all(),
68        main: player.main(),
69        hotbar: player.hotbar(),
70        offhand,
71    })
72}
73
74/// Per-menu player-inventory state: recipe handler, result container, and the
75/// section handles for its custom shift-click.
76pub struct InventoryKind {
77    /// The result container.
78    result_container: Shared<ResultContainer>,
79    handler: CraftingHandler,
80    /// The result (slot 0).
81    result: Section,
82    /// The 2x2 grid (slots 1-4).
83    grid: Section,
84    /// Armor slots (slots 5-8).
85    armor: Section,
86    /// Main inventory + hotbar (slots 9-44).
87    inv: Section,
88    /// Main inventory (slots 9-35).
89    main: Section,
90    /// Hotbar (slots 36-44).
91    hotbar: Section,
92    /// Offhand (slot 45).
93    offhand: Section,
94}
95
96// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
97// within the process.
98unsafe impl steel_utils::DowncastType for InventoryKind {
99    const TYPE_KEY: steel_utils::DowncastTypeKey =
100        steel_utils::DowncastTypeKey::new("steel:menu/inventory");
101}
102
103impl InventoryKind {
104    /// `ContainerId` of the 2x2 grid.
105    pub(crate) fn crafting_id(&self) -> ContainerId {
106        self.handler.crafting_id()
107    }
108
109    /// Shared handle to the 2x2 grid container.
110    pub(crate) fn crafting_container(&self) -> Shared<CraftingContainer> {
111        self.handler.crafting_container()
112    }
113
114    /// Shared recipe handler for the 2x2 crafting grid and its result.
115    pub(crate) fn crafting_handler(&self) -> CraftingHandler {
116        self.handler.clone()
117    }
118
119    /// Recomputes the result from the current grid contents.
120    pub(crate) fn update_result(&self, guard: &mut ContainerLockGuard) {
121        self.handler.update_result(guard);
122    }
123
124    /// Moves items between the main inventory and hotbar.
125    fn move_between_inventory_and_hotbar(
126        &self,
127        behavior: &MenuBehavior,
128        guard: &mut ContainerLockGuard,
129        slot_index: usize,
130        stack: &mut ItemStack,
131    ) -> bool {
132        if self.main.contains(slot_index) {
133            behavior.move_item_stack_to(
134                guard,
135                slot_index,
136                stack,
137                self.hotbar.start(),
138                self.hotbar.end(),
139                FillDirection::Forward,
140            )
141        } else if self.hotbar.contains(slot_index) {
142            behavior.move_item_stack_to(
143                guard,
144                slot_index,
145                stack,
146                self.main.start(),
147                self.main.end(),
148                FillDirection::Forward,
149            )
150        } else {
151            behavior.move_item_stack_to(
152                guard,
153                slot_index,
154                stack,
155                self.inv.start(),
156                self.inv.end(),
157                FillDirection::Forward,
158            )
159        }
160    }
161}
162
163impl MenuKind for InventoryKind {
164    /// Handles shift-click for a slot, including armor/offhand auto-equip.
165    ///
166    /// Always returns `Some`: the item originally in the slot, or empty if
167    /// nothing moved.
168    #[expect(
169        clippy::too_many_lines,
170        reason = "mirrors Java's InventoryMenu::quickMoveStack branch structure"
171    )]
172    fn quick_move(
173        &mut self,
174        behavior: &mut MenuBehavior,
175        guard: &mut ContainerLockGuard,
176        slot_index: usize,
177        player: &Player,
178    ) -> Option<ItemStack> {
179        if slot_index >= behavior.slots().len() {
180            return Some(ItemStack::empty());
181        }
182
183        let stack = behavior.slots()[slot_index].get_item(guard).clone();
184        if stack.is_empty() {
185            return Some(ItemStack::empty());
186        }
187        if self.result.contains(slot_index)
188            && !behavior.slots()[slot_index].may_pickup(guard, player)
189        {
190            return Some(ItemStack::empty());
191        }
192
193        let clicked = stack.clone();
194        let mut stack_mut = stack;
195
196        // Target range depends on the clicked slot.
197        let moved = if self.result.contains(slot_index) {
198            // Result to inventory, filling existing stacks first.
199            behavior.move_item_stack_to(
200                guard,
201                slot_index,
202                &mut stack_mut,
203                self.inv.start(),
204                self.inv.end(),
205                FillDirection::Backward,
206            )
207        } else if self.grid.contains(slot_index) || self.armor.contains(slot_index) {
208            // Grid or armor to inventory.
209            behavior.move_item_stack_to(
210                guard,
211                slot_index,
212                &mut stack_mut,
213                self.inv.start(),
214                self.inv.end(),
215                FillDirection::Forward,
216            )
217        } else {
218            // Item is in inventory/hotbar, try to equip it first.
219            let equippable_slot = clicked.get_equippable_slot();
220
221            if let Some(eq_slot) = equippable_slot {
222                if eq_slot.slot_type() == EquipmentSlotType::HumanoidArmor {
223                    // Armor slots are ordered head, chest, legs, feet.
224                    let armor_slot_index = self.armor.start()
225                        + match eq_slot {
226                            EquipmentSlot::Head => 0,
227                            EquipmentSlot::Chest => 1,
228                            EquipmentSlot::Legs => 2,
229                            EquipmentSlot::Feet => 3,
230                            _ => unreachable!(),
231                        };
232
233                    if behavior.slots()[armor_slot_index].has_item(guard) {
234                        self.move_between_inventory_and_hotbar(
235                            behavior,
236                            guard,
237                            slot_index,
238                            &mut stack_mut,
239                        )
240                    } else {
241                        behavior.move_item_stack_to(
242                            guard,
243                            slot_index,
244                            &mut stack_mut,
245                            armor_slot_index,
246                            armor_slot_index + 1,
247                            FillDirection::Forward,
248                        )
249                    }
250                } else if eq_slot == EquipmentSlot::OffHand {
251                    if behavior.slots()[self.offhand.start()].has_item(guard) {
252                        self.move_between_inventory_and_hotbar(
253                            behavior,
254                            guard,
255                            slot_index,
256                            &mut stack_mut,
257                        )
258                    } else {
259                        behavior.move_item_stack_to(
260                            guard,
261                            slot_index,
262                            &mut stack_mut,
263                            self.offhand.start(),
264                            self.offhand.end(),
265                            FillDirection::Forward,
266                        )
267                    }
268                } else {
269                    self.move_between_inventory_and_hotbar(
270                        behavior,
271                        guard,
272                        slot_index,
273                        &mut stack_mut,
274                    )
275                }
276            } else {
277                self.move_between_inventory_and_hotbar(behavior, guard, slot_index, &mut stack_mut)
278            }
279        };
280
281        if !moved {
282            return Some(ItemStack::empty());
283        }
284
285        behavior.update_quick_move_source(guard, slot_index, &stack_mut, &clicked);
286
287        if stack_mut.count == clicked.count {
288            return Some(ItemStack::empty());
289        }
290
291        if let Some(remainder) = behavior.slots()[slot_index].on_take(guard, &stack_mut, player) {
292            // Crafting remainders like empty buckets go back to the inventory.
293            player.add_item_or_drop_with_guard(guard, remainder);
294        }
295
296        if self.result.contains(slot_index) {
297            // Drop result output that didn't fit.
298            if !stack_mut.is_empty() {
299                let _ = guard.run_unlocked(|| player.drop_item(stack_mut, false, false));
300            }
301        }
302
303        Some(clicked)
304    }
305
306    /// Prevents taking from the result slot during pickup-all.
307    fn can_take_item_for_pick_all(&self, _carried: &ItemStack, slot_index: usize) -> bool {
308        !self.result.contains(slot_index)
309    }
310
311    /// Clears the virtual result on close. The grid is drained by [`Menu::removed`].
312    fn removed(&mut self, _behavior: &mut MenuBehavior, _player: &Player) {
313        self.result_container.lock().set_item(0, ItemStack::empty());
314    }
315
316    fn slots_changed(
317        &mut self,
318        _behavior: &mut MenuBehavior,
319        guard: &mut ContainerLockGuard,
320        _player: &Player,
321    ) {
322        self.handler.update_result(guard);
323    }
324}
325
326#[cfg(test)]
327mod tests;