From 63b4394e3e69bc0a46733068345287b1aacb2471 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 18 Mar 2026 09:54:52 -0700 Subject: [PATCH] feat: world-space floating combat text above entities Combat text (damage, heals, misses, crits, etc.) now floats above the target entity in 3D space instead of appearing at fixed screen positions. Text rises upward from the entity's head, with random horizontal stagger to prevent stacking. HUD-only types (XP, Honor, Procs) and entries without a valid entity anchor fall back to the original screen overlay. --- include/game/spell_defines.hpp | 3 + src/game/game_handler.cpp | 6 + src/ui/game_screen.cpp | 529 +++++++++++++++++++-------------- 3 files changed, 308 insertions(+), 230 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 67d94c2d..c6fe3663 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -61,6 +61,9 @@ struct CombatTextEntry { float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower + uint64_t srcGuid = 0; // Source entity (attacker/caster) + uint64_t dstGuid = 0; // Destination entity (victim/target) — used for world-space positioning + float xSeed = 0.0f; // Random horizontal offset seed (-1..1) to stagger overlapping text static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 15e55819..eac38722 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -15560,6 +15560,12 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; entry.powerType = powerType; + entry.srcGuid = srcGuid; + entry.dstGuid = dstGuid; + // Random horizontal stagger so simultaneous hits don't stack vertically + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution dist(-1.0f, 1.0f); + entry.xSeed = dist(rng); combatText.push_back(entry); // Persistent combat log — use explicit GUIDs if provided, else fall back to diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index db869acf..dd727550 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -10408,262 +10408,331 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { if (entries.empty()) return; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); - // Render combat text entries overlaid on screen - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + // Camera for world-space projection + auto* appRenderer = core::Application::getInstance().getRenderer(); + rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; + glm::mat4 viewProj; + if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + const float baseFontSize = ImGui::GetFontSize(); - if (ImGui::Begin("##CombatText", nullptr, flags)) { - // Incoming events (enemy attacks player) float near screen center (over the player). - // Outgoing events (player attacks enemy) float on the right side (near the target). - const float incomingX = screenW * 0.40f; - const float outgoingX = screenW * 0.68f; + // HUD fallback: entries without world-space anchor use classic screen-position layout. + // We still need an ImGui window for those. + const float hudIncomingX = screenW * 0.40f; + const float hudOutgoingX = screenW * 0.68f; + int hudInIdx = 0, hudOutIdx = 0; + bool needsHudWindow = false; - int inIdx = 0, outIdx = 0; - for (const auto& entry : entries) { - float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); - float yOffset = 200.0f - entry.age * 60.0f; - const bool outgoing = entry.isPlayerSource; + for (const auto& entry : entries) { + const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + const bool outgoing = entry.isPlayerSource; - ImVec4 color; - char text[64]; - switch (entry.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow - ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red - break; - case game::CombatTextEntry::CRIT_DAMAGE: - snprintf(text, sizeof(text), "-%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.8f, 0.0f, alpha) : // Outgoing crit = bright yellow - ImVec4(1.0f, 0.5f, 0.0f, alpha); // Incoming crit = orange - break; - case game::CombatTextEntry::HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_HEAL: - snprintf(text, sizeof(text), "+%d!", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::MISS: - snprintf(text, sizeof(text), "Miss"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DODGE: - // outgoing=true: enemy dodged player's attack - // outgoing=false: player dodged incoming attack - snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PARRY: - snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::BLOCK: - if (entry.amount > 0) - snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); - else - snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::EVADE: - snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PERIODIC_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.9f, 0.3f, alpha) : // Outgoing DoT = pale yellow - ImVec4(1.0f, 0.4f, 0.4f, alpha); // Incoming DoT = pale red - break; - case game::CombatTextEntry::PERIODIC_HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.4f, 1.0f, 0.5f, alpha); - break; - case game::CombatTextEntry::ENVIRONMENTAL: { - const char* envLabel = ""; - switch (entry.powerType) { - case 0: envLabel = "Fatigue "; break; - case 1: envLabel = "Drowning "; break; - case 2: envLabel = ""; break; // Fall: just show the number (WoW convention) - case 3: envLabel = "Lava "; break; - case 4: envLabel = "Slime "; break; - case 5: envLabel = "Fire "; break; - default: envLabel = ""; break; - } - snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); - color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental - break; + // --- Format text and color (identical logic for both world and HUD paths) --- + ImVec4 color; + char text[128]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : + ImVec4(1.0f, 0.3f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.8f, 0.0f, alpha) : + ImVec4(1.0f, 0.5f, 0.0f, alpha); + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::BLOCK: + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else + snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PERIODIC_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.9f, 0.3f, alpha) : + ImVec4(1.0f, 0.4f, 0.4f, alpha); + break; + case game::CombatTextEntry::PERIODIC_HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.4f, 1.0f, 0.5f, alpha); + break; + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; } - case game::CombatTextEntry::ENERGIZE: - snprintf(text, sizeof(text), "+%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red - case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange - case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow - case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal - default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue - } - break; - case game::CombatTextEntry::POWER_DRAIN: - snprintf(text, sizeof(text), "-%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; - case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; - case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; - case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; - default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; - } - break; - case game::CombatTextEntry::XP_GAIN: - snprintf(text, sizeof(text), "+%d XP", entry.amount); - color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP - break; - case game::CombatTextEntry::IMMUNE: - snprintf(text, sizeof(text), "Immune!"); - color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune - break; - case game::CombatTextEntry::ABSORB: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Absorbed %d", entry.amount); - else - snprintf(text, sizeof(text), "Absorbed"); - color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb - break; - case game::CombatTextEntry::RESIST: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Resisted %d", entry.amount); - else - snprintf(text, sizeof(text), "Resisted"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist - break; - case game::CombatTextEntry::DEFLECT: - snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); - color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) - : ImVec4(0.5f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::REFLECT: { - const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!reflectName.empty()) - snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); - else - snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); - color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) - : ImVec4(0.75f, 0.85f, 1.0f, alpha); - break; + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); + break; + } + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; } - case game::CombatTextEntry::PROC_TRIGGER: { - const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!procName.empty()) - snprintf(text, sizeof(text), "%s!", procName.c_str()); - else - snprintf(text, sizeof(text), "PROC!"); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc - break; + break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; } - case game::CombatTextEntry::DISPEL: - if (entry.spellId != 0) { - const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); - if (!dispelledName.empty()) - snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); - else - snprintf(text, sizeof(text), "Dispel"); - } else { + break; + case game::CombatTextEntry::XP_GAIN: + snprintf(text, sizeof(text), "+%d XP", entry.amount); + color = ImVec4(0.7f, 0.3f, 1.0f, alpha); + break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); + break; + case game::CombatTextEntry::ABSORB: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); + break; + case game::CombatTextEntry::RESIST: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; + } + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else snprintf(text, sizeof(text), "Dispel"); - } - color = ImVec4(0.6f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::STEAL: - if (entry.spellId != 0) { - const std::string& stolenName = gameHandler.getSpellName(entry.spellId); - if (!stolenName.empty()) - snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); - else - snprintf(text, sizeof(text), "Spellsteal"); - } else { - snprintf(text, sizeof(text), "Spellsteal"); - } - color = ImVec4(0.8f, 0.7f, 1.0f, alpha); - break; - case game::CombatTextEntry::INTERRUPT: { - const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!interruptedName.empty()) - snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); - else - snprintf(text, sizeof(text), "Interrupt"); - color = ImVec4(1.0f, 0.6f, 0.9f, alpha); - break; + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : + ImVec4(0.75f, 0.35f, 0.35f, alpha); + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : + ImVec4(1.0f, 0.15f, 0.15f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // --- Rendering style --- + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + + // --- Try world-space anchor if we have a destination entity --- + // Types that should always stay as HUD elements (no world anchor) + bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || + entry.type == game::CombatTextEntry::HONOR_GAIN || + entry.type == game::CombatTextEntry::PROC_TRIGGER); + + bool rendered = false; + if (!isHudOnly && camera && entry.dstGuid != 0) { + // Look up the destination entity's render position + glm::vec3 renderPos; + bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); + if (!havePos) { + // Fallback to entity canonical position + auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + havePos = true; + } } - case game::CombatTextEntry::INSTAKILL: - snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); - color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) - : ImVec4(1.0f, 0.1f, 0.1f, alpha); - break; - case game::CombatTextEntry::HONOR_GAIN: - snprintf(text, sizeof(text), "+%d Honor", entry.amount); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor - break; - case game::CombatTextEntry::GLANCING: - snprintf(text, sizeof(text), "~%d", entry.amount); - color = outgoing ? - ImVec4(0.75f, 0.75f, 0.5f, alpha) : // Outgoing glancing = muted yellow - ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red - break; - case game::CombatTextEntry::CRUSHING: - snprintf(text, sizeof(text), "%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.55f, 0.1f, alpha) : // Outgoing crushing = orange - ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red - break; - default: - snprintf(text, sizeof(text), "%d", entry.amount); - color = ImVec4(1.0f, 1.0f, 1.0f, alpha); - break; } - // Outgoing → right side (near target), incoming → center-left (near player) - int& idx = outgoing ? outIdx : inIdx; - float baseX = outgoing ? outgoingX : incomingX; + if (havePos) { + // Float upward from above the entity's head + renderPos.z += 2.5f + entry.age * 1.2f; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w > 0.01f) { + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Horizontal stagger using the random seed + sx += entry.xSeed * 40.0f; + + // Center the text horizontally on the projected point + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + sx -= ts.x * 0.5f; + + // Clamp to screen bounds + sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); + + drawList->AddText(font, renderFontSize, + ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); + drawList->AddText(font, renderFontSize, + ImVec2(sx, sy), textCol, text); + rendered = true; + } + } + } + } + + // --- HUD fallback for entries without world anchor or HUD-only types --- + if (!rendered) { + if (!needsHudWindow) { + needsHudWindow = true; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImGui::Begin("##CombatText", nullptr, flags); + } + + float yOffset = 200.0f - entry.age * 60.0f; + int& idx = outgoing ? hudOutIdx : hudInIdx; + float baseX = outgoing ? hudOutgoingX : hudIncomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; - // Crits render at 1.35× normal font size for visual impact - bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || - entry.type == game::CombatTextEntry::CRIT_HEAL); - ImFont* font = ImGui::GetFont(); - float baseFontSize = ImGui::GetFontSize(); - float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; - - // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); ImVec2 screenPos = ImGui::GetCursorScreenPos(); - // Drop shadow for readability over complex backgrounds - ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); - ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); - ImDrawList* dl = ImGui::GetWindowDrawList(); + ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), shadowCol, text); dl->AddText(font, renderFontSize, screenPos, textCol, text); - // Reserve space so ImGui doesn't clip the window prematurely ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); ImGui::Dummy(ts); } } - ImGui::End(); + + if (needsHudWindow) { + ImGui::End(); + } } // ============================================================