mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Add trainer dialog system with spell list UI and buy support
This commit is contained in:
parent
046d4615ea
commit
9a01261401
7 changed files with 340 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue