From 8d4d9b7169c2b98b37ded23412f4d3fab2d9c82b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 19 Feb 2026 02:27:01 -0800 Subject: [PATCH] Fix quest flow regressions, tooltip compare stats, and M2 alpha-key handling --- include/rendering/m2_renderer.hpp | 3 + src/game/game_handler.cpp | 102 +++++++--------------------- src/game/packet_parsers_classic.cpp | 62 +++++++++++++++-- src/rendering/m2_renderer.cpp | 84 ++++++++++++++++++----- src/ui/game_screen.cpp | 96 ++++++++++++++++---------- src/ui/inventory_screen.cpp | 99 +++++++++++++++------------ 6 files changed, 269 insertions(+), 177 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 0e6069dd..db8d2512 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -32,6 +32,7 @@ struct M2ModelGPU { uint32_t indexStart = 0; // offset in indices (not bytes) uint32_t indexCount = 0; bool hasAlpha = false; + bool colorKeyBlack = false; uint16_t textureAnimIndex = 0xFFFF; // 0xFFFF = no texture animation uint16_t blendMode = 0; // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, etc. uint16_t materialFlags = 0; // M2 material flags (0x01=Unlit, 0x04=TwoSided, 0x10=NoDepthWrite) @@ -366,9 +367,11 @@ private: size_t approxBytes = 0; uint64_t lastUse = 0; bool hasAlpha = true; + bool colorKeyBlack = false; }; std::unordered_map textureCache; std::unordered_map textureHasAlphaById_; + std::unordered_map textureColorKeyBlackById_; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; // Default, overridden at init diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 7dc992f7..474e1806 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4283,9 +4283,6 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; - const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride; // 25 quest slots for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -4299,31 +4296,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerMoneyCopper_ = val; LOG_INFO("Money set from update fields: ", val, " copper"); } - // Parse quest log fields (stride varies by expansion: 5=WotLK, 3=Classic) - else if (key >= ufQuestStart && key < ufQuestEnd && (key - ufQuestStart) % qStride == 0) { - uint32_t questId = val; - if (questId != 0) { - // Check if quest is already in log - bool found = false; - for (auto& q : questLog_) { - if (q.questId == questId) { - found = true; - break; - } - } - if (!found) { - // Add quest to log and request quest details - QuestLogEntry entry; - entry.questId = questId; - entry.complete = false; // Will be updated by gossip or quest status packets - entry.title = "Quest #" + std::to_string(questId); - questLog_.push_back(entry); - LOG_INFO("Found quest in update fields: ", questId); - - requestQuestQuery(questId); - } - } - } + // Do not synthesize quest-log entries from raw update-field slots. + // Slot layouts differ on some classic-family realms and can produce + // phantom "already accepted" quests that block quest acceptance. } if (applyInventoryFields(block.fields)) slotsChanged = true; if (slotsChanged) rebuildOnlineInventory(); @@ -4608,38 +4583,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } - // Scan quest log fields in VALUES updates too (server may re-send them - // after quest accept, abandon, or same-map repositions). - { - const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; - const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride; - for (const auto& [key, val] : block.fields) { - if (key >= ufQuestStart && key < ufQuestEnd && - (key - ufQuestStart) % qStride == 0) { - uint32_t qId = val; - if (qId != 0) { - bool found = false; - for (auto& q : questLog_) { - if (q.questId == qId) { found = true; break; } - } - if (!found) { - QuestLogEntry entry; - entry.questId = qId; - entry.complete = false; - entry.title = "Quest #" + std::to_string(qId); - questLog_.push_back(entry); - LOG_INFO("Quest found in VALUES update: ", qId); - requestQuestQuery(qId); - } - } else { - // Quest slot cleared — remove from log if present - uint16_t slot = (key - ufQuestStart) / qStride; - (void)slot; // slot index available if needed - } - } - } - } + // Do not auto-create quests from VALUES quest-log slot fields for the + // same reason as CREATE_OBJECT2 above (can be misaligned per realm). if (applyInventoryFields(block.fields)) slotsChanged = true; if (slotsChanged) rebuildOnlineInventory(); extractSkillFields(lastPlayerFields_); @@ -8722,22 +8667,7 @@ void GameHandler::selectGossipOption(uint32_t optionId) { void GameHandler::selectGossipQuest(uint32_t questId) { if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return; - // Prefer current gossip icon semantics to choose flow. - // WotLK/classic gossip icon conventions commonly use: - // 2 = available (!), 4 = active/incomplete (?), 5 = completable (?) - const GossipQuestItem* gossipQuest = nullptr; - for (const auto& q : currentGossip.quests) { - if (q.questId == questId) { - gossipQuest = &q; - break; - } - } - - const bool iconSaysAvailable = gossipQuest && gossipQuest->questIcon == 2; - const bool iconSaysActive = gossipQuest && - (gossipQuest->questIcon == 4 || gossipQuest->questIcon == 5); - - // Keep quest-log fallback for servers that don't use canonical icon values. + // Keep quest-log fallback for servers that don't provide stable icon semantics. const QuestLogEntry* activeQuest = nullptr; for (const auto& q : questLog_) { if (q.questId == questId) { @@ -8746,7 +8676,25 @@ void GameHandler::selectGossipQuest(uint32_t questId) { } } - const bool shouldStartProgressFlow = iconSaysActive || (!iconSaysAvailable && activeQuest); + // Validate against server-auth quest slot fields to avoid stale local entries + // forcing turn-in flow for quests that are not actually accepted. + auto questInServerLogSlots = [&](uint32_t qid) -> bool { + if (qid == 0 || lastPlayerFields_.empty()) return false; + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride; + for (const auto& [key, val] : lastPlayerFields_) { + if (key < ufQuestStart || key >= ufQuestEnd) continue; + if ((key - ufQuestStart) % qStride != 0) continue; + if (val == qid) return true; + } + return false; + }; + const bool activeQuestConfirmedByServer = activeQuest && questInServerLogSlots(questId); + // Only trust server quest-log slots for deciding "already accepted" flow. + // Gossip icon values can differ across cores/expansions and misclassify + // available quests as active, which blocks acceptance. + const bool shouldStartProgressFlow = activeQuestConfirmedByServer; if (shouldStartProgressFlow) { pendingTurnInQuestId_ = questId; pendingTurnInNpcGuid_ = currentGossip.npcGuid; diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index d159d05c..d40ae9a2 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -882,8 +882,40 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ uint32_t subClass = packet.readUInt32(); // Vanilla: NO SoundOverrideSubclass - (void)itemClass; - (void)subClass; + data.itemClass = itemClass; + data.subClass = subClass; + data.subclassName = ""; + if (itemClass == 2) { // Weapon + switch (subClass) { + case 0: data.subclassName = "Axe"; break; + case 1: data.subclassName = "Axe"; break; + case 2: data.subclassName = "Bow"; break; + case 3: data.subclassName = "Gun"; break; + case 4: data.subclassName = "Mace"; break; + case 5: data.subclassName = "Mace"; break; + case 6: data.subclassName = "Polearm"; break; + case 7: data.subclassName = "Sword"; break; + case 8: data.subclassName = "Sword"; break; + case 10: data.subclassName = "Staff"; break; + case 13: data.subclassName = "Fist Weapon"; break; + case 15: data.subclassName = "Dagger"; break; + case 16: data.subclassName = "Thrown"; break; + case 18: data.subclassName = "Crossbow"; break; + case 19: data.subclassName = "Wand"; break; + case 20: data.subclassName = "Fishing Pole"; break; + default: data.subclassName = "Weapon"; break; + } + } else if (itemClass == 4) { // Armor + switch (subClass) { + case 0: data.subclassName = "Miscellaneous"; break; + case 1: data.subclassName = "Cloth"; break; + case 2: data.subclassName = "Leather"; break; + case 3: data.subclassName = "Mail"; break; + case 4: data.subclassName = "Plate"; break; + case 6: data.subclassName = "Shield"; break; + default: data.subclassName = "Armor"; break; + } + } // 4 name strings data.name = packet.readString(); @@ -935,14 +967,34 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ // Vanilla: NO ScalingStatDistribution, NO ScalingStatValue // Vanilla: 5 damage types (same count as WotLK) + bool haveWeaponDamage = false; for (int i = 0; i < 5; i++) { - packet.readFloat(); // DamageMin - packet.readFloat(); // DamageMax - packet.readUInt32(); // DamageType + float dmgMin = packet.readFloat(); + float dmgMax = packet.readFloat(); + uint32_t damageType = packet.readUInt32(); + if (!haveWeaponDamage && dmgMax > 0.0f) { + // Prefer physical damage (type 0) when present. + if (damageType == 0 || data.damageMax <= 0.0f) { + data.damageMin = dmgMin; + data.damageMax = dmgMax; + haveWeaponDamage = (damageType == 0); + } + } } data.armor = static_cast(packet.readUInt32()); + // Remaining tail can vary by core. Read resistances + delay when present. + if (packet.getSize() - packet.getReadPos() >= 28) { + packet.readUInt32(); // HolyRes + packet.readUInt32(); // FireRes + packet.readUInt32(); // NatureRes + packet.readUInt32(); // FrostRes + packet.readUInt32(); // ShadowRes + packet.readUInt32(); // ArcaneRes + data.delayMs = packet.readUInt32(); + } + data.valid = !data.name.empty(); LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, " invType=", data.inventoryType, " stack=", data.maxStack, ")"); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 7730c607..4506c577 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -325,6 +325,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform sampler2D uTexture; uniform bool uHasTexture; uniform bool uAlphaTest; + uniform bool uColorKeyBlack; uniform bool uUnlit; uniform float uFadeAlpha; @@ -348,10 +349,16 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish } - // Alpha test for leaves, fences, etc. + // Alpha test / alpha-key cutout for card textures. if (uAlphaTest && texColor.a < 0.5) { discard; } + if (uAlphaTest && max(texColor.r, max(texColor.g, texColor.b)) < 0.06) { + discard; + } + if (uColorKeyBlack && max(texColor.r, max(texColor.g, texColor.b)) < 0.08) { + discard; + } // Distance fade - discard nearly invisible fragments float finalAlpha = texColor.a * uFadeAlpha; @@ -536,6 +543,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { in float vTile; uniform sampler2D uTexture; uniform vec2 uTileCount; + uniform bool uAlphaKey; out vec4 FragColor; void main() { @@ -555,6 +563,14 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec2 tileSize = vec2(1.0 / tilesX, 1.0 / tilesY); vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize; vec4 texColor = texture(uTexture, uv); + + // Alpha-key particle textures often encode transparency as near-black + // color without meaningful alpha. + if (uAlphaKey) { + float maxRgb = max(texColor.r, max(texColor.g, texColor.b)); + if (maxRgb < 0.06 || texColor.a < 0.5) discard; + } + FragColor = texColor * vColor; FragColor.a *= edgeFade; if (FragColor.a < 0.01) discard; @@ -665,6 +681,7 @@ void M2Renderer::shutdown() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; textureHasAlphaById_.clear(); + textureColorKeyBlackById_.clear(); if (whiteTexture != 0) { glDeleteTextures(1, &whiteTexture); whiteTexture = 0; @@ -1170,7 +1187,18 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { texFailed = !textureLoadFailed.empty() && textureLoadFailed[0]; } bgpu.texture = tex; - bgpu.hasAlpha = (tex != 0 && tex != whiteTexture); + bool texHasAlpha = false; + if (tex != 0 && tex != whiteTexture) { + auto ait = textureHasAlphaById_.find(tex); + texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; + } + bgpu.hasAlpha = texHasAlpha; + bool colorKeyBlack = false; + if (tex != 0 && tex != whiteTexture) { + auto cit = textureColorKeyBlackById_.find(tex); + colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; + } + bgpu.colorKeyBlack = colorKeyBlack; // textureCoordIndex is an index into a texture coord combo table, not directly // a UV set selector. Most batches have index=0 (UV set 0). We always use UV set 0 // since we don't have the full combo table — dual-UV effects are rare edge cases. @@ -1218,7 +1246,18 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { bgpu.indexStart = 0; bgpu.indexCount = gpuModel.indexCount; bgpu.texture = allTextures.empty() ? whiteTexture : allTextures[0]; - bgpu.hasAlpha = (bgpu.texture != 0 && bgpu.texture != whiteTexture); + bool texHasAlpha = false; + if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { + auto ait = textureHasAlphaById_.find(bgpu.texture); + texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; + } + bgpu.hasAlpha = texHasAlpha; + bool colorKeyBlack = false; + if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { + auto cit = textureColorKeyBlackById_.find(bgpu.texture); + colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; + } + bgpu.colorKeyBlack = colorKeyBlack; gpuModel.batches.push_back(bgpu); } @@ -1826,6 +1865,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: static GLuint lastBoundTexture = 0; static bool lastHasTexture = false; static bool lastAlphaTest = false; + static bool lastColorKeyBlack = false; static bool lastUnlit = false; static bool lastUseBones = false; static bool lastInteriorDarken = false; @@ -1838,6 +1878,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastBoundTexture = 0; lastHasTexture = false; lastAlphaTest = false; + lastColorKeyBlack = false; lastUnlit = false; lastUseBones = false; lastInteriorDarken = false; @@ -1849,6 +1890,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // Set texture unit once per frame instead of per-batch glActiveTexture(GL_TEXTURE0); shader->setUniform("uTexture", 0); // Texture unit 0, set once per frame + shader->setUniform("uColorKeyBlack", false); // Performance counters uint32_t boneMatrixUploads = 0; @@ -1942,19 +1984,8 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // Skip batches with zero opacity from texture weight tracks (should be invisible) if (batch.batchOpacity < 0.01f) continue; - // Additive/mod batches (glow halos, light effects): collect as glow sprites - // instead of rendering the mesh geometry which appears as flat orange disks. - if (batch.blendMode >= 3) { - if (entry.distSq < 120.0f * 120.0f) { // Only render glow within 120 units - glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(batch.center, 1.0f)); - GlowSprite gs; - gs.worldPos = worldPos; - gs.color = glm::vec4(1.0f, 0.75f, 0.35f, 0.85f); - gs.size = batch.glowSize * instance.scale; - glowSprites_.push_back(gs); - } - continue; - } + // Render additive/mod batches as authored geometry so alpha-cutout cards + // (e.g. candle flames) keep their original transparency/glow behavior. // Compute UV offset for texture animation (only set uniform if changed) glm::vec2 uvOffset(0.0f, 0.0f); @@ -2040,11 +2071,17 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastHasTexture = hasTexture; } - bool alphaTest = (batch.blendMode == 1); + bool alphaTest = (batch.blendMode == 1) || + (batch.blendMode >= 2 && !batch.hasAlpha); if (alphaTest != lastAlphaTest) { shader->setUniform("uAlphaTest", alphaTest); lastAlphaTest = alphaTest; } + bool colorKeyBlack = batch.colorKeyBlack; + if (colorKeyBlack != lastColorKeyBlack) { + shader->setUniform("uColorKeyBlack", colorKeyBlack); + lastColorKeyBlack = colorKeyBlack; + } // Only bind texture if it changed (texture unit already set to GL_TEXTURE0) if (hasTexture && batch.texture != lastBoundTexture) { @@ -2519,6 +2556,7 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection"); GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture"); GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount"); + GLint alphaKeyLoc = glGetUniformLocation(m2ParticleShader_, "uAlphaKey"); glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(proj)); glUniform1i(texLoc, 0); @@ -2532,6 +2570,7 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) // Use blend mode as specified by the emitter — don't override based on texture alpha. // BlendType: 0=opaque, 1=alphaKey, 2=alpha, 3=add, 4=mod uint8_t blendType = group.blendType; + glUniform1i(alphaKeyLoc, (blendType == 1) ? 1 : 0); if (blendType == 3 || blendType == 4) { glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive } else { @@ -2819,6 +2858,15 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { return it->second.id; } + auto containsToken = [](const std::string& haystack, const char* token) { + return haystack.find(token) != std::string::npos; + }; + const bool colorKeyBlackHint = + containsToken(key, "candle") || + containsToken(key, "flame") || + containsToken(key, "fire") || + containsToken(key, "torch"); + // Load BLP texture pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { @@ -2860,10 +2908,12 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; e.approxBytes = base + (base / 3); e.hasAlpha = hasAlpha; + e.colorKeyBlack = colorKeyBlackHint; e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = e; textureHasAlphaById_[textureID] = hasAlpha; + textureColorKeyBlackById_[textureID] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); return textureID; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 75a9b30c..f7eaf6e8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -763,32 +763,50 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); } } - if (info->damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax); - if (info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("Speed %.2f", speed); - ImGui::Text("%.1f damage per second", dps); + 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; } - } - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - auto renderStat = [&](int32_t val, const char* name) { - if (val > 0) ImGui::TextColored(green, "+%d %s", val, name); - else if (val < 0) ImGui::TextColored(ImVec4(1, 0.2f, 0.2f, 1), "%d %s", val, name); }; - renderStat(info->stamina, "Stamina"); - renderStat(info->strength, "Strength"); - renderStat(info->agility, "Agility"); - renderStat(info->intellect, "Intellect"); - renderStat(info->spirit, "Spirit"); + const bool isWeapon = isWeaponInventoryType(info->inventoryType); + + 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; + ImGui::Text("%.1f DPS", 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 (!isWeapon && info->armor > 0) { + ImGui::Text("%d Armor", info->armor); + } if (info->sellPrice > 0) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; uint32_t c = info->sellPrice % 100; - ImGui::Separator(); - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); } if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { @@ -801,15 +819,24 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); } ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (eq->item.damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", eq->item.damageMin, eq->item.damageMax); + 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; + ImGui::Text("%.1f DPS", dps); + } + if (!isWeaponInventoryType(eq->item.inventoryType) && 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()); } - if (eq->item.armor > 0) ImGui::Text("%d Armor", eq->item.armor); - renderStat(eq->item.stamina, "Stamina"); - renderStat(eq->item.strength, "Strength"); - renderStat(eq->item.agility, "Agility"); - renderStat(eq->item.intellect, "Intellect"); - renderStat(eq->item.spirit, "Spirit"); } } ImGui::EndTooltip(); @@ -1002,6 +1029,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; + std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); ImVec4 color = getChatTypeColor(msg.type); @@ -1027,7 +1055,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); ImGui::SameLine(0, 0); } - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } else if (msg.type == game::ChatType::TEXT_EMOTE) { if (!tsPrefix.empty()) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.6f, 1.0f)); @@ -1035,7 +1063,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); ImGui::SameLine(0, 0); } - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } else if (!msg.senderName.empty()) { if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_YELL) { std::string prefix = tsPrefix + msg.senderName + " says: "; @@ -1043,7 +1071,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 @@ -1054,14 +1082,14 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } else { std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": "; ImGui::PushStyleColor(ImGuiCol_Text, color); ImGui::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } } else { std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] "; @@ -1069,7 +1097,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", prefix.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - renderTextWithLinks(msg.message, color); + renderTextWithLinks(processedMessage, color); } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 7f0dbeed..508b1e28 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1571,51 +1571,53 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } } - if (item.damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax); - if (item.delayMs > 0) { - float speed = static_cast(item.delayMs) / 1000.0f; - float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; - ImGui::Text("Speed %.2f", speed); - ImGui::Text("%.1f damage per second", dps); - } - } - - // Armor - if (item.armor > 0) { - ImGui::Text("%d Armor", item.armor); - } - - // Stats with "Equip:" prefix style - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - ImVec4 red(1.0f, 0.2f, 0.2f, 1.0f); - - auto renderStat = [&](int32_t val, const char* name) { - if (val > 0) { - ImGui::TextColored(green, "+%d %s", val, name); - } else if (val < 0) { - ImGui::TextColored(red, "%d %s", val, name); + 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(item.inventoryType); - renderStat(item.stamina, "Stamina"); - renderStat(item.strength, "Strength"); - renderStat(item.agility, "Agility"); - renderStat(item.intellect, "Intellect"); - renderStat(item.spirit, "Spirit"); - - // Stack info - if (item.maxStack > 1) { - ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Stack: %u/%u", item.stackCount, item.maxStack); + // Compact stats view for weapons: DPS + condensed stat bonuses. + // Non-weapons keep armor/sell info visible. + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) { + float speed = static_cast(item.delayMs) / 1000.0f; + float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; + ImGui::Text("%.1f DPS", dps); + } + 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, item.strength, "Str"); + appendBonus(bonusLine, item.agility, "Agi"); + appendBonus(bonusLine, item.stamina, "Sta"); + appendBonus(bonusLine, item.intellect, "Int"); + appendBonus(bonusLine, item.spirit, "Spi"); + if (!bonusLine.empty()) { + ImGui::TextColored(green, "%s", bonusLine.c_str()); } - // Sell price + if (!isWeapon && item.armor > 0) { + ImGui::Text("%d Armor", item.armor); + } if (item.sellPrice > 0) { uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; uint32_t c = item.sellPrice % 100; - ImGui::Separator(); - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); } // Shift-hover comparison with currently equipped equivalent. @@ -1630,15 +1632,24 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (eq->item.damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", eq->item.damageMin, eq->item.damageMax); + 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; + ImGui::Text("%.1f DPS", dps); + } + if (!isWeaponInventoryType(eq->item.inventoryType) && 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()); } - if (eq->item.armor > 0) ImGui::Text("%d Armor", eq->item.armor); - renderStat(eq->item.stamina, "Stamina"); - renderStat(eq->item.strength, "Strength"); - renderStat(eq->item.agility, "Agility"); - renderStat(eq->item.intellect, "Intellect"); - renderStat(eq->item.spirit, "Spirit"); } }