From fdc614902b08df99667fc1d00ca4d29701681588 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Feb 2026 18:34:45 -0800 Subject: [PATCH] Fix online interactions, UI, and inventory sync --- include/core/application.hpp | 3 + include/game/game_handler.hpp | 13 ++ include/game/opcodes.hpp | 2 + include/game/world_packets.hpp | 18 ++ include/rendering/character_renderer.hpp | 1 + include/ui/game_screen.hpp | 4 + include/ui/inventory_screen.hpp | 1 + src/core/application.cpp | 36 +++- src/game/game_handler.cpp | 190 ++++++++++++++++----- src/game/world_packets.cpp | 27 +++ src/rendering/camera_controller.cpp | 4 +- src/rendering/character_renderer.cpp | 28 ++- src/ui/game_screen.cpp | 208 +++++++++++++++++------ src/ui/inventory_screen.cpp | 133 +++++++++++---- 14 files changed, 525 insertions(+), 143 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index d961c9b1..9f3c3b30 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -67,6 +67,9 @@ public: // Teleport to a spawn preset location (single-player only) void teleportTo(int presetIndex); + // Render bounds lookup (for click targeting / selection) + bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const; + // Character skin composite state (saved at spawn for re-compositing on equipment change) const std::string& getBodySkinPath() const { return bodySkinPath_; } const std::vector& getUnderwearPaths() const { return underwearPaths_; } diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 547bb477..8ad451f6 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -110,6 +110,7 @@ public: using CharDeleteCallback = std::function; void setCharDeleteCallback(CharDeleteCallback cb) { charDeleteCallback_ = std::move(cb); } + uint8_t getLastCharDeleteResult() const { return lastCharDeleteResult_; } /** * Select and log in with a character @@ -214,6 +215,7 @@ public: void startAutoAttack(uint64_t targetGuid); void stopAutoAttack(); bool isAutoAttacking() const { return autoAttacking; } + bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); @@ -332,6 +334,7 @@ public: void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); void closeLoot(); + void activateSpiritHealer(uint64_t npcGuid); bool isLootWindowOpen() const { return lootWindowOpen; } const LootResponseData& getCurrentLoot() const { return currentLoot; } @@ -363,6 +366,8 @@ public: void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint8_t count); void sellItemBySlot(int backpackIndex); + void autoEquipItemBySlot(int backpackIndex); + void useItemBySlot(int backpackIndex); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } const ItemQueryResponseData* getItemInfo(uint32_t itemId) const { @@ -467,6 +472,8 @@ private: void handleItemQueryResponse(network::Packet& packet); void queryItemInfo(uint32_t entry, uint64_t guid); void rebuildOnlineInventory(); + void detectInventorySlotBases(const std::map& fields); + bool applyInventoryFields(const std::map& fields); // ---- Phase 2 handlers ---- void handleAttackStart(network::Packet& packet); @@ -606,11 +613,16 @@ private: std::unordered_set pendingItemQueries_; std::array equipSlotGuids_{}; std::array backpackSlotGuids_{}; + int invSlotBase_ = -1; + int packSlotBase_ = -1; + std::map lastPlayerFields_; bool onlineEquipDirty_ = false; // ---- Phase 2: Combat ---- bool autoAttacking = false; uint64_t autoAttackTarget = 0; + bool autoAttackOutOfRange_ = false; + std::unordered_set hostileAttackers_; std::vector combatText; // ---- Phase 3: Spells ---- @@ -674,6 +686,7 @@ private: WorldConnectFailureCallback onFailure; CharCreateCallback charCreateCallback_; CharDeleteCallback charDeleteCallback_; + uint8_t lastCharDeleteResult_ = 0xFF; bool pendingCharCreateResult_ = false; bool pendingCharCreateSuccess_ = false; std::string pendingCharCreateMsg_; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 131b97eb..3b7a6247 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -162,11 +162,13 @@ enum class Opcode : uint16_t { // ---- Phase 5: Item/Equip ---- CMSG_ITEM_QUERY_SINGLE = 0x056, SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058, + CMSG_USE_ITEM = 0x00AB, CMSG_AUTOEQUIP_ITEM = 0x10A, SMSG_INVENTORY_CHANGE_FAILURE = 0x112, // ---- Death/Respawn ---- CMSG_REPOP_REQUEST = 0x015A, + CMSG_SPIRIT_HEALER_ACTIVATE = 0x0176, }; } // namespace game diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 3ce88764..6cd107c6 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1125,6 +1125,18 @@ public: static network::Packet build(uint8_t slotIndex); }; +/** CMSG_USE_ITEM packet builder */ +class UseItemPacket { +public: + static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid); +}; + +/** CMSG_AUTOEQUIP_ITEM packet builder */ +class AutoEquipItemPacket { +public: + static network::Packet build(uint64_t itemGuid); +}; + /** CMSG_LOOT_RELEASE packet builder */ class LootReleasePacket { public: @@ -1274,5 +1286,11 @@ public: static network::Packet build(); }; +/** CMSG_SPIRIT_HEALER_ACTIVATE packet builder */ +class SpiritHealerActivatePacket { +public: + static network::Packet build(uint64_t npcGuid); +}; + } // namespace game } // namespace wowee diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 0c072978..e715a75d 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -72,6 +72,7 @@ public: bool hasAnimation(uint32_t instanceId, uint32_t animationId) const; bool getAnimationSequences(uint32_t instanceId, std::vector& out) const; bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const; + bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const; /** Attach a weapon model to a character instance at the given attachment point. */ bool attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 602d8f32..b873b865 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -52,12 +52,16 @@ private: char chatInputBuffer[512] = ""; bool chatInputActive = false; int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, etc. + bool chatInputMoveCursorToEnd = false; // UI state bool showEntityWindow = false; bool showChatWindow = true; bool showPlayerInfo = false; bool refocusChatInput = false; + bool chatWindowLocked = true; + ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); + bool chatWindowPosInit_ = false; bool showTeleporter = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index a37aa581..9ff3fa7b 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -38,6 +38,7 @@ public: vendorMode_ = enabled; gameHandler_ = handler; } + void setGameHandler(game::GameHandler* handler) { gameHandler_ = handler; } /// Set asset manager for icon/model loading void setAssetManager(pipeline::AssetManager* am) { assetManager_ = am; } diff --git a/src/core/application.cpp b/src/core/application.cpp index 0f804034..0d0d49e7 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -465,14 +465,12 @@ void Application::update(float deltaTime) { gameHandler->setOrientation(wowOrientation); } - // Send movement heartbeat every 500ms while moving - if (renderer && renderer->isMoving()) { + // Send movement heartbeat every 500ms (keeps server position in sync) + if (gameHandler && renderer && !singlePlayerMode) { movementHeartbeatTimer += deltaTime; if (movementHeartbeatTimer >= 0.5f) { movementHeartbeatTimer = 0.0f; - if (gameHandler && !singlePlayerMode) { - gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); - } + gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); } } else { movementHeartbeatTimer = 0.0f; @@ -712,7 +710,9 @@ void Application::setupUICallbacks() { gameHandler->requestCharacterList(); } } else { - uiManager->getCharacterScreen().setStatus("Delete failed."); + uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF; + uiManager->getCharacterScreen().setStatus( + "Delete failed (code " + std::to_string(static_cast(code)) + ")."); } }); } @@ -2183,6 +2183,25 @@ std::string Application::getModelPathForDisplayId(uint32_t displayId) const { return itPath->second; } +bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const { + if (!renderer || !renderer->getCharacterRenderer()) return false; + uint32_t instanceId = 0; + + if (gameHandler && guid == gameHandler->getPlayerGuid()) { + instanceId = renderer->getCharacterInstanceId(); + } + if (instanceId == 0) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId == 0 && npcManager) { + instanceId = npcManager->findRenderInstanceId(guid); + } + if (instanceId == 0) return false; + + return renderer->getCharacterRenderer()->getInstanceBounds(instanceId, outCenter, outRadius); +} + void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return; @@ -2388,9 +2407,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical → render coordinates glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + // Convert canonical WoW orientation (0=north) -> render yaw (0=west) + float renderYaw = orientation + glm::radians(90.0f); + // Create instance uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, orientation), 1.0f); + glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); if (instanceId == 0) { LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fa3fff10..d4ed54dc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -900,6 +900,20 @@ void GameHandler::update(float deltaTime) { if (unit->getHealth() == 0) { stopAutoAttack(); } else { + // Out-of-range notice (melee) + constexpr float MELEE_RANGE = 5.0f; + float dx = target->getX() - movementInfo.x; + float dy = target->getY() - movementInfo.y; + float dz = target->getZ() - movementInfo.z; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + bool outOfRange = dist > MELEE_RANGE; + if (outOfRange && !autoAttackOutOfRange_) { + addSystemChatMessage("Target is out of range."); + autoAttackOutOfRange_ = true; + } else if (!outOfRange && autoAttackOutOfRange_) { + autoAttackOutOfRange_ = false; + } + // Re-send attack swing every 2 seconds to keep server combat alive swingTimer_ += deltaTime; if (swingTimer_ >= 2.0f) { @@ -966,9 +980,10 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_CHAR_DELETE: { uint8_t result = packet.readUInt8(); - bool success = (result == 0x47); // CHAR_DELETE_SUCCESS + lastCharDeleteResult_ = result; + bool success = (result == 0x00 || result == 0x47); // Common success codes LOG_INFO("SMSG_CHAR_DELETE result: ", (int)result, success ? " (success)" : " (failed)"); - if (success) requestCharacterList(); + requestCharacterList(); if (charDeleteCallback_) charDeleteCallback_(success); break; } @@ -2608,6 +2623,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Extract XP / inventory slot fields for player entity if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + lastPlayerFields_ = block.fields; + detectInventorySlotBases(block.fields); bool slotsChanged = false; for (const auto& [key, val] : block.fields) { if (key == 634) { playerXp_ = val; } // PLAYER_XP @@ -2619,28 +2636,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } else if (key == 632) { playerMoneyCopper_ = val; } // PLAYER_FIELD_COINAGE - else if (key >= 322 && key <= 367) { - // PLAYER_FIELD_INV_SLOT_HEAD: equipment slots (23 slots × 2 fields) - int slotIndex = (key - 322) / 2; - bool isLow = ((key - 322) % 2 == 0); - if (slotIndex < 23) { - uint64_t& guid = equipSlotGuids_[slotIndex]; - if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; - else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); - slotsChanged = true; - } - } else if (key >= 368 && key <= 399) { - // PLAYER_FIELD_PACK_SLOT_1: backpack slots (16 slots × 2 fields) - int slotIndex = (key - 368) / 2; - bool isLow = ((key - 368) % 2 == 0); - if (slotIndex < 16) { - uint64_t& guid = backpackSlotGuids_[slotIndex]; - if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; - else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); - slotsChanged = true; - } - } } + if (applyInventoryFields(block.fields)) slotsChanged = true; if (slotsChanged) rebuildOnlineInventory(); } break; @@ -2666,6 +2663,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (block.guid == autoAttackTarget) { stopAutoAttack(); } + hostileAttackers_.erase(block.guid); // Player death if (block.guid == playerGuid) { playerDead_ = true; @@ -2705,6 +2703,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Update XP / inventory slot fields for player entity if (block.guid == playerGuid) { + for (const auto& [key, val] : block.fields) { + lastPlayerFields_[key] = val; + } + detectInventorySlotBases(block.fields); bool slotsChanged = false; for (const auto& [key, val] : block.fields) { if (key == 634) { @@ -2730,26 +2732,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerMoneyCopper_ = val; LOG_INFO("Money updated via VALUES: ", val, " copper"); } - else if (key >= 322 && key <= 367) { - int slotIndex = (key - 322) / 2; - bool isLow = ((key - 322) % 2 == 0); - if (slotIndex < 23) { - uint64_t& guid = equipSlotGuids_[slotIndex]; - if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; - else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); - slotsChanged = true; - } - } else if (key >= 368 && key <= 399) { - int slotIndex = (key - 368) / 2; - bool isLow = ((key - 368) % 2 == 0); - if (slotIndex < 16) { - uint64_t& guid = backpackSlotGuids_[slotIndex]; - if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; - else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); - slotsChanged = true; - } - } } + if (applyInventoryFields(block.fields)) slotsChanged = true; if (slotsChanged) rebuildOnlineInventory(); } @@ -2791,6 +2775,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { tabCycleStale = true; LOG_INFO("Entity count: ", entityManager.getEntityCount()); + + // Late inventory base detection once items are known + if (playerGuid != 0 && invSlotBase_ < 0 && !lastPlayerFields_.empty() && !onlineItems_.empty()) { + detectInventorySlotBases(lastPlayerFields_); + if (invSlotBase_ >= 0) { + if (applyInventoryFields(lastPlayerFields_)) { + rebuildOnlineInventory(); + } + } + } } void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { @@ -2856,6 +2850,7 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { if (data.guid == targetGuid) { targetGuid = 0; } + hostileAttackers_.erase(data.guid); // Remove online item tracking if (onlineItems_.erase(data.guid)) { @@ -2975,6 +2970,14 @@ void GameHandler::releaseSpirit() { } } +void GameHandler::activateSpiritHealer(uint64_t npcGuid) { + if (!playerDead_) return; + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = SpiritHealerActivatePacket::build(npcGuid); + socket->send(packet); + LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE to 0x", std::hex, npcGuid, std::dec); +} + void GameHandler::tabTarget(float playerX, float playerY, float playerZ) { // Rebuild cycle list if stale if (tabCycleStale) { @@ -3117,6 +3120,65 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { } } +void GameHandler::detectInventorySlotBases(const std::map& fields) { + if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return; + if (onlineItems_.empty() || fields.empty()) return; + + std::vector matchingPairs; + matchingPairs.reserve(32); + + for (const auto& [idx, low] : fields) { + if ((idx % 2) != 0) continue; + auto itHigh = fields.find(static_cast(idx + 1)); + if (itHigh == fields.end()) continue; + uint64_t guid = (uint64_t(itHigh->second) << 32) | low; + if (guid == 0) continue; + if (onlineItems_.count(guid)) { + matchingPairs.push_back(idx); + } + } + + if (matchingPairs.empty()) return; + std::sort(matchingPairs.begin(), matchingPairs.end()); + + if (invSlotBase_ < 0) { + invSlotBase_ = matchingPairs.front(); + packSlotBase_ = invSlotBase_ + (game::Inventory::NUM_EQUIP_SLOTS * 2); + LOG_INFO("Detected inventory field base: equip=", invSlotBase_, + " pack=", packSlotBase_); + } +} + +bool GameHandler::applyInventoryFields(const std::map& fields) { + bool slotsChanged = false; + int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : 322; + int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : 368; + + for (const auto& [key, val] : fields) { + if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) { + int slotIndex = (key - equipBase) / 2; + bool isLow = ((key - equipBase) % 2 == 0); + if (slotIndex < static_cast(equipSlotGuids_.size())) { + uint64_t& guid = equipSlotGuids_[slotIndex]; + if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; + else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); + slotsChanged = true; + } + } else if (key >= packBase && key <= packBase + (game::Inventory::BACKPACK_SLOTS * 2 - 1)) { + int slotIndex = (key - packBase) / 2; + bool isLow = ((key - packBase) % 2 == 0); + if (slotIndex < static_cast(backpackSlotGuids_.size())) { + uint64_t& guid = backpackSlotGuids_[slotIndex]; + if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; + else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); + slotsChanged = true; + } + } + } + + return slotsChanged; +} + void GameHandler::rebuildOnlineInventory() { if (singlePlayerMode_) return; @@ -3151,6 +3213,7 @@ void GameHandler::rebuildOnlineInventory() { def.spirit = infoIt->second.spirit; } else { def.name = "Item " + std::to_string(def.itemId); + queryItemInfo(def.itemId, guid); } inventory.setEquipSlot(static_cast(i), def); @@ -3185,6 +3248,7 @@ void GameHandler::rebuildOnlineInventory() { def.spirit = infoIt->second.spirit; } else { def.name = "Item " + std::to_string(def.itemId); + queryItemInfo(def.itemId, guid); } inventory.setBackpackSlot(i, def); @@ -3206,6 +3270,7 @@ void GameHandler::rebuildOnlineInventory() { void GameHandler::startAutoAttack(uint64_t targetGuid) { autoAttacking = true; autoAttackTarget = targetGuid; + autoAttackOutOfRange_ = false; swingTimer_ = 0.0f; if (state == WorldState::IN_WORLD && socket) { auto packet = AttackSwingPacket::build(targetGuid); @@ -3218,6 +3283,7 @@ void GameHandler::stopAutoAttack() { if (!autoAttacking) return; autoAttacking = false; autoAttackTarget = 0; + autoAttackOutOfRange_ = false; if (state == WorldState::IN_WORLD && socket) { auto packet = AttackStopPacket::build(); socket->send(packet); @@ -3265,6 +3331,8 @@ void GameHandler::handleAttackStop(network::Packet& packet) { // We'll re-send CMSG_ATTACKSWING periodically in the update loop. if (data.attackerGuid == playerGuid) { LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); + } else if (data.victimGuid == playerGuid) { + hostileAttackers_.erase(data.attackerGuid); } } @@ -3345,6 +3413,10 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { npcSwingCallback_(data.attackerGuid); } + if (isPlayerTarget && data.attackerGuid != 0) { + hostileAttackers_.insert(data.attackerGuid); + } + if (data.isMiss()) { addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); } else if (data.victimState == 1) { @@ -3921,6 +3993,40 @@ void GameHandler::sellItemBySlot(int backpackIndex) { } } +void GameHandler::autoEquipItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + const auto& slot = inventory.getBackpackSlot(backpackIndex); + if (slot.empty()) return; + + if (singlePlayerMode_) { + // Fall back to local equip logic (UI already handles this). + return; + } + + uint64_t itemGuid = backpackSlotGuids_[backpackIndex]; + if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) { + auto packet = AutoEquipItemPacket::build(itemGuid); + socket->send(packet); + } +} + +void GameHandler::useItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + const auto& slot = inventory.getBackpackSlot(backpackIndex); + if (slot.empty()) return; + + if (singlePlayerMode_) { + // Single-player consumable use not implemented yet. + return; + } + + uint64_t itemGuid = backpackSlotGuids_[backpackIndex]; + if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) { + auto packet = UseItemPacket::build(0xFF, static_cast(backpackIndex), itemGuid); + socket->send(packet); + } +} + void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot)) return; lootWindowOpen = true; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8460efb0..88285edd 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1906,6 +1906,27 @@ network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) { return packet; } +network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid) { + network::Packet packet(static_cast(Opcode::CMSG_USE_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + packet.writeUInt8(0); // spell index + packet.writeUInt8(0); // cast count + packet.writeUInt32(0); // spell id (unused) + packet.writeUInt64(itemGuid); + packet.writeUInt32(0); // glyph index + packet.writeUInt8(0); // cast flags + // SpellCastTargets: self + packet.writeUInt32(0x00); + return packet; +} + +network::Packet AutoEquipItemPacket::build(uint64_t itemGuid) { + network::Packet packet(static_cast(Opcode::CMSG_AUTOEQUIP_ITEM)); + packet.writeUInt64(itemGuid); + return packet; +} + network::Packet LootReleasePacket::build(uint64_t lootGuid) { network::Packet packet(static_cast(Opcode::CMSG_LOOT_RELEASE)); packet.writeUInt64(lootGuid); @@ -2122,5 +2143,11 @@ network::Packet RepopRequestPacket::build() { return packet; } +network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { + network::Packet packet(static_cast(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE)); + packet.writeUInt64(npcGuid); + return packet; +} + } // namespace game } // namespace wowee diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 9f13c707..6ee8d9cd 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -79,8 +79,8 @@ void CameraController::update(float deltaTime) { auto& input = core::Input::getInstance(); - // Don't process keyboard input when UI (e.g. chat box) has focus - bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; + // Don't process keyboard input when UI text input (e.g. chat box) has focus + bool uiWantsKeyboard = ImGui::GetIO().WantTextInput; // Determine current key states bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 978a47d7..384660e3 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1415,10 +1415,11 @@ glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) c // Apply transformations: T * R * S model = glm::translate(model, instance.position); - // Apply rotation (euler angles) - model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Yaw + // Apply rotation (euler angles, Z-up) + // Convention: yaw around Z, pitch around X, roll around Y. + model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Yaw model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch - model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Roll + model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll model = glm::scale(model, glm::vec3(instance.scale)); @@ -1697,6 +1698,27 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen return true; } +bool CharacterRenderer::getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const { + auto it = instances.find(instanceId); + if (it == instances.end()) return false; + auto mIt = models.find(it->second.modelId); + if (mIt == models.end()) return false; + + const auto& inst = it->second; + const auto& model = mIt->second.data; + + glm::vec3 localCenter = (model.boundMin + model.boundMax) * 0.5f; + float radius = model.boundRadius; + if (radius <= 0.001f) { + radius = glm::length(model.boundMax - model.boundMin) * 0.5f; + } + + float scale = std::max(0.001f, inst.scale); + outCenter = inst.position + localCenter * scale; + outRadius = std::max(0.5f, radius * scale); + return true; +} + void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) { auto charIt = instances.find(charInstanceId); if (charIt == instances.end()) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index df14413f..dff0d079 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -17,7 +17,9 @@ #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" #include +#include #include +#include #include namespace { @@ -138,6 +140,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { } // Bags (B key toggle handled inside) + inventoryScreen.setGameHandler(&gameHandler); inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper()); // Character screen (C key toggle handled inside render()) @@ -174,11 +177,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Selection circle color: WoW-canonical level-based colors glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow float circleRadius = 1.5f; + { + glm::vec3 boundsCenter; + float boundsRadius = 0.0f; + if (core::Application::getInstance().getRenderBoundsForGuid(target->getGuid(), boundsCenter, boundsRadius)) { + float r = boundsRadius * 1.1f; + circleRadius = std::min(std::max(r, 0.8f), 8.0f); + } + } if (target->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(target); if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead) - } else if (unit->isHostile()) { + } else if (unit->isHostile() || gameHandler.isAggressiveTowardPlayer(target->getGuid())) { uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = unit->getLevel(); int32_t diff = static_cast(mobLv) - static_cast(playerLv); @@ -363,9 +374,24 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { float chatH = 220.0f; float chatX = 8.0f; float chatY = screenH - chatH - 80.0f; // Above action bar - ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImVec2(chatX, chatY), ImGuiCond_Always); - ImGui::Begin("Chat", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove); + if (!chatWindowPosInit_) { + chatWindowPos_ = ImVec2(chatX, chatY); + chatWindowPosInit_ = true; + } + if (chatWindowLocked) { + ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always); + ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_Always); + } else { + ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver); + } + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + if (chatWindowLocked) flags |= ImGuiWindowFlags_NoMove; + ImGui::Begin("Chat", nullptr, flags); + + if (!chatWindowLocked) { + chatWindowPos_ = ImGui::GetWindowPos(); + } // Chat history const auto& chatHistory = gameHandler.getChatHistory(); @@ -404,6 +430,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::Spacing(); + // Lock toggle + ImGui::Checkbox("Lock", &chatWindowLocked); + ImGui::SameLine(); + ImGui::TextDisabled(chatWindowLocked ? "(locked)" : "(movable)"); + // Chat input ImGui::Text("Type:"); ImGui::SameLine(); @@ -420,7 +451,20 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::SetKeyboardFocusHere(); refocusChatInput = false; } - if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), ImGuiInputTextFlags_EnterReturnsTrue)) { + auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { + auto* self = static_cast(data->UserData); + if (self && self->chatInputMoveCursorToEnd) { + int len = static_cast(std::strlen(data->Buf)); + data->CursorPos = len; + data->SelectionStart = len; + data->SelectionEnd = len; + self->chatInputMoveCursorToEnd = false; + } + return 0; + }; + + ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways; + if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); refocusChatInput = true; } @@ -486,6 +530,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { refocusChatInput = true; chatInputBuffer[0] = '/'; chatInputBuffer[1] = '\0'; + chatInputMoveCursorToEnd = true; } // Enter key: focus chat input (empty) @@ -516,23 +561,29 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue; if (guid == myGuid) continue; // Don't target self - // Scale hitbox based on entity type - float hitRadius = 1.5f; - float heightOffset = 1.5f; - if (t == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - // Critters have very low max health (< 100) - if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) { - hitRadius = 0.5f; - heightOffset = 0.3f; + glm::vec3 hitCenter; + float hitRadius = 0.0f; + bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); + if (!hasBounds) { + // Fallback hitbox based on entity type + float heightOffset = 1.5f; + hitRadius = 1.5f; + if (t == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + // Critters have very low max health (< 100) + if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) { + hitRadius = 0.5f; + heightOffset = 0.3f; + } } + hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + hitCenter.z += heightOffset; + } else { + hitRadius = std::max(hitRadius * 1.1f, 0.6f); } - glm::vec3 entityGL = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - entityGL.z += heightOffset; - float hitT; - if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) { + if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -569,20 +620,27 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER) continue; if (guid == myGuid) continue; - float hitRadius = 1.5f; - float heightOffset = 1.5f; - if (t == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) { - hitRadius = 0.5f; - heightOffset = 0.3f; + glm::vec3 hitCenter; + float hitRadius = 0.0f; + bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); + if (!hasBounds) { + float heightOffset = 1.5f; + hitRadius = 1.5f; + if (t == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) { + hitRadius = 0.5f; + heightOffset = 0.3f; + } } + hitCenter = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + hitCenter.z += heightOffset; + } else { + hitRadius = std::max(hitRadius * 1.1f, 0.6f); } - glm::vec3 entityGL = core::coords::canonicalToRender( - glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - entityGL.z += heightOffset; float hitT; - if (raySphereIntersect(ray, entityGL, hitRadius, hitT)) { + if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -603,26 +661,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) { gameHandler.lootTarget(target->getGuid()); } else if (gameHandler.isSinglePlayerMode()) { - // Single-player: interact with friendly NPCs, attack hostiles + // Single-player: interact with friendly NPCs, otherwise attack if (!unit->isHostile() && unit->isInteractable()) { gameHandler.interactWithNpc(target->getGuid()); - } else if (unit->isHostile()) { - if (gameHandler.isAutoAttacking()) { - gameHandler.stopAutoAttack(); - } else { - gameHandler.startAutoAttack(target->getGuid()); - } + } else { + gameHandler.startAutoAttack(target->getGuid()); } } else { - // Online mode: interact with friendly NPCs, attack hostiles + // Online mode: interact with friendly NPCs, otherwise attack if (!unit->isHostile() && unit->isInteractable()) { gameHandler.interactWithNpc(target->getGuid()); - } else if (unit->isHostile()) { - if (gameHandler.isAutoAttacking()) { - gameHandler.stopAutoAttack(); - } else { - gameHandler.startAutoAttack(target->getGuid()); - } + } else { + gameHandler.startAutoAttack(target->getGuid()); } } } else if (target->getType() == game::ObjectType::PLAYER) { @@ -634,6 +684,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { + bool isDead = gameHandler.isPlayerDead(); ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always); @@ -643,7 +694,12 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 1.0f)); + ImVec4 playerBorder = isDead + ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) + : (gameHandler.isAutoAttacking() + ? ImVec4(1.0f, 0.2f, 0.2f, 1.0f) + : ImVec4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, playerBorder); if (ImGui::Begin("##PlayerFrame", nullptr, flags)) { // Use selected character info if available, otherwise defaults @@ -671,6 +727,10 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextDisabled("Lv %u", playerLevel); + if (isDead) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); @@ -690,7 +750,8 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { // Health bar float pct = static_cast(playerHp) / static_cast(playerMaxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + ImVec4 hpColor = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) : ImVec4(0.2f, 0.8f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); char overlay[64]; snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); @@ -773,7 +834,11 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f)); + ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f); + if (gameHandler.isAutoAttacking()) { + borderColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_Border, borderColor); if (ImGui::Begin("##TargetFrame", nullptr, flags)) { // Entity name and type @@ -1471,8 +1536,6 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) uint32_t currentXp = gameHandler.getPlayerXp(); - uint32_t level = gameHandler.getPlayerLevel(); - auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; @@ -1485,7 +1548,7 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { float barH = slotSize + 24.0f; float actionBarY = screenH - barH; - float xpBarH = 14.0f; + float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; float xpBarY = actionBarY - xpBarH - 2.0f; @@ -1506,14 +1569,38 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { float pct = static_cast(currentXp) / static_cast(nextLevelXp); if (pct > 1.0f) pct = 1.0f; - // Purple XP bar (WoW-style) - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.58f, 0.2f, 0.93f, 1.0f)); + // Custom segmented XP bar (20 bubbles) + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* drawList = ImGui::GetWindowDrawList(); + + ImU32 bg = IM_COL32(15, 15, 20, 220); + ImU32 fg = IM_COL32(148, 51, 238, 255); + ImU32 seg = IM_COL32(35, 35, 45, 255); + drawList->AddRectFilled(barMin, barMax, bg, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * pct; + if (fillW > 0.0f) { + drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); + } + + const int segments = 20; + float segW = barSize.x / static_cast(segments); + for (int i = 1; i < segments; ++i) { + float x = barMin.x + segW * i; + drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); + } char overlay[96]; - snprintf(overlay, sizeof(overlay), "Lv %u - %u / %u XP", level, currentXp, nextLevelXp); - ImGui::ProgressBar(pct, ImVec2(-1, xpBarH - 4.0f), overlay); + snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + ImVec2 textSize = ImGui::CalcTextSize(overlay); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); - ImGui::PopStyleColor(); + ImGui::Dummy(barSize); } ImGui::End(); @@ -1857,6 +1944,9 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { lootSlotClicked = item.slotIndex; } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + lootSlotClicked = item.slotIndex; + } bool hovered = ImGui::IsItemHovered(); ImDrawList* drawList = ImGui::GetWindowDrawList(); @@ -1906,7 +1996,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } if (loot.items.empty() && loot.gold == 0) { - ImGui::TextDisabled("Empty"); + gameHandler.closeLoot(); } ImGui::Spacing(); @@ -1961,7 +2051,13 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { char label[256]; snprintf(label, sizeof(label), "%s %s", icon, opt.text.c_str()); if (ImGui::Selectable(label)) { - gameHandler.selectGossipOption(opt.id); + if (opt.icon == 4) { // Spirit guide + gameHandler.selectGossipOption(opt.id); + gameHandler.activateSpiritHealer(gossip.npcGuid); + gameHandler.closeGossip(); + } else { + gameHandler.selectGossipOption(opt.id); + } } ImGui::PopID(); } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index d228d53d..68ab6db4 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -371,6 +371,12 @@ void InventoryScreen::pickupFromEquipment(game::Inventory& inv, game::EquipSlot void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { if (!holdingItem) return; + if (gameHandler_ && !gameHandler_->isSinglePlayerMode() && + heldSource == HeldSource::EQUIPMENT) { + // Online mode: avoid client-side unequip; wait for server update. + cancelPickup(inv); + return; + } const auto& target = inv.getBackpackSlot(index); if (target.empty()) { inv.setBackpackSlot(index, heldItem); @@ -388,6 +394,19 @@ void InventoryScreen::placeInBackpack(game::Inventory& inv, int index) { void InventoryScreen::placeInEquipment(game::Inventory& inv, game::EquipSlot slot) { if (!holdingItem) return; + if (gameHandler_ && !gameHandler_->isSinglePlayerMode()) { + if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) { + // Online mode: request server auto-equip and keep local state intact. + gameHandler_->autoEquipItemBySlot(heldBackpackIndex); + cancelPickup(inv); + return; + } + if (heldSource == HeldSource::EQUIPMENT) { + // Online mode: avoid client-side equipment swaps. + cancelPickup(inv); + return; + } + } // Validate: check if the held item can go in this slot if (heldItem.inventoryType > 0) { @@ -588,8 +607,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGui::End(); // Detect held item dropped outside inventory windows → drop confirmation - if (holdingItem && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && - !ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) { + if (holdingItem && heldItem.itemId != 6948 && ImGui::IsMouseReleased(ImGuiMouseButton_Left) && + !ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && + !ImGui::IsAnyItemHovered() && !ImGui::IsAnyItemActive()) { dropConfirmOpen_ = true; dropItemName_ = heldItem.name; } @@ -675,14 +695,51 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (clamped) ImGui::SetWindowPos(pos); } - renderEquipmentPanel(inventory); + if (ImGui::BeginTabBar("##CharacterTabs")) { + if (ImGui::BeginTabItem("Equipment")) { + renderEquipmentPanel(inventory); + ImGui::EndTabItem(); + } - // Stats panel — use full width and separate from equipment layout - ImGui::SetCursorPosX(ImGui::GetStyle().WindowPadding.x); - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - renderStatsPanel(inventory, gameHandler.getPlayerLevel()); + if (ImGui::BeginTabItem("Stats")) { + ImGui::Spacing(); + renderStatsPanel(inventory, gameHandler.getPlayerLevel()); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Skills")) { + uint32_t level = gameHandler.getPlayerLevel(); + uint32_t cap = (level > 0) ? (level * 5) : 0; + ImGui::TextDisabled("Skills (online sync pending)"); + ImGui::Spacing(); + if (ImGui::BeginTable("SkillsTable", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH)) { + ImGui::TableSetupColumn("Skill", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableHeadersRow(); + + const char* skills[] = { + "Unarmed", "Swords", "Axes", "Maces", "Daggers", + "Staves", "Polearms", "Bows", "Guns", "Crossbows" + }; + for (const char* skill : skills) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Text("%s", skill); + ImGui::TableSetColumnIndex(1); + if (cap > 0) { + ImGui::Text("-- / %u", cap); + } else { + ImGui::TextDisabled("--"); + } + } + + ImGui::EndTable(); + } + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } ImGui::End(); @@ -1039,34 +1096,44 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite equipmentDirty = true; inventoryDirty = true; } - } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0 && item.inventoryType > 0) { - // Auto-equip - uint8_t equippingType = item.inventoryType; - game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory); - if (targetSlot != game::EquipSlot::NUM_SLOTS) { - const auto& eqSlot = inventory.getEquipSlot(targetSlot); - if (eqSlot.empty()) { - inventory.setEquipSlot(targetSlot, item); - inventory.clearBackpackSlot(backpackIndex); + } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + if (gameHandler_ && !gameHandler_->isSinglePlayerMode()) { + if (item.inventoryType > 0) { + // Auto-equip (online) + gameHandler_->autoEquipItemBySlot(backpackIndex); } else { - game::ItemDef equippedItem = eqSlot.item; - inventory.setEquipSlot(targetSlot, item); - inventory.setBackpackSlot(backpackIndex, equippedItem); + // Use consumable (online) + gameHandler_->useItemBySlot(backpackIndex); } - if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) { - const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND); - if (!offHand.empty()) { - inventory.addItem(offHand.item); - inventory.clearEquipSlot(game::EquipSlot::OFF_HAND); + } else if (item.inventoryType > 0) { + // Auto-equip (single-player) + uint8_t equippingType = item.inventoryType; + game::EquipSlot targetSlot = getEquipSlotForType(equippingType, inventory); + if (targetSlot != game::EquipSlot::NUM_SLOTS) { + const auto& eqSlot = inventory.getEquipSlot(targetSlot); + if (eqSlot.empty()) { + inventory.setEquipSlot(targetSlot, item); + inventory.clearBackpackSlot(backpackIndex); + } else { + game::ItemDef equippedItem = eqSlot.item; + inventory.setEquipSlot(targetSlot, item); + inventory.setBackpackSlot(backpackIndex, equippedItem); } + if (targetSlot == game::EquipSlot::MAIN_HAND && equippingType == 17) { + const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND); + if (!offHand.empty()) { + inventory.addItem(offHand.item); + inventory.clearEquipSlot(game::EquipSlot::OFF_HAND); + } + } + if (targetSlot == game::EquipSlot::OFF_HAND && + inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) { + inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item); + inventory.clearEquipSlot(game::EquipSlot::MAIN_HAND); + } + equipmentDirty = true; + inventoryDirty = true; } - if (targetSlot == game::EquipSlot::OFF_HAND && - inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item.inventoryType == 17) { - inventory.addItem(inventory.getEquipSlot(game::EquipSlot::MAIN_HAND).item); - inventory.clearEquipSlot(game::EquipSlot::MAIN_HAND); - } - equipmentDirty = true; - inventoryDirty = true; } } }