From 347180a6e8581c63c811a13951aad8291d0e914a Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 18:21:47 +0100 Subject: [PATCH 1/3] Add client-side prediction for survival inventory clicks --- pomme-client/src/app/core.rs | 21 +- pomme-client/src/app/input.rs | 4 + pomme-client/src/app/phases/in_game.rs | 124 +++++++++- pomme-client/src/net/connection.rs | 5 + pomme-client/src/net/handler.rs | 8 + pomme-client/src/net/mod.rs | 7 + pomme-client/src/player/menu_click.rs | 317 +++++++++++++++++++++++++ pomme-client/src/player/mod.rs | 1 + pomme-client/src/ui/common.rs | 38 +-- pomme-client/src/ui/inventory.rs | 291 ++++++++++++++++++----- 10 files changed, 734 insertions(+), 82 deletions(-) create mode 100644 pomme-client/src/player/menu_click.rs diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 370c2e8f..d92cdf83 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -503,10 +503,27 @@ impl AppCore { game.player.armor = armor; } } - NetworkEvent::InventoryContent { items } => { + NetworkEvent::InventoryContent { + items, + carried, + state_id, + } => { game.player.inventory.set_contents(items); + game.cursor_item = carried; + game.container_state_id = game.container_state_id.max(state_id); + } + NetworkEvent::CursorItem { item } => { + game.cursor_item = item; } - NetworkEvent::InventorySlot { index, item } => { + NetworkEvent::Registries(registries) => { + game.registries = registries; + } + NetworkEvent::InventorySlot { + index, + item, + state_id, + } => { + game.container_state_id = game.container_state_id.max(state_id); game.player.inventory.set_slot(index as usize, item); } NetworkEvent::ChatMessage { spans } => { diff --git a/pomme-client/src/app/input.rs b/pomme-client/src/app/input.rs index e911512e..ac302b65 100644 --- a/pomme-client/src/app/input.rs +++ b/pomme-client/src/app/input.rs @@ -630,6 +630,10 @@ impl InputState { self.left_click.just_pressed } + pub fn right_just_pressed(&self) -> bool { + self.right_click.just_pressed + } + pub fn left_held(&self) -> bool { self.left_click.held } diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 11a70c04..c8bcb897 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -56,6 +56,19 @@ pub struct GameState { pub inventory_open: bool, pub creative_inventory_open: bool, pub creative_state: crate::ui::creative_inventory::CreativeState, + /// Latest container state id from the server, echoed in container clicks. + pub container_state_id: u32, + /// Carried (cursor) stack for the survival inventory, driven by the server. + pub cursor_item: azalea_inventory::ItemStack, + /// Whether the survival inventory was open last frame, to detect the close + /// transition and send a container-close packet. + pub inventory_was_open: bool, + /// Active survival click-drag (button + slots covered), if any. + pub inv_drag: Option<(azalea_inventory::operations::QuickCraftKind, Vec)>, + /// Last survival left click (slot, time) for double-click detection. + pub inv_last_click: Option<(u16, Instant)>, + /// Server registries, for hashing predicted container clicks. + pub registries: Arc, pub chat: ChatState, pub command_tree: Option>, pub tab_list: TabList, @@ -176,6 +189,12 @@ impl GameState { inventory_open: false, creative_inventory_open: false, creative_state: crate::ui::creative_inventory::CreativeState::new(), + container_state_id: 0, + cursor_item: azalea_inventory::ItemStack::Empty, + inventory_was_open: false, + inv_drag: None, + inv_last_click: None, + registries: Arc::new(azalea_core::registry_holder::RegistryHolder::default()), chat: ChatState::new(), command_tree: None, tab_list: TabList::new(), @@ -588,6 +607,82 @@ fn apply_render_distance( game.sync_render_distance(connection, rd); } +/// Predict each survival container click locally (instant UI + drag preview), +/// then send the predicted diff as `HashedStack`es so the server suppresses +/// corrections when the prediction is right (vanilla lockstep). +fn send_container_clicks( + game: &mut GameState, + connection: &ConnectionHandle, + ops: Vec, +) { + use azalea_inventory::ItemStack; + use azalea_inventory::operations::{ + ClickOperation, QuickCraftClick, QuickCraftKind, QuickCraftStatus, + }; + use azalea_protocol::packets::game::s_container_click::{ + HashedStack, ServerboundContainerClick, + }; + + use crate::player::menu_click; + + let mut drag_kind = QuickCraftKind::Left; + let mut drag_slots: Vec = Vec::new(); + for op in &ops { + let (changed, carried): (Vec<(u16, ItemStack)>, ItemStack) = match op { + ClickOperation::QuickCraft(QuickCraftClick { kind, status }) => match status { + QuickCraftStatus::Start => { + drag_kind = kind.clone(); + drag_slots.clear(); + (Vec::new(), game.cursor_item.clone()) + } + QuickCraftStatus::Add { slot } => { + drag_slots.push(*slot); + (Vec::new(), game.cursor_item.clone()) + } + QuickCraftStatus::End => { + let (changed, remainder) = menu_click::drag_distribution( + &game.player.inventory, + &game.cursor_item, + &drag_kind, + &drag_slots, + ); + for (s, item) in &changed { + game.player.inventory.set_slot(*s as usize, item.clone()); + } + game.cursor_item = remainder.clone(); + (changed, remainder) + } + }, + other => { + let changed = menu_click::apply_click( + &mut game.player.inventory, + &mut game.cursor_item, + other, + ); + (changed, game.cursor_item.clone()) + } + }; + + let mut click = ServerboundContainerClick { + container_id: 0, + state_id: game.container_state_id, + slot_num: op.slot_num().map(|s| s as i16).unwrap_or(-999), + button_num: op.button_num(), + click_type: op.click_type(), + changed_slots: Default::default(), + carried_item: HashedStack::from_item_stack(&carried, &game.registries), + }; + for (s, item) in &changed { + click + .changed_slots + .insert(*s, HashedStack::from_item_stack(item, &game.registries)); + } + connection + .packet_tx + .send(ServerboundGamePacket::ContainerClick(click)); + } +} + pub fn update_game( core: &mut AppCore, dt: f32, @@ -1102,18 +1197,27 @@ pub fn update_game( let mut player_preview = None; if game.inventory_open { - let cursor = core.input.cursor_pos(); - let clicked = core.input.left_just_pressed(); + let input = crate::ui::inventory::InventoryInput { + left_pressed: core.input.left_just_pressed(), + right_pressed: core.input.right_just_pressed(), + left_held: core.input.left_held(), + right_held: core.input.right_held(), + shift: core.input.shift_held(), + }; let result = crate::ui::inventory::build_inventory( &mut elements, sw, sh, - cursor, - clicked, + core.input.cursor_pos(), + &input, &game.player.inventory, + &game.cursor_item, + &mut game.inv_drag, + &mut game.inv_last_click, gs, ); close_inventory = result.clicked_outside; + send_container_clicks(game, connection, result.ops); player_preview = Some(result.player_preview); core.input.clear_just_pressed_actions(); } @@ -1390,6 +1494,18 @@ pub fn update_game( core.apply_cursor_grab(&gfx.window, Some(game)); } + // Tell the server when the survival inventory closes so it returns/drops the + // cursor stack. + if game.inventory_was_open && !game.inventory_open { + use azalea_protocol::packets::game::s_container_close::ServerboundContainerClose; + connection + .packet_tx + .send(ServerboundGamePacket::ContainerClose( + ServerboundContainerClose { container_id: 0 }, + )); + } + game.inventory_was_open = game.inventory_open; + match death_action { DeathAction::Respawn => { game.death_confirm = false; diff --git a/pomme-client/src/net/connection.rs b/pomme-client/src/net/connection.rs index 6a91f76e..027ca808 100644 --- a/pomme-client/src/net/connection.rs +++ b/pomme-client/src/net/connection.rs @@ -517,6 +517,11 @@ async fn game_loop( } }); + // Share the registries with the game loop for hashing predicted container + // clicks. + let registry_holder = std::sync::Arc::new(registry_holder); + let _ = event_tx.try_send(NetworkEvent::Registries(registry_holder.clone())); + loop { match reader.read().await { Ok(packet) => { diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 58b025fe..72472c98 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -145,6 +145,13 @@ pub fn handle_game_packet( ClientboundGamePacket::ContainerSetContent(p) if p.container_id == 0 => { let _ = event_tx.try_send(NetworkEvent::InventoryContent { items: p.items.clone(), + carried: p.carried_item.clone(), + state_id: p.state_id, + }); + } + ClientboundGamePacket::SetCursorItem(p) => { + let _ = event_tx.try_send(NetworkEvent::CursorItem { + item: p.contents.clone(), }); } ClientboundGamePacket::ContainerSetSlot(p) @@ -153,6 +160,7 @@ pub fn handle_game_packet( let _ = event_tx.try_send(NetworkEvent::InventorySlot { index: p.slot, item: p.item_stack.clone(), + state_id: p.state_id, }); } ClientboundGamePacket::SetHealth(p) => { diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 9de36661..d4cd9173 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -17,6 +17,7 @@ use crate::entity::components::Position; pub enum NetworkEvent { Connected, + Registries(Arc), BiomeColors { colors: std::collections::HashMap, }, @@ -59,10 +60,16 @@ pub enum NetworkEvent { }, InventoryContent { items: Vec, + carried: ItemStack, + state_id: u32, }, InventorySlot { index: u16, item: ItemStack, + state_id: u32, + }, + CursorItem { + item: ItemStack, }, ChatMessage { spans: Vec, diff --git a/pomme-client/src/player/menu_click.rs b/pomme-client/src/player/menu_click.rs new file mode 100644 index 00000000..112064cc --- /dev/null +++ b/pomme-client/src/player/menu_click.rs @@ -0,0 +1,317 @@ +//! Client-side prediction of survival container clicks: a port of vanilla +//! `AbstractContainerMenu.doClick` for the player inventory (container 0). The +//! server stays authoritative and reconciles, so a wrong prediction only causes +//! a self-correcting glitch, never item dup/loss. + +use azalea_inventory::components::{EquipmentSlot, Equippable}; +use azalea_inventory::item::MaxStackSizeExt; +use azalea_inventory::operations::{ + ClickOperation, PickupClick, QuickCraftKind, QuickMoveClick, ThrowClick, +}; +use azalea_inventory::{ItemStack, ItemStackData, Menu, Player}; + +use crate::player::inventory::Inventory; + +const SLOTS: usize = 46; +const CRAFT_RESULT: u16 = 0; +const HOTBAR_BASE: usize = 36; +const OFFHAND: usize = 45; + +/// Apply a non-drag click to the local player menu, returning the changed +/// slots. Returns empty (mutating nothing) for ops we don't predict, leaving +/// those server-authoritative. +pub fn apply_click( + inv: &mut Inventory, + cursor: &mut ItemStack, + op: &ClickOperation, +) -> Vec<(u16, ItemStack)> { + // Crafting-result clicks need recipe logic; leave them to the server. + if op.slot_num() == Some(CRAFT_RESULT) { + return Vec::new(); + } + let pre: Vec = (0..SLOTS).map(|i| inv.slot(i).clone()).collect(); + let mut menu = build_menu(&pre); + apply_op(&mut menu, cursor, op); + + let mut changed = Vec::new(); + for (i, before) in pre.iter().enumerate() { + let after = menu.slot(i).cloned().unwrap_or(ItemStack::Empty); + if after != *before { + inv.set_slot(i, after.clone()); + changed.push((i as u16, after)); + } + } + changed +} + +/// Distribute the carried stack across the dragged slots (left = even split, +/// right = one each), matching vanilla quick-craft. Returns each covered slot's +/// resulting stack and the remainder left on the cursor. Read-only: used for +/// both the live preview and the release commit. +pub fn drag_distribution( + inv: &Inventory, + cursor: &ItemStack, + kind: &QuickCraftKind, + slots: &[u16], +) -> (Vec<(u16, ItemStack)>, ItemStack) { + let ItemStack::Present(carried) = cursor else { + return (Vec::new(), cursor.clone()); + }; + let eligible: Vec = slots + .iter() + .copied() + .filter(|&s| drag_slot_eligible(inv, cursor, s)) + .collect(); + let n = eligible.len() as i32; + if n == 0 { + return (Vec::new(), cursor.clone()); + } + let max = carried.kind.max_stack_size(); + let place = match kind { + QuickCraftKind::Left => carried.count / n, + QuickCraftKind::Right => 1, + QuickCraftKind::Middle => max, + }; + let mut remaining = carried.count; + let mut changed = Vec::new(); + for &s in &eligible { + let it = inv.slot(s as usize); + let existing = if same_item(cursor, it) { it.count() } else { 0 }; + let new_count = (place + existing).min(max); + remaining -= new_count - existing; + let mut stack = carried.clone(); + stack.count = new_count; + changed.push((s, ItemStack::Present(stack))); + } + (changed, with_count(carried.clone(), remaining)) +} + +/// A drag can cover a slot only if it's empty or holds the same item as the +/// carried stack. +pub fn drag_slot_eligible(inv: &Inventory, cursor: &ItemStack, slot: u16) -> bool { + if cursor.is_empty() { + return false; + } + let it = inv.slot(slot as usize); + it.is_empty() || same_item(cursor, it) +} + +fn build_menu(slots: &[ItemStack]) -> Menu { + let mut menu = Menu::Player(Player::default()); + for (i, item) in slots.iter().enumerate() { + if let Some(s) = menu.slot_mut(i) { + *s = item.clone(); + } + } + menu +} + +fn apply_op(menu: &mut Menu, cursor: &mut ItemStack, op: &ClickOperation) { + match op { + ClickOperation::Pickup(p) => match p { + PickupClick::Left { slot: Some(s) } => pickup_click(menu, cursor, *s as usize, true), + PickupClick::Right { slot: Some(s) } => pickup_click(menu, cursor, *s as usize, false), + PickupClick::Left { slot: None } | PickupClick::LeftOutside => { + *cursor = ItemStack::Empty; // drop whole + } + PickupClick::Right { slot: None } | PickupClick::RightOutside => shrink(cursor, 1), + }, + ClickOperation::QuickMove(q) => { + let s = match q { + QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot } => *slot as usize, + }; + quick_move(menu, s); + } + ClickOperation::Swap(sw) => swap_hotbar(menu, sw.source_slot as usize, sw.target_slot), + ClickOperation::Throw(t) => match t { + ThrowClick::Single { slot } => shrink_slot(menu, *slot as usize, 1), + ThrowClick::All { slot } => put_slot(menu, *slot as usize, ItemStack::Empty), + }, + ClickOperation::PickupAll(_) => pickup_all(menu, cursor), + // Drag is handled at the send site; clone is creative-only. + ClickOperation::QuickCraft(_) | ClickOperation::Clone(_) => {} + } +} + +/// Left/right click on a slot, following vanilla `doClick` PICKUP: `primary` is +/// left (whole stack), otherwise right (one / rounded-up half). Respects +/// `may_place` so restricted slots (armor) reject the wrong item. +fn pickup_click(menu: &mut Menu, cursor: &mut ItemStack, s: usize, primary: bool) { + let mut slot_item = take_slot(menu, s); + let mut carried = std::mem::take(cursor); + if slot_item.is_empty() { + let can_place = carried.as_present().is_some_and(|c| may_place(s, c)); + if can_place { + let amount = if primary { carried.count() } else { 1 }; + safe_insert(&mut slot_item, &mut carried, amount); + } + } else if carried.is_empty() { + let total = slot_item.count(); + let amount = if primary { total } else { (total + 1) / 2 }; + carried = slot_item.split(amount as u32); + } else if carried.as_present().is_some_and(|c| may_place(s, c)) { + if same_item(&carried, &slot_item) { + let amount = if primary { carried.count() } else { 1 }; + safe_insert(&mut slot_item, &mut carried, amount); + } else { + std::mem::swap(&mut carried, &mut slot_item); + } + } else if same_item(&carried, &slot_item) { + // Slot won't accept a placement but holds the same item: pull it into hand. + merge_into(&mut carried, &mut slot_item); + } + put_slot(menu, s, slot_item); + *cursor = carried; +} + +/// Whether `item` may be placed into slot `s`: crafting result never, armor +/// slots only their matching equipment, everything else yes. Mirrors vanilla +/// `mayPlace`. +fn may_place(s: usize, item: &ItemStackData) -> bool { + match s { + 0 => false, + 5..=8 => { + let want = match s { + 5 => EquipmentSlot::Head, + 6 => EquipmentSlot::Chest, + 7 => EquipmentSlot::Legs, + _ => EquipmentSlot::Feet, + }; + item.get_component::().map(|c| c.slot) == Some(want) + } + _ => true, + } +} + +/// Move up to `amount` of `carried` into `slot` (empty or same item), capped to +/// the item's max stack, like vanilla `Slot::safeInsert`. +fn safe_insert(slot: &mut ItemStack, carried: &mut ItemStack, amount: i32) { + let ItemStack::Present(c) = carried.clone() else { + return; + }; + let max = c.kind.max_stack_size(); + let take = match slot { + ItemStack::Empty => amount.min(c.count).min(max), + ItemStack::Present(d) => amount.min(c.count).min((max - d.count).max(0)), + }; + if take <= 0 { + return; + } + match slot { + ItemStack::Present(d) => d.count += take, + ItemStack::Empty => { + let mut d = c; + d.count = take; + *slot = ItemStack::Present(d); + } + } + shrink(carried, take); +} + +/// Shift-click: let azalea's `quick_move_stack` move the stack to its +/// destination, repeating until it stops making progress (vanilla loops too). +fn quick_move(menu: &mut Menu, s: usize) { + for _ in 0..SLOTS { + let before = menu.slot(s).map(ItemStack::count).unwrap_or(0); + if before == 0 { + break; + } + menu.quick_move_stack(s); + if menu.slot(s).map(ItemStack::count).unwrap_or(0) == before { + break; + } + } +} + +fn swap_hotbar(menu: &mut Menu, source: usize, target_slot: u8) { + let target = match target_slot { + 0..=8 => HOTBAR_BASE + target_slot as usize, + 40 => OFFHAND, + _ => return, + }; + if source >= SLOTS { + return; + } + let a = take_slot(menu, source); + let b = take_slot(menu, target); + put_slot(menu, source, b); + put_slot(menu, target, a); +} + +/// Double-click: gather matching items from the player inventory onto the +/// cursor up to a full stack, partial stacks first (vanilla `PICKUP_ALL`). +fn pickup_all(menu: &mut Menu, cursor: &mut ItemStack) { + let ItemStack::Present(carried) = cursor else { + return; + }; + let max = carried.kind.max_stack_size(); + for pass in 0..2 { + for s in 9..OFFHAND { + if cursor.count() >= max { + break; + } + let slot_count = menu.slot(s).map(ItemStack::count).unwrap_or(0); + if slot_count == 0 || !same_item(cursor, menu.slot(s).unwrap()) { + continue; + } + if pass == 0 && slot_count >= max { + continue; // leave full stacks for the second pass + } + let take = (max - cursor.count()).min(slot_count); + shrink_slot(menu, s, take); + if let ItemStack::Present(c) = cursor { + c.count += take; + } + } + } +} + +fn merge_into(dst: &mut ItemStack, src: &mut ItemStack) { + if let (ItemStack::Present(d), ItemStack::Present(s)) = (&mut *dst, &mut *src) { + let moved = (d.kind.max_stack_size() - d.count).max(0).min(s.count); + d.count += moved; + s.count -= moved; + } + src.update_empty(); +} + +fn take_slot(menu: &mut Menu, s: usize) -> ItemStack { + menu.slot_mut(s) + .map(std::mem::take) + .unwrap_or(ItemStack::Empty) +} + +fn put_slot(menu: &mut Menu, s: usize, item: ItemStack) { + if let Some(sl) = menu.slot_mut(s) { + *sl = item; + } +} + +fn shrink(item: &mut ItemStack, n: i32) { + if let ItemStack::Present(d) = item { + d.count -= n; + } + item.update_empty(); +} + +fn shrink_slot(menu: &mut Menu, s: usize, n: i32) { + if let Some(sl) = menu.slot_mut(s) { + shrink(sl, n); + } +} + +fn same_item(a: &ItemStack, b: &ItemStack) -> bool { + match (a, b) { + (ItemStack::Present(x), ItemStack::Present(y)) => x.is_same_item_and_components(y), + _ => false, + } +} + +fn with_count(mut data: ItemStackData, count: i32) -> ItemStack { + if count > 0 { + data.count = count; + ItemStack::Present(data) + } else { + ItemStack::Empty + } +} diff --git a/pomme-client/src/player/mod.rs b/pomme-client/src/player/mod.rs index dbe0447f..fa1d8e54 100644 --- a/pomme-client/src/player/mod.rs +++ b/pomme-client/src/player/mod.rs @@ -1,5 +1,6 @@ pub mod interaction; pub mod inventory; +pub mod menu_click; pub mod tab_list; use glam::{dvec2, dvec3}; diff --git a/pomme-client/src/ui/common.rs b/pomme-client/src/ui/common.rs index 1bea5dd0..a4489b79 100644 --- a/pomme-client/src/ui/common.rs +++ b/pomme-client/src/ui/common.rs @@ -1,4 +1,4 @@ -use azalea_inventory::ItemStack; +use azalea_inventory::{ItemStack, ItemStackData}; use crate::benchmark::UploadStatus; use crate::player::inventory::item_resource_name; @@ -243,19 +243,7 @@ pub fn push_slot( }); } } - ItemStack::Present(data) => { - elements.push(MenuElement::ItemIcon { - x, - y, - w: size, - h: size, - item_name: item_resource_name(data.kind), - tint: WHITE, - }); - if data.count > 1 { - push_item_count(elements, x, y, size, scale, data.count); - } - } + ItemStack::Present(data) => push_item_icon(elements, x, y, size, scale, data), } if hovered { elements.push(highlight(SpriteId::SlotHighlightFront)); @@ -263,6 +251,28 @@ pub fn push_slot( hovered } +/// Draws an item icon (and its stack count when > 1) at the given position. +pub fn push_item_icon( + elements: &mut Vec, + x: f32, + y: f32, + size: f32, + scale: f32, + data: &ItemStackData, +) { + elements.push(MenuElement::ItemIcon { + x, + y, + w: size, + h: size, + item_name: item_resource_name(data.kind), + tint: WHITE, + }); + if data.count > 1 { + push_item_count(elements, x, y, size, scale, data.count); + } +} + #[allow(clippy::too_many_arguments)] pub fn push_button( elements: &mut Vec, diff --git a/pomme-client/src/ui/inventory.rs b/pomme-client/src/ui/inventory.rs index 78e185c3..b9a01f51 100644 --- a/pomme-client/src/ui/inventory.rs +++ b/pomme-client/src/ui/inventory.rs @@ -1,15 +1,35 @@ +use std::collections::HashMap; +use std::time::Instant; + use azalea_inventory::ItemStack; +use azalea_inventory::operations::{ + ClickOperation, PickupAllClick, PickupClick, QuickCraftClick, QuickCraftKind, QuickCraftStatus, + QuickMoveClick, +}; use super::common::{ FONT_SIZE, SLOT_LABEL_COLOR, SLOT_SIZE, SLOT_STRIDE, WHITE, hit_test, push_gradient_overlay, - push_slot, + push_item_icon, push_slot, }; use crate::player::inventory::Inventory; +use crate::player::menu_click; use crate::renderer::PlayerPreview; use crate::renderer::pipelines::menu_overlay::{MenuElement, SpriteId}; const INV_TEX_W: f32 = 176.0; const INV_TEX_H: f32 = 166.0; +const DOUBLE_CLICK_MS: u128 = 250; + +// Vanilla player-menu slot indices. +const SLOT_CRAFT_RESULT: u16 = 0; +const SLOT_CRAFT_BASE: u16 = 1; +const SLOT_ARMOR_BASE: u16 = 5; +const SLOT_MAIN_BASE: u16 = 9; +const SLOT_HOTBAR_BASE: u16 = 36; +const SLOT_OFFHAND: u16 = 45; + +/// Active click-drag: which button, and the slots covered so far. +pub type DragState = (QuickCraftKind, Vec); struct SlotPos { x: f32, @@ -25,16 +45,32 @@ const ARMOR_EMPTY_SPRITES: [SpriteId; 4] = [ pub struct InventoryResult { pub clicked_outside: bool, + /// Container-click operations to send this frame (usually 0-1; a drag + /// release emits a start/add.../end sequence). + pub ops: Vec, pub player_preview: PlayerPreview, } +/// Input for the survival inventory this frame. +pub struct InventoryInput { + pub left_pressed: bool, + pub right_pressed: bool, + pub left_held: bool, + pub right_held: bool, + pub shift: bool, +} + +#[allow(clippy::too_many_arguments)] pub fn build_inventory( elements: &mut Vec, screen_w: f32, screen_h: f32, cursor: (f32, f32), - clicked: bool, + input: &InventoryInput, inventory: &Inventory, + cursor_item: &ItemStack, + drag: &mut Option, + last_click: &mut Option<(u16, Instant)>, gs: f32, ) -> InventoryResult { let scale = gs.min(screen_w / INV_TEX_W).min(screen_h / INV_TEX_H); @@ -61,7 +97,6 @@ pub fn build_inventory( }); let fs = FONT_SIZE * scale; - elements.push(MenuElement::TextFlat { x: ox + 97.0 * scale, y: oy + 6.0 * scale, @@ -70,61 +105,63 @@ pub fn build_inventory( color: SLOT_LABEL_COLOR, }); + // Live drag preview: what each covered slot would receive and the remainder + // left on the cursor. Read-only; the real change happens on release. + let drag_preview: Option<(HashMap, ItemStack)> = + drag.as_ref().map(|(kind, slots)| { + let (changed, remainder) = + menu_click::drag_distribution(inventory, cursor_item, kind, slots); + (changed.into_iter().collect(), remainder) + }); + + let mut hovered = None; + let mut slot = |elements: &mut Vec, pos: SlotPos, item: &ItemStack, empty, num| { + let shown = drag_preview + .as_ref() + .and_then(|(m, _)| m.get(&num)) + .unwrap_or(item); + hovered = hovered.or(build_slot( + elements, ox, oy, scale, &pos, cursor, shown, empty, num, + )); + }; + let hotbar = inventory.hotbar_slots(); for col in 0..9usize { - let slot = SlotPos { + let pos = SlotPos { x: 8.0 + col as f32 * SLOT_STRIDE, y: 142.0, }; - build_slot( - elements, - ox, - oy, - scale, - &slot, - cursor, - hotbar.get(col).unwrap_or(&ItemStack::Empty), - None, - ); + let item = hotbar.get(col).unwrap_or(&ItemStack::Empty); + slot(elements, pos, item, None, SLOT_HOTBAR_BASE + col as u16); } let main = inventory.main_slots(); for row in 0..3usize { for col in 0..9usize { let idx = row * 9 + col; - let slot = SlotPos { + let pos = SlotPos { x: 8.0 + col as f32 * SLOT_STRIDE, y: 84.0 + row as f32 * SLOT_STRIDE, }; - build_slot( - elements, - ox, - oy, - scale, - &slot, - cursor, - main.get(idx).unwrap_or(&ItemStack::Empty), - None, - ); + let item = main.get(idx).unwrap_or(&ItemStack::Empty); + slot(elements, pos, item, None, SLOT_MAIN_BASE + idx as u16); } } let armor = inventory.armor_slots(); let armor_ys = [8.0, 26.0, 44.0, 62.0]; for i in 0..4usize { - let slot = SlotPos { + let pos = SlotPos { x: 8.0, y: armor_ys[i], }; - build_slot( + let item = armor.get(i).unwrap_or(&ItemStack::Empty); + slot( elements, - ox, - oy, - scale, - &slot, - cursor, - armor.get(i).unwrap_or(&ItemStack::Empty), + pos, + item, Some(ARMOR_EMPTY_SPRITES[i]), + SLOT_ARMOR_BASE + i as u16, ); } @@ -132,45 +169,29 @@ pub fn build_inventory( for row in 0..2usize { for col in 0..2usize { let idx = row * 2 + col; - let slot = SlotPos { + let pos = SlotPos { x: 98.0 + col as f32 * SLOT_STRIDE, y: 18.0 + row as f32 * SLOT_STRIDE, }; - build_slot( - elements, - ox, - oy, - scale, - &slot, - cursor, - craft_in.get(idx).unwrap_or(&ItemStack::Empty), - None, - ); + let item = craft_in.get(idx).unwrap_or(&ItemStack::Empty); + slot(elements, pos, item, None, SLOT_CRAFT_BASE + idx as u16); } } - let craft_out_slot = SlotPos { x: 154.0, y: 28.0 }; - build_slot( + slot( elements, - ox, - oy, - scale, - &craft_out_slot, - cursor, + SlotPos { x: 154.0, y: 28.0 }, inventory.craft_output(), None, + SLOT_CRAFT_RESULT, ); - let offhand_slot = SlotPos { x: 77.0, y: 62.0 }; - build_slot( + slot( elements, - ox, - oy, - scale, - &offhand_slot, - cursor, + SlotPos { x: 77.0, y: 62.0 }, inventory.offhand(), Some(SpriteId::EmptyShield), + SLOT_OFFHAND, ); let book_x = ox + 104.0 * scale; @@ -189,9 +210,35 @@ pub fn build_inventory( tint: WHITE, }); - let outside = cursor.0 < ox || cursor.0 > ox + inv_w || cursor.1 < oy || cursor.1 > oy + inv_h; + // The carried stack rides the cursor, on top of everything; while dragging + // it shows the un-distributed remainder. + let cursor_stack = drag_preview.as_ref().map(|(_, r)| r).unwrap_or(cursor_item); + if let ItemStack::Present(data) = cursor_stack { + let size = SLOT_SIZE * scale; + push_item_icon( + elements, + cursor.0 - size / 2.0, + cursor.1 - size / 2.0, + size, + scale, + data, + ); + } + + let carrying = matches!(cursor_item, ItemStack::Present(_)); + let (ops, clicked_outside) = resolve_gesture( + input, + hovered, + carrying, + inventory, + cursor_item, + drag, + last_click, + ); + InventoryResult { - clicked_outside: clicked && outside, + clicked_outside, + ops, player_preview: PlayerPreview { rect: [ ox + 26.0 * scale, @@ -205,6 +252,121 @@ pub fn build_inventory( } } +/// Turns this frame's input + hover into container-click operations, driving +/// the drag state machine. The server applies and resyncs, so no local +/// prediction. +#[allow(clippy::too_many_arguments)] +fn resolve_gesture( + input: &InventoryInput, + hovered: Option, + carrying: bool, + inventory: &Inventory, + cursor_item: &ItemStack, + drag: &mut Option, + last_click: &mut Option<(u16, Instant)>, +) -> (Vec, bool) { + let mut ops = Vec::new(); + + if let Some((kind, slots)) = drag { + let held = matches!( + (&kind, input.left_held, input.right_held), + (QuickCraftKind::Left, true, _) | (QuickCraftKind::Right, _, true) + ); + if held { + // Match vanilla's accumulation so our slot set (and split) equals the + // server's: only eligible slots, and only while items remain to share. + if let Some(slot) = hovered + && !slots.contains(&slot) + && (cursor_item.count() as usize) > slots.len() + && menu_click::drag_slot_eligible(inventory, cursor_item, slot) + { + slots.push(slot); + } + return (ops, false); + } + // Released: distribute across 2+ slots, else treat as a single click. + let kind = kind.clone(); + let slots = std::mem::take(slots); + *drag = None; + if slots.len() >= 2 { + ops.push(quick_craft(&kind, QuickCraftStatus::Start)); + for s in slots { + ops.push(quick_craft(&kind, QuickCraftStatus::Add { slot: s })); + } + ops.push(quick_craft(&kind, QuickCraftStatus::End)); + } else if let Some(&s) = slots.first() { + ops.push(pickup(&kind, Some(s))); + } + return (ops, false); + } + + if !(input.left_pressed || input.right_pressed) { + return (ops, false); + } + let kind = if input.left_pressed { + QuickCraftKind::Left + } else { + QuickCraftKind::Right + }; + + let Some(slot) = hovered else { + // Outside click: drop the cursor stack, else request a close. + if carrying { + ops.push(ClickOperation::Pickup(match kind { + QuickCraftKind::Left => PickupClick::LeftOutside, + _ => PickupClick::RightOutside, + })); + return (ops, false); + } + return (ops, input.left_pressed); + }; + + // Timing-based like vanilla; the server only gathers if it has a cursor item + // (avoids depending on the round-trip-lagged local carried state). + let double = input.left_pressed + && matches!(last_click, Some((s, t)) if *s == slot && t.elapsed().as_millis() <= DOUBLE_CLICK_MS); + + if input.shift { + ops.push(ClickOperation::QuickMove(match kind { + QuickCraftKind::Left => QuickMoveClick::Left { slot }, + _ => QuickMoveClick::Right { slot }, + })); + } else if double { + ops.push(ClickOperation::PickupAll(PickupAllClick { + slot, + reversed: false, + })); + *last_click = None; + } else if carrying { + // Start a drag; a single-slot release becomes a normal click. + *drag = Some((kind, vec![slot])); + if input.left_pressed { + *last_click = Some((slot, Instant::now())); + } + } else { + ops.push(pickup(&kind, Some(slot))); + if input.left_pressed { + *last_click = Some((slot, Instant::now())); + } + } + (ops, false) +} + +fn pickup(kind: &QuickCraftKind, slot: Option) -> ClickOperation { + ClickOperation::Pickup(match kind { + QuickCraftKind::Left => PickupClick::Left { slot }, + _ => PickupClick::Right { slot }, + }) +} + +fn quick_craft(kind: &QuickCraftKind, status: QuickCraftStatus) -> ClickOperation { + ClickOperation::QuickCraft(QuickCraftClick { + kind: kind.clone(), + status, + }) +} + +/// Draws a slot; returns its number when hovered (regardless of click). #[allow(clippy::too_many_arguments)] fn build_slot( elements: &mut Vec, @@ -215,9 +377,14 @@ fn build_slot( cursor: (f32, f32), item: &ItemStack, empty_sprite: Option, -) { + slot_num: u16, +) -> Option { let x = ox + slot.x * scale; let y = oy + slot.y * scale; let size = SLOT_SIZE * scale; - push_slot(elements, x, y, size, scale, cursor, item, empty_sprite); + if push_slot(elements, x, y, size, scale, cursor, item, empty_sprite) { + Some(slot_num) + } else { + None + } } From 75ad58e72443a31f199a2a41908a5a34c89f16d1 Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 20:00:30 +0100 Subject: [PATCH 2/3] cleanup --- pomme-client/src/app/core.rs | 4 +- pomme-client/src/app/phases/in_game.rs | 5 +- pomme-client/src/player/inventory.rs | 14 +++--- pomme-client/src/player/menu_click.rs | 67 ++++++++++--------------- pomme-client/src/ui/inventory.rs | 68 +++++++++++++++++--------- 5 files changed, 85 insertions(+), 73 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index d92cdf83..d4c24a6b 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -510,7 +510,7 @@ impl AppCore { } => { game.player.inventory.set_contents(items); game.cursor_item = carried; - game.container_state_id = game.container_state_id.max(state_id); + game.container_state_id = state_id; } NetworkEvent::CursorItem { item } => { game.cursor_item = item; @@ -523,7 +523,7 @@ impl AppCore { item, state_id, } => { - game.container_state_id = game.container_state_id.max(state_id); + game.container_state_id = state_id; game.player.inventory.set_slot(index as usize, item); } NetworkEvent::ChatMessage { spans } => { diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index c8bcb897..3e94dde9 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1495,7 +1495,8 @@ pub fn update_game( } // Tell the server when the survival inventory closes so it returns/drops the - // cursor stack. + // cursor stack, and forget any in-flight gesture so a stale drag can't + // commit on reopen. if game.inventory_was_open && !game.inventory_open { use azalea_protocol::packets::game::s_container_close::ServerboundContainerClose; connection @@ -1503,6 +1504,8 @@ pub fn update_game( .send(ServerboundGamePacket::ContainerClose( ServerboundContainerClose { container_id: 0 }, )); + game.inv_drag = None; + game.inv_last_click = None; } game.inventory_was_open = game.inventory_open; diff --git a/pomme-client/src/player/inventory.rs b/pomme-client/src/player/inventory.rs index ea376f96..05e908f8 100644 --- a/pomme-client/src/player/inventory.rs +++ b/pomme-client/src/player/inventory.rs @@ -1,17 +1,17 @@ use azalea_inventory::ItemStack; use azalea_registry::builtin::ItemKind; -const PLAYER_SLOTS: usize = 46; -const HOTBAR_START: usize = 36; +pub const PLAYER_SLOTS: usize = 46; +pub const HOTBAR_START: usize = 36; const HOTBAR_END: usize = 45; -const MAIN_START: usize = 9; +pub const MAIN_START: usize = 9; const MAIN_END: usize = 36; -const ARMOR_START: usize = 5; +pub const ARMOR_START: usize = 5; const ARMOR_END: usize = 9; -const CRAFT_INPUT_START: usize = 1; +pub const CRAFT_INPUT_START: usize = 1; const CRAFT_INPUT_END: usize = 5; -const CRAFT_OUTPUT: usize = 0; -const OFFHAND: usize = 45; +pub const CRAFT_OUTPUT: usize = 0; +pub const OFFHAND: usize = 45; pub struct Inventory { slots: Vec, diff --git a/pomme-client/src/player/menu_click.rs b/pomme-client/src/player/menu_click.rs index 112064cc..838e57e6 100644 --- a/pomme-client/src/player/menu_click.rs +++ b/pomme-client/src/player/menu_click.rs @@ -5,17 +5,10 @@ use azalea_inventory::components::{EquipmentSlot, Equippable}; use azalea_inventory::item::MaxStackSizeExt; -use azalea_inventory::operations::{ - ClickOperation, PickupClick, QuickCraftKind, QuickMoveClick, ThrowClick, -}; +use azalea_inventory::operations::{ClickOperation, PickupClick, QuickCraftKind, QuickMoveClick}; use azalea_inventory::{ItemStack, ItemStackData, Menu, Player}; -use crate::player::inventory::Inventory; - -const SLOTS: usize = 46; -const CRAFT_RESULT: u16 = 0; -const HOTBAR_BASE: usize = 36; -const OFFHAND: usize = 45; +use crate::player::inventory::{CRAFT_OUTPUT, Inventory, PLAYER_SLOTS}; /// Apply a non-drag click to the local player menu, returning the changed /// slots. Returns empty (mutating nothing) for ops we don't predict, leaving @@ -26,10 +19,10 @@ pub fn apply_click( op: &ClickOperation, ) -> Vec<(u16, ItemStack)> { // Crafting-result clicks need recipe logic; leave them to the server. - if op.slot_num() == Some(CRAFT_RESULT) { + if op.slot_num() == Some(CRAFT_OUTPUT as u16) { return Vec::new(); } - let pre: Vec = (0..SLOTS).map(|i| inv.slot(i).clone()).collect(); + let pre: Vec = (0..PLAYER_SLOTS).map(|i| inv.slot(i).clone()).collect(); let mut menu = build_menu(&pre); apply_op(&mut menu, cursor, op); @@ -86,10 +79,14 @@ pub fn drag_distribution( (changed, with_count(carried.clone(), remaining)) } -/// A drag can cover a slot only if it's empty or holds the same item as the -/// carried stack. +/// A drag can cover a slot only if the item may go there (vanilla gates +/// quick-craft slots on `mayPlace`) and it's empty or holds the same item as +/// the carried stack. pub fn drag_slot_eligible(inv: &Inventory, cursor: &ItemStack, slot: u16) -> bool { - if cursor.is_empty() { + let ItemStack::Present(carried) = cursor else { + return false; + }; + if !may_place(slot as usize, carried) { return false; } let it = inv.slot(slot as usize); @@ -122,14 +119,12 @@ fn apply_op(menu: &mut Menu, cursor: &mut ItemStack, op: &ClickOperation) { }; quick_move(menu, s); } - ClickOperation::Swap(sw) => swap_hotbar(menu, sw.source_slot as usize, sw.target_slot), - ClickOperation::Throw(t) => match t { - ThrowClick::Single { slot } => shrink_slot(menu, *slot as usize, 1), - ThrowClick::All { slot } => put_slot(menu, *slot as usize, ItemStack::Empty), - }, ClickOperation::PickupAll(_) => pickup_all(menu, cursor), - // Drag is handled at the send site; clone is creative-only. - ClickOperation::QuickCraft(_) | ClickOperation::Clone(_) => {} + // Drag is handled at the send site; the rest have no UI path yet. + ClickOperation::Swap(_) + | ClickOperation::Throw(_) + | ClickOperation::QuickCraft(_) + | ClickOperation::Clone(_) => {} } } @@ -153,7 +148,11 @@ fn pickup_click(menu: &mut Menu, cursor: &mut ItemStack, s: usize, primary: bool if same_item(&carried, &slot_item) { let amount = if primary { carried.count() } else { 1 }; safe_insert(&mut slot_item, &mut carried, amount); - } else { + } else if carried + .as_present() + .is_some_and(|c| c.count <= c.kind.max_stack_size()) + { + // Vanilla swaps only when the carried stack fits the slot's limit. std::mem::swap(&mut carried, &mut slot_item); } } else if same_item(&carried, &slot_item) { @@ -211,7 +210,7 @@ fn safe_insert(slot: &mut ItemStack, carried: &mut ItemStack, amount: i32) { /// Shift-click: let azalea's `quick_move_stack` move the stack to its /// destination, repeating until it stops making progress (vanilla loops too). fn quick_move(menu: &mut Menu, s: usize) { - for _ in 0..SLOTS { + for _ in 0..PLAYER_SLOTS { let before = menu.slot(s).map(ItemStack::count).unwrap_or(0); if before == 0 { break; @@ -223,30 +222,16 @@ fn quick_move(menu: &mut Menu, s: usize) { } } -fn swap_hotbar(menu: &mut Menu, source: usize, target_slot: u8) { - let target = match target_slot { - 0..=8 => HOTBAR_BASE + target_slot as usize, - 40 => OFFHAND, - _ => return, - }; - if source >= SLOTS { - return; - } - let a = take_slot(menu, source); - let b = take_slot(menu, target); - put_slot(menu, source, b); - put_slot(menu, target, a); -} - -/// Double-click: gather matching items from the player inventory onto the -/// cursor up to a full stack, partial stacks first (vanilla `PICKUP_ALL`). +/// Double-click: gather matching items from every slot but the craft result +/// onto the cursor up to a full stack, partial stacks first (vanilla +/// `PICKUP_ALL` + `InventoryMenu.canTakeItemForPickAll`). fn pickup_all(menu: &mut Menu, cursor: &mut ItemStack) { let ItemStack::Present(carried) = cursor else { return; }; let max = carried.kind.max_stack_size(); for pass in 0..2 { - for s in 9..OFFHAND { + for s in (0..PLAYER_SLOTS).filter(|&s| s != CRAFT_OUTPUT) { if cursor.count() >= max { break; } diff --git a/pomme-client/src/ui/inventory.rs b/pomme-client/src/ui/inventory.rs index b9a01f51..20aa2d21 100644 --- a/pomme-client/src/ui/inventory.rs +++ b/pomme-client/src/ui/inventory.rs @@ -11,7 +11,7 @@ use super::common::{ FONT_SIZE, SLOT_LABEL_COLOR, SLOT_SIZE, SLOT_STRIDE, WHITE, hit_test, push_gradient_overlay, push_item_icon, push_slot, }; -use crate::player::inventory::Inventory; +use crate::player::inventory::{self, Inventory}; use crate::player::menu_click; use crate::renderer::PlayerPreview; use crate::renderer::pipelines::menu_overlay::{MenuElement, SpriteId}; @@ -20,13 +20,13 @@ const INV_TEX_W: f32 = 176.0; const INV_TEX_H: f32 = 166.0; const DOUBLE_CLICK_MS: u128 = 250; -// Vanilla player-menu slot indices. -const SLOT_CRAFT_RESULT: u16 = 0; -const SLOT_CRAFT_BASE: u16 = 1; -const SLOT_ARMOR_BASE: u16 = 5; -const SLOT_MAIN_BASE: u16 = 9; -const SLOT_HOTBAR_BASE: u16 = 36; -const SLOT_OFFHAND: u16 = 45; +// Vanilla player-menu slot indices, as u16 for click ops. +const SLOT_CRAFT_RESULT: u16 = inventory::CRAFT_OUTPUT as u16; +const SLOT_CRAFT_BASE: u16 = inventory::CRAFT_INPUT_START as u16; +const SLOT_ARMOR_BASE: u16 = inventory::ARMOR_START as u16; +const SLOT_MAIN_BASE: u16 = inventory::MAIN_START as u16; +const SLOT_HOTBAR_BASE: u16 = inventory::HOTBAR_START as u16; +const SLOT_OFFHAND: u16 = inventory::OFFHAND as u16; /// Active click-drag: which button, and the slots covered so far. pub type DragState = (QuickCraftKind, Vec); @@ -225,10 +225,12 @@ pub fn build_inventory( ); } - let carrying = matches!(cursor_item, ItemStack::Present(_)); + let carrying = cursor_item.is_present(); + let outside = !hit_test(cursor, [ox, oy, inv_w, inv_h]); let (ops, clicked_outside) = resolve_gesture( input, hovered, + outside, carrying, inventory, cursor_item, @@ -259,6 +261,7 @@ pub fn build_inventory( fn resolve_gesture( input: &InventoryInput, hovered: Option, + outside: bool, carrying: bool, inventory: &Inventory, cursor_item: &ItemStack, @@ -284,7 +287,9 @@ fn resolve_gesture( } return (ops, false); } - // Released: distribute across 2+ slots, else treat as a single click. + // Released: distribute across 2+ slots; one covered slot converts to a + // normal click (vanilla quickCraftToSlots), none falls back to a click + // wherever the cursor is now (vanilla mouseReleased, -999 outside). let kind = kind.clone(); let slots = std::mem::take(slots); *drag = None; @@ -296,6 +301,13 @@ fn resolve_gesture( ops.push(quick_craft(&kind, QuickCraftStatus::End)); } else if let Some(&s) = slots.first() { ops.push(pickup(&kind, Some(s))); + } else if carrying { + // Vanilla only falls back to a click while still carrying. + if let Some(s) = hovered { + ops.push(pickup(&kind, Some(s))); + } else if outside { + ops.push(pickup(&kind, None)); + } } return (ops, false); } @@ -309,16 +321,22 @@ fn resolve_gesture( QuickCraftKind::Right }; - let Some(slot) = hovered else { + if outside { // Outside click: drop the cursor stack, else request a close. if carrying { - ops.push(ClickOperation::Pickup(match kind { - QuickCraftKind::Left => PickupClick::LeftOutside, - _ => PickupClick::RightOutside, - })); + ops.push(pickup(&kind, None)); return (ops, false); } return (ops, input.left_pressed); + } + + let Some(slot) = hovered else { + // Panel background: no-op, but a carrying press still enters the drag + // state machine like vanilla (with no slots covered yet). + if carrying { + *drag = Some((kind, Vec::new())); + } + return (ops, false); }; // Timing-based like vanilla; the server only gathers if it has a cursor item @@ -337,14 +355,20 @@ fn resolve_gesture( reversed: false, })); *last_click = None; - } else if carrying { - // Start a drag; a single-slot release becomes a normal click. - *drag = Some((kind, vec![slot])); - if input.left_pressed { - *last_click = Some((slot, Instant::now())); - } } else { - ops.push(pickup(&kind, Some(slot))); + if carrying { + // Start a drag; only an eligible slot joins the covered set (vanilla + // gates every quick-craft slot on mayPlace). A single-slot or empty + // set resolves to a normal click on release. + let slots = if menu_click::drag_slot_eligible(inventory, cursor_item, slot) { + vec![slot] + } else { + Vec::new() + }; + *drag = Some((kind, slots)); + } else { + ops.push(pickup(&kind, Some(slot))); + } if input.left_pressed { *last_click = Some((slot, Instant::now())); } From ee9c1541c86a3f8445f5dbf31ee5cab192e2ff7d Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 20:06:23 +0100 Subject: [PATCH 3/3] fix duplicate right_just_pressed after merge --- pomme-client/src/app/input.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pomme-client/src/app/input.rs b/pomme-client/src/app/input.rs index a48ba575..fc2382cd 100644 --- a/pomme-client/src/app/input.rs +++ b/pomme-client/src/app/input.rs @@ -658,10 +658,6 @@ impl InputState { self.middle_click.just_pressed } - pub fn right_just_pressed(&self) -> bool { - self.right_click.just_pressed - } - pub fn on_cursor_moved(&mut self, x: f32, y: f32) { self.cursor_pos = (x, y); self.cursor_moved = true;