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.
This commit is contained in:
Kelsi 2026-03-18 09:54:52 -07:00
parent 6aea48aea9
commit 63b4394e3e
3 changed files with 308 additions and 230 deletions

View file

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

View file

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

View file

@ -10408,42 +10408,47 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
if (entries.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
if (!window) return;
const float screenW = static_cast<float>(window->getWidth());
const float screenH = static_cast<float>(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 float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME);
const bool outgoing = entry.isPlayerSource;
// --- Format text and color (identical logic for both world and HUD paths) ---
ImVec4 color;
char text[64];
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) : // Outgoing = yellow
ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red
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) : // Outgoing crit = bright yellow
ImVec4(1.0f, 0.5f, 0.0f, alpha); // Incoming crit = orange
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);
@ -10458,8 +10463,6 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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);
@ -10485,8 +10488,8 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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
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);
@ -10497,24 +10500,24 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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 2: envLabel = ""; break;
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
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; // 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
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;
}
break;
case game::CombatTextEntry::POWER_DRAIN:
@ -10529,25 +10532,25 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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
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); // White for 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); // Light blue for absorb
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); // Grey for resist
color = ImVec4(0.7f, 0.7f, 0.7f, alpha);
break;
case game::CombatTextEntry::DEFLECT:
snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect");
@ -10570,7 +10573,7 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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
color = ImVec4(1.0f, 0.85f, 0.0f, alpha);
break;
}
case game::CombatTextEntry::DISPEL:
@ -10613,19 +10616,19 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
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
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) : // Outgoing glancing = muted yellow
ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red
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) : // Outgoing crushing = orange
ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red
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);
@ -10633,37 +10636,103 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
break;
}
// Outgoing → right side (near target), incoming → center-left (near player)
int& idx = outgoing ? outIdx : inIdx;
float baseX = outgoing ? outgoingX : incomingX;
// --- 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<int>(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<game::Unit*>(entity.get());
if (unit) {
renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
havePos = true;
}
}
}
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<int>(alpha * 180));
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color);
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);
}
}
if (needsHudWindow) {
ImGui::End();
}
}
// ============================================================