Add trainer dialog system with spell list UI and buy support

This commit is contained in:
Kelsi 2026-02-08 14:33:39 -08:00
parent 046d4615ea
commit 9a01261401
7 changed files with 340 additions and 1 deletions

View file

@ -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<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
void handleTrainerList(network::Packet& packet);
void loadSpellNameCache();
// Callbacks
WorldConnectSuccessCallback onSuccess;
WorldConnectFailureCallback onFailure;

View file

@ -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,

View file

@ -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<TrainerSpell> 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
// ============================================================

View file

@ -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);

View file

@ -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
// ============================================================

View file

@ -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<uint16_t>(Opcode::CMSG_TRAINER_BUY_SPELL));
packet.writeUInt64(trainerGuid);
packet.writeUInt32(spellId);
return packet;
}
// ============================================================
// Death/Respawn
// ============================================================

View file

@ -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<float>(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<game::Unit>(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<uint32_t>(money / 10000);
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(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<int>(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
// ============================================================