mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
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:
parent
6aea48aea9
commit
63b4394e3e
3 changed files with 308 additions and 230 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue