From ada019e0d4cb8b51ec40311847f923754c85f3b6 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Sun, 12 Apr 2026 15:46:03 +0300 Subject: [PATCH] refactor(chat): extract ItemTooltipRenderer, slim render(), consolidate utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract renderItemTooltip() (510 LOC) from ChatMarkupRenderer into dedicated ItemTooltipRenderer class; chat_markup_renderer.cpp 766→192 LOC - Extract formatChatMessage(), detectChannelPrefix(), inputTextCallback() from render(); render() 711→376 LOC - Consolidate replaceGenderPlaceholders() from 3 copies into chat_utils::replaceGenderPlaceholders(); remove 118 LOC duplicate from quest_log_screen.cpp, update 8 call sites in window_manager.cpp - Delete chat_panel_commands.cpp (359 LOC) — absorb sendChatMessage, executeMacroText, PortBot helpers into chat_panel.cpp; move evaluateMacroConditionals to macro_eval_convenience.cpp - Delete chat_panel_utils.cpp (229 LOC) — absorb small utilities into chat_panel.cpp - Replace 3 forward declarations of evaluateMacroConditionals with #include "ui/chat/macro_evaluator.hpp" Signed-off-by: Pavel Okhlopkov --- CMakeLists.txt | 5 +- include/ui/chat/chat_markup_renderer.hpp | 9 - include/ui/chat/chat_utils.hpp | 19 +- include/ui/chat/item_tooltip_renderer.hpp | 28 + include/ui/chat/macro_evaluator.hpp | 7 + include/ui/chat_panel.hpp | 6 +- src/ui/chat/chat_markup_renderer.cpp | 520 +--------- .../chat_utils.cpp} | 130 +-- src/ui/chat/commands/combat_commands.cpp | 6 +- src/ui/chat/commands/system_commands.cpp | 6 +- src/ui/chat/commands/target_commands.cpp | 6 +- src/ui/chat/item_tooltip_renderer.cpp | 521 ++++++++++ src/ui/chat/macro_eval_convenience.cpp | 24 + src/ui/chat_panel.cpp | 978 ++++++++++++------ src/ui/chat_panel_commands.cpp | 359 ------- src/ui/quest_log_screen.cpp | 122 +-- src/ui/window_manager.cpp | 19 +- 17 files changed, 1302 insertions(+), 1463 deletions(-) create mode 100644 include/ui/chat/item_tooltip_renderer.hpp rename src/ui/{chat_panel_utils.cpp => chat/chat_utils.cpp} (54%) create mode 100644 src/ui/chat/item_tooltip_renderer.cpp create mode 100644 src/ui/chat/macro_eval_convenience.cpp delete mode 100644 src/ui/chat_panel_commands.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 540d86a0..b392a992 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -685,17 +685,18 @@ set(WOWEE_SOURCES src/ui/game_screen_hud.cpp src/ui/game_screen_minimap.cpp src/ui/chat_panel.cpp - src/ui/chat_panel_commands.cpp - src/ui/chat_panel_utils.cpp src/ui/chat/chat_settings.cpp src/ui/chat/chat_input.cpp + src/ui/chat/chat_utils.cpp src/ui/chat/chat_tab_manager.cpp src/ui/chat/chat_bubble_manager.cpp src/ui/chat/chat_markup_parser.cpp src/ui/chat/chat_markup_renderer.cpp + src/ui/chat/item_tooltip_renderer.cpp src/ui/chat/chat_command_registry.cpp src/ui/chat/chat_tab_completer.cpp src/ui/chat/macro_evaluator.cpp + src/ui/chat/macro_eval_convenience.cpp src/ui/chat/game_state_adapter.cpp src/ui/chat/input_modifier_adapter.cpp src/ui/chat/commands/system_commands.cpp diff --git a/include/ui/chat/chat_markup_renderer.hpp b/include/ui/chat/chat_markup_renderer.hpp index 69f62da5..1702b543 100644 --- a/include/ui/chat/chat_markup_renderer.hpp +++ b/include/ui/chat/chat_markup_renderer.hpp @@ -47,15 +47,6 @@ public: void render(const std::vector& segments, const ImVec4& baseColor, const MarkupRenderContext& ctx) const; - - /** - * Render a full item tooltip for the given item entry. - * Extracted from the renderItemLinkTooltip inline lambda. - */ - static void renderItemTooltip(uint32_t itemEntry, - game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - pipeline::AssetManager* assetMgr); }; } // namespace ui diff --git a/include/ui/chat/chat_utils.hpp b/include/ui/chat/chat_utils.hpp index 5e288961..3310ff8f 100644 --- a/include/ui/chat/chat_utils.hpp +++ b/include/ui/chat/chat_utils.hpp @@ -1,14 +1,18 @@ #pragma once #include "game/world_packets.hpp" +#include "game/entity.hpp" #include #include +#include #include namespace wowee { -// Forward declaration -namespace game { class GameHandler; } +// Forward declarations +namespace game { + class GameHandler; +} namespace ui { namespace chat_utils { @@ -38,6 +42,17 @@ inline std::string toLower(std::string s) { return s; } +/** + * Replace $g/$G gender, $n/$N name, $c/$C class, $r/$R race, + * $p/$o/$s/$S pronoun, $b/$B linebreak, and |n linebreak placeholders. + * Extracted from ChatPanel::replaceGenderPlaceholders (Phase 6.6). + */ +std::string replaceGenderPlaceholders(const std::string& text, + game::GameHandler& gameHandler); + +/** Get display name for any entity (Player/Unit/GameObject). */ +std::string getEntityDisplayName(const std::shared_ptr& entity); + } // namespace chat_utils } // namespace ui } // namespace wowee diff --git a/include/ui/chat/item_tooltip_renderer.hpp b/include/ui/chat/item_tooltip_renderer.hpp new file mode 100644 index 00000000..14486910 --- /dev/null +++ b/include/ui/chat/item_tooltip_renderer.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace wowee { +namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace ui { + +class InventoryScreen; + +/** + * Renders a full WoW-style item tooltip via ImGui. + * + * Extracted from ChatMarkupRenderer::renderItemTooltip (Phase 6.7). + * Handles: item name/quality color, armor/DPS, stats, sockets, set bonuses, + * durability, sell price, required level, comparison tooltip. + */ +class ItemTooltipRenderer { +public: + static void render(uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr); +}; + +} // namespace ui +} // namespace wowee diff --git a/include/ui/chat/macro_evaluator.hpp b/include/ui/chat/macro_evaluator.hpp index 00c07610..de493a95 100644 --- a/include/ui/chat/macro_evaluator.hpp +++ b/include/ui/chat/macro_evaluator.hpp @@ -7,6 +7,7 @@ #include namespace wowee { +namespace game { class GameHandler; } namespace ui { class IGameState; @@ -46,5 +47,11 @@ private: IModifierState& modState_; }; +// Convenience free function — thin wrapper over MacroEvaluator. +// Used by command modules (combat_commands, system_commands, target_commands). +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); + } // namespace ui } // namespace wowee diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp index 954def48..657f011b 100644 --- a/include/ui/chat_panel.hpp +++ b/include/ui/chat_panel.hpp @@ -125,9 +125,6 @@ public: // UIServices injection (Phase B singleton breaking) void setServices(const UIServices& services) { services_ = services; } - /** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */ - std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); - // ---- Accessors for command system (Phase 3) ---- char* getChatInputBuffer() { return chatInputBuffer_; } size_t getChatInputBufferSize() const { return sizeof(chatInputBuffer_); } @@ -203,7 +200,8 @@ private: // ---- Helpers ---- void sendChatMessage(game::GameHandler& gameHandler); - // getChatTypeName / getChatTypeColor now static in ChatTabManager + static int inputTextCallback(ImGuiInputTextCallbackData* data); + void detectChannelPrefix(game::GameHandler& gameHandler); // Cached game handler for input callback (set each frame in render) game::GameHandler* cachedGameHandler_ = nullptr; diff --git a/src/ui/chat/chat_markup_renderer.cpp b/src/ui/chat/chat_markup_renderer.cpp index dc380c83..dfe25e37 100644 --- a/src/ui/chat/chat_markup_renderer.cpp +++ b/src/ui/chat/chat_markup_renderer.cpp @@ -1,531 +1,19 @@ // ChatMarkupRenderer — render parsed ChatSegments via ImGui. // Moved from ChatPanel::render() inline lambdas (Phase 2.2). +// Item tooltip rendering extracted to ItemTooltipRenderer (Phase 6.7). #include "ui/chat/chat_markup_renderer.hpp" +#include "ui/chat/item_tooltip_renderer.hpp" #include "ui/ui_colors.hpp" #include "ui/inventory_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/quest_log_screen.hpp" #include "game/game_handler.hpp" #include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" #include #include -#include -#include namespace wowee { namespace ui { -// ---- renderItemTooltip (moved from renderItemLinkTooltip lambda) ---- - -void ChatMarkupRenderer::renderItemTooltip( - uint32_t itemEntry, - game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - pipeline::AssetManager* assetMgr) -{ - const auto* info = gameHandler.getItemInfo(itemEntry); - if (!info || !info->valid) return; - - auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { - using ES = game::EquipSlot; - const auto& inv = gameHandler.getInventory(); - auto slotPtr = [&](ES slot) -> const game::ItemSlot* { - const auto& s = inv.getEquipSlot(slot); - return s.empty() ? nullptr : &s; - }; - switch (inventoryType) { - case 1: return slotPtr(ES::HEAD); - case 2: return slotPtr(ES::NECK); - case 3: return slotPtr(ES::SHOULDERS); - case 4: return slotPtr(ES::SHIRT); - case 5: - case 20: return slotPtr(ES::CHEST); - case 6: return slotPtr(ES::WAIST); - case 7: return slotPtr(ES::LEGS); - case 8: return slotPtr(ES::FEET); - case 9: return slotPtr(ES::WRISTS); - case 10: return slotPtr(ES::HANDS); - case 11: { - if (auto* s = slotPtr(ES::RING1)) return s; - return slotPtr(ES::RING2); - } - case 12: { - if (auto* s = slotPtr(ES::TRINKET1)) return s; - return slotPtr(ES::TRINKET2); - } - case 13: - if (auto* s = slotPtr(ES::MAIN_HAND)) return s; - return slotPtr(ES::OFF_HAND); - case 14: - case 22: - case 23: return slotPtr(ES::OFF_HAND); - case 15: - case 25: - case 26: return slotPtr(ES::RANGED); - case 16: return slotPtr(ES::BACK); - case 17: - case 21: return slotPtr(ES::MAIN_HAND); - case 18: - for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { - auto slot = static_cast(static_cast(ES::BAG1) + i); - if (auto* s = slotPtr(slot)) return s; - } - return nullptr; - case 19: return slotPtr(ES::TABARD); - default: return nullptr; - } - }; - - ImGui::BeginTooltip(); - // Quality color for name - auto qColor = ui::getQualityColor(static_cast(info->quality)); - ImGui::TextColored(qColor, "%s", info->name.c_str()); - - // Heroic indicator (green, matches WoW tooltip style) - constexpr uint32_t kFlagHeroic = 0x8; - constexpr uint32_t kFlagUniqueEquipped = 0x1000000; - if (info->itemFlags & kFlagHeroic) - ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); - - // Bind type (appears right under name in WoW) - switch (info->bindType) { - case 1: ImGui::TextDisabled("Binds when picked up"); break; - case 2: ImGui::TextDisabled("Binds when equipped"); break; - case 3: ImGui::TextDisabled("Binds when used"); break; - case 4: ImGui::TextDisabled("Quest Item"); break; - } - // Unique / Unique-Equipped - if (info->maxCount == 1) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); - else if (info->itemFlags & kFlagUniqueEquipped) - ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); - - // Slot type - if (info->inventoryType > 0) { - const char* slotName = ui::getInventorySlotName(info->inventoryType); - if (slotName[0]) { - if (!info->subclassName.empty()) - ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); - else - ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); - } - } - auto isWeaponInventoryType = [](uint32_t invType) { - switch (invType) { - case 13: // One-Hand - case 15: // Ranged - case 17: // Two-Hand - case 21: // Main Hand - case 25: // Thrown - case 26: // Ranged Right - return true; - default: - return false; - } - }; - const bool isWeapon = isWeaponInventoryType(info->inventoryType); - - // Item level (after slot/subclass) - if (info->itemLevel > 0) - ImGui::TextDisabled("Item Level %u", info->itemLevel); - - if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - char dmgBuf[64], spdBuf[32]; - std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", - static_cast(info->damageMin), static_cast(info->damageMax)); - std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); - float spdW = ImGui::CalcTextSize(spdBuf).x; - ImGui::Text("%s", dmgBuf); - ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); - ImGui::Text("%s", spdBuf); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " "; - out += shortName; - }; - std::string bonusLine; - appendBonus(bonusLine, info->strength, "Str"); - appendBonus(bonusLine, info->agility, "Agi"); - appendBonus(bonusLine, info->stamina, "Sta"); - appendBonus(bonusLine, info->intellect, "Int"); - appendBonus(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) { - ImGui::TextColored(green, "%s", bonusLine.c_str()); - } - if (info->armor > 0) { - ImGui::Text("%d Armor", info->armor); - } - // Elemental resistances - { - const int32_t resVals[6] = { - info->holyRes, info->fireRes, info->natureRes, - info->frostRes, info->shadowRes, info->arcaneRes - }; - static constexpr const char* resLabels[6] = { - "Holy Resistance", "Fire Resistance", "Nature Resistance", - "Frost Resistance", "Shadow Resistance", "Arcane Resistance" - }; - for (int ri = 0; ri < 6; ++ri) - if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); - } - // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) - if (!info->extraStats.empty()) { - auto statName = [](uint32_t t) -> const char* { - switch (t) { - case 12: return "Defense Rating"; - case 13: return "Dodge Rating"; - case 14: return "Parry Rating"; - case 15: return "Block Rating"; - case 16: case 17: case 18: case 31: return "Hit Rating"; - case 19: case 20: case 21: case 32: return "Critical Strike Rating"; - case 28: case 29: case 30: case 35: return "Haste Rating"; - case 34: return "Resilience Rating"; - case 36: return "Expertise Rating"; - case 37: return "Attack Power"; - case 38: return "Ranged Attack Power"; - case 45: return "Spell Power"; - case 46: return "Healing Power"; - case 47: return "Spell Damage"; - case 49: return "Mana per 5 sec."; - case 43: return "Spell Penetration"; - case 44: return "Block Value"; - default: return nullptr; - } - }; - for (const auto& es : info->extraStats) { - const char* nm = statName(es.statType); - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - // Gem sockets - { - const auto& kSocketTypes = ui::kSocketTypes; - bool hasSocket = false; - for (int s = 0; s < 3; ++s) { - if (info->socketColor[s] == 0) continue; - if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } - for (const auto& st : kSocketTypes) { - if (info->socketColor[s] & st.mask) { - ImGui::TextColored(st.col, "%s", st.label); - break; - } - } - } - if (hasSocket && info->socketBonus != 0) { - static std::unordered_map s_enchantNames; - static bool s_enchantNamesLoaded = false; - if (!s_enchantNamesLoaded && assetMgr) { - s_enchantNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* lay = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; - uint32_t nameField = lay ? lay->field("Name") : 8u; - if (nameField == 0xFFFFFFFF) nameField = 8; - uint32_t fc = dbc->getFieldCount(); - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t eid = dbc->getUInt32(r, 0); - if (eid == 0 || nameField >= fc) continue; - std::string ename = dbc->getString(r, nameField); - if (!ename.empty()) s_enchantNames[eid] = std::move(ename); - } - } - } - auto enchIt = s_enchantNames.find(info->socketBonus); - if (enchIt != s_enchantNames.end()) - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); - else - ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); - } - } - // Item set membership - if (info->itemSetId != 0) { - struct SetEntry { - std::string name; - std::array itemIds{}; - std::array spellIds{}; - std::array thresholds{}; - }; - static std::unordered_map s_setData; - static bool s_setDataLoaded = false; - if (!s_setDataLoaded && assetMgr) { - s_setDataLoaded = true; - auto dbc = assetMgr->loadDBC("ItemSet.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; - auto lf = [&](const char* k, uint32_t def) -> uint32_t { - return layout ? (*layout)[k] : def; - }; - uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); - const auto& itemKeys = ui::kItemSetItemKeys; - const auto& spellKeys = ui::kItemSetSpellKeys; - const auto& thrKeys = ui::kItemSetThresholdKeys; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t id = dbc->getUInt32(r, idF); - if (!id) continue; - SetEntry e; - e.name = dbc->getString(r, nameF); - for (int i = 0; i < 10; ++i) { - e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); - e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); - e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); - } - s_setData[id] = std::move(e); - } - } - } - ImGui::Spacing(); - const auto& inv = gameHandler.getInventory(); - auto setIt = s_setData.find(info->itemSetId); - if (setIt != s_setData.end()) { - const SetEntry& se = setIt->second; - int equipped = 0, total = 0; - for (int i = 0; i < 10; ++i) { - if (se.itemIds[i] == 0) continue; - ++total; - for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { - const auto& eq = inv.getEquipSlot(static_cast(sl)); - if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } - } - } - if (total > 0) - ImGui::TextColored(ui::colors::kTooltipGold, - "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); - else if (!se.name.empty()) - ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); - for (int i = 0; i < 10; ++i) { - if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; - const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); - bool active = (equipped >= static_cast(se.thresholds[i])); - ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; - if (!bname.empty()) - ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); - else - ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); - } - } else { - ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); - } - } - // Item spell effects (Use / Equip / Chance on Hit / Teaches) - for (const auto& sp : info->spells) { - if (sp.spellId == 0) continue; - const char* triggerLabel = nullptr; - switch (sp.spellTrigger) { - case 0: triggerLabel = "Use"; break; - case 1: triggerLabel = "Equip"; break; - case 2: triggerLabel = "Chance on Hit"; break; - case 5: triggerLabel = "Teaches"; break; - } - if (!triggerLabel) continue; - const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); - const std::string& spText = !spDesc.empty() ? spDesc - : gameHandler.getSpellName(sp.spellId); - if (!spText.empty()) { - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); - ImGui::TextColored(colors::kCyan, - "%s: %s", triggerLabel, spText.c_str()); - ImGui::PopTextWrapPos(); - } - } - // Required level - if (info->requiredLevel > 1) - ImGui::TextDisabled("Requires Level %u", info->requiredLevel); - // Required skill - if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { - static std::unordered_map s_skillNames; - static bool s_skillNamesLoaded = false; - if (!s_skillNamesLoaded && assetMgr) { - s_skillNamesLoaded = true; - auto dbc = assetMgr->loadDBC("SkillLine.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 2u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t sid = dbc->getUInt32(r, idF); - if (!sid) continue; - std::string sname = dbc->getString(r, nameF); - if (!sname.empty()) s_skillNames[sid] = std::move(sname); - } - } - } - uint32_t playerSkillVal = 0; - const auto& skills = gameHandler.getPlayerSkills(); - auto skPit = skills.find(info->requiredSkill); - if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); - bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); - ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - auto skIt = s_skillNames.find(info->requiredSkill); - if (skIt != s_skillNames.end()) - ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); - else - ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); - } - // Required reputation - if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { - static std::unordered_map s_factionNames; - static bool s_factionNamesLoaded = false; - if (!s_factionNamesLoaded && assetMgr) { - s_factionNamesLoaded = true; - auto dbc = assetMgr->loadDBC("Faction.dbc"); - if (dbc && dbc->isLoaded()) { - const auto* layout = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; - uint32_t idF = layout ? (*layout)["ID"] : 0u; - uint32_t nameF = layout ? (*layout)["Name"] : 20u; - for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { - uint32_t fid = dbc->getUInt32(r, idF); - if (!fid) continue; - std::string fname = dbc->getString(r, nameF); - if (!fname.empty()) s_factionNames[fid] = std::move(fname); - } - } - } - static constexpr const char* kRepRankNames[] = { - "Hated", "Hostile", "Unfriendly", "Neutral", - "Friendly", "Honored", "Revered", "Exalted" - }; - const char* rankName = (info->requiredReputationRank < 8) - ? kRepRankNames[info->requiredReputationRank] : "Unknown"; - auto fIt = s_factionNames.find(info->requiredReputationFaction); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", - rankName, - fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); - } - // Class restriction - if (info->allowableClass != 0) { - const auto& kClasses = ui::kClassMasks; - int matchCount = 0; - for (const auto& kc : kClasses) - if (info->allowableClass & kc.mask) ++matchCount; - if (matchCount > 0 && matchCount < 10) { - char classBuf[128] = "Classes: "; - bool first = true; - for (const auto& kc : kClasses) { - if (!(info->allowableClass & kc.mask)) continue; - if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); - strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); - first = false; - } - uint8_t pc = gameHandler.getPlayerClass(); - uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); - ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(clColor, "%s", classBuf); - } - } - // Race restriction - if (info->allowableRace != 0) { - const auto& kRaces = ui::kRaceMasks; - constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; - if ((info->allowableRace & kAllPlayable) != kAllPlayable) { - int matchCount = 0; - for (const auto& kr : kRaces) - if (info->allowableRace & kr.mask) ++matchCount; - if (matchCount > 0) { - char raceBuf[160] = "Races: "; - bool first = true; - for (const auto& kr : kRaces) { - if (!(info->allowableRace & kr.mask)) continue; - if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); - strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); - first = false; - } - uint8_t pr = gameHandler.getPlayerRace(); - uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; - bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); - ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; - ImGui::TextColored(rColor, "%s", raceBuf); - } - } - } - // Flavor text - if (!info->description.empty()) { - ImGui::Spacing(); - ImGui::PushTextWrapPos(300.0f); - ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); - ImGui::PopTextWrapPos(); - } - if (info->sellPrice > 0) { - ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); - renderCoinsFromCopper(info->sellPrice); - } - - if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { - if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { - ImGui::Separator(); - ImGui::TextDisabled("Equipped:"); - VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); - if (eqIcon) { - ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); - ImGui::SameLine(); - } - ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - char eqDmg[64], eqSpd[32]; - std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", - static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); - std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); - float eqSpdW = ImGui::CalcTextSize(eqSpd).x; - ImGui::Text("%s", eqDmg); - ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); - ImGui::Text("%s", eqSpd); - ImGui::TextDisabled("(%.1f damage per second)", dps); - } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); - } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); - } - // Extra stats for the equipped item - for (const auto& es : eq->item.extraStats) { - const char* nm = nullptr; - switch (es.statType) { - case 12: nm = "Defense Rating"; break; - case 13: nm = "Dodge Rating"; break; - case 14: nm = "Parry Rating"; break; - case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; - case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; - case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; - case 34: nm = "Resilience Rating"; break; - case 36: nm = "Expertise Rating"; break; - case 37: nm = "Attack Power"; break; - case 38: nm = "Ranged Attack Power"; break; - case 45: nm = "Spell Power"; break; - case 46: nm = "Healing Power"; break; - case 49: nm = "Mana per 5 sec."; break; - default: break; - } - if (nm && es.statValue > 0) - ImGui::TextColored(green, "+%d %s", es.statValue, nm); - } - } - } - ImGui::EndTooltip(); -} - - // ---- Main segment renderer ---- void ChatMarkupRenderer::render( @@ -570,7 +58,7 @@ void ChatMarkupRenderer::render( ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + ItemTooltipRenderer::render(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); } ImGui::SameLine(0, 2); } @@ -583,7 +71,7 @@ void ChatMarkupRenderer::render( if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (itemEntry > 0 && ctx.gameHandler && ctx.inventory) { - renderItemTooltip(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); + ItemTooltipRenderer::render(itemEntry, *ctx.gameHandler, *ctx.inventory, ctx.assetMgr); } } // Shift-click: insert entire link back into chat input diff --git a/src/ui/chat_panel_utils.cpp b/src/ui/chat/chat_utils.cpp similarity index 54% rename from src/ui/chat_panel_utils.cpp rename to src/ui/chat/chat_utils.cpp index bef47243..5681d8ff 100644 --- a/src/ui/chat_panel_utils.cpp +++ b/src/ui/chat/chat_utils.cpp @@ -1,40 +1,15 @@ -#include "ui/chat_panel.hpp" -#include "ui/ui_colors.hpp" -#include "rendering/vk_context.hpp" -#include "core/application.hpp" -#include "rendering/renderer.hpp" -#include "rendering/camera.hpp" -#include "rendering/camera_controller.hpp" -#include "audio/audio_coordinator.hpp" -#include "audio/ui_sound_manager.hpp" -#include "pipeline/asset_manager.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/dbc_layout.hpp" -#include "game/expansion_profile.hpp" +// chat_utils.cpp — Shared chat utility functions. +// Extracted from chat_panel_utils.cpp (Phase 6.6 of chat_panel_ref.md). + +#include "ui/chat/chat_utils.hpp" +#include "game/game_handler.hpp" #include "game/character.hpp" -#include "core/logger.hpp" -#include -#include -#include "core/coordinates.hpp" -#include -#include -#include -#include -#include +#include -namespace { - using namespace wowee::ui::colors; - constexpr auto& kColorRed = kRed; - constexpr auto& kColorBrightGreen= kBrightGreen; - constexpr auto& kColorYellow = kYellow; -} // namespace +namespace wowee { namespace ui { namespace chat_utils { -namespace wowee { namespace ui { - -// getChatTypeName / getChatTypeColor moved to ChatTabManager (Phase 1.3) - - -std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { +std::string replaceGenderPlaceholders(const std::string& text, + game::GameHandler& gameHandler) { // Get player gender, pronouns, and name game::Gender gender = game::Gender::NONBINARY; std::string playerName = "Adventurer"; @@ -50,7 +25,7 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: std::string result = text; // Helper to trim whitespace - auto trim = [](std::string& s) { + auto trimStr = [](std::string& s) { const char* ws = " \t\n\r"; size_t start = s.find_first_not_of(ws); if (start == std::string::npos) { s.clear(); return; } @@ -76,46 +51,32 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: size_t colonPos; while ((colonPos = placeholder.find(':', start)) != std::string::npos) { std::string part = placeholder.substr(start, colonPos - start); - trim(part); + trimStr(part); parts.push_back(part); start = colonPos + 1; } // Add the last part std::string lastPart = placeholder.substr(start); - trim(lastPart); + trimStr(lastPart); parts.push_back(lastPart); // Select appropriate text based on gender std::string replacement; if (parts.size() >= 3) { - // Three options: male, female, nonbinary switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - replacement = parts[2]; - break; + case game::Gender::MALE: replacement = parts[0]; break; + case game::Gender::FEMALE: replacement = parts[1]; break; + case game::Gender::NONBINARY: replacement = parts[2]; break; } } else if (parts.size() >= 2) { - // Two options: male, female (use first for nonbinary) switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; + case game::Gender::MALE: replacement = parts[0]; break; + case game::Gender::FEMALE: replacement = parts[1]; break; case game::Gender::NONBINARY: - // Default to gender-neutral: use the shorter/simpler option replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; break; } } else { - // Malformed placeholder pos = endPos + 1; continue; } @@ -177,53 +138,20 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game:: return result; } -// renderBubbles delegates to ChatBubbleManager (Phase 1.4) -void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { - bubbleManager_.render(gameHandler, services_); -} - - -// ---- Public interface methods ---- - -// setupCallbacks delegates to ChatBubbleManager (Phase 1.4) -void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { - bubbleManager_.setupCallback(gameHandler); -} - -void ChatPanel::insertChatLink(const std::string& link) { - if (link.empty()) return; - size_t curLen = strlen(chatInputBuffer_); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { - strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); - chatInputMoveCursorToEnd_ = true; - refocusChatInput_ = true; +std::string getEntityDisplayName(const std::shared_ptr& entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); } + return "Unknown"; } -void ChatPanel::activateSlashInput() { - refocusChatInput_ = true; - chatInputBuffer_[0] = '/'; - chatInputBuffer_[1] = '\0'; - chatInputMoveCursorToEnd_ = true; -} - -void ChatPanel::activateInput() { - if (chatInputCooldown_ > 0) return; // suppress re-activation right after send - refocusChatInput_ = true; -} - -void ChatPanel::setWhisperTarget(const std::string& name) { - selectedChatType_ = 4; // WHISPER - strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - refocusChatInput_ = true; -} - -ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { - SlashCommands result = slashCmds_; - slashCmds_ = {}; - return result; -} - +} // namespace chat_utils } // namespace ui } // namespace wowee diff --git a/src/ui/chat/commands/combat_commands.cpp b/src/ui/chat/commands/combat_commands.cpp index 01060e7b..c824c458 100644 --- a/src/ui/chat/commands/combat_commands.cpp +++ b/src/ui/chat/commands/combat_commands.cpp @@ -2,6 +2,7 @@ // /startattack, /stopattack, /stopcasting, /cancelqueuedspell // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "game/game_handler.hpp" #include "game/inventory.hpp" @@ -12,11 +13,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - // --------------- helpers (local to this TU) --------------- namespace { diff --git a/src/ui/chat/commands/system_commands.cpp b/src/ui/chat/commands/system_commands.cpp index 1051c20c..cf9f73bd 100644 --- a/src/ui/chat/commands/system_commands.cpp +++ b/src/ui/chat/commands/system_commands.cpp @@ -1,6 +1,7 @@ // System commands: /run, /dump, /reload, /stopmacro, /clear, /logout // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "ui/ui_services.hpp" #include "game/game_handler.hpp" @@ -11,11 +12,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - // --- /run, /script --- class RunCommand : public IChatCommand { public: diff --git a/src/ui/chat/commands/target_commands.cpp b/src/ui/chat/commands/target_commands.cpp index 23cdb88f..bc1cec1a 100644 --- a/src/ui/chat/commands/target_commands.cpp +++ b/src/ui/chat/commands/target_commands.cpp @@ -3,6 +3,7 @@ // /focus, /clearfocus // Moved from ChatPanel::sendChatMessage() if/else chain (Phase 3). #include "ui/chat/i_chat_command.hpp" +#include "ui/chat/macro_evaluator.hpp" #include "ui/chat_panel.hpp" #include "game/game_handler.hpp" #include "game/entity.hpp" @@ -13,11 +14,6 @@ namespace wowee { namespace ui { -// Forward declaration of evaluateMacroConditionals (still in chat_panel_commands.cpp) -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - namespace { // Trim leading/trailing whitespace. diff --git a/src/ui/chat/item_tooltip_renderer.cpp b/src/ui/chat/item_tooltip_renderer.cpp new file mode 100644 index 00000000..360ee43b --- /dev/null +++ b/src/ui/chat/item_tooltip_renderer.cpp @@ -0,0 +1,521 @@ +// ItemTooltipRenderer — renders full WoW-style item tooltips via ImGui. +// Extracted from ChatMarkupRenderer::renderItemTooltip (Phase 6.7). +#include "ui/chat/item_tooltip_renderer.hpp" +#include "ui/ui_colors.hpp" +#include "ui/inventory_screen.hpp" +#include "game/game_handler.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include +#include +#include +#include + +namespace wowee { namespace ui { + +void ItemTooltipRenderer::render( + uint32_t itemEntry, + game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + pipeline::AssetManager* assetMgr) +{ + const auto* info = gameHandler.getItemInfo(itemEntry); + if (!info || !info->valid) return; + + auto findComparableEquipped = [&](uint8_t inventoryType) -> const game::ItemSlot* { + using ES = game::EquipSlot; + const auto& inv = gameHandler.getInventory(); + auto slotPtr = [&](ES slot) -> const game::ItemSlot* { + const auto& s = inv.getEquipSlot(slot); + return s.empty() ? nullptr : &s; + }; + switch (inventoryType) { + case 1: return slotPtr(ES::HEAD); + case 2: return slotPtr(ES::NECK); + case 3: return slotPtr(ES::SHOULDERS); + case 4: return slotPtr(ES::SHIRT); + case 5: + case 20: return slotPtr(ES::CHEST); + case 6: return slotPtr(ES::WAIST); + case 7: return slotPtr(ES::LEGS); + case 8: return slotPtr(ES::FEET); + case 9: return slotPtr(ES::WRISTS); + case 10: return slotPtr(ES::HANDS); + case 11: { + if (auto* s = slotPtr(ES::RING1)) return s; + return slotPtr(ES::RING2); + } + case 12: { + if (auto* s = slotPtr(ES::TRINKET1)) return s; + return slotPtr(ES::TRINKET2); + } + case 13: + if (auto* s = slotPtr(ES::MAIN_HAND)) return s; + return slotPtr(ES::OFF_HAND); + case 14: + case 22: + case 23: return slotPtr(ES::OFF_HAND); + case 15: + case 25: + case 26: return slotPtr(ES::RANGED); + case 16: return slotPtr(ES::BACK); + case 17: + case 21: return slotPtr(ES::MAIN_HAND); + case 18: + for (int i = 0; i < game::Inventory::NUM_BAG_SLOTS; ++i) { + auto slot = static_cast(static_cast(ES::BAG1) + i); + if (auto* s = slotPtr(slot)) return s; + } + return nullptr; + case 19: return slotPtr(ES::TABARD); + default: return nullptr; + } + }; + + auto isWeaponInventoryType = [](uint32_t invType) { + switch (invType) { + case 13: case 15: case 17: case 21: case 25: case 26: return true; + default: return false; + } + }; + + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " "; + out += shortName; + }; + + ImGui::BeginTooltip(); + // Quality color for name + auto qColor = ui::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ui::colors::kTooltipGold, "Unique-Equipped"); + + // Slot type + if (info->inventoryType > 0) { + const char* slotName = ui::getInventorySlotName(info->inventoryType); + if (slotName[0]) { + if (!info->subclassName.empty()) + ImGui::TextColored(ui::colors::kLightGray, "%s %s", slotName, info->subclassName.c_str()); + else + ImGui::TextColored(ui::colors::kLightGray, "%s", slotName); + } + } + + const bool isWeapon = isWeaponInventoryType(info->inventoryType); + + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { + float speed = static_cast(info->delayMs) / 1000.0f; + float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + std::string bonusLine; + appendBonus(bonusLine, info->strength, "Str"); + appendBonus(bonusLine, info->agility, "Agi"); + appendBonus(bonusLine, info->stamina, "Sta"); + appendBonus(bonusLine, info->intellect, "Int"); + appendBonus(bonusLine, info->spirit, "Spi"); + if (!bonusLine.empty()) { + ImGui::TextColored(green, "%s", bonusLine.c_str()); + } + if (info->armor > 0) { + ImGui::Text("%d Armor", info->armor); + } + // Elemental resistances + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static constexpr const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets + { + const auto& kSocketTypes = ui::kSocketTypes; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(colors::kSocketGreen, "Socket Bonus: (id %u)", info->socketBonus); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + const auto& itemKeys = ui::kItemSetItemKeys; + const auto& spellKeys = ui::kItemSetSpellKeys; + const auto& thrKeys = ui::kItemSetThresholdKeys; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ui::colors::kTooltipGold, + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ui::colors::kTooltipGold, "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? colors::kActiveGreen : colors::kInactiveGray; + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ui::colors::kTooltipGold, "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(colors::kCyan, + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static constexpr const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction + if (info->allowableClass != 0) { + const auto& kClasses = ui::kClassMasks; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction + if (info->allowableRace != 0) { + const auto& kRaces = ui::kRaceMasks; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : colors::kPaleRed; + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor text + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } + if (info->sellPrice > 0) { + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsFromCopper(info->sellPrice); + } + + if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { + if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); + if (eqIcon) { + ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); + ImGui::SameLine(); + } + ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + if (isWeaponInventoryType(eq->item.inventoryType) && + eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { + float speed = static_cast(eq->item.delayMs) / 1000.0f; + float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); + } + if (eq->item.armor > 0) { + ImGui::Text("%d Armor", eq->item.armor); + } + std::string eqBonusLine; + appendBonus(eqBonusLine, eq->item.strength, "Str"); + appendBonus(eqBonusLine, eq->item.agility, "Agi"); + appendBonus(eqBonusLine, eq->item.stamina, "Sta"); + appendBonus(eqBonusLine, eq->item.intellect, "Int"); + appendBonus(eqBonusLine, eq->item.spirit, "Spi"); + if (!eqBonusLine.empty()) { + ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + } + ImGui::EndTooltip(); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat/macro_eval_convenience.cpp b/src/ui/chat/macro_eval_convenience.cpp new file mode 100644 index 00000000..b38cfa26 --- /dev/null +++ b/src/ui/chat/macro_eval_convenience.cpp @@ -0,0 +1,24 @@ +// evaluateMacroConditionals — convenience free function. +// Thin wrapper over MacroEvaluator with concrete adapters. +// Separate TU to avoid pulling Application/Renderer into macro_evaluator unit tests. +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/game_state_adapter.hpp" +#include "ui/chat/input_modifier_adapter.hpp" +#include "game/game_handler.hpp" +#include "core/application.hpp" +#include "rendering/renderer.hpp" + +namespace wowee { namespace ui { + +std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + auto* renderer = core::Application::getInstance().getRenderer(); + GameStateAdapter gs(gameHandler, renderer); + InputModifierAdapter im; + MacroEvaluator eval(gs, im); + return eval.evaluate(rawArg, targetOverride); +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp index 632dac66..77db55b3 100644 --- a/src/ui/chat_panel.cpp +++ b/src/ui/chat_panel.cpp @@ -1,9 +1,13 @@ #include "ui/chat_panel.hpp" +#include "ui/chat/chat_utils.hpp" +#include "ui/chat/macro_evaluator.hpp" +#include "ui/chat/game_state_adapter.hpp" +#include "ui/chat/input_modifier_adapter.hpp" +#include "ui/chat/gm_command_data.hpp" #include "ui/inventory_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/quest_log_screen.hpp" #include "ui/ui_colors.hpp" -#include "ui/chat/gm_command_data.hpp" #include "rendering/vk_context.hpp" #include "core/application.hpp" #include "addons/addon_manager.hpp" @@ -27,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +52,61 @@ namespace { // Common ImGui window flags for popup dialogs const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + // --------------------------------------------------------------------------- + // formatChatMessage — build the display string for a single chat message. + // Extracted from ChatPanel::render() message loop (Phase 6.2). + // --------------------------------------------------------------------------- + std::string formatChatMessage( + const wowee::game::MessageChatData& msg, + const std::string& processedMessage, + const std::string& resolvedSenderName, + const std::string& tsPrefix, + wowee::game::GameHandler& gameHandler) + { + using CT = wowee::game::ChatType; + + // Build chat tag prefix: , , + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + + if (msg.type == CT::SYSTEM || msg.type == CT::TEXT_EMOTE) + return tsPrefix + processedMessage; + + if (!resolvedSenderName.empty()) { + if (msg.type == CT::SAY || msg.type == CT::MONSTER_SAY || msg.type == CT::MONSTER_PARTY) + return tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; + if (msg.type == CT::YELL || msg.type == CT::MONSTER_YELL) + return tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; + if (msg.type == CT::WHISPER || msg.type == CT::MONSTER_WHISPER || msg.type == CT::RAID_BOSS_WHISPER) + return tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; + if (msg.type == CT::WHISPER_INFORM) { + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; + return tsPrefix + "To " + target + ": " + processedMessage; + } + if (msg.type == CT::EMOTE || msg.type == CT::MONSTER_EMOTE || msg.type == CT::RAID_BOSS_EMOTE) + return tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; + if (msg.type == CT::CHANNEL && !msg.channelName.empty()) { + int chIdx = gameHandler.getChannelIndex(msg.channelName); + std::string chDisplay = chIdx > 0 + ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" + : "[" + msg.channelName + "]"; + return tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; + } + return tsPrefix + "[" + std::string(wowee::ui::ChatTabManager::getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; + } + + bool isGroupType = + msg.type == CT::PARTY || msg.type == CT::GUILD || + msg.type == CT::OFFICER || msg.type == CT::RAID || + msg.type == CT::RAID_LEADER || msg.type == CT::RAID_WARNING || + msg.type == CT::BATTLEGROUND || msg.type == CT::BATTLEGROUND_LEADER; + if (isGroupType) + return tsPrefix + "[" + std::string(wowee::ui::ChatTabManager::getChatTypeName(msg.type)) + "] " + processedMessage; + return tsPrefix + processedMessage; + } } namespace wowee { namespace ui { @@ -188,7 +248,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!tabManager_.shouldShowMessage(msg, activeChatTab)) continue; - std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + std::string processedMessage = chat_utils::replaceGenderPlaceholders(msg.message, gameHandler); // Resolve sender name at render time in case it wasn't available at parse time. // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. @@ -217,56 +277,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, tsPrefix = tsBuf; } - // Build chat tag prefix: , , from chatTag bitmask - std::string tagPrefix; - if (msg.chatTag & 0x04) tagPrefix = " "; - else if (msg.chatTag & 0x01) tagPrefix = " "; - else if (msg.chatTag & 0x02) tagPrefix = " "; - - // Build full message string for this entry - std::string fullMsg; - if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { - fullMsg = tsPrefix + processedMessage; - } else if (!resolvedSenderName.empty()) { - if (msg.type == game::ChatType::SAY || - msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; - } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER || - msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; - } else if (msg.type == game::ChatType::WHISPER_INFORM) { - const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; - fullMsg = tsPrefix + "To " + target + ": " + processedMessage; - } else if (msg.type == game::ChatType::EMOTE || - msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; - } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { - int chIdx = gameHandler.getChannelIndex(msg.channelName); - std::string chDisplay = chIdx > 0 - ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" - : "[" + msg.channelName + "]"; - fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; - } else { - fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; - } - } else { - bool isGroupType = - msg.type == game::ChatType::PARTY || - msg.type == game::ChatType::GUILD || - msg.type == game::ChatType::OFFICER || - msg.type == game::ChatType::RAID || - msg.type == game::ChatType::RAID_LEADER || - msg.type == game::ChatType::RAID_WARNING || - msg.type == game::ChatType::BATTLEGROUND || - msg.type == game::ChatType::BATTLEGROUND_LEADER; - if (isGroupType) { - fullMsg = tsPrefix + "[" + std::string(ChatTabManager::getChatTypeName(msg.type)) + "] " + processedMessage; - } else { - fullMsg = tsPrefix + processedMessage; - } - } + std::string fullMsg = formatChatMessage(msg, processedMessage, resolvedSenderName, tsPrefix, gameHandler); // Detect mention: does this message contain the local player's name? bool isMention = false; @@ -435,70 +446,7 @@ void ChatPanel::render(game::GameHandler& gameHandler, } // Detect chat channel prefix as user types and switch the dropdown - { - std::string buf(chatInputBuffer_); - if (buf.size() >= 2 && buf[0] == '/') { - // Find the command and check if there's a space after it - size_t sp = buf.find(' ', 1); - if (sp != std::string::npos) { - std::string cmd = buf.substr(1, sp - 1); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - int detected = -1; - bool isReply = false; - if (cmd == "s" || cmd == "say") detected = 0; - else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; - else if (cmd == "p" || cmd == "party") detected = 2; - else if (cmd == "g" || cmd == "guild") detected = 3; - else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; - else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } - else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; - else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; - else if (cmd == "bg" || cmd == "battleground") detected = 7; - else if (cmd == "rw" || cmd == "raidwarning") detected = 8; - else if (cmd == "i" || cmd == "instance") detected = 9; - else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. - if (detected >= 0 && (selectedChatType_ != detected || detected == 10 || isReply)) { - // For channel shortcuts, also update selectedChannelIdx_ - if (detected == 10) { - int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. - const auto& chans = gameHandler.getJoinedChannels(); - if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { - selectedChannelIdx_ = chanIdx; - } - } - selectedChatType_ = detected; - // Strip the prefix, keep only the message part - std::string remaining = buf.substr(sp + 1); - // /r reply: pre-fill whisper target from last whisper sender - if (detected == 4 && isReply) { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (!lastSender.empty()) { - strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - } - // remaining is the message — don't extract a target from it - } else if (detected == 4) { - // For whisper, first word after /w is the target - size_t msgStart = remaining.find(' '); - if (msgStart != std::string::npos) { - std::string wTarget = remaining.substr(0, msgStart); - strncpy(whisperTargetBuffer_, wTarget.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - remaining = remaining.substr(msgStart + 1); - } else { - // Just the target name so far, no message yet - strncpy(whisperTargetBuffer_, remaining.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - remaining = ""; - } - } - strncpy(chatInputBuffer_, remaining.c_str(), sizeof(chatInputBuffer_) - 1); - chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - chatInputMoveCursorToEnd_ = true; - } - } - } - } + detectChannelPrefix(gameHandler); // Color the input text based on current chat type ImVec4 inputColor; @@ -517,220 +465,11 @@ void ChatPanel::render(game::GameHandler& gameHandler, } ImGui::PushStyleColor(ImGuiCol_Text, inputColor); - auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { - auto* self = static_cast(data->UserData); - if (!self) return 0; - - // Cursor-to-end after channel switch - if (self->chatInputMoveCursorToEnd_) { - int len = static_cast(std::strlen(data->Buf)); - data->CursorPos = len; - data->SelectionStart = len; - data->SelectionEnd = len; - self->chatInputMoveCursorToEnd_ = false; - } - - // Tab: slash-command autocomplete (Phase 5 — uses ChatTabCompleter + ChatCommandRegistry) - if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { - if (data->BufTextLen > 0 && data->Buf[0] == '/') { - // Split buffer into command word and trailing args - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; - std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - - // Normalize to lowercase for matching (strip leading /) - std::string lowerCmd = word.substr(1); - for (auto& ch : lowerCmd) ch = static_cast(std::tolower(static_cast(ch))); - - // New session if prefix changed - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerCmd) { - auto candidates = self->commandRegistry_.getCompletions(lowerCmd); - // Prepend / to each candidate for display - for (auto& c : candidates) c = "/" + c; - self->tabCompleter_.startCompletion(lowerCmd, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - // Append trailing space when match is unambiguous - if (self->tabCompleter_.matchCount() == 1 && rest.empty()) - match += ' '; - std::string newBuf = match + rest; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } else if (data->BufTextLen > 1 && data->Buf[0] == '.') { - // GM dot-command tab-completion (uses gm_command_data.hpp table) - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; - std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; - - // Lowercase the typed prefix (keep the dot) - std::string lowerDot = word; - for (auto& ch : lowerDot) ch = static_cast(std::tolower(static_cast(ch))); - - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerDot) { - // Gather candidates from the GM command table - std::vector candidates; - for (const auto& entry : kGmCommands) { - std::string dotName = "." + std::string(entry.name); - if (dotName.size() >= lowerDot.size() && - dotName.compare(0, lowerDot.size(), lowerDot) == 0) { - candidates.push_back(dotName); - } - } - std::sort(candidates.begin(), candidates.end()); - self->tabCompleter_.startCompletion(lowerDot, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - if (self->tabCompleter_.matchCount() == 1 && rest.empty()) - match += ' '; - std::string newBuf = match + rest; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } else if (data->BufTextLen > 0) { - // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel - std::string fullBuf(data->Buf, data->BufTextLen); - size_t spacePos = fullBuf.find(' '); - bool isNameCommand = false; - std::string namePrefix; - size_t replaceStart = 0; - - if (fullBuf[0] == '/' && spacePos != std::string::npos) { - std::string cmd = fullBuf.substr(0, spacePos); - for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); - if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || - cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || - cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || - cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || - cmd == "/t" || cmd == "/target" || cmd == "/kick" || - cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { - namePrefix = fullBuf.substr(spacePos + 1); - size_t nameSpace = namePrefix.find(' '); - if (nameSpace == std::string::npos) { - isNameCommand = true; - replaceStart = spacePos + 1; - } - } - } - - if (isNameCommand && !namePrefix.empty()) { - std::string lowerPrefix = namePrefix; - for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); - - if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerPrefix) { - std::vector candidates; - auto* gh = self->cachedGameHandler_; - // Party/raid members - for (const auto& m : gh->getPartyData().members) { - if (m.name.empty()) continue; - std::string lname = m.name; - for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) - candidates.push_back(m.name); - } - // Friends - for (const auto& c : gh->getContacts()) { - if (!c.isFriend() || c.name.empty()) continue; - std::string lname = c.name; - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == c.name) { dup = true; break; } - if (!dup) candidates.push_back(c.name); - } - } - // Nearby visible players - for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; - auto player = std::static_pointer_cast(entity); - if (player->getName().empty()) continue; - std::string lname = player->getName(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == player->getName()) { dup = true; break; } - if (!dup) candidates.push_back(player->getName()); - } - } - // Last whisper sender (insert at front for priority) - if (!gh->getLastWhisperSender().empty()) { - std::string lname = gh->getLastWhisperSender(); - for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); - if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { - bool dup = false; - for (const auto& em : candidates) - if (em == gh->getLastWhisperSender()) { dup = true; break; } - if (!dup) candidates.insert(candidates.begin(), gh->getLastWhisperSender()); - } - } - self->tabCompleter_.startCompletion(lowerPrefix, std::move(candidates)); - } else { - self->tabCompleter_.next(); - } - - std::string match = self->tabCompleter_.getCurrentMatch(); - if (!match.empty()) { - std::string prefix = fullBuf.substr(0, replaceStart); - std::string newBuf = prefix + match; - if (self->tabCompleter_.matchCount() == 1) newBuf += ' '; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, newBuf.c_str()); - } - } - } - return 0; - } - - // Up/Down arrow: cycle through sent message history - if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { - // Any history navigation resets autocomplete - self->tabCompleter_.reset(); - - const int histSize = static_cast(self->chatSentHistory_.size()); - if (histSize == 0) return 0; - - if (data->EventKey == ImGuiKey_UpArrow) { - // Go back in history - if (self->chatHistoryIdx_ == -1) - self->chatHistoryIdx_ = histSize - 1; - else if (self->chatHistoryIdx_ > 0) - --self->chatHistoryIdx_; - } else if (data->EventKey == ImGuiKey_DownArrow) { - if (self->chatHistoryIdx_ == -1) return 0; - ++self->chatHistoryIdx_; - if (self->chatHistoryIdx_ >= histSize) { - self->chatHistoryIdx_ = -1; - data->DeleteChars(0, data->BufTextLen); - return 0; - } - } - - if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { - const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; - data->DeleteChars(0, data->BufTextLen); - data->InsertChars(0, entry.c_str()); - } - } - return 0; - }; - ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion; - if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, inputCallback, this)) { + if (ImGui::InputText("##ChatInput", chatInputBuffer_, sizeof(chatInputBuffer_), inputFlags, &ChatPanel::inputTextCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. refocusChatInput_ = false; @@ -757,6 +496,272 @@ void ChatPanel::render(game::GameHandler& gameHandler, ImGui::End(); } +// --------------------------------------------------------------------------- +// detectChannelPrefix — auto-detect /say, /party, /whisper etc. prefixes +// and switch the chat type dropdown + strip the prefix from the input buffer. +// Extracted from render() (Phase 6.2). +// --------------------------------------------------------------------------- +void ChatPanel::detectChannelPrefix(game::GameHandler& gameHandler) { + std::string buf(chatInputBuffer_); + if (buf.size() < 2 || buf[0] != '/') return; + + size_t sp = buf.find(' ', 1); + if (sp == std::string::npos) return; + + std::string cmd = buf.substr(1, sp - 1); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + + int detected = -1; + bool isReply = false; + if (cmd == "s" || cmd == "say") detected = 0; + else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; + else if (cmd == "p" || cmd == "party") detected = 2; + else if (cmd == "g" || cmd == "guild") detected = 3; + else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; + else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } + else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; + else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; + else if (cmd == "bg" || cmd == "battleground") detected = 7; + else if (cmd == "rw" || cmd == "raidwarning") detected = 8; + else if (cmd == "i" || cmd == "instance") detected = 9; + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; + + if (detected < 0 || (selectedChatType_ == detected && detected != 10 && !isReply)) return; + + if (detected == 10) { + int chanIdx = cmd[0] - '1'; + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) + selectedChannelIdx_ = chanIdx; + } + selectedChatType_ = detected; + std::string remaining = buf.substr(sp + 1); + + if (detected == 4 && isReply) { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (!lastSender.empty()) { + strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } + } else if (detected == 4) { + size_t msgStart = remaining.find(' '); + if (msgStart != std::string::npos) { + std::string wTarget = remaining.substr(0, msgStart); + strncpy(whisperTargetBuffer_, wTarget.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = remaining.substr(msgStart + 1); + } else { + strncpy(whisperTargetBuffer_, remaining.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + remaining = ""; + } + } + strncpy(chatInputBuffer_, remaining.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +// --------------------------------------------------------------------------- +// inputTextCallback — static ImGui input text callback for tab-completion, +// cursor management, and sent-message history (Up/Down arrows). +// Extracted from render() inline lambda (Phase 6.2). +// --------------------------------------------------------------------------- +int ChatPanel::inputTextCallback(ImGuiInputTextCallbackData* data) { + auto* self = static_cast(data->UserData); + if (!self) return 0; + + // Cursor-to-end after channel switch + if (self->chatInputMoveCursorToEnd_) { + int len = static_cast(std::strlen(data->Buf)); + data->CursorPos = len; + data->SelectionStart = len; + data->SelectionEnd = len; + self->chatInputMoveCursorToEnd_ = false; + } + + // Tab: slash-command autocomplete (Phase 5) + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + std::string lowerCmd = word.substr(1); + for (auto& ch : lowerCmd) ch = static_cast(std::tolower(static_cast(ch))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerCmd) { + auto candidates = self->commandRegistry_.getCompletions(lowerCmd); + for (auto& c : candidates) c = "/" + c; + self->tabCompleter_.startCompletion(lowerCmd, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 1 && data->Buf[0] == '.') { + // GM dot-command tab-completion + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + std::string lowerDot = word; + for (auto& ch : lowerDot) ch = static_cast(std::tolower(static_cast(ch))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerDot) { + std::vector candidates; + for (const auto& entry : kGmCommands) { + std::string dotName = "." + std::string(entry.name); + if (dotName.size() >= lowerDot.size() && + dotName.compare(0, lowerDot.size(), lowerDot) == 0) { + candidates.push_back(dotName); + } + } + std::sort(candidates.begin(), candidates.end()); + self->tabCompleter_.startCompletion(lowerDot, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + if (self->tabCompleter_.matchCount() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 0) { + // Player name tab-completion + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + bool isNameCommand = false; + std::string namePrefix; + size_t replaceStart = 0; + + if (fullBuf[0] == '/' && spacePos != std::string::npos) { + std::string cmd = fullBuf.substr(0, spacePos); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || + cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || + cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || + cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || + cmd == "/t" || cmd == "/target" || cmd == "/kick" || + cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { + namePrefix = fullBuf.substr(spacePos + 1); + size_t nameSpace = namePrefix.find(' '); + if (nameSpace == std::string::npos) { + isNameCommand = true; + replaceStart = spacePos + 1; + } + } + } + + if (isNameCommand && !namePrefix.empty()) { + std::string lowerPrefix = namePrefix; + for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); + + if (!self->tabCompleter_.isActive() || self->tabCompleter_.getPrefix() != lowerPrefix) { + std::vector candidates; + auto* gh = self->cachedGameHandler_; + for (const auto& m : gh->getPartyData().members) { + if (m.name.empty()) continue; + std::string lname = m.name; + for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) + candidates.push_back(m.name); + } + for (const auto& c : gh->getContacts()) { + if (!c.isFriend() || c.name.empty()) continue; + std::string lname = c.name; + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == c.name) { dup = true; break; } + if (!dup) candidates.push_back(c.name); + } + } + for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) continue; + std::string lname = player->getName(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == player->getName()) { dup = true; break; } + if (!dup) candidates.push_back(player->getName()); + } + } + if (!gh->getLastWhisperSender().empty()) { + std::string lname = gh->getLastWhisperSender(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : candidates) + if (em == gh->getLastWhisperSender()) { dup = true; break; } + if (!dup) candidates.insert(candidates.begin(), gh->getLastWhisperSender()); + } + } + self->tabCompleter_.startCompletion(lowerPrefix, std::move(candidates)); + } else { + self->tabCompleter_.next(); + } + + std::string match = self->tabCompleter_.getCurrentMatch(); + if (!match.empty()) { + std::string prefix = fullBuf.substr(0, replaceStart); + std::string newBuf = prefix + match; + if (self->tabCompleter_.matchCount() == 1) newBuf += ' '; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + } + return 0; + } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + self->tabCompleter_.reset(); + + const int histSize = static_cast(self->chatSentHistory_.size()); + if (histSize == 0) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + if (self->chatHistoryIdx_ == -1) + self->chatHistoryIdx_ = histSize - 1; + else if (self->chatHistoryIdx_ > 0) + --self->chatHistoryIdx_; + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (self->chatHistoryIdx_ == -1) return 0; + ++self->chatHistoryIdx_; + if (self->chatHistoryIdx_ >= histSize) { + self->chatHistoryIdx_ = -1; + data->DeleteChars(0, data->BufTextLen); + return 0; + } + } + + if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { + const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, entry.c_str()); + } + } + return 0; +} + // --- Command registration (calls into each command group file) --- // Forward declarations of registration functions from command files void registerSystemCommands(ChatCommandRegistry& reg); @@ -785,8 +790,329 @@ void ChatPanel::registerAllCommands() { registerGmCommands(commandRegistry_); } +// renderBubbles delegates to ChatBubbleManager (Phase 1.4) +void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { + bubbleManager_.render(gameHandler, services_); +} + +// setupCallbacks delegates to ChatBubbleManager (Phase 1.4) +void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { + bubbleManager_.setupCallback(gameHandler); +} + +void ChatPanel::insertChatLink(const std::string& link) { + if (link.empty()) return; + size_t curLen = strlen(chatInputBuffer_); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { + strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); + chatInputMoveCursorToEnd_ = true; + refocusChatInput_ = true; + } +} + +void ChatPanel::activateSlashInput() { + refocusChatInput_ = true; + chatInputBuffer_[0] = '/'; + chatInputBuffer_[1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +void ChatPanel::activateInput() { + if (chatInputCooldown_ > 0) return; + refocusChatInput_ = true; +} + +void ChatPanel::setWhisperTarget(const std::string& name) { + selectedChatType_ = 4; // WHISPER + strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + refocusChatInput_ = true; +} + +ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { + SlashCommands result = slashCmds_; + slashCmds_ = {}; + return result; +} + +namespace { + bool isPortBotTarget(const std::string& target) { + std::string t = chat_utils::toLower(chat_utils::trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; + } + + std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = chat_utils::trim(rawInput); + if (input.empty()) return ""; + + std::string lower = chat_utils::toLower(input); + if (lower == "help" || lower == "?") return "__help__"; + + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) return input; + if (lower.rfind("xyz ", 0) == 0) return ".go " + input; + + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + + return ".tele " + input; + } +} // anonymous namespace // Collect all non-comment, non-empty lines from a macro body. +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// Execute all non-comment lines of a macro body in sequence. +void ChatPanel::executeMacroText(game::GameHandler& gameHandler, + const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + sendChatMessage(gameHandler); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +void ChatPanel::sendChatMessage(game::GameHandler& gameHandler) { + if (strlen(chatInputBuffer_) == 0) return; + std::string input(chatInputBuffer_); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; + + game::ChatType type = game::ChatType::SAY; + std::string message = input; + std::string target; + + // GM dot-prefix commands (.gm, .tele, .additem, etc.) + if (input.size() > 1 && input[0] == '.') { + LOG_INFO("GM command: '", input, "' — sending as SAY to server"); + gameHandler.sendChatMessage(game::ChatType::SAY, input, ""); + + std::string dotCmd = input; + size_t sp = dotCmd.find(' '); + std::string cmdPart = (sp != std::string::npos) + ? dotCmd.substr(1, sp - 1) : dotCmd.substr(1); + for (char& c : cmdPart) c = static_cast(std::tolower(static_cast(c))); + + std::string feedback; + for (const auto& entry : kGmCommands) { + if (entry.name == cmdPart) { + feedback = "Sent: " + input + " (" + std::string(entry.help) + ")"; + break; + } + } + if (feedback.empty()) + feedback = "Sent: " + input + + " (requires GM access — server console: account set gmlevel 3 -1)"; + gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(feedback)); + chatInputBuffer_[0] = '\0'; + return; + } + + // Slash commands + if (input.size() > 1 && input[0] == '/') { + std::string command = input.substr(1); + size_t spacePos = command.find(' '); + std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; + std::string cmdLower = cmd; + for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); + + // /run + if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { + std::string luaCode = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am) { + am->runScript(luaCode); + } else { + gameHandler.addUIError("Addon system not initialized."); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /dump + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Addon slash commands (SlashCmdList) + { + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + std::string slashCmd = "/" + cmdLower; + std::string slashArgs; + if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); + if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Dispatch through command registry (Phase 3.11) + std::string args; + if (spacePos != std::string::npos) + args = command.substr(spacePos + 1); + + ChatCommandContext ctx{gameHandler, services_, *this, args, cmdLower}; + ChatCommandResult result = commandRegistry_.dispatch(cmdLower, ctx); + if (result.handled) { + if (result.clearInput) + chatInputBuffer_[0] = '\0'; + return; + } + + // Emote fallthrough — dynamic DBC lookup for emote text. + { + std::string targetName; + const std::string* targetNamePtr = nullptr; + if (gameHandler.hasTarget()) { + auto targetEntity = gameHandler.getTarget(); + if (targetEntity) { + targetName = chat_utils::getEntityDisplayName(targetEntity); + if (!targetName.empty()) targetNamePtr = &targetName; + } + } + + std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); + if (!emoteText.empty()) { + auto* renderer = services_.renderer; + if (renderer) { + if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); + } + + uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); + if (dbcId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendTextEmote(dbcId, targetGuid); + } + + game::MessageChatData msg; + msg.type = game::ChatType::TEXT_EMOTE; + msg.language = game::ChatLanguage::COMMON; + msg.message = emoteText; + gameHandler.addLocalChatMessage(msg); + + chatInputBuffer_[0] = '\0'; + return; + } + } + + // Unrecognized slash command — fall through to dropdown chat type + message = input; + } + + // Determine chat type from dropdown selection + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; + case 10: { + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + + // PortBot whisper interception + if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Validate whisper has a target + if (type == game::ChatType::WHISPER && target.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must specify a player name for whisper."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + if (!message.empty()) { + gameHandler.sendChatMessage(type, message, target); + } + chatInputBuffer_[0] = '\0'; +} } // namespace ui } // namespace wowee diff --git a/src/ui/chat_panel_commands.cpp b/src/ui/chat_panel_commands.cpp deleted file mode 100644 index 0fdca8c9..00000000 --- a/src/ui/chat_panel_commands.cpp +++ /dev/null @@ -1,359 +0,0 @@ -#include "ui/chat_panel.hpp" -#include "ui/chat/macro_evaluator.hpp" -#include "ui/chat/game_state_adapter.hpp" -#include "ui/chat/input_modifier_adapter.hpp" -#include "ui/chat/chat_utils.hpp" -#include "ui/chat/gm_command_data.hpp" -#include "core/application.hpp" -#include "core/logger.hpp" -#include "addons/addon_manager.hpp" -#include "rendering/renderer.hpp" -#include "rendering/animation_controller.hpp" -#include -#include -#include - -using wowee::ui::chat_utils::trim; -using wowee::ui::chat_utils::toLower; - -namespace { - - bool isPortBotTarget(const std::string& target) { - std::string t = toLower(trim(target)); - return t == "portbot" || t == "gmbot" || t == "telebot"; - } - - std::string buildPortBotCommand(const std::string& rawInput) { - std::string input = trim(rawInput); - if (input.empty()) return ""; - - std::string lower = toLower(input); - if (lower == "help" || lower == "?") { - return "__help__"; - } - - if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { - return input; - } - - if (lower.rfind("xyz ", 0) == 0) { - return ".go " + input; - } - - if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; - if (lower == "if" || lower == "ironforge") return ".tele ironforge"; - if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; - if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; - if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; - if (lower == "uc" || lower == "undercity") return ".tele undercity"; - if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; - if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; - - return ".tele " + input; - } - - std::string getEntityName(const std::shared_ptr& entity) { - if (entity->getType() == wowee::game::ObjectType::PLAYER) { - auto player = std::static_pointer_cast(entity); - if (!player->getName().empty()) return player->getName(); - } else if (entity->getType() == wowee::game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - if (!unit->getName().empty()) return unit->getName(); - } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - if (!go->getName().empty()) return go->getName(); - } - return "Unknown"; - } -} // namespace - -namespace wowee { namespace ui { - -static std::vector allMacroCommands(const std::string& macroText) { - std::vector cmds; - size_t pos = 0; - while (pos <= macroText.size()) { - size_t nl = macroText.find('\n', pos); - std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); - if (!line.empty() && line.back() == '\r') line.pop_back(); - size_t start = line.find_first_not_of(" \t"); - if (start != std::string::npos) line = line.substr(start); - if (!line.empty() && line.front() != '#') - cmds.push_back(std::move(line)); - if (nl == std::string::npos) break; - pos = nl + 1; - } - return cmds; -} - -// --------------------------------------------------------------------------- -// evaluateMacroConditionals — thin wrapper over MacroEvaluator (Phase 4). -// Preserved for backward compatibility with command files that forward-declare it. -// --------------------------------------------------------------------------- -std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride) { - auto* renderer = core::Application::getInstance().getRenderer(); - GameStateAdapter gs(gameHandler, renderer); - InputModifierAdapter im; - MacroEvaluator eval(gs, im); - return eval.evaluate(rawArg, targetOverride); -} - -// Execute all non-comment lines of a macro body in sequence. -// In WoW, every line executes per click; the server enforces spell-cast limits. -// /stopmacro (with optional conditionals) halts the remaining commands early. - -void ChatPanel::executeMacroText(game::GameHandler& gameHandler, - const std::string& macroText) { - macroStopped_ = false; - for (const auto& cmd : allMacroCommands(macroText)) { - strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); - chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - sendChatMessage(gameHandler); - if (macroStopped_) break; - } - macroStopped_ = false; -} - -// /castsequence state moved to CastSequenceTracker member (Phase 1.5) - - -void ChatPanel::sendChatMessage(game::GameHandler& gameHandler) { - if (strlen(chatInputBuffer_) > 0) { - std::string input(chatInputBuffer_); - - // Save to sent-message history (skip pure whitespace, cap at 50 entries) - { - bool allSpace = true; - for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } - if (!allSpace) { - // Remove duplicate of last entry if identical - if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { - chatSentHistory_.push_back(input); - if (chatSentHistory_.size() > 50) - chatSentHistory_.erase(chatSentHistory_.begin()); - } - } - } - chatHistoryIdx_ = -1; // reset browsing position after send - - game::ChatType type = game::ChatType::SAY; - std::string message = input; - std::string target; - - // GM dot-prefix commands (.gm, .tele, .additem, etc.) - // Sent to server as SAY — the server interprets the dot-prefix. - // Requires GM security level on the server (account set gmlevel 3 -1). - if (input.size() > 1 && input[0] == '.') { - LOG_INFO("GM command: '", input, "' — sending as SAY to server"); - gameHandler.sendChatMessage(game::ChatType::SAY, input, ""); - - // Build feedback: check if this is a known command - std::string dotCmd = input; - size_t sp = dotCmd.find(' '); - std::string cmdPart = (sp != std::string::npos) - ? dotCmd.substr(1, sp - 1) : dotCmd.substr(1); - for (char& c : cmdPart) c = static_cast(std::tolower(static_cast(c))); - - // Look for a matching entry in the GM command table - std::string feedback; - for (const auto& entry : kGmCommands) { - if (entry.name == cmdPart) { - feedback = "Sent: " + input + " (" + std::string(entry.help) + ")"; - break; - } - } - if (feedback.empty()) - feedback = "Sent: " + input - + " (requires GM access — server console: account set gmlevel 3 -1)"; - gameHandler.addLocalChatMessage(chat_utils::makeSystemMessage(feedback)); - chatInputBuffer_[0] = '\0'; - return; - } - - // Check for slash commands - if (input.size() > 1 && input[0] == '/') { - std::string command = input.substr(1); - size_t spacePos = command.find(' '); - std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; - - // Convert command to lowercase for comparison - std::string cmdLower = cmd; - for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); - - // /run — execute Lua script via addon system - if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { - std::string luaCode = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am) { - am->runScript(luaCode); - } else { - gameHandler.addUIError("Addon system not initialized."); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /dump — evaluate Lua expression and print result - if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { - std::string expr = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - // Wrap expression in print(tostring(...)) to display the value - std::string wrapped = "local __v = " + expr + - "; if type(__v) == 'table' then " - " local parts = {} " - " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " - " print('{' .. table.concat(parts, ', ') .. '}') " - "else print(tostring(__v)) end"; - am->runScript(wrapped); - } else { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "Addon system not initialized."; - gameHandler.addLocalChatMessage(errMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Check addon slash commands (SlashCmdList) before built-in commands - { - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - std::string slashCmd = "/" + cmdLower; - std::string slashArgs; - if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); - if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { - chatInputBuffer_[0] = '\0'; - return; - } - } - } - - // Dispatch through command registry (Phase 3.11) - std::string args; - if (spacePos != std::string::npos) - args = command.substr(spacePos + 1); - - ChatCommandContext ctx{gameHandler, services_, *this, args, cmdLower}; - ChatCommandResult result = commandRegistry_.dispatch(cmdLower, ctx); - - if (result.handled) { - if (result.clearInput) - chatInputBuffer_[0] = '\0'; - return; - } - - // Emote fallthrough — dynamic DBC lookup for emote text (catch-all). - // Not registered in the command registry because emote names are data-driven. - { - std::string targetName; - const std::string* targetNamePtr = nullptr; - if (gameHandler.hasTarget()) { - auto targetEntity = gameHandler.getTarget(); - if (targetEntity) { - targetName = getEntityName(targetEntity); - if (!targetName.empty()) targetNamePtr = &targetName; - } - } - - std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); - if (!emoteText.empty()) { - auto* renderer = services_.renderer; - if (renderer) { - if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); - } - - uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); - if (dbcId != 0) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendTextEmote(dbcId, targetGuid); - } - - game::MessageChatData msg; - msg.type = game::ChatType::TEXT_EMOTE; - msg.language = game::ChatLanguage::COMMON; - msg.message = emoteText; - gameHandler.addLocalChatMessage(msg); - - chatInputBuffer_[0] = '\0'; - return; - } - } - - // Unrecognized slash command — fall through to dropdown chat type - message = input; - } - - // Determine chat type from dropdown selection - // (reached when: no slash prefix, OR unrecognized slash command) - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - - // PortBot whisper interception (for dropdown-typed whispers, not /w command) - if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { - std::string cmd = buildPortBotCommand(message); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - if (cmd.empty() || cmd == "__help__") { - msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); - msg.message = "PortBot executed: " + cmd; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Validate whisper has a target - if (type == game::ChatType::WHISPER && target.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must specify a player name for whisper."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Don't send empty messages - if (!message.empty()) { - gameHandler.sendChatMessage(type, message, target); - } - - // Clear input - chatInputBuffer_[0] = '\0'; - } -} - - - -} // namespace ui -} // namespace wowee diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 1ce53ed7..5bbb8ea7 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -2,6 +2,7 @@ #include "ui/ui_colors.hpp" #include "ui/inventory_screen.hpp" #include "ui/keybinding_manager.hpp" +#include "ui/chat/chat_utils.hpp" #include "core/application.hpp" #include "core/input.hpp" #include @@ -10,125 +11,6 @@ namespace wowee { namespace ui { namespace { -// Helper function to replace gender placeholders, pronouns, and name -std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { - game::Gender gender = game::Gender::NONBINARY; - std::string playerName = "Adventurer"; - const auto* character = gameHandler.getActiveCharacter(); - if (character) { - gender = character->gender; - if (!character->name.empty()) { - playerName = character->name; - } - } - game::Pronouns pronouns = game::Pronouns::forGender(gender); - - std::string result = text; - - auto trim = [](std::string& s) { - const char* ws = " \t\n\r"; - size_t start = s.find_first_not_of(ws); - if (start == std::string::npos) { s.clear(); return; } - size_t end = s.find_last_not_of(ws); - s = s.substr(start, end - start + 1); - }; - - // Replace $g placeholders - size_t pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - char marker = result[pos + 1]; - if (marker != 'g' && marker != 'G') { pos++; continue; } - - size_t endPos = result.find(';', pos); - if (endPos == std::string::npos) { pos += 2; continue; } - - std::string placeholder = result.substr(pos + 2, endPos - pos - 2); - - std::vector parts; - size_t start = 0; - size_t colonPos; - while ((colonPos = placeholder.find(':', start)) != std::string::npos) { - std::string part = placeholder.substr(start, colonPos - start); - trim(part); - parts.push_back(part); - start = colonPos + 1; - } - std::string lastPart = placeholder.substr(start); - trim(lastPart); - parts.push_back(lastPart); - - std::string replacement; - if (parts.size() >= 3) { - switch (gender) { - case game::Gender::MALE: replacement = parts[0]; break; - case game::Gender::FEMALE: replacement = parts[1]; break; - case game::Gender::NONBINARY: replacement = parts[2]; break; - } - } else if (parts.size() >= 2) { - switch (gender) { - case game::Gender::MALE: replacement = parts[0]; break; - case game::Gender::FEMALE: replacement = parts[1]; break; - case game::Gender::NONBINARY: - replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; - break; - } - } else { - pos = endPos + 1; - continue; - } - - result.replace(pos, endPos - pos + 1, replacement); - pos += replacement.length(); - } - - // Resolve class and race names for $C and $R placeholders - std::string className = "Adventurer"; - std::string raceName = "Unknown"; - if (character) { - className = game::getClassName(character->characterClass); - raceName = game::getRaceName(character->race); - } - - // Replace simple placeholders - pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - - char code = result[pos + 1]; - std::string replacement; - - switch (code) { - case 'n': case 'N': replacement = playerName; break; - case 'c': case 'C': replacement = className; break; - case 'r': case 'R': replacement = raceName; break; - case 'p': replacement = pronouns.subject; break; - case 'o': replacement = pronouns.object; break; - case 's': replacement = pronouns.possessive; break; - case 'S': replacement = pronouns.possessiveP; break; - case 'b': case 'B': replacement = "\n"; break; - case 'g': case 'G': pos++; continue; - default: pos++; continue; - } - - result.replace(pos, 2, replacement); - pos += replacement.length(); - } - - // WoW markup linebreak token - pos = 0; - while ((pos = result.find("|n", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - pos = 0; - while ((pos = result.find("|N", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - - return result; -} std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { std::string s = raw; @@ -446,7 +328,7 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv if (lastDetailRequestQuestId_ == sel.questId) lastDetailRequestQuestId_ = 0; questDetailQueryNoResponse_.erase(sel.questId); ImGui::TextColored(ImVec4(0.82f, 0.9f, 1.0f, 1.0f), "Summary"); - std::string processedObjectives = replaceGenderPlaceholders(sel.objectives, gameHandler); + std::string processedObjectives = chat_utils::replaceGenderPlaceholders(sel.objectives, gameHandler); float textHeight = ImGui::GetContentRegionAvail().y * 0.45f; if (textHeight < 120.0f) textHeight = 120.0f; ImGui::BeginChild("QuestObjectiveText", ImVec2(0, textHeight), true); diff --git a/src/ui/window_manager.cpp b/src/ui/window_manager.cpp index 767e2806..74bb994f 100644 --- a/src/ui/window_manager.cpp +++ b/src/ui/window_manager.cpp @@ -4,6 +4,7 @@ // ============================================================ #include "ui/window_manager.hpp" #include "ui/chat_panel.hpp" +#include "ui/chat/chat_utils.hpp" #include "ui/settings_panel.hpp" #include "ui/spellbook_screen.hpp" #include "ui/inventory_screen.hpp" @@ -252,7 +253,7 @@ void WindowManager::renderLootWindow(game::GameHandler& gameHandler, } void WindowManager::renderGossipWindow(game::GameHandler& gameHandler, - ChatPanel& chatPanel) { + ChatPanel& /*chatPanel*/) { if (!gameHandler.isGossipWindowOpen()) return; auto* window = services_.window; @@ -330,7 +331,7 @@ void WindowManager::renderGossipWindow(game::GameHandler& gameHandler, displayText = placeholderIt->second; } - std::string processedText = chatPanel.replaceGenderPlaceholders(displayText, gameHandler); + std::string processedText = chat_utils::replaceGenderPlaceholders(displayText, gameHandler); std::string label = std::string(icon) + " " + processedText; if (ImGui::Selectable(label.c_str())) { if (opt.text == "GOSSIP_OPTION_ARMORER") { @@ -436,11 +437,11 @@ void WindowManager::renderQuestDetailsWindow(game::GameHandler& gameHandler, bool open = true; const auto& quest = gameHandler.getQuestDetails(); - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open)) { // Quest description if (!quest.details.empty()) { - std::string processedDetails = chatPanel.replaceGenderPlaceholders(quest.details, gameHandler); + std::string processedDetails = chat_utils::replaceGenderPlaceholders(quest.details, gameHandler); ImGui::TextWrapped("%s", processedDetails.c_str()); } @@ -449,7 +450,7 @@ void WindowManager::renderQuestDetailsWindow(game::GameHandler& gameHandler, ImGui::Spacing(); ImGui::Separator(); ImGui::TextColored(ui::colors::kTooltipGold, "Objectives:"); - std::string processedObjectives = chatPanel.replaceGenderPlaceholders(quest.objectives, gameHandler); + std::string processedObjectives = chat_utils::replaceGenderPlaceholders(quest.objectives, gameHandler); ImGui::TextWrapped("%s", processedObjectives.c_str()); } @@ -577,10 +578,10 @@ void WindowManager::renderQuestRequestItemsWindow(game::GameHandler& gameHandler return total; }; - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.completionText.empty()) { - std::string processedCompletionText = chatPanel.replaceGenderPlaceholders(quest.completionText, gameHandler); + std::string processedCompletionText = chat_utils::replaceGenderPlaceholders(quest.completionText, gameHandler); ImGui::TextWrapped("%s", processedCompletionText.c_str()); } @@ -670,10 +671,10 @@ void WindowManager::renderQuestOfferRewardWindow(game::GameHandler& gameHandler, selectedChoice = 0; } - std::string processedTitle = chatPanel.replaceGenderPlaceholders(quest.title, gameHandler); + std::string processedTitle = chat_utils::replaceGenderPlaceholders(quest.title, gameHandler); if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { if (!quest.rewardText.empty()) { - std::string processedRewardText = chatPanel.replaceGenderPlaceholders(quest.rewardText, gameHandler); + std::string processedRewardText = chat_utils::replaceGenderPlaceholders(quest.rewardText, gameHandler); ImGui::TextWrapped("%s", processedRewardText.c_str()); }