feat: add item stack splitting via Shift+right-click

Implements CMSG_SPLIT_ITEM (0x10E) with a slider popup for choosing
split count. Auto-finds empty destination slot across backpack and bags.
Shift+right-click on stackable items (count > 1) opens split dialog;
non-stackable items still get the destroy confirmation.
This commit is contained in:
Kelsi 2026-03-18 11:07:27 -07:00
parent 17d652947c
commit 9b32a328c3
6 changed files with 122 additions and 15 deletions

View file

@ -2029,6 +2029,7 @@ public:
void openItemBySlot(int backpackIndex);
void openItemInBag(int bagIndex, int slotIndex);
void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1);
void splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count);
void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot);
void swapBagSlots(int srcBagIndex, int dstBagIndex);
void useItemById(uint32_t itemId);

View file

@ -2046,6 +2046,13 @@ public:
static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot);
};
/** CMSG_SPLIT_ITEM packet builder */
class SplitItemPacket {
public:
static network::Packet build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count);
};
/** CMSG_SWAP_INV_ITEM packet builder */
class SwapInvItemPacket {
public:

View file

@ -187,6 +187,14 @@ private:
uint8_t destroyCount_ = 1;
std::string destroyItemName_;
// Stack split popup state
bool splitConfirmOpen_ = false;
uint8_t splitBag_ = 0xFF;
uint8_t splitSlot_ = 0;
int splitMax_ = 1;
int splitCount_ = 1;
std::string splitItemName_;
// Pending chat item link from shift-click
std::string pendingChatItemLink_;

View file

@ -21177,6 +21177,40 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
socket->send(packet);
}
void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
if (count == 0) return;
// Find a free slot for the split destination: try backpack first, then bags
int freeBp = inventory.findFreeBackpackSlot();
if (freeBp >= 0) {
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeBp);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
// Try equipped bags
for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) {
int bagSize = inventory.getBagSize(b);
for (int s = 0; s < bagSize; s++) {
if (inventory.getBagSlot(b, s).empty()) {
uint8_t dstBag = static_cast<uint8_t>(19 + b);
uint8_t dstSlot = static_cast<uint8_t>(s);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=", (int)dstBag,
" slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
}
}
addSystemChatMessage("Cannot split: no free inventory slots.");
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);

View file

@ -4358,6 +4358,17 @@ network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t s
return packet;
}
network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count) {
network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM));
packet.writeUInt8(srcBag);
packet.writeUInt8(srcSlot);
packet.writeUInt8(dstBag);
packet.writeUInt8(dstSlot);
packet.writeUInt8(count);
return packet;
}
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM));
packet.writeUInt8(srcSlot);

View file

@ -871,6 +871,35 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
ImGui::EndPopup();
}
// Stack split popup
if (splitConfirmOpen_) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
ImGui::OpenPopup("##SplitStack");
splitConfirmOpen_ = false;
}
if (ImGui::BeginPopup("##SplitStack", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Text("Split %s", splitItemName_.c_str());
ImGui::Spacing();
ImGui::SetNextItemWidth(120.0f);
ImGui::SliderInt("##splitcount", &splitCount_, 1, splitMax_ - 1);
ImGui::Spacing();
if (ImGui::Button("OK", ImVec2(55, 0))) {
if (gameHandler_ && splitCount_ > 0 && splitCount_ < splitMax_) {
gameHandler_->splitItem(splitBag_, splitSlot_, static_cast<uint8_t>(splitCount_));
}
splitItemName_.clear();
inventoryDirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(55, 0))) {
splitItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Draw held item at cursor
renderHeldItem();
}
@ -2302,22 +2331,39 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Shift+right-click: open destroy confirmation for non-quest items
// Shift+right-click: split stack (if stackable >1) or destroy confirmation
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) &&
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) {
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0) {
if (item.stackCount > 1 && item.maxStack > 1) {
// Open split popup for stackable items
splitConfirmOpen_ = true;
splitItemName_ = item.name;
splitMax_ = static_cast<int>(item.stackCount);
splitCount_ = splitMax_ / 2;
if (splitCount_ < 1) splitCount_ = 1;
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
splitBag_ = 0xFF;
splitSlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
splitBag_ = static_cast<uint8_t>(19 + bagIndex);
splitSlot_ = static_cast<uint8_t>(bagSlotIndex);
}
} else if (item.bindType != 4) {
// Destroy confirmation for non-quest, non-stackable items
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
}
}
}