refactor(chat): extract ItemTooltipRenderer, slim render(), consolidate utils

- 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 <pavel.okhlopkov@flant.com>
This commit is contained in:
Pavel Okhlopkov 2026-04-12 15:46:03 +03:00
parent 42f1bb98ea
commit ada019e0d4
17 changed files with 1302 additions and 1463 deletions

View file

@ -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 <imgui.h>
#include <cstring>
#include <unordered_map>
#include <cstdio>
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<ES>(static_cast<int>(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<game::ItemQuality>(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<float>(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<int>(info->damageMin), static_cast<int>(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<uint32_t, std::string> 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<uint32_t, 10> itemIds{};
std::array<uint32_t, 10> spellIds{};
std::array<uint32_t, 10> thresholds{};
};
static std::unordered_map<uint32_t, SetEntry> 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<game::EquipSlot>(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<int>(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<uint32_t, std::string> 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<uint32_t, std::string> 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<uint8_t>(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<float>(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<int>(eq->item.damageMin), static_cast<int>(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

View file

@ -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 <imgui.h>
#include <imgui_internal.h>
#include "core/coordinates.hpp"
#include <algorithm>
#include <cmath>
#include <sstream>
#include <cstring>
#include <unordered_map>
#include <vector>
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<game::Entity>& entity) {
if (entity->getType() == game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<game::Player>(entity);
if (!player->getName().empty()) return player->getName();
} else if (entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (!unit->getName().empty()) return unit->getName();
} else if (entity->getType() == game::ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<game::GameObject>(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

View file

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

View file

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

View file

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

View file

@ -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 <imgui.h>
#include <cstring>
#include <unordered_map>
#include <cstdio>
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<ES>(static_cast<int>(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<game::ItemQuality>(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<float>(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<int>(info->damageMin), static_cast<int>(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<uint32_t, std::string> 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<uint32_t, 10> itemIds{};
std::array<uint32_t, 10> spellIds{};
std::array<uint32_t, 10> thresholds{};
};
static std::unordered_map<uint32_t, SetEntry> 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<game::EquipSlot>(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<int>(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<uint32_t, std::string> 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<uint32_t, std::string> 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<uint8_t>(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<float>(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<int>(eq->item.damageMin), static_cast<int>(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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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 <algorithm>
#include <cstring>
#include <cctype>
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<wowee::game::Entity>& entity) {
if (entity->getType() == wowee::game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<wowee::game::Player>(entity);
if (!player->getName().empty()) return player->getName();
} else if (entity->getType() == wowee::game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<wowee::game::Unit>(entity);
if (!unit->getName().empty()) return unit->getName();
} else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<wowee::game::GameObject>(entity);
if (!go->getName().empty()) return go->getName();
}
return "Unknown";
}
} // namespace
namespace wowee { namespace ui {
static std::vector<std::string> allMacroCommands(const std::string& macroText) {
std::vector<std::string> 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<unsigned char>(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 <user> 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<char>(std::tolower(static_cast<unsigned char>(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 <user> 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<char>(std::tolower(static_cast<unsigned char>(c)));
// /run <lua code> — 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 <expression> — 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<int>(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 <dest>. 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

View file

@ -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 <imgui.h>
@ -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<std::string> 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);

View file

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