diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 6679d102..2c84e9a0 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -536,6 +536,14 @@ public: void useItemById(uint32_t itemId); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } + + // Trainer + bool isTrainerWindowOpen() const { return trainerWindowOpen_; } + const TrainerListData& getTrainerSpells() const { return currentTrainerList_; } + void trainSpell(uint32_t spellId); + void closeTrainer(); + const std::string& getSpellName(uint32_t spellId) const; + const std::string& getSpellRank(uint32_t spellId) const; const ItemQueryResponseData* getItemInfo(uint32_t itemId) const { auto it = itemInfoCache_.find(itemId); return (it != itemInfoCache_.end()) ? &it->second : nullptr; @@ -948,6 +956,15 @@ private: bool vendorWindowOpen = false; ListInventoryData currentVendorItems; + // Trainer + bool trainerWindowOpen_ = false; + TrainerListData currentTrainerList_; + struct SpellNameEntry { std::string name; std::string rank; }; + std::unordered_map spellNameCache_; + bool spellNameCacheLoaded_ = false; + void handleTrainerList(network::Packet& packet); + void loadSpellNameCache(); + // Callbacks WorldConnectSuccessCallback onSuccess; WorldConnectFailureCallback onFailure; diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 78b31498..19cd0ed0 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -126,7 +126,7 @@ enum class Opcode : uint16_t { CMSG_GAMEOBJECT_QUERY = 0x05E, SMSG_GAMEOBJECT_QUERY_RESPONSE = 0x05F, CMSG_SET_ACTIVE_MOVER = 0x26A, - CMSG_BINDER_ACTIVATE = 0x1B2, + CMSG_BINDER_ACTIVATE = 0x1B5, // ---- XP ---- SMSG_LOG_XPGAIN = 0x1D0, @@ -231,6 +231,10 @@ enum class Opcode : uint16_t { CMSG_BUY_ITEM = 0x1A2, SMSG_BUY_FAILED = 0x1A5, + // ---- Trainer ---- + SMSG_TRAINER_LIST = 0x01B1, + CMSG_TRAINER_BUY_SPELL = 0x01B2, + // ---- Phase 5: Item/Equip ---- CMSG_ITEM_QUERY_SINGLE = 0x056, SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 91200088..38f95050 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1716,6 +1716,42 @@ public: static bool parse(network::Packet& packet, ListInventoryData& data); }; +// ============================================================ +// Trainer +// ============================================================ + +struct TrainerSpell { + uint32_t spellId = 0; + uint8_t state = 0; // 0=known(green), 1=available, 2=unavailable(red) + uint32_t spellCost = 0; // copper + uint32_t profDialog = 0; + uint32_t profButton = 0; + uint8_t reqLevel = 0; + uint32_t reqSkill = 0; + uint32_t reqSkillValue = 0; + uint32_t chainNode1 = 0; + uint32_t chainNode2 = 0; + uint32_t chainNode3 = 0; +}; + +struct TrainerListData { + uint64_t trainerGuid = 0; + uint32_t trainerType = 0; // 0=class, 1=mounts, 2=tradeskills, 3=pets + std::vector spells; + std::string greeting; + bool isValid() const { return true; } +}; + +class TrainerListParser { +public: + static bool parse(network::Packet& packet, TrainerListData& data); +}; + +class TrainerBuySpellPacket { +public: + static network::Packet build(uint64_t trainerGuid, uint32_t spellId); +}; + // ============================================================ // Taxi / Flight Paths // ============================================================ diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 6e6fdac2..ce376b46 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -145,6 +145,7 @@ private: void renderQuestRequestItemsWindow(game::GameHandler& gameHandler); void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); + void renderTrainerWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 806c5428..dde6b795 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -313,6 +313,18 @@ void GameHandler::update(float deltaTime) { } } } + if (trainerWindowOpen_ && currentTrainerList_.trainerGuid != 0) { + auto npc = entityManager.getEntity(currentTrainerList_.trainerGuid); + if (npc) { + float dx = movementInfo.x - npc->getX(); + float dy = movementInfo.y - npc->getY(); + float dist = std::sqrt(dx * dx + dy * dy); + if (dist > 15.0f) { + closeTrainer(); + LOG_INFO("Trainer closed: walked too far from NPC"); + } + } + } // Update entity movement interpolation (keeps targeting in sync with visuals) for (auto& [guid, entity] : entityManager.getEntities()) { @@ -639,6 +651,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LIST_INVENTORY: handleListInventory(packet); break; + case Opcode::SMSG_TRAINER_LIST: + handleTrainerList(packet); + break; // Silently ignore common packets we don't handle yet case Opcode::SMSG_FEATURE_SYSTEM_STATUS: @@ -4538,6 +4553,74 @@ void GameHandler::handleListInventory(network::Packet& packet) { } } +// ============================================================ +// Trainer +// ============================================================ + +void GameHandler::handleTrainerList(network::Packet& packet) { + if (!TrainerListParser::parse(packet, currentTrainerList_)) return; + trainerWindowOpen_ = true; + gossipWindowOpen = false; + + // Ensure spell name cache is populated + loadSpellNameCache(); +} + +void GameHandler::trainSpell(uint32_t spellId) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = TrainerBuySpellPacket::build(currentTrainerList_.trainerGuid, spellId); + socket->send(packet); +} + +void GameHandler::closeTrainer() { + trainerWindowOpen_ = false; + currentTrainerList_ = TrainerListData{}; +} + +void GameHandler::loadSpellNameCache() { + if (spellNameCacheLoaded_) return; + spellNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Spell.dbc"); + if (!dbc || !dbc->isLoaded()) { + LOG_WARNING("Trainer: Could not load Spell.dbc for spell names"); + return; + } + + if (dbc->getFieldCount() < 154) { + LOG_WARNING("Trainer: Spell.dbc has too few fields"); + return; + } + + // Fields: 0=SpellID, 136=SpellName_enUS, 153=RankText_enUS + uint32_t count = dbc->getRecordCount(); + for (uint32_t i = 0; i < count; ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + std::string name = dbc->getString(i, 136); + std::string rank = dbc->getString(i, 153); + if (!name.empty()) { + spellNameCache_[id] = {std::move(name), std::move(rank)}; + } + } + LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc"); +} + +static const std::string EMPTY_STRING; + +const std::string& GameHandler::getSpellName(uint32_t spellId) const { + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.name : EMPTY_STRING; +} + +const std::string& GameHandler::getSpellRank(uint32_t spellId) const { + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; +} + // ============================================================ // Single-player local combat // ============================================================ diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4835fc2e..2fd74d08 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2643,6 +2643,52 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data return true; } +// ============================================================ +// Trainer +// ============================================================ + +bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { + data = TrainerListData{}; + data.trainerGuid = packet.readUInt64(); + data.trainerType = packet.readUInt32(); + uint32_t spellCount = packet.readUInt32(); + + if (spellCount > 1000) { + LOG_ERROR("TrainerListParser: unreasonable spell count ", spellCount); + return false; + } + + data.spells.reserve(spellCount); + for (uint32_t i = 0; i < spellCount; ++i) { + TrainerSpell spell; + spell.spellId = packet.readUInt32(); + spell.state = packet.readUInt8(); + spell.spellCost = packet.readUInt32(); + spell.profDialog = packet.readUInt32(); + spell.profButton = packet.readUInt32(); + spell.reqLevel = packet.readUInt8(); + spell.reqSkill = packet.readUInt32(); + spell.reqSkillValue = packet.readUInt32(); + spell.chainNode1 = packet.readUInt32(); + spell.chainNode2 = packet.readUInt32(); + spell.chainNode3 = packet.readUInt32(); + data.spells.push_back(spell); + } + + data.greeting = packet.readString(); + + LOG_INFO("Trainer list: ", spellCount, " spells, type=", data.trainerType, + ", greeting=\"", data.greeting, "\""); + return true; +} + +network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) { + network::Packet packet(static_cast(Opcode::CMSG_TRAINER_BUY_SPELL)); + packet.writeUInt64(trainerGuid); + packet.writeUInt32(spellId); + return packet; +} + // ============================================================ // Death/Respawn // ============================================================ diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 215ccb9d..c82d454b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -103,6 +103,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestRequestItemsWindow(gameHandler); renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); + renderTrainerWindow(gameHandler); renderTaxiWindow(gameHandler); renderQuestMarkers(gameHandler); renderMinimapMarkers(gameHandler); @@ -3519,6 +3520,157 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Trainer +// ============================================================ + +void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTrainerWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing); + + bool open = true; + if (ImGui::Begin("Trainer", &open)) { + const auto& trainer = gameHandler.getTrainerSpells(); + + // NPC name + auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid); + if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(npcEntity); + if (!unit->getName().empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str()); + } + } + + // Greeting + if (!trainer.greeting.empty()) { + ImGui::TextWrapped("%s", trainer.greeting.c_str()); + } + ImGui::Separator(); + + // Player money + uint64_t money = gameHandler.getMoneyCopper(); + uint32_t mg = static_cast(money / 10000); + uint32_t ms = static_cast((money / 100) % 100); + uint32_t mc = static_cast(money % 100); + ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::Separator(); + + if (trainer.spells.empty()) { + ImGui::TextDisabled("This trainer has nothing to teach you."); + } else { + // Known spells for checking + const auto& knownSpells = gameHandler.getKnownSpells(); + auto isKnown = [&](uint32_t id) { + return std::find(knownSpells.begin(), knownSpells.end(), id) != knownSpells.end(); + }; + + if (ImGui::BeginTable("TrainerTable", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableHeadersRow(); + + for (const auto& spell : trainer.spells) { + ImGui::TableNextRow(); + ImGui::PushID(static_cast(spell.spellId)); + + // State color: 0=known(green), 1=available(white), 2=unavailable(gray) + ImVec4 color; + const char* statusLabel; + if (spell.state == 0 || isKnown(spell.spellId)) { + color = ImVec4(0.3f, 0.9f, 0.3f, 1.0f); + statusLabel = "Known"; + } else if (spell.state == 1) { + color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + statusLabel = "Available"; + } else { + color = ImVec4(0.6f, 0.3f, 0.3f, 1.0f); + statusLabel = "Unavailable"; + } + + // Spell name + ImGui::TableSetColumnIndex(0); + const std::string& name = gameHandler.getSpellName(spell.spellId); + const std::string& rank = gameHandler.getSpellRank(spell.spellId); + if (!name.empty()) { + if (!rank.empty()) { + ImGui::TextColored(color, "%s (%s)", name.c_str(), rank.c_str()); + } else { + ImGui::TextColored(color, "%s", name.c_str()); + } + } else { + ImGui::TextColored(color, "Spell #%u", spell.spellId); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (!name.empty()) { + ImGui::Text("%s", name.c_str()); + if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str()); + } + ImGui::Text("Status: %s", statusLabel); + if (spell.reqLevel > 0) ImGui::Text("Required Level: %u", spell.reqLevel); + if (spell.reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell.reqSkill, spell.reqSkillValue); + if (spell.chainNode1 > 0) { + const std::string& prereq = gameHandler.getSpellName(spell.chainNode1); + if (!prereq.empty()) ImGui::Text("Requires: %s", prereq.c_str()); + else ImGui::Text("Requires: Spell #%u", spell.chainNode1); + } + ImGui::EndTooltip(); + } + + // Level + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(color, "%u", spell.reqLevel); + + // Cost + ImGui::TableSetColumnIndex(2); + if (spell.spellCost > 0) { + uint32_t g = spell.spellCost / 10000; + uint32_t s = (spell.spellCost / 100) % 100; + uint32_t c = spell.spellCost % 100; + bool canAfford = money >= spell.spellCost; + ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); + ImGui::TextColored(costColor, "%ug %us %uc", g, s, c); + } else { + ImGui::TextColored(color, "Free"); + } + + // Train button + ImGui::TableSetColumnIndex(3); + bool canTrain = (spell.state == 1) && (money >= spell.spellCost); + if (!canTrain) { + ImGui::BeginDisabled(); + } + if (ImGui::SmallButton("Train")) { + gameHandler.trainSpell(spell.spellId); + } + if (!canTrain) { + ImGui::EndDisabled(); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeTrainer(); + } +} + // ============================================================ // Teleporter Panel // ============================================================