diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index d0364020..1520bfe8 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -355,6 +355,11 @@ public: void acceptGuildInvite(); void declineGuildInvite(); void queryGuildInfo(uint32_t guildId); + void createGuild(const std::string& guildName); + void addGuildRank(const std::string& rankName); + void deleteGuildRank(); + void requestPetitionShowlist(uint64_t npcGuid); + void buyPetition(uint64_t npcGuid, const std::string& guildName); // Guild state accessors bool isInGuild() const { @@ -369,6 +374,13 @@ public: bool hasPendingGuildInvite() const { return pendingGuildInvite_; } const std::string& getPendingGuildInviterName() const { return pendingGuildInviterName_; } const std::string& getPendingGuildInviteGuildName() const { return pendingGuildInviteGuildName_; } + const GuildInfoData& getGuildInfoData() const { return guildInfoData_; } + const GuildQueryResponseData& getGuildQueryData() const { return guildQueryData_; } + bool hasGuildInfoData() const { return guildInfoData_.isValid(); } + bool hasPetitionShowlist() const { return showPetitionDialog_; } + void clearPetitionDialog() { showPetitionDialog_ = false; } + uint32_t getPetitionCost() const { return petitionCost_; } + uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } // Ready check void initiateReadyCheck(); @@ -856,12 +868,28 @@ public: int getSelectedMailIndex() const { return selectedMailIndex_; } void setSelectedMailIndex(int idx) { selectedMailIndex_ = idx; } bool isMailComposeOpen() const { return showMailCompose_; } - void openMailCompose() { showMailCompose_ = true; } - void closeMailCompose() { showMailCompose_ = false; } + void openMailCompose() { showMailCompose_ = true; clearMailAttachments(); } + void closeMailCompose() { showMailCompose_ = false; clearMailAttachments(); } bool hasNewMail() const { return hasNewMail_; } void closeMailbox(); void sendMail(const std::string& recipient, const std::string& subject, const std::string& body, uint32_t money, uint32_t cod = 0); + + // Mail attachments (max 12 per WotLK) + static constexpr int MAIL_MAX_ATTACHMENTS = 12; + struct MailAttachSlot { + uint64_t itemGuid = 0; + game::ItemDef item; + uint8_t srcBag = 0xFF; // source container for return + uint8_t srcSlot = 0; + bool occupied() const { return itemGuid != 0; } + }; + bool attachItemFromBackpack(int backpackIndex); + bool attachItemFromBag(int bagIndex, int slotIndex); + bool detachMailAttachment(int attachIndex); + void clearMailAttachments(); + const std::array& getMailAttachments() const { return mailAttachments_; } + int getMailAttachmentCount() const; void mailTakeMoney(uint32_t mailId); void mailTakeItem(uint32_t mailId, uint32_t itemIndex); void mailDelete(uint32_t mailId); @@ -1107,6 +1135,8 @@ private: void handleGuildEvent(network::Packet& packet); void handleGuildInvite(network::Packet& packet); void handleGuildCommandResult(network::Packet& packet); + void handlePetitionShowlist(network::Packet& packet); + void handleTurnInPetitionResults(network::Packet& packet); // ---- Character creation handler ---- void handleCharCreateResponse(network::Packet& packet); @@ -1451,10 +1481,15 @@ private: std::string guildName_; std::vector guildRankNames_; GuildRosterData guildRoster_; + GuildInfoData guildInfoData_; + GuildQueryResponseData guildQueryData_; bool hasGuildRoster_ = false; bool pendingGuildInvite_ = false; std::string pendingGuildInviterName_; std::string pendingGuildInviteGuildName_; + bool showPetitionDialog_ = false; + uint32_t petitionCost_ = 0; + uint64_t petitionNpcGuid_ = 0; uint64_t activeCharacterGuid_ = 0; Race playerRace_ = Race::HUMAN; @@ -1568,6 +1603,7 @@ private: int selectedMailIndex_ = -1; bool showMailCompose_ = false; bool hasNewMail_ = false; + std::array mailAttachments_{}; // Bank bool bankOpen_ = false; @@ -1590,6 +1626,18 @@ private: AuctionListResult auctionBidderResults_; int auctionActiveTab_ = 0; // 0=Browse, 1=Bids, 2=Auctions float auctionSearchDelayTimer_ = 0.0f; + // Last search params for re-query (pagination, auto-refresh after bid/buyout) + struct AuctionSearchParams { + std::string name; + uint8_t levelMin = 0, levelMax = 0; + uint32_t quality = 0xFFFFFFFF; + uint32_t itemClass = 0xFFFFFFFF; + uint32_t itemSubClass = 0xFFFFFFFF; + uint32_t invTypeMask = 0; + uint8_t usableOnly = 0; + uint32_t offset = 0; + }; + AuctionSearchParams lastAuctionSearch_; // Routing: which result vector to populate from next SMSG_AUCTION_LIST_RESULT enum class AuctionResultTarget { BROWSE, OWNER, BIDDER }; AuctionResultTarget pendingAuctionTarget_ = AuctionResultTarget::BROWSE; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index edd97e8c..15a271bd 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -217,8 +217,9 @@ public: /** Build CMSG_SEND_MAIL */ virtual network::Packet buildSendMail(uint64_t mailboxGuid, const std::string& recipient, const std::string& subject, const std::string& body, - uint32_t money, uint32_t cod) { - return SendMailPacket::build(mailboxGuid, recipient, subject, body, money, cod); + uint32_t money, uint32_t cod, + const std::vector& itemGuids = {}) { + return SendMailPacket::build(mailboxGuid, recipient, subject, body, money, cod, itemGuids); } /** Parse SMSG_MAIL_LIST_RESULT into a vector of MailMessage */ @@ -323,7 +324,8 @@ public: network::Packet buildLeaveChannel(const std::string& channelName) override; network::Packet buildSendMail(uint64_t mailboxGuid, const std::string& recipient, const std::string& subject, const std::string& body, - uint32_t money, uint32_t cod) override; + uint32_t money, uint32_t cod, + const std::vector& itemGuids = {}) override; bool parseMailList(network::Packet& packet, std::vector& inbox) override; network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) override; network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 9124d75e..308ec642 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1050,6 +1050,60 @@ public: static network::Packet build(); }; +/** CMSG_GUILD_CREATE packet builder */ +class GuildCreatePacket { +public: + static network::Packet build(const std::string& guildName); +}; + +/** CMSG_GUILD_ADD_RANK packet builder */ +class GuildAddRankPacket { +public: + static network::Packet build(const std::string& rankName); +}; + +/** CMSG_GUILD_DEL_RANK packet builder (empty body) */ +class GuildDelRankPacket { +public: + static network::Packet build(); +}; + +/** CMSG_PETITION_SHOWLIST packet builder */ +class PetitionShowlistPacket { +public: + static network::Packet build(uint64_t npcGuid); +}; + +/** CMSG_PETITION_BUY packet builder */ +class PetitionBuyPacket { +public: + static network::Packet build(uint64_t npcGuid, const std::string& guildName); +}; + +/** SMSG_PETITION_SHOWLIST data */ +struct PetitionShowlistData { + uint64_t npcGuid = 0; + uint32_t itemId = 0; + uint32_t displayId = 0; + uint32_t cost = 0; + uint32_t charterType = 0; + uint32_t requiredSigs = 0; + + bool isValid() const { return npcGuid != 0; } +}; + +/** SMSG_PETITION_SHOWLIST parser */ +class PetitionShowlistParser { +public: + static bool parse(network::Packet& packet, PetitionShowlistData& data); +}; + +/** SMSG_TURN_IN_PETITION_RESULTS parser */ +class TurnInPetitionResultsParser { +public: + static bool parse(network::Packet& packet, uint32_t& result); +}; + // Guild event type constants namespace GuildEvent { constexpr uint8_t PROMOTION = 0; @@ -2290,7 +2344,8 @@ class SendMailPacket { public: static network::Packet build(uint64_t mailboxGuid, const std::string& recipient, const std::string& subject, const std::string& body, - uint32_t money, uint32_t cod); + uint32_t money, uint32_t cod, + const std::vector& itemGuids = {}); }; /** CMSG_MAIL_TAKE_MONEY packet builder */ diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index da99ed53..ee06eb30 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -212,6 +212,7 @@ public: * Unload all tiles */ void unloadAll(); + void softReset(); // Clear tile data without stopping worker threads (non-blocking) /** * Precache a set of tiles (for taxi routes, etc.) diff --git a/include/rendering/water_renderer.hpp b/include/rendering/water_renderer.hpp index cf04cbc5..99767782 100644 --- a/include/rendering/water_renderer.hpp +++ b/include/rendering/water_renderer.hpp @@ -172,6 +172,7 @@ private: VkImageView sceneDepthView = VK_NULL_HANDLE; VkExtent2D sceneHistoryExtent = {0, 0}; bool sceneHistoryReady = false; + mutable uint32_t renderDiagCounter_ = 0; // Planar reflection resources static constexpr uint32_t REFLECTION_WIDTH = 512; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd0cf212..cc1bd4ab 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -68,6 +68,12 @@ private: bool showGuildNoteEdit_ = false; bool editingOfficerNote_ = false; char guildNoteEditBuffer_[256] = {0}; + int guildRosterTab_ = 0; // 0=Roster, 1=Guild Info + char guildMotdEditBuffer_[256] = {0}; + bool showMotdEdit_ = false; + char petitionNameBuffer_[64] = {0}; + char addRankNameBuffer_[64] = {0}; + bool showAddRankModal_ = false; bool refocusChatInput = false; bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session bool chatWindowLocked = true; @@ -284,6 +290,10 @@ private: int auctionSellBid_[3] = {0, 0, 0}; // gold, silver, copper int auctionSellBuyout_[3] = {0, 0, 0}; // gold, silver, copper int auctionSelectedItem_ = -1; + int auctionSellSlotIndex_ = -1; // Selected backpack slot for selling + uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results + int auctionItemClass_ = -1; // Item class filter (-1 = All) + int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All) // Guild bank money input int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index baffe8de..ccc55631 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -173,6 +173,8 @@ public: /// Drop the currently held item into a specific equipment slot. /// Returns true if the drop was accepted and consumed. bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot); + /// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM. + void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot); }; } // namespace ui diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index b77a2ece..537e862c 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -18,8 +18,13 @@ struct SpellInfo { uint32_t spellId = 0; std::string name; std::string rank; - uint32_t iconId = 0; // SpellIconID - uint32_t attributes = 0; // Spell attributes (field 75) + std::string description; // Tooltip/description text from Spell.dbc + uint32_t iconId = 0; // SpellIconID + uint32_t attributes = 0; // Spell attributes (field 4) + uint32_t castTimeMs = 0; // Cast time in ms (0 = instant) + uint32_t manaCost = 0; // Mana cost + uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy + uint32_t rangeIndex = 0; // Range index from SpellRange.dbc bool isPassive() const { return (attributes & 0x40) != 0; } }; @@ -55,19 +60,21 @@ private: // Icon data (loaded from SpellIcon.dbc) bool iconDbLoaded = false; std::unordered_map spellIconPaths; // SpellIconID -> path - std::unordered_map spellIconCache; // SpellIconID -> GL texture + std::unordered_map spellIconCache; // SpellIconID -> texture // Skill line data (loaded from SkillLine.dbc + SkillLineAbility.dbc) bool skillLineDbLoaded = false; - std::unordered_map skillLineNames; // skillLineID -> name - std::unordered_map skillLineCategories; // skillLineID -> categoryID - std::unordered_map spellToSkillLine; // spellID -> skillLineID + std::unordered_map skillLineNames; + std::unordered_map skillLineCategories; + std::unordered_map spellToSkillLine; - // Categorized spell tabs (rebuilt when spell list changes) - // ordered map so tabs appear in consistent order + // Categorized spell tabs std::vector spellTabs; size_t lastKnownSpellCount = 0; + // Search filter + char searchFilter_[128] = ""; + // Drag-and-drop from spellbook to action bar bool draggingSpell_ = false; uint32_t dragSpellId_ = 0; @@ -79,6 +86,9 @@ private: void categorizeSpells(const std::unordered_set& knownSpells); VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); const SpellInfo* getSpellInfo(uint32_t spellId) const; + + // Tooltip rendering helper + void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler); }; } // namespace ui diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 792e7706..18bbe152 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -20,8 +20,11 @@ public: private: void renderTalentTrees(game::GameHandler& gameHandler); - void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId); - void renderTalent(game::GameHandler& gameHandler, const game::GameHandler::TalentEntry& talent); + void renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId, + const std::string& bgFile); + void renderTalent(game::GameHandler& gameHandler, + const game::GameHandler::TalentEntry& talent, + uint32_t pointsInTree); void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); @@ -33,10 +36,11 @@ private: // DBC caches bool spellDbcLoaded = false; bool iconDbcLoaded = false; - std::unordered_map spellIconIds; // spellId -> iconId + std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path std::unordered_map spellIconCache; // iconId -> texture - std::unordered_map spellTooltips; // spellId -> description + std::unordered_map spellTooltips; // spellId -> description + std::unordered_map bgTextureCache_; // tabId -> bg texture }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index eb9d0a7a..994813c6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -661,7 +661,11 @@ void Application::logoutToLogin() { if (auto* m2 = renderer->getM2Renderer()) { m2->clear(); } - // TerrainManager will be re-initialized on next world entry + // Clear terrain tile tracking + water surfaces so next world entry starts fresh. + // Use softReset() instead of unloadAll() to avoid blocking on worker thread joins. + if (auto* terrain = renderer->getTerrainManager()) { + terrain->softReset(); + } if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { questMarkers->clear(); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 4f1c844a..22bd86be 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1774,6 +1774,12 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GUILD_COMMAND_RESULT: handleGuildCommandResult(packet); break; + case Opcode::SMSG_PETITION_SHOWLIST: + handlePetitionShowlist(packet); + break; + case Opcode::SMSG_TURN_IN_PETITION_RESULTS: + handleTurnInPetitionResults(packet); + break; // ---- Phase 5: Loot/Gossip/Vendor ---- case Opcode::SMSG_LOOT_RESPONSE: @@ -2781,11 +2787,36 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_AUCTION_COMMAND_RESULT: handleAuctionCommandResult(packet); break; - case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: - case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: - // Auction notification payloads are informational; ignore until UI support lands. + case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: { + // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ... + if (packet.getSize() - packet.getReadPos() >= 16) { + uint32_t auctionId = packet.readUInt32(); + uint32_t action = packet.readUInt32(); + uint32_t error = packet.readUInt32(); + uint32_t itemEntry = packet.readUInt32(); + (void)auctionId; (void)action; (void)error; + ensureItemInfo(itemEntry); + auto* info = getItemInfo(itemEntry); + std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); + addSystemChatMessage("Your auction of " + itemName + " has sold!"); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: { + // auctionId(u32) + itemEntry(u32) + ... + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t auctionId = packet.readUInt32(); + uint32_t itemEntry = packet.readUInt32(); + (void)auctionId; + ensureItemInfo(itemEntry); + auto* info = getItemInfo(itemEntry); + std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); + addSystemChatMessage("You have been outbid on " + itemName + "."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_TAXINODE_STATUS: // Node status cache not implemented yet. packet.setReadPos(packet.getSize()); @@ -9568,10 +9599,72 @@ void GameHandler::queryGuildInfo(uint32_t guildId) { LOG_INFO("Querying guild info: guildId=", guildId); } +void GameHandler::createGuild(const std::string& guildName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GuildCreatePacket::build(guildName); + socket->send(packet); + LOG_INFO("Creating guild: ", guildName); +} + +void GameHandler::addGuildRank(const std::string& rankName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GuildAddRankPacket::build(rankName); + socket->send(packet); + LOG_INFO("Adding guild rank: ", rankName); + // Refresh roster to update rank list + requestGuildRoster(); +} + +void GameHandler::deleteGuildRank() { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = GuildDelRankPacket::build(); + socket->send(packet); + LOG_INFO("Deleting last guild rank"); + // Refresh roster to update rank list + requestGuildRoster(); +} + +void GameHandler::requestPetitionShowlist(uint64_t npcGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = PetitionShowlistPacket::build(npcGuid); + socket->send(packet); +} + +void GameHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = PetitionBuyPacket::build(npcGuid, guildName); + socket->send(packet); + LOG_INFO("Buying guild petition: ", guildName); +} + +void GameHandler::handlePetitionShowlist(network::Packet& packet) { + PetitionShowlistData data; + if (!PetitionShowlistParser::parse(packet, data)) return; + + petitionNpcGuid_ = data.npcGuid; + petitionCost_ = data.cost; + showPetitionDialog_ = true; + LOG_INFO("Petition showlist: cost=", data.cost); +} + +void GameHandler::handleTurnInPetitionResults(network::Packet& packet) { + uint32_t result = 0; + if (!TurnInPetitionResultsParser::parse(packet, result)) return; + + switch (result) { + case 0: addSystemChatMessage("Guild created successfully!"); break; + case 1: addSystemChatMessage("Guild creation failed: already in a guild."); break; + case 2: addSystemChatMessage("Guild creation failed: not enough signatures."); break; + case 3: addSystemChatMessage("Guild creation failed: name already taken."); break; + default: addSystemChatMessage("Guild creation failed (error " + std::to_string(result) + ")."); break; + } +} + void GameHandler::handleGuildInfo(network::Packet& packet) { GuildInfoData data; if (!GuildInfoParser::parse(packet, data)) return; + guildInfoData_ = data; addSystemChatMessage("Guild: " + data.guildName + " (" + std::to_string(data.numMembers) + " members, " + std::to_string(data.numAccounts) + " accounts)"); @@ -9591,6 +9684,7 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; guildName_ = data.guildName; + guildQueryData_ = data; guildRankNames_.clear(); for (uint32_t i = 0; i < 10; ++i) { guildRankNames_.push_back(data.rankNames[i]); @@ -12851,10 +12945,114 @@ void GameHandler::sendMail(const std::string& recipient, const std::string& subj LOG_WARNING("sendMail: mailboxGuid_ is 0 (mailbox closed?)"); return; } - auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod); + // Collect attached item GUIDs + std::vector itemGuids; + for (const auto& att : mailAttachments_) { + if (att.occupied()) { + itemGuids.push_back(att.itemGuid); + } + } + auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids); LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money, - " mailboxGuid=", mailboxGuid_); + " attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_); socket->send(packet); + clearMailAttachments(); +} + +bool GameHandler::attachItemFromBackpack(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return false; + const auto& slot = inventory.getBackpackSlot(backpackIndex); + if (slot.empty()) return false; + + uint64_t itemGuid = backpackSlotGuids_[backpackIndex]; + if (itemGuid == 0) { + itemGuid = resolveOnlineItemGuid(slot.item.itemId); + } + if (itemGuid == 0) { + addSystemChatMessage("Cannot attach: item not found."); + return false; + } + + // Check not already attached + for (const auto& att : mailAttachments_) { + if (att.occupied() && att.itemGuid == itemGuid) return false; + } + + // Find free attachment slot + for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) { + if (!mailAttachments_[i].occupied()) { + mailAttachments_[i].itemGuid = itemGuid; + mailAttachments_[i].item = slot.item; + mailAttachments_[i].srcBag = 0xFF; + mailAttachments_[i].srcSlot = static_cast(23 + backpackIndex); + LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x", + std::hex, itemGuid, std::dec, " from backpack[", backpackIndex, "]"); + return true; + } + } + addSystemChatMessage("Cannot attach: all attachment slots full."); + return false; +} + +bool GameHandler::attachItemFromBag(int bagIndex, int slotIndex) { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return false; + if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return false; + const auto& slot = inventory.getBagSlot(bagIndex, slotIndex); + if (slot.empty()) return false; + + uint64_t itemGuid = 0; + uint64_t bagGuid = equipSlotGuids_[19 + bagIndex]; + if (bagGuid != 0) { + auto it = containerContents_.find(bagGuid); + if (it != containerContents_.end() && slotIndex < static_cast(it->second.numSlots)) { + itemGuid = it->second.slotGuids[slotIndex]; + } + } + if (itemGuid == 0) { + itemGuid = resolveOnlineItemGuid(slot.item.itemId); + } + if (itemGuid == 0) { + addSystemChatMessage("Cannot attach: item not found."); + return false; + } + + for (const auto& att : mailAttachments_) { + if (att.occupied() && att.itemGuid == itemGuid) return false; + } + + for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) { + if (!mailAttachments_[i].occupied()) { + mailAttachments_[i].itemGuid = itemGuid; + mailAttachments_[i].item = slot.item; + mailAttachments_[i].srcBag = static_cast(19 + bagIndex); + mailAttachments_[i].srcSlot = static_cast(slotIndex); + LOG_INFO("Mail attach: slot=", i, " item='", slot.item.name, "' guid=0x", + std::hex, itemGuid, std::dec, " from bag[", bagIndex, "][", slotIndex, "]"); + return true; + } + } + addSystemChatMessage("Cannot attach: all attachment slots full."); + return false; +} + +bool GameHandler::detachMailAttachment(int attachIndex) { + if (attachIndex < 0 || attachIndex >= MAIL_MAX_ATTACHMENTS) return false; + if (!mailAttachments_[attachIndex].occupied()) return false; + LOG_INFO("Mail detach: slot=", attachIndex, " item='", mailAttachments_[attachIndex].item.name, "'"); + mailAttachments_[attachIndex] = MailAttachSlot{}; + return true; +} + +void GameHandler::clearMailAttachments() { + for (auto& att : mailAttachments_) att = MailAttachSlot{}; +} + +int GameHandler::getMailAttachmentCount() const { + int count = 0; + for (const auto& att : mailAttachments_) { + if (att.occupied()) ++count; + } + return count; } void GameHandler::mailTakeMoney(uint32_t mailId) { @@ -13201,6 +13399,8 @@ void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8 addSystemChatMessage("Please wait before searching again."); return; } + // Save search params for pagination and auto-refresh + lastAuctionSearch_ = {name, levelMin, levelMax, quality, itemClass, itemSubClass, invTypeMask, usableOnly, offset}; pendingAuctionTarget_ = AuctionResultTarget::BROWSE; auto pkt = AuctionListItemsPacket::build(auctioneerGuid_, offset, name, levelMin, levelMax, invTypeMask, @@ -13333,9 +13533,16 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) { if (result.errorCode == 0) { std::string msg = std::string("Auction ") + actionName + " successful."; addSystemChatMessage(msg); - // Refresh appropriate list - if (result.action == 0) auctionListOwnerItems(); - else if (result.action == 1) auctionListOwnerItems(); + // Refresh appropriate lists + if (result.action == 0) auctionListOwnerItems(); // create + else if (result.action == 1) auctionListOwnerItems(); // cancel + else if (result.action == 2 || result.action == 3) { // bid or buyout + auctionListBidderItems(); + // Re-query browse results with the same filters the user last searched with + const auto& s = lastAuctionSearch_; + auctionSearch(s.name, s.levelMin, s.levelMax, s.quality, + s.itemClass, s.itemSubClass, s.invTypeMask, s.usableOnly, s.offset); + } } else { const char* errors[] = {"OK", "Inventory", "Not enough money", "Item not found", "Higher bid", "Increment", "Not enough items", diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 6dcfe934..a6e81764 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -728,7 +728,8 @@ network::Packet ClassicPacketParsers::buildSendMail(uint64_t mailboxGuid, const std::string& recipient, const std::string& subject, const std::string& body, - uint32_t money, uint32_t cod) { + uint32_t money, uint32_t cod, + const std::vector& itemGuids) { network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL)); packet.writeUInt64(mailboxGuid); packet.writeString(recipient); @@ -736,7 +737,9 @@ network::Packet ClassicPacketParsers::buildSendMail(uint64_t mailboxGuid, packet.writeString(body); packet.writeUInt32(0); // stationery packet.writeUInt32(0); // unknown - packet.writeUInt64(0); // item GUID (0 = no attachment, single item only in Vanilla) + // Vanilla supports only one item attachment (single uint64 GUID) + uint64_t singleItemGuid = itemGuids.empty() ? 0 : itemGuids[0]; + packet.writeUInt64(singleItemGuid); packet.writeUInt32(money); packet.writeUInt32(cod); packet.writeUInt64(0); // unk3 (clients > 1.9.4) diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 51b7bf44..260ea594 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1843,6 +1843,85 @@ network::Packet GuildDeclineInvitationPacket::build() { return packet; } +network::Packet GuildCreatePacket::build(const std::string& guildName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_CREATE)); + packet.writeString(guildName); + LOG_DEBUG("Built CMSG_GUILD_CREATE: ", guildName); + return packet; +} + +network::Packet GuildAddRankPacket::build(const std::string& rankName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ADD_RANK)); + packet.writeString(rankName); + LOG_DEBUG("Built CMSG_GUILD_ADD_RANK: ", rankName); + return packet; +} + +network::Packet GuildDelRankPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEL_RANK)); + LOG_DEBUG("Built CMSG_GUILD_DEL_RANK"); + return packet; +} + +network::Packet PetitionShowlistPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_SHOWLIST)); + packet.writeUInt64(npcGuid); + LOG_DEBUG("Built CMSG_PETITION_SHOWLIST: guid=", npcGuid); + return packet; +} + +network::Packet PetitionBuyPacket::build(uint64_t npcGuid, const std::string& guildName) { + network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_BUY)); + packet.writeUInt64(npcGuid); // NPC GUID + packet.writeUInt32(0); // unk + packet.writeUInt64(0); // unk + packet.writeString(guildName); // guild name + packet.writeUInt32(0); // body text (empty) + packet.writeUInt32(0); // min sigs + packet.writeUInt32(0); // max sigs + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt16(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk index + packet.writeUInt32(0); // unk + LOG_DEBUG("Built CMSG_PETITION_BUY: npcGuid=", npcGuid, " name=", guildName); + return packet; +} + +bool PetitionShowlistParser::parse(network::Packet& packet, PetitionShowlistData& data) { + if (packet.getSize() < 12) { + LOG_ERROR("SMSG_PETITION_SHOWLIST too small: ", packet.getSize()); + return false; + } + data.npcGuid = packet.readUInt64(); + uint32_t count = packet.readUInt32(); + if (count > 0) { + data.itemId = packet.readUInt32(); + data.displayId = packet.readUInt32(); + data.cost = packet.readUInt32(); + // Skip unused fields if present + if ((packet.getSize() - packet.getReadPos()) >= 8) { + data.charterType = packet.readUInt32(); + data.requiredSigs = packet.readUInt32(); + } + } + LOG_INFO("Parsed SMSG_PETITION_SHOWLIST: npcGuid=", data.npcGuid, " cost=", data.cost); + return true; +} + +bool TurnInPetitionResultsParser::parse(network::Packet& packet, uint32_t& result) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_TURN_IN_PETITION_RESULTS too small: ", packet.getSize()); + return false; + } + result = packet.readUInt32(); + LOG_INFO("Parsed SMSG_TURN_IN_PETITION_RESULTS: result=", result); + return true; +} + bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponseData& data) { if (packet.getSize() < 8) { LOG_ERROR("SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize()); @@ -3939,7 +4018,8 @@ network::Packet GetMailListPacket::build(uint64_t mailboxGuid) { network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& recipient, const std::string& subject, const std::string& body, - uint32_t money, uint32_t cod) { + uint32_t money, uint32_t cod, + const std::vector& itemGuids) { // WotLK 3.3.5a format network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL)); packet.writeUInt64(mailboxGuid); @@ -3948,7 +4028,12 @@ network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& r packet.writeString(body); packet.writeUInt32(0); // stationery packet.writeUInt32(0); // unknown - packet.writeUInt8(0); // attachment count (0 = no attachments) + uint8_t attachCount = static_cast(itemGuids.size()); + packet.writeUInt8(attachCount); + for (uint8_t i = 0; i < attachCount; ++i) { + packet.writeUInt8(i); // attachment slot index + packet.writeUInt64(itemGuids[i]); + } packet.writeUInt32(money); packet.writeUInt32(cod); return packet; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 5caeab9d..ee118c1f 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -705,7 +705,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { // Load water immediately after terrain (same frame) — water is now // deduplicated to ~1-2 merged surfaces per tile, so this is fast. if (waterRenderer) { + size_t beforeSurfaces = waterRenderer->getSurfaceCount(); waterRenderer->loadFromTerrain(pending->terrain, true, x, y); + size_t afterSurfaces = waterRenderer->getSurfaceCount(); + if (afterSurfaces > beforeSurfaces) { + LOG_INFO("Water: tile [", x, ",", y, "] added ", afterSurfaces - beforeSurfaces, + " surfaces (total: ", afterSurfaces, ")"); + } + } else { + LOG_WARNING("Water: waterRenderer is null during tile [", x, ",", y, "] finalization!"); } // Ensure M2 renderer has asset manager @@ -1230,13 +1238,35 @@ void TerrainManager::unloadTile(int x, int y) { } void TerrainManager::unloadAll() { - // Stop worker threads + // Signal worker threads to stop and wait briefly for them to finish. + // Workers may be mid-prepareTile (reading MPQ / parsing ADT) which can + // take seconds, so use a short deadline and detach any stragglers. if (workerRunning.load()) { workerRunning.store(false); queueCV.notify_all(); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); for (auto& t : workerThreads) { - if (t.joinable()) { - t.join(); + if (!t.joinable()) continue; + // Try a timed wait via polling — std::thread has no timed join. + bool joined = false; + while (std::chrono::steady_clock::now() < deadline) { + // Check if thread finished by trying a native timed join + #ifdef __linux__ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_nsec += 50000000; // 50ms + if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; } + if (pthread_timedjoin_np(t.native_handle(), nullptr, &ts) == 0) { + joined = true; + break; + } + #else + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + #endif + } + if (!joined && t.joinable()) { + t.detach(); } } workerThreads.clear(); @@ -1288,6 +1318,32 @@ void TerrainManager::unloadAll() { } } +void TerrainManager::softReset() { + // Clear queues (workers may still be running — they'll find empty queues) + { + std::lock_guard lock(queueMutex); + loadQueue.clear(); + while (!readyQueue.empty()) readyQueue.pop(); + } + pendingTiles.clear(); + finalizingTiles_.clear(); + placedDoodadIds.clear(); + + LOG_INFO("Soft-resetting terrain (clearing tiles + water, workers stay alive)"); + loadedTiles.clear(); + failedTiles.clear(); + + currentTile = {-1, -1}; + lastStreamTile = {-1, -1}; + + if (terrainRenderer) { + terrainRenderer->clear(); + } + if (waterRenderer) { + waterRenderer->clear(); + } +} + TileCoord TerrainManager::worldToTile(float glX, float glY) const { auto [tileX, tileY] = core::coords::worldToTile(glX, glY); return {tileX, tileY}; diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 8ecb1d7a..09d07b0b 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -76,6 +76,7 @@ bool WaterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLay VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_WATER_SETS; poolInfo.poolSizeCount = 1; poolInfo.pPoolSizes = &poolSize; @@ -541,6 +542,8 @@ void WaterRenderer::updateMaterialUBO(WaterSurface& surface) { write.pBufferInfo = &bufInfo; vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } else { + LOG_WARNING("Water: failed to allocate material descriptor set (pool exhaustion?)"); } } @@ -802,8 +805,10 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap totalSurfaces++; } - LOG_DEBUG("Water: Loaded ", totalSurfaces, " surfaces from tile [", tileX, ",", tileY, - "] (", mergeGroups.size(), " groups), total surfaces: ", surfaces.size()); + if (totalSurfaces > 0) { + LOG_INFO("Water: Loaded ", totalSurfaces, " surfaces from tile [", tileX, ",", tileY, + "] (", mergeGroups.size(), " groups), total surfaces: ", surfaces.size()); + } } void WaterRenderer::removeTile(int tileX, int tileY) { @@ -936,8 +941,21 @@ void WaterRenderer::clear() { void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& /*camera*/, float /*time*/, bool use1x) { VkPipeline pipeline = (use1x && water1xPipeline) ? water1xPipeline : waterPipeline; - if (!renderingEnabled || surfaces.empty() || !pipeline) return; - if (!sceneSet) return; + if (!renderingEnabled || surfaces.empty() || !pipeline) { + if (renderDiagCounter_++ % 300 == 0 && !surfaces.empty()) { + LOG_WARNING("Water: render skipped — enabled=", renderingEnabled, + " surfaces=", surfaces.size(), + " pipeline=", (pipeline ? "ok" : "null"), + " use1x=", use1x); + } + return; + } + if (!sceneSet) { + if (renderDiagCounter_++ % 300 == 0) { + LOG_WARNING("Water: render skipped — sceneSet is null, surfaces=", surfaces.size()); + } + return; + } vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); @@ -1251,6 +1269,9 @@ void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { destroyBuffer(allocator, ab); surface.materialUBO = VK_NULL_HANDLE; } + if (surface.materialSet && materialDescPool) { + vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet); + } surface.materialSet = VK_NULL_HANDLE; } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d1e6fa22..44a37578 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2357,6 +2357,24 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /gcreate command + if (cmdLower == "gcreate" || cmdLower == "guildcreate") { + if (spacePos != std::string::npos) { + std::string guildName = command.substr(spacePos + 1); + gameHandler.createGuild(guildName); + chatInputBuffer[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gcreate "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer[0] = '\0'; + return; + } + // /gdisband command if (cmdLower == "gdisband" || cmdLower == "guilddisband") { gameHandler.disbandGuild(); @@ -4300,11 +4318,50 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { } } gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); } } + // Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST) + if (gameHandler.hasPetitionShowlist()) { + ImGui::OpenPopup("CreateGuildPetition"); + gameHandler.clearPetitionDialog(); + } + if (ImGui::BeginPopupModal("CreateGuildPetition", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Create Guild Charter"); + ImGui::Separator(); + uint32_t cost = gameHandler.getPetitionCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + ImGui::Text("Cost: %ug %us %uc", gold, silver, copper); + ImGui::Spacing(); + ImGui::Text("Guild Name:"); + ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); + ImGui::Spacing(); + if (ImGui::Button("Create", ImVec2(120, 0))) { + if (petitionNameBuffer_[0] != '\0') { + gameHandler.buyPetition(gameHandler.getPetitionNpcGuid(), petitionNameBuffer_); + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + petitionNameBuffer_[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + if (!showGuildRoster_) return; + // Get zone manager for name lookup + game::ZoneManager* zoneManager = nullptr; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + zoneManager = renderer->getZoneManager(); + } + auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; @@ -4312,164 +4369,311 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Roster") : "Guild Roster"; + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild"; bool open = showGuildRoster_; if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { - if (!gameHandler.hasGuildRoster()) { - ImGui::Text("Loading roster..."); - } else { - const auto& roster = gameHandler.getGuildRoster(); + // Tab bar: Roster | Guild Info + if (ImGui::BeginTabBar("GuildTabs")) { + if (ImGui::BeginTabItem("Roster")) { + guildRosterTab_ = 0; + if (!gameHandler.hasGuildRoster()) { + ImGui::Text("Loading roster..."); + } else { + const auto& roster = gameHandler.getGuildRoster(); - // MOTD - if (!roster.motd.empty()) { - ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); - ImGui::Separator(); - } - - // Count online - int onlineCount = 0; - for (const auto& m : roster.members) { - if (m.online) ++onlineCount; - } - ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); - ImGui::Separator(); - - const auto& rankNames = gameHandler.getGuildRankNames(); - - // Table - if (ImGui::BeginTable("GuildRoster", 7, - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | - ImGuiTableFlags_Sortable)) { - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); - ImGui::TableSetupColumn("Rank"); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); - ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableSetupColumn("Note"); - ImGui::TableSetupColumn("Officer Note"); - ImGui::TableHeadersRow(); - - // Online members first, then offline - auto sortedMembers = roster.members; - std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { - if (a.online != b.online) return a.online > b.online; - return a.name < b.name; - }); - - static const char* classNames[] = { - "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", - "Priest", "Death Knight", "Shaman", "Mage", "Warlock", - "", "Druid" - }; - - for (const auto& m : sortedMembers) { - ImGui::TableNextRow(); - ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) - : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.name.c_str()); - - // Right-click context menu - if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - selectedGuildMember_ = m.name; - ImGui::OpenPopup("GuildMemberContext"); + // MOTD + if (!roster.motd.empty()) { + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str()); + ImGui::Separator(); } - ImGui::TableNextColumn(); - // Show rank name instead of index - if (m.rankIndex < rankNames.size()) { - ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); - } else { - ImGui::TextColored(textColor, "Rank %u", m.rankIndex); + // Count online + int onlineCount = 0; + for (const auto& m : roster.members) { + if (m.online) ++onlineCount; } + ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); + ImGui::Separator(); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%u", m.level); + const auto& rankNames = gameHandler.getGuildRankNames(); - ImGui::TableNextColumn(); - const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - ImGui::TextColored(textColor, "%s", className); + // Table + if (ImGui::BeginTable("GuildRoster", 7, + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_Sortable)) { + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); + ImGui::TableSetupColumn("Rank"); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Note"); + ImGui::TableSetupColumn("Officer Note"); + ImGui::TableHeadersRow(); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%u", m.zoneId); + // Online members first, then offline + auto sortedMembers = roster.members; + std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) { + if (a.online != b.online) return a.online > b.online; + return a.name < b.name; + }); - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + static const char* classNames[] = { + "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", + "Priest", "Death Knight", "Shaman", "Mage", "Warlock", + "", "Druid" + }; - ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); - } - ImGui::EndTable(); - } + for (const auto& m : sortedMembers) { + ImGui::TableNextRow(); + ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); - // Context menu popup - if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::Text("%s", selectedGuildMember_.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Promote")) { - gameHandler.promoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Demote")) { - gameHandler.demoteGuildMember(selectedGuildMember_); - } - if (ImGui::MenuItem("Kick")) { - gameHandler.kickGuildMember(selectedGuildMember_); - } - ImGui::Separator(); - if (ImGui::MenuItem("Set Public Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = false; - guildNoteEditBuffer_[0] = '\0'; - // Pre-fill with existing note - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); - break; + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.name.c_str()); + + // Right-click context menu + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + selectedGuildMember_ = m.name; + ImGui::OpenPopup("GuildMemberContext"); + } + + ImGui::TableNextColumn(); + // Show rank name instead of index + if (m.rankIndex < rankNames.size()) { + ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str()); + } else { + ImGui::TextColored(textColor, "Rank %u", m.rankIndex); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%u", m.level); + + ImGui::TableNextColumn(); + const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; + ImGui::TextColored(textColor, "%s", className); + + ImGui::TableNextColumn(); + // Zone name lookup + if (zoneManager) { + const auto* zoneInfo = zoneManager->getZoneInfo(m.zoneId); + if (zoneInfo && !zoneInfo->name.empty()) { + ImGui::TextColored(textColor, "%s", zoneInfo->name.c_str()); + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + } else { + ImGui::TextColored(textColor, "%u", m.zoneId); + } + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); + + ImGui::TableNextColumn(); + ImGui::TextColored(textColor, "%s", m.officerNote.c_str()); } + ImGui::EndTable(); } - } - if (ImGui::MenuItem("Set Officer Note...")) { - showGuildNoteEdit_ = true; - editingOfficerNote_ = true; - guildNoteEditBuffer_[0] = '\0'; - for (const auto& mem : roster.members) { - if (mem.name == selectedGuildMember_) { - snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); - break; + + // Context menu popup + if (ImGui::BeginPopup("GuildMemberContext")) { + ImGui::Text("%s", selectedGuildMember_.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Promote")) { + gameHandler.promoteGuildMember(selectedGuildMember_); } + if (ImGui::MenuItem("Demote")) { + gameHandler.demoteGuildMember(selectedGuildMember_); + } + if (ImGui::MenuItem("Kick")) { + gameHandler.kickGuildMember(selectedGuildMember_); + } + ImGui::Separator(); + if (ImGui::MenuItem("Set Public Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = false; + guildNoteEditBuffer_[0] = '\0'; + // Pre-fill with existing note + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str()); + break; + } + } + } + if (ImGui::MenuItem("Set Officer Note...")) { + showGuildNoteEdit_ = true; + editingOfficerNote_ = true; + guildNoteEditBuffer_[0] = '\0'; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { + snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str()); + break; + } + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Set as Leader")) { + gameHandler.setGuildLeader(selectedGuildMember_); + } + ImGui::EndPopup(); + } + + // Note edit modal + if (showGuildNoteEdit_) { + ImGui::OpenPopup("EditGuildNote"); + showGuildNoteEdit_ = false; + } + if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("%s %s for %s:", + editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); + ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); + if (ImGui::Button("Save")) { + if (editingOfficerNote_) { + gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); + } else { + gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); } } - ImGui::Separator(); - if (ImGui::MenuItem("Set as Leader")) { - gameHandler.setGuildLeader(selectedGuildMember_); - } - ImGui::EndPopup(); + ImGui::EndTabItem(); } - // Note edit modal - if (showGuildNoteEdit_) { - ImGui::OpenPopup("EditGuildNote"); - showGuildNoteEdit_ = false; - } - if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::Text("%s %s for %s:", - editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str()); - ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_)); - if (ImGui::Button("Save")) { - if (editingOfficerNote_) { - gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_); - } else { - gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_); + if (ImGui::BeginTabItem("Guild Info")) { + guildRosterTab_ = 1; + const auto& infoData = gameHandler.getGuildInfoData(); + const auto& queryData = gameHandler.getGuildQueryData(); + const auto& roster = gameHandler.getGuildRoster(); + const auto& rankNames = gameHandler.getGuildRankNames(); + + // Guild name (large, gold) + ImGui::PushFont(nullptr); // default font + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "<%s>", gameHandler.getGuildName().c_str()); + ImGui::PopFont(); + ImGui::Separator(); + + // Creation date + if (infoData.isValid()) { + ImGui::Text("Created: %u/%u/%u", infoData.creationDay, infoData.creationMonth, infoData.creationYear); + ImGui::Text("Members: %u | Accounts: %u", infoData.numMembers, infoData.numAccounts); + } + ImGui::Spacing(); + + // Guild description / info text + if (!roster.guildInfo.empty()) { + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "Description:"); + ImGui::TextWrapped("%s", roster.guildInfo.c_str()); + } + ImGui::Spacing(); + + // MOTD with edit button + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD:"); + ImGui::SameLine(); + if (!roster.motd.empty()) { + ImGui::TextWrapped("%s", roster.motd.c_str()); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "(not set)"); + } + if (ImGui::Button("Set MOTD")) { + showMotdEdit_ = true; + snprintf(guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_), "%s", roster.motd.c_str()); + } + ImGui::Spacing(); + + // MOTD edit modal + if (showMotdEdit_) { + ImGui::OpenPopup("EditMotd"); + showMotdEdit_ = false; + } + if (ImGui::BeginPopupModal("EditMotd", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Set Message of the Day:"); + ImGui::InputText("##motdinput", guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_)); + if (ImGui::Button("Save", ImVec2(120, 0))) { + gameHandler.setGuildMotd(guildMotdEditBuffer_); + ImGui::CloseCurrentPopup(); } - ImGui::CloseCurrentPopup(); + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + // Emblem info + if (queryData.isValid()) { + ImGui::Separator(); + ImGui::Text("Emblem: Style %u, Color %u | Border: Style %u, Color %u | BG: %u", + queryData.emblemStyle, queryData.emblemColor, + queryData.borderStyle, queryData.borderColor, queryData.backgroundColor); + } + + // Rank list + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Ranks:"); + for (size_t i = 0; i < rankNames.size(); ++i) { + if (rankNames[i].empty()) continue; + // Show rank permission summary from roster data + if (i < roster.ranks.size()) { + uint32_t rights = roster.ranks[i].rights; + std::string perms; + if (rights & 0x01) perms += "Invite "; + if (rights & 0x02) perms += "Remove "; + if (rights & 0x40) perms += "Promote "; + if (rights & 0x80) perms += "Demote "; + if (rights & 0x04) perms += "OChat "; + if (rights & 0x10) perms += "MOTD "; + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + if (!perms.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "[%s]", perms.c_str()); + } + } else { + ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str()); + } + } + + // Rank management buttons + ImGui::Spacing(); + if (ImGui::Button("Add Rank")) { + showAddRankModal_ = true; + addRankNameBuffer_[0] = '\0'; } ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - ImGui::CloseCurrentPopup(); + if (ImGui::Button("Delete Last Rank")) { + gameHandler.deleteGuildRank(); } - ImGui::EndPopup(); + + // Add rank modal + if (showAddRankModal_) { + ImGui::OpenPopup("AddGuildRank"); + showAddRankModal_ = false; + } + if (ImGui::BeginPopupModal("AddGuildRank", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("New Rank Name:"); + ImGui::InputText("##rankname", addRankNameBuffer_, sizeof(addRankNameBuffer_)); + if (ImGui::Button("Add", ImVec2(120, 0))) { + if (addRankNameBuffer_[0] != '\0') { + gameHandler.addGuildRank(addRankNameBuffer_); + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); } + + ImGui::EndTabBar(); } } ImGui::End(); @@ -7384,8 +7588,8 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, screenH / 2 - 200), ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(380, 400), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 250), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_Appearing); bool open = true; if (ImGui::Begin("Send Mail", &open)) { @@ -7401,8 +7605,56 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { ImGui::Text("Body:"); ImGui::InputTextMultiline("##MailBody", mailBodyBuffer_, sizeof(mailBodyBuffer_), - ImVec2(-1, 150)); + ImVec2(-1, 120)); + // Attachments section + int attachCount = gameHandler.getMailAttachmentCount(); + ImGui::Text("Attachments (%d/12):", attachCount); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Right-click items in bags to attach"); + + const auto& attachments = gameHandler.getMailAttachments(); + // Show attachment slots in a grid (6 per row) + for (int i = 0; i < game::GameHandler::MAIL_MAX_ATTACHMENTS; ++i) { + if (i % 6 != 0) ImGui::SameLine(); + ImGui::PushID(i + 5000); + const auto& att = attachments[i]; + if (att.occupied()) { + // Show item with quality color border + ImVec4 qualColor = ui::InventoryScreen::getQualityColor(att.item.quality); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qualColor.x * 0.3f, qualColor.y * 0.3f, qualColor.z * 0.3f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qualColor.x * 0.5f, qualColor.y * 0.5f, qualColor.z * 0.5f, 0.9f)); + + // Try to show icon + VkDescriptorSet icon = inventoryScreen.getItemIcon(att.item.displayInfoId); + bool clicked = false; + if (icon) { + clicked = ImGui::ImageButton("##att", (ImTextureID)icon, ImVec2(30, 30)); + } else { + // Truncate name to fit + std::string label = att.item.name.substr(0, 4); + clicked = ImGui::Button(label.c_str(), ImVec2(36, 36)); + } + ImGui::PopStyleColor(2); + + if (clicked) { + gameHandler.detachMailAttachment(i); + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextColored(qualColor, "%s", att.item.name.c_str()); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Click to remove"); + ImGui::EndTooltip(); + } + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.5f)); + ImGui::Button("##empty", ImVec2(36, 36)); + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + ImGui::Spacing(); ImGui::Text("Money:"); ImGui::SameLine(60); ImGui::SetNextItemWidth(60); @@ -7429,7 +7681,8 @@ void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) { static_cast(mailComposeMoney_[1]) * 100 + static_cast(mailComposeMoney_[2]); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Sending cost: 30c"); + uint32_t sendCost = attachCount > 0 ? static_cast(30 * attachCount) : 30u; + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Sending cost: %uc", sendCost); ImGui::Spacing(); bool canSend = (strlen(mailRecipientBuffer_) > 0); @@ -7472,13 +7725,24 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { // Main bank slots (28 = 7 columns × 4 rows) ImGui::Text("Bank Slots"); ImGui::Separator(); + bool isHolding = inventoryScreen.isHoldingItem(); for (int i = 0; i < game::Inventory::BANK_SLOTS; i++) { if (i % 7 != 0) ImGui::SameLine(); const auto& slot = inv.getBankSlot(i); ImGui::PushID(i + 1000); if (slot.empty()) { + // Highlight as drop target when holding an item + if (isHolding) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.08f, 0.20f, 0.08f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.35f, 0.0f, 0.9f)); + } ImGui::Button("##bank", ImVec2(42, 42)); + if (isHolding) ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && isHolding) { + // Drop held item into empty bank slot + inventoryScreen.dropIntoBankSlot(gameHandler, 0xFF, static_cast(39 + i)); + } } else { ImVec4 qc = InventoryScreen::getQualityColor(slot.item.quality); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); @@ -7486,13 +7750,17 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { std::string label = std::to_string(slot.item.stackCount > 1 ? slot.item.stackCount : 0); if (slot.item.stackCount <= 1) label = "##b" + std::to_string(i); - if (ImGui::Button(label.c_str(), ImVec2(42, 42))) { - // Right-click to withdraw: bag=0xFF means bank, slot=i - // Use CMSG_AUTOSTORE_BANK_ITEM with bank container - // WoW bank slots are inventory slots 39-66 (BANK_SLOT_1 = 39) - gameHandler.withdrawItem(0xFF, static_cast(39 + i)); - } + ImGui::Button(label.c_str(), ImVec2(42, 42)); ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (isHolding) { + // Swap held item with bank slot + inventoryScreen.dropIntoBankSlot(gameHandler, 0xFF, static_cast(39 + i)); + } else { + // Withdraw on click + gameHandler.withdrawItem(0xFF, static_cast(39 + i)); + } + } if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(qc, "%s", slot.item.name.c_str()); @@ -7537,17 +7805,29 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { const auto& slot = inv.getBankBagSlot(bagIdx, s); ImGui::PushID(3000 + bagIdx * 100 + s); if (slot.empty()) { + if (isHolding) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.08f, 0.20f, 0.08f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.35f, 0.0f, 0.9f)); + } ImGui::Button("##bb", ImVec2(42, 42)); + if (isHolding) ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && isHolding) { + inventoryScreen.dropIntoBankSlot(gameHandler, static_cast(67 + bagIdx), static_cast(s)); + } } else { ImVec4 qc = InventoryScreen::getQualityColor(slot.item.quality); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); std::string lbl = slot.item.stackCount > 1 ? std::to_string(slot.item.stackCount) : ("##bb" + std::to_string(bagIdx * 100 + s)); - if (ImGui::Button(lbl.c_str(), ImVec2(42, 42))) { - // Withdraw from bank bag: bank bag container indices start at 67 - gameHandler.withdrawItem(static_cast(67 + bagIdx), static_cast(s)); - } + ImGui::Button(lbl.c_str(), ImVec2(42, 42)); ImGui::PopStyleColor(2); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (isHolding) { + inventoryScreen.dropIntoBankSlot(gameHandler, static_cast(67 + bagIdx), static_cast(s)); + } else { + gameHandler.withdrawItem(static_cast(67 + bagIdx), static_cast(s)); + } + } if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); ImGui::TextColored(qc, "%s", slot.item.name.c_str()); @@ -7717,8 +7997,74 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (tab == 0) { // Browse tab - Search filters + + // --- Helper: resolve current UI filter state into wire-format search params --- + // WoW 3.3.5a item class IDs: + // 0=Consumable, 1=Container, 2=Weapon, 3=Gem, 4=Armor, + // 7=Projectile/TradeGoods, 9=Recipe, 11=Quiver, 15=Miscellaneous + struct AHClassMapping { const char* label; uint32_t classId; }; + static const AHClassMapping classMappings[] = { + {"All", 0xFFFFFFFF}, + {"Weapon", 2}, + {"Armor", 4}, + {"Container", 1}, + {"Consumable", 0}, + {"Trade Goods", 7}, + {"Gem", 3}, + {"Recipe", 9}, + {"Quiver", 11}, + {"Miscellaneous", 15}, + }; + static constexpr int NUM_CLASSES = 10; + + // Weapon subclass IDs (WoW 3.3.5a) + struct AHSubMapping { const char* label; uint32_t subId; }; + static const AHSubMapping weaponSubs[] = { + {"All", 0xFFFFFFFF}, {"Axe (1H)", 0}, {"Axe (2H)", 1}, {"Bow", 2}, + {"Gun", 3}, {"Mace (1H)", 4}, {"Mace (2H)", 5}, {"Polearm", 6}, + {"Sword (1H)", 7}, {"Sword (2H)", 8}, {"Staff", 10}, + {"Fist Weapon", 13}, {"Dagger", 15}, {"Thrown", 16}, + {"Crossbow", 18}, {"Wand", 19}, + }; + static constexpr int NUM_WEAPON_SUBS = 16; + + // Armor subclass IDs + static const AHSubMapping armorSubs[] = { + {"All", 0xFFFFFFFF}, {"Cloth", 1}, {"Leather", 2}, {"Mail", 3}, + {"Plate", 4}, {"Shield", 6}, {"Miscellaneous", 0}, + }; + static constexpr int NUM_ARMOR_SUBS = 7; + + auto getSearchClassId = [&]() -> uint32_t { + if (auctionItemClass_ < 0 || auctionItemClass_ >= NUM_CLASSES) return 0xFFFFFFFF; + return classMappings[auctionItemClass_].classId; + }; + + auto getSearchSubClassId = [&]() -> uint32_t { + if (auctionItemSubClass_ < 0) return 0xFFFFFFFF; + uint32_t cid = getSearchClassId(); + if (cid == 2 && auctionItemSubClass_ < NUM_WEAPON_SUBS) + return weaponSubs[auctionItemSubClass_].subId; + if (cid == 4 && auctionItemSubClass_ < NUM_ARMOR_SUBS) + return armorSubs[auctionItemSubClass_].subId; + return 0xFFFFFFFF; + }; + + auto doSearch = [&](uint32_t offset) { + auctionBrowseOffset_ = offset; + if (auctionLevelMin_ < 0) auctionLevelMin_ = 0; + if (auctionLevelMax_ < 0) auctionLevelMax_ = 0; + uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; + gameHandler.auctionSearch(auctionSearchName_, + static_cast(auctionLevelMin_), + static_cast(auctionLevelMax_), + q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset); + }; + + // Row 1: Name + Level range ImGui::SetNextItemWidth(200); - ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_)); + bool enterPressed = ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_), + ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); ImGui::SetNextItemWidth(50); ImGui::InputInt("Min Lv", &auctionLevelMin_, 0); @@ -7726,23 +8072,49 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(50); ImGui::InputInt("Max Lv", &auctionLevelMax_, 0); + // Row 2: Quality + Category + Subcategory + Search button const char* qualities[] = {"All", "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary"}; ImGui::SetNextItemWidth(100); ImGui::Combo("Quality", &auctionQuality_, qualities, 7); + ImGui::SameLine(); + // Build class label list from mappings + const char* classLabels[NUM_CLASSES]; + for (int c = 0; c < NUM_CLASSES; c++) classLabels[c] = classMappings[c].label; + ImGui::SetNextItemWidth(120); + int classIdx = auctionItemClass_ < 0 ? 0 : auctionItemClass_; + if (ImGui::Combo("Category", &classIdx, classLabels, NUM_CLASSES)) { + if (classIdx != auctionItemClass_) auctionItemSubClass_ = -1; + auctionItemClass_ = classIdx; + } + + // Subcategory (only for Weapon and Armor) + uint32_t curClassId = getSearchClassId(); + if (curClassId == 2 || curClassId == 4) { + const AHSubMapping* subs = (curClassId == 2) ? weaponSubs : armorSubs; + int numSubs = (curClassId == 2) ? NUM_WEAPON_SUBS : NUM_ARMOR_SUBS; + const char* subLabels[20]; + for (int s = 0; s < numSubs && s < 20; s++) subLabels[s] = subs[s].label; + int subIdx = auctionItemSubClass_ + 1; // -1 → 0 ("All") + if (subIdx < 0 || subIdx >= numSubs) subIdx = 0; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110); + if (ImGui::Combo("Subcat", &subIdx, subLabels, numSubs)) { + auctionItemSubClass_ = subIdx - 1; // 0 → -1 ("All") + } + } + ImGui::SameLine(); float delay = gameHandler.getAuctionSearchDelay(); if (delay > 0.0f) { + char delayBuf[32]; + snprintf(delayBuf, sizeof(delayBuf), "Search (%.0fs)", delay); ImGui::BeginDisabled(); - ImGui::Button("Search..."); + ImGui::Button(delayBuf); ImGui::EndDisabled(); } else { - if (ImGui::Button("Search")) { - uint32_t q = auctionQuality_ > 0 ? static_cast(auctionQuality_ - 1) : 0xFFFFFFFF; - gameHandler.auctionSearch(auctionSearchName_, - static_cast(auctionLevelMin_), - static_cast(auctionLevelMax_), - q, 0xFFFFFFFF, 0xFFFFFFFF, 0, 0); + if (ImGui::Button("Search") || enterPressed) { + doSearch(0); } } @@ -7750,9 +8122,34 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Results table const auto& results = gameHandler.getAuctionBrowseResults(); + constexpr uint32_t AH_PAGE_SIZE = 50; ImGui::Text("%zu results (of %u total)", results.auctions.size(), results.totalCount); - if (ImGui::BeginChild("AuctionResults", ImVec2(0, -80), true)) { + // Pagination + if (results.totalCount > AH_PAGE_SIZE) { + ImGui::SameLine(); + uint32_t page = auctionBrowseOffset_ / AH_PAGE_SIZE + 1; + uint32_t totalPages = (results.totalCount + AH_PAGE_SIZE - 1) / AH_PAGE_SIZE; + + if (auctionBrowseOffset_ == 0) ImGui::BeginDisabled(); + if (ImGui::SmallButton("< Prev")) { + uint32_t newOff = (auctionBrowseOffset_ >= AH_PAGE_SIZE) ? auctionBrowseOffset_ - AH_PAGE_SIZE : 0; + doSearch(newOff); + } + if (auctionBrowseOffset_ == 0) ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::Text("Page %u/%u", page, totalPages); + + ImGui::SameLine(); + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Next >")) { + doSearch(auctionBrowseOffset_ + AH_PAGE_SIZE); + } + if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::EndDisabled(); + } + + if (ImGui::BeginChild("AuctionResults", ImVec2(0, -110), true)) { if (ImGui::BeginTable("AuctionTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); @@ -7771,7 +8168,47 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); + // Item icon + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } ImGui::TextColored(qc, "%s", name.c_str()); + // Item tooltip on hover + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", info->name.c_str()); + if (info->inventoryType > 0) { + if (!info->subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1), "%s", info->subclassName.c_str()); + } + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + std::string bonusLine; + auto appendStat = [](std::string& out, int32_t val, const char* n) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + n; + }; + appendStat(bonusLine, info->strength, "Str"); + appendStat(bonusLine, info->agility, "Agi"); + appendStat(bonusLine, info->stamina, "Sta"); + appendStat(bonusLine, info->intellect, "Int"); + appendStat(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + if (info->sellPrice > 0) { + ImGui::TextColored(ImVec4(1, 0.84f, 0, 1), "Sell: %ug %us %uc", + info->sellPrice / 10000, (info->sellPrice / 100) % 100, info->sellPrice % 100); + } + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", auction.stackCount); @@ -7818,8 +8255,52 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Sell section ImGui::Separator(); - ImGui::Text("Sell:"); - ImGui::SameLine(); + ImGui::Text("Sell Item:"); + + // Item picker from backpack + { + auto& inv = gameHandler.getInventory(); + // Build list of non-empty backpack slots + std::string preview = (auctionSellSlotIndex_ >= 0) + ? ([&]() -> std::string { + const auto& slot = inv.getBackpackSlot(auctionSellSlotIndex_); + if (!slot.empty()) { + std::string s = slot.item.name; + if (slot.item.stackCount > 1) s += " x" + std::to_string(slot.item.stackCount); + return s; + } + return "Select item..."; + })() + : "Select item..."; + + ImGui::SetNextItemWidth(250); + if (ImGui::BeginCombo("##sellitem", preview.c_str())) { + for (int i = 0; i < game::Inventory::BACKPACK_SLOTS; i++) { + const auto& slot = inv.getBackpackSlot(i); + if (slot.empty()) continue; + ImGui::PushID(i + 9000); + // Item icon + if (slot.item.displayInfoId != 0) { + VkDescriptorSet sIcon = inventoryScreen.getItemIcon(slot.item.displayInfoId); + if (sIcon) { + ImGui::Image((void*)(intptr_t)sIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + std::string label = slot.item.name; + if (slot.item.stackCount > 1) label += " x" + std::to_string(slot.item.stackCount); + ImVec4 iqc = InventoryScreen::getQualityColor(slot.item.quality); + ImGui::PushStyleColor(ImGuiCol_Text, iqc); + if (ImGui::Selectable(label.c_str(), auctionSellSlotIndex_ == i)) { + auctionSellSlotIndex_ = i; + } + ImGui::PopStyleColor(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + } + ImGui::Text("Bid:"); ImGui::SameLine(); ImGui::SetNextItemWidth(50); @@ -7831,7 +8312,7 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(35); ImGui::InputInt("##sbc", &auctionSellBid_[2], 0); ImGui::SameLine(); ImGui::Text("c"); - ImGui::Text(" "); ImGui::SameLine(); + ImGui::SameLine(0, 20); ImGui::Text("Buyout:"); ImGui::SameLine(); ImGui::SetNextItemWidth(50); @@ -7844,31 +8325,92 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::InputInt("##sboc", &auctionSellBuyout_[2], 0); ImGui::SameLine(); ImGui::Text("c"); const char* durations[] = {"12 hours", "24 hours", "48 hours"}; - ImGui::SameLine(); ImGui::SetNextItemWidth(90); ImGui::Combo("##dur", &auctionSellDuration_, durations, 3); + ImGui::SameLine(); + + // Create Auction button + bool canCreate = auctionSellSlotIndex_ >= 0 && + !gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_).empty() && + (auctionSellBid_[0] > 0 || auctionSellBid_[1] > 0 || auctionSellBid_[2] > 0); + if (!canCreate) ImGui::BeginDisabled(); + if (ImGui::Button("Create Auction")) { + uint32_t bidCopper = static_cast(auctionSellBid_[0]) * 10000 + + static_cast(auctionSellBid_[1]) * 100 + + static_cast(auctionSellBid_[2]); + uint32_t buyoutCopper = static_cast(auctionSellBuyout_[0]) * 10000 + + static_cast(auctionSellBuyout_[1]) * 100 + + static_cast(auctionSellBuyout_[2]); + const uint32_t durationMins[] = {720, 1440, 2880}; + uint32_t dur = durationMins[auctionSellDuration_]; + uint64_t itemGuid = gameHandler.getBackpackItemGuid(auctionSellSlotIndex_); + const auto& slot = gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_); + uint32_t stackCount = slot.item.stackCount; + if (itemGuid != 0) { + gameHandler.auctionSellItem(itemGuid, stackCount, bidCopper, buyoutCopper, dur); + // Clear sell inputs + auctionSellSlotIndex_ = -1; + auctionSellBid_[0] = auctionSellBid_[1] = auctionSellBid_[2] = 0; + auctionSellBuyout_[0] = auctionSellBuyout_[1] = auctionSellBuyout_[2] = 0; + } + } + if (!canCreate) ImGui::EndDisabled(); } else if (tab == 1) { // Bids tab const auto& results = gameHandler.getAuctionBidderResults(); ImGui::Text("Your Bids: %zu items", results.auctions.size()); - if (ImGui::BeginTable("BidTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + if (ImGui::BeginTable("BidTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40); ImGui::TableSetupColumn("Your Bid", ImGuiTableColumnFlags_WidthFixed, 90); ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90); ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60); ImGui::TableHeadersRow(); - for (const auto& a : results.auctions) { + for (size_t bi = 0; bi < results.auctions.size(); bi++) { + const auto& a = results.auctions[bi]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; + ImVec4 bqc = InventoryScreen::getQualityColor(quality); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - ImGui::TextColored(InventoryScreen::getQualityColor(quality), "%s", name.c_str()); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet bIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (bIcon) { + ImGui::Image((void*)(intptr_t)bIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(bqc, "%s", name.c_str()); + // Tooltip + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(bqc, "%s", info->name.c_str()); + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + std::string bl; + auto appS = [](std::string& o, int32_t v, const char* n) { + if (v <= 0) return; + if (!o.empty()) o += " "; + o += "+" + std::to_string(v) + " " + n; + }; + appS(bl, info->strength, "Str"); appS(bl, info->agility, "Agi"); + appS(bl, info->stamina, "Sta"); appS(bl, info->intellect, "Int"); + appS(bl, info->spirit, "Spi"); + if (!bl.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", bl.c_str()); + if (info->sellPrice > 0) + ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", + info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); @@ -7883,6 +8425,20 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { if (mins > 720) ImGui::Text("Long"); else if (mins > 120) ImGui::Text("Medium"); else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short"); + + ImGui::TableSetColumnIndex(5); + ImGui::PushID(static_cast(bi) + 7500); + if (a.buyoutPrice > 0 && ImGui::SmallButton("Buy")) { + gameHandler.auctionBuyout(a.auctionId, a.buyoutPrice); + } + if (a.buyoutPrice > 0) ImGui::SameLine(); + if (ImGui::SmallButton("Bid")) { + uint32_t bidAmt = a.currentBid > 0 + ? a.currentBid + a.minBidIncrement + : a.startBid; + gameHandler.auctionPlaceBid(a.auctionId, bidAmt); + } + ImGui::PopID(); } ImGui::EndTable(); } @@ -7908,7 +8464,38 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - ImGui::TextColored(InventoryScreen::getQualityColor(quality), "%s", name.c_str()); + ImVec4 oqc = InventoryScreen::getQualityColor(quality); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet oIcon = inventoryScreen.getItemIcon(info->displayInfoId); + if (oIcon) { + ImGui::Image((void*)(intptr_t)oIcon, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(oqc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) { + ImGui::BeginTooltip(); + ImGui::TextColored(oqc, "%s", info->name.c_str()); + if (info->armor > 0) ImGui::Text("%d Armor", info->armor); + if (info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + } + std::string ol; + auto appO = [](std::string& o, int32_t v, const char* n) { + if (v <= 0) return; + if (!o.empty()) o += " "; + o += "+" + std::to_string(v) + " " + n; + }; + appO(ol, info->strength, "Str"); appO(ol, info->agility, "Agi"); + appO(ol, info->stamina, "Sta"); appO(ol, info->intellect, "Int"); + appO(ol, info->spirit, "Spi"); + if (!ol.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", ol.c_str()); + if (info->sellPrice > 0) + ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", + info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); + ImGui::EndTooltip(); + } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index aa48cc40..3d7f03dc 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -543,6 +543,25 @@ bool InventoryScreen::dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipS return !holdingItem; } +void InventoryScreen::dropIntoBankSlot(game::GameHandler& /*gh*/, uint8_t dstBag, uint8_t dstSlot) { + if (!holdingItem || !gameHandler_) return; + uint8_t srcBag = 0xFF; + uint8_t srcSlot = 0; + if (heldSource == HeldSource::BACKPACK && heldBackpackIndex >= 0) { + srcSlot = static_cast(23 + heldBackpackIndex); + } else if (heldSource == HeldSource::BAG) { + srcBag = static_cast(19 + heldBagIndex); + srcSlot = static_cast(heldBagSlotIndex); + } else if (heldSource == HeldSource::EQUIPMENT) { + srcSlot = static_cast(heldEquipSlot); + } else { + return; + } + gameHandler_->swapContainerItems(srcBag, srcSlot, dstBag, dstSlot); + holdingItem = false; + inventoryDirty = true; +} + bool InventoryScreen::beginPickupFromEquipSlot(game::Inventory& inv, game::EquipSlot slot) { if (holdingItem) return false; const auto& eq = inv.getEquipSlot(slot); @@ -1402,13 +1421,22 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } - // Right-click: vendor sell (if vendor mode) or auto-equip/use + // Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use if (ImGui::IsItemClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) { LOG_INFO("Right-click slot: kind=", (int)kind, " backpackIndex=", backpackIndex, " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, - " vendorMode=", vendorMode_); - if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + " vendorMode=", vendorMode_, + " bankOpen=", gameHandler_->isBankOpen()); + if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + gameHandler_->attachItemFromBackpack(backpackIndex); + } else if (gameHandler_->isMailComposeOpen() && kind == SlotKind::BACKPACK && isBagSlot) { + gameHandler_->attachItemFromBag(bagIndex, bagSlotIndex); + } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && backpackIndex >= 0) { + gameHandler_->depositItem(0xFF, static_cast(23 + backpackIndex)); + } else if (gameHandler_->isBankOpen() && kind == SlotKind::BACKPACK && isBagSlot) { + gameHandler_->depositItem(static_cast(19 + bagIndex), static_cast(bagSlotIndex)); + } else if (vendorMode_ && kind == SlotKind::BACKPACK && backpackIndex >= 0) { gameHandler_->sellItemBySlot(backpackIndex); } else if (vendorMode_ && kind == SlotKind::BACKPACK && isBagSlot) { gameHandler_->sellItemInBag(bagIndex, bagSlotIndex); diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 9fec4524..73a43091 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -9,9 +9,29 @@ #include "core/logger.hpp" #include #include +#include namespace wowee { namespace ui { +// Case-insensitive substring match +static bool containsCI(const std::string& haystack, const char* needle) { + if (!needle || !needle[0]) return true; + size_t needleLen = strlen(needle); + if (needleLen > haystack.size()) return false; + for (size_t i = 0; i <= haystack.size() - needleLen; i++) { + bool match = true; + for (size_t j = 0; j < needleLen; j++) { + if (std::tolower(static_cast(haystack[i + j])) != + std::tolower(static_cast(needle[j]))) { + match = false; + break; + } + } + if (match) return true; + } + return false; +} + void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (dbcLoadAttempted) return; dbcLoadAttempted = true; @@ -30,12 +50,11 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { return; } - // Try expansion-specific layout first, then fall back to WotLK hardcoded indices - // if the DBC is from WotLK MPQs but the active expansion uses different field offsets. const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, - uint32_t nameField, uint32_t rankField, const char* label) { + uint32_t nameField, uint32_t rankField, uint32_t tooltipField, + const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { @@ -48,6 +67,7 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { info.iconId = dbc->getUInt32(i, iconField); info.name = dbc->getString(i, nameField); info.rank = dbc->getString(i, rankField); + info.description = dbc->getString(i, tooltipField); if (!info.name.empty()) { spellData[spellId] = std::move(info); @@ -56,17 +76,17 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")"); }; - // Try active expansion layout if (spellL) { + uint32_t tooltipField = 139; + // Try to get Tooltip field from layout, fall back to 139 + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], - (*spellL)["Name"], (*spellL)["Rank"], "expansion layout"); + (*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout"); } - // If layout failed or loaded 0 spells, try WotLK hardcoded indices - // (binary DBC may be from WotLK MPQs regardless of active expansion) if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); - tryLoad(0, 4, 133, 136, 153, "WotLK fallback"); + tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback"); } dbcLoaded = !spellData.empty(); @@ -88,10 +108,7 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (!assetManager || !assetManager->isInitialized()) return; auto dbc = assetManager->loadDBC("SpellIcon.dbc"); - if (!dbc || !dbc->isLoaded()) { - LOG_WARNING("Spellbook: Could not load SpellIcon.dbc"); - return; - } + if (!dbc || !dbc->isLoaded()) return; const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { @@ -101,8 +118,6 @@ void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { spellIconPaths[id] = path; } } - - LOG_INFO("Spellbook: Loaded ", spellIconPaths.size(), " spell icon paths"); } void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { @@ -111,7 +126,6 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { if (!assetManager || !assetManager->isInitialized()) return; - // Load SkillLine.dbc: field 0 = ID, field 1 = categoryID, field 3 = name_enUS auto skillLineDbc = assetManager->loadDBC("SkillLine.dbc"); const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; if (skillLineDbc && skillLineDbc->isLoaded()) { @@ -124,12 +138,8 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { skillLineCategories[id] = category; } } - LOG_INFO("Spellbook: Loaded ", skillLineNames.size(), " skill lines"); - } else { - LOG_WARNING("Spellbook: Could not load SkillLine.dbc"); } - // Load SkillLineAbility.dbc: field 0 = ID, field 1 = skillLineID, field 2 = spellID auto slaDbc = assetManager->loadDBC("SkillLineAbility.dbc"); const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr; if (slaDbc && slaDbc->isLoaded()) { @@ -140,20 +150,26 @@ void SpellbookScreen::loadSkillLineDBCs(pipeline::AssetManager* assetManager) { spellToSkillLine[spellId] = skillLineId; } } - LOG_INFO("Spellbook: Loaded ", spellToSkillLine.size(), " skill line abilities"); - } else { - LOG_WARNING("Spellbook: Could not load SkillLineAbility.dbc"); } } void SpellbookScreen::categorizeSpells(const std::unordered_set& knownSpells) { spellTabs.clear(); - // Only SkillLine category 7 ("Class") gets its own tab (the 3 specialties). - // Everything else (weapons, professions, racials, general utilities) → General. - static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7; + // SkillLine.dbc category IDs + static constexpr uint32_t CAT_CLASS = 7; // Class abilities (spec trees) + static constexpr uint32_t CAT_PROFESSION = 11; // Primary professions + static constexpr uint32_t CAT_SECONDARY = 9; // Secondary skills (Cooking, First Aid, Fishing, Riding, Companions) - std::map> specialtySpells; + // Special skill line IDs within category 9 that get their own tabs + static constexpr uint32_t SKILLLINE_RIDING = 762; // Mounts + static constexpr uint32_t SKILLLINE_COMPANIONS = 778; // Vanity/companion pets + + // Buckets + std::map> specSpells; // class spec trees + std::map> profSpells; // professions + secondary + std::vector mountSpells; + std::vector companionSpells; std::vector generalSpells; for (uint32_t spellId : knownSpells) { @@ -165,11 +181,41 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known auto slIt = spellToSkillLine.find(spellId); if (slIt != spellToSkillLine.end()) { uint32_t skillLineId = slIt->second; - auto catIt = skillLineCategories.find(skillLineId); - if (catIt != skillLineCategories.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { - specialtySpells[skillLineId].push_back(info); + + // Mounts: Riding skill line (762) + if (skillLineId == SKILLLINE_RIDING) { + mountSpells.push_back(info); continue; } + + // Companions: vanity pets skill line (778) + if (skillLineId == SKILLLINE_COMPANIONS) { + companionSpells.push_back(info); + continue; + } + + auto catIt = skillLineCategories.find(skillLineId); + if (catIt != skillLineCategories.end()) { + uint32_t cat = catIt->second; + + // Class spec abilities + if (cat == CAT_CLASS) { + specSpells[skillLineId].push_back(info); + continue; + } + + // Primary professions + if (cat == CAT_PROFESSION) { + profSpells[skillLineId].push_back(info); + continue; + } + + // Secondary skills (Cooking, First Aid, Fishing) + if (cat == CAT_SECONDARY) { + profSpells[skillLineId].push_back(info); + continue; + } + } } generalSpells.push_back(info); @@ -177,28 +223,47 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known auto byName = [](const SpellInfo* a, const SpellInfo* b) { return a->name < b->name; }; - // Specialty tabs sorted alphabetically by skill line name - std::vector>> named; - for (auto& [skillLineId, spells] : specialtySpells) { - auto nameIt = skillLineNames.find(skillLineId); - std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second - : "Specialty"; - std::sort(spells.begin(), spells.end(), byName); - named.push_back({std::move(tabName), std::move(spells)}); - } - std::sort(named.begin(), named.end(), - [](const auto& a, const auto& b) { return a.first < b.first; }); + // Helper: add sorted skill-line-grouped tabs + auto addGroupedTabs = [&](std::map>& groups, + const char* fallbackName) { + std::vector>> named; + for (auto& [skillLineId, spells] : groups) { + auto nameIt = skillLineNames.find(skillLineId); + std::string tabName = (nameIt != skillLineNames.end()) ? nameIt->second : fallbackName; + std::sort(spells.begin(), spells.end(), byName); + named.push_back({std::move(tabName), std::move(spells)}); + } + std::sort(named.begin(), named.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + for (auto& [name, spells] : named) { + spellTabs.push_back({std::move(name), std::move(spells)}); + } + }; - for (auto& [name, spells] : named) { - spellTabs.push_back({std::move(name), std::move(spells)}); - } + // 1. Class spec tabs + addGroupedTabs(specSpells, "Spec"); - // General tab last + // 2. General tab if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); spellTabs.push_back({"General", std::move(generalSpells)}); } + // 3. Professions tabs + addGroupedTabs(profSpells, "Profession"); + + // 4. Mounts tab + if (!mountSpells.empty()) { + std::sort(mountSpells.begin(), mountSpells.end(), byName); + spellTabs.push_back({"Mounts", std::move(mountSpells)}); + } + + // 5. Companions tab + if (!companionSpells.empty()) { + std::sort(companionSpells.begin(), companionSpells.end(), byName); + spellTabs.push_back({"Companions", std::move(companionSpells)}); + } + lastKnownSpellCount = knownSpells.size(); } @@ -244,6 +309,47 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { return (it != spellData.end()) ? &it->second : nullptr; } +void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(320.0f); + + // Spell name in yellow + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", info->name.c_str()); + + // Rank in gray + if (!info->rank.empty()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "(%s)", info->rank.c_str()); + } + + // Passive indicator + if (info->isPassive()) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); + } + + // Cooldown if active + float cd = gameHandler.getSpellCooldown(info->spellId); + if (cd > 0.0f) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1fs", cd); + } + + // Description + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::TextWrapped("%s", info->description.c_str()); + } + + // Usage hints + if (!info->isPassive()) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar"); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast"); + } + + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); +} + void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) { // P key toggle (edge-triggered) bool wantsTextInput = ImGui::GetIO().WantTextInput; @@ -272,88 +378,156 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float bookW = 360.0f; - float bookH = std::min(520.0f, screenH - 120.0f); + float bookW = 380.0f; + float bookH = std::min(560.0f, screenH - 100.0f); float bookX = screenW - bookW - 10.0f; float bookY = 80.0f; ImGui::SetNextWindowPos(ImVec2(bookX, bookY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(bookW, bookH), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSizeConstraints(ImVec2(280, 200), ImVec2(screenW, screenH)); + ImGui::SetNextWindowSizeConstraints(ImVec2(300, 250), ImVec2(screenW, screenH)); bool windowOpen = open; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8)); if (ImGui::Begin("Spellbook", &windowOpen)) { - // Clamp window position to stay on screen - ImVec2 winPos = ImGui::GetWindowPos(); - ImVec2 winSize = ImGui::GetWindowSize(); - float clampedX = std::max(0.0f, std::min(winPos.x, screenW - winSize.x)); - float clampedY = std::max(0.0f, std::min(winPos.y, screenH - winSize.y)); - if (clampedX != winPos.x || clampedY != winPos.y) { - ImGui::SetWindowPos(ImVec2(clampedX, clampedY)); - } + // Search bar + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##search", "Search spells...", searchFilter_, sizeof(searchFilter_)); + + ImGui::Spacing(); // Tab bar if (ImGui::BeginTabBar("SpellbookTabs")) { for (size_t tabIdx = 0; tabIdx < spellTabs.size(); tabIdx++) { const auto& tab = spellTabs[tabIdx]; - char tabLabel[64]; - snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)", - tab.name.c_str(), tab.spells.size()); + // Count visible spells (respecting search filter) + int visibleCount = 0; + for (const SpellInfo* info : tab.spells) { + if (containsCI(info->name, searchFilter_)) visibleCount++; + } + + char tabLabel[128]; + snprintf(tabLabel, sizeof(tabLabel), "%s (%d)###sbtab%zu", + tab.name.c_str(), visibleCount, tabIdx); if (ImGui::BeginTabItem(tabLabel)) { - if (tab.spells.empty()) { - ImGui::TextDisabled("No spells in this category."); + if (visibleCount == 0) { + if (searchFilter_[0]) + ImGui::TextDisabled("No matching spells."); + else + ImGui::TextDisabled("No spells in this category."); } ImGui::BeginChild("SpellList", ImVec2(0, 0), true); - float iconSize = 32.0f; + const float iconSize = 36.0f; + const float rowHeight = iconSize + 4.0f; for (const SpellInfo* info : tab.spells) { + // Apply search filter + if (!containsCI(info->name, searchFilter_)) continue; + ImGui::PushID(static_cast(info->spellId)); float cd = gameHandler.getSpellCooldown(info->spellId); bool onCooldown = cd > 0.0f; bool isPassive = info->isPassive(); - bool isDim = isPassive || onCooldown; VkDescriptorSet iconTex = getSpellIcon(info->iconId, assetManager); - // Selectable consumes clicks properly (prevents window drag) + // Row selectable ImGui::Selectable("##row", false, - ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, iconSize)); + ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); auto* dl = ImGui::GetWindowDrawList(); - // Draw icon on top of selectable + // Hover highlight + if (rowHovered) { + dl->AddRectFilled(rMin, rMax, IM_COL32(255, 255, 255, 15), 3.0f); + } + + // Icon background + ImVec2 iconMin = rMin; + ImVec2 iconMax(rMin.x + iconSize, rMin.y + iconSize); + dl->AddRectFilled(iconMin, iconMax, IM_COL32(25, 25, 35, 200), 3.0f); + + // Icon if (iconTex) { + ImU32 tint = (isPassive || onCooldown) ? IM_COL32(150, 150, 150, 255) : IM_COL32(255, 255, 255, 255); dl->AddImage((ImTextureID)(uintptr_t)iconTex, - rMin, ImVec2(rMin.x + iconSize, rMin.y + iconSize)); - } else { - dl->AddRectFilled(rMin, - ImVec2(rMin.x + iconSize, rMin.y + iconSize), - IM_COL32(60, 60, 80, 255)); + ImVec2(iconMin.x + 1, iconMin.y + 1), + ImVec2(iconMax.x - 1, iconMax.y - 1), + ImVec2(0, 0), ImVec2(1, 1), tint); } - // Draw name and rank text - ImU32 textCol = isDim ? IM_COL32(153, 153, 153, 255) - : ImGui::GetColorU32(ImGuiCol_Text); - ImU32 dimCol = ImGui::GetColorU32(ImGuiCol_TextDisabled); - float textX = rMin.x + iconSize + 4.0f; - dl->AddText(ImVec2(textX, rMin.y), textCol, info->name.c_str()); - if (!info->rank.empty()) { - dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()), - dimCol, info->rank.c_str()); + // Icon border + ImU32 borderCol; + if (isPassive) { + borderCol = IM_COL32(180, 180, 50, 200); // Yellow for passive } else if (onCooldown) { - char cdBuf[32]; - snprintf(cdBuf, sizeof(cdBuf), "%.1fs cooldown", cd); - dl->AddText(ImVec2(textX, rMin.y + ImGui::GetTextLineHeight()), - dimCol, cdBuf); + borderCol = IM_COL32(120, 40, 40, 200); // Red for cooldown + } else { + borderCol = IM_COL32(100, 100, 120, 200); // Default border + } + dl->AddRect(iconMin, iconMax, borderCol, 3.0f, 0, 1.5f); + + // Cooldown overlay on icon + if (onCooldown) { + // Darkened sweep + dl->AddRectFilled(iconMin, iconMax, IM_COL32(0, 0, 0, 120), 3.0f); + // Cooldown text centered on icon + char cdBuf[16]; + snprintf(cdBuf, sizeof(cdBuf), "%.0f", cd); + ImVec2 cdSize = ImGui::CalcTextSize(cdBuf); + ImVec2 cdPos(iconMin.x + (iconSize - cdSize.x) * 0.5f, + iconMin.y + (iconSize - cdSize.y) * 0.5f); + dl->AddText(ImVec2(cdPos.x + 1, cdPos.y + 1), IM_COL32(0, 0, 0, 255), cdBuf); + dl->AddText(cdPos, IM_COL32(255, 80, 80, 255), cdBuf); } + // Spell name + float textX = rMin.x + iconSize + 8.0f; + float nameY = rMin.y + 2.0f; + + ImU32 nameCol; + if (isPassive) { + nameCol = IM_COL32(255, 255, 130, 255); // Yellow-ish for passive + } else if (onCooldown) { + nameCol = IM_COL32(150, 150, 150, 255); + } else { + nameCol = IM_COL32(255, 255, 255, 255); + } + dl->AddText(ImVec2(textX, nameY), nameCol, info->name.c_str()); + + // Second line: rank or passive/cooldown indicator + float subY = nameY + ImGui::GetTextLineHeight() + 1.0f; + if (!info->rank.empty()) { + dl->AddText(ImVec2(textX, subY), + IM_COL32(150, 150, 150, 255), info->rank.c_str()); + } + if (isPassive) { + float afterRank = textX; + if (!info->rank.empty()) { + afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f; + } + dl->AddText(ImVec2(afterRank, subY), + IM_COL32(200, 200, 80, 200), "Passive"); + } else if (onCooldown) { + float afterRank = textX; + if (!info->rank.empty()) { + afterRank += ImGui::CalcTextSize(info->rank.c_str()).x + 8.0f; + } + char cdText[32]; + snprintf(cdText, sizeof(cdText), "%.1fs", cd); + dl->AddText(ImVec2(afterRank, subY), + IM_COL32(255, 100, 100, 200), cdText); + } + + // Interaction if (rowHovered) { // Start drag on click (not passive) if (rowClicked && !isPassive) { @@ -362,31 +536,18 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana dragSpellIconTex_ = iconTex; } + // Double-click to cast if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) { draggingSpell_ = false; dragSpellId_ = 0; - dragSpellIconTex_ = 0; + dragSpellIconTex_ = VK_NULL_HANDLE; uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; gameHandler.castSpell(info->spellId, target); } // Tooltip (only when not dragging) if (!draggingSpell_) { - ImGui::BeginTooltip(); - ImGui::Text("%s", info->name.c_str()); - if (!info->rank.empty()) { - ImGui::TextDisabled("%s", info->rank.c_str()); - } - ImGui::TextDisabled("Spell ID: %u", info->spellId); - if (isPassive) { - ImGui::TextDisabled("Passive"); - } else { - ImGui::TextDisabled("Drag to action bar to assign"); - if (!onCooldown) { - ImGui::TextDisabled("Double-click to cast"); - } - } - ImGui::EndTooltip(); + renderSpellTooltip(info, gameHandler); } } @@ -402,6 +563,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana } } ImGui::End(); + ImGui::PopStyleVar(); if (!windowOpen) { open = false; @@ -410,7 +572,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Render dragged spell icon at cursor if (draggingSpell_ && dragSpellId_ != 0) { ImVec2 mousePos = ImGui::GetMousePos(); - float dragSize = 32.0f; + float dragSize = 36.0f; if (dragSpellIconTex_) { ImGui::GetForegroundDrawList()->AddImage( (ImTextureID)(uintptr_t)dragSpellIconTex_, @@ -420,14 +582,13 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana ImGui::GetForegroundDrawList()->AddRectFilled( ImVec2(mousePos.x - dragSize * 0.5f, mousePos.y - dragSize * 0.5f), ImVec2(mousePos.x + dragSize * 0.5f, mousePos.y + dragSize * 0.5f), - IM_COL32(80, 80, 120, 180)); + IM_COL32(80, 80, 120, 180), 3.0f); } - // Cancel drag on mouse release (action bar consumes it before this if dropped on a slot) if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { draggingSpell_ = false; dragSpellId_ = 0; - dragSpellIconTex_ = 0; + dragSpellIconTex_ = VK_NULL_HANDLE; } } } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index 62f49593..eeff7c41 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -7,9 +7,20 @@ #include "pipeline/blp_loader.hpp" #include "pipeline/dbc_layout.hpp" #include +#include namespace wowee { namespace ui { +// WoW class names indexed by class ID (1-11) +static const char* classNames[] = { + "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", "Priest", + "Death Knight", "Shaman", "Mage", "Warlock", "Unknown", "Druid" +}; + +static const char* getClassName(uint8_t classId) { + return (classId >= 1 && classId <= 11) ? classNames[classId] : "Unknown"; +} + void TalentScreen::render(game::GameHandler& gameHandler) { // N key toggle (edge-triggered) bool wantsTextInput = ImGui::GetIO().WantTextInput; @@ -25,19 +36,28 @@ void TalentScreen::render(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - float winW = 600.0f; // Wider for talent grid - float winH = 550.0f; + float winW = 680.0f; + float winH = 600.0f; float winX = (screenW - winW) * 0.5f; float winY = (screenH - winH) * 0.5f; ImGui::SetNextWindowPos(ImVec2(winX, winY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_FirstUseEver); + // Build title with point distribution + uint8_t playerClass = gameHandler.getPlayerClass(); + std::string title = "Talents"; + if (playerClass > 0) { + title = std::string(getClassName(playerClass)) + " Talents"; + } + bool windowOpen = open; - if (ImGui::Begin("Talents", &windowOpen)) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8)); + if (ImGui::Begin(title.c_str(), &windowOpen)) { renderTalentTrees(gameHandler); } ImGui::End(); + ImGui::PopStyleVar(); if (!windowOpen) { open = false; @@ -47,93 +67,95 @@ void TalentScreen::render(game::GameHandler& gameHandler) { void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { auto* assetManager = core::Application::getInstance().getAssetManager(); - // Ensure talent DBCs are loaded (even if server hasn't sent SMSG_TALENTS_INFO) + // Ensure talent DBCs are loaded once static bool dbcLoadAttempted = false; if (!dbcLoadAttempted) { dbcLoadAttempted = true; gameHandler.loadTalentDbc(); loadSpellDBC(assetManager); loadSpellIconDBC(assetManager); - LOG_INFO("Talent window opened, DBC load triggered"); } uint8_t playerClass = gameHandler.getPlayerClass(); - LOG_INFO("Talent window: playerClass=", static_cast(playerClass)); - - // Active spec indicator and switcher - uint8_t activeSpec = gameHandler.getActiveTalentSpec(); - ImGui::Text("Active Spec: %u", activeSpec + 1); - ImGui::SameLine(); - - // Spec buttons - if (ImGui::SmallButton("Spec 1")) { - gameHandler.switchTalentSpec(0); - } - ImGui::SameLine(); - if (ImGui::SmallButton("Spec 2")) { - gameHandler.switchTalentSpec(1); - } - ImGui::SameLine(); - - // Show unspent points for both specs - ImGui::Text("| Unspent: Spec1=%u Spec2=%u", - gameHandler.getUnspentTalentPoints(0), - gameHandler.getUnspentTalentPoints(1)); - - ImGui::Separator(); - - // Debug info - ImGui::Text("Player Class: %u", playerClass); - ImGui::Text("Total Talent Tabs: %zu", gameHandler.getAllTalentTabs().size()); - ImGui::Text("Total Talents: %zu", gameHandler.getAllTalents().size()); - ImGui::Separator(); - if (playerClass == 0) { ImGui::TextDisabled("Class information not available."); - LOG_WARNING("Talent window: getPlayerClass() returned 0"); return; } - // Get talent tabs for this class (class mask: 1 << (class - 1)) + // Get talent tabs for this class, sorted by orderIndex uint32_t classMask = 1u << (playerClass - 1); - LOG_INFO("Talent window: classMask=0x", std::hex, classMask, std::dec); - - // Collect talent tabs for this class, sorted by orderIndex std::vector classTabs; for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) { if (tab.classMask & classMask) { classTabs.push_back(&tab); } } - std::sort(classTabs.begin(), classTabs.end(), [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); - LOG_INFO("Talent window: found ", classTabs.size(), " tabs for class mask 0x", std::hex, classMask, std::dec); - - ImGui::Text("Class Mask: 0x%X", classMask); - ImGui::Text("Tabs for this class: %zu", classTabs.size()); - if (classTabs.empty()) { ImGui::TextDisabled("No talent trees available for your class."); - ImGui::Spacing(); - ImGui::TextDisabled("Available tabs:"); - for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) { - ImGui::Text(" Tab %u: %s (mask: 0x%X)", tabId, tab.name.c_str(), tab.classMask); - } return; } - // Display points - uint8_t unspentPoints = gameHandler.getUnspentTalentPoints(); - ImGui::Text("Unspent Points: %u", unspentPoints); + // Compute points-per-tree for display + uint32_t treeTotals[3] = {0, 0, 0}; + for (size_t ti = 0; ti < classTabs.size() && ti < 3; ti++) { + for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) { + const auto* t = gameHandler.getTalentEntry(tid); + if (t && t->tabId == classTabs[ti]->tabId) { + treeTotals[ti] += rank; + } + } + } + + // Header: spec switcher + unspent points + point distribution + uint8_t activeSpec = gameHandler.getActiveTalentSpec(); + uint8_t unspent = gameHandler.getUnspentTalentPoints(); + + // Spec buttons + for (uint8_t s = 0; s < 2; s++) { + if (s > 0) ImGui::SameLine(); + bool isActive = (s == activeSpec); + if (isActive) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.8f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.6f, 0.9f, 1.0f)); + } + char specLabel[32]; + snprintf(specLabel, sizeof(specLabel), "Spec %u", s + 1); + if (ImGui::Button(specLabel, ImVec2(70, 0))) { + if (!isActive) gameHandler.switchTalentSpec(s); + } + if (isActive) ImGui::PopStyleColor(2); + } + + // Point distribution + ImGui::SameLine(0, 20); + if (classTabs.size() >= 3) { + ImGui::Text("(%u / %u / %u)", treeTotals[0], treeTotals[1], treeTotals[2]); + } + + // Unspent points + ImGui::SameLine(0, 20); + if (unspent > 0) { + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "%u point%s available", + unspent, unspent > 1 ? "s" : ""); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "No points available"); + } + ImGui::Separator(); - // Render tabs + // Render tabs with point counts in tab labels if (ImGui::BeginTabBar("TalentTabs")) { - for (const auto* tab : classTabs) { - if (ImGui::BeginTabItem(tab->name.c_str())) { - renderTalentTree(gameHandler, tab->tabId); + for (size_t ti = 0; ti < classTabs.size(); ti++) { + const auto* tab = classTabs[ti]; + char tabLabel[128]; + uint32_t pts = (ti < 3) ? treeTotals[ti] : 0; + snprintf(tabLabel, sizeof(tabLabel), "%s (%u)###tab%u", tab->name.c_str(), pts, tab->tabId); + + if (ImGui::BeginTabItem(tabLabel)) { + renderTalentTree(gameHandler, tab->tabId, tab->backgroundFile); ImGui::EndTabItem(); } } @@ -141,7 +163,10 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { } } -void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId) { +void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId, + const std::string& bgFile) { + auto* assetManager = core::Application::getInstance().getAssetManager(); + // Collect all talents for this tab std::vector talents; for (const auto& [talentId, talent] : gameHandler.getAllTalents()) { @@ -155,25 +180,132 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab return; } + // Sort talents by row then column for consistent rendering + std::sort(talents.begin(), talents.end(), [](const auto* a, const auto* b) { + if (a->row != b->row) return a->row < b->row; + return a->column < b->column; + }); + // Find grid dimensions uint8_t maxRow = 0, maxCol = 0; for (const auto* talent : talents) { maxRow = std::max(maxRow, talent->row); maxCol = std::max(maxCol, talent->column); } + // WoW talent grids are always 4 columns wide + if (maxCol < 3) maxCol = 3; const float iconSize = 40.0f; + const float spacing = 8.0f; + const float cellSize = iconSize + spacing; + const float gridWidth = (maxCol + 1) * cellSize + spacing; + const float gridHeight = (maxRow + 1) * cellSize + spacing; + + // Points in this tree + uint32_t pointsInTree = 0; + for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) { + const auto* t = gameHandler.getTalentEntry(tid); + if (t && t->tabId == tabId) { + pointsInTree += rank; + } + } + + // Center the grid + float availW = ImGui::GetContentRegionAvail().x; + float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f); ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false); - // Render grid - for (uint8_t row = 0; row <= maxRow; ++row) { - // Row label - ImGui::Text("Tier %u", row); - ImGui::SameLine(80); + ImVec2 gridOrigin = ImGui::GetCursorScreenPos(); + gridOrigin.x += offsetX; + // Draw background texture if available + if (!bgFile.empty() && assetManager) { + VkDescriptorSet bgTex = VK_NULL_HANDLE; + auto bgIt = bgTextureCache_.find(tabId); + if (bgIt != bgTextureCache_.end()) { + bgTex = bgIt->second; + } else { + // Try to load the background texture + std::string bgPath = bgFile; + // Normalize path separators + for (auto& c : bgPath) { if (c == '\\') c = '/'; } + bgPath += ".blp"; + auto blpData = assetManager->readFile(bgPath); + if (!blpData.empty()) { + auto image = pipeline::BLPLoader::load(blpData); + if (image.isValid()) { + auto* window = core::Application::getInstance().getWindow(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (vkCtx) { + bgTex = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + } + } + } + bgTextureCache_[tabId] = bgTex; + } + + if (bgTex) { + auto* drawList = ImGui::GetWindowDrawList(); + float bgW = gridWidth + spacing * 2; + float bgH = gridHeight + spacing * 2; + drawList->AddImage((ImTextureID)(uintptr_t)bgTex, + ImVec2(gridOrigin.x - spacing, gridOrigin.y - spacing), + ImVec2(gridOrigin.x + bgW - spacing, gridOrigin.y + bgH - spacing), + ImVec2(0, 0), ImVec2(1, 1), + IM_COL32(255, 255, 255, 60)); // Subtle background + } + } + + // Build a position lookup for prerequisite arrows + struct TalentPos { + const game::GameHandler::TalentEntry* talent; + ImVec2 center; + }; + std::unordered_map talentPositions; + + // First pass: compute positions + for (const auto* talent : talents) { + float x = gridOrigin.x + talent->column * cellSize + spacing; + float y = gridOrigin.y + talent->row * cellSize + spacing; + ImVec2 center(x + iconSize * 0.5f, y + iconSize * 0.5f); + talentPositions[talent->talentId] = {talent, center}; + } + + // Draw prerequisite arrows + auto* drawList = ImGui::GetWindowDrawList(); + for (const auto* talent : talents) { + for (int i = 0; i < 3; ++i) { + if (talent->prereqTalent[i] == 0) continue; + auto fromIt = talentPositions.find(talent->prereqTalent[i]); + auto toIt = talentPositions.find(talent->talentId); + if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue; + + uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]); + bool met = prereqRank >= talent->prereqRank[i]; + ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150); + + ImVec2 from = fromIt->second.center; + ImVec2 to = toIt->second.center; + + // Draw line from bottom of prerequisite to top of dependent + ImVec2 lineStart(from.x, from.y + iconSize * 0.5f); + ImVec2 lineEnd(to.x, to.y - iconSize * 0.5f); + drawList->AddLine(lineStart, lineEnd, lineCol, 2.0f); + + // Arrow head + float arrowSize = 5.0f; + drawList->AddTriangleFilled( + ImVec2(lineEnd.x, lineEnd.y), + ImVec2(lineEnd.x - arrowSize, lineEnd.y - arrowSize * 1.5f), + ImVec2(lineEnd.x + arrowSize, lineEnd.y - arrowSize * 1.5f), + lineCol); + } + } + + // Render talent icons + for (uint8_t row = 0; row <= maxRow; ++row) { for (uint8_t col = 0; col <= maxCol; ++col) { - // Find talent at this position const game::GameHandler::TalentEntry* talent = nullptr; for (const auto* t : talents) { if (t->row == row && t->column == col) { @@ -182,23 +314,31 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab } } - if (col > 0) ImGui::SameLine(); + float x = gridOrigin.x + col * cellSize + spacing; + float y = gridOrigin.y + row * cellSize + spacing; + + ImGui::SetCursorScreenPos(ImVec2(x, y)); if (talent) { - renderTalent(gameHandler, *talent); + renderTalent(gameHandler, *talent, pointsInTree); } else { - // Empty slot - ImGui::InvisibleButton(("empty_" + std::to_string(row) + "_" + std::to_string(col)).c_str(), - ImVec2(iconSize, iconSize)); + // Empty cell — invisible placeholder + ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(), + ImVec2(iconSize, iconSize)); } } } + // Reserve space for the full grid so scrolling works + ImGui::SetCursorScreenPos(ImVec2(gridOrigin.x, gridOrigin.y + gridHeight)); + ImGui::Dummy(ImVec2(gridWidth, 0)); + ImGui::EndChild(); } void TalentScreen::renderTalent(game::GameHandler& gameHandler, - const game::GameHandler::TalentEntry& talent) { + const game::GameHandler::TalentEntry& talent, + uint32_t pointsInTree) { auto* assetManager = core::Application::getInstance().getAssetManager(); uint8_t currentRank = gameHandler.getTalentRank(talent.talentId); @@ -220,38 +360,35 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, } } - // Check tier requirement (need 5 points in previous tier) + // Check tier requirement (need row*5 points in tree) if (talent.row > 0) { - // Count points spent in this tree - uint32_t pointsInTree = 0; - for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) { - const auto* t = gameHandler.getTalentEntry(tid); - if (t && t->tabId == talent.tabId) { - pointsInTree += rank; - } - } - uint32_t requiredPoints = talent.row * 5; if (pointsInTree < requiredPoints) { canLearn = false; } } - // Determine state color and tint + // Determine visual state + enum TalentState { MAXED, PARTIAL, AVAILABLE, LOCKED }; + TalentState state; + if (currentRank >= talent.maxRank) { + state = MAXED; + } else if (currentRank > 0) { + state = PARTIAL; + } else if (canLearn && prereqsMet) { + state = AVAILABLE; + } else { + state = LOCKED; + } + + // Colors per state ImVec4 borderColor; ImVec4 tint; - if (currentRank == talent.maxRank) { - borderColor = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); // Green border (maxed) - tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color - } else if (currentRank > 0) { - borderColor = ImVec4(1.0f, 0.9f, 0.3f, 1.0f); // Yellow border (partial) - tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color - } else if (canLearn && prereqsMet) { - borderColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White border (available) - tint = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // Full color - } else { - borderColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Gray border (locked) - tint = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); // Desaturated + switch (state) { + case MAXED: borderColor = ImVec4(0.2f, 0.9f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break; + case PARTIAL: borderColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); tint = ImVec4(1,1,1,1); break; + case AVAILABLE:borderColor = ImVec4(1.0f, 1.0f, 1.0f, 0.8f); tint = ImVec4(1,1,1,1); break; + case LOCKED: borderColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); tint = ImVec4(0.4f,0.4f,0.4f,1); break; } const float iconSize = 40.0f; @@ -267,60 +404,76 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, } } - // Use InvisibleButton for click handling - bool clicked = ImGui::InvisibleButton("##talent", ImVec2(iconSize, iconSize)); + // Click target + bool clicked = ImGui::InvisibleButton("##t", ImVec2(iconSize, iconSize)); bool hovered = ImGui::IsItemHovered(); - // Draw icon and border ImVec2 pMin = ImGui::GetItemRectMin(); ImVec2 pMax = ImGui::GetItemRectMax(); - auto* drawList = ImGui::GetWindowDrawList(); + auto* dl = ImGui::GetWindowDrawList(); + + // Background fill + ImU32 bgCol; + if (state == LOCKED) { + bgCol = IM_COL32(20, 20, 25, 200); + } else { + bgCol = IM_COL32(30, 30, 40, 200); + } + dl->AddRectFilled(pMin, pMax, bgCol, 3.0f); + + // Icon + if (iconTex) { + ImU32 tintCol = IM_COL32( + static_cast(tint.x * 255), static_cast(tint.y * 255), + static_cast(tint.z * 255), static_cast(tint.w * 255)); + dl->AddImage((ImTextureID)(uintptr_t)iconTex, + ImVec2(pMin.x + 2, pMin.y + 2), + ImVec2(pMax.x - 2, pMax.y - 2), + ImVec2(0, 0), ImVec2(1, 1), tintCol); + } // Border - float borderThickness = hovered ? 3.0f : 2.0f; - ImU32 borderCol = IM_COL32(borderColor.x * 255, borderColor.y * 255, borderColor.z * 255, 255); - drawList->AddRect(pMin, pMax, borderCol, 0.0f, 0, borderThickness); + float borderThick = hovered ? 2.5f : 1.5f; + ImU32 borderCol = IM_COL32( + static_cast(borderColor.x * 255), static_cast(borderColor.y * 255), + static_cast(borderColor.z * 255), static_cast(borderColor.w * 255)); + dl->AddRect(pMin, pMax, borderCol, 3.0f, 0, borderThick); - // Icon or colored background - if (iconTex) { - ImU32 tintCol = IM_COL32(tint.x * 255, tint.y * 255, tint.z * 255, tint.w * 255); - drawList->AddImage((ImTextureID)(uintptr_t)iconTex, - ImVec2(pMin.x + 2, pMin.y + 2), - ImVec2(pMax.x - 2, pMax.y - 2), - ImVec2(0, 0), ImVec2(1, 1), tintCol); - } else { - ImU32 bgCol = IM_COL32(borderColor.x * 80, borderColor.y * 80, borderColor.z * 80, 255); - drawList->AddRectFilled(ImVec2(pMin.x + 2, pMin.y + 2), - ImVec2(pMax.x - 2, pMax.y - 2), bgCol); + // Hover glow + if (hovered && state != LOCKED) { + dl->AddRect(ImVec2(pMin.x - 1, pMin.y - 1), ImVec2(pMax.x + 1, pMax.y + 1), + IM_COL32(255, 255, 255, 60), 3.0f, 0, 1.0f); } - // Rank indicator overlay - if (talent.maxRank > 1) { - ImVec2 pMax = ImGui::GetItemRectMax(); - auto* drawList = ImGui::GetWindowDrawList(); - - // Display rank: if learned, show (rank+1) since ranks are 0-indexed - const auto& learned = gameHandler.getLearnedTalents(); - uint8_t displayRank = (learned.find(talent.talentId) != learned.end()) ? currentRank + 1 : 0; - + // Rank counter (bottom-right corner) + { char rankText[16]; - snprintf(rankText, sizeof(rankText), "%u/%u", displayRank, talent.maxRank); - + snprintf(rankText, sizeof(rankText), "%u/%u", currentRank, talent.maxRank); ImVec2 textSize = ImGui::CalcTextSize(rankText); - ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 2); + ImVec2 textPos(pMax.x - textSize.x - 2, pMax.y - textSize.y - 1); - // Shadow - drawList->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText); - // Text - ImU32 rankCol = displayRank == talent.maxRank ? IM_COL32(0, 255, 0, 255) : - displayRank > 0 ? IM_COL32(255, 255, 0, 255) : - IM_COL32(255, 255, 255, 255); - drawList->AddText(textPos, rankCol, rankText); + // Background pill for readability + dl->AddRectFilled(ImVec2(textPos.x - 2, textPos.y - 1), + ImVec2(pMax.x, pMax.y), + IM_COL32(0, 0, 0, 180), 2.0f); + + // Text shadow + dl->AddText(ImVec2(textPos.x + 1, textPos.y + 1), IM_COL32(0, 0, 0, 255), rankText); + + // Rank text color + ImU32 rankCol; + switch (state) { + case MAXED: rankCol = IM_COL32(80, 255, 80, 255); break; + case PARTIAL: rankCol = IM_COL32(80, 255, 80, 255); break; + default: rankCol = IM_COL32(200, 200, 200, 255); break; + } + dl->AddText(textPos, rankCol, rankText); } - // Enhanced tooltip + // Tooltip if (hovered) { ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(320.0f); // Spell name const std::string& spellName = gameHandler.getSpellName(spellId); @@ -330,60 +483,55 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Talent #%u", talent.talentId); } - // Rank - ImGui::TextColored(borderColor, "Rank %u/%u", currentRank, talent.maxRank); + // Rank display + ImVec4 rankColor; + switch (state) { + case MAXED: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break; + case PARTIAL: rankColor = ImVec4(0.3f, 0.9f, 0.3f, 1); break; + default: rankColor = ImVec4(0.7f, 0.7f, 0.7f, 1); break; + } + ImGui::TextColored(rankColor, "Rank %u/%u", currentRank, talent.maxRank); // Current rank description - if (currentRank > 0 && talent.rankSpells[currentRank - 1] != 0) { + if (currentRank > 0 && currentRank <= 5 && talent.rankSpells[currentRank - 1] != 0) { auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank - 1]); if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f); + ImGui::Spacing(); ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Current:"); ImGui::TextWrapped("%s", tooltipIt->second.c_str()); - ImGui::PopTextWrapPos(); } } // Next rank description - if (currentRank < talent.maxRank && talent.rankSpells[currentRank] != 0) { + if (currentRank < talent.maxRank && currentRank < 5 && talent.rankSpells[currentRank] != 0) { auto tooltipIt = spellTooltips.find(talent.rankSpells[currentRank]); if (tooltipIt != spellTooltips.end() && !tooltipIt->second.empty()) { ImGui::Spacing(); - ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 300.0f); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Next Rank:"); ImGui::TextWrapped("%s", tooltipIt->second.c_str()); - ImGui::PopTextWrapPos(); } } // Prerequisites for (int i = 0; i < 3; ++i) { - if (talent.prereqTalent[i] != 0) { - const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]); - if (prereq && prereq->rankSpells[0] != 0) { - uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - bool met = prereqCurrentRank >= talent.prereqRank[i]; - ImVec4 prereqColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); + if (talent.prereqTalent[i] == 0) continue; + const auto* prereq = gameHandler.getTalentEntry(talent.prereqTalent[i]); + if (!prereq || prereq->rankSpells[0] == 0) continue; - const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]); - ImGui::Spacing(); - ImGui::TextColored(prereqColor, "Requires %u point%s in %s", - talent.prereqRank[i], - talent.prereqRank[i] > 1 ? "s" : "", - prereqName.empty() ? "prerequisite" : prereqName.c_str()); - } - } + uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]); + bool met = prereqCurrentRank >= talent.prereqRank[i]; + ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1); + + const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]); + ImGui::Spacing(); + ImGui::TextColored(pColor, "Requires %u point%s in %s", + talent.prereqRank[i], + talent.prereqRank[i] > 1 ? "s" : "", + prereqName.empty() ? "prerequisite" : prereqName.c_str()); } // Tier requirement if (talent.row > 0 && currentRank == 0) { - uint32_t pointsInTree = 0; - for (const auto& [tid, rank] : gameHandler.getLearnedTalents()) { - const auto* t = gameHandler.getTalentEntry(tid); - if (t && t->tabId == talent.tabId) { - pointsInTree += rank; - } - } uint32_t requiredPoints = talent.row * 5; if (pointsInTree < requiredPoints) { ImGui::Spacing(); @@ -397,38 +545,22 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, if (canLearn && prereqsMet) { ImGui::Spacing(); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Click to learn"); - } else if (currentRank >= talent.maxRank) { - ImGui::Spacing(); - ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Maxed"); } + ImGui::PopTextWrapPos(); ImGui::EndTooltip(); } // Handle click - if (clicked) { - LOG_INFO("Talent clicked: id=", talent.talentId, " canLearn=", canLearn, " prereqsMet=", prereqsMet, - " currentRank=", static_cast(currentRank), " maxRank=", static_cast(talent.maxRank), - " unspent=", static_cast(gameHandler.getUnspentTalentPoints())); - - if (canLearn && prereqsMet) { - // Rank is 0-indexed: first point = rank 0, second = rank 1, etc. - // Check if talent is already learned - const auto& learned = gameHandler.getLearnedTalents(); - uint8_t desiredRank; - if (learned.find(talent.talentId) == learned.end()) { - // Not learned yet, learn first rank (0) - desiredRank = 0; - } else { - // Already learned, upgrade to next rank - desiredRank = currentRank + 1; - } - LOG_INFO("Sending CMSG_LEARN_TALENT for talent ", talent.talentId, " rank ", static_cast(desiredRank), " (0-indexed)"); - gameHandler.learnTalent(talent.talentId, desiredRank); + if (clicked && canLearn && prereqsMet) { + const auto& learned = gameHandler.getLearnedTalents(); + uint8_t desiredRank; + if (learned.find(talent.talentId) == learned.end()) { + desiredRank = 0; // First rank (0-indexed on wire) } else { - if (!canLearn) LOG_WARNING("Cannot learn: canLearn=false"); - if (!prereqsMet) LOG_WARNING("Cannot learn: prereqsMet=false"); + desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn } + gameHandler.learnTalent(talent.talentId, desiredRank); } ImGui::PopID(); @@ -441,12 +573,8 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (!assetManager || !assetManager->isInitialized()) return; auto dbc = assetManager->loadDBC("Spell.dbc"); - if (!dbc || !dbc->isLoaded()) { - LOG_WARNING("Talent screen: Could not load Spell.dbc"); - return; - } + if (!dbc || !dbc->isLoaded()) return; - // WoW 3.3.5a Spell.dbc fields: 0=SpellID, 133=SpellIconID, 136=SpellName_enUS, 139=Tooltip_enUS const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { @@ -461,8 +589,6 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { spellTooltips[spellId] = tooltip; } } - - LOG_INFO("Talent screen: Loaded ", spellIconIds.size(), " spell icons from Spell.dbc"); } void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { @@ -472,10 +598,7 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (!assetManager || !assetManager->isInitialized()) return; auto dbc = assetManager->loadDBC("SpellIcon.dbc"); - if (!dbc || !dbc->isLoaded()) { - LOG_WARNING("Talent screen: Could not load SpellIcon.dbc"); - return; - } + if (!dbc || !dbc->isLoaded()) return; const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { @@ -485,8 +608,6 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { spellIconPaths[id] = path; } } - - LOG_INFO("Talent screen: Loaded ", spellIconPaths.size(), " spell icon paths from SpellIcon.dbc"); } VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) {