diff options
| author | mat <27899617+mat-1@users.noreply.github.com> | 2023-05-03 20:57:27 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-05-03 20:57:27 -0500 |
| commit | 634cb8d72c6608512aedba19e5cd669104bc35ea (patch) | |
| tree | f8e76ce9eb43403d29cc0cbcf9a4f51522419dc2 /azalea-inventory | |
| parent | 1fb4418f2c9cbd004c64c2f23d2d0352ee12c0e5 (diff) | |
| download | azalea-drasl-634cb8d72c6608512aedba19e5cd669104bc35ea.tar.xz | |
Inventory (#48)
* start adding azalea-inventory
* design more of how inventories are defined
* start working on az-inv-macros
* inventory macro works
* start adding inventory codegen
* update some deps
* add inventory codegen
* manually write inventory menus
* put the inventories in Client
* start on containersetcontent
* inventory menu should hopefully work
* checks in containersetcontent
* format a comment
* move some variant matches
* inventory.rs
* inventory stuff
* more inventory stuff
* inventory/container tracking works
* start adding interact function
* sequence number
* start adding HitResultComponent
* implement traverse_blocks
* start adding clip
* add clip function
* update_hit_result_component
* start trying to fix
* fix
* make some stuff simpler
* clippy
* lever
* chest
* container handle
* fix ambiguity
* fix some doc tests
* move some container stuff from az-client to azalea
* clicking container
* start implementing simulate_click
* keep working on simulate click
* implement more of simulate_click
this is really boring
* inventory fixes
* start implementing shift clicking
* fix panic in azalea-chat i hope
* shift clicking implemented
* more inventory stuff
* fix items not showing in containers sometimes
* fix test
* fix all warnings
* remove a println
---------
Co-authored-by: mat <git@matdoes.dev>
Diffstat (limited to 'azalea-inventory')
| -rw-r--r-- | azalea-inventory/Cargo.toml | 12 | ||||
| -rw-r--r-- | azalea-inventory/README.md | 2 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/Cargo.toml | 14 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/lib.rs | 45 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/location_enum.rs | 59 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/menu_enum.rs | 70 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/menu_impl.rs | 448 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/parse_macro.rs | 69 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/utils.rs | 54 | ||||
| -rw-r--r-- | azalea-inventory/src/item/mod.rs | 21 | ||||
| -rw-r--r-- | azalea-inventory/src/lib.rs | 172 | ||||
| -rw-r--r-- | azalea-inventory/src/operations.rs | 698 | ||||
| -rw-r--r-- | azalea-inventory/src/slot.rs | 146 |
13 files changed, 1810 insertions, 0 deletions
diff --git a/azalea-inventory/Cargo.toml b/azalea-inventory/Cargo.toml new file mode 100644 index 00000000..4b00901f --- /dev/null +++ b/azalea-inventory/Cargo.toml @@ -0,0 +1,12 @@ +[package] +edition = "2021" +name = "azalea-inventory" +version = "0.1.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +azalea-buf = { version = "0.6.0", path = "../azalea-buf" } +azalea-inventory-macros = { version = "0.1.0", path = "./azalea-inventory-macros" } +azalea-nbt = { version = "0.6.0", path = "../azalea-nbt" } +azalea-registry = { version = "0.6.0", path = "../azalea-registry" } diff --git a/azalea-inventory/README.md b/azalea-inventory/README.md new file mode 100644 index 00000000..67030f6a --- /dev/null +++ b/azalea-inventory/README.md @@ -0,0 +1,2 @@ +Representations of various inventory data structures in Minecraft. + diff --git a/azalea-inventory/azalea-inventory-macros/Cargo.toml b/azalea-inventory/azalea-inventory-macros/Cargo.toml new file mode 100644 index 00000000..9ac1cd68 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "azalea-inventory-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +proc-macro2 = "1.0.47" +quote = "1.0.21" +syn = "1.0.104" diff --git a/azalea-inventory/azalea-inventory-macros/src/lib.rs b/azalea-inventory/azalea-inventory-macros/src/lib.rs new file mode 100644 index 00000000..d3faa091 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/lib.rs @@ -0,0 +1,45 @@ +mod location_enum; +mod menu_enum; +mod menu_impl; +mod parse_macro; +mod utils; + +use parse_macro::{DeclareMenus, Field}; +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::quote; +use syn::{self, parse_macro_input, Ident}; + +#[proc_macro] +pub fn declare_menus(input: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(input as DeclareMenus); + + // implicitly add a `player` field at the end unless an `inventory` field + // is present + for menu in &mut input.menus { + let mut inventory_field_missing = true; + for field in &menu.fields { + if matches!(field.name.to_string().as_str(), "inventory" | "player") { + inventory_field_missing = false; + } + } + if inventory_field_missing { + menu.fields.push(Field { + name: Ident::new("player", Span::call_site()), + length: 36, + }) + } + } + + let menu_enum = menu_enum::generate(&input); + let menu_impl = menu_impl::generate(&input); + let location_enum = location_enum::generate(&input); + + quote! { + #menu_enum + #menu_impl + + #location_enum + } + .into() +} diff --git a/azalea-inventory/azalea-inventory-macros/src/location_enum.rs b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs new file mode 100644 index 00000000..6cec88e6 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs @@ -0,0 +1,59 @@ +use crate::{parse_macro::DeclareMenus, utils::to_pascal_case}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Ident; + +pub fn generate(input: &DeclareMenus) -> TokenStream { + // pub enum MenuLocation { + // Player(PlayerMenuLocation), + // ... + // } + // pub enum PlayerMenuLocation { + // CraftResult, + // Craft, + // Armor, + // Inventory, + // Offhand, + // } + // ... + + let mut menu_location_variants = quote! {}; + let mut enums = quote! {}; + for menu in &input.menus { + let name_snake_case = &menu.name; + let variant_name = Ident::new( + &to_pascal_case(&name_snake_case.to_string()), + name_snake_case.span(), + ); + let enum_name = Ident::new( + &format!("{}MenuLocation", variant_name), + variant_name.span(), + ); + menu_location_variants.extend(quote! { + #variant_name(#enum_name), + }); + let mut individual_menu_location_variants = quote! {}; + for field in &menu.fields { + let field_name = &field.name; + let variant_name = + Ident::new(&to_pascal_case(&field_name.to_string()), field_name.span()); + individual_menu_location_variants.extend(quote! { + #variant_name, + }); + } + enums.extend(quote! { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum #enum_name { + #individual_menu_location_variants + } + }); + } + + quote! { + pub enum MenuLocation { + #menu_location_variants + } + + #enums + } +} diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs new file mode 100644 index 00000000..a9e4f430 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs @@ -0,0 +1,70 @@ +//! Generate the `enum menu` and nothing else. Implementations are in +//! impl_menu.rs + +use crate::parse_macro::{DeclareMenus, Field, Menu}; +use proc_macro2::TokenStream; +use quote::quote; + +pub fn generate(input: &DeclareMenus) -> TokenStream { + let mut variants = quote! {}; + let mut player_fields = None; + for menu in &input.menus { + if menu.name == "Player" { + player_fields = Some(generate_fields(&menu.fields, true)); + } else { + variants.extend(generate_variant_for_menu(menu)); + } + } + let player_fields = player_fields.expect("Player variant must be present"); + + quote! { + #[derive(Clone, Debug, Default)] + pub struct Player { + #player_fields + } + + /// A menu, which is a fixed collection of slots. + #[derive(Clone, Debug)] + pub enum Menu { + Player(Player), + #variants + } + } +} + +/// Player { +/// craft_result: ItemSlot, +/// craft: [ItemSlot; 4], +/// armor: [ItemSlot; 4], +/// inventory: [ItemSlot; 36], +/// offhand: ItemSlot, +/// }, +fn generate_variant_for_menu(menu: &Menu) -> TokenStream { + let name = &menu.name; + let fields = generate_fields(&menu.fields, false); + + quote! { + #name { + #fields + }, + } +} + +fn generate_fields(fields: &[Field], public: bool) -> TokenStream { + let mut generated_fields = quote! {}; + for field in fields { + let field_length = field.length; + let field_type = if field.length == 1 { + quote! { ItemSlot } + } else { + quote! { SlotList<#field_length> } + }; + let field_name = &field.name; + if public { + generated_fields.extend(quote! { pub #field_name: #field_type, }) + } else { + generated_fields.extend(quote! { #field_name: #field_type, }) + } + } + generated_fields +} diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs new file mode 100644 index 00000000..804f69f2 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs @@ -0,0 +1,448 @@ +use crate::{ + parse_macro::{DeclareMenus, Menu}, + utils::{to_pascal_case, to_snake_case}, +}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Ident; + +pub fn generate(input: &DeclareMenus) -> TokenStream { + let mut slot_mut_match_variants = quote! {}; + let mut slot_match_variants = quote! {}; + let mut len_match_variants = quote! {}; + let mut kind_match_variants = quote! {}; + let mut slots_match_variants = quote! {}; + let mut contents_match_variants = quote! {}; + let mut location_match_variants = quote! {}; + let mut player_slots_range_match_variants = quote! {}; + + let mut player_consts = quote! {}; + let mut menu_consts = quote! {}; + + let mut hotbar_slots_start = 0; + let mut hotbar_slots_end = 0; + let mut inventory_without_hotbar_slots_start = 0; + let mut inventory_without_hotbar_slots_end = 0; + + for menu in &input.menus { + slot_mut_match_variants.extend(generate_match_variant_for_slot_mut(menu, true)); + slot_match_variants.extend(generate_match_variant_for_slot_mut(menu, false)); + len_match_variants.extend(generate_match_variant_for_len(menu)); + kind_match_variants.extend(generate_match_variant_for_kind(menu)); + slots_match_variants.extend(generate_match_variant_for_slots(menu)); + contents_match_variants.extend(generate_match_variant_for_contents(menu)); + location_match_variants.extend(generate_match_variant_for_location(menu)); + player_slots_range_match_variants + .extend(generate_match_variant_for_player_slots_range(menu)); + + // this part is only used to generate `Player::is_hotbar_slot` + if menu.name == "Player" { + let mut i = 0; + for field in &menu.fields { + let field_name = &field.name; + let start = i; + i += field.length; + let end = i - 1; + + if field_name == "inventory" { + // it only subtracts 8 here since it's inclusive (there's 9 total hotbar slots) + hotbar_slots_start = end - 8; + hotbar_slots_end = end; + + inventory_without_hotbar_slots_start = start; + inventory_without_hotbar_slots_end = end - 9; + } + + if start == end { + let const_name = Ident::new( + &format!("{}_SLOT", field_name.to_string().to_uppercase()), + field_name.span(), + ); + player_consts.extend(quote! { + pub const #const_name: usize = #start; + }); + } else { + let const_name = Ident::new( + &format!("{}_SLOTS", field_name.to_string().to_uppercase()), + field_name.span(), + ); + player_consts.extend(quote! { + pub const #const_name: RangeInclusive<usize> = #start..=#end; + }); + } + } + } else { + menu_consts.extend(generate_menu_consts(menu)); + } + } + + assert!(hotbar_slots_start != 0 && hotbar_slots_end != 0); + quote! { + impl Player { + pub const HOTBAR_SLOTS: RangeInclusive<usize> = #hotbar_slots_start..=#hotbar_slots_end; + pub const INVENTORY_WITHOUT_HOTBAR_SLOTS: RangeInclusive<usize> = #inventory_without_hotbar_slots_start..=#inventory_without_hotbar_slots_end; + #player_consts + + /// Returns whether the given protocol index is in the player's hotbar. + /// + /// Equivalent to `Player::HOTBAR_SLOTS.contains(&i)`. + pub fn is_hotbar_slot(i: usize) -> bool { + Self::HOTBAR_SLOTS.contains(&i) + } + } + + impl Menu { + #menu_consts + + /// Get a mutable reference to the [`ItemSlot`] at the given protocol index. + /// + /// If you're trying to get an item in a menu without caring about + /// protocol indexes, you should just `match` it and index the + /// [`ItemSlot`] you get. + /// + /// Use [`Menu::slot`] if you don't need a mutable reference to the slot. + /// + /// # Errors + /// + /// Returns `None` if the index is out of bounds. + #[inline] + pub fn slot_mut(&mut self, i: usize) -> Option<&mut ItemSlot> { + Some(match self { + #slot_mut_match_variants + }) + } + + /// Get a reference to the [`ItemSlot`] at the given protocol index. + /// + /// If you're trying to get an item in a menu without caring about + /// protocol indexes, you should just `match` it and index the + /// [`ItemSlot`] you get. + /// + /// Use [`Menu::slot_mut`] if you need a mutable reference to the slot. + /// + /// # Errors + /// + /// Returns `None` if the index is out of bounds. + pub fn slot(&self, i: usize) -> Option<&ItemSlot> { + Some(match self { + #slot_match_variants + }) + } + + /// Returns the number of slots in the menu. + #[allow(clippy::len_without_is_empty)] + pub const fn len(&self) -> usize { + match self { + #len_match_variants + } + } + + pub fn from_kind(kind: azalea_registry::MenuKind) -> Self { + match kind { + #kind_match_variants + } + } + + /// Return the contents of the menu, including the player's inventory. + /// + /// The indexes in this will match up with [`Menu::slot_mut`]. + /// + /// If you don't want to include the player's inventory, use [`Menu::contents`] instead. + pub fn slots(&self) -> Vec<ItemSlot> { + match self { + #slots_match_variants + } + } + + /// Return the contents of the menu, not including the player's inventory. + /// + /// If you want to include the player's inventory, use [`Menu::slots`] instead. + pub fn contents(&self) -> Vec<ItemSlot> { + match self { + #contents_match_variants + } + } + + pub fn location_for_slot(&self, i: usize) -> Option<MenuLocation> { + Some(match self { + #location_match_variants + }) + } + + /// Get the range of slot indexes that contain the player's inventory. This may be different for each menu. + pub fn player_slots_range(&self) -> RangeInclusive<usize> { + match self { + #player_slots_range_match_variants + } + } + + /// Get the range of slot indexes that contain the player's hotbar. This may be different for each menu. + pub fn hotbar_slots_range(&self) -> RangeInclusive<usize> { + // hotbar is always last 9 slots in the player's inventory + ((*self.player_slots_range().end() - 8)..=*self.player_slots_range().end()) + } + + /// Get the range of slot indexes that contain the player's inventory, not including the hotbar. This may be different for each menu. + pub fn player_slots_without_hotbar_range(&self) -> RangeInclusive<usize> { + (*self.player_slots_range().start()..=*self.player_slots_range().end() - 9) + } + + /// Returns whether the given index would be in the player's hotbar. + /// + /// Equivalent to `self.hotbar_slots_range().contains(&i)`. + pub fn is_hotbar_slot(&self, i: usize) -> bool { + self.hotbar_slots_range().contains(&i) + } + } + } +} + +/// Menu::Player { +/// craft_result, +/// craft, +/// armor, +/// inventory, +/// offhand, +/// } => { +/// match i { +/// 0 => craft_result, +/// 1..=4 => craft, +/// 5..=8 => armor, +/// // ... +/// _ => return None, +/// } +/// } // ... +pub fn generate_match_variant_for_slot_mut(menu: &Menu, mutable: bool) -> TokenStream { + let mut match_arms = quote! {}; + let mut i = 0; + for field in &menu.fields { + let field_name = &field.name; + let start = i; + i += field.length; + let end = i - 1; + match_arms.extend(if start == end { + quote! { #start => #field_name, } + } else if start == 0 { + if mutable { + quote! { #start..=#end => &mut #field_name[i], } + } else { + quote! { #start..=#end => &#field_name[i], } + } + } else if mutable { + quote! { #start..=#end => &mut #field_name[i - #start], } + } else { + quote! { #start..=#end => &#field_name[i - #start], } + }); + } + + generate_matcher( + menu, + "e! { + match i { + #match_arms + _ => return None + } + }, + true, + ) +} + +pub fn generate_match_variant_for_len(menu: &Menu) -> TokenStream { + let length = menu.fields.iter().map(|f| f.length).sum::<usize>(); + generate_matcher( + menu, + "e! { + #length + }, + false, + ) +} + +pub fn generate_match_variant_for_kind(menu: &Menu) -> TokenStream { + // azalea_registry::MenuKind::Generic9x3 => Menu::Generic9x3 { contents: + // Default::default(), player: Default::default() }, + + let menu_name = &menu.name; + let menu_field_names = if menu.name == "Player" { + // player isn't in MenuKind + return quote! {}; + } else { + let mut menu_field_names = quote! {}; + for field in &menu.fields { + let field_name = &field.name; + menu_field_names.extend(quote! { #field_name: Default::default(), }) + } + quote! { { #menu_field_names } } + }; + + quote! { + azalea_registry::MenuKind::#menu_name => Menu::#menu_name #menu_field_names, + } +} + +pub fn generate_match_variant_for_slots(menu: &Menu) -> TokenStream { + let mut instructions = quote! {}; + let mut length = 0; + for field in &menu.fields { + let field_name = &field.name; + instructions.extend(if field.length == 1 { + quote! { items.push(#field_name.clone()); } + } else { + quote! { items.extend(#field_name.iter().cloned()); } + }); + length += field.length; + } + + generate_matcher( + menu, + "e! { + let mut items = Vec::with_capacity(#length); + #instructions + items + }, + true, + ) +} + +pub fn generate_match_variant_for_contents(menu: &Menu) -> TokenStream { + let mut instructions = quote! {}; + let mut length = 0; + for field in &menu.fields { + let field_name = &field.name; + if field_name == "player" { + continue; + } + instructions.extend(if field.length == 1 { + quote! { items.push(#field_name.clone()); } + } else { + quote! { items.extend(#field_name.iter().cloned()); } + }); + length += field.length; + } + + generate_matcher( + menu, + "e! { + let mut items = Vec::with_capacity(#length); + #instructions + items + }, + true, + ) +} + +pub fn generate_match_variant_for_location(menu: &Menu) -> TokenStream { + let mut match_arms = quote! {}; + let mut i = 0; + + let menu_name = Ident::new(&to_pascal_case(&menu.name.to_string()), menu.name.span()); + let menu_enum_name = Ident::new(&format!("{menu_name}MenuLocation"), menu_name.span()); + + for field in &menu.fields { + let field_name = Ident::new(&to_pascal_case(&field.name.to_string()), field.name.span()); + let start = i; + i += field.length; + let end = i - 1; + match_arms.extend(if start == end { + quote! { #start => #menu_enum_name::#field_name, } + } else { + quote! { #start..=#end => #menu_enum_name::#field_name, } + }); + } + + generate_matcher( + menu, + "e! { + MenuLocation::#menu_name(match i { + #match_arms + _ => return None + }) + }, + false, + ) +} + +pub fn generate_match_variant_for_player_slots_range(menu: &Menu) -> TokenStream { + // Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS_RANGE,, + // Menu::Generic9x3 { .. } => Menu::GENERIC9X3_SLOTS_RANGE, + // .. + + match menu.name.to_string().as_str() { + "Player" => { + quote! { + Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS, + } + } + _ => { + let menu_name = &menu.name; + let menu_slots_range_name = Ident::new( + &format!( + "{}_PLAYER_SLOTS", + to_snake_case(&menu.name.to_string()).to_uppercase() + ), + menu.name.span(), + ); + quote! { + Menu::#menu_name { .. } => Menu::#menu_slots_range_name, + } + } + } +} + +fn generate_menu_consts(menu: &Menu) -> TokenStream { + let mut menu_consts = quote! {}; + + let mut i = 0; + + for field in &menu.fields { + let field_name_start = format!( + "{}_{}", + to_snake_case(&menu.name.to_string()).to_uppercase(), + to_snake_case(&field.name.to_string()).to_uppercase() + ); + let field_index_start = i; + i += field.length; + let field_index_end = i - 1; + + if field.length == 1 { + let field_name = Ident::new( + format!("{}_SLOT", field_name_start).as_str(), + field.name.span(), + ); + menu_consts.extend(quote! { pub const #field_name: usize = #field_index_start; }); + } else { + let field_name = Ident::new( + format!("{}_SLOTS", field_name_start).as_str(), + field.name.span(), + ); + menu_consts.extend(quote! { pub const #field_name: RangeInclusive<usize> = #field_index_start..=#field_index_end; }); + } + } + + menu_consts +} + +pub fn generate_matcher(menu: &Menu, match_arms: &TokenStream, needs_fields: bool) -> TokenStream { + let menu_name = &menu.name; + let menu_field_names = if needs_fields { + let mut menu_field_names = quote! {}; + for field in &menu.fields { + let field_name = &field.name; + menu_field_names.extend(quote! { #field_name, }) + } + menu_field_names + } else { + quote! { .. } + }; + + let matcher = if menu.name == "Player" { + quote! { (Player { #menu_field_names }) } + } else { + quote! { { #menu_field_names } } + }; + quote! { + Menu::#menu_name #matcher => { + #match_arms + }, + } +} diff --git a/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs new file mode 100644 index 00000000..8eada4ec --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs @@ -0,0 +1,69 @@ +use syn::{ + self, braced, + parse::{Parse, ParseStream, Result}, + Ident, LitInt, Token, +}; + +/// An identifier, colon, and number +/// `craft_result: 1` +pub struct Field { + pub name: Ident, + pub length: usize, +} +impl Parse for Field { + fn parse(input: ParseStream) -> Result<Self> { + let name = input.parse::<Ident>()?; + let _ = input.parse::<Token![:]>()?; + let length = input.parse::<LitInt>()?.base10_parse()?; + Ok(Self { name, length }) + } +} + +/// An identifier and a list of `Field` in curly brackets +/// ```rust,ignore +/// Player { +/// craft_result: 1, +/// ... +/// } +/// ``` +pub struct Menu { + /// The menu name, e.g. `Player` + pub name: Ident, + pub fields: Vec<Field>, +} + +impl Parse for Menu { + fn parse(input: ParseStream) -> Result<Self> { + let name = input.parse::<Ident>()?; + + let content; + braced!(content in input); + let fields = content + .parse_terminated::<Field, Token![,]>(Field::parse)? + .into_iter() + .collect(); + + Ok(Self { name, fields }) + } +} + +/// A list of `Menu`s +/// ```rust,ignore +/// Player { +/// craft_result: 1, +/// ... +/// }, +/// ... +/// ``` +pub struct DeclareMenus { + pub menus: Vec<Menu>, +} +impl Parse for DeclareMenus { + fn parse(input: ParseStream) -> Result<Self> { + let menus = input + .parse_terminated::<Menu, Token![,]>(Menu::parse)? + .into_iter() + .collect(); + Ok(Self { menus }) + } +} diff --git a/azalea-inventory/azalea-inventory-macros/src/utils.rs b/azalea-inventory/azalea-inventory-macros/src/utils.rs new file mode 100644 index 00000000..568c9f71 --- /dev/null +++ b/azalea-inventory/azalea-inventory-macros/src/utils.rs @@ -0,0 +1,54 @@ +pub fn to_pascal_case(s: &str) -> String { + // we get the first item later so this is to make it impossible for that + // to error + if s.is_empty() { + return String::new(); + } + + let mut result = String::new(); + let mut prev_was_underscore = true; // set to true by default so the first character is capitalized + if s.chars().next().unwrap().is_numeric() { + result.push('_'); + } + for c in s.chars() { + if c == '_' { + prev_was_underscore = true; + } else if prev_was_underscore { + result.push(c.to_ascii_uppercase()); + prev_was_underscore = false; + } else { + result.push(c); + } + } + result +} + +pub fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + let mut prev_was_uppercase = true; + for c in s.chars() { + if c.is_ascii_uppercase() { + if !prev_was_uppercase { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_was_uppercase = true; + } else { + result.push(c); + prev_was_uppercase = false; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_snake_case() { + assert_eq!(to_snake_case("HelloWorld"), "hello_world"); + assert_eq!(to_snake_case("helloWorld"), "hello_world"); + assert_eq!(to_snake_case("hello_world"), "hello_world"); + } +} diff --git a/azalea-inventory/src/item/mod.rs b/azalea-inventory/src/item/mod.rs new file mode 100644 index 00000000..07e51363 --- /dev/null +++ b/azalea-inventory/src/item/mod.rs @@ -0,0 +1,21 @@ +pub trait MaxStackSizeExt { + /// Get the maximum stack size for this item. + /// + /// This is a signed integer to be consistent with the `count` field of + /// [`ItemSlotData`]. + fn max_stack_size(&self) -> i8; + + /// Whether this item can be stacked with other items. + /// + /// This is equivalent to `self.max_stack_size() > 1`. + fn stackable(&self) -> bool { + self.max_stack_size() > 1 + } +} + +impl MaxStackSizeExt for azalea_registry::Item { + fn max_stack_size(&self) -> i8 { + // TODO: have the properties for every item defined somewhere + 64 + } +} diff --git a/azalea-inventory/src/lib.rs b/azalea-inventory/src/lib.rs new file mode 100644 index 00000000..518c7a1d --- /dev/null +++ b/azalea-inventory/src/lib.rs @@ -0,0 +1,172 @@ +#![doc = include_str!("../README.md")] + +pub mod item; +pub mod operations; +mod slot; + +use std::ops::{Deref, DerefMut, RangeInclusive}; + +use azalea_inventory_macros::declare_menus; +pub use slot::{ItemSlot, ItemSlotData}; + +// TODO: remove this here and in azalea-inventory-macros when rust makes +// Default be implemented for all array sizes (since right now it's only up to +// 32) + +/// A fixed-size list of [`ItemSlot`]s. +#[derive(Debug, Clone)] +pub struct SlotList<const N: usize>([ItemSlot; N]); +impl<const N: usize> Deref for SlotList<N> { + type Target = [ItemSlot; N]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl<const N: usize> DerefMut for SlotList<N> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl<const N: usize> Default for SlotList<N> { + fn default() -> Self { + SlotList([(); N].map(|_| ItemSlot::Empty)) + } +} + +impl Menu { + /// Get the [`Player`] from this [`Menu`]. + /// + /// # Panics + /// + /// Will panic if the menu isn't `Menu::Player`. + pub fn as_player(&self) -> &Player { + if let Menu::Player(player) = &self { + player + } else { + unreachable!("Called `Menu::as_player` on a menu that wasn't `Player`.") + } + } +} + +// the player inventory part is always the last 36 slots (except in the Player +// menu), so we don't have to explicitly specify it + +// Client { +// ... +// pub menu: Menu, +// pub inventory: Arc<[Slot; 36]> +// } + +// Generate a `struct Player`, `enum Menu`, and `impl Menu`. +// a "player" field gets implicitly added with the player inventory + +declare_menus! { + Player { + craft_result: 1, + craft: 4, + armor: 4, + inventory: 36, + offhand: 1, + }, + Generic9x1 { + contents: 9, + }, + Generic9x2 { + contents: 18, + }, + Generic9x3 { + contents: 27, + }, + Generic9x4 { + contents: 36, + }, + Generic9x5 { + contents: 45, + }, + Generic9x6 { + contents: 54, + }, + Generic3x3 { + contents: 9, + }, + Anvil { + first: 1, + second: 1, + result: 1, + }, + Beacon { + payment: 1, + }, + BlastFurnace { + ingredient: 1, + fuel: 1, + result: 1, + }, + BrewingStand { + bottles: 3, + ingredient: 1, + fuel: 1, + }, + Crafting { + result: 1, + grid: 9, + }, + Enchantment { + item: 1, + lapis: 1, + }, + Furnace { + ingredient: 1, + fuel: 1, + result: 1, + }, + Grindstone { + input: 1, + additional: 1, + result: 1, + }, + Hopper { + contents: 5, + }, + Lectern { + book: 1, + }, + Loom { + banner: 1, + dye: 1, + pattern: 1, + result: 1, + }, + Merchant { + payments: 2, + result: 1, + }, + ShulkerBox { + contents: 27, + }, + LegacySmithing { + input: 1, + additional: 1, + result: 1, + }, + Smithing { + template: 1, + base: 1, + additional: 1, + result: 1, + }, + Smoker { + ingredient: 1, + fuel: 1, + result: 1, + }, + CartographyTable { + map: 1, + additional: 1, + result: 1, + }, + Stonecutter { + input: 1, + result: 1, + }, +} diff --git a/azalea-inventory/src/operations.rs b/azalea-inventory/src/operations.rs new file mode 100644 index 00000000..1379b8a9 --- /dev/null +++ b/azalea-inventory/src/operations.rs @@ -0,0 +1,698 @@ +use std::ops::RangeInclusive; + +use azalea_buf::McBuf; + +use crate::{ + item::MaxStackSizeExt, AnvilMenuLocation, BeaconMenuLocation, BlastFurnaceMenuLocation, + BrewingStandMenuLocation, CartographyTableMenuLocation, CraftingMenuLocation, + EnchantmentMenuLocation, FurnaceMenuLocation, Generic3x3MenuLocation, Generic9x1MenuLocation, + Generic9x2MenuLocation, Generic9x3MenuLocation, Generic9x4MenuLocation, Generic9x5MenuLocation, + Generic9x6MenuLocation, GrindstoneMenuLocation, HopperMenuLocation, ItemSlot, ItemSlotData, + LecternMenuLocation, LegacySmithingMenuLocation, LoomMenuLocation, Menu, MenuLocation, + MerchantMenuLocation, Player, PlayerMenuLocation, ShulkerBoxMenuLocation, SmithingMenuLocation, + SmokerMenuLocation, StonecutterMenuLocation, +}; + +#[derive(Debug, Clone)] +pub enum ClickOperation { + Pickup(PickupClick), + QuickMove(QuickMoveClick), + Swap(SwapClick), + Clone(CloneClick), + Throw(ThrowClick), + QuickCraft(QuickCraftClick), + PickupAll(PickupAllClick), +} + +#[derive(Debug, Clone)] +pub enum PickupClick { + /// Left mouse click. Note that in the protocol, None is represented as + /// -999. + Left { slot: Option<u16> }, + /// Right mouse click. Note that in the protocol, None is represented as + /// -999. + Right { slot: Option<u16> }, + /// Drop cursor stack. + LeftOutside, + /// Drop cursor single item. + RightOutside, +} +impl From<PickupClick> for ClickOperation { + fn from(click: PickupClick) -> Self { + ClickOperation::Pickup(click) + } +} + +/// Shift click +#[derive(Debug, Clone)] +pub enum QuickMoveClick { + /// Shift + left mouse click + Left { slot: u16 }, + /// Shift + right mouse click (identical behavior) + Right { slot: u16 }, +} +impl From<QuickMoveClick> for ClickOperation { + fn from(click: QuickMoveClick) -> Self { + ClickOperation::QuickMove(click) + } +} + +/// Used when you press number keys or F in an inventory. +#[derive(Debug, Clone)] +pub struct SwapClick { + pub source_slot: u16, + pub target_slot: u8, +} + +impl From<SwapClick> for ClickOperation { + fn from(click: SwapClick) -> Self { + ClickOperation::Swap(click) + } +} +/// Middle click, only defined for creative players in non-player +/// inventories. +#[derive(Debug, Clone)] +pub struct CloneClick { + pub slot: u16, +} +impl From<CloneClick> for ClickOperation { + fn from(click: CloneClick) -> Self { + ClickOperation::Clone(click) + } +} +#[derive(Debug, Clone)] +pub enum ThrowClick { + /// Drop key (Q) + Single { slot: u16 }, + /// Ctrl + drop key (Q) + All { slot: u16 }, +} +impl From<ThrowClick> for ClickOperation { + fn from(click: ThrowClick) -> Self { + ClickOperation::Throw(click) + } +} +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct QuickCraftClick { + pub kind: QuickCraftKind, + pub status: QuickCraftStatus, +} +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum QuickCraftKind { + Left, + Right, + Middle, +} +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum QuickCraftStatusKind { + /// Starting drag + Start, + /// Add slot + Add, + /// Ending drag + End, +} +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum QuickCraftStatus { + /// Starting drag + Start, + /// Add a slot. + Add { slot: u16 }, + /// Ending drag + End, +} +impl From<QuickCraftStatus> for QuickCraftStatusKind { + fn from(status: QuickCraftStatus) -> Self { + match status { + QuickCraftStatus::Start => QuickCraftStatusKind::Start, + QuickCraftStatus::Add { .. } => QuickCraftStatusKind::Add, + QuickCraftStatus::End => QuickCraftStatusKind::End, + } + } +} + +/// Double click +#[derive(Debug, Clone)] +pub struct PickupAllClick { + /// The slot that we're double clicking on. It should be empty or at least + /// not pickup-able (since the carried item is used as the filter). + pub slot: u16, + /// Impossible in vanilla clients. + pub reversed: bool, +} +impl From<PickupAllClick> for ClickOperation { + fn from(click: PickupAllClick) -> Self { + ClickOperation::PickupAll(click) + } +} + +impl ClickOperation { + /// Return the slot number that this operation is acting on, if any. + /// + /// Note that in the protocol, "None" is represented as -999. + pub fn slot_num(&self) -> Option<u16> { + match self { + ClickOperation::Pickup(pickup) => match pickup { + PickupClick::Left { slot } => *slot, + PickupClick::Right { slot } => *slot, + PickupClick::LeftOutside => None, + PickupClick::RightOutside => None, + }, + ClickOperation::QuickMove(quick_move) => match quick_move { + QuickMoveClick::Left { slot } => Some(*slot), + QuickMoveClick::Right { slot } => Some(*slot), + }, + ClickOperation::Swap(swap) => Some(swap.source_slot), + ClickOperation::Clone(clone) => Some(clone.slot), + ClickOperation::Throw(throw) => match throw { + ThrowClick::Single { slot } => Some(*slot), + ThrowClick::All { slot } => Some(*slot), + }, + ClickOperation::QuickCraft(quick_craft) => match quick_craft.status { + QuickCraftStatus::Start => None, + QuickCraftStatus::Add { slot } => Some(slot), + QuickCraftStatus::End => None, + }, + ClickOperation::PickupAll(pickup_all) => Some(pickup_all.slot), + } + } + + pub fn button_num(&self) -> u8 { + match self { + ClickOperation::Pickup(pickup) => match pickup { + PickupClick::Left { .. } => 0, + PickupClick::Right { .. } => 1, + PickupClick::LeftOutside => 0, + PickupClick::RightOutside => 1, + }, + ClickOperation::QuickMove(quick_move) => match quick_move { + QuickMoveClick::Left { .. } => 0, + QuickMoveClick::Right { .. } => 1, + }, + ClickOperation::Swap(swap) => swap.target_slot, + ClickOperation::Clone(_) => 2, + ClickOperation::Throw(throw) => match throw { + ThrowClick::Single { .. } => 0, + ThrowClick::All { .. } => 1, + }, + ClickOperation::QuickCraft(quick_craft) => match quick_craft { + QuickCraftClick { + kind: QuickCraftKind::Left, + status: QuickCraftStatus::Start, + } => 0, + QuickCraftClick { + kind: QuickCraftKind::Right, + status: QuickCraftStatus::Start, + } => 4, + QuickCraftClick { + kind: QuickCraftKind::Middle, + status: QuickCraftStatus::Start, + } => 8, + QuickCraftClick { + kind: QuickCraftKind::Left, + status: QuickCraftStatus::Add { .. }, + } => 1, + QuickCraftClick { + kind: QuickCraftKind::Right, + status: QuickCraftStatus::Add { .. }, + } => 5, + QuickCraftClick { + kind: QuickCraftKind::Middle, + status: QuickCraftStatus::Add { .. }, + } => 9, + QuickCraftClick { + kind: QuickCraftKind::Left, + status: QuickCraftStatus::End, + } => 2, + QuickCraftClick { + kind: QuickCraftKind::Right, + status: QuickCraftStatus::End, + } => 6, + QuickCraftClick { + kind: QuickCraftKind::Middle, + status: QuickCraftStatus::End, + } => 10, + }, + ClickOperation::PickupAll(_) => 0, + } + } + + pub fn click_type(&self) -> ClickType { + match self { + ClickOperation::Pickup(_) => ClickType::Pickup, + ClickOperation::QuickMove(_) => ClickType::QuickMove, + ClickOperation::Swap(_) => ClickType::Swap, + ClickOperation::Clone(_) => ClickType::Clone, + ClickOperation::Throw(_) => ClickType::Throw, + ClickOperation::QuickCraft(_) => ClickType::QuickCraft, + ClickOperation::PickupAll(_) => ClickType::PickupAll, + } + } +} + +#[derive(McBuf, Clone, Copy, Debug)] +pub enum ClickType { + Pickup = 0, + QuickMove = 1, + Swap = 2, + Clone = 3, + Throw = 4, + QuickCraft = 5, + PickupAll = 6, +} + +impl Menu { + /// Shift-click a slot in this menu. + pub fn quick_move_stack(&mut self, slot_index: usize) -> ItemSlot { + let slot = self.slot(slot_index); + if slot.is_none() { + return ItemSlot::Empty; + }; + + let slot_location = self + .location_for_slot(slot_index) + .expect("we just checked to make sure the slot is Some above, so this shouldn't be able to error"); + match slot_location { + MenuLocation::Player(l) => match l { + PlayerMenuLocation::CraftResult => { + self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS); + } + PlayerMenuLocation::Craft => { + self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS); + } + PlayerMenuLocation::Armor => { + self.try_move_item_to_slots(slot_index, Player::INVENTORY_SLOTS); + } + _ => { + // TODO: armor handling (see quickMoveStack in + // InventoryMenu.java) + + // if slot.kind().is_armor() && + + // also offhand handling + + if l == PlayerMenuLocation::Inventory { + // shift-clicking in hotbar moves to inventory, and vice versa + if Player::is_hotbar_slot(slot_index) { + self.try_move_item_to_slots( + slot_index, + Player::INVENTORY_WITHOUT_HOTBAR_SLOTS, + ); + } else { + self.try_move_item_to_slots(slot_index, Player::HOTBAR_SLOTS); + } + } else { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + } + }, + MenuLocation::Generic9x1(l) => match l { + Generic9x1MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x1MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X1_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic9x2(l) => match l { + Generic9x2MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x2MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X2_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic9x3(l) => match l { + Generic9x3MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x3MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X3_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic9x4(l) => match l { + Generic9x4MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x4MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X4_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic9x5(l) => match l { + Generic9x5MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x5MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X5_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic9x6(l) => match l { + Generic9x6MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic9x6MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC9X6_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Generic3x3(l) => match l { + Generic3x3MenuLocation::Contents => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + Generic3x3MenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GENERIC3X3_CONTENTS_SLOTS, + ); + } + }, + MenuLocation::Anvil(l) => match l { + AnvilMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::ANVIL_FIRST_SLOT..=Menu::ANVIL_SECOND_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Beacon(l) => match l { + BeaconMenuLocation::Payment => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + BeaconMenuLocation::Player => { + self.try_move_item_to_slots( + slot_index, + Menu::BEACON_PAYMENT_SLOT..=Menu::BEACON_PAYMENT_SLOT, + ); + } + }, + MenuLocation::BlastFurnace(l) => match l { + BlastFurnaceMenuLocation::Player => { + self.try_move_item_to_slots( + slot_index, + Menu::BLAST_FURNACE_INGREDIENT_SLOT..=Menu::BLAST_FURNACE_FUEL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::BrewingStand(l) => match l { + BrewingStandMenuLocation::Player => { + self.try_move_item_to_slots( + slot_index, + *Menu::BREWING_STAND_BOTTLES_SLOTS.start() + ..=Menu::BREWING_STAND_INGREDIENT_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Crafting(l) => match l { + CraftingMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::CRAFTING_GRID_SLOTS, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Enchantment(l) => match l { + EnchantmentMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::ENCHANTMENT_ITEM_SLOT..=Menu::ENCHANTMENT_LAPIS_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Furnace(l) => match l { + FurnaceMenuLocation::Player => { + self.try_move_item_to_slots( + slot_index, + Menu::FURNACE_INGREDIENT_SLOT..=Menu::FURNACE_FUEL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Grindstone(l) => match l { + GrindstoneMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::GRINDSTONE_INPUT_SLOT..=Menu::GRINDSTONE_ADDITIONAL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Hopper(l) => match l { + HopperMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::HOPPER_CONTENTS_SLOTS, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Lectern(l) => match l { + LecternMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::LECTERN_BOOK_SLOT..=Menu::LECTERN_BOOK_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Loom(l) => match l { + LoomMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::LOOM_BANNER_SLOT..=Menu::LOOM_PATTERN_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Merchant(l) => match l { + MerchantMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::MERCHANT_PAYMENTS_SLOTS, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::ShulkerBox(l) => match l { + ShulkerBoxMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::SHULKER_BOX_CONTENTS_SLOTS, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::LegacySmithing(l) => match l { + LegacySmithingMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::LEGACY_SMITHING_INPUT_SLOT..=Menu::LEGACY_SMITHING_ADDITIONAL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Smithing(l) => match l { + SmithingMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::SMITHING_TEMPLATE_SLOT..=Menu::SMITHING_ADDITIONAL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Smoker(l) => match l { + SmokerMenuLocation::Player => { + self.try_move_item_to_slots( + slot_index, + Menu::SMOKER_INGREDIENT_SLOT..=Menu::SMOKER_FUEL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::CartographyTable(l) => match l { + CartographyTableMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::CARTOGRAPHY_TABLE_MAP_SLOT..=Menu::CARTOGRAPHY_TABLE_ADDITIONAL_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + MenuLocation::Stonecutter(l) => match l { + StonecutterMenuLocation::Player => { + self.try_move_item_to_slots_or_toggle_hotbar( + slot_index, + Menu::STONECUTTER_INPUT_SLOT..=Menu::STONECUTTER_INPUT_SLOT, + ); + } + _ => { + self.try_move_item_to_slots(slot_index, self.player_slots_range()); + } + }, + } + + ItemSlot::Empty + } + + fn try_move_item_to_slots_or_toggle_hotbar( + &mut self, + slot_index: usize, + target_slot_indexes: RangeInclusive<usize>, + ) { + if !self.try_move_item_to_slots(slot_index, target_slot_indexes) { + self.try_move_item_to_slots( + slot_index, + if self.is_hotbar_slot(slot_index) { + self.player_slots_without_hotbar_range() + } else { + self.hotbar_slots_range() + }, + ); + } + } + + /// Whether the given item could be placed in this menu. + /// + /// TODO: right now this always returns true + pub fn may_place(&self, _target_slot_index: usize, _item: &ItemSlotData) -> bool { + true + } + + /// Whether the item in the given slot could be clicked and picked up. + /// TODO: right now this always returns true + pub fn may_pickup(&self, _source_slot_index: usize) -> bool { + true + } + + /// Get the maximum number of items that can be placed in this slot. + pub fn max_stack_size(&self, _target_slot_index: usize) -> u8 { + 64 + } + + /// Try moving an item to a set of slots in this menu. + /// + /// Returns the updated item slot. + fn try_move_item_to_slots( + &mut self, + item_slot_index: usize, + target_slot_indexes: RangeInclusive<usize>, + ) -> bool { + let mut item_slot = self.slot(item_slot_index).unwrap().clone(); + + // first see if we can stack it with another item + if item_slot.kind().stackable() { + for target_slot_index in target_slot_indexes.clone() { + self.move_item_to_slot_if_stackable(&mut item_slot, target_slot_index); + if item_slot.is_empty() { + break; + } + } + } + + // and if not then just try putting it in an empty slot + if item_slot.is_present() { + for target_slot_index in target_slot_indexes { + self.move_item_to_slot_if_empty(&mut item_slot, target_slot_index); + if item_slot.is_empty() { + break; + } + } + } + + item_slot.is_empty() + } + + /// Merge this item slot into the target item slot, only if the target item + /// slot is present and the same item. + fn move_item_to_slot_if_stackable( + &mut self, + item_slot: &mut ItemSlot, + target_slot_index: usize, + ) { + let ItemSlot::Present(item) = item_slot else { + return; + }; + let target_slot = self.slot(target_slot_index).unwrap(); + if let ItemSlot::Present(target_item) = target_slot { + // the target slot is empty, so we can just move the item there + if self.may_place(target_slot_index, item) && target_item.is_same_item_and_nbt(item) { + let slot_item_limit = self.max_stack_size(target_slot_index); + let new_target_slot_data = item.split(u8::min(slot_item_limit, item.count as u8)); + + // get the target slot again but mut this time so we can update it + let target_slot = self.slot_mut(target_slot_index).unwrap(); + *target_slot = ItemSlot::Present(new_target_slot_data); + + item_slot.update_empty(); + } + } + } + + fn move_item_to_slot_if_empty(&mut self, item_slot: &mut ItemSlot, target_slot_index: usize) { + let ItemSlot::Present(item) = item_slot else { + return; + }; + let target_slot = self.slot(target_slot_index).unwrap(); + if target_slot.is_empty() && self.may_place(target_slot_index, item) { + let slot_item_limit = self.max_stack_size(target_slot_index); + let new_target_slot_data = item.split(u8::min(slot_item_limit, item.count as u8)); + + let target_slot = self.slot_mut(target_slot_index).unwrap(); + *target_slot = ItemSlot::Present(new_target_slot_data); + item_slot.update_empty(); + } + } +} diff --git a/azalea-inventory/src/slot.rs b/azalea-inventory/src/slot.rs new file mode 100644 index 00000000..cef555d7 --- /dev/null +++ b/azalea-inventory/src/slot.rs @@ -0,0 +1,146 @@ +use azalea_buf::{BufReadError, McBuf, McBufReadable, McBufWritable}; +use azalea_nbt::Nbt; +use std::io::{Cursor, Write}; + +/// Either an item in an inventory or nothing. +#[derive(Debug, Clone, Default, PartialEq)] +pub enum ItemSlot { + #[default] + Empty, + Present(ItemSlotData), +} + +impl ItemSlot { + /// Check if the slot is ItemSlot::Empty, if the count is <= 0, or if the + /// item is air. + /// + /// This is the opposite of [`ItemSlot::is_present`]. + pub fn is_empty(&self) -> bool { + match self { + ItemSlot::Empty => true, + ItemSlot::Present(item) => item.is_empty(), + } + } + /// Check if the slot is not ItemSlot::Empty, if the count is > 0, and if + /// the item is not air. + /// + /// This is the opposite of [`ItemSlot::is_empty`]. + pub fn is_present(&self) -> bool { + !self.is_empty() + } + + /// Return the amount of the item in the slot, or 0 if the slot is empty. + /// + /// Note that it's possible for the count to be zero or negative when the + /// slot is present. + pub fn count(&self) -> i8 { + match self { + ItemSlot::Empty => 0, + ItemSlot::Present(i) => i.count, + } + } + + /// Remove `count` items from this slot, returning the removed items. + pub fn split(&mut self, count: u8) -> ItemSlot { + match self { + ItemSlot::Empty => ItemSlot::Empty, + ItemSlot::Present(i) => { + let returning = i.split(count); + if i.is_empty() { + *self = ItemSlot::Empty; + } + ItemSlot::Present(returning) + } + } + } + + /// Get the `kind` of the item in this slot, or + /// [`azalea_registry::Item::Air`] + pub fn kind(&self) -> azalea_registry::Item { + match self { + ItemSlot::Empty => azalea_registry::Item::Air, + ItemSlot::Present(i) => i.kind, + } + } + + /// Update whether this slot is empty, based on the count. + pub fn update_empty(&mut self) { + if let ItemSlot::Present(i) = self { + if i.is_empty() { + *self = ItemSlot::Empty; + } + } + } +} + +/// An item in an inventory, with a count and NBT. Usually you want [`ItemSlot`] +/// or [`azalea_registry::Item`] instead. +#[derive(Debug, Clone, McBuf, PartialEq)] +pub struct ItemSlotData { + pub kind: azalea_registry::Item, + /// The amount of the item in this slot. + /// + /// The count can be zero or negative, but this is rare. + pub count: i8, + pub nbt: Nbt, +} + +impl ItemSlotData { + /// Remove `count` items from this slot, returning the removed items. + pub fn split(&mut self, count: u8) -> ItemSlotData { + let returning_count = i8::min(count as i8, self.count); + let mut returning = self.clone(); + returning.count = returning_count; + self.count -= returning_count; + returning + } + + /// Check if the count of the item is <= 0 or if the item is air. + pub fn is_empty(&self) -> bool { + self.count <= 0 || self.kind == azalea_registry::Item::Air + } + + /// Whether this item is the same as another item, ignoring the count. + /// + /// ``` + /// # use azalea_inventory::ItemSlotData; + /// # use azalea_registry::Item; + /// let mut a = ItemSlotData { + /// kind: Item::Stone, + /// count: 1, + /// nbt: Default::default(), + /// }; + /// let mut b = ItemSlotData { + /// kind: Item::Stone, + /// count: 2, + /// nbt: Default::default(), + /// }; + /// assert!(a.is_same_item_and_nbt(&b)); + /// + /// b.kind = Item::Dirt; + /// assert!(!a.is_same_item_and_nbt(&b)); + /// ``` + pub fn is_same_item_and_nbt(&self, other: &ItemSlotData) -> bool { + self.kind == other.kind && self.nbt == other.nbt + } +} + +impl McBufReadable for ItemSlot { + fn read_from(buf: &mut Cursor<&[u8]>) -> Result<Self, BufReadError> { + let slot = Option::<ItemSlotData>::read_from(buf)?; + Ok(slot.map_or(ItemSlot::Empty, ItemSlot::Present)) + } +} + +impl McBufWritable for ItemSlot { + fn write_into(&self, buf: &mut impl Write) -> Result<(), std::io::Error> { + match self { + ItemSlot::Empty => false.write_into(buf)?, + ItemSlot::Present(i) => { + true.write_into(buf)?; + i.write_into(buf)?; + } + }; + Ok(()) + } +} |
