Kelsidavis-WoWee/src/ui/game_screen.cpp

15285 lines
689 KiB
C++
Raw Normal View History

#include "ui/game_screen.hpp"
#include "rendering/character_preview.hpp"
#include "rendering/vk_context.hpp"
#include "core/application.hpp"
#include "core/coordinates.hpp"
#include "core/spawn_presets.hpp"
#include "core/input.hpp"
#include "rendering/renderer.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/terrain_manager.hpp"
#include "rendering/minimap.hpp"
#include "rendering/world_map.hpp"
#include "rendering/character_renderer.hpp"
#include "rendering/camera.hpp"
#include "rendering/camera_controller.hpp"
#include "audio/audio_engine.hpp"
2026-02-05 16:17:04 -08:00
#include "audio/music_manager.hpp"
#include "game/zone_manager.hpp"
#include "audio/footstep_manager.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/mount_sound_manager.hpp"
#include "audio/npc_voice_manager.hpp"
#include "audio/ambient_sound_manager.hpp"
#include "audio/ui_sound_manager.hpp"
#include "audio/combat_sound_manager.hpp"
#include "audio/spell_sound_manager.hpp"
#include "audio/movement_sound_manager.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "pipeline/blp_loader.hpp"
#include "pipeline/dbc_layout.hpp"
#include "game/expansion_profile.hpp"
#include "core/logger.hpp"
#include <imgui.h>
2026-02-14 22:00:26 -08:00
#include <imgui_internal.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <cctype>
#include <chrono>
#include <ctime>
#include <unordered_set>
namespace {
// Build a WoW-format item link string for chat insertion.
// Format: |cff<qualHex>|Hitem:<itemId>:0:0:0:0:0:0:0:0|h[<name>]|h|r
std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) {
static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"};
uint8_t qi = quality < 6 ? quality : 1;
char buf[512];
snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r",
kQualHex[qi], itemId, name.c_str());
return buf;
}
std::string trim(const std::string& s) {
size_t first = s.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return "";
size_t last = s.find_last_not_of(" \t\r\n");
return s.substr(first, last - first + 1);
}
std::string toLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return s;
}
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;
}
bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) {
glm::vec3 oc = ray.origin - center;
float b = glm::dot(oc, ray.direction);
float c = glm::dot(oc, oc) - radius * radius;
float discriminant = b * b - c;
if (discriminant < 0.0f) return false;
float t = -b - std::sqrt(discriminant);
if (t < 0.0f) t = -b + std::sqrt(discriminant);
if (t < 0.0f) return false;
tOut = t;
return true;
}
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 wowee { namespace ui {
GameScreen::GameScreen() {
loadSettings();
initChatTabs();
}
void GameScreen::initChatTabs() {
chatTabs_.clear();
// General tab: shows everything
chatTabs_.push_back({"General", 0xFFFFFFFF});
// Combat tab: system, loot, skills, achievements, and NPC speech/emotes
chatTabs_.push_back({"Combat", (1u << static_cast<uint8_t>(game::ChatType::SYSTEM)) |
(1u << static_cast<uint8_t>(game::ChatType::LOOT)) |
(1u << static_cast<uint8_t>(game::ChatType::SKILL)) |
(1u << static_cast<uint8_t>(game::ChatType::ACHIEVEMENT)) |
(1u << static_cast<uint8_t>(game::ChatType::GUILD_ACHIEVEMENT)) |
(1u << static_cast<uint8_t>(game::ChatType::MONSTER_SAY)) |
(1u << static_cast<uint8_t>(game::ChatType::MONSTER_YELL)) |
(1u << static_cast<uint8_t>(game::ChatType::MONSTER_EMOTE))});
// Whispers tab
chatTabs_.push_back({"Whispers", (1u << static_cast<uint8_t>(game::ChatType::WHISPER)) |
(1u << static_cast<uint8_t>(game::ChatType::WHISPER_INFORM))});
// Trade/LFG tab: channel messages
chatTabs_.push_back({"Trade/LFG", (1u << static_cast<uint8_t>(game::ChatType::CHANNEL))});
}
bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const {
if (tabIndex < 0 || tabIndex >= static_cast<int>(chatTabs_.size())) return true;
const auto& tab = chatTabs_[tabIndex];
if (tab.typeMask == 0xFFFFFFFF) return true; // General tab shows all
uint32_t typeBit = 1u << static_cast<uint8_t>(msg.type);
// For Trade/LFG tab, also filter by channel name
if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) {
const std::string& ch = msg.channelName;
if (ch.find("Trade") == std::string::npos &&
ch.find("General") == std::string::npos &&
ch.find("LookingForGroup") == std::string::npos &&
ch.find("Local") == std::string::npos) {
return false;
}
return true;
}
return (tab.typeMask & typeBit) != 0;
}
void GameScreen::render(game::GameHandler& gameHandler) {
// Set up chat bubble callback (once)
if (!chatBubbleCallbackSet_) {
gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) {
float duration = 8.0f + static_cast<float>(msg.size()) * 0.06f;
if (isYell) duration += 2.0f;
if (duration > 15.0f) duration = 15.0f;
// Replace existing bubble for same sender
for (auto& b : chatBubbles_) {
if (b.senderGuid == guid) {
b.message = msg;
b.timeRemaining = duration;
b.totalDuration = duration;
b.isYell = isYell;
return;
}
}
// Evict oldest if too many
if (chatBubbles_.size() >= 10) {
chatBubbles_.erase(chatBubbles_.begin());
}
chatBubbles_.push_back({guid, msg, duration, duration, isYell});
});
chatBubbleCallbackSet_ = true;
}
// Set up level-up callback (once)
if (!levelUpCallbackSet_) {
gameHandler.setLevelUpCallback([this](uint32_t newLevel) {
levelUpFlashAlpha_ = 1.0f;
levelUpDisplayLevel_ = newLevel;
triggerDing(newLevel);
});
levelUpCallbackSet_ = true;
}
// Set up achievement toast callback (once)
if (!achievementCallbackSet_) {
gameHandler.setAchievementEarnedCallback([this](uint32_t id, const std::string& name) {
triggerAchievementToast(id, name);
});
achievementCallbackSet_ = true;
}
// Set up UI error frame callback (once)
if (!uiErrorCallbackSet_) {
gameHandler.setUIErrorCallback([this](const std::string& msg) {
uiErrors_.push_back({msg, 0.0f});
if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin());
});
uiErrorCallbackSet_ = true;
}
// Set up reputation change toast callback (once)
if (!repChangeCallbackSet_) {
gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) {
repToasts_.push_back({name, delta, standing, 0.0f});
if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin());
});
repChangeCallbackSet_ = true;
}
// Apply UI transparency setting
float prevAlpha = ImGui::GetStyle().Alpha;
ImGui::GetStyle().Alpha = uiOpacity_;
// Sync minimap opacity with UI opacity
{
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setOpacity(uiOpacity_);
}
}
}
// Apply initial settings when renderer becomes available
if (!minimapSettingsApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimapRotate_ = false;
pendingMinimapRotate = false;
minimap->setRotateWithCamera(false);
minimap->setSquareShape(minimapSquare_);
minimapSettingsApplied_ = true;
}
if (auto* zm = renderer->getZoneManager()) {
zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(static_cast<float>(pendingGroundClutterDensity) / 100.0f);
}
// Restore mute state: save actual master volume first, then apply mute
if (soundMuted_) {
float actual = audio::AudioEngine::instance().getMasterVolume();
preMuteVolume_ = (actual > 0.0f) ? actual
: static_cast<float>(pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(0.0f);
}
}
}
// Apply saved volume settings once when audio managers first become available
if (!volumeSettingsApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer && renderer->getUiSoundManager()) {
float masterScale = soundMuted_ ? 0.0f : static_cast<float>(pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(masterScale);
if (auto* music = renderer->getMusicManager()) {
music->setVolume(pendingMusicVolume);
}
if (auto* ambient = renderer->getAmbientSoundManager()) {
ambient->setVolumeScale(pendingAmbientVolume / 100.0f);
}
if (auto* ui = renderer->getUiSoundManager()) {
ui->setVolumeScale(pendingUiVolume / 100.0f);
}
if (auto* combat = renderer->getCombatSoundManager()) {
combat->setVolumeScale(pendingCombatVolume / 100.0f);
}
if (auto* spell = renderer->getSpellSoundManager()) {
spell->setVolumeScale(pendingSpellVolume / 100.0f);
}
if (auto* movement = renderer->getMovementSoundManager()) {
movement->setVolumeScale(pendingMovementVolume / 100.0f);
}
if (auto* footstep = renderer->getFootstepManager()) {
footstep->setVolumeScale(pendingFootstepVolume / 100.0f);
}
if (auto* npcVoice = renderer->getNpcVoiceManager()) {
npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f);
}
if (auto* mount = renderer->getMountSoundManager()) {
mount->setVolumeScale(pendingMountVolume / 100.0f);
}
if (auto* activity = renderer->getActivitySoundManager()) {
activity->setVolumeScale(pendingActivityVolume / 100.0f);
}
volumeSettingsApplied_ = true;
}
}
// Apply saved MSAA setting once when renderer is available
if (!msaaSettingsApplied_ && pendingAntiAliasing > 0) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
static const VkSampleCountFlagBits aaSamples[] = {
VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT,
VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT
};
renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]);
msaaSettingsApplied_ = true;
}
} else {
msaaSettingsApplied_ = true;
}
// Apply saved water refraction setting once when renderer is available
if (!waterRefractionApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
renderer->setWaterRefractionEnabled(pendingWaterRefraction);
waterRefractionApplied_ = true;
}
}
// Apply saved normal mapping / POM settings once when WMO renderer is available
if (!normalMapSettingsApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping);
wr->setNormalMapStrength(pendingNormalMapStrength);
wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality);
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
cr->setNormalMapStrength(pendingNormalMapStrength);
cr->setPOMEnabled(pendingPOM);
cr->setPOMQuality(pendingPOMQuality);
}
normalMapSettingsApplied_ = true;
}
}
}
// Apply saved upscaling setting once when renderer is available
if (!fsrSettingsApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
static const float fsrScales[] = { 0.77f, 0.67f, 0.59f, 1.00f };
pendingFSRQuality = std::clamp(pendingFSRQuality, 0, 3);
renderer->setFSRQuality(fsrScales[pendingFSRQuality]);
renderer->setFSRSharpness(pendingFSRSharpness);
renderer->setFSR2DebugTuning(pendingFSR2JitterSign, pendingFSR2MotionVecScaleX, pendingFSR2MotionVecScaleY);
renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen);
// Safety fallback: persisted FSR2 can still hang on some systems during startup.
// Require explicit opt-in for startup FSR2; otherwise fall back to FSR1.
const bool allowStartupFsr2 = (std::getenv("WOWEE_ALLOW_STARTUP_FSR2") != nullptr);
int effectiveMode = pendingUpscalingMode;
if (effectiveMode == 2 && !allowStartupFsr2) {
static bool warnedStartupFsr2Fallback = false;
if (!warnedStartupFsr2Fallback) {
LOG_WARNING("Startup FSR2 is disabled by default for stability; falling back to FSR1. Set WOWEE_ALLOW_STARTUP_FSR2=1 to override.");
warnedStartupFsr2Fallback = true;
}
effectiveMode = 1;
pendingUpscalingMode = 1;
pendingFSR = true;
}
// If explicitly enabled, still defer FSR2 until fully in-world.
if (effectiveMode == 2 && gameHandler.getState() != game::WorldState::IN_WORLD) {
renderer->setFSREnabled(false);
renderer->setFSR2Enabled(false);
} else {
renderer->setFSREnabled(effectiveMode == 1);
renderer->setFSR2Enabled(effectiveMode == 2);
fsrSettingsApplied_ = true;
}
}
}
// Apply auto-loot setting to GameHandler every frame (cheap bool sync)
gameHandler.setAutoLoot(pendingAutoLoot);
// Sync chat auto-join settings to GameHandler
gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_;
gameHandler.chatAutoJoin.trade = chatAutoJoinTrade_;
gameHandler.chatAutoJoin.localDefense = chatAutoJoinLocalDefense_;
gameHandler.chatAutoJoin.lfg = chatAutoJoinLFG_;
gameHandler.chatAutoJoin.local = chatAutoJoinLocal_;
// Process targeting input before UI windows
processTargetInput(gameHandler);
// Player unit frame (top-left)
renderPlayerFrame(gameHandler);
// Pet frame (below player frame, only when player has an active pet)
if (gameHandler.hasPet()) {
renderPetFrame(gameHandler);
}
// Target frame (only when we have a target)
if (gameHandler.hasTarget()) {
renderTargetFrame(gameHandler);
}
// Focus target frame (only when we have a focus)
if (gameHandler.hasFocus()) {
renderFocusFrame(gameHandler);
}
// Render windows
if (showPlayerInfo) {
renderPlayerInfo(gameHandler);
}
if (showEntityWindow) {
renderEntityList(gameHandler);
}
if (showChatWindow) {
renderChatWindow(gameHandler);
}
// ---- New UI elements ----
renderActionBar(gameHandler);
renderBagBar(gameHandler);
renderXpBar(gameHandler);
renderCastBar(gameHandler);
renderMirrorTimers(gameHandler);
renderQuestObjectiveTracker(gameHandler);
renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_
renderBattlegroundScore(gameHandler);
renderRaidWarningOverlay(gameHandler);
renderCombatText(gameHandler);
renderDPSMeter(gameHandler);
renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime);
renderRepToasts(ImGui::GetIO().DeltaTime);
if (showRaidFrames_) {
renderPartyFrames(gameHandler);
}
renderBossFrames(gameHandler);
renderGroupInvitePopup(gameHandler);
renderDuelRequestPopup(gameHandler);
renderLootRollPopup(gameHandler);
renderTradeRequestPopup(gameHandler);
renderTradeWindow(gameHandler);
renderSummonRequestPopup(gameHandler);
renderSharedQuestPopup(gameHandler);
renderItemTextWindow(gameHandler);
renderGuildInvitePopup(gameHandler);
renderReadyCheckPopup(gameHandler);
renderBgInvitePopup(gameHandler);
renderLfgProposalPopup(gameHandler);
renderGuildRoster(gameHandler);
renderSocialFrame(gameHandler);
renderBuffBar(gameHandler);
renderLootWindow(gameHandler);
renderGossipWindow(gameHandler);
renderQuestDetailsWindow(gameHandler);
renderQuestRequestItemsWindow(gameHandler);
renderQuestOfferRewardWindow(gameHandler);
renderVendorWindow(gameHandler);
renderTrainerWindow(gameHandler);
renderTaxiWindow(gameHandler);
renderMailWindow(gameHandler);
renderMailComposeWindow(gameHandler);
renderBankWindow(gameHandler);
renderGuildBankWindow(gameHandler);
renderAuctionHouseWindow(gameHandler);
renderDungeonFinderWindow(gameHandler);
renderInstanceLockouts(gameHandler);
renderAchievementWindow(gameHandler);
renderGmTicketWindow(gameHandler);
renderInspectWindow(gameHandler);
renderThreatWindow(gameHandler);
renderObjectiveTracker(gameHandler);
// renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now
if (showMinimap_) {
renderMinimapMarkers(gameHandler);
}
renderDeathScreen(gameHandler);
renderReclaimCorpseButton(gameHandler);
renderResurrectDialog(gameHandler);
renderTalentWipeConfirmDialog(gameHandler);
renderChatBubbles(gameHandler);
renderEscapeMenu();
renderSettingsWindow();
renderDingEffect();
renderAchievementToast();
renderZoneText();
// World map (M key toggle handled inside)
renderWorldMap(gameHandler);
// Quest Log (L key toggle handled inside)
questLogScreen.render(gameHandler, inventoryScreen);
// Spellbook (P key toggle handled inside)
spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager());
// Insert spell link into chat if player shift-clicked a spellbook entry
{
std::string pendingSpellLink = spellbookScreen.getAndClearPendingChatLink();
if (!pendingSpellLink.empty()) {
size_t curLen = strlen(chatInputBuffer);
if (curLen + pendingSpellLink.size() + 1 < sizeof(chatInputBuffer)) {
strncat(chatInputBuffer, pendingSpellLink.c_str(), sizeof(chatInputBuffer) - curLen - 1);
chatInputMoveCursorToEnd = true;
refocusChatInput = true;
}
}
}
// Talents (N key toggle handled inside)
talentScreen.render(gameHandler);
// Set up inventory screen asset manager + player appearance (re-init on character switch)
{
uint64_t activeGuid = gameHandler.getActiveCharacterGuid();
if (activeGuid != 0 && activeGuid != inventoryScreenCharGuid_) {
auto* am = core::Application::getInstance().getAssetManager();
if (am) {
inventoryScreen.setAssetManager(am);
const auto* ch = gameHandler.getActiveCharacter();
if (ch) {
uint8_t skin = ch->appearanceBytes & 0xFF;
uint8_t face = (ch->appearanceBytes >> 8) & 0xFF;
uint8_t hairStyle = (ch->appearanceBytes >> 16) & 0xFF;
uint8_t hairColor = (ch->appearanceBytes >> 24) & 0xFF;
inventoryScreen.setPlayerAppearance(
ch->race, ch->gender, skin, face,
hairStyle, hairColor, ch->facialFeatures);
inventoryScreenCharGuid_ = activeGuid;
}
}
}
}
// Set vendor mode before rendering inventory
inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler);
// Auto-open bags once when vendor window first opens
if (gameHandler.isVendorWindowOpen()) {
if (!vendorBagsOpened_) {
vendorBagsOpened_ = true;
if (inventoryScreen.isSeparateBags()) {
inventoryScreen.openAllBags();
} else if (!inventoryScreen.isOpen()) {
inventoryScreen.setOpen(true);
}
}
} else {
vendorBagsOpened_ = false;
}
// Bags (B key toggle handled inside)
inventoryScreen.setGameHandler(&gameHandler);
inventoryScreen.render(gameHandler.getInventory(), gameHandler.getMoneyCopper());
// Character screen (C key toggle handled inside render())
inventoryScreen.renderCharacterScreen(gameHandler);
// Insert item link into chat if player shift-clicked any inventory/equipment slot
{
std::string pendingLink = inventoryScreen.getAndClearPendingChatLink();
if (!pendingLink.empty()) {
size_t curLen = strlen(chatInputBuffer);
if (curLen + pendingLink.size() + 1 < sizeof(chatInputBuffer)) {
strncat(chatInputBuffer, pendingLink.c_str(), sizeof(chatInputBuffer) - curLen - 1);
chatInputMoveCursorToEnd = true;
refocusChatInput = true;
}
}
}
if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) {
updateCharacterGeosets(gameHandler.getInventory());
updateCharacterTextures(gameHandler.getInventory());
core::Application::getInstance().loadEquippedWeapons();
inventoryScreen.markPreviewDirty();
// Update renderer weapon type for animation selection
auto* r = core::Application::getInstance().getRenderer();
if (r) {
const auto& mh = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND);
r->setEquippedWeaponType(mh.empty() ? 0 : mh.item.inventoryType);
}
}
// Update renderer face-target position and selection circle
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
renderer->setInCombat(gameHandler.isInCombat());
static glm::vec3 targetGLPos;
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
// Prefer the renderer's actual instance position so the selection
// circle tracks the rendered model (not a parallel entity-space
// interpolator that can drift from the visual position).
glm::vec3 instPos;
if (core::Application::getInstance().getRenderPositionForGuid(target->getGuid(), instPos)) {
targetGLPos = instPos;
// Override Z with foot position to sit the circle on the ground.
float footZ = 0.0f;
if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) {
targetGLPos.z = footZ;
}
} else {
// Fallback: entity game-logic position (no CharacterRenderer instance yet)
targetGLPos = core::coords::canonicalToRender(
glm::vec3(target->getX(), target->getY(), target->getZ()));
float footZ = 0.0f;
if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) {
targetGLPos.z = footZ;
}
}
renderer->setTargetPosition(&targetGLPos);
// Selection circle color: WoW-canonical level-based colors
bool showSelectionCircle = false;
glm::vec3 circleColor(1.0f, 1.0f, 0.3f); // default yellow
float circleRadius = 1.5f;
{
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
if (core::Application::getInstance().getRenderBoundsForGuid(target->getGuid(), boundsCenter, boundsRadius)) {
float r = boundsRadius * 1.1f;
circleRadius = std::min(std::max(r, 0.8f), 8.0f);
}
}
if (target->getType() == game::ObjectType::UNIT) {
showSelectionCircle = true;
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
circleColor = glm::vec3(0.5f, 0.5f, 0.5f); // gray (dead)
} else if (unit->isHostile() || gameHandler.isAggressiveTowardPlayer(target->getGuid())) {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = unit->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0) {
circleColor = glm::vec3(0.6f, 0.6f, 0.6f); // grey
} else if (diff >= 10) {
circleColor = glm::vec3(1.0f, 0.1f, 0.1f); // red
} else if (diff >= 5) {
circleColor = glm::vec3(1.0f, 0.5f, 0.1f); // orange
} else if (diff >= -2) {
circleColor = glm::vec3(1.0f, 1.0f, 0.1f); // yellow
} else {
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green
}
} else {
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (friendly)
}
} else if (target->getType() == game::ObjectType::PLAYER) {
showSelectionCircle = true;
circleColor = glm::vec3(0.3f, 1.0f, 0.3f); // green (player)
}
if (showSelectionCircle) {
renderer->setSelectionCircle(targetGLPos, circleRadius, circleColor);
} else {
renderer->clearSelectionCircle();
}
} else {
renderer->setTargetPosition(nullptr);
renderer->clearSelectionCircle();
}
} else {
renderer->setTargetPosition(nullptr);
renderer->clearSelectionCircle();
}
}
// Screen edge damage flash — red vignette that fires on HP decrease
{
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
uint32_t currentHp = 0;
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER ||
playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0)
currentHp = unit->getHealth();
}
// Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized)
if (damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0)
damageFlashAlpha_ = 1.0f;
lastPlayerHp_ = currentHp;
// Fade out over ~0.5 seconds
if (damageFlashAlpha_ > 0.0f) {
damageFlashAlpha_ -= ImGui::GetIO().DeltaTime * 2.0f;
if (damageFlashAlpha_ < 0.0f) damageFlashAlpha_ = 0.0f;
// Draw four red gradient rectangles along each screen edge (vignette style)
ImDrawList* fg = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
const float W = io.DisplaySize.x;
const float H = io.DisplaySize.y;
const int alpha = static_cast<int>(damageFlashAlpha_ * 100.0f);
const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha);
const ImU32 fadeCol = IM_COL32(200, 0, 0, 0);
const float thickness = std::min(W, H) * 0.12f;
// Top
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness),
edgeCol, edgeCol, fadeCol, fadeCol);
// Bottom
fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H),
fadeCol, fadeCol, edgeCol, edgeCol);
// Left
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H),
edgeCol, fadeCol, fadeCol, edgeCol);
// Right
fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H),
fadeCol, edgeCol, edgeCol, fadeCol);
}
}
// Level-up golden burst overlay
if (levelUpFlashAlpha_ > 0.0f) {
levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second
if (levelUpFlashAlpha_ < 0.0f) levelUpFlashAlpha_ = 0.0f;
ImDrawList* fg = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
const float W = io.DisplaySize.x;
const float H = io.DisplaySize.y;
const int alpha = static_cast<int>(levelUpFlashAlpha_ * 160.0f);
const ImU32 goldEdge = IM_COL32(255, 210, 50, alpha);
const ImU32 goldFade = IM_COL32(255, 210, 50, 0);
const float thickness = std::min(W, H) * 0.18f;
// Four golden gradient edges
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness),
goldEdge, goldEdge, goldFade, goldFade);
fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H),
goldFade, goldFade, goldEdge, goldEdge);
fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H),
goldEdge, goldFade, goldFade, goldEdge);
fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H),
goldFade, goldEdge, goldEdge, goldFade);
// "Level X!" text in the center during the first half of the animation
if (levelUpFlashAlpha_ > 0.5f && levelUpDisplayLevel_ > 0) {
char lvlText[32];
snprintf(lvlText, sizeof(lvlText), "Level %u!", levelUpDisplayLevel_);
ImVec2 ts = ImGui::CalcTextSize(lvlText);
float tx = (W - ts.x) * 0.5f;
float ty = H * 0.35f;
// Large shadow + bright gold text
fg->AddText(nullptr, 28.0f, ImVec2(tx + 2, ty + 2), IM_COL32(0, 0, 0, alpha), lvlText);
fg->AddText(nullptr, 28.0f, ImVec2(tx, ty), IM_COL32(255, 230, 80, alpha), lvlText);
}
}
// Restore previous alpha
ImGui::GetStyle().Alpha = prevAlpha;
}
void GameScreen::renderPlayerInfo(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(350, 250), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 30), ImGuiCond_FirstUseEver);
ImGui::Begin("Player Info", &showPlayerInfo);
const auto& movement = gameHandler.getMovementInfo();
ImGui::Text("Position & Movement");
ImGui::Separator();
ImGui::Spacing();
// Position
ImGui::Text("Position:");
ImGui::Indent();
ImGui::Text("X: %.2f", movement.x);
ImGui::Text("Y: %.2f", movement.y);
ImGui::Text("Z: %.2f", movement.z);
ImGui::Text("Orientation: %.2f rad (%.1f deg)", movement.orientation, movement.orientation * 180.0f / 3.14159f);
ImGui::Unindent();
ImGui::Spacing();
// Movement flags
ImGui::Text("Movement Flags: 0x%08X", movement.flags);
ImGui::Text("Time: %u ms", movement.time);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Connection state
ImGui::Text("Connection State:");
ImGui::Indent();
auto state = gameHandler.getState();
switch (state) {
case game::WorldState::IN_WORLD:
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "In World");
break;
case game::WorldState::AUTHENTICATED:
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Authenticated");
break;
case game::WorldState::ENTERING_WORLD:
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Entering World...");
break;
default:
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "State: %d", static_cast<int>(state));
break;
}
ImGui::Unindent();
ImGui::End();
}
void GameScreen::renderEntityList(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 290), ImGuiCond_FirstUseEver);
ImGui::Begin("Entities", &showEntityWindow);
const auto& entityManager = gameHandler.getEntityManager();
const auto& entities = entityManager.getEntities();
ImGui::Text("Entities in View: %zu", entities.size());
ImGui::Separator();
ImGui::Spacing();
if (entities.empty()) {
ImGui::TextDisabled("No entities in view");
} else {
// Entity table
if (ImGui::BeginTable("EntitiesTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("GUID", ImGuiTableColumnFlags_WidthFixed, 140.0f);
ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 100.0f);
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Position", ImGuiTableColumnFlags_WidthFixed, 150.0f);
ImGui::TableSetupColumn("Distance", ImGuiTableColumnFlags_WidthFixed, 80.0f);
ImGui::TableHeadersRow();
const auto& playerMovement = gameHandler.getMovementInfo();
float playerX = playerMovement.x;
float playerY = playerMovement.y;
float playerZ = playerMovement.z;
for (const auto& [guid, entity] : entities) {
ImGui::TableNextRow();
// GUID
ImGui::TableSetColumnIndex(0);
char guidStr[24];
snprintf(guidStr, sizeof(guidStr), "0x%016llX", (unsigned long long)guid);
ImGui::Text("%s", guidStr);
// Type
ImGui::TableSetColumnIndex(1);
switch (entity->getType()) {
case game::ObjectType::PLAYER:
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Player");
break;
case game::ObjectType::UNIT:
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Unit");
break;
case game::ObjectType::GAMEOBJECT:
ImGui::TextColored(ImVec4(0.3f, 0.8f, 1.0f, 1.0f), "GameObject");
break;
default:
ImGui::Text("Object");
break;
}
// Name (for players and units)
ImGui::TableSetColumnIndex(2);
if (entity->getType() == game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<game::Player>(entity);
ImGui::Text("%s", player->getName().c_str());
} else if (entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (!unit->getName().empty()) {
ImGui::Text("%s", unit->getName().c_str());
} else {
ImGui::TextDisabled("--");
}
} else {
ImGui::TextDisabled("--");
}
// Position
ImGui::TableSetColumnIndex(3);
ImGui::Text("%.1f, %.1f, %.1f", entity->getX(), entity->getY(), entity->getZ());
// Distance from player
ImGui::TableSetColumnIndex(4);
float dx = entity->getX() - playerX;
float dy = entity->getY() - playerY;
float dz = entity->getZ() - playerZ;
float distance = std::sqrt(dx*dx + dy*dy + dz*dz);
ImGui::Text("%.1f", distance);
}
ImGui::EndTable();
}
}
ImGui::End();
}
void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float chatW = std::min(500.0f, screenW * 0.4f);
float chatH = 220.0f;
float chatX = 8.0f;
float chatY = screenH - chatH - 80.0f; // Above action bar
if (chatWindowLocked) {
// Always recompute position from current window size when locked
chatWindowPos_ = ImVec2(chatX, chatY);
ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_Always);
ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_Always);
} else {
if (!chatWindowPosInit_) {
chatWindowPos_ = ImVec2(chatX, chatY);
chatWindowPosInit_ = true;
}
ImGui::SetNextWindowSize(ImVec2(chatW, chatH), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(chatWindowPos_, ImGuiCond_FirstUseEver);
}
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize;
if (chatWindowLocked) {
flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar;
}
ImGui::Begin("Chat", nullptr, flags);
if (!chatWindowLocked) {
chatWindowPos_ = ImGui::GetWindowPos();
}
// Chat tabs
if (ImGui::BeginTabBar("ChatTabs")) {
for (int i = 0; i < static_cast<int>(chatTabs_.size()); ++i) {
if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) {
activeChatTab_ = i;
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
// Chat history
const auto& chatHistory = gameHandler.getChatHistory();
// Apply chat font size scaling
float chatScale = chatFontSize_ == 0 ? 0.85f : (chatFontSize_ == 2 ? 1.2f : 1.0f);
ImGui::SetWindowFontScale(chatScale);
ImGui::BeginChild("ChatHistory", ImVec2(0, -70), true, ImGuiWindowFlags_HorizontalScrollbar);
bool chatHistoryHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem);
// Helper: parse WoW color code |cAARRGGBB → ImVec4
auto parseWowColor = [](const std::string& text, size_t pos) -> ImVec4 {
// |cAARRGGBB (10 chars total: |c + 8 hex)
if (pos + 10 > text.size()) return ImVec4(1, 1, 1, 1);
auto hexByte = [&](size_t offset) -> float {
const char* s = text.c_str() + pos + offset;
char buf[3] = {s[0], s[1], '\0'};
return static_cast<float>(strtol(buf, nullptr, 16)) / 255.0f;
};
float a = hexByte(2);
float r = hexByte(4);
float g = hexByte(6);
float b = hexByte(8);
return ImVec4(r, g, b, a);
};
// Helper: render an item tooltip from ItemQueryResponseData
auto renderItemLinkTooltip = [&](uint32_t itemEntry) {
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
ImVec4 qColor(1, 1, 1, 1);
switch (info->quality) {
case 0: qColor = ImVec4(0.62f, 0.62f, 0.62f, 1.0f); break; // Poor
case 1: qColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common
case 2: qColor = ImVec4(0.12f, 1.0f, 0.0f, 1.0f); break; // Uncommon
case 3: qColor = ImVec4(0.0f, 0.44f, 0.87f, 1.0f); break; // Rare
case 4: qColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic
case 5: qColor = ImVec4(1.0f, 0.50f, 0.0f, 1.0f); break; // Legendary
}
ImGui::TextColored(qColor, "%s", info->name.c_str());
// Slot type
if (info->inventoryType > 0) {
const char* slotName = "";
switch (info->inventoryType) {
case 1: slotName = "Head"; break;
case 2: slotName = "Neck"; break;
case 3: slotName = "Shoulder"; break;
case 4: slotName = "Shirt"; break;
case 5: slotName = "Chest"; break;
case 6: slotName = "Waist"; break;
case 7: slotName = "Legs"; break;
case 8: slotName = "Feet"; break;
case 9: slotName = "Wrist"; break;
case 10: slotName = "Hands"; break;
case 11: slotName = "Finger"; break;
case 12: slotName = "Trinket"; break;
case 13: slotName = "One-Hand"; break;
case 14: slotName = "Shield"; break;
case 15: slotName = "Ranged"; break;
case 16: slotName = "Back"; break;
case 17: slotName = "Two-Hand"; break;
case 18: slotName = "Bag"; break;
case 19: slotName = "Tabard"; break;
case 20: slotName = "Robe"; break;
case 21: slotName = "Main Hand"; break;
case 22: slotName = "Off Hand"; break;
case 23: slotName = "Held In Off-hand"; break;
case 25: slotName = "Thrown"; break;
case 26: slotName = "Ranged"; break;
}
if (slotName[0]) {
if (!info->subclassName.empty())
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info->subclassName.c_str());
else
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%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);
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;
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 (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::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
}
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;
ImGui::Text("%.1f DPS", 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());
}
}
}
ImGui::EndTooltip();
};
// Helper: render text with clickable URLs and WoW item links
auto renderTextWithLinks = [&](const std::string& text, const ImVec4& color) {
size_t pos = 0;
while (pos < text.size()) {
// Find next special element: URL or WoW link
size_t urlStart = text.find("https://", pos);
// Find next WoW item link: |cXXXXXXXX|Hitem:ENTRY:...|h[Name]|h|r
size_t linkStart = text.find("|c", pos);
// Also handle bare |Hitem: without color prefix
size_t bareLinkStart = text.find("|Hitem:", pos);
// Determine which comes first
size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart});
if (nextSpecial == std::string::npos) {
// No more special elements, render remaining text
std::string remaining = text.substr(pos);
if (!remaining.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", remaining.c_str());
ImGui::PopStyleColor();
}
break;
}
// Render plain text before special element
if (nextSpecial > pos) {
std::string before = text.substr(pos, nextSpecial - pos);
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", before.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
// Handle WoW item link
if (nextSpecial == linkStart || nextSpecial == bareLinkStart) {
ImVec4 linkColor = color;
size_t hStart = std::string::npos;
if (nextSpecial == linkStart && text.size() > linkStart + 10) {
// Parse |cAARRGGBB color
linkColor = parseWowColor(text, linkStart);
hStart = text.find("|Hitem:", linkStart + 10);
} else if (nextSpecial == bareLinkStart) {
hStart = bareLinkStart;
}
if (hStart != std::string::npos) {
// Parse item entry: |Hitem:ENTRY:...
size_t entryStart = hStart + 7; // skip "|Hitem:"
size_t entryEnd = text.find(':', entryStart);
uint32_t itemEntry = 0;
if (entryEnd != std::string::npos) {
itemEntry = static_cast<uint32_t>(strtoul(
text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10));
}
// Find display name: |h[Name]|h
size_t nameTagStart = text.find("|h[", hStart);
size_t nameTagEnd = (nameTagStart != std::string::npos)
? text.find("]|h", nameTagStart + 3) : std::string::npos;
std::string itemName = "Unknown Item";
if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) {
itemName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3);
}
// Find end of entire link sequence (|r or after ]|h)
size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + 7;
size_t resetPos = text.find("|r", linkEnd);
if (resetPos != std::string::npos && resetPos <= linkEnd + 2) {
linkEnd = resetPos + 2;
}
// Ensure item info is cached (trigger query if needed)
if (itemEntry > 0) {
gameHandler.ensureItemInfo(itemEntry);
}
// Show small icon before item link if available
if (itemEntry > 0) {
const auto* chatInfo = gameHandler.getItemInfo(itemEntry);
if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) {
VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId);
if (chatIcon) {
ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12));
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
renderItemLinkTooltip(itemEntry);
}
ImGui::SameLine(0, 2);
}
}
}
// Render bracketed item name in quality color
std::string display = "[" + itemName + "]";
ImGui::PushStyleColor(ImGuiCol_Text, linkColor);
ImGui::TextWrapped("%s", display.c_str());
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (itemEntry > 0) {
renderItemLinkTooltip(itemEntry);
}
}
// Shift-click: insert item link into chat input
if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) {
std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial);
size_t curLen = strlen(chatInputBuffer);
if (curLen + linkText.size() + 1 < sizeof(chatInputBuffer)) {
strncat(chatInputBuffer, linkText.c_str(), sizeof(chatInputBuffer) - curLen - 1);
chatInputMoveCursorToEnd = true;
}
}
pos = linkEnd;
continue;
}
// Not an item link — treat as colored text: |cAARRGGBB...text...|r
if (nextSpecial == linkStart && text.size() > linkStart + 10) {
ImVec4 cColor = parseWowColor(text, linkStart);
size_t textStart = linkStart + 10; // after |cAARRGGBB
size_t resetPos2 = text.find("|r", textStart);
std::string coloredText;
if (resetPos2 != std::string::npos) {
coloredText = text.substr(textStart, resetPos2 - textStart);
pos = resetPos2 + 2; // skip |r
} else {
coloredText = text.substr(textStart);
pos = text.size();
}
// Strip any remaining WoW markup from the colored segment
// (e.g. |H...|h pairs that aren't item links)
std::string clean;
for (size_t i = 0; i < coloredText.size(); i++) {
if (coloredText[i] == '|' && i + 1 < coloredText.size()) {
char next = coloredText[i + 1];
if (next == 'H') {
// Skip |H...|h
size_t hEnd = coloredText.find("|h", i + 2);
if (hEnd != std::string::npos) { i = hEnd + 1; continue; }
} else if (next == 'h') {
i += 1; continue; // skip |h
} else if (next == 'r') {
i += 1; continue; // skip |r
}
}
clean += coloredText[i];
}
if (!clean.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, cColor);
ImGui::TextWrapped("%s", clean.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
} else {
// Bare |c without enough chars for color — render literally
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("|c");
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
pos = nextSpecial + 2;
}
continue;
}
// Handle URL
if (nextSpecial == urlStart) {
size_t urlEnd = text.find_first_of(" \t\n\r", urlStart);
if (urlEnd == std::string::npos) urlEnd = text.size();
std::string url = text.substr(urlStart, urlEnd - urlStart);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f));
ImGui::TextWrapped("%s", url.c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("Open: %s", url.c_str());
}
if (ImGui::IsItemClicked()) {
std::string cmd = "xdg-open '" + url + "' &";
[[maybe_unused]] int result = system(cmd.c_str());
}
ImGui::PopStyleColor();
pos = urlEnd;
continue;
}
}
};
int chatMsgIdx = 0;
for (const auto& msg : chatHistory) {
if (!shouldShowMessage(msg, activeChatTab_)) continue;
std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler);
// Resolve sender name at render time in case it wasn't available at parse time.
// This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns.
const std::string& resolvedSenderName = [&]() -> const std::string& {
if (!msg.senderName.empty()) return msg.senderName;
if (msg.senderGuid == 0) return msg.senderName;
const std::string& cached = gameHandler.lookupName(msg.senderGuid);
if (!cached.empty()) return cached;
return msg.senderName;
}();
ImVec4 color = getChatTypeColor(msg.type);
// Optional timestamp prefix
std::string tsPrefix;
if (chatShowTimestamps_) {
auto tt = std::chrono::system_clock::to_time_t(msg.timestamp);
std::tm tm{};
#ifdef _WIN32
localtime_s(&tm, &tt);
#else
localtime_r(&tt, &tm);
#endif
char tsBuf[16];
snprintf(tsBuf, sizeof(tsBuf), "[%02d:%02d] ", tm.tm_hour, tm.tm_min);
tsPrefix = tsBuf;
}
// Build chat tag prefix: <GM>, <AFK>, <DND> from chatTag bitmask
std::string tagPrefix;
if (msg.chatTag & 0x04) tagPrefix = "<GM> ";
else if (msg.chatTag & 0x01) tagPrefix = "<AFK> ";
else if (msg.chatTag & 0x02) tagPrefix = "<DND> ";
// Build full message string for this entry
std::string fullMsg;
if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) {
fullMsg = tsPrefix + processedMessage;
} else if (!resolvedSenderName.empty()) {
if (msg.type == game::ChatType::SAY ||
msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) {
fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage;
} else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) {
fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage;
} else if (msg.type == game::ChatType::WHISPER ||
msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) {
fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage;
} else if (msg.type == game::ChatType::WHISPER_INFORM) {
const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName;
fullMsg = tsPrefix + "To " + target + ": " + processedMessage;
} else if (msg.type == game::ChatType::EMOTE ||
msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) {
fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage;
} else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) {
int chIdx = gameHandler.getChannelIndex(msg.channelName);
std::string chDisplay = chIdx > 0
? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]"
: "[" + msg.channelName + "]";
fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage;
} else {
fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage;
}
} else {
bool isGroupType =
msg.type == game::ChatType::PARTY ||
msg.type == game::ChatType::GUILD ||
msg.type == game::ChatType::OFFICER ||
msg.type == game::ChatType::RAID ||
msg.type == game::ChatType::RAID_LEADER ||
msg.type == game::ChatType::RAID_WARNING ||
msg.type == game::ChatType::BATTLEGROUND ||
msg.type == game::ChatType::BATTLEGROUND_LEADER;
if (isGroupType) {
fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage;
} else {
fullMsg = tsPrefix + processedMessage;
}
}
// Render message in a group so we can attach a right-click context menu
ImGui::PushID(chatMsgIdx++);
ImGui::BeginGroup();
renderTextWithLinks(fullMsg, color);
ImGui::EndGroup();
// Right-click context menu (only for player messages with a sender)
bool isPlayerMsg = !resolvedSenderName.empty() &&
msg.type != game::ChatType::SYSTEM &&
msg.type != game::ChatType::TEXT_EMOTE &&
msg.type != game::ChatType::MONSTER_SAY &&
msg.type != game::ChatType::MONSTER_YELL &&
msg.type != game::ChatType::MONSTER_WHISPER &&
msg.type != game::ChatType::MONSTER_EMOTE &&
msg.type != game::ChatType::MONSTER_PARTY &&
msg.type != game::ChatType::RAID_BOSS_WHISPER &&
msg.type != game::ChatType::RAID_BOSS_EMOTE;
if (isPlayerMsg && ImGui::BeginPopupContextItem("ChatMsgCtx")) {
ImGui::TextDisabled("%s", resolvedSenderName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4; // WHISPER
strncpy(whisperTargetBuffer, resolvedSenderName.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Invite to Group")) {
gameHandler.inviteToGroup(resolvedSenderName);
}
if (ImGui::MenuItem("Add Friend")) {
gameHandler.addFriend(resolvedSenderName);
}
if (ImGui::MenuItem("Ignore")) {
gameHandler.addIgnore(resolvedSenderName);
}
ImGui::EndPopup();
}
ImGui::PopID();
}
// Auto-scroll to bottom; track whether user has scrolled up
{
float scrollY = ImGui::GetScrollY();
float scrollMaxY = ImGui::GetScrollMaxY();
bool atBottom = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 2.0f);
if (atBottom || chatForceScrollToBottom_) {
ImGui::SetScrollHereY(1.0f);
chatScrolledUp_ = false;
chatForceScrollToBottom_ = false;
} else {
chatScrolledUp_ = true;
}
}
ImGui::EndChild();
// Reset font scale after chat history
ImGui::SetWindowFontScale(1.0f);
// "Jump to bottom" indicator when scrolled up
if (chatScrolledUp_) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.35f, 0.7f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f));
if (ImGui::SmallButton(" v New messages ")) {
chatForceScrollToBottom_ = true;
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Lock toggle
ImGui::Checkbox("Lock", &chatWindowLocked);
ImGui::SameLine();
ImGui::TextDisabled(chatWindowLocked ? "(locked)" : "(movable)");
// Chat input
ImGui::Text("Type:");
ImGui::SameLine();
ImGui::SetNextItemWidth(100);
const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE" };
ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 10);
// Auto-fill whisper target when switching to WHISPER mode
if (selectedChatType == 4 && lastChatType != 4) {
// Just switched to WHISPER mode
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target && target->getType() == game::ObjectType::PLAYER) {
auto player = std::static_pointer_cast<game::Player>(target);
if (!player->getName().empty()) {
strncpy(whisperTargetBuffer, player->getName().c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
}
}
}
}
lastChatType = selectedChatType;
// Show whisper target field if WHISPER is selected
if (selectedChatType == 4) {
ImGui::SameLine();
ImGui::Text("To:");
ImGui::SameLine();
ImGui::SetNextItemWidth(120);
ImGui::InputText("##WhisperTarget", whisperTargetBuffer, sizeof(whisperTargetBuffer));
}
ImGui::SameLine();
ImGui::Text("Message:");
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
if (refocusChatInput) {
ImGui::SetKeyboardFocusHere();
refocusChatInput = false;
}
// Detect chat channel prefix as user types and switch the dropdown
{
std::string buf(chatInputBuffer);
if (buf.size() >= 2 && buf[0] == '/') {
// Find the command and check if there's a space after it
size_t sp = buf.find(' ', 1);
if (sp != std::string::npos) {
std::string cmd = buf.substr(1, sp - 1);
for (char& c : cmd) c = std::tolower(c);
int detected = -1;
if (cmd == "s" || cmd == "say") detected = 0;
else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1;
else if (cmd == "p" || cmd == "party") detected = 2;
else if (cmd == "g" || cmd == "guild") detected = 3;
else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4;
else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5;
else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6;
else if (cmd == "bg" || cmd == "battleground") detected = 7;
else if (cmd == "rw" || cmd == "raidwarning") detected = 8;
else if (cmd == "i" || cmd == "instance") detected = 9;
if (detected >= 0 && selectedChatType != detected) {
selectedChatType = detected;
// Strip the prefix, keep only the message part
std::string remaining = buf.substr(sp + 1);
// For whisper, first word after /w is the target
if (detected == 4) {
size_t msgStart = remaining.find(' ');
if (msgStart != std::string::npos) {
std::string wTarget = remaining.substr(0, msgStart);
strncpy(whisperTargetBuffer, wTarget.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
remaining = remaining.substr(msgStart + 1);
} else {
// Just the target name so far, no message yet
strncpy(whisperTargetBuffer, remaining.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
remaining = "";
}
}
strncpy(chatInputBuffer, remaining.c_str(), sizeof(chatInputBuffer) - 1);
chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0';
chatInputMoveCursorToEnd = true;
}
}
}
}
// Color the input text based on current chat type
ImVec4 inputColor;
switch (selectedChatType) {
case 1: inputColor = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); break; // YELL - red
case 2: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // PARTY - blue
case 3: inputColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; // GUILD - green
case 4: inputColor = ImVec4(1.0f, 0.5f, 1.0f, 1.0f); break; // WHISPER - pink
case 5: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // RAID - orange
case 6: inputColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; // OFFICER - green
case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange
case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange
case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue
default: inputColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // SAY - white
}
ImGui::PushStyleColor(ImGuiCol_Text, inputColor);
auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int {
auto* self = static_cast<GameScreen*>(data->UserData);
if (!self) return 0;
// Cursor-to-end after channel switch
if (self->chatInputMoveCursorToEnd) {
int len = static_cast<int>(std::strlen(data->Buf));
data->CursorPos = len;
data->SelectionStart = len;
data->SelectionEnd = len;
self->chatInputMoveCursorToEnd = false;
}
// Up/Down arrow: cycle through sent message history
if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
const int histSize = static_cast<int>(self->chatSentHistory_.size());
if (histSize == 0) return 0;
if (data->EventKey == ImGuiKey_UpArrow) {
// Go back in history
if (self->chatHistoryIdx_ == -1)
self->chatHistoryIdx_ = histSize - 1;
else if (self->chatHistoryIdx_ > 0)
--self->chatHistoryIdx_;
} else if (data->EventKey == ImGuiKey_DownArrow) {
if (self->chatHistoryIdx_ == -1) return 0;
++self->chatHistoryIdx_;
if (self->chatHistoryIdx_ >= histSize) {
self->chatHistoryIdx_ = -1;
data->DeleteChars(0, data->BufTextLen);
return 0;
}
}
if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) {
const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_];
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, entry.c_str());
}
}
return 0;
};
ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CallbackAlways |
ImGuiInputTextFlags_CallbackHistory;
if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) {
sendChatMessage(gameHandler);
2026-02-14 22:00:26 -08:00
// Close chat input on send so movement keys work immediately.
refocusChatInput = false;
ImGui::ClearActiveID();
}
ImGui::PopStyleColor();
if (ImGui::IsItemActive()) {
chatInputActive = true;
} else {
chatInputActive = false;
}
// Click in chat history area (received messages) → focus input.
{
if (chatHistoryHovered && ImGui::IsMouseClicked(0)) {
refocusChatInput = true;
}
}
ImGui::End();
}
void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
auto& io = ImGui::GetIO();
auto& input = core::Input::getInstance();
// Tab targeting (when keyboard not captured by UI)
if (!io.WantCaptureKeyboard) {
if (input.isKeyJustPressed(SDL_SCANCODE_TAB)) {
const auto& movement = gameHandler.getMovementInfo();
gameHandler.tabTarget(movement.x, movement.y, movement.z);
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) {
if (showSettingsWindow) {
// Close settings window if open
showSettingsWindow = false;
} else if (showEscapeMenu) {
showEscapeMenu = false;
showEscapeSettingsNotice = false;
} else if (gameHandler.isCasting()) {
gameHandler.cancelCast();
} else if (gameHandler.isLootWindowOpen()) {
gameHandler.closeLoot();
} else if (gameHandler.isGossipWindowOpen()) {
gameHandler.closeGossip();
} else {
showEscapeMenu = true;
}
}
// Toggle nameplates (customizable keybinding, default V)
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) {
inventoryScreen.toggle();
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) {
showNameplates_ = !showNameplates_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) {
showWorldMap_ = !showWorldMap_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) {
showMinimap_ = !showMinimap_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) {
showRaidFrames_ = !showRaidFrames_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) {
questLogScreen.toggle();
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) {
showAchievementWindow_ = !showAchievementWindow_;
}
// Action bar keys (1-9, 0, -, =)
static const SDL_Scancode actionBarKeys[] = {
SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4,
SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8,
SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS
};
const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT);
const auto& bar = gameHandler.getActionBar();
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
if (input.isKeyJustPressed(actionBarKeys[i])) {
int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i;
if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(bar[slotIdx].id, target);
} else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) {
gameHandler.useItemById(bar[slotIdx].id);
}
}
}
2026-02-05 16:14:11 -08:00
}
// Slash key: focus chat input — always works unless already typing in chat
if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) {
refocusChatInput = true;
chatInputBuffer[0] = '/';
chatInputBuffer[1] = '\0';
chatInputMoveCursorToEnd = true;
}
// Enter key: focus chat input (empty) — always works unless already typing
if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) {
refocusChatInput = true;
}
// Cursor affordance: show hand cursor over interactable game objects.
if (!io.WantCaptureMouse) {
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (camera && window) {
glm::vec2 mousePos = input.getMousePosition();
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH);
float closestT = 1e30f;
bool hoverInteractableGo = false;
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (entity->getType() != game::ObjectType::GAMEOBJECT) continue;
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
hitRadius = 2.5f;
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += 1.2f;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.8f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT) && hitT < closestT) {
closestT = hitT;
hoverInteractableGo = true;
}
}
if (hoverInteractableGo) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
}
}
// Left-click targeting: only on mouse-up if the mouse didn't drag (camera rotate)
// Record press position on mouse-down
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_LEFT) && !input.isMouseButtonPressed(SDL_BUTTON_RIGHT)) {
leftClickPressPos_ = input.getMousePosition();
leftClickWasPress_ = true;
}
// On mouse-up, check if it was a click (not a drag)
if (leftClickWasPress_ && input.isMouseButtonJustReleased(SDL_BUTTON_LEFT)) {
leftClickWasPress_ = false;
glm::vec2 releasePos = input.getMousePosition();
float dragDist = glm::length(releasePos - leftClickPressPos_);
constexpr float CLICK_THRESHOLD = 5.0f; // pixels
if (dragDist < CLICK_THRESHOLD) {
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (camera && window) {
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(leftClickPressPos_.x, leftClickPressPos_.y, screenW, screenH);
float closestT = 1e30f;
uint64_t closestGuid = 0;
float closestHostileUnitT = 1e30f;
uint64_t closestHostileUnitGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT &&
t != game::ObjectType::PLAYER &&
t != game::ObjectType::GAMEOBJECT) continue;
if (guid == myGuid) continue; // Don't target self
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
// Fallback hitbox based on entity type
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
// Critters have very low max health (< 100)
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
hitRadius = 2.5f;
heightOffset = 1.2f;
}
hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
bool hostileUnit = unit->isHostile() || gameHandler.isAggressiveTowardPlayer(guid);
if (hostileUnit && hitT < closestHostileUnitT) {
closestHostileUnitT = hitT;
closestHostileUnitGuid = guid;
}
}
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
}
}
}
// Prefer hostile monsters over nearby gameobjects/others when both are hittable.
if (closestHostileUnitGuid != 0) {
closestGuid = closestHostileUnitGuid;
}
if (closestGuid != 0) {
gameHandler.setTarget(closestGuid);
} else {
// Clicked empty space — deselect current target
gameHandler.clearTarget();
}
}
}
}
// Right-click: select NPC (if needed) then interact / loot / auto-attack
// Suppress when left button is held (both-button run)
if (!io.WantCaptureMouse && input.isMouseButtonJustPressed(SDL_BUTTON_RIGHT) && !input.isMouseButtonPressed(SDL_BUTTON_LEFT)) {
// If a gameobject is already targeted, prioritize interacting with that target
// instead of re-picking under cursor (which can hit nearby decorative GOs).
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target && target->getType() == game::ObjectType::GAMEOBJECT) {
gameHandler.setTarget(target->getGuid());
gameHandler.interactWithGameObject(target->getGuid());
return;
}
}
// If no target or right-clicking in world, try to pick one under cursor
{
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (camera && window) {
glm::vec2 mousePos = input.getMousePosition();
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH);
float closestT = 1e30f;
uint64_t closestGuid = 0;
game::ObjectType closestType = game::ObjectType::OBJECT;
float closestHostileUnitT = 1e30f;
uint64_t closestHostileUnitGuid = 0;
const uint64_t myGuid = gameHandler.getPlayerGuid();
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
auto t = entity->getType();
if (t != game::ObjectType::UNIT &&
t != game::ObjectType::PLAYER &&
t != game::ObjectType::GAMEOBJECT) continue;
if (guid == myGuid) continue;
glm::vec3 hitCenter;
float hitRadius = 0.0f;
bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius);
if (!hasBounds) {
float heightOffset = 1.5f;
hitRadius = 1.5f;
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (unit->getMaxHealth() > 0 && unit->getMaxHealth() < 100) {
hitRadius = 0.5f;
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
// For GOs with no renderer instance yet, use a tight fallback
// sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads)
// are not accidentally clicked during camera right-drag.
hitRadius = 1.2f;
heightOffset = 1.0f;
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
float hitT;
if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) {
if (t == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
bool hostileUnit = unit->isHostile() || gameHandler.isAggressiveTowardPlayer(guid);
if (hostileUnit && hitT < closestHostileUnitT) {
closestHostileUnitT = hitT;
closestHostileUnitGuid = guid;
}
}
if (hitT < closestT) {
closestT = hitT;
closestGuid = guid;
closestType = t;
}
}
}
// Prefer hostile monsters over nearby gameobjects/others when right-click picking.
if (closestHostileUnitGuid != 0) {
closestGuid = closestHostileUnitGuid;
closestType = game::ObjectType::UNIT;
}
if (closestGuid != 0) {
if (closestType == game::ObjectType::GAMEOBJECT) {
gameHandler.setTarget(closestGuid);
gameHandler.interactWithGameObject(closestGuid);
return;
}
gameHandler.setTarget(closestGuid);
}
}
}
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target) {
if (target->getType() == game::ObjectType::UNIT) {
// Check if unit is dead (health == 0) → loot, otherwise interact/attack
auto unit = std::static_pointer_cast<game::Unit>(target);
if (unit->getHealth() == 0 && unit->getMaxHealth() > 0) {
gameHandler.lootTarget(target->getGuid());
} else {
// Interact with service NPCs; otherwise treat non-interactable living units
// as attackable fallback (covers bad faction-template classification).
auto isSpiritNpc = [&]() -> bool {
constexpr uint32_t NPC_FLAG_SPIRIT_GUIDE = 0x00004000;
constexpr uint32_t NPC_FLAG_SPIRIT_HEALER = 0x00008000;
if (unit->getNpcFlags() & (NPC_FLAG_SPIRIT_GUIDE | NPC_FLAG_SPIRIT_HEALER)) {
return true;
}
std::string name = unit->getName();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
return (name.find("spirit healer") != std::string::npos) ||
(name.find("spirit guide") != std::string::npos);
};
bool allowSpiritInteract = (gameHandler.isPlayerDead() || gameHandler.isPlayerGhost()) && isSpiritNpc();
bool canInteractNpc = unit->isInteractable() || allowSpiritInteract;
bool shouldAttackByFallback = !canInteractNpc;
if (!unit->isHostile() && canInteractNpc) {
gameHandler.interactWithNpc(target->getGuid());
} else if (unit->isHostile() || shouldAttackByFallback) {
gameHandler.startAutoAttack(target->getGuid());
}
}
} else if (target->getType() == game::ObjectType::GAMEOBJECT) {
gameHandler.interactWithGameObject(target->getGuid());
} else if (target->getType() == game::ObjectType::PLAYER) {
// Right-click another player could start attack in PvP context
}
}
}
}
}
void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
bool isDead = gameHandler.isPlayerDead();
ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
const bool inCombatConfirmed = gameHandler.isInCombat();
const bool attackIntentOnly = gameHandler.hasAutoAttackIntent() && !inCombatConfirmed;
ImVec4 playerBorder = isDead
? ImVec4(0.5f, 0.5f, 0.5f, 1.0f)
: (inCombatConfirmed
? ImVec4(1.0f, 0.2f, 0.2f, 1.0f)
: (attackIntentOnly
? ImVec4(1.0f, 0.7f, 0.2f, 1.0f)
: ImVec4(0.4f, 0.4f, 0.4f, 1.0f)));
ImGui::PushStyleColor(ImGuiCol_Border, playerBorder);
if (ImGui::Begin("##PlayerFrame", nullptr, flags)) {
// Use selected character info if available, otherwise defaults
std::string playerName = "Adventurer";
uint32_t playerLevel = 1;
uint32_t playerHp = 100;
uint32_t playerMaxHp = 100;
const auto& characters = gameHandler.getCharacters();
uint64_t activeGuid = gameHandler.getActiveCharacterGuid();
const game::Character* activeChar = nullptr;
for (const auto& c : characters) {
if (c.guid == activeGuid) { activeChar = &c; break; }
}
if (!activeChar && !characters.empty()) activeChar = &characters[0];
if (activeChar) {
const auto& ch = *activeChar;
playerName = ch.name;
// Use live server level if available, otherwise character struct
playerLevel = gameHandler.getPlayerLevel();
if (playerLevel == 0) playerLevel = ch.level;
playerMaxHp = 20 + playerLevel * 10;
playerHp = playerMaxHp;
}
// Derive class color (WoW standard class colors)
ImVec4 classColor(0.3f, 1.0f, 0.3f, 1.0f); // default green
if (activeChar) {
switch (activeChar->characterClass) {
case game::Class::WARRIOR: classColor = ImVec4(0.78f, 0.61f, 0.43f, 1.0f); break;
case game::Class::PALADIN: classColor = ImVec4(0.96f, 0.55f, 0.73f, 1.0f); break;
case game::Class::HUNTER: classColor = ImVec4(0.67f, 0.83f, 0.45f, 1.0f); break;
case game::Class::ROGUE: classColor = ImVec4(1.00f, 0.96f, 0.41f, 1.0f); break;
case game::Class::PRIEST: classColor = ImVec4(1.00f, 1.00f, 1.00f, 1.0f); break;
case game::Class::DEATH_KNIGHT: classColor = ImVec4(0.77f, 0.12f, 0.23f, 1.0f); break;
case game::Class::SHAMAN: classColor = ImVec4(0.00f, 0.44f, 0.87f, 1.0f); break;
case game::Class::MAGE: classColor = ImVec4(0.41f, 0.80f, 0.94f, 1.0f); break;
case game::Class::WARLOCK: classColor = ImVec4(0.58f, 0.51f, 0.79f, 1.0f); break;
case game::Class::DRUID: classColor = ImVec4(1.00f, 0.49f, 0.04f, 1.0f); break;
}
}
// Name in class color — clickable for self-target, right-click for menu
ImGui::PushStyleColor(ImGuiCol_Text, classColor);
if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) {
gameHandler.setTarget(gameHandler.getPlayerGuid());
}
if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) {
ImGui::TextDisabled("%s", playerName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Open Character")) {
inventoryScreen.setCharacterOpen(true);
}
if (ImGui::MenuItem("Toggle PvP")) {
gameHandler.togglePvp();
}
ImGui::Separator();
bool afk = gameHandler.isAfk();
bool dnd = gameHandler.isDnd();
if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) {
gameHandler.toggleAfk();
}
if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) {
gameHandler.toggleDnd();
}
if (gameHandler.isInGroup()) {
ImGui::Separator();
if (ImGui::MenuItem("Leave Group")) {
gameHandler.leaveGroup();
}
}
ImGui::EndPopup();
}
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", playerLevel);
if (isDead) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD");
}
if (gameHandler.isAfk()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), "<AFK>");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel");
} else if (gameHandler.isDnd()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), "<DND>");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel");
}
// Try to get real HP/mana from the player entity
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
if (unit->getMaxHealth() > 0) {
playerHp = unit->getHealth();
playerMaxHp = unit->getMaxHealth();
}
}
// Health bar — color transitions green→yellow→red as HP drops
float pct = static_cast<float>(playerHp) / static_cast<float>(playerMaxHp);
ImVec4 hpColor;
if (isDead) {
hpColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (pct > 0.5f) {
hpColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); // green
} else if (pct > 0.2f) {
float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50%
hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow
} else {
// Critical — pulse red when < 20%
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.5f);
hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor);
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Mana/Power bar
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(playerEntity);
uint8_t powerType = unit->getPowerType();
uint32_t power = unit->getPower();
uint32_t maxPower = unit->getMaxPower();
// Rage (1), Focus (2), Energy (3), and Runic Power (6) always cap at 100.
// Show bar even if server hasn't sent UNIT_FIELD_MAXPOWER1 yet.
if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3 || powerType == 6)) maxPower = 100;
if (maxPower > 0) {
float mpPct = static_cast<float>(power) / static_cast<float>(maxPower);
ImVec4 powerColor;
switch (powerType) {
case 0: {
// Mana: pulse desaturated blue when critically low (< 20%)
if (mpPct < 0.2f) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f);
} else {
powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f);
}
break;
}
case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red)
case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange)
case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow)
case 4: powerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green)
case 6: powerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson)
case 7: powerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple)
default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay);
ImGui::PopStyleColor();
}
}
// Death Knight rune bar (class 6) — 6 colored squares with fill fraction
if (gameHandler.getPlayerClass() == 6) {
const auto& runes = gameHandler.getPlayerRunes();
float dt = ImGui::GetIO().DeltaTime;
ImGui::Spacing();
ImVec2 cursor = ImGui::GetCursorScreenPos();
float totalW = ImGui::GetContentRegionAvail().x;
float spacing = 3.0f;
float squareW = (totalW - spacing * 5.0f) / 6.0f;
float squareH = 14.0f;
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int i = 0; i < 6; i++) {
// Client-side prediction: advance fill over ~10s cooldown
runeClientFill_[i] = runes[i].ready ? 1.0f
: std::min(runeClientFill_[i] + dt / 10.0f, runes[i].readyFraction + 0.02f);
runeClientFill_[i] = std::clamp(runeClientFill_[i], 0.0f, runes[i].ready ? 1.0f : 0.97f);
float x0 = cursor.x + i * (squareW + spacing);
float y0 = cursor.y;
float x1 = x0 + squareW;
float y1 = y0 + squareH;
// Background (dark)
dl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1),
IM_COL32(30, 30, 30, 200), 2.0f);
// Fill color by rune type
ImVec4 fc;
switch (runes[i].type) {
case game::GameHandler::RuneType::Blood: fc = ImVec4(0.85f, 0.12f, 0.12f, 1.0f); break;
case game::GameHandler::RuneType::Unholy: fc = ImVec4(0.20f, 0.72f, 0.20f, 1.0f); break;
case game::GameHandler::RuneType::Frost: fc = ImVec4(0.30f, 0.55f, 0.90f, 1.0f); break;
case game::GameHandler::RuneType::Death: fc = ImVec4(0.55f, 0.20f, 0.70f, 1.0f); break;
default: fc = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); break;
}
float fillX = x0 + (x1 - x0) * runeClientFill_[i];
dl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1),
ImGui::ColorConvertFloat4ToU32(fc), 2.0f);
// Border
ImU32 borderCol = runes[i].ready
? IM_COL32(220, 220, 220, 180)
: IM_COL32(100, 100, 100, 160);
dl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f);
}
ImGui::Dummy(ImVec2(totalW, squareH));
}
// Combo point display — Rogue (4) and Druid (11) in Cat Form
{
uint8_t cls = gameHandler.getPlayerClass();
const bool isRogue = (cls == 4);
const bool isDruid = (cls == 11);
if (isRogue || isDruid) {
uint8_t cp = gameHandler.getComboPoints();
if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid
ImGui::Spacing();
ImVec2 cursor = ImGui::GetCursorScreenPos();
float totalW = ImGui::GetContentRegionAvail().x;
constexpr int MAX_CP = 5;
constexpr float DOT_R = 7.0f;
constexpr float SPACING = 4.0f;
float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING;
float startX = cursor.x + (totalW - totalDotsW) * 0.5f;
float cy = cursor.y + DOT_R;
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int i = 0; i < MAX_CP; ++i) {
float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R;
ImU32 col = (i < static_cast<int>(cp))
? IM_COL32(255, 210, 0, 240) // bright gold — active
: IM_COL32(60, 60, 60, 160); // dark — empty
dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col);
dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f);
}
ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f));
}
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::renderPetFrame(game::GameHandler& gameHandler) {
uint64_t petGuid = gameHandler.getPetGuid();
if (petGuid == 0) return;
auto petEntity = gameHandler.getEntityManager().getEntity(petGuid);
if (!petEntity) return;
auto* petUnit = dynamic_cast<game::Unit*>(petEntity.get());
if (!petUnit) return;
// Position below player frame. If in a group, push below party frames
// (party frame at y=120, each member ~50px, up to 4 members → max ~320px + y=120 = ~440).
// When not grouped, the player frame ends at ~110px so y=125 is fine.
const int partyMemberCount = gameHandler.isInGroup()
? static_cast<int>(gameHandler.getPartyData().members.size()) : 0;
float petY = (partyMemberCount > 0)
? 120.0f + partyMemberCount * 52.0f + 8.0f
: 125.0f;
ImGui::SetNextWindowPos(ImVec2(10.0f, petY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.1f, 0.08f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.2f, 0.6f, 0.2f, 1.0f));
if (ImGui::Begin("##PetFrame", nullptr, flags)) {
const std::string& petName = petUnit->getName();
uint32_t petLevel = petUnit->getLevel();
// Name + level on one row — clicking the pet name targets it
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.9f, 0.4f, 1.0f));
char petLabel[96];
snprintf(petLabel, sizeof(petLabel), "%s",
petName.empty() ? "Pet" : petName.c_str());
if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) {
gameHandler.setTarget(petGuid);
}
// Right-click context menu on pet name
if (ImGui::BeginPopupContextItem("PetNameCtx")) {
ImGui::TextDisabled("%s", petLabel);
ImGui::Separator();
if (ImGui::MenuItem("Target Pet")) {
gameHandler.setTarget(petGuid);
}
if (ImGui::MenuItem("Dismiss Pet")) {
gameHandler.dismissPet();
}
ImGui::EndPopup();
}
ImGui::PopStyleColor();
if (petLevel > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", petLevel);
}
// Health bar
uint32_t hp = petUnit->getHealth();
uint32_t maxHp = petUnit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImVec4 petHpColor = pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f)
: pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f)
: ImVec4(0.9f, 0.15f, 0.15f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor);
char hpText[32];
snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText);
ImGui::PopStyleColor();
}
// Power/mana bar (hunters' pets use focus)
uint8_t powerType = petUnit->getPowerType();
uint32_t power = petUnit->getPower();
uint32_t maxPower = petUnit->getMaxPower();
if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3)) maxPower = 100;
if (maxPower > 0) {
float mpPct = static_cast<float>(power) / static_cast<float>(maxPower);
ImVec4 powerColor;
switch (powerType) {
case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana
case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage
case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (hunter pets)
case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy
default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
char mpText[32];
snprintf(mpText, sizeof(mpText), "%u/%u", power, maxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpText);
ImGui::PopStyleColor();
}
// Dismiss button (compact, right-aligned)
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f);
if (ImGui::SmallButton("Dismiss")) {
gameHandler.dismissPet();
}
// Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS
{
const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS;
// Filter to non-zero slots; lay them out as small icon/text buttons.
// Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID,
// high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type).
// Built-in commands: id=2 follow, id=3 stay/move, id=5 attack.
auto* assetMgr = core::Application::getInstance().getAssetManager();
const float iconSz = 20.0f;
const float spacing = 2.0f;
ImGui::Separator();
int rendered = 0;
for (int i = 0; i < slotCount; ++i) {
uint32_t slotVal = gameHandler.getPetActionSlot(i);
if (slotVal == 0) continue;
uint32_t actionId = slotVal & 0x00FFFFFFu;
// Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags.
bool autocastOn = gameHandler.isPetSpellAutocast(actionId);
ImGui::PushID(i);
if (rendered > 0) ImGui::SameLine(0.0f, spacing);
// Try to show spell icon; fall back to abbreviated text label.
VkDescriptorSet iconTex = VK_NULL_HANDLE;
const char* builtinLabel = nullptr;
if (actionId == 2) builtinLabel = "Fol";
else if (actionId == 3) builtinLabel = "Sty";
else if (actionId == 5) builtinLabel = "Atk";
else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr);
// Tint green when autocast is on.
ImVec4 tint = autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f)
: ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
bool clicked = false;
if (iconTex) {
clicked = ImGui::ImageButton("##pa",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(iconSz, iconSz),
ImVec2(0,0), ImVec2(1,1),
ImVec4(0.1f,0.1f,0.1f,0.9f), tint);
} else {
char label[8];
if (builtinLabel) {
snprintf(label, sizeof(label), "%s", builtinLabel);
} else {
// Show first 3 chars of spell name or spell ID.
std::string nm = gameHandler.getSpellName(actionId);
if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100);
else snprintf(label, sizeof(label), "%.3s", nm.c_str());
}
ImGui::PushStyleColor(ImGuiCol_Button,
autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f)
: ImVec4(0.2f,0.2f,0.3f,0.9f));
clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz));
ImGui::PopStyleColor();
}
if (clicked) {
// Send pet action; use current target for spells.
uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u;
gameHandler.sendPetAction(slotVal, targetGuid);
}
// Tooltip: show spell name or built-in command name.
if (ImGui::IsItemHovered()) {
const char* tip = builtinLabel
? (actionId == 5 ? "Attack" : actionId == 2 ? "Follow" : "Stay")
: nullptr;
std::string spellNm;
if (!tip && actionId > 5) {
spellNm = gameHandler.getSpellName(actionId);
if (!spellNm.empty()) tip = spellNm.c_str();
}
if (tip) ImGui::SetTooltip("%s", tip);
}
ImGui::PopID();
++rendered;
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
auto target = gameHandler.getTarget();
if (!target) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float frameW = 250.0f;
float frameX = (screenW - frameW) / 2.0f;
ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
// Determine hostility/level color for border and name (WoW-canonical)
ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f);
if (target->getType() == game::ObjectType::PLAYER) {
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else if (target->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(target);
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (u->isHostile()) {
// WoW level-based color for hostile mobs
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0) {
hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey - no XP
} else if (diff >= 10) {
hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard
} else if (diff >= 5) {
hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard
} else if (diff >= -2) {
hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even
} else {
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy
}
} else {
hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly
}
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f));
const uint64_t targetGuid = target->getGuid();
const bool confirmedCombatWithTarget = gameHandler.isInCombatWith(targetGuid);
const bool intentTowardTarget =
gameHandler.hasAutoAttackIntent() &&
gameHandler.getAutoAttackTargetGuid() == targetGuid &&
!confirmedCombatWithTarget;
ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f);
if (confirmedCombatWithTarget) {
float t = ImGui::GetTime();
float pulse = (std::fmod(t, 0.6f) < 0.3f) ? 1.0f : 0.0f;
borderColor = ImVec4(1.0f, 0.1f, 0.1f, pulse);
} else if (intentTowardTarget) {
borderColor = ImVec4(1.0f, 0.7f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_Border, borderColor);
if (ImGui::Begin("##TargetFrame", nullptr, flags)) {
// Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull)
static const struct { const char* sym; ImU32 col; } kRaidMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star (yellow)
{ "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle (orange)
{ "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple)
{ "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green)
{ "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue)
{ "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal)
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red)
{ "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white)
};
uint8_t mark = gameHandler.getEntityRaidMark(target->getGuid());
if (mark < game::GameHandler::kRaidMarkCount) {
ImGui::GetWindowDrawList()->AddText(
ImGui::GetCursorScreenPos(),
kRaidMarks[mark].col, kRaidMarks[mark].sym);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f);
}
// Entity name and type — Selectable so we can attach a right-click context menu
std::string name = getEntityName(target);
ImVec4 nameColor = hostileColor;
ImGui::SameLine(0.0f, 0.0f);
ImGui::PushStyleColor(ImGuiCol_Text, nameColor);
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f));
ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0));
ImGui::PopStyleColor(4);
// Right-click context menu on the target name
if (ImGui::BeginPopupContextItem("##TargetNameCtx")) {
const bool isPlayer = (target->getType() == game::ObjectType::PLAYER);
const uint64_t tGuid = target->getGuid();
ImGui::TextDisabled("%s", name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Set Focus")) {
gameHandler.setFocus(tGuid);
}
if (ImGui::MenuItem("Clear Target")) {
gameHandler.clearTarget();
}
if (isPlayer) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Follow")) {
gameHandler.followTarget();
}
if (ImGui::MenuItem("Invite to Group")) {
gameHandler.inviteToGroup(name);
}
if (ImGui::MenuItem("Trade")) {
gameHandler.initiateTrade(tGuid);
}
if (ImGui::MenuItem("Duel")) {
gameHandler.proposeDuel(tGuid);
}
if (ImGui::MenuItem("Inspect")) {
gameHandler.inspectTarget();
showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend")) {
gameHandler.addFriend(name);
}
if (ImGui::MenuItem("Ignore")) {
gameHandler.addIgnore(name);
}
}
ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) {
static const char* kRaidMarkNames[] = {
"{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle",
"{)} Moon", "{ } Square", "{x} Cross", "{8} Skull"
};
for (int mi = 0; mi < 8; ++mi) {
if (ImGui::MenuItem(kRaidMarkNames[mi]))
gameHandler.setRaidMark(tGuid, static_cast<uint8_t>(mi));
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Mark"))
gameHandler.setRaidMark(tGuid, 0xFF);
ImGui::EndMenu();
}
ImGui::EndPopup();
}
// Level (for units/players) — colored by difficulty
if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<game::Unit>(target);
ImGui::SameLine();
// Level color matches the hostility/difficulty color
ImVec4 levelColor = hostileColor;
if (target->getType() == game::ObjectType::PLAYER) {
levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
}
ImGui::TextColored(levelColor, "Lv %u", unit->getLevel());
// Health bar
uint32_t hp = unit->getHealth();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) :
ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay);
ImGui::PopStyleColor();
// Target power bar (mana/rage/energy)
uint8_t targetPowerType = unit->getPowerType();
uint32_t targetPower = unit->getPower();
uint32_t targetMaxPower = unit->getMaxPower();
if (targetMaxPower == 0 && (targetPowerType == 1 || targetPowerType == 3)) targetMaxPower = 100;
if (targetMaxPower > 0) {
float mpPct = static_cast<float>(targetPower) / static_cast<float>(targetMaxPower);
ImVec4 targetPowerColor;
switch (targetPowerType) {
case 0: targetPowerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue)
case 1: targetPowerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red)
case 2: targetPowerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange)
case 3: targetPowerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow)
case 4: targetPowerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green)
case 6: targetPowerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson)
case 7: targetPowerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple)
default: targetPowerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, targetPowerColor);
char mpOverlay[64];
snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower);
ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay);
ImGui::PopStyleColor();
}
} else {
ImGui::TextDisabled("No health data");
}
}
// Target cast bar — shown when the target is casting
if (gameHandler.isTargetCasting()) {
float castPct = gameHandler.getTargetCastProgress();
float castLeft = gameHandler.getTargetCastTimeRemaining();
uint32_t tspell = gameHandler.getTargetCastSpellId();
const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : "";
// Pulse bright orange when cast is > 80% complete — interrupt window closing
ImVec4 castBarColor;
if (castPct > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
castBarColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f);
} else {
castBarColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor);
char castLabel[72];
if (!castName.empty())
snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft);
else
snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft);
ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel);
ImGui::PopStyleColor();
}
// Distance
const auto& movement = gameHandler.getMovementInfo();
float dx = target->getX() - movement.x;
float dy = target->getY() - movement.y;
float dz = target->getZ() - movement.z;
float distance = std::sqrt(dx*dx + dy*dy + dz*dz);
ImGui::TextDisabled("%.1f yd", distance);
// Threat button (shown when in combat and threat data is available)
if (gameHandler.getTargetThreatList()) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f));
if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_;
ImGui::PopStyleColor(2);
}
// Target auras (buffs/debuffs)
const auto& targetAuras = gameHandler.getTargetAuras();
int activeAuras = 0;
for (const auto& a : targetAuras) {
if (!a.isEmpty()) activeAuras++;
}
if (activeAuras > 0) {
auto* assetMgr = core::Application::getInstance().getAssetManager();
constexpr float ICON_SIZE = 24.0f;
constexpr int ICONS_PER_ROW = 8;
ImGui::Separator();
int shown = 0;
for (size_t i = 0; i < targetAuras.size() && shown < 16; ++i) {
const auto& aura = targetAuras[i];
if (aura.isEmpty()) continue;
if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(10000 + i));
bool isBuff = (aura.flags & 0x80) == 0;
ImVec4 auraBorderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f);
VkDescriptorSet iconTex = VK_NULL_HANDLE;
if (assetMgr) {
iconTex = getSpellIcon(aura.spellId, assetMgr);
}
if (iconTex) {
ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1));
ImGui::ImageButton("##taura",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(ICON_SIZE - 2, ICON_SIZE - 2));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor);
char label[8];
snprintf(label, sizeof(label), "%u", aura.spellId);
ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE));
ImGui::PopStyleColor();
}
// Compute remaining once for overlay + tooltip
uint64_t tNowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
int32_t tRemainMs = aura.getRemainingMs(tNowMs);
// Duration countdown overlay
if (tRemainMs > 0) {
ImVec2 iconMin = ImGui::GetItemRectMin();
ImVec2 iconMax = ImGui::GetItemRectMax();
char timeStr[12];
int secs = (tRemainMs + 999) / 1000;
if (secs >= 3600)
snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600);
else if (secs >= 60)
snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60);
else
snprintf(timeStr, sizeof(timeStr), "%d", secs);
ImVec2 textSize = ImGui::CalcTextSize(timeStr);
float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f;
float cy = iconMax.y - textSize.y - 1.0f;
// Color by urgency (matches player buff bar)
ImU32 tTimerColor;
if (tRemainMs < 10000) {
float pulse = 0.7f + 0.3f * std::sin(
static_cast<float>(ImGui::GetTime()) * 6.0f);
tTimerColor = IM_COL32(
static_cast<int>(255 * pulse),
static_cast<int>(80 * pulse),
static_cast<int>(60 * pulse), 255);
} else if (tRemainMs < 30000) {
tTimerColor = IM_COL32(255, 165, 0, 255);
} else {
tTimerColor = IM_COL32(255, 255, 255, 255);
}
ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1),
IM_COL32(0, 0, 0, 200), timeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy),
tTimerColor, timeStr);
}
// Stack / charge count — upper-left corner
if (aura.charges > 1) {
ImVec2 iconMin = ImGui::GetItemRectMin();
char chargeStr[8];
snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast<unsigned>(aura.charges));
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3),
IM_COL32(0, 0, 0, 200), chargeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2),
IM_COL32(255, 220, 50, 255), chargeStr);
}
// Tooltip: rich spell info + remaining duration
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr);
if (!richOk) {
std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr);
if (name.empty()) name = "Spell #" + std::to_string(aura.spellId);
ImGui::Text("%s", name.c_str());
}
if (tRemainMs > 0) {
int seconds = tRemainMs / 1000;
char durBuf[32];
if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds);
else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf);
}
ImGui::EndTooltip();
}
ImGui::PopID();
shown++;
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
// ---- Target-of-Target (ToT) mini frame ----
// Read target's current target from UNIT_FIELD_TARGET_LO/HI update fields
if (target) {
const auto& fields = target->getFields();
uint64_t totGuid = 0;
auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (loIt != fields.end()) {
totGuid = loIt->second;
auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (hiIt != fields.end())
totGuid |= (static_cast<uint64_t>(hiIt->second) << 32);
}
if (totGuid != 0) {
auto totEntity = gameHandler.getEntityManager().getEntity(totGuid);
if (totEntity) {
// Position ToT frame just below and right-aligned with the target frame
float totW = 160.0f;
float totX = (screenW - totW) / 2.0f + (frameW - totW);
ImGui::SetNextWindowPos(ImVec2(totX, 30.0f + 130.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(totW, 0.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.80f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 0.7f));
if (ImGui::Begin("##ToTFrame", nullptr,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) {
std::string totName = getEntityName(totEntity);
// Selectable so we can attach a right-click context menu
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.8f, 0.8f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f));
if (ImGui::Selectable(totName.c_str(), false,
ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) {
gameHandler.setTarget(totGuid);
}
ImGui::PopStyleColor(4);
if (ImGui::BeginPopupContextItem("##ToTCtx")) {
ImGui::TextDisabled("%s", totName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(totGuid);
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(totGuid);
ImGui::EndPopup();
}
if (totEntity->getType() == game::ObjectType::UNIT ||
totEntity->getType() == game::ObjectType::PLAYER) {
auto totUnit = std::static_pointer_cast<game::Unit>(totEntity);
if (totUnit->getLevel() > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv%u", totUnit->getLevel());
}
uint32_t hp = totUnit->getHealth();
uint32_t maxHp = totUnit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) :
ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
ImGui::ProgressBar(pct, ImVec2(-1, 10), "");
ImGui::PopStyleColor();
}
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
}
}
}
void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) {
auto focus = gameHandler.getFocus();
if (!focus) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Position: right side of screen, mirroring the target frame on the opposite side
float frameW = 200.0f;
float frameX = screenW - frameW - 10.0f;
ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
// Determine color based on relation (same logic as target frame)
ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f);
if (focus->getType() == game::ObjectType::PLAYER) {
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else if (focus->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(focus);
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (u->isHostile()) {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0)
focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f);
else if (diff >= 10)
focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f);
else if (diff >= 5)
focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f);
else if (diff >= -2)
focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f);
else
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else {
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
}
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus
if (ImGui::Begin("##FocusFrame", nullptr, flags)) {
// "Focus" label
ImGui::TextDisabled("[Focus]");
ImGui::SameLine();
std::string focusName = getEntityName(focus);
ImGui::PushStyleColor(ImGuiCol_Text, focusColor);
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f));
ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups,
ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0));
ImGui::PopStyleColor(4);
if (ImGui::BeginPopupContextItem("##FocusNameCtx")) {
const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER);
const uint64_t fGuid = focus->getGuid();
ImGui::TextDisabled("%s", focusName.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(fGuid);
if (ImGui::MenuItem("Clear Focus"))
gameHandler.clearFocus();
if (focusIsPlayer) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(focusName);
if (ImGui::MenuItem("Trade"))
gameHandler.initiateTrade(fGuid);
if (ImGui::MenuItem("Duel"))
gameHandler.proposeDuel(fGuid);
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(fGuid);
gameHandler.inspectTarget();
showInspectWindow_ = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(focusName);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(focusName);
}
ImGui::EndPopup();
}
if (focus->getType() == game::ObjectType::UNIT ||
focus->getType() == game::ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<game::Unit>(focus);
// Level + health on same row
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", unit->getLevel());
uint32_t hp = unit->getHealth();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) :
ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
char overlay[32];
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay);
ImGui::PopStyleColor();
// Power bar
uint8_t pType = unit->getPowerType();
uint32_t pwr = unit->getPower();
uint32_t maxPwr = unit->getMaxPower();
if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100;
if (maxPwr > 0) {
float mpPct = static_cast<float>(pwr) / static_cast<float>(maxPwr);
ImVec4 pwrColor;
switch (pType) {
case 0: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
case 1: pwrColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break;
case 3: pwrColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break;
case 6: pwrColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break;
default: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor);
ImGui::ProgressBar(mpPct, ImVec2(-1, 10), "");
ImGui::PopStyleColor();
}
}
// Focus cast bar
const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid());
if (focusCast) {
float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f;
float rem = focusCast->timeRemaining;
float prog = std::clamp(1.0f - rem / total, 0.f, 1.f);
const std::string& spName = gameHandler.getSpellName(focusCast->spellId);
// Pulse orange when > 80% complete — interrupt window closing
ImVec4 focusCastColor;
if (prog > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f);
} else {
focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor);
char castBuf[64];
if (!spName.empty())
snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem);
else
snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem);
ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf);
ImGui::PopStyleColor();
}
}
// Clicking the focus frame targets it
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) {
gameHandler.setTarget(focus->getGuid());
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::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;
// Track if a channel shortcut should change the chat type dropdown
int switchChatType = -1;
// 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 = std::tolower(c);
// Special commands
if (cmdLower == "logout") {
2026-02-05 15:59:06 -08:00
core::Application::getInstance().logoutToLogin();
chatInputBuffer[0] = '\0';
return;
}
// /invite command
if (cmdLower == "invite" && spacePos != std::string::npos) {
std::string targetName = command.substr(spacePos + 1);
gameHandler.inviteToGroup(targetName);
chatInputBuffer[0] = '\0';
return;
}
// /inspect command
if (cmdLower == "inspect") {
gameHandler.inspectTarget();
showInspectWindow_ = true;
chatInputBuffer[0] = '\0';
return;
}
// /threat command
if (cmdLower == "threat") {
showThreatWindow_ = !showThreatWindow_;
chatInputBuffer[0] = '\0';
return;
}
// /time command
if (cmdLower == "time") {
gameHandler.queryServerTime();
chatInputBuffer[0] = '\0';
return;
}
// /played command
if (cmdLower == "played") {
gameHandler.requestPlayedTime();
chatInputBuffer[0] = '\0';
return;
}
// /ticket command — open GM ticket window
if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") {
showGmTicketWindow_ = true;
chatInputBuffer[0] = '\0';
return;
}
// /help command — list available slash commands
if (cmdLower == "help" || cmdLower == "?") {
static const char* kHelpLines[] = {
"--- Wowee Slash Commands ---",
"Chat: /s /y /p /g /raid /rw /o /bg /w <name> [msg] /r [msg]",
"Social: /who [filter] /whois <name> /friend add/remove <name>",
" /ignore <name> /unignore <name>",
"Party: /invite <name> /uninvite <name> /leave /readycheck",
" /maintank /mainassist /roll [min-max]",
"Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd",
" /gleader /groster /ginfo /gcreate /gdisband",
"Combat: /startattack /stopattack /stopcasting /duel /pvp",
" /forfeit /follow /assist",
"Target: /target <name> /cleartarget /focus /clearfocus",
"Movement: /sit /stand /kneel /dismount",
"Misc: /played /time /afk [msg] /dnd [msg] /inspect",
" /helm /cloak /trade /join <channel> /leave <channel>",
" /unstuck /logout /ticket /help",
};
for (const char* line : kHelpLines) {
game::MessageChatData helpMsg;
helpMsg.type = game::ChatType::SYSTEM;
helpMsg.language = game::ChatLanguage::UNIVERSAL;
helpMsg.message = line;
gameHandler.addLocalChatMessage(helpMsg);
}
chatInputBuffer[0] = '\0';
return;
}
// /who commands
if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") {
std::string query;
if (spacePos != std::string::npos) {
query = command.substr(spacePos + 1);
// Trim leading/trailing whitespace
size_t first = query.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
query.clear();
} else {
size_t last = query.find_last_not_of(" \t\r\n");
query = query.substr(first, last - first + 1);
}
}
if ((cmdLower == "whois") && query.empty()) {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /whois <playerName>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "who" && (query == "help" || query == "?")) {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Who commands: /who [name/filter], /whois <name>, /online";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
gameHandler.queryWho(query);
chatInputBuffer[0] = '\0';
return;
}
// /roll command
if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") {
uint32_t minRoll = 1;
uint32_t maxRoll = 100;
if (spacePos != std::string::npos) {
std::string args = command.substr(spacePos + 1);
size_t dashPos = args.find('-');
size_t spacePos2 = args.find(' ');
if (dashPos != std::string::npos) {
// Format: /roll 1-100
try {
minRoll = std::stoul(args.substr(0, dashPos));
maxRoll = std::stoul(args.substr(dashPos + 1));
} catch (...) {}
} else if (spacePos2 != std::string::npos) {
// Format: /roll 1 100
try {
minRoll = std::stoul(args.substr(0, spacePos2));
maxRoll = std::stoul(args.substr(spacePos2 + 1));
} catch (...) {}
} else {
// Format: /roll 100 (means 1-100)
try {
maxRoll = std::stoul(args);
} catch (...) {}
}
}
gameHandler.randomRoll(minRoll, maxRoll);
chatInputBuffer[0] = '\0';
return;
}
// /friend or /addfriend command
if (cmdLower == "friend" || cmdLower == "addfriend") {
if (spacePos != std::string::npos) {
std::string args = command.substr(spacePos + 1);
size_t subCmdSpace = args.find(' ');
if (cmdLower == "friend" && subCmdSpace != std::string::npos) {
std::string subCmd = args.substr(0, subCmdSpace);
std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower);
if (subCmd == "add") {
std::string playerName = args.substr(subCmdSpace + 1);
gameHandler.addFriend(playerName);
chatInputBuffer[0] = '\0';
return;
} else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") {
std::string playerName = args.substr(subCmdSpace + 1);
gameHandler.removeFriend(playerName);
chatInputBuffer[0] = '\0';
return;
}
} else {
// /addfriend name or /friend name (assume add)
gameHandler.addFriend(args);
chatInputBuffer[0] = '\0';
return;
}
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /friend add <name> or /friend remove <name>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /removefriend or /delfriend command
if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.removeFriend(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /removefriend <name>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /ignore command
if (cmdLower == "ignore") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.addIgnore(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /ignore <name>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /unignore command
if (cmdLower == "unignore") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.removeIgnore(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /unignore <name>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /dismount command
if (cmdLower == "dismount") {
gameHandler.dismount();
chatInputBuffer[0] = '\0';
return;
}
// /sit command
if (cmdLower == "sit") {
gameHandler.setStandState(1); // 1 = sit
chatInputBuffer[0] = '\0';
return;
}
// /stand command
if (cmdLower == "stand") {
gameHandler.setStandState(0); // 0 = stand
chatInputBuffer[0] = '\0';
return;
}
// /kneel command
if (cmdLower == "kneel") {
gameHandler.setStandState(8); // 8 = kneel
chatInputBuffer[0] = '\0';
return;
}
// /logout command (already exists but using /logout instead of going to login)
if (cmdLower == "logout" || cmdLower == "camp") {
gameHandler.requestLogout();
chatInputBuffer[0] = '\0';
return;
}
// /cancellogout command
if (cmdLower == "cancellogout") {
gameHandler.cancelLogout();
chatInputBuffer[0] = '\0';
return;
}
// /helm command
if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") {
gameHandler.toggleHelm();
chatInputBuffer[0] = '\0';
return;
}
// /cloak command
if (cmdLower == "cloak" || cmdLower == "showcloak") {
gameHandler.toggleCloak();
chatInputBuffer[0] = '\0';
return;
}
// /follow command
if (cmdLower == "follow" || cmdLower == "f") {
gameHandler.followTarget();
chatInputBuffer[0] = '\0';
return;
}
// /assist command
if (cmdLower == "assist") {
gameHandler.assistTarget();
chatInputBuffer[0] = '\0';
return;
}
// /pvp command
if (cmdLower == "pvp") {
gameHandler.togglePvp();
chatInputBuffer[0] = '\0';
return;
}
// /ginfo command
if (cmdLower == "ginfo" || cmdLower == "guildinfo") {
gameHandler.requestGuildInfo();
chatInputBuffer[0] = '\0';
return;
}
// /groster command
if (cmdLower == "groster" || cmdLower == "guildroster") {
gameHandler.requestGuildRoster();
chatInputBuffer[0] = '\0';
return;
}
// /gmotd command
if (cmdLower == "gmotd" || cmdLower == "guildmotd") {
if (spacePos != std::string::npos) {
std::string motd = command.substr(spacePos + 1);
gameHandler.setGuildMotd(motd);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gmotd <message>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gpromote command
if (cmdLower == "gpromote" || cmdLower == "guildpromote") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.promoteGuildMember(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gpromote <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gdemote command
if (cmdLower == "gdemote" || cmdLower == "guilddemote") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.demoteGuildMember(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gdemote <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gquit command
if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") {
gameHandler.leaveGuild();
chatInputBuffer[0] = '\0';
return;
}
// /ginvite command
if (cmdLower == "ginvite" || cmdLower == "guildinvite") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.inviteToGuild(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /ginvite <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gkick command
if (cmdLower == "gkick" || cmdLower == "guildkick") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.kickGuildMember(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gkick <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gcreate command
if (cmdLower == "gcreate" || cmdLower == "guildcreate") {
if (spacePos != std::string::npos) {
std::string guildName = command.substr(spacePos + 1);
gameHandler.createGuild(guildName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gcreate <guild name>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /gdisband command
if (cmdLower == "gdisband" || cmdLower == "guilddisband") {
gameHandler.disbandGuild();
chatInputBuffer[0] = '\0';
return;
}
// /gleader command
if (cmdLower == "gleader" || cmdLower == "guildleader") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.setGuildLeader(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gleader <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /readycheck command
if (cmdLower == "readycheck" || cmdLower == "rc") {
gameHandler.initiateReadyCheck();
chatInputBuffer[0] = '\0';
return;
}
// /ready command (respond yes to ready check)
if (cmdLower == "ready") {
gameHandler.respondToReadyCheck(true);
chatInputBuffer[0] = '\0';
return;
}
// /notready command (respond no to ready check)
if (cmdLower == "notready" || cmdLower == "nr") {
gameHandler.respondToReadyCheck(false);
chatInputBuffer[0] = '\0';
return;
}
// /yield or /forfeit command
if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") {
gameHandler.forfeitDuel();
chatInputBuffer[0] = '\0';
return;
}
// AFK command
if (cmdLower == "afk" || cmdLower == "away") {
std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
gameHandler.toggleAfk(afkMsg);
chatInputBuffer[0] = '\0';
return;
}
// DND command
if (cmdLower == "dnd" || cmdLower == "busy") {
std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
gameHandler.toggleDnd(dndMsg);
chatInputBuffer[0] = '\0';
return;
}
// Reply command
if (cmdLower == "r" || cmdLower == "reply") {
std::string lastSender = gameHandler.getLastWhisperSender();
if (lastSender.empty()) {
game::MessageChatData errMsg;
errMsg.type = game::ChatType::SYSTEM;
errMsg.language = game::ChatLanguage::UNIVERSAL;
errMsg.message = "No one has whispered you yet.";
gameHandler.addLocalChatMessage(errMsg);
chatInputBuffer[0] = '\0';
return;
}
// Set whisper target to last whisper sender
strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
if (spacePos != std::string::npos) {
// /r message — send reply immediately
std::string replyMsg = command.substr(spacePos + 1);
gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender);
}
// Switch to whisper tab
selectedChatType = 4;
chatInputBuffer[0] = '\0';
return;
}
// Party/Raid management commands
if (cmdLower == "uninvite" || cmdLower == "kick") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.uninvitePlayer(playerName);
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /uninvite <player name>";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "leave" || cmdLower == "leaveparty") {
gameHandler.leaveParty();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "maintank" || cmdLower == "mt") {
if (gameHandler.hasTarget()) {
gameHandler.setMainTank(gameHandler.getTargetGuid());
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a player to set as main tank.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "mainassist" || cmdLower == "ma") {
if (gameHandler.hasTarget()) {
gameHandler.setMainAssist(gameHandler.getTargetGuid());
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a player to set as main assist.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "clearmaintank") {
gameHandler.clearMainTank();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "clearmainassist") {
gameHandler.clearMainAssist();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "raidinfo") {
gameHandler.requestRaidInfo();
chatInputBuffer[0] = '\0';
return;
}
// Combat and Trade commands
if (cmdLower == "duel") {
if (gameHandler.hasTarget()) {
gameHandler.proposeDuel(gameHandler.getTargetGuid());
} else if (spacePos != std::string::npos) {
// Target player by name (would need name-to-GUID lookup)
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a player to challenge to a duel.";
gameHandler.addLocalChatMessage(msg);
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a player to challenge to a duel.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "trade") {
if (gameHandler.hasTarget()) {
gameHandler.initiateTrade(gameHandler.getTargetGuid());
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a player to trade with.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "startattack") {
if (gameHandler.hasTarget()) {
gameHandler.startAutoAttack(gameHandler.getTargetGuid());
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You have no target.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "stopattack") {
gameHandler.stopAutoAttack();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "stopcasting") {
gameHandler.stopCasting();
chatInputBuffer[0] = '\0';
return;
}
// Targeting commands
if (cmdLower == "cleartarget") {
gameHandler.clearTarget();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "target" && spacePos != std::string::npos) {
// Search visible entities for name match (case-insensitive prefix)
std::string targetArg = command.substr(spacePos + 1);
std::string targetArgLower = targetArg;
for (char& c : targetArgLower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
uint64_t bestGuid = 0;
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (!entity || entity->getType() == game::ObjectType::OBJECT) continue;
std::string name;
if (entity->getType() == game::ObjectType::PLAYER ||
entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
name = unit->getName();
}
if (name.empty()) continue;
std::string nameLower = name;
for (char& c : nameLower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (nameLower.find(targetArgLower) == 0) {
bestGuid = guid;
if (nameLower == targetArgLower) break; // Exact match wins
}
}
if (bestGuid) {
gameHandler.setTarget(bestGuid);
} else {
game::MessageChatData sysMsg;
sysMsg.type = game::ChatType::SYSTEM;
sysMsg.language = game::ChatLanguage::UNIVERSAL;
sysMsg.message = "No target matching '" + targetArg + "' found.";
gameHandler.addLocalChatMessage(sysMsg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "targetenemy") {
gameHandler.targetEnemy(false);
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "targetfriend") {
gameHandler.targetFriend(false);
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") {
gameHandler.targetLastTarget();
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "targetlastenemy") {
gameHandler.targetEnemy(true); // Reverse direction
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "targetlastfriend") {
gameHandler.targetFriend(true); // Reverse direction
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "focus") {
if (gameHandler.hasTarget()) {
gameHandler.setFocus(gameHandler.getTargetGuid());
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You must target a unit to set as focus.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "clearfocus") {
gameHandler.clearFocus();
chatInputBuffer[0] = '\0';
return;
}
// /unstuck command — resets player position to floor height
if (cmdLower == "unstuck") {
gameHandler.unstuck();
chatInputBuffer[0] = '\0';
return;
}
// /unstuckgy command — move to nearest graveyard
if (cmdLower == "unstuckgy") {
gameHandler.unstuckGy();
chatInputBuffer[0] = '\0';
return;
}
// /unstuckhearth command — teleport to hearthstone bind point
if (cmdLower == "unstuckhearth") {
gameHandler.unstuckHearth();
chatInputBuffer[0] = '\0';
return;
}
// /transport board — board test transport
if (cmdLower == "transport board") {
auto* tm = gameHandler.getTransportManager();
if (tm) {
// Test transport GUID
uint64_t testTransportGuid = 0x1000000000000001ULL;
// Place player at center of deck (rough estimate)
glm::vec3 deckCenter(0.0f, 0.0f, 5.0f);
gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter);
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Boarded test transport. Use '/transport leave' to disembark.";
gameHandler.addLocalChatMessage(msg);
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Transport system not available.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
// /transport leave — disembark from transport
if (cmdLower == "transport leave") {
if (gameHandler.isOnTransport()) {
gameHandler.clearPlayerTransport();
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Disembarked from transport.";
gameHandler.addLocalChatMessage(msg);
} else {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "You are not on a transport.";
gameHandler.addLocalChatMessage(msg);
}
chatInputBuffer[0] = '\0';
return;
}
// Chat channel slash commands
// If used without a message (e.g. just "/s"), switch the chat type dropdown
bool isChannelCommand = false;
if (cmdLower == "s" || cmdLower == "say") {
type = game::ChatType::SAY;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 0;
} else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") {
type = game::ChatType::YELL;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 1;
} else if (cmdLower == "p" || cmdLower == "party") {
type = game::ChatType::PARTY;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 2;
} else if (cmdLower == "g" || cmdLower == "guild") {
type = game::ChatType::GUILD;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 3;
} else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") {
type = game::ChatType::RAID;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 5;
} else if (cmdLower == "raidwarning" || cmdLower == "rw") {
type = game::ChatType::RAID_WARNING;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 8;
} else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") {
type = game::ChatType::OFFICER;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 6;
} else if (cmdLower == "battleground" || cmdLower == "bg") {
type = game::ChatType::BATTLEGROUND;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 7;
} else if (cmdLower == "instance" || cmdLower == "i") {
// Instance chat uses PARTY chat type
type = game::ChatType::PARTY;
message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : "";
isChannelCommand = true;
switchChatType = 9;
} else if (cmdLower == "join") {
// /join with no args: accept pending BG invite if any
if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) {
gameHandler.acceptBattlefield();
chatInputBuffer[0] = '\0';
return;
}
// /join ChannelName [password]
if (spacePos != std::string::npos) {
std::string rest = command.substr(spacePos + 1);
size_t pwStart = rest.find(' ');
std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest;
std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : "";
gameHandler.joinChannel(channelName, password);
}
chatInputBuffer[0] = '\0';
return;
} else if (cmdLower == "leave") {
// /leave ChannelName
if (spacePos != std::string::npos) {
std::string channelName = command.substr(spacePos + 1);
gameHandler.leaveChannel(channelName);
}
chatInputBuffer[0] = '\0';
return;
} else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') {
// /1 msg, /2 msg — channel shortcuts
int channelIdx = cmdLower[0] - '0';
std::string channelName = gameHandler.getChannelByIndex(channelIdx);
if (!channelName.empty() && spacePos != std::string::npos) {
message = command.substr(spacePos + 1);
type = game::ChatType::CHANNEL;
target = channelName;
isChannelCommand = true;
} else if (channelName.empty()) {
game::MessageChatData errMsg;
errMsg.type = game::ChatType::SYSTEM;
errMsg.message = "You are not in channel " + std::to_string(channelIdx) + ".";
gameHandler.addLocalChatMessage(errMsg);
chatInputBuffer[0] = '\0';
return;
} else {
chatInputBuffer[0] = '\0';
return;
}
} else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") {
switchChatType = 4;
if (spacePos != std::string::npos) {
std::string rest = command.substr(spacePos + 1);
size_t msgStart = rest.find(' ');
if (msgStart != std::string::npos) {
// /w PlayerName message — send whisper immediately
target = rest.substr(0, msgStart);
message = rest.substr(msgStart + 1);
type = game::ChatType::WHISPER;
isChannelCommand = true;
// Set whisper target for future messages
strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
} else {
// /w PlayerName — switch to whisper mode with target set
strncpy(whisperTargetBuffer, rest.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
message = "";
isChannelCommand = true;
}
} else {
// Just "/w" — switch to whisper mode
message = "";
isChannelCommand = true;
}
}
// Check for emote commands
if (!isChannelCommand) {
2026-02-07 20:02:14 -08:00
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::Renderer::getEmoteText(cmdLower, targetNamePtr);
if (!emoteText.empty()) {
// Play the emote animation
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
renderer->playEmote(cmdLower);
}
// Send CMSG_TEXT_EMOTE to server
uint32_t dbcId = rendering::Renderer::getEmoteDbcId(cmdLower);
if (dbcId != 0) {
uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.sendTextEmote(dbcId, targetGuid);
}
// Add local chat message
game::MessageChatData msg;
msg.type = game::ChatType::TEXT_EMOTE;
msg.language = game::ChatLanguage::COMMON;
2026-02-07 20:02:14 -08:00
msg.message = emoteText;
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// Not a recognized command — fall through and send as normal chat
if (!isChannelCommand) {
message = input;
}
}
// If no valid command found and starts with /, just send as-is
if (!isChannelCommand && message == input) {
// Use the selected chat type from dropdown
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
default: type = game::ChatType::SAY; break;
}
}
} else {
// No slash command, use the selected chat type from dropdown
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
default: type = game::ChatType::SAY; break;
}
}
// Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands.
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 — but switch chat type if a channel shortcut was used
if (!message.empty()) {
gameHandler.sendChatMessage(type, message, target);
}
// Switch chat type dropdown when channel shortcut used (with or without message)
if (switchChatType >= 0) {
selectedChatType = switchChatType;
}
// Clear input
chatInputBuffer[0] = '\0';
}
}
const char* GameScreen::getChatTypeName(game::ChatType type) const {
switch (type) {
case game::ChatType::SAY: return "Say";
case game::ChatType::YELL: return "Yell";
case game::ChatType::EMOTE: return "Emote";
case game::ChatType::TEXT_EMOTE: return "Emote";
case game::ChatType::PARTY: return "Party";
case game::ChatType::GUILD: return "Guild";
case game::ChatType::OFFICER: return "Officer";
case game::ChatType::RAID: return "Raid";
case game::ChatType::RAID_LEADER: return "Raid Leader";
case game::ChatType::RAID_WARNING: return "Raid Warning";
case game::ChatType::BATTLEGROUND: return "Battleground";
case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader";
case game::ChatType::WHISPER: return "Whisper";
case game::ChatType::WHISPER_INFORM: return "To";
case game::ChatType::SYSTEM: return "System";
case game::ChatType::MONSTER_SAY: return "Say";
case game::ChatType::MONSTER_YELL: return "Yell";
case game::ChatType::MONSTER_EMOTE: return "Emote";
case game::ChatType::CHANNEL: return "Channel";
case game::ChatType::ACHIEVEMENT: return "Achievement";
case game::ChatType::DND: return "DND";
case game::ChatType::AFK: return "AFK";
case game::ChatType::BG_SYSTEM_NEUTRAL:
case game::ChatType::BG_SYSTEM_ALLIANCE:
case game::ChatType::BG_SYSTEM_HORDE: return "System";
default: return "Unknown";
}
}
ImVec4 GameScreen::getChatTypeColor(game::ChatType type) const {
switch (type) {
case game::ChatType::SAY:
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White
case game::ChatType::YELL:
return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red
case game::ChatType::EMOTE:
return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange
case game::ChatType::TEXT_EMOTE:
return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange
case game::ChatType::PARTY:
return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue
case game::ChatType::GUILD:
return ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green
case game::ChatType::OFFICER:
return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green
case game::ChatType::RAID:
return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange
case game::ChatType::RAID_LEADER:
return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange
case game::ChatType::RAID_WARNING:
return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red
case game::ChatType::BATTLEGROUND:
return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold
case game::ChatType::BATTLEGROUND_LEADER:
return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange
case game::ChatType::WHISPER:
return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink
case game::ChatType::WHISPER_INFORM:
return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink
case game::ChatType::SYSTEM:
return ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // Yellow
case game::ChatType::MONSTER_SAY:
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (same as SAY)
case game::ChatType::MONSTER_YELL:
return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red (same as YELL)
case game::ChatType::MONSTER_EMOTE:
return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE)
case game::ChatType::CHANNEL:
return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink
case game::ChatType::ACHIEVEMENT:
return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow
default:
return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); // Gray
}
}
void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
uint32_t instanceId = renderer->getCharacterInstanceId();
if (instanceId == 0) return;
auto* charRenderer = renderer->getCharacterRenderer();
if (!charRenderer) return;
auto* assetManager = app.getAssetManager();
// Load ItemDisplayInfo.dbc for geosetGroup lookup
std::shared_ptr<pipeline::DBCFile> displayInfoDbc;
if (assetManager) {
displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
}
// Helper: get geosetGroup field for an equipped item's displayInfoId
// DBC binary fields: 7=geosetGroup_1, 8=geosetGroup_2, 9=geosetGroup_3
auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t {
if (!displayInfoDbc || displayInfoId == 0) return 0;
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
};
// Helper: find first equipped item matching inventoryType, return its displayInfoId
auto findEquippedDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty()) {
for (uint8_t t : types) {
if (slot.item.inventoryType == t)
return slot.item.displayInfoId;
}
}
}
return 0;
};
// Helper: check if any equipment slot has the given inventoryType
auto hasEquippedType = [&](std::initializer_list<uint8_t> types) -> bool {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty()) {
for (uint8_t t : types) {
if (slot.item.inventoryType == t) return true;
}
}
}
return false;
};
// Base geosets always present (group 0: IDs 0-99, some models use up to 27)
std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 99; i++) {
geosets.insert(i);
}
// Hair/facial geosets must match the active character's appearance, otherwise
// we end up forcing a default hair mesh (often perceived as "wrong hair").
{
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (auto* gh = app.getGameHandler()) {
if (const auto* ch = gh->getActiveCharacter()) {
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
facialId = ch->facialFeatures;
}
}
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair
geosets.insert(static_cast<uint16_t>(200 + facialId + 1)); // Group 2 facial
}
geosets.insert(702); // Ears: visible (default)
geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET, always on)
// CharGeosets mapping (verified via vertex bounding boxes):
// Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4)
// Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6)
// Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor)
// Group 9 (901+) = KNEEPADS
// Group 13 (1301+) = TROUSERS/PANTS
// Group 15 (1501+) = CAPE/CLOAK
// Group 20 (2002) = FEET
// Gloves: inventoryType 10 → group 4 (forearms)
// 401=bare forearms, 402+=glove styles covering forearm
{
uint32_t did = findEquippedDisplayId({10});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 401 + gg : 401));
}
// Boots: inventoryType 8 → group 5 (shins/lower legs)
// 501=narrow bare shin, 502=wider (matches thigh width better). Use 502 as bare default.
// When boots equipped, gg selects boot style: 501+gg (gg=1→502, gg=2→503, etc.)
{
uint32_t did = findEquippedDisplayId({8});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 501 + gg : 502));
}
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe)
// Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles
// Also controls group 13 (trousers) via GeosetGroup[2] for robes
{
uint32_t did = findEquippedDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 801 + gg : 801));
// Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+)
uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) {
geosets.insert(static_cast<uint16_t>(1301 + gg3));
}
}
// Kneepads: group 9 (always default 902)
geosets.insert(902);
// Legs/Pants: inventoryType 7 → group 13 (trousers/thighs)
// 1301=bare legs, 1302+=pant/kilt styles
{
uint32_t did = findEquippedDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0);
// Only add if robe hasn't already set a kilt geoset
if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
geosets.insert(static_cast<uint16_t>(gg > 0 ? 1301 + gg : 1301));
}
}
// Back/Cloak: inventoryType 16 → group 15
geosets.insert(hasEquippedType({16}) ? 1502 : 1501);
// Tabard: inventoryType 19 → group 12
if (hasEquippedType({19})) {
geosets.insert(1201);
}
charRenderer->setActiveGeosets(instanceId, geosets);
}
void GameScreen::updateCharacterTextures(game::Inventory& inventory) {
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
auto* charRenderer = renderer->getCharacterRenderer();
if (!charRenderer) return;
auto* assetManager = app.getAssetManager();
if (!assetManager) return;
const auto& bodySkinPath = app.getBodySkinPath();
const auto& underwearPaths = app.getUnderwearPaths();
uint32_t skinSlot = app.getSkinTextureSlotIndex();
if (bodySkinPath.empty()) return;
// Component directory names indexed by region
static const char* componentDirs[] = {
"ArmUpperTexture", // 0
"ArmLowerTexture", // 1
"HandTexture", // 2
"TorsoUpperTexture", // 3
"TorsoLowerTexture", // 4
"LegUpperTexture", // 5
"LegLowerTexture", // 6
"FootTexture", // 7
};
// Load ItemDisplayInfo.dbc
auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc) return;
const auto* idiL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
// Texture component region fields (8 regions: ArmUpper..Foot)
// Binary DBC (23 fields) has textures at 14+
const uint32_t texRegionFields[8] = {
idiL ? (*idiL)["TextureArmUpper"] : 14u,
idiL ? (*idiL)["TextureArmLower"] : 15u,
idiL ? (*idiL)["TextureHand"] : 16u,
idiL ? (*idiL)["TextureTorsoUpper"]: 17u,
idiL ? (*idiL)["TextureTorsoLower"]: 18u,
idiL ? (*idiL)["TextureLegUpper"] : 19u,
idiL ? (*idiL)["TextureLegLower"] : 20u,
idiL ? (*idiL)["TextureFoot"] : 21u,
};
// Collect equipment texture regions from all equipped items
std::vector<std::pair<int, std::string>> regionLayers;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (slot.empty() || slot.item.displayInfoId == 0) continue;
int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId);
if (recIdx < 0) continue;
for (int region = 0; region < 8; region++) {
std::string texName = displayInfoDbc->getString(
static_cast<uint32_t>(recIdx), texRegionFields[region]);
if (texName.empty()) continue;
// Actual MPQ files have a gender suffix: _M (male), _F (female), _U (unisex)
// Try gender-specific first, then unisex fallback
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
// Determine gender suffix from active character
bool isFemale = false;
if (auto* gh = app.getGameHandler()) {
if (auto* ch = gh->getActiveCharacter()) {
isFemale = (ch->gender == game::Gender::FEMALE) ||
(ch->gender == game::Gender::NONBINARY && ch->useFemaleModel);
}
}
std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp");
std::string unisexPath = base + "_U.blp";
std::string fullPath;
if (assetManager->fileExists(genderPath)) {
fullPath = genderPath;
} else if (assetManager->fileExists(unisexPath)) {
fullPath = unisexPath;
} else {
// Last resort: try without suffix
fullPath = base + ".blp";
}
regionLayers.emplace_back(region, fullPath);
}
}
// Re-composite: base skin + underwear + equipment regions
// Clear composite cache first to prevent stale textures from being reused
charRenderer->clearCompositeCache();
// Use per-instance texture override (not model-level) to avoid deleting cached composites.
uint32_t instanceId = renderer->getCharacterInstanceId();
auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers);
if (newTex != nullptr && instanceId != 0) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(skinSlot), newTex);
}
// Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin)
uint32_t cloakSlot = app.getCloakTextureSlotIndex();
if (cloakSlot > 0 && instanceId != 0) {
// Find equipped cloak (inventoryType 16)
uint32_t cloakDisplayId = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (!slot.empty() && slot.item.inventoryType == 16 && slot.item.displayInfoId != 0) {
cloakDisplayId = slot.item.displayInfoId;
break;
}
}
if (cloakDisplayId > 0) {
int32_t recIdx = displayInfoDbc->findRecordById(cloakDisplayId);
if (recIdx >= 0) {
// DBC field 3 = modelTexture_1 (cape texture name)
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
std::string capeName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3);
if (!capeName.empty()) {
std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp";
auto* capeTex = charRenderer->loadTexture(capePath);
if (capeTex != nullptr) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot), capeTex);
LOG_INFO("Cloak texture applied: ", capePath);
}
}
}
} else {
// No cloak equipped — clear override so model's default (white) shows
charRenderer->clearTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot));
}
}
}
// ============================================================
// World Map
// ============================================================
void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
if (!showWorldMap_) return;
auto& app = core::Application::getInstance();
auto* renderer = app.getRenderer();
if (!renderer) return;
auto* wm = renderer->getWorldMap();
if (!wm) return;
// Keep map name in sync with minimap's map name
auto* minimap = renderer->getMinimap();
if (minimap) {
wm->setMapName(minimap->getMapName());
}
wm->setServerExplorationMask(
gameHandler.getPlayerExploredZoneMasks(),
gameHandler.hasPlayerExploredZoneMasks());
glm::vec3 playerPos = renderer->getCharacterPosition();
auto* window = app.getWindow();
int screenW = window ? window->getWidth() : 1280;
int screenH = window ? window->getHeight() : 720;
wm->render(playerPos, screenW, screenH);
}
// ============================================================
// Action Bar (Phase 3)
// ============================================================
VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) {
if (spellId == 0 || !am) return VK_NULL_HANDLE;
// Check cache first
auto cit = spellIconCache_.find(spellId);
if (cit != spellIconCache_.end()) return cit->second;
// Lazy-load SpellIcon.dbc and Spell.dbc icon IDs
if (!spellIconDbLoaded_) {
spellIconDbLoaded_ = true;
// Load SpellIcon.dbc: field 0 = ID, field 1 = icon path
auto iconDbc = am->loadDBC("SpellIcon.dbc");
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
if (iconDbc && iconDbc->isLoaded()) {
for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) {
uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0);
std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1);
if (!path.empty() && id > 0) {
spellIconPaths_[id] = path;
}
}
}
// Load Spell.dbc: SpellIconID field
auto spellDbc = am->loadDBC("Spell.dbc");
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (spellDbc && spellDbc->isLoaded()) {
uint32_t fieldCount = spellDbc->getFieldCount();
// Helper to load icons for a given field layout
auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) {
spellIconIds_.clear();
if (iconField >= fieldCount) return;
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
uint32_t id = spellDbc->getUInt32(i, idField);
uint32_t iconId = spellDbc->getUInt32(i, iconField);
if (id > 0 && iconId > 0) {
spellIconIds_[id] = iconId;
}
}
};
// Always use expansion-aware layout if available
// Field indices vary by expansion: Classic=117, TBC=124, WotLK=133
if (spellL) {
tryLoadIcons((*spellL)["ID"], (*spellL)["IconID"]);
}
// Fallback if expansion layout missing or yielded nothing
// Only use WotLK field 133 as last resort if we have no layout
if (spellIconIds_.empty() && !spellL && fieldCount > 133) {
tryLoadIcons(0, 133);
}
}
}
// Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached
// (e.g., first login, after loading screen, or many new auras appearing at once).
static int gsLoadsThisFrame = 0;
static int gsLastImGuiFrame = -1;
int gsCurFrame = ImGui::GetFrameCount();
if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; }
if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
// Look up spellId -> SpellIconID -> icon path
auto iit = spellIconIds_.find(spellId);
if (iit == spellIconIds_.end()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
auto pit = spellIconPaths_.find(iit->second);
if (pit == spellIconPaths_.end()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
// Path from DBC has no extension — append .blp
std::string iconPath = pit->second + ".blp";
auto blpData = am->readFile(iconPath);
if (blpData.empty()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
auto image = pipeline::BLPLoader::load(blpData);
if (!image.isValid()) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
// Upload to Vulkan via VkContext
auto* window = core::Application::getInstance().getWindow();
auto* vkCtx = window ? window->getVkContext() : nullptr;
if (!vkCtx) {
spellIconCache_[spellId] = VK_NULL_HANDLE;
return VK_NULL_HANDLE;
}
++gsLoadsThisFrame;
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
spellIconCache_[spellId] = ds;
return ds;
}
void GameScreen::renderActionBar(game::GameHandler& gameHandler) {
// Use ImGui's display size — always in sync with the current swap-chain/frame,
// whereas window->getWidth/Height() can lag by one frame on resize events.
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
auto* assetMgr = core::Application::getInstance().getAssetManager();
float slotSize = 48.0f * pendingActionBarScale;
float spacing = 4.0f;
float padding = 8.0f;
float barW = 12 * slotSize + 11 * spacing + padding * 2;
float barH = slotSize + 24.0f;
float barX = (screenW - barW) / 2.0f;
float barY = screenH - barH;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding));
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f));
// Per-slot rendering lambda — shared by both action bars
const auto& bar = gameHandler.getActionBar();
static const char* keyLabels1[] = {"1","2","3","4","5","6","7","8","9","0","-","="};
// "⇧N" labels for bar 2 (UTF-8: E2 87 A7 = U+21E7 UPWARDS WHITE ARROW)
static const char* keyLabels2[] = {
"\xe2\x87\xa7" "1", "\xe2\x87\xa7" "2", "\xe2\x87\xa7" "3",
"\xe2\x87\xa7" "4", "\xe2\x87\xa7" "5", "\xe2\x87\xa7" "6",
"\xe2\x87\xa7" "7", "\xe2\x87\xa7" "8", "\xe2\x87\xa7" "9",
"\xe2\x87\xa7" "0", "\xe2\x87\xa7" "-", "\xe2\x87\xa7" "="
};
auto renderBarSlot = [&](int absSlot, const char* keyLabel) {
ImGui::BeginGroup();
ImGui::PushID(absSlot);
const auto& slot = bar[absSlot];
bool onCooldown = !slot.isReady();
auto getSpellName = [&](uint32_t spellId) -> std::string {
std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr);
if (!name.empty()) return name;
return "Spell #" + std::to_string(spellId);
};
// Try to get icon texture for this slot
VkDescriptorSet iconTex = VK_NULL_HANDLE;
const game::ItemDef* barItemDef = nullptr;
uint32_t itemDisplayInfoId = 0;
std::string itemNameFromQuery;
if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) {
iconTex = getSpellIcon(slot.id, assetMgr);
} else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) {
auto& inv = gameHandler.getInventory();
for (int bi = 0; bi < inv.getBackpackSize(); bi++) {
const auto& bs = inv.getBackpackSlot(bi);
if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; }
}
if (!barItemDef) {
for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) {
const auto& es = inv.getEquipSlot(static_cast<game::EquipSlot>(ei));
if (!es.empty() && es.item.itemId == slot.id) { barItemDef = &es.item; break; }
}
}
if (!barItemDef) {
for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) {
for (int si = 0; si < inv.getBagSize(bag); si++) {
const auto& bs = inv.getBagSlot(bag, si);
if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; }
}
}
}
if (barItemDef && barItemDef->displayInfoId != 0)
itemDisplayInfoId = barItemDef->displayInfoId;
if (itemDisplayInfoId == 0) {
if (auto* info = gameHandler.getItemInfo(slot.id)) {
itemDisplayInfoId = info->displayInfoId;
if (itemNameFromQuery.empty() && !info->name.empty())
itemNameFromQuery = info->name;
}
}
if (itemDisplayInfoId != 0)
iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId);
}
bool clicked = false;
if (iconTex) {
ImVec4 tintColor(1, 1, 1, 1);
ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f);
if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); }
clicked = ImGui::ImageButton("##icon",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(slotSize, slotSize),
ImVec2(0, 0), ImVec2(1, 1),
bgColor, tintColor);
} else {
if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f));
else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f));
else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f));
char label[32];
if (slot.type == game::ActionBarSlot::SPELL) {
std::string spellName = getSpellName(slot.id);
if (spellName.size() > 6) spellName = spellName.substr(0, 6);
snprintf(label, sizeof(label), "%s", spellName.c_str());
} else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) {
std::string itemName = barItemDef->name;
if (itemName.size() > 6) itemName = itemName.substr(0, 6);
snprintf(label, sizeof(label), "%s", itemName.c_str());
} else if (slot.type == game::ActionBarSlot::ITEM) {
snprintf(label, sizeof(label), "Item");
} else if (slot.type == game::ActionBarSlot::MACRO) {
snprintf(label, sizeof(label), "Macro");
} else {
snprintf(label, sizeof(label), "--");
}
clicked = ImGui::Button(label, ImVec2(slotSize, slotSize));
ImGui::PopStyleColor();
}
bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
ImGui::IsMouseReleased(ImGuiMouseButton_Left);
if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) {
gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::SPELL,
spellbookScreen.getDragSpellId());
spellbookScreen.consumeDragSpell();
} else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) {
const auto& held = inventoryScreen.getHeldItem();
gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::ITEM, held.itemId);
inventoryScreen.returnHeldItem(gameHandler.getInventory());
} else if (clicked && actionBarDragSlot_ >= 0) {
if (absSlot != actionBarDragSlot_) {
const auto& dragSrc = bar[actionBarDragSlot_];
gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id);
gameHandler.setActionBarSlot(absSlot, dragSrc.type, dragSrc.id);
}
actionBarDragSlot_ = -1;
actionBarDragIcon_ = 0;
} else if (clicked && !slot.isEmpty()) {
if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(slot.id, target);
} else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) {
gameHandler.useItemById(slot.id);
}
}
// Right-click context menu for non-empty slots
if (!slot.isEmpty()) {
// Use a unique popup ID per slot so multiple slots don't share state
char ctxId[32];
snprintf(ctxId, sizeof(ctxId), "##ABCtx%d", absSlot);
if (ImGui::BeginPopupContextItem(ctxId)) {
if (slot.type == game::ActionBarSlot::SPELL) {
std::string spellName = getSpellName(slot.id);
ImGui::TextDisabled("%s", spellName.c_str());
ImGui::Separator();
if (onCooldown) ImGui::BeginDisabled();
if (ImGui::MenuItem("Cast")) {
uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(slot.id, target);
}
if (onCooldown) ImGui::EndDisabled();
} else if (slot.type == game::ActionBarSlot::ITEM) {
const char* iName = (barItemDef && !barItemDef->name.empty())
? barItemDef->name.c_str()
: (!itemNameFromQuery.empty() ? itemNameFromQuery.c_str() : "Item");
ImGui::TextDisabled("%s", iName);
ImGui::Separator();
if (ImGui::MenuItem("Use")) {
gameHandler.useItemById(slot.id);
}
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Slot")) {
gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0);
}
ImGui::EndPopup();
}
}
// Tooltip
if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) {
if (slot.type == game::ActionBarSlot::SPELL) {
// Use the spellbook's rich tooltip (school, cost, cast time, range, description).
// Falls back to the simple name if DBC data isn't loaded yet.
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr);
if (!richOk) {
ImGui::Text("%s", getSpellName(slot.id).c_str());
}
// Hearthstone: add location note after the spell tooltip body
if (slot.id == 8690) {
uint32_t mapId = 0; glm::vec3 pos;
if (gameHandler.getHomeBind(mapId, pos)) {
const char* mapName = "Unknown";
switch (mapId) {
case 0: mapName = "Eastern Kingdoms"; break;
case 1: mapName = "Kalimdor"; break;
case 530: mapName = "Outland"; break;
case 571: mapName = "Northrend"; break;
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName);
}
}
if (onCooldown) {
float cd = slot.cooldownRemaining;
if (cd >= 60.0f)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Cooldown: %d min %d sec", (int)cd/60, (int)cd%60);
else
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd);
}
ImGui::EndTooltip();
} else if (slot.type == game::ActionBarSlot::ITEM) {
ImGui::BeginTooltip();
if (barItemDef && !barItemDef->name.empty())
ImGui::Text("%s", barItemDef->name.c_str());
else if (!itemNameFromQuery.empty())
ImGui::Text("%s", itemNameFromQuery.c_str());
else
ImGui::Text("Item #%u", slot.id);
if (onCooldown) {
float cd = slot.cooldownRemaining;
if (cd >= 60.0f)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Cooldown: %d min %d sec", (int)cd/60, (int)cd%60);
else
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd);
}
ImGui::EndTooltip();
}
}
// Cooldown overlay: WoW-style clock-sweep + time text
if (onCooldown) {
ImVec2 btnMin = ImGui::GetItemRectMin();
ImVec2 btnMax = ImGui::GetItemRectMax();
float cx = (btnMin.x + btnMax.x) * 0.5f;
float cy = (btnMin.y + btnMax.y) * 0.5f;
float r = (btnMax.x - btnMin.x) * 0.5f;
auto* dl = ImGui::GetWindowDrawList();
float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f;
float elapsed = total - slot.cooldownRemaining;
float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total));
if (elapsedFrac > 0.005f) {
constexpr int N_SEGS = 32;
float startAngle = -IM_PI * 0.5f;
float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI;
float fanR = r * 1.5f;
ImVec2 pts[N_SEGS + 2];
pts[0] = ImVec2(cx, cy);
for (int s = 0; s <= N_SEGS; ++s) {
float a = startAngle + (endAngle - startAngle) * s / static_cast<float>(N_SEGS);
pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR);
}
dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170));
}
char cdText[16];
float cd = slot.cooldownRemaining;
if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600);
else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60);
else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", (int)cd);
else snprintf(cdText, sizeof(cdText), "%.1f", cd);
ImVec2 textSize = ImGui::CalcTextSize(cdText);
float tx = cx - textSize.x * 0.5f;
float ty = cy - textSize.y * 0.5f;
dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText);
dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText);
}
// Item stack count overlay — bottom-right corner of icon
if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) {
// Count total of this item across all inventory slots
auto& inv = gameHandler.getInventory();
int totalCount = 0;
for (int bi = 0; bi < inv.getBackpackSize(); bi++) {
const auto& bs = inv.getBackpackSlot(bi);
if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount;
}
for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) {
for (int si = 0; si < inv.getBagSize(bag); si++) {
const auto& bs = inv.getBagSlot(bag, si);
if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount;
}
}
if (totalCount > 0) {
char countStr[8];
snprintf(countStr, sizeof(countStr), "%d", totalCount);
ImVec2 btnMax = ImGui::GetItemRectMax();
ImVec2 tsz = ImGui::CalcTextSize(countStr);
float cx2 = btnMax.x - tsz.x - 2.0f;
float cy2 = btnMax.y - tsz.y - 1.0f;
auto* cdl = ImGui::GetWindowDrawList();
cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr);
cdl->AddText(ImVec2(cx2, cy2),
totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255),
countStr);
}
}
// Key label below
ImGui::TextDisabled("%s", keyLabel);
ImGui::PopID();
ImGui::EndGroup();
};
// Bar 2 (slots 12-23) — only show if at least one slot is populated
if (pendingShowActionBar2) {
bool bar2HasContent = false;
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i)
if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; }
float bar2X = barX + pendingActionBar2OffsetX;
float bar2Y = barY - barH - 2.0f + pendingActionBar2OffsetY;
ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding));
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg,
bar2HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f));
if (ImGui::Begin("##ActionBar2", nullptr, flags)) {
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
if (i > 0) ImGui::SameLine(0, spacing);
renderBarSlot(game::GameHandler::SLOTS_PER_BAR + i, keyLabels2[i]);
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(4);
}
// Bar 1 (slots 0-11)
if (ImGui::Begin("##ActionBar", nullptr, flags)) {
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
if (i > 0) ImGui::SameLine(0, spacing);
renderBarSlot(i, keyLabels1[i]);
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(4);
// Right side vertical bar (bar 3, slots 24-35)
if (pendingShowRightBar) {
bool bar3HasContent = false;
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i)
if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; }
float sideBarW = slotSize + padding * 2;
float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2;
float sideBarX = screenW - sideBarW - 4.0f;
float sideBarY = (screenH - sideBarH) / 2.0f + pendingRightBarOffsetY;
ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding));
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg,
bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f));
if (ImGui::Begin("##ActionBarRight", nullptr, flags)) {
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, "");
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(4);
}
// Left side vertical bar (bar 4, slots 36-47)
if (pendingShowLeftBar) {
bool bar4HasContent = false;
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i)
if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; }
float sideBarW = slotSize + padding * 2;
float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2;
float sideBarX = 4.0f;
float sideBarY = (screenH - sideBarH) / 2.0f + pendingLeftBarOffsetY;
ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding));
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg,
bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f));
if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) {
for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) {
renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, "");
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(4);
}
// Handle action bar drag: render icon at cursor and detect drop outside
if (actionBarDragSlot_ >= 0) {
ImVec2 mousePos = ImGui::GetMousePos();
// Draw dragged icon at cursor
if (actionBarDragIcon_) {
ImGui::GetForegroundDrawList()->AddImage(
(ImTextureID)(uintptr_t)actionBarDragIcon_,
ImVec2(mousePos.x - 20, mousePos.y - 20),
ImVec2(mousePos.x + 20, mousePos.y + 20));
} else {
ImGui::GetForegroundDrawList()->AddRectFilled(
ImVec2(mousePos.x - 20, mousePos.y - 20),
ImVec2(mousePos.x + 20, mousePos.y + 20),
IM_COL32(80, 80, 120, 180));
}
// On right mouse release, check if outside the action bar area
if (ImGui::IsMouseReleased(ImGuiMouseButton_Right)) {
bool insideBar = (mousePos.x >= barX && mousePos.x <= barX + barW &&
mousePos.y >= barY && mousePos.y <= barY + barH);
if (!insideBar) {
// Dropped outside - clear the slot
gameHandler.setActionBarSlot(actionBarDragSlot_, game::ActionBarSlot::EMPTY, 0);
}
actionBarDragSlot_ = -1;
actionBarDragIcon_ = 0;
}
}
}
// ============================================================
// Bag Bar
// ============================================================
void GameScreen::renderBagBar(game::GameHandler& gameHandler) {
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
auto* assetMgr = core::Application::getInstance().getAssetManager();
float slotSize = 42.0f;
float spacing = 4.0f;
float padding = 6.0f;
// 5 slots: backpack + 4 bags
float barW = 5 * slotSize + 4 * spacing + padding * 2;
float barH = slotSize + padding * 2;
// Position in bottom right corner
float barX = screenW - barW - 10.0f;
float barY = screenH - barH - 10.0f;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding));
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f));
if (ImGui::Begin("##BagBar", nullptr, flags)) {
auto& inv = gameHandler.getInventory();
// Load backpack icon if needed
if (!backpackIconTexture_ && assetMgr && assetMgr->isInitialized()) {
auto blpData = assetMgr->readFile("Interface\\Buttons\\Button-Backpack-Up.blp");
if (!blpData.empty()) {
auto image = pipeline::BLPLoader::load(blpData);
if (image.isValid()) {
auto* w = core::Application::getInstance().getWindow();
auto* vkCtx = w ? w->getVkContext() : nullptr;
if (vkCtx)
backpackIconTexture_ = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
}
}
}
// Track bag slot screen rects for drop detection
ImVec2 bagSlotMins[4], bagSlotMaxs[4];
// Slots 1-4: Bag slots (leftmost)
for (int i = 0; i < 4; ++i) {
if (i > 0) ImGui::SameLine(0, spacing);
ImGui::PushID(i + 1);
game::EquipSlot bagSlot = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + i);
const auto& bagItem = inv.getEquipSlot(bagSlot);
VkDescriptorSet bagIcon = VK_NULL_HANDLE;
if (!bagItem.empty() && bagItem.item.displayInfoId != 0) {
bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId);
}
// Render the slot as an invisible button so we control all interaction
ImVec2 cpos = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##bagSlot", ImVec2(slotSize, slotSize));
bagSlotMins[i] = cpos;
bagSlotMaxs[i] = ImVec2(cpos.x + slotSize, cpos.y + slotSize);
ImDrawList* dl = ImGui::GetWindowDrawList();
// Draw background + icon
if (bagIcon) {
dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(25, 25, 25, 230));
dl->AddImage((ImTextureID)(uintptr_t)bagIcon, bagSlotMins[i], bagSlotMaxs[i]);
} else {
dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(38, 38, 38, 204));
}
// Hover highlight
bool hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem);
if (hovered && bagBarPickedSlot_ < 0) {
dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 100));
}
// Track which slot was pressed for drag detection
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && bagBarPickedSlot_ < 0 && bagIcon) {
bagBarDragSource_ = i;
}
// Click toggles bag open/close (handled in mouse release section below)
// Dim the slot being dragged
if (bagBarPickedSlot_ == i) {
dl->AddRectFilled(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(0, 0, 0, 150));
}
// Tooltip
if (hovered && bagBarPickedSlot_ < 0) {
if (bagIcon)
ImGui::SetTooltip("%s", bagItem.item.name.c_str());
else
ImGui::SetTooltip("Empty Bag Slot");
}
// Open bag indicator
if (inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i)) {
dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f);
}
// Right-click context menu
if (ImGui::BeginPopupContextItem("##bagSlotCtx")) {
if (!bagItem.empty()) {
ImGui::TextDisabled("%s", bagItem.item.name.c_str());
ImGui::Separator();
bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i);
if (ImGui::MenuItem(isOpen ? "Close Bag" : "Open Bag")) {
if (inventoryScreen.isSeparateBags())
inventoryScreen.toggleBag(i);
else
inventoryScreen.toggle();
}
if (ImGui::MenuItem("Unequip Bag")) {
gameHandler.unequipToBackpack(bagSlot);
}
} else {
ImGui::TextDisabled("Empty Bag Slot");
}
ImGui::EndPopup();
}
// Accept dragged item from inventory
if (hovered && inventoryScreen.isHoldingItem()) {
const auto& heldItem = inventoryScreen.getHeldItem();
if ((heldItem.inventoryType == 18 || heldItem.bagSlots > 0) &&
ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
auto& inventory = gameHandler.getInventory();
inventoryScreen.dropHeldItemToEquipSlot(inventory, bagSlot);
}
}
ImGui::PopID();
}
// Drag lifecycle: press on a slot sets bagBarDragSource_,
// dragging 3+ pixels promotes to bagBarPickedSlot_ (visual drag),
// releasing completes swap or click
if (bagBarDragSource_ >= 0) {
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 3.0f) && bagBarPickedSlot_ < 0) {
// If an inventory window is open, hand off drag to inventory held-item
// so the bag can be dropped into backpack/bag slots.
if (inventoryScreen.isOpen() || inventoryScreen.isCharacterOpen()) {
auto equip = static_cast<game::EquipSlot>(
static_cast<int>(game::EquipSlot::BAG1) + bagBarDragSource_);
if (inventoryScreen.beginPickupFromEquipSlot(inv, equip)) {
bagBarDragSource_ = -1;
} else {
bagBarPickedSlot_ = bagBarDragSource_;
}
} else {
// Mouse moved enough — start visual drag
bagBarPickedSlot_ = bagBarDragSource_;
}
}
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
if (bagBarPickedSlot_ >= 0) {
// Was dragging — check for drop target
ImVec2 mousePos = ImGui::GetIO().MousePos;
int dropTarget = -1;
for (int j = 0; j < 4; ++j) {
if (j == bagBarPickedSlot_) continue;
if (mousePos.x >= bagSlotMins[j].x && mousePos.x <= bagSlotMaxs[j].x &&
mousePos.y >= bagSlotMins[j].y && mousePos.y <= bagSlotMaxs[j].y) {
dropTarget = j;
break;
}
}
if (dropTarget >= 0) {
gameHandler.swapBagSlots(bagBarPickedSlot_, dropTarget);
}
bagBarPickedSlot_ = -1;
} else {
// Was just a click (no drag) — toggle bag
int slot = bagBarDragSource_;
auto equip = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + slot);
if (!inv.getEquipSlot(equip).empty()) {
if (inventoryScreen.isSeparateBags())
inventoryScreen.toggleBag(slot);
else
inventoryScreen.toggle();
}
}
bagBarDragSource_ = -1;
}
}
// Backpack (rightmost slot)
ImGui::SameLine(0, spacing);
ImGui::PushID(0);
if (backpackIconTexture_) {
if (ImGui::ImageButton("##backpack", (ImTextureID)(uintptr_t)backpackIconTexture_,
ImVec2(slotSize, slotSize),
ImVec2(0, 0), ImVec2(1, 1),
ImVec4(0.1f, 0.1f, 0.1f, 0.9f),
ImVec4(1, 1, 1, 1))) {
if (inventoryScreen.isSeparateBags())
inventoryScreen.toggleBackpack();
else
inventoryScreen.toggle();
}
} else {
if (ImGui::Button("B", ImVec2(slotSize, slotSize))) {
if (inventoryScreen.isSeparateBags())
inventoryScreen.toggleBackpack();
else
inventoryScreen.toggle();
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Backpack");
}
// Right-click context menu on backpack
if (ImGui::BeginPopupContextItem("##backpackCtx")) {
bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen();
if (ImGui::MenuItem(isOpen ? "Close Backpack" : "Open Backpack")) {
if (inventoryScreen.isSeparateBags())
inventoryScreen.toggleBackpack();
else
inventoryScreen.toggle();
}
ImGui::Separator();
if (ImGui::MenuItem("Open All Bags")) {
inventoryScreen.openAllBags();
}
if (ImGui::MenuItem("Close All Bags")) {
inventoryScreen.closeAllBags();
}
ImGui::EndPopup();
}
if (inventoryScreen.isSeparateBags() &&
inventoryScreen.isBackpackOpen()) {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 r0 = ImGui::GetItemRectMin();
ImVec2 r1 = ImGui::GetItemRectMax();
dl->AddRect(r0, r1, IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f);
}
ImGui::PopID();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(4);
// Draw dragged bag icon following cursor
if (bagBarPickedSlot_ >= 0) {
auto& inv2 = gameHandler.getInventory();
auto pickedEquip = static_cast<game::EquipSlot>(
static_cast<int>(game::EquipSlot::BAG1) + bagBarPickedSlot_);
const auto& pickedItem = inv2.getEquipSlot(pickedEquip);
VkDescriptorSet pickedIcon = VK_NULL_HANDLE;
if (!pickedItem.empty() && pickedItem.item.displayInfoId != 0) {
pickedIcon = inventoryScreen.getItemIcon(pickedItem.item.displayInfoId);
}
if (pickedIcon) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
float sz = 40.0f;
ImVec2 p0(mousePos.x - sz * 0.5f, mousePos.y - sz * 0.5f);
ImVec2 p1(mousePos.x + sz * 0.5f, mousePos.y + sz * 0.5f);
ImDrawList* fg = ImGui::GetForegroundDrawList();
fg->AddImage((ImTextureID)(uintptr_t)pickedIcon, p0, p1);
fg->AddRect(p0, p1, IM_COL32(200, 200, 200, 255), 0.0f, 0, 2.0f);
}
}
}
// ============================================================
// XP Bar
// ============================================================
void GameScreen::renderXpBar(game::GameHandler& gameHandler) {
uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp();
if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized)
uint32_t currentXp = gameHandler.getPlayerXp();
uint32_t restedXp = gameHandler.getPlayerRestedXp();
bool isResting = gameHandler.isPlayerResting();
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
auto* window = core::Application::getInstance().getWindow();
(void)window; // Not used for positioning; kept for AssetManager if needed
// Position just above both action bars (bar1 at screenH-barH, bar2 above that)
float slotSize = 48.0f * pendingActionBarScale;
float spacing = 4.0f;
float padding = 8.0f;
float barW = 12 * slotSize + 11 * spacing + padding * 2;
float barH = slotSize + 24.0f;
float xpBarH = 20.0f;
float xpBarW = barW;
float xpBarX = (screenW - xpBarW) / 2.0f;
// XP bar sits just above whichever bar is topmost.
// bar1 top edge: screenH - barH
// bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset
float bar1TopY = screenH - barH;
float xpBarY;
if (pendingShowActionBar2) {
float bar2TopY = bar1TopY - barH - 2.0f + pendingActionBar2OffsetY;
xpBarY = bar2TopY - xpBarH - 2.0f;
} else {
xpBarY = bar1TopY - xpBarH - 2.0f;
}
ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f));
if (ImGui::Begin("##XpBar", nullptr, flags)) {
float pct = static_cast<float>(currentXp) / static_cast<float>(nextLevelXp);
if (pct > 1.0f) pct = 1.0f;
// Custom segmented XP bar (20 bubbles)
ImVec2 barMin = ImGui::GetCursorScreenPos();
ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f);
ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y);
auto* drawList = ImGui::GetWindowDrawList();
ImU32 bg = IM_COL32(15, 15, 20, 220);
ImU32 fg = IM_COL32(148, 51, 238, 255);
ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion
ImU32 seg = IM_COL32(35, 35, 45, 255);
drawList->AddRectFilled(barMin, barMax, bg, 2.0f);
drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f);
float fillW = barSize.x * pct;
if (fillW > 0.0f) {
drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f);
}
// Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill
if (restedXp > 0) {
float restedEndPct = std::min(1.0f, static_cast<float>(currentXp + restedXp)
/ static_cast<float>(nextLevelXp));
float restedStartX = barMin.x + fillW;
float restedEndX = barMin.x + barSize.x * restedEndPct;
if (restedEndX > restedStartX) {
drawList->AddRectFilled(ImVec2(restedStartX, barMin.y),
ImVec2(restedEndX, barMax.y),
fgRest, 2.0f);
}
}
const int segments = 20;
float segW = barSize.x / static_cast<float>(segments);
for (int i = 1; i < segments; ++i) {
float x = barMin.x + segW * i;
drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f);
}
// Rest indicator "zzz" to the right of the bar when resting
if (isResting) {
const char* zzz = "zzz";
ImVec2 zSize = ImGui::CalcTextSize(zzz);
float zx = barMax.x - zSize.x - 4.0f;
float zy = barMin.y + (barSize.y - zSize.y) * 0.5f;
drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz);
}
char overlay[96];
if (restedXp > 0) {
snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp);
} else {
snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp);
}
ImVec2 textSize = ImGui::CalcTextSize(overlay);
float tx = barMin.x + (barSize.x - textSize.x) * 0.5f;
float ty = barMin.y + (barSize.y - textSize.y) * 0.5f;
drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay);
ImGui::Dummy(barSize);
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar(2);
}
// ============================================================
// Cast Bar (Phase 3)
// ============================================================
void GameScreen::renderCastBar(game::GameHandler& gameHandler) {
if (!gameHandler.isCasting()) return;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
float barW = 300.0f;
float barX = (screenW - barW) / 2.0f;
float barY = screenH - 120.0f;
ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(barW, 40), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f));
if (ImGui::Begin("##CastBar", nullptr, flags)) {
const bool channeling = gameHandler.isChanneling();
// Channels drain right-to-left; regular casts fill left-to-right
float progress = channeling
? (1.0f - gameHandler.getCastProgress())
: gameHandler.getCastProgress();
ImVec4 barColor = channeling
? ImVec4(0.3f, 0.6f, 0.9f, 1.0f) // blue for channels
: ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
char overlay[64];
uint32_t currentSpellId = gameHandler.getCurrentCastSpellId();
if (currentSpellId == 0) {
snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining());
} else {
const std::string& spellName = gameHandler.getSpellName(currentSpellId);
const char* verb = channeling ? "Channeling" : "Casting";
if (!spellName.empty())
snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining());
else
snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining());
}
ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay);
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Mirror Timers (breath / fatigue / feign death)
// ============================================================
void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) {
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
static const struct { const char* label; ImVec4 color; } kTimerInfo[3] = {
{ "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) },
{ "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) },
{ "Feign", ImVec4(0.6f, 0.6f, 0.6f, 1.0f) },
};
float barW = 280.0f;
float barH = 36.0f;
float barX = (screenW - barW) / 2.0f;
float baseY = screenH - 160.0f; // Just above the cast bar slot
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoInputs;
for (int i = 0; i < 3; ++i) {
const auto& t = gameHandler.getMirrorTimer(i);
if (!t.active || t.maxValue <= 0) continue;
float frac = static_cast<float>(t.value) / static_cast<float>(t.maxValue);
frac = std::max(0.0f, std::min(1.0f, frac));
char winId[32];
std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i);
ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f));
if (ImGui::Begin(winId, nullptr, flags)) {
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color);
char overlay[48];
float sec = static_cast<float>(t.value) / 1000.0f;
std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec);
ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay);
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
}
// ============================================================
// Quest Objective Tracker (right-side HUD)
// ============================================================
void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
const auto& questLog = gameHandler.getQuestLog();
if (questLog.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
constexpr float TRACKER_W = 220.0f;
constexpr float RIGHT_MARGIN = 10.0f;
constexpr int MAX_QUESTS = 5;
// Build display list: tracked quests only, or all quests if none tracked
const auto& trackedIds = gameHandler.getTrackedQuestIds();
std::vector<const game::GameHandler::QuestLogEntry*> toShow;
toShow.reserve(MAX_QUESTS);
if (!trackedIds.empty()) {
for (const auto& q : questLog) {
if (q.questId == 0) continue;
if (trackedIds.count(q.questId)) toShow.push_back(&q);
if (static_cast<int>(toShow.size()) >= MAX_QUESTS) break;
}
}
// Fallback: show all quests if nothing is tracked
if (toShow.empty()) {
for (const auto& q : questLog) {
if (q.questId == 0) continue;
toShow.push_back(&q);
if (static_cast<int>(toShow.size()) >= MAX_QUESTS) break;
}
}
if (toShow.empty()) return;
float x = screenW - TRACKER_W - RIGHT_MARGIN;
float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px)
ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f));
if (ImGui::Begin("##QuestTracker", nullptr, flags)) {
for (int i = 0; i < static_cast<int>(toShow.size()); ++i) {
const auto& q = *toShow[i];
// Clickable quest title — opens quest log
ImGui::PushID(q.questId);
ImVec4 titleCol = q.complete ? ImVec4(1.0f, 0.84f, 0.0f, 1.0f)
: ImVec4(1.0f, 1.0f, 0.85f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_Text, titleCol);
if (ImGui::Selectable(q.title.c_str(), false,
ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) {
questLogScreen.openAndSelectQuest(q.questId);
}
if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) {
ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options");
}
ImGui::PopStyleColor();
// Right-click context menu for quest tracker entry
if (ImGui::BeginPopupContextItem("##QTCtx")) {
ImGui::TextDisabled("%s", q.title.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Open in Quest Log")) {
questLogScreen.openAndSelectQuest(q.questId);
}
bool tracked = gameHandler.isQuestTracked(q.questId);
if (tracked) {
if (ImGui::MenuItem("Stop Tracking")) {
gameHandler.setQuestTracked(q.questId, false);
}
} else {
if (ImGui::MenuItem("Track")) {
gameHandler.setQuestTracked(q.questId, true);
}
}
if (!q.complete) {
ImGui::Separator();
if (ImGui::MenuItem("Abandon Quest")) {
gameHandler.abandonQuest(q.questId);
gameHandler.setQuestTracked(q.questId, false);
}
}
ImGui::EndPopup();
}
ImGui::PopID();
// Objectives line (condensed)
if (q.complete) {
ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), " (Complete)");
} else {
// Kill counts — green when complete, gray when in progress
for (const auto& [entry, progress] : q.killCounts) {
bool objDone = (progress.first >= progress.second && progress.second > 0);
ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f)
: ImVec4(0.75f, 0.75f, 0.75f, 1.0f);
std::string name = gameHandler.getCachedCreatureName(entry);
if (name.empty()) {
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
if (goInfo && !goInfo->name.empty()) name = goInfo->name;
}
if (!name.empty()) {
ImGui::TextColored(objColor,
" %s: %u/%u", name.c_str(),
progress.first, progress.second);
} else {
ImGui::TextColored(objColor,
" %u/%u", progress.first, progress.second);
}
}
// Item counts — green when complete, gray when in progress
for (const auto& [itemId, count] : q.itemCounts) {
uint32_t required = 1;
auto reqIt = q.requiredItemCounts.find(itemId);
if (reqIt != q.requiredItemCounts.end()) required = reqIt->second;
bool objDone = (count >= required);
ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f)
: ImVec4(0.75f, 0.75f, 0.75f, 1.0f);
const auto* info = gameHandler.getItemInfo(itemId);
const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr;
// Show small icon if available
uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0;
VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12));
ImGui::SameLine(0, 3);
ImGui::TextColored(objColor,
"%s: %u/%u", itemName ? itemName : "Item", count, required);
} else if (itemName) {
ImGui::TextColored(objColor,
" %s: %u/%u", itemName, count, required);
} else {
ImGui::TextColored(objColor,
" Item: %u/%u", count, required);
}
}
if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) {
const std::string& obj = q.objectives;
if (obj.size() > 40) {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
" %.37s...", obj.c_str());
} else {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
" %s", obj.c_str());
}
}
}
if (i < static_cast<int>(toShow.size()) - 1) {
ImGui::Spacing();
}
}
}
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
}
// ============================================================
// Raid Warning / Boss Emote Center-Screen Overlay
// ============================================================
void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) {
// Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages
const auto& chatHistory = gameHandler.getChatHistory();
size_t newCount = chatHistory.size();
if (newCount > raidWarnChatSeenCount_) {
// Walk only the new messages (deque — iterate from back by skipping old ones)
size_t toScan = newCount - raidWarnChatSeenCount_;
size_t startIdx = newCount > toScan ? newCount - toScan : 0;
for (size_t i = startIdx; i < newCount; ++i) {
const auto& msg = chatHistory[i];
if (msg.type == game::ChatType::RAID_WARNING ||
msg.type == game::ChatType::RAID_BOSS_EMOTE ||
msg.type == game::ChatType::MONSTER_EMOTE) {
bool isBoss = (msg.type != game::ChatType::RAID_WARNING);
// Limit display text length to avoid giant overlay
std::string text = msg.message;
if (text.size() > 200) text = text.substr(0, 200) + "...";
raidWarnEntries_.push_back({text, 0.0f, isBoss});
if (raidWarnEntries_.size() > 3)
raidWarnEntries_.erase(raidWarnEntries_.begin());
}
}
raidWarnChatSeenCount_ = newCount;
}
// Age and remove expired entries
float dt = ImGui::GetIO().DeltaTime;
for (auto& e : raidWarnEntries_) e.age += dt;
raidWarnEntries_.erase(
std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(),
[](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }),
raidWarnEntries_.end());
if (raidWarnEntries_.empty()) return;
ImGuiIO& io = ImGui::GetIO();
float screenW = io.DisplaySize.x;
float screenH = io.DisplaySize.y;
ImDrawList* fg = ImGui::GetForegroundDrawList();
// Stack entries vertically near upper-center (below target frame area)
float baseY = screenH * 0.28f;
for (const auto& e : raidWarnEntries_) {
float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f);
// Fade in quickly, hold, then fade out last 20%
if (e.age < 0.3f) alpha = e.age / 0.3f;
// Truncate to fit screen width reasonably
const char* txt = e.text.c_str();
const float fontSize = 22.0f;
ImFont* font = ImGui::GetFont();
// Word-wrap manually: compute text size, center horizontally
float maxW = screenW * 0.7f;
ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt);
float tx = (screenW - textSz.x) * 0.5f;
ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast<int>(alpha * 200));
ImU32 mainCol;
if (e.isBossEmote) {
mainCol = IM_COL32(255, 185, 60, static_cast<int>(alpha * 255)); // amber
} else {
// Raid warning: alternating red/yellow flash during first second
float flashT = std::fmod(e.age * 4.0f, 1.0f);
if (flashT < 0.5f)
mainCol = IM_COL32(255, 50, 50, static_cast<int>(alpha * 255));
else
mainCol = IM_COL32(255, 220, 50, static_cast<int>(alpha * 255));
}
// Background dim box for readability
float pad = 8.0f;
fg->AddRectFilled(ImVec2(tx - pad, baseY - pad),
ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad),
IM_COL32(0, 0, 0, static_cast<int>(alpha * 120)), 4.0f);
// Shadow + main text
fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt,
nullptr, maxW);
fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt,
nullptr, maxW);
baseY += textSz.y + 6.0f;
}
}
// ============================================================
// Floating Combat Text (Phase 2)
// ============================================================
void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
const auto& entries = gameHandler.getCombatText();
if (entries.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Render combat text entries overlaid on screen
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, 400));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
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;
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;
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::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:
snprintf(text, sizeof(text), "-%d", entry.amount);
color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental
break;
case game::CombatTextEntry::ENERGIZE:
snprintf(text, sizeof(text), "+%d", entry.amount);
color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy
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;
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;
float xOffset = baseX + (idx % 3 - 1) * 60.0f;
++idx;
ImGui::SetCursorPos(ImVec2(xOffset, yOffset));
ImGui::TextColored(color, "%s", text);
}
}
ImGui::End();
}
// ============================================================
// DPS / HPS Meter
// ============================================================
void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) {
if (!showDPSMeter_) return;
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
const float dt = ImGui::GetIO().DeltaTime;
// Track combat duration for accurate DPS denominator in short fights
bool inCombat = gameHandler.isInCombat();
if (inCombat) {
dpsCombatAge_ += dt;
} else if (dpsWasInCombat_) {
// Just left combat — let meter show last reading for LIFETIME then reset
dpsCombatAge_ = 0.0f;
}
dpsWasInCombat_ = inCombat;
// Sum all player-source damage and healing in the current combat-text window
float totalDamage = 0.0f, totalHeal = 0.0f;
for (const auto& e : gameHandler.getCombatText()) {
if (!e.isPlayerSource) continue;
switch (e.type) {
case game::CombatTextEntry::MELEE_DAMAGE:
case game::CombatTextEntry::SPELL_DAMAGE:
case game::CombatTextEntry::CRIT_DAMAGE:
case game::CombatTextEntry::PERIODIC_DAMAGE:
totalDamage += static_cast<float>(e.amount);
break;
case game::CombatTextEntry::HEAL:
case game::CombatTextEntry::CRIT_HEAL:
case game::CombatTextEntry::PERIODIC_HEAL:
totalHeal += static_cast<float>(e.amount);
break;
default: break;
}
}
// Only show if there's something to report
if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat) return;
// DPS window = min(combat age, combat-text lifetime) to avoid under-counting
// at the start of a fight and over-counting when entries expire.
float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME);
if (window < 0.1f) window = 0.1f;
float dps = totalDamage / window;
float hps = totalHeal / window;
// Format numbers with K/M suffix for readability
auto fmtNum = [](float v, char* buf, int bufSz) {
if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f);
else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f);
else snprintf(buf, bufSz, "%.0f", v);
};
char dpsBuf[16], hpsBuf[16];
fmtNum(dps, dpsBuf, sizeof(dpsBuf));
fmtNum(hps, hpsBuf, sizeof(hpsBuf));
// Position: small floating label just above the action bar, right of center
auto* appWin = core::Application::getInstance().getWindow();
float screenW = appWin ? static_cast<float>(appWin->getWidth()) : 1280.0f;
float screenH = appWin ? static_cast<float>(appWin->getHeight()) : 720.0f;
constexpr float WIN_W = 90.0f;
constexpr float WIN_H = 36.0f;
float wx = screenW * 0.5f + 160.0f; // right of cast bar
float wy = screenH - 130.0f; // above action bar area
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoInputs;
ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.55f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f));
if (ImGui::Begin("##DPSMeter", nullptr, flags)) {
if (dps > 0.5f) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf);
ImGui::SameLine(0, 2);
ImGui::TextDisabled("dps");
}
if (hps > 0.5f) {
ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf);
ImGui::SameLine(0, 2);
ImGui::TextDisabled("hps");
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
}
// ============================================================
// Nameplates — world-space health bars projected to screen
// ============================================================
void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
auto* appRenderer = core::Application::getInstance().getRenderer();
if (!appRenderer) return;
rendering::Camera* camera = appRenderer->getCamera();
if (!camera) return;
auto* window = core::Application::getInstance().getWindow();
if (!window) return;
const float screenW = static_cast<float>(window->getWidth());
const float screenH = static_cast<float>(window->getHeight());
const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
const glm::vec3 camPos = camera->getPosition();
const uint64_t playerGuid = gameHandler.getPlayerGuid();
const uint64_t targetGuid = gameHandler.getTargetGuid();
// Build set of creature entries that are kill objectives in active (incomplete) quests.
std::unordered_set<uint32_t> questKillEntries;
{
const auto& questLog = gameHandler.getQuestLog();
const auto& trackedIds = gameHandler.getTrackedQuestIds();
for (const auto& q : questLog) {
if (q.complete || q.questId == 0) continue;
// Only highlight for tracked quests (or all if nothing tracked).
if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue;
for (const auto& obj : q.killObjectives) {
if (obj.npcOrGoId > 0 && obj.required > 0) {
// Check if not already completed.
auto it = q.killCounts.find(static_cast<uint32_t>(obj.npcOrGoId));
if (it == q.killCounts.end() || it->second.first < it->second.second) {
questKillEntries.insert(static_cast<uint32_t>(obj.npcOrGoId));
}
}
}
}
}
ImDrawList* drawList = ImGui::GetBackgroundDrawList();
for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) {
if (!entityPtr || guid == playerGuid) continue;
auto* unit = dynamic_cast<game::Unit*>(entityPtr.get());
if (!unit || unit->getMaxHealth() == 0) continue;
bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER);
bool isTarget = (guid == targetGuid);
// Player nameplates are always shown; NPC nameplates respect the V-key toggle
if (!isPlayer && !showNameplates_) continue;
// For corpses (dead units), only show a minimal grey nameplate if selected
bool isCorpse = (unit->getHealth() == 0);
if (isCorpse && !isTarget) continue;
// Prefer the renderer's actual instance position so the nameplate tracks the
// rendered model exactly (avoids drift from the parallel entity interpolator).
glm::vec3 renderPos;
if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) {
renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
}
renderPos.z += 2.3f;
// Cull distance: target or other players up to 40 units; NPC others up to 20 units
float dist = glm::length(renderPos - camPos);
float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f;
if (dist > cullDist) continue;
// Project to clip space
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w <= 0.01f) continue; // Behind camera
glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w;
if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue;
// NDC → screen pixels.
// The camera bakes the Vulkan Y-flip into the projection matrix, so
// NDC y = -1 is the top of the screen and y = 1 is the bottom.
// Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion).
float sx = (ndc.x * 0.5f + 0.5f) * screenW;
float sy = (ndc.y * 0.5f + 0.5f) * screenH;
// Fade out in the last 5 units of cull range
float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f;
auto A = [&](int v) { return static_cast<int>(v * alpha); };
// Bar colour by hostility (grey for corpses)
ImU32 barColor, bgColor;
if (isCorpse) {
// Minimal grey bar for selected corpses (loot/skin targets)
barColor = IM_COL32(140, 140, 140, A(200));
bgColor = IM_COL32(70, 70, 70, A(160));
} else if (unit->isHostile()) {
barColor = IM_COL32(220, 60, 60, A(200));
bgColor = IM_COL32(100, 25, 25, A(160));
} else {
barColor = IM_COL32(60, 200, 80, A(200));
bgColor = IM_COL32(25, 100, 35, A(160));
}
ImU32 borderColor = isTarget
? IM_COL32(255, 215, 0, A(255))
: IM_COL32(20, 20, 20, A(180));
// Bar geometry
const float barW = 80.0f * nameplateScale_;
const float barH = 8.0f * nameplateScale_;
const float barX = sx - barW * 0.5f;
float healthPct = std::clamp(
static_cast<float>(unit->getHealth()) / static_cast<float>(unit->getMaxHealth()),
0.0f, 1.0f);
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f);
// For corpses, don't fill health bar (just show grey background)
if (!isCorpse) {
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
}
drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f);
// HP % text centered on health bar (non-corpse, non-full-health for readability)
if (!isCorpse && unit->getMaxHealth() > 0) {
int hpPct = static_cast<int>(healthPct * 100.0f + 0.5f);
char hpBuf[8];
snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct);
ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf);
float hpTx = sx - hpTextSz.x * 0.5f;
float hpTy = sy + (barH - hpTextSz.y) * 0.5f;
drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf);
drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf);
}
// Cast bar below health bar when unit is casting
float castBarBaseY = sy + barH + 2.0f;
{
const auto* cs = gameHandler.getUnitCastState(guid);
if (cs && cs->casting && cs->timeTotal > 0.0f) {
float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f);
const float cbH = 6.0f * nameplateScale_;
// Spell name above the cast bar
const std::string& spellName = gameHandler.getSpellName(cs->spellId);
if (!spellName.empty()) {
ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str());
float snX = sx - snSz.x * 0.5f;
float snY = castBarBaseY;
drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str());
drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str());
castBarBaseY += snSz.y + 2.0f;
}
// Cast bar background + fill (pulse orange when >80% = interrupt window closing)
ImU32 cbBg = IM_COL32(40, 30, 60, A(180));
ImU32 cbFill;
if (castPct > 0.8f && unit->isHostile()) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
cbFill = IM_COL32(static_cast<int>(255 * pulse), static_cast<int>(130 * pulse), 0, A(220));
} else {
cbFill = IM_COL32(140, 80, 220, A(200)); // purple cast bar
}
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f);
drawList->AddRectFilled(ImVec2(barX, castBarBaseY),
ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f);
drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f),
ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f),
IM_COL32(20, 10, 40, A(200)), 2.0f);
// Time remaining text
char timeBuf[12];
snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining);
ImVec2 timeSz = ImGui::CalcTextSize(timeBuf);
float timeX = sx - timeSz.x * 0.5f;
float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f;
drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf);
drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf);
}
}
// Name + level label above health bar
uint32_t level = unit->getLevel();
const std::string& unitName = unit->getName();
char labelBuf[96];
if (isPlayer) {
// Player nameplates: show name only (no level clutter).
// Fall back to level as placeholder while the name query is pending.
if (!unitName.empty())
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
else {
// Name query may be pending; request it now to ensure it gets resolved
gameHandler.queryPlayerName(unit->getGuid());
if (level > 0)
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
else
snprintf(labelBuf, sizeof(labelBuf), "Player");
}
} else if (level > 0) {
uint32_t playerLevel = gameHandler.getPlayerLevel();
// Show skull for units more than 10 levels above the player
if (playerLevel > 0 && level > playerLevel + 10)
snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str());
else
snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str());
} else {
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
}
ImVec2 textSize = ImGui::CalcTextSize(labelBuf);
float nameX = sx - textSize.x * 0.5f;
float nameY = sy - barH - 12.0f;
// Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention)
ImU32 nameColor = isPlayer
? IM_COL32( 80, 200, 255, A(230)) // cyan — other players
: unit->isHostile()
? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC
: IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC
drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf);
drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf);
// Raid mark (if any) to the left of the name
{
static const struct { const char* sym; ImU32 col; } kNPMarks[] = {
{ "\xe2\x98\x85", IM_COL32(255,220, 50,230) }, // Star
{ "\xe2\x97\x8f", IM_COL32(255,140, 0,230) }, // Circle
{ "\xe2\x97\x86", IM_COL32(160, 32,240,230) }, // Diamond
{ "\xe2\x96\xb2", IM_COL32( 50,200, 50,230) }, // Triangle
{ "\xe2\x97\x8c", IM_COL32( 80,160,255,230) }, // Moon
{ "\xe2\x96\xa0", IM_COL32( 50,200,220,230) }, // Square
{ "\xe2\x9c\x9d", IM_COL32(255, 80, 80,230) }, // Cross
{ "\xe2\x98\xa0", IM_COL32(255,255,255,230) }, // Skull
};
uint8_t raidMark = gameHandler.getEntityRaidMark(guid);
if (raidMark < game::GameHandler::kRaidMarkCount) {
float markX = nameX - 14.0f;
drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym);
drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym);
}
// Quest kill objective indicator: small yellow sword icon to the right of the name
if (!isPlayer && questKillEntries.count(unit->getEntry())) {
const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8)
float objX = nameX + textSize.x + 4.0f;
drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym);
drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym);
}
}
// Click to target / right-click context: detect clicks inside the nameplate region
if (!ImGui::GetIO().WantCaptureMouse) {
ImVec2 mouse = ImGui::GetIO().MousePos;
float nx0 = nameX - 2.0f;
float ny0 = nameY - 1.0f;
float nx1 = nameX + textSize.x + 2.0f;
float ny1 = sy + barH + 2.0f;
if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) {
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
gameHandler.setTarget(guid);
} else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
nameplateCtxGuid_ = guid;
nameplateCtxPos_ = mouse;
ImGui::OpenPopup("##NameplateCtx");
}
}
}
}
// Render nameplate context popup (uses a tiny overlay window as host)
if (nameplateCtxGuid_ != 0) {
ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always);
ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_AlwaysAutoResize;
if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) {
if (ImGui::BeginPopup("##NameplateCtx")) {
auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_);
std::string ctxName = entityPtr ? getEntityName(entityPtr) : "";
if (!ctxName.empty()) {
ImGui::TextDisabled("%s", ctxName.c_str());
ImGui::Separator();
}
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(nameplateCtxGuid_);
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(nameplateCtxGuid_);
bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER;
if (isPlayer && !ctxName.empty()) {
ImGui::Separator();
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, ctxName.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(ctxName);
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(ctxName);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(ctxName);
}
ImGui::EndPopup();
} else {
nameplateCtxGuid_ = 0;
}
}
ImGui::End();
}
}
// ============================================================
// Party Frames (Phase 4)
// ============================================================
void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
if (!gameHandler.isInGroup()) return;
const auto& partyData = gameHandler.getPartyData();
const bool isRaid = (partyData.groupType == 1);
float frameY = 120.0f;
// ---- Raid frame layout ----
if (isRaid) {
// Organize members by subgroup (0-7, up to 5 members each)
constexpr int MAX_SUBGROUPS = 8;
constexpr int MAX_PER_GROUP = 5;
std::vector<const game::GroupMember*> subgroups[MAX_SUBGROUPS];
for (const auto& m : partyData.members) {
int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0;
if (static_cast<int>(subgroups[sg].size()) < MAX_PER_GROUP)
subgroups[sg].push_back(&m);
}
// Count non-empty subgroups to determine layout
int activeSgs = 0;
for (int sg = 0; sg < MAX_SUBGROUPS; sg++)
if (!subgroups[sg].empty()) activeSgs++;
// Compact raid cell: name + 2 narrow bars
constexpr float CELL_W = 90.0f;
constexpr float CELL_H = 42.0f;
constexpr float BAR_H = 7.0f;
constexpr float CELL_PAD = 3.0f;
float winW = activeSgs * (CELL_W + CELL_PAD) + CELL_PAD + 8.0f;
float winH = MAX_PER_GROUP * (CELL_H + CELL_PAD) + CELL_PAD + 20.0f;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float raidX = (screenW - winW) / 2.0f;
float raidY = screenH - winH - 120.0f; // above action bar area
ImGui::SetNextWindowPos(ImVec2(raidX, raidY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Always);
ImGuiWindowFlags raidFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(CELL_PAD, CELL_PAD));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.07f, 0.07f, 0.1f, 0.85f));
if (ImGui::Begin("##RaidFrames", nullptr, raidFlags)) {
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 winPos = ImGui::GetWindowPos();
int colIdx = 0;
for (int sg = 0; sg < MAX_SUBGROUPS; sg++) {
if (subgroups[sg].empty()) continue;
float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD);
for (int row = 0; row < static_cast<int>(subgroups[sg].size()); row++) {
const auto& m = *subgroups[sg][row];
float cellY = winPos.y + CELL_PAD + 14.0f + row * (CELL_H + CELL_PAD);
ImVec2 cellMin(colX, cellY);
ImVec2 cellMax(colX + CELL_W, cellY + CELL_H);
// Cell background
bool isTarget = (gameHandler.getTargetGuid() == m.guid);
ImU32 bg = isTarget ? IM_COL32(60, 80, 120, 200) : IM_COL32(30, 30, 40, 180);
draw->AddRectFilled(cellMin, cellMax, bg, 3.0f);
if (isTarget)
draw->AddRect(cellMin, cellMax, IM_COL32(100, 150, 255, 200), 3.0f);
// Dead/ghost overlay
bool isOnline = (m.onlineStatus & 0x0001) != 0;
bool isDead = (m.onlineStatus & 0x0020) != 0;
bool isGhost = (m.onlineStatus & 0x0010) != 0;
// Name text (truncated); leader name is gold
char truncName[16];
snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str());
bool isMemberLeader = (m.guid == partyData.leaderGuid);
ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) :
(!isOnline || isDead || isGhost)
? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255);
draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName);
// Leader crown star in top-right of cell
if (isMemberLeader)
draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*");
// LFG role badge in bottom-right corner of cell
if (m.roles & 0x02)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T");
else if (m.roles & 0x04)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H");
else if (m.roles & 0x08)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D");
// Health bar
uint32_t hp = m.hasPartyStats ? m.curHealth : 0;
uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0;
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
float barY = cellMin.y + 16.0f;
ImVec2 barBg(cellMin.x + 3.0f, barY);
ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H);
draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f);
ImVec2 barFill(barBg.x, barBg.y);
ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y);
ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) :
pct > 0.2f ? IM_COL32(200, 180, 50, 255) :
IM_COL32(200, 60, 60, 255);
draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f);
// HP percentage text centered on bar
char hpPct[8];
snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast<int>(pct * 100.0f + 0.5f));
ImVec2 ts = ImGui::CalcTextSize(hpPct);
float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f;
float ty = barBg.y + (BAR_H - ts.y) * 0.5f;
draw->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), hpPct);
draw->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 230), hpPct);
}
// Power bar
if (m.hasPartyStats && m.maxPower > 0) {
float pct = static_cast<float>(m.curPower) / static_cast<float>(m.maxPower);
float barY = cellMin.y + 16.0f + BAR_H + 2.0f;
ImVec2 barBg(cellMin.x + 3.0f, barY);
ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H - 2.0f);
draw->AddRectFilled(barBg, barBgEnd, IM_COL32(30, 30, 40, 200), 2.0f);
ImVec2 barFill(barBg.x, barBg.y);
ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y);
ImU32 pwrCol;
switch (m.powerType) {
case 0: pwrCol = IM_COL32(50, 80, 220, 255); break; // Mana
case 1: pwrCol = IM_COL32(200, 50, 50, 255); break; // Rage
case 3: pwrCol = IM_COL32(220, 210, 50, 255); break; // Energy
case 6: pwrCol = IM_COL32(180, 30, 50, 255); break; // Runic Power
default: pwrCol = IM_COL32(80, 120, 80, 255); break;
}
draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f);
}
// Clickable invisible region over the whole cell
ImGui::SetCursorScreenPos(cellMin);
ImGui::PushID(static_cast<int>(m.guid));
if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) {
gameHandler.setTarget(m.guid);
}
if (ImGui::BeginPopupContextItem("RaidMemberCtx")) {
ImGui::TextDisabled("%s", m.name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target"))
gameHandler.setTarget(m.guid);
if (ImGui::MenuItem("Set Focus"))
gameHandler.setFocus(m.guid);
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, m.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Trade"))
gameHandler.initiateTrade(m.guid);
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(m.guid);
gameHandler.inspectTarget();
showInspectWindow_ = true;
}
bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid());
if (isLeader) {
ImGui::Separator();
if (ImGui::MenuItem("Kick from Raid"))
gameHandler.uninvitePlayer(m.name);
}
ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) {
static const char* kRaidMarkNames[] = {
"{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle",
"{)} Moon", "{ } Square", "{x} Cross", "{8} Skull"
};
for (int mi = 0; mi < 8; ++mi) {
if (ImGui::MenuItem(kRaidMarkNames[mi]))
gameHandler.setRaidMark(m.guid, static_cast<uint8_t>(mi));
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Mark"))
gameHandler.setRaidMark(m.guid, 0xFF);
ImGui::EndMenu();
}
ImGui::EndPopup();
}
ImGui::PopID();
}
colIdx++;
}
// Subgroup header row
colIdx = 0;
for (int sg = 0; sg < MAX_SUBGROUPS; sg++) {
if (subgroups[sg].empty()) continue;
float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD);
char sgLabel[8];
snprintf(sgLabel, sizeof(sgLabel), "G%d", sg + 1);
draw->AddText(ImVec2(colX + CELL_W / 2 - 8.0f, winPos.y + CELL_PAD), IM_COL32(160, 160, 180, 200), sgLabel);
colIdx++;
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
return;
}
// ---- Party frame layout (5-man) ----
ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f));
if (ImGui::Begin("##PartyFrames", nullptr, flags)) {
const uint64_t leaderGuid = partyData.leaderGuid;
for (const auto& member : partyData.members) {
ImGui::PushID(static_cast<int>(member.guid));
bool isLeader = (member.guid == leaderGuid);
// Name with level and status info — leader gets a gold star prefix
std::string label = (isLeader ? "* " : " ") + member.name;
if (member.hasPartyStats && member.level > 0) {
label += " [" + std::to_string(member.level) + "]";
}
if (member.hasPartyStats) {
bool isOnline = (member.onlineStatus & 0x0001) != 0;
bool isDead = (member.onlineStatus & 0x0020) != 0;
bool isGhost = (member.onlineStatus & 0x0010) != 0;
if (!isOnline) label += " (offline)";
else if (isDead || isGhost) label += " (dead)";
}
// Clickable name to target; leader name is gold
if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) {
gameHandler.setTarget(member.guid);
}
if (isLeader) ImGui::PopStyleColor();
// LFG role badge (Tank/Healer/DPS) — shown on same line as name when set
if (member.roles != 0) {
ImGui::SameLine();
if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]");
if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); }
if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); }
}
// Health bar: prefer party stats, fall back to entity
uint32_t hp = 0, maxHp = 0;
if (member.hasPartyStats && member.maxHealth > 0) {
hp = member.curHealth;
maxHp = member.maxHealth;
} else {
auto entity = gameHandler.getEntityManager().getEntity(member.guid);
if (entity && (entity->getType() == game::ObjectType::PLAYER || entity->getType() == game::ObjectType::UNIT)) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
hp = unit->getHealth();
maxHp = unit->getMaxHealth();
}
}
// Check dead/ghost state for health bar rendering
bool memberDead = false;
bool memberOffline = false;
if (member.hasPartyStats) {
bool isOnline2 = (member.onlineStatus & 0x0001) != 0;
bool isDead2 = (member.onlineStatus & 0x0020) != 0;
bool isGhost2 = (member.onlineStatus & 0x0010) != 0;
memberDead = isDead2 || isGhost2;
memberOffline = !isOnline2;
}
if (memberDead) {
// Gray "Dead" bar for fallen party members
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f));
ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead");
ImGui::PopStyleColor(2);
} else if (memberOffline) {
// Dim bar for offline members
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f));
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f));
ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline");
ImGui::PopStyleColor(2);
} else if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) :
ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
char hpText[32];
if (maxHp >= 10000)
snprintf(hpText, sizeof(hpText), "%dk/%dk",
(int)hp / 1000, (int)maxHp / 1000);
else
snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText);
ImGui::PopStyleColor();
}
// Power bar (mana/rage/energy) from party stats — hidden for dead/offline
if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) {
float powerPct = static_cast<float>(member.curPower) / static_cast<float>(member.maxPower);
ImVec4 powerColor;
switch (member.powerType) {
case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue)
case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red)
case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange)
case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow)
case 4: powerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green)
case 6: powerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson)
case 7: powerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple)
default: powerColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor);
ImGui::ProgressBar(powerPct, ImVec2(-1, 8), "");
ImGui::PopStyleColor();
}
// Party member cast bar — shows when the party member is casting
if (auto* cs = gameHandler.getUnitCastState(member.guid)) {
float castPct = (cs->timeTotal > 0.0f)
? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f;
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.8f, 0.2f, 1.0f));
char pcastLabel[48];
const std::string& spellNm = gameHandler.getSpellName(cs->spellId);
if (!spellNm.empty())
snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining);
else
snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining);
ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel);
ImGui::PopStyleColor();
}
// Right-click context menu for party member actions
if (ImGui::BeginPopupContextItem("PartyMemberCtx")) {
ImGui::TextDisabled("%s", member.name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target")) {
gameHandler.setTarget(member.guid);
}
if (ImGui::MenuItem("Set Focus")) {
gameHandler.setFocus(member.guid);
}
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4; // WHISPER
strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Follow")) {
gameHandler.setTarget(member.guid);
gameHandler.followTarget();
}
if (ImGui::MenuItem("Trade")) {
gameHandler.initiateTrade(member.guid);
}
if (ImGui::MenuItem("Duel")) {
gameHandler.proposeDuel(member.guid);
}
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(member.guid);
gameHandler.inspectTarget();
showInspectWindow_ = true;
}
ImGui::Separator();
if (!member.name.empty()) {
if (ImGui::MenuItem("Add Friend")) {
gameHandler.addFriend(member.name);
}
if (ImGui::MenuItem("Ignore")) {
gameHandler.addIgnore(member.name);
}
}
// Leader-only actions
bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid());
if (isLeader) {
ImGui::Separator();
if (ImGui::MenuItem("Kick from Group")) {
gameHandler.uninvitePlayer(member.name);
}
}
ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) {
static const char* kRaidMarkNames[] = {
"{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle",
"{)} Moon", "{ } Square", "{x} Cross", "{8} Skull"
};
for (int mi = 0; mi < 8; ++mi) {
if (ImGui::MenuItem(kRaidMarkNames[mi]))
gameHandler.setRaidMark(member.guid, static_cast<uint8_t>(mi));
}
ImGui::Separator();
if (ImGui::MenuItem("Clear Mark"))
gameHandler.setRaidMark(member.guid, 0xFF);
ImGui::EndMenu();
}
ImGui::EndPopup();
}
ImGui::Separator();
ImGui::PopID();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// UI Error Frame (WoW-style center-bottom error overlay)
// ============================================================
void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) {
// Age out old entries
for (auto& e : uiErrors_) e.age += deltaTime;
uiErrors_.erase(
std::remove_if(uiErrors_.begin(), uiErrors_.end(),
[](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }),
uiErrors_.end());
if (uiErrors_.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Fixed invisible overlay
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, screenH));
ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
if (ImGui::Begin("##UIErrors", nullptr, flags)) {
// Render messages stacked above the action bar (~200px from bottom)
// The newest message is on top; older ones fade below it.
const float baseY = screenH - 200.0f;
const float lineH = 20.0f;
const int count = static_cast<int>(uiErrors_.size());
ImDrawList* draw = ImGui::GetWindowDrawList();
for (int i = count - 1; i >= 0; --i) {
const auto& e = uiErrors_[i];
float alpha = 1.0f - (e.age / kUIErrorLifetime);
alpha = std::max(0.0f, std::min(1.0f, alpha));
// Fade fast in the last 0.5 s
if (e.age > kUIErrorLifetime - 0.5f)
alpha *= (kUIErrorLifetime - e.age) / 0.5f;
uint8_t a8 = static_cast<uint8_t>(alpha * 255.0f);
ImU32 textCol = IM_COL32(255, 50, 50, a8);
ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast<uint8_t>(alpha * 180));
const char* txt = e.text.c_str();
ImVec2 sz = ImGui::CalcTextSize(txt);
float x = std::round((screenW - sz.x) * 0.5f);
float y = std::round(baseY - (count - 1 - i) * lineH);
// Drop shadow
draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt);
draw->AddText(ImVec2(x, y), textCol, txt);
}
}
ImGui::End();
ImGui::PopStyleVar();
}
// ============================================================
// Reputation change toasts
// ============================================================
void GameScreen::renderRepToasts(float deltaTime) {
for (auto& e : repToasts_) e.age += deltaTime;
repToasts_.erase(
std::remove_if(repToasts_.begin(), repToasts_.end(),
[](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }),
repToasts_.end());
if (repToasts_.empty()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Stack toasts in the lower-right corner (above the action bar), newest on top
const float toastW = 220.0f;
const float toastH = 26.0f;
const float padY = 4.0f;
const float rightEdge = screenW - 14.0f;
const float baseY = screenH - 180.0f;
const int count = static_cast<int>(repToasts_.size());
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
// Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated)
auto standingLabel = [](int32_t s) -> const char* {
if (s >= 42000) return "Exalted";
if (s >= 21000) return "Revered";
if (s >= 9000) return "Honored";
if (s >= 3000) return "Friendly";
if (s >= 0) return "Neutral";
if (s >= -3000) return "Unfriendly";
if (s >= -6000) return "Hostile";
return "Hated";
};
for (int i = 0; i < count; ++i) {
const auto& e = repToasts_[i];
// Slide in from right on appear, slide out at end
constexpr float kSlideDur = 0.3f;
float slideIn = std::min(e.age, kSlideDur) / kSlideDur;
float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur;
float slide = std::min(slideIn, slideOut);
float alpha = std::clamp(slide, 0.0f, 1.0f);
float xFull = rightEdge - toastW;
float xStart = screenW + 10.0f;
float toastX = xStart + (xFull - xStart) * slide;
float toastY = baseY - i * (toastH + padY);
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + toastW, toastY + toastH);
// Background
draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, (int)(alpha * 200)), 4.0f);
// Border: green for gain, red for loss
ImU32 borderCol = (e.delta > 0)
? IM_COL32(80, 200, 80, (int)(alpha * 220))
: IM_COL32(200, 60, 60, (int)(alpha * 220));
draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f);
// Delta text: "+250" or "-250"
char deltaBuf[16];
snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta);
ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, (int)(alpha * 255))
: IM_COL32(220, 70, 70, (int)(alpha * 255));
draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f),
deltaCol, deltaBuf);
// Faction name + standing
char nameBuf[64];
snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing));
draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f),
IM_COL32(210, 210, 210, (int)(alpha * 220)), nameBuf);
}
}
// ============================================================
// Boss Encounter Frames
// ============================================================
void GameScreen::renderBossFrames(game::GameHandler& gameHandler) {
// Collect active boss unit slots
struct BossSlot { uint32_t slot; uint64_t guid; };
std::vector<BossSlot> active;
for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) {
uint64_t g = gameHandler.getEncounterUnitGuid(s);
if (g != 0) active.push_back({s, g});
}
if (active.empty()) return;
const float frameW = 200.0f;
const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f;
float frameY = 120.0f;
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f));
ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
if (ImGui::Begin("##BossFrames", nullptr, flags)) {
for (const auto& bs : active) {
ImGui::PushID(static_cast<int>(bs.guid));
// Try to resolve name and health from entity manager
std::string name = "Boss";
uint32_t hp = 0, maxHp = 0;
auto entity = gameHandler.getEntityManager().getEntity(bs.guid);
if (entity && (entity->getType() == game::ObjectType::UNIT ||
entity->getType() == game::ObjectType::PLAYER)) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
const auto& n = unit->getName();
if (!n.empty()) name = n;
hp = unit->getHealth();
maxHp = unit->getMaxHealth();
}
// Clickable name to target
if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) {
gameHandler.setTarget(bs.guid);
}
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
// Boss health bar in red shades
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.8f, 0.2f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) :
ImVec4(1.0f, 0.8f, 0.1f, 1.0f));
char label[32];
std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), label);
ImGui::PopStyleColor();
}
// Boss cast bar — shown when the boss is casting (critical for interrupt)
if (auto* cs = gameHandler.getUnitCastState(bs.guid)) {
float castPct = (cs->timeTotal > 0.0f)
? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f;
uint32_t bspell = cs->spellId;
const std::string& bcastName = (bspell != 0)
? gameHandler.getSpellName(bspell) : "";
// Pulse bright orange when > 80% complete — interrupt window closing
ImVec4 bcastColor;
if (castPct > 0.8f) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 8.0f);
bcastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f);
} else {
bcastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f);
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor);
char bcastLabel[72];
if (!bcastName.empty())
snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)",
bcastName.c_str(), cs->timeRemaining);
else
snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining);
ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel);
ImGui::PopStyleColor();
}
ImGui::PopID();
ImGui::Spacing();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Group Invite Popup (Phase 4)
// ============================================================
void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGroupInvite()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
if (ImGui::Begin("Group Invite", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s has invited you to a group.", gameHandler.getPendingInviterName().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptGroupInvite();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.declineGroupInvite();
}
}
ImGui::End();
}
void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingDuelRequest()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 250), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
if (ImGui::Begin("Duel Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s challenges you to a duel!", gameHandler.getDuelChallengerName().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptDuel();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.forfeitDuel();
}
}
ImGui::End();
}
void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isItemTextOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 200, screenH * 0.15f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(400, 300), ImGuiCond_FirstUseEver);
bool open = true;
if (!ImGui::Begin("Book", &open, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
if (!open) gameHandler.closeItemText();
return;
}
if (!open) {
ImGui::End();
gameHandler.closeItemText();
return;
}
// Parchment-toned background text
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.2f, 0.1f, 0.0f, 1.0f));
ImGui::TextWrapped("%s", gameHandler.getItemText().c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(80, 0))) {
gameHandler.closeItemText();
}
ImGui::End();
}
void GameScreen::renderSharedQuestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingSharedQuest()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 490), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
if (ImGui::Begin("Shared Quest", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s has shared a quest with you:", gameHandler.getSharedQuestSharerName().c_str());
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\"%s\"", gameHandler.getSharedQuestTitle().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptSharedQuest();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.declineSharedQuest();
}
}
ImGui::End();
}
void GameScreen::renderSummonRequestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingSummonRequest()) return;
// Tick the timeout down
float dt = ImGui::GetIO().DeltaTime;
gameHandler.tickSummonTimeout(dt);
if (!gameHandler.hasPendingSummonRequest()) return; // expired
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 430), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
if (ImGui::Begin("Summon Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s is summoning you.", gameHandler.getSummonerName().c_str());
float t = gameHandler.getSummonTimeoutSec();
if (t > 0.0f) {
ImGui::Text("Time remaining: %.0fs", t);
}
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptSummon();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.declineSummon();
}
}
ImGui::End();
}
void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingTradeRequest()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 370), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
if (ImGui::Begin("Trade Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::Text("%s wants to trade with you.", gameHandler.getTradePeerName().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(130, 30))) {
gameHandler.acceptTradeRequest();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(130, 30))) {
gameHandler.declineTradeRequest();
}
}
ImGui::End();
}
void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isTradeOpen()) return;
const auto& mySlots = gameHandler.getMyTradeSlots();
const auto& peerSlots = gameHandler.getPeerTradeSlots();
const uint64_t myGold = gameHandler.getMyTradeGold();
const uint64_t peerGold = gameHandler.getPeerTradeGold();
const auto& peerName = gameHandler.getTradePeerName();
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once);
bool open = true;
if (ImGui::Begin(("Trade with " + peerName).c_str(), &open,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) {
uint64_t g = copper / 10000;
uint64_t s = (copper % 10000) / 100;
uint64_t c = copper % 100;
if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc",
(unsigned long long)g, (unsigned long long)s, (unsigned long long)c);
else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc",
(unsigned long long)s, (unsigned long long)c);
else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c);
};
auto renderSlotColumn = [&](const char* label,
const std::array<game::GameHandler::TradeSlot,
game::GameHandler::TRADE_SLOT_COUNT>& slots,
uint64_t gold, bool isMine) {
ImGui::Text("%s", label);
ImGui::Separator();
for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) {
const auto& slot = slots[i];
ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100));
if (slot.occupied && slot.itemId != 0) {
const auto* info = gameHandler.getItemInfo(slot.itemId);
std::string name = (info && info->valid && !info->name.empty())
? info->name
: ("Item " + std::to_string(slot.itemId));
if (slot.stackCount > 1)
name += " x" + std::to_string(slot.stackCount);
ImVec4 qc = (info && info->valid)
? InventoryScreen::getQualityColor(static_cast<game::ItemQuality>(info->quality))
: ImVec4(1.0f, 0.9f, 0.5f, 1.0f);
if (info && info->valid && info->displayInfoId != 0) {
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId);
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(16, 16));
ImGui::SameLine();
}
}
ImGui::TextColored(qc, "%d. %s", i + 1, name.c_str());
if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
gameHandler.clearTradeItem(static_cast<uint8_t>(i));
}
if (ImGui::IsItemHovered()) {
if (info && info->valid) inventoryScreen.renderItemTooltip(*info);
else if (isMine) ImGui::SetTooltip("Double-click to remove");
}
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
} else {
ImGui::TextDisabled(" %d. (empty)", i + 1);
// Allow dragging inventory items into trade slots via right-click context menu
if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str());
}
}
if (isMine) {
// Drag-from-inventory: show small popup listing bag items
if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) {
ImGui::TextDisabled("Add from inventory:");
const auto& inv = gameHandler.getInventory();
// Backpack slots 0-15 (bag=255)
for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) {
const auto& slot = inv.getBackpackSlot(si);
if (slot.empty()) continue;
const auto* ii = gameHandler.getItemInfo(slot.item.itemId);
std::string iname = (ii && ii->valid && !ii->name.empty())
? ii->name
: (!slot.item.name.empty() ? slot.item.name
: ("Item " + std::to_string(slot.item.itemId)));
if (ImGui::Selectable(iname.c_str())) {
// bag=255 = main backpack
gameHandler.setTradeItem(static_cast<uint8_t>(i), 255u,
static_cast<uint8_t>(si));
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
}
ImGui::PopID();
}
// Gold row
char gbuf[48];
formatGold(gold, gbuf, sizeof(gbuf));
ImGui::Spacing();
if (isMine) {
ImGui::Text("Gold offered: %s", gbuf);
static char goldInput[32] = "0";
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput),
ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) {
uint64_t copper = std::strtoull(goldInput, nullptr, 10);
gameHandler.setTradeGold(copper);
}
ImGui::SameLine();
ImGui::TextDisabled("(copper, Enter to set)");
} else {
ImGui::Text("Gold offered: %s", gbuf);
}
};
// Two-column layout: my offer | peer offer
float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f;
ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true);
renderSlotColumn("Your offer", mySlots, myGold, true);
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true);
renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false);
ImGui::EndChild();
// Buttons
ImGui::Spacing();
ImGui::Separator();
float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) {
gameHandler.acceptTrade();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(bw, 0))) {
gameHandler.cancelTrade();
}
}
ImGui::End();
if (!open) {
gameHandler.cancelTrade();
}
}
void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingLootRoll()) return;
const auto& roll = gameHandler.getPendingLootRoll();
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 310), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
if (ImGui::Begin("Loot Roll", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
// Quality color for item name
static const ImVec4 kQualityColors[] = {
ImVec4(0.6f, 0.6f, 0.6f, 1.0f), // 0=poor (grey)
ImVec4(1.0f, 1.0f, 1.0f, 1.0f), // 1=common (white)
ImVec4(0.1f, 1.0f, 0.1f, 1.0f), // 2=uncommon (green)
ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue)
ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple)
ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange)
};
uint8_t q = roll.itemQuality;
ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1];
ImGui::Text("An item is up for rolls:");
// Show item icon if available
const auto* rollInfo = gameHandler.getItemInfo(roll.itemId);
uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0;
VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE;
if (rollIcon) {
ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24));
ImGui::SameLine();
}
ImGui::TextColored(col, "[%s]", roll.itemName.c_str());
if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) {
inventoryScreen.renderItemTooltip(*rollInfo);
}
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) {
std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name);
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;
}
}
ImGui::Spacing();
if (ImGui::Button("Need", ImVec2(80, 30))) {
gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0);
}
ImGui::SameLine();
if (ImGui::Button("Greed", ImVec2(80, 30))) {
gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1);
}
ImGui::SameLine();
if (ImGui::Button("Disenchant", ImVec2(95, 30))) {
gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2);
}
ImGui::SameLine();
if (ImGui::Button("Pass", ImVec2(70, 30))) {
gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96);
}
}
ImGui::End();
}
void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGuildInvite()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 250), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
if (ImGui::Begin("Guild Invite", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
ImGui::TextWrapped("%s has invited you to join %s.",
gameHandler.getPendingGuildInviterName().c_str(),
gameHandler.getPendingGuildInviteGuildName().c_str());
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(155, 30))) {
gameHandler.acceptGuildInvite();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(155, 30))) {
gameHandler.declineGuildInvite();
}
}
ImGui::End();
}
void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingReadyCheck()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, screenH / 2 - 60), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
if (ImGui::Begin("Ready Check", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
const std::string& initiator = gameHandler.getReadyCheckInitiator();
if (initiator.empty()) {
ImGui::Text("A ready check has been initiated!");
} else {
ImGui::TextWrapped("%s has initiated a ready check!", initiator.c_str());
}
ImGui::Spacing();
if (ImGui::Button("Ready", ImVec2(155, 30))) {
gameHandler.respondToReadyCheck(true);
gameHandler.dismissReadyCheck();
}
ImGui::SameLine();
if (ImGui::Button("Not Ready", ImVec2(155, 30))) {
gameHandler.respondToReadyCheck(false);
gameHandler.dismissReadyCheck();
}
}
ImGui::End();
}
void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingBgInvite()) return;
const auto& queues = gameHandler.getBgQueues();
// Find the first WAIT_JOIN slot
const game::GameHandler::BgQueueSlot* slot = nullptr;
for (const auto& s : queues) {
if (s.statusId == 2) { slot = &s; break; }
}
if (!slot) return;
// Compute time remaining
auto now = std::chrono::steady_clock::now();
double elapsed = std::chrono::duration<double>(now - slot->inviteReceivedTime).count();
double remaining = static_cast<double>(slot->inviteTimeout) - elapsed;
// If invite has expired, clear it silently (server will handle the queue)
if (remaining <= 0.0) {
gameHandler.declineBattlefield(slot->queueSlot);
return;
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags popupFlags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) {
// BG name
std::string bgName;
if (slot->arenaType > 0) {
bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena";
} else {
switch (slot->bgTypeId) {
case 1: bgName = "Alterac Valley"; break;
case 2: bgName = "Warsong Gulch"; break;
case 3: bgName = "Arathi Basin"; break;
case 7: bgName = "Eye of the Storm"; break;
case 9: bgName = "Strand of the Ancients"; break;
case 11: bgName = "Isle of Conquest"; break;
default: bgName = "Battleground"; break;
}
}
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str());
ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast<int>(remaining));
ImGui::Spacing();
// Countdown progress bar
float frac = static_cast<float>(remaining / static_cast<double>(slot->inviteTimeout));
frac = std::clamp(frac, 0.0f, 1.0f);
ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f)
: frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f)
: ImVec4(0.9f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
char countdownLabel[32];
snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast<int>(remaining));
ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel);
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) {
gameHandler.acceptBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
if (ImGui::Button("Leave Queue", ImVec2(175, 30))) {
gameHandler.declineBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) {
using LfgState = game::GameHandler::LfgState;
if (gameHandler.getLfgState() != LfgState::Proposal) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Dungeon Finder", nullptr, flags)) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "A group has been found!");
ImGui::Spacing();
ImGui::TextWrapped("Please accept or decline to join the dungeon.");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true);
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false);
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
// Guild Roster toggle (customizable keybind)
if (!ImGui::GetIO().WantCaptureKeyboard && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) {
showGuildRoster_ = !showGuildRoster_;
if (showGuildRoster_) {
// Open friends tab directly if not in guild
if (!gameHandler.isInGuild()) {
guildRosterTab_ = 2; // Friends tab
} else {
// Re-query guild name if we have guildId but no name yet
if (gameHandler.getGuildName().empty()) {
const auto* ch = gameHandler.getActiveCharacter();
if (ch && ch->hasGuild()) {
gameHandler.queryGuildInfo(ch->guildId);
}
}
gameHandler.requestGuildRoster();
gameHandler.requestGuildInfo();
}
}
}
// Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST)
if (gameHandler.hasPetitionShowlist()) {
ImGui::OpenPopup("CreateGuildPetition");
gameHandler.clearPetitionDialog();
}
if (ImGui::BeginPopupModal("CreateGuildPetition", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("Create Guild Charter");
ImGui::Separator();
uint32_t cost = gameHandler.getPetitionCost();
uint32_t gold = cost / 10000;
uint32_t silver = (cost % 10000) / 100;
uint32_t copper = cost % 100;
ImGui::Text("Cost: %ug %us %uc", gold, silver, copper);
ImGui::Spacing();
ImGui::Text("Guild Name:");
ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_));
ImGui::Spacing();
if (ImGui::Button("Create", ImVec2(120, 0))) {
if (petitionNameBuffer_[0] != '\0') {
gameHandler.buyPetition(gameHandler.getPetitionNpcGuid(), petitionNameBuffer_);
petitionNameBuffer_[0] = '\0';
ImGui::CloseCurrentPopup();
}
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
petitionNameBuffer_[0] = '\0';
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
if (!showGuildRoster_) return;
// Get zone manager for name lookup
game::ZoneManager* zoneManager = nullptr;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
zoneManager = renderer->getZoneManager();
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once);
std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social";
bool open = showGuildRoster_;
if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
// Tab bar: Roster | Guild Info
if (ImGui::BeginTabBar("GuildTabs")) {
if (ImGui::BeginTabItem("Roster")) {
guildRosterTab_ = 0;
if (!gameHandler.hasGuildRoster()) {
ImGui::Text("Loading roster...");
} else {
const auto& roster = gameHandler.getGuildRoster();
// MOTD
if (!roster.motd.empty()) {
ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD: %s", roster.motd.c_str());
ImGui::Separator();
}
// Count online
int onlineCount = 0;
for (const auto& m : roster.members) {
if (m.online) ++onlineCount;
}
ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount);
ImGui::Separator();
const auto& rankNames = gameHandler.getGuildRankNames();
// Table
if (ImGui::BeginTable("GuildRoster", 7,
ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV |
ImGuiTableFlags_Sortable)) {
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort);
ImGui::TableSetupColumn("Rank");
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f);
ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f);
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("Note");
ImGui::TableSetupColumn("Officer Note");
ImGui::TableHeadersRow();
// Online members first, then offline
auto sortedMembers = roster.members;
std::sort(sortedMembers.begin(), sortedMembers.end(), [](const auto& a, const auto& b) {
if (a.online != b.online) return a.online > b.online;
return a.name < b.name;
});
static const char* classNames[] = {
"Unknown", "Warrior", "Paladin", "Hunter", "Rogue",
"Priest", "Death Knight", "Shaman", "Mage", "Warlock",
"", "Druid"
};
for (const auto& m : sortedMembers) {
ImGui::TableNextRow();
ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f)
: ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.name.c_str());
// Right-click context menu
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
selectedGuildMember_ = m.name;
ImGui::OpenPopup("GuildMemberContext");
}
ImGui::TableNextColumn();
// Show rank name instead of index
if (m.rankIndex < rankNames.size()) {
ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str());
} else {
ImGui::TextColored(textColor, "Rank %u", m.rankIndex);
}
ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%u", m.level);
ImGui::TableNextColumn();
const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown";
ImGui::TextColored(textColor, "%s", className);
ImGui::TableNextColumn();
// Zone name lookup
if (zoneManager) {
const auto* zoneInfo = zoneManager->getZoneInfo(m.zoneId);
if (zoneInfo && !zoneInfo->name.empty()) {
ImGui::TextColored(textColor, "%s", zoneInfo->name.c_str());
} else {
ImGui::TextColored(textColor, "%u", m.zoneId);
}
} else {
ImGui::TextColored(textColor, "%u", m.zoneId);
}
ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.publicNote.c_str());
ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.officerNote.c_str());
}
ImGui::EndTable();
}
// Context menu popup
if (ImGui::BeginPopup("GuildMemberContext")) {
ImGui::TextDisabled("%s", selectedGuildMember_.c_str());
ImGui::Separator();
// Social actions — only for online members
bool memberOnline = false;
for (const auto& mem : roster.members) {
if (mem.name == selectedGuildMember_) { memberOnline = mem.online; break; }
}
if (memberOnline) {
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, selectedGuildMember_.c_str(),
sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Invite to Group")) {
gameHandler.inviteToGroup(selectedGuildMember_);
}
ImGui::Separator();
}
if (!selectedGuildMember_.empty()) {
if (ImGui::MenuItem("Add Friend"))
gameHandler.addFriend(selectedGuildMember_);
if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(selectedGuildMember_);
ImGui::Separator();
}
if (ImGui::MenuItem("Promote")) {
gameHandler.promoteGuildMember(selectedGuildMember_);
}
if (ImGui::MenuItem("Demote")) {
gameHandler.demoteGuildMember(selectedGuildMember_);
}
if (ImGui::MenuItem("Kick")) {
gameHandler.kickGuildMember(selectedGuildMember_);
}
ImGui::Separator();
if (ImGui::MenuItem("Set Public Note...")) {
showGuildNoteEdit_ = true;
editingOfficerNote_ = false;
guildNoteEditBuffer_[0] = '\0';
// Pre-fill with existing note
for (const auto& mem : roster.members) {
if (mem.name == selectedGuildMember_) {
snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str());
break;
}
}
}
if (ImGui::MenuItem("Set Officer Note...")) {
showGuildNoteEdit_ = true;
editingOfficerNote_ = true;
guildNoteEditBuffer_[0] = '\0';
for (const auto& mem : roster.members) {
if (mem.name == selectedGuildMember_) {
snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str());
break;
}
}
}
ImGui::Separator();
if (ImGui::MenuItem("Set as Leader")) {
gameHandler.setGuildLeader(selectedGuildMember_);
}
ImGui::EndPopup();
}
// Note edit modal
if (showGuildNoteEdit_) {
ImGui::OpenPopup("EditGuildNote");
showGuildNoteEdit_ = false;
}
if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("%s %s for %s:",
editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str());
ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_));
if (ImGui::Button("Save")) {
if (editingOfficerNote_) {
gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_);
} else {
gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_);
}
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Guild Info")) {
guildRosterTab_ = 1;
const auto& infoData = gameHandler.getGuildInfoData();
const auto& queryData = gameHandler.getGuildQueryData();
const auto& roster = gameHandler.getGuildRoster();
const auto& rankNames = gameHandler.getGuildRankNames();
// Guild name (large, gold)
ImGui::PushFont(nullptr); // default font
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "<%s>", gameHandler.getGuildName().c_str());
ImGui::PopFont();
ImGui::Separator();
// Creation date
if (infoData.isValid()) {
ImGui::Text("Created: %u/%u/%u", infoData.creationDay, infoData.creationMonth, infoData.creationYear);
ImGui::Text("Members: %u | Accounts: %u", infoData.numMembers, infoData.numAccounts);
}
ImGui::Spacing();
// Guild description / info text
if (!roster.guildInfo.empty()) {
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "Description:");
ImGui::TextWrapped("%s", roster.guildInfo.c_str());
}
ImGui::Spacing();
// MOTD with edit button
ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "MOTD:");
ImGui::SameLine();
if (!roster.motd.empty()) {
ImGui::TextWrapped("%s", roster.motd.c_str());
} else {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "(not set)");
}
if (ImGui::Button("Set MOTD")) {
showMotdEdit_ = true;
snprintf(guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_), "%s", roster.motd.c_str());
}
ImGui::Spacing();
// MOTD edit modal
if (showMotdEdit_) {
ImGui::OpenPopup("EditMotd");
showMotdEdit_ = false;
}
if (ImGui::BeginPopupModal("EditMotd", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("Set Message of the Day:");
ImGui::InputText("##motdinput", guildMotdEditBuffer_, sizeof(guildMotdEditBuffer_));
if (ImGui::Button("Save", ImVec2(120, 0))) {
gameHandler.setGuildMotd(guildMotdEditBuffer_);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Emblem info
if (queryData.isValid()) {
ImGui::Separator();
ImGui::Text("Emblem: Style %u, Color %u | Border: Style %u, Color %u | BG: %u",
queryData.emblemStyle, queryData.emblemColor,
queryData.borderStyle, queryData.borderColor, queryData.backgroundColor);
}
// Rank list
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Ranks:");
for (size_t i = 0; i < rankNames.size(); ++i) {
if (rankNames[i].empty()) continue;
// Show rank permission summary from roster data
if (i < roster.ranks.size()) {
uint32_t rights = roster.ranks[i].rights;
std::string perms;
if (rights & 0x01) perms += "Invite ";
if (rights & 0x02) perms += "Remove ";
if (rights & 0x40) perms += "Promote ";
if (rights & 0x80) perms += "Demote ";
if (rights & 0x04) perms += "OChat ";
if (rights & 0x10) perms += "MOTD ";
ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str());
if (!perms.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "[%s]", perms.c_str());
}
} else {
ImGui::Text(" %zu. %s", i + 1, rankNames[i].c_str());
}
}
// Rank management buttons
ImGui::Spacing();
if (ImGui::Button("Add Rank")) {
showAddRankModal_ = true;
addRankNameBuffer_[0] = '\0';
}
ImGui::SameLine();
if (ImGui::Button("Delete Last Rank")) {
gameHandler.deleteGuildRank();
}
// Add rank modal
if (showAddRankModal_) {
ImGui::OpenPopup("AddGuildRank");
showAddRankModal_ = false;
}
if (ImGui::BeginPopupModal("AddGuildRank", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("New Rank Name:");
ImGui::InputText("##rankname", addRankNameBuffer_, sizeof(addRankNameBuffer_));
if (ImGui::Button("Add", ImVec2(120, 0))) {
if (addRankNameBuffer_[0] != '\0') {
gameHandler.addGuildRank(addRankNameBuffer_);
ImGui::CloseCurrentPopup();
}
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::EndTabItem();
}
// ---- Friends tab ----
if (ImGui::BeginTabItem("Friends")) {
guildRosterTab_ = 2;
const auto& contacts = gameHandler.getContacts();
// Add Friend row
static char addFriendBuf[64] = {};
ImGui::SetNextItemWidth(180.0f);
ImGui::InputText("##addfriend", addFriendBuf, sizeof(addFriendBuf));
ImGui::SameLine();
if (ImGui::Button("Add Friend") && addFriendBuf[0] != '\0') {
gameHandler.addFriend(addFriendBuf);
addFriendBuf[0] = '\0';
}
ImGui::Separator();
// Note-edit state
static std::string friendNoteTarget;
static char friendNoteBuf[256] = {};
static bool openNotePopup = false;
// Filter to friends only
int friendCount = 0;
for (size_t ci = 0; ci < contacts.size(); ++ci) {
const auto& c = contacts[ci];
if (!c.isFriend()) continue;
++friendCount;
ImGui::PushID(static_cast<int>(ci));
// Status dot
ImU32 dotColor = c.isOnline()
? IM_COL32(80, 200, 80, 255)
: IM_COL32(120, 120, 120, 255);
ImVec2 cursor = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor);
ImGui::Dummy(ImVec2(14.0f, 0.0f));
ImGui::SameLine();
// Name as Selectable for right-click context menu
const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str();
ImVec4 nameCol = c.isOnline()
? ImVec4(1.0f, 1.0f, 1.0f, 1.0f)
: ImVec4(0.55f, 0.55f, 0.55f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_Text, nameCol);
ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f));
ImGui::PopStyleColor();
// Double-click to whisper
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)
&& !c.name.empty()) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
// Right-click context menu
if (ImGui::BeginPopupContextItem("FriendCtx")) {
ImGui::TextDisabled("%s", displayName);
ImGui::Separator();
if (ImGui::MenuItem("Whisper") && !c.name.empty()) {
selectedChatType = 4;
strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) {
gameHandler.inviteToGroup(c.name);
}
if (ImGui::MenuItem("Edit Note")) {
friendNoteTarget = c.name;
strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1);
friendNoteBuf[sizeof(friendNoteBuf) - 1] = '\0';
openNotePopup = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Remove Friend")) {
gameHandler.removeFriend(c.name);
}
ImGui::EndPopup();
}
// Note tooltip on hover
if (ImGui::IsItemHovered() && !c.note.empty()) {
ImGui::BeginTooltip();
ImGui::TextDisabled("Note: %s", c.note.c_str());
ImGui::EndTooltip();
}
// Level and status
if (c.isOnline()) {
ImGui::SameLine(160.0f);
const char* statusLabel =
(c.status == 2) ? " (AFK)" :
(c.status == 3) ? " (DND)" : "";
if (c.level > 0) {
ImGui::TextDisabled("Lv %u%s", c.level, statusLabel);
} else if (*statusLabel) {
ImGui::TextDisabled("%s", statusLabel + 1);
}
}
ImGui::PopID();
}
if (friendCount == 0) {
ImGui::TextDisabled("No friends found.");
}
// Note edit modal
if (openNotePopup) {
ImGui::OpenPopup("EditFriendNote");
openNotePopup = false;
}
if (ImGui::BeginPopupModal("EditFriendNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("Note for %s:", friendNoteTarget.c_str());
ImGui::SetNextItemWidth(240.0f);
ImGui::InputText("##fnote", friendNoteBuf, sizeof(friendNoteBuf));
if (ImGui::Button("Save", ImVec2(110, 0))) {
gameHandler.setFriendNote(friendNoteTarget, friendNoteBuf);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(110, 0))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::EndTabItem();
}
// ---- Ignore List tab ----
if (ImGui::BeginTabItem("Ignore")) {
guildRosterTab_ = 3;
const auto& contacts = gameHandler.getContacts();
// Add Ignore row
static char addIgnoreBuf[64] = {};
ImGui::SetNextItemWidth(180.0f);
ImGui::InputText("##addignore", addIgnoreBuf, sizeof(addIgnoreBuf));
ImGui::SameLine();
if (ImGui::Button("Ignore Player") && addIgnoreBuf[0] != '\0') {
gameHandler.addIgnore(addIgnoreBuf);
addIgnoreBuf[0] = '\0';
}
ImGui::Separator();
int ignoreCount = 0;
for (size_t ci = 0; ci < contacts.size(); ++ci) {
const auto& c = contacts[ci];
if (!c.isIgnored()) continue;
++ignoreCount;
ImGui::PushID(static_cast<int>(ci) + 10000);
const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str();
ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap);
if (ImGui::BeginPopupContextItem("IgnoreCtx")) {
ImGui::TextDisabled("%s", displayName);
ImGui::Separator();
if (ImGui::MenuItem("Remove Ignore")) {
gameHandler.removeIgnore(c.name);
}
ImGui::EndPopup();
}
ImGui::PopID();
}
if (ignoreCount == 0) {
ImGui::TextDisabled("Ignore list is empty.");
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
ImGui::End();
showGuildRoster_ = open;
}
// ============================================================
// Social Frame — compact online friends panel (toggled by showSocialFrame_)
// ============================================================
void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) {
if (!showSocialFrame_) return;
const auto& contacts = gameHandler.getContacts();
// Count online friends for early-out
int onlineCount = 0;
for (const auto& c : contacts)
if (c.isFriend() && c.isOnline()) ++onlineCount;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW - 230.0f, 240.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f));
bool open = showSocialFrame_;
char socialTitle[32];
snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount);
if (ImGui::Begin(socialTitle, &open,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) {
if (ImGui::BeginTabBar("##SocialTabs")) {
// ---- Friends tab ----
if (ImGui::BeginTabItem("Friends")) {
ImGui::BeginChild("##FriendsList", ImVec2(200, 200), false);
// Online friends first
int shown = 0;
for (int pass = 0; pass < 2; ++pass) {
bool wantOnline = (pass == 0);
for (size_t ci = 0; ci < contacts.size(); ++ci) {
const auto& c = contacts[ci];
if (!c.isFriend()) continue;
if (c.isOnline() != wantOnline) continue;
ImGui::PushID(static_cast<int>(ci));
// Status dot
ImU32 dotColor;
if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200);
else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK
else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND
else dotColor = IM_COL32( 50, 220, 50, 255); // online
ImVec2 dotMin = ImGui::GetCursorScreenPos();
dotMin.y += 4.0f;
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f);
const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str();
ImVec4 nameCol = c.isOnline()
? ImVec4(0.9f, 0.9f, 0.9f, 1.0f)
: ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
ImGui::TextColored(nameCol, "%s", displayName);
if (c.isOnline() && c.level > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv%u", c.level);
}
// Right-click context menu
if (ImGui::BeginPopupContextItem("FriendCtx")) {
ImGui::TextDisabled("%s", displayName);
ImGui::Separator();
if (c.isOnline()) {
if (ImGui::MenuItem("Whisper")) {
showSocialFrame_ = false;
strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
selectedChatType = 4;
refocusChatInput = true;
}
if (ImGui::MenuItem("Invite to Group"))
gameHandler.inviteToGroup(c.name);
}
if (ImGui::MenuItem("Remove Friend"))
gameHandler.removeFriend(c.name);
ImGui::EndPopup();
}
++shown;
ImGui::PopID();
}
// Separator between online and offline if there are both
if (pass == 0 && shown > 0) {
ImGui::Separator();
}
}
if (shown == 0) {
ImGui::TextDisabled("No friends yet.");
}
ImGui::EndChild();
ImGui::Separator();
// Add friend
static char addFriendBuf[64] = {};
ImGui::SetNextItemWidth(140.0f);
ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf));
ImGui::SameLine();
if (ImGui::Button("+##addfriend") && addFriendBuf[0] != '\0') {
gameHandler.addFriend(addFriendBuf);
addFriendBuf[0] = '\0';
}
ImGui::EndTabItem();
}
// ---- Ignore tab ----
if (ImGui::BeginTabItem("Ignore")) {
const auto& ignores = gameHandler.getIgnoreCache();
ImGui::BeginChild("##IgnoreList", ImVec2(200, 200), false);
if (ignores.empty()) {
ImGui::TextDisabled("Ignore list is empty.");
} else {
for (const auto& kv : ignores) {
ImGui::PushID(kv.first.c_str());
ImGui::TextUnformatted(kv.first.c_str());
if (ImGui::BeginPopupContextItem("IgnoreCtx")) {
ImGui::TextDisabled("%s", kv.first.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Unignore"))
gameHandler.removeIgnore(kv.first);
ImGui::EndPopup();
}
ImGui::PopID();
}
}
ImGui::EndChild();
ImGui::Separator();
// Add ignore
static char addIgnBuf[64] = {};
ImGui::SetNextItemWidth(140.0f);
ImGui::InputText("##sf_addignore", addIgnBuf, sizeof(addIgnBuf));
ImGui::SameLine();
if (ImGui::Button("+##addignore") && addIgnBuf[0] != '\0') {
gameHandler.addIgnore(addIgnBuf);
addIgnBuf[0] = '\0';
}
ImGui::EndTabItem();
}
// ---- Channels tab ----
if (ImGui::BeginTabItem("Channels")) {
const auto& channels = gameHandler.getJoinedChannels();
ImGui::BeginChild("##ChannelList", ImVec2(200, 200), false);
if (channels.empty()) {
ImGui::TextDisabled("Not in any channels.");
} else {
for (size_t ci = 0; ci < channels.size(); ++ci) {
ImGui::PushID(static_cast<int>(ci));
ImGui::TextUnformatted(channels[ci].c_str());
if (ImGui::BeginPopupContextItem("ChanCtx")) {
ImGui::TextDisabled("%s", channels[ci].c_str());
ImGui::Separator();
if (ImGui::MenuItem("Leave Channel"))
gameHandler.leaveChannel(channels[ci]);
ImGui::EndPopup();
}
ImGui::PopID();
}
}
ImGui::EndChild();
ImGui::Separator();
// Join a channel
static char joinChanBuf[64] = {};
ImGui::SetNextItemWidth(140.0f);
ImGui::InputText("##sf_joinchan", joinChanBuf, sizeof(joinChanBuf));
ImGui::SameLine();
if (ImGui::Button("+##joinchan") && joinChanBuf[0] != '\0') {
gameHandler.joinChannel(joinChanBuf);
joinChanBuf[0] = '\0';
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
ImGui::End();
showSocialFrame_ = open;
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Buff/Debuff Bar (Phase 3)
// ============================================================
void GameScreen::renderBuffBar(game::GameHandler& gameHandler) {
const auto& auras = gameHandler.getPlayerAuras();
// Count non-empty auras
int activeCount = 0;
for (const auto& a : auras) {
if (!a.isEmpty()) activeCount++;
}
if (activeCount == 0 && !gameHandler.hasPet()) return;
auto* assetMgr = core::Application::getInstance().getAssetManager();
// Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210)
// Anchored to the right side to stay away from party frames on the left
constexpr float ICON_SIZE = 32.0f;
constexpr int ICONS_PER_ROW = 8;
float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
// Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210)
ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f));
if (ImGui::Begin("##BuffBar", nullptr, flags)) {
// Separate buffs and debuffs; show buffs first, then debuffs with a visual gap
// Render one pass for buffs, one for debuffs
for (int pass = 0; pass < 2; ++pass) {
bool wantBuff = (pass == 0);
int shown = 0;
for (size_t i = 0; i < auras.size() && shown < 40; ++i) {
const auto& aura = auras[i];
if (aura.isEmpty()) continue;
bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag
if (isBuff != wantBuff) continue; // only render matching pass
if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(i) + (pass * 256));
ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f);
// Try to get spell icon
VkDescriptorSet iconTex = VK_NULL_HANDLE;
if (assetMgr) {
iconTex = getSpellIcon(aura.spellId, assetMgr);
}
if (iconTex) {
ImGui::PushStyleColor(ImGuiCol_Button, borderColor);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
ImGui::ImageButton("##aura",
(ImTextureID)(uintptr_t)iconTex,
ImVec2(ICON_SIZE - 4, ICON_SIZE - 4));
ImGui::PopStyleVar();
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Button, borderColor);
char label[8];
snprintf(label, sizeof(label), "%u", aura.spellId);
ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE));
ImGui::PopStyleColor();
}
// Compute remaining duration once (shared by overlay and tooltip)
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
int32_t remainMs = aura.getRemainingMs(nowMs);
// Duration countdown overlay — always visible on the icon bottom
if (remainMs > 0) {
ImVec2 iconMin = ImGui::GetItemRectMin();
ImVec2 iconMax = ImGui::GetItemRectMax();
char timeStr[12];
int secs = (remainMs + 999) / 1000; // ceiling seconds
if (secs >= 3600)
snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600);
else if (secs >= 60)
snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60);
else
snprintf(timeStr, sizeof(timeStr), "%d", secs);
ImVec2 textSize = ImGui::CalcTextSize(timeStr);
float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f;
float cy = iconMax.y - textSize.y - 2.0f;
// Choose timer color based on urgency
ImU32 timerColor;
if (remainMs < 10000) {
// < 10s: pulse red
float pulse = 0.7f + 0.3f * std::sin(
static_cast<float>(ImGui::GetTime()) * 6.0f);
timerColor = IM_COL32(
static_cast<int>(255 * pulse),
static_cast<int>(80 * pulse),
static_cast<int>(60 * pulse), 255);
} else if (remainMs < 30000) {
timerColor = IM_COL32(255, 165, 0, 255); // orange
} else {
timerColor = IM_COL32(255, 255, 255, 255); // white
}
// Drop shadow for readability over any icon colour
ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1),
IM_COL32(0, 0, 0, 200), timeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy),
timerColor, timeStr);
}
// Stack / charge count overlay — upper-left corner of the icon
if (aura.charges > 1) {
ImVec2 iconMin = ImGui::GetItemRectMin();
char chargeStr[8];
snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast<unsigned>(aura.charges));
// Drop shadow then bright yellow text
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3),
IM_COL32(0, 0, 0, 200), chargeStr);
ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2),
IM_COL32(255, 220, 50, 255), chargeStr);
}
// Right-click to cancel buffs / dismount
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
if (gameHandler.isMounted()) {
gameHandler.dismount();
} else if (isBuff) {
gameHandler.cancelAura(aura.spellId);
}
}
// Tooltip: rich spell info + remaining duration
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr);
if (!richOk) {
std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr);
if (name.empty()) name = "Spell #" + std::to_string(aura.spellId);
ImGui::Text("%s", name.c_str());
}
if (remainMs > 0) {
int seconds = remainMs / 1000;
char durBuf[32];
if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds);
else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf);
}
ImGui::EndTooltip();
}
ImGui::PopID();
shown++;
} // end aura loop
// Add visual gap between buffs and debuffs
if (pass == 0 && shown > 0) ImGui::Spacing();
} // end pass loop
// Dismiss Pet button
if (gameHandler.hasPet()) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f));
if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) {
gameHandler.dismissPet();
}
ImGui::PopStyleColor(2);
}
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
// ============================================================
// Loot Window (Phase 5)
// ============================================================
void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isLootWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always);
bool open = true;
if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
const auto& loot = gameHandler.getCurrentLoot();
// Gold
if (loot.gold > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc",
loot.getGold(), loot.getSilver(), loot.getCopper());
ImGui::Separator();
}
// Items with icons and labels
constexpr float iconSize = 32.0f;
int lootSlotClicked = -1; // defer loot pickup to avoid iterator invalidation
for (const auto& item : loot.items) {
ImGui::PushID(item.slotIndex);
// Get item info for name and quality
const auto* info = gameHandler.getItemInfo(item.itemId);
std::string itemName;
game::ItemQuality quality = game::ItemQuality::COMMON;
if (info && !info->name.empty()) {
itemName = info->name;
quality = static_cast<game::ItemQuality>(info->quality);
} else {
itemName = "Item #" + std::to_string(item.itemId);
}
ImVec4 qColor = InventoryScreen::getQualityColor(quality);
// Get item icon
uint32_t displayId = item.displayInfoId;
if (displayId == 0 && info) displayId = info->displayInfoId;
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(displayId);
ImVec2 cursor = ImGui::GetCursorScreenPos();
float rowH = std::max(iconSize, ImGui::GetTextLineHeight() * 2.0f);
// Invisible selectable for click handling
if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) {
if (ImGui::GetIO().KeyShift && info && !info->name.empty()) {
// Shift-click: insert item link into chat
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
} else {
lootSlotClicked = item.slotIndex;
}
}
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
lootSlotClicked = item.slotIndex;
}
bool hovered = ImGui::IsItemHovered();
// Show item tooltip on hover
if (hovered && info && info->valid) {
inventoryScreen.renderItemTooltip(*info);
} else if (hovered && !itemName.empty() && itemName[0] != 'I') {
ImGui::SetTooltip("%s", itemName.c_str());
}
ImDrawList* drawList = ImGui::GetWindowDrawList();
// Draw hover highlight
if (hovered) {
drawList->AddRectFilled(cursor,
ImVec2(cursor.x + ImGui::GetContentRegionAvail().x + iconSize + 8.0f,
cursor.y + rowH),
IM_COL32(255, 255, 255, 30));
}
// Draw icon
if (iconTex) {
drawList->AddImage((ImTextureID)(uintptr_t)iconTex,
cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize));
drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize),
ImGui::ColorConvertFloat4ToU32(qColor));
} else {
drawList->AddRectFilled(cursor,
ImVec2(cursor.x + iconSize, cursor.y + iconSize),
IM_COL32(40, 40, 50, 200));
drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize),
IM_COL32(80, 80, 80, 200));
}
// Draw item name
float textX = cursor.x + iconSize + 6.0f;
float textY = cursor.y + 2.0f;
drawList->AddText(ImVec2(textX, textY),
ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str());
// Draw count if > 1
if (item.count > 1) {
char countStr[32];
snprintf(countStr, sizeof(countStr), "x%u", item.count);
float countY = textY + ImGui::GetTextLineHeight();
drawList->AddText(ImVec2(textX, countY), IM_COL32(200, 200, 200, 220), countStr);
}
ImGui::PopID();
}
// Process deferred loot pickup (after loop to avoid iterator invalidation)
if (lootSlotClicked >= 0) {
gameHandler.lootItem(static_cast<uint8_t>(lootSlotClicked));
}
if (loot.items.empty() && loot.gold == 0) {
gameHandler.closeLoot();
}
ImGui::Spacing();
2026-03-11 22:16:19 -07:00
bool hasItems = !loot.items.empty();
if (hasItems) {
if (ImGui::Button("Loot All", ImVec2(-1, 0))) {
for (const auto& item : loot.items) {
gameHandler.lootItem(item.slotIndex);
}
}
ImGui::Spacing();
}
if (ImGui::Button("Close", ImVec2(-1, 0))) {
gameHandler.closeLoot();
}
}
ImGui::End();
if (!open) {
gameHandler.closeLoot();
}
}
// ============================================================
// Gossip Window (Phase 5)
// ============================================================
void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isGossipWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always);
bool open = true;
if (ImGui::Begin("NPC Dialog", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
const auto& gossip = gameHandler.getCurrentGossip();
// NPC name (from creature cache)
auto npcEntity = gameHandler.getEntityManager().getEntity(gossip.npcGuid);
if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(npcEntity);
if (!unit->getName().empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str());
ImGui::Separator();
}
}
ImGui::Spacing();
// Gossip option icons - matches WoW GossipOptionIcon enum
static const char* gossipIcons[] = {
"[Chat]", // 0 = GOSSIP_ICON_CHAT
"[Vendor]", // 1 = GOSSIP_ICON_VENDOR
"[Taxi]", // 2 = GOSSIP_ICON_TAXI
"[Trainer]", // 3 = GOSSIP_ICON_TRAINER
"[Interact]", // 4 = GOSSIP_ICON_INTERACT_1
"[Interact]", // 5 = GOSSIP_ICON_INTERACT_2
"[Banker]", // 6 = GOSSIP_ICON_MONEY_BAG (banker)
"[Chat]", // 7 = GOSSIP_ICON_TALK
"[Tabard]", // 8 = GOSSIP_ICON_TABARD
"[Battlemaster]", // 9 = GOSSIP_ICON_BATTLE
"[Option]", // 10 = GOSSIP_ICON_DOT
};
// Default text for server-sent gossip option placeholders
static const std::unordered_map<std::string, std::string> gossipPlaceholders = {
{"GOSSIP_OPTION_BANKER", "I would like to check my deposit box."},
{"GOSSIP_OPTION_AUCTIONEER", "I'd like to browse your auctions."},
{"GOSSIP_OPTION_VENDOR", "I want to browse your goods."},
{"GOSSIP_OPTION_TAXIVENDOR", "I'd like to fly."},
{"GOSSIP_OPTION_TRAINER", "I seek training."},
{"GOSSIP_OPTION_INNKEEPER", "Make this inn your home."},
{"GOSSIP_OPTION_SPIRITGUIDE", "Return me to life."},
{"GOSSIP_OPTION_SPIRITHEALER", "Bring me back to life."},
{"GOSSIP_OPTION_STABLEPET", "I'd like to stable my pet."},
{"GOSSIP_OPTION_ARMORER", "I need to repair my equipment."},
{"GOSSIP_OPTION_GOSSIP", "What can you tell me?"},
{"GOSSIP_OPTION_BATTLEFIELD", "I'd like to go to the battleground."},
{"GOSSIP_OPTION_TABARDDESIGNER", "I want to create a guild tabard."},
{"GOSSIP_OPTION_PETITIONER", "I want to create a guild."},
};
for (const auto& opt : gossip.options) {
ImGui::PushID(static_cast<int>(opt.id));
// Determine icon label - use text-based detection for shared icons
const char* icon = (opt.icon < 11) ? gossipIcons[opt.icon] : "[Option]";
if (opt.text == "GOSSIP_OPTION_AUCTIONEER") icon = "[Auctioneer]";
else if (opt.text == "GOSSIP_OPTION_BANKER") icon = "[Banker]";
else if (opt.text == "GOSSIP_OPTION_VENDOR") icon = "[Vendor]";
else if (opt.text == "GOSSIP_OPTION_TRAINER") icon = "[Trainer]";
else if (opt.text == "GOSSIP_OPTION_INNKEEPER") icon = "[Innkeeper]";
else if (opt.text == "GOSSIP_OPTION_STABLEPET") icon = "[Stable Master]";
else if (opt.text == "GOSSIP_OPTION_ARMORER") icon = "[Repair]";
// Resolve placeholder text from server
std::string displayText = opt.text;
auto placeholderIt = gossipPlaceholders.find(displayText);
if (placeholderIt != gossipPlaceholders.end()) {
displayText = placeholderIt->second;
}
std::string processedText = replaceGenderPlaceholders(displayText, gameHandler);
std::string label = std::string(icon) + " " + processedText;
if (ImGui::Selectable(label.c_str())) {
if (opt.text == "GOSSIP_OPTION_ARMORER") {
gameHandler.setVendorCanRepair(true);
}
gameHandler.selectGossipOption(opt.id);
}
ImGui::PopID();
}
// Fallback: some spirit healers don't send gossip options.
if (gossip.options.empty() && gameHandler.isPlayerGhost()) {
bool isSpirit = false;
if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(npcEntity);
std::string name = unit->getName();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
if (name.find("spirit healer") != std::string::npos ||
name.find("spirit guide") != std::string::npos) {
isSpirit = true;
}
}
if (isSpirit) {
if (ImGui::Selectable("[Spiritguide] Return to Graveyard")) {
gameHandler.activateSpiritHealer(gossip.npcGuid);
gameHandler.closeGossip();
}
}
}
// Quest items
if (!gossip.quests.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "Quests:");
for (size_t qi = 0; qi < gossip.quests.size(); qi++) {
const auto& quest = gossip.quests[qi];
ImGui::PushID(static_cast<int>(qi));
// Determine icon and color based on QuestGiverStatus stored in questIcon
// 5=INCOMPLETE (gray?), 6=REWARD_REP (yellow?), 7=AVAILABLE_LOW (gray!),
// 8=AVAILABLE (yellow!), 10=REWARD (yellow?)
const char* statusIcon = "!";
ImVec4 statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow
switch (quest.questIcon) {
case 5: // INCOMPLETE — in progress but not done
statusIcon = "?";
statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray
break;
case 6: // REWARD_REP — repeatable, ready to turn in
case 10: // REWARD — ready to turn in
statusIcon = "?";
statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow
break;
case 7: // AVAILABLE_LOW — available but gray (low-level)
statusIcon = "!";
statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray
break;
default: // AVAILABLE (8) and any others
statusIcon = "!";
statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow
break;
}
// Render: colored icon glyph then [Lv] Title
ImGui::TextColored(statusColor, "%s", statusIcon);
ImGui::SameLine(0, 4);
char qlabel[256];
snprintf(qlabel, sizeof(qlabel), "[%d] %s", quest.questLevel, quest.title.c_str());
ImGui::PushStyleColor(ImGuiCol_Text, statusColor);
if (ImGui::Selectable(qlabel)) {
gameHandler.selectGossipQuest(quest.questId);
}
ImGui::PopStyleColor();
ImGui::PopID();
}
}
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(-1, 0))) {
gameHandler.closeGossip();
}
}
ImGui::End();
if (!open) {
gameHandler.closeGossip();
}
}
// ============================================================
// Quest Details Window
// ============================================================
void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isQuestDetailsOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing);
bool open = true;
const auto& quest = gameHandler.getQuestDetails();
std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler);
if (ImGui::Begin(processedTitle.c_str(), &open)) {
// Quest description
if (!quest.details.empty()) {
std::string processedDetails = replaceGenderPlaceholders(quest.details, gameHandler);
ImGui::TextWrapped("%s", processedDetails.c_str());
}
// Objectives
if (!quest.objectives.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Objectives:");
std::string processedObjectives = replaceGenderPlaceholders(quest.objectives, gameHandler);
ImGui::TextWrapped("%s", processedObjectives.c_str());
}
// Choice reward items (player picks one)
auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) {
gameHandler.ensureItemInfo(ri.itemId);
auto* info = gameHandler.getItemInfo(ri.itemId);
VkDescriptorSet iconTex = VK_NULL_HANDLE;
uint32_t dispId = ri.displayInfoId;
if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId;
if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId);
std::string label;
ImVec4 nameCol = ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
if (info && info->valid && !info->name.empty()) {
label = info->name;
nameCol = InventoryScreen::getQualityColor(static_cast<game::ItemQuality>(info->quality));
} else {
label = "Item " + std::to_string(ri.itemId);
}
if (ri.count > 1) label += " x" + std::to_string(ri.count);
if (iconTex) {
ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18));
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
ImGui::SameLine();
}
ImGui::TextColored(nameCol, " %s", label.c_str());
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
};
if (!quest.rewardChoiceItems.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose one reward:");
for (const auto& ri : quest.rewardChoiceItems) {
renderQuestRewardItem(ri);
}
}
// Fixed reward items (always given)
if (!quest.rewardItems.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will receive:");
for (const auto& ri : quest.rewardItems) {
renderQuestRewardItem(ri);
}
}
// XP and money rewards
if (quest.rewardXp > 0 || quest.rewardMoney > 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Rewards:");
if (quest.rewardXp > 0) {
ImGui::Text(" %u experience", quest.rewardXp);
}
if (quest.rewardMoney > 0) {
uint32_t gold = quest.rewardMoney / 10000;
uint32_t silver = (quest.rewardMoney % 10000) / 100;
uint32_t copper = quest.rewardMoney % 100;
if (gold > 0) ImGui::Text(" %ug %us %uc", gold, silver, copper);
else if (silver > 0) ImGui::Text(" %us %uc", silver, copper);
else ImGui::Text(" %uc", copper);
}
}
if (quest.suggestedPlayers > 1) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
"Suggested players: %u", quest.suggestedPlayers);
}
// Accept / Decline buttons
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button("Accept", ImVec2(buttonW, 0))) {
gameHandler.acceptQuest();
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(buttonW, 0))) {
gameHandler.declineQuest();
}
}
ImGui::End();
if (!open) {
gameHandler.declineQuest();
}
}
// ============================================================
// Quest Request Items Window (turn-in progress check)
// ============================================================
void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isQuestRequestItemsOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(450, 350), ImGuiCond_Appearing);
bool open = true;
const auto& quest = gameHandler.getQuestRequestItems();
auto countItemInInventory = [&](uint32_t itemId) -> uint32_t {
const auto& inv = gameHandler.getInventory();
uint32_t total = 0;
for (int i = 0; i < inv.getBackpackSize(); ++i) {
const auto& slot = inv.getBackpackSlot(i);
if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount;
}
for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; ++bag) {
int bagSize = inv.getBagSize(bag);
for (int s = 0; s < bagSize; ++s) {
const auto& slot = inv.getBagSlot(bag, s);
if (!slot.empty() && slot.item.itemId == itemId) total += slot.item.stackCount;
}
}
return total;
};
std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler);
if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
if (!quest.completionText.empty()) {
std::string processedCompletionText = replaceGenderPlaceholders(quest.completionText, gameHandler);
ImGui::TextWrapped("%s", processedCompletionText.c_str());
}
// Required items
if (!quest.requiredItems.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Required Items:");
for (const auto& item : quest.requiredItems) {
uint32_t have = countItemInInventory(item.itemId);
bool enough = have >= item.count;
ImVec4 textCol = enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f);
auto* info = gameHandler.getItemInfo(item.itemId);
const char* name = (info && info->valid) ? info->name.c_str() : nullptr;
// Show icon if display info is available
uint32_t dispId = item.displayInfoId;
if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId;
if (dispId != 0) {
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId);
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18));
ImGui::SameLine();
}
}
if (name && *name) {
ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count);
} else {
ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count);
}
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
}
}
if (quest.requiredMoney > 0) {
ImGui::Spacing();
uint32_t g = quest.requiredMoney / 10000;
uint32_t s = (quest.requiredMoney % 10000) / 100;
uint32_t c = quest.requiredMoney % 100;
ImGui::Text("Required money: %ug %us %uc", g, s, c);
}
// Complete / Cancel buttons
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) {
gameHandler.completeQuest();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) {
gameHandler.closeQuestRequestItems();
}
if (!quest.isCompletable()) {
ImGui::TextDisabled("Server flagged this quest as incomplete; completion will be server-validated.");
}
}
ImGui::End();
if (!open) {
gameHandler.closeQuestRequestItems();
}
}
// ============================================================
// Quest Offer Reward Window (choose reward)
// ============================================================
void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isQuestOfferRewardOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, screenH / 2 - 200), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing);
bool open = true;
const auto& quest = gameHandler.getQuestOfferReward();
static int selectedChoice = -1;
// Auto-select if only one choice reward
if (quest.choiceRewards.size() == 1 && selectedChoice == -1) {
selectedChoice = 0;
}
std::string processedTitle = replaceGenderPlaceholders(quest.title, gameHandler);
if (ImGui::Begin(processedTitle.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
if (!quest.rewardText.empty()) {
std::string processedRewardText = replaceGenderPlaceholders(quest.rewardText, gameHandler);
ImGui::TextWrapped("%s", processedRewardText.c_str());
}
// Choice rewards (pick one)
// Trigger item info fetch for all reward items
for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId);
for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId);
// Helper: resolve icon tex + quality color for a reward item
auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri)
-> std::pair<VkDescriptorSet, ImVec4>
{
auto* info = gameHandler.getItemInfo(ri.itemId);
uint32_t dispId = ri.displayInfoId;
if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId;
VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
ImVec4 col = (info && info->valid)
? InventoryScreen::getQualityColor(static_cast<game::ItemQuality>(info->quality))
: ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
return {iconTex, col};
};
// Helper: show full item tooltip (reuses InventoryScreen's rich tooltip)
auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) {
auto* info = gameHandler.getItemInfo(ri.itemId);
if (!info || !info->valid) {
ImGui::BeginTooltip();
ImGui::TextDisabled("Loading item data...");
ImGui::EndTooltip();
return;
}
inventoryScreen.renderItemTooltip(*info);
};
if (!quest.choiceRewards.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose a reward:");
for (size_t i = 0; i < quest.choiceRewards.size(); ++i) {
const auto& item = quest.choiceRewards[i];
auto* info = gameHandler.getItemInfo(item.itemId);
auto [iconTex, qualityColor] = resolveRewardItemVis(item);
std::string label;
if (info && info->valid && !info->name.empty()) label = info->name;
else label = "Item " + std::to_string(item.itemId);
if (item.count > 1) label += " x" + std::to_string(item.count);
bool selected = (selectedChoice == static_cast<int>(i));
ImGui::PushID(static_cast<int>(i));
// Icon then selectable on same line
if (iconTex) {
ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20));
if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor);
ImGui::SameLine();
}
ImGui::PushStyleColor(ImGuiCol_Text, qualityColor);
if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) {
if (ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
} else {
selectedChoice = static_cast<int>(i);
}
}
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor);
ImGui::PopID();
}
}
// Fixed rewards (always given)
if (!quest.fixedRewards.empty()) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will also receive:");
for (const auto& item : quest.fixedRewards) {
auto* info = gameHandler.getItemInfo(item.itemId);
auto [iconTex, qualityColor] = resolveRewardItemVis(item);
std::string label;
if (info && info->valid && !info->name.empty()) label = info->name;
else label = "Item " + std::to_string(item.itemId);
if (item.count > 1) label += " x" + std::to_string(item.count);
if (iconTex) {
ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18));
if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor);
ImGui::SameLine();
}
ImGui::TextColored(qualityColor, " %s", label.c_str());
if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
}
}
// Money / XP rewards
if (quest.rewardXp > 0 || quest.rewardMoney > 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Rewards:");
if (quest.rewardXp > 0)
ImGui::Text(" %u experience", quest.rewardXp);
if (quest.rewardMoney > 0) {
uint32_t g = quest.rewardMoney / 10000;
uint32_t s = (quest.rewardMoney % 10000) / 100;
uint32_t c = quest.rewardMoney % 100;
if (g > 0) ImGui::Text(" %ug %us %uc", g, s, c);
else if (s > 0) ImGui::Text(" %us %uc", s, c);
else ImGui::Text(" %uc", c);
}
}
// Complete button
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float buttonW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
bool canComplete = quest.choiceRewards.empty() || selectedChoice >= 0;
if (!canComplete) ImGui::BeginDisabled();
if (ImGui::Button("Complete Quest", ImVec2(buttonW, 0))) {
uint32_t rewardIdx = 0;
if (!quest.choiceRewards.empty() && selectedChoice >= 0 &&
selectedChoice < static_cast<int>(quest.choiceRewards.size())) {
// Server expects the original slot index from its fixed-size reward array.
rewardIdx = quest.choiceRewards[static_cast<size_t>(selectedChoice)].choiceSlot;
}
gameHandler.chooseQuestReward(rewardIdx);
selectedChoice = -1;
}
if (!canComplete) ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(buttonW, 0))) {
gameHandler.closeQuestOfferReward();
selectedChoice = -1;
}
}
ImGui::End();
if (!open) {
gameHandler.closeQuestOfferReward();
selectedChoice = -1;
}
}
// ============================================================
// Vendor Window (Phase 5)
// ============================================================
void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isVendorWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 100), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(450, 400), ImGuiCond_Appearing);
bool open = true;
if (ImGui::Begin("Vendor", &open)) {
const auto& vendor = gameHandler.getVendorItems();
// Show player money
uint64_t money = gameHandler.getMoneyCopper();
uint32_t mg = static_cast<uint32_t>(money / 10000);
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(money % 100);
ImGui::Text("Your money: %ug %us %uc", mg, ms, mc);
if (vendor.canRepair) {
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f);
if (ImGui::SmallButton("Repair All")) {
gameHandler.repairAll(vendor.vendorGuid, false);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Repair all equipped items");
}
}
ImGui::Separator();
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell");
// Count grey (POOR quality) sellable items across backpack and bags
const auto& inv = gameHandler.getInventory();
int junkCount = 0;
for (int i = 0; i < inv.getBackpackSize(); ++i) {
const auto& sl = inv.getBackpackSlot(i);
if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0)
++junkCount;
}
for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) {
for (int s = 0; s < inv.getBagSize(b); ++s) {
const auto& sl = inv.getBagSlot(b, s);
if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0)
++junkCount;
}
}
if (junkCount > 0) {
char junkLabel[64];
snprintf(junkLabel, sizeof(junkLabel), "Sell All Junk (%d item%s)",
junkCount, junkCount == 1 ? "" : "s");
if (ImGui::Button(junkLabel, ImVec2(-1, 0))) {
for (int i = 0; i < inv.getBackpackSize(); ++i) {
const auto& sl = inv.getBackpackSlot(i);
if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0)
gameHandler.sellItemBySlot(i);
}
for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) {
for (int s = 0; s < inv.getBagSize(b); ++s) {
const auto& sl = inv.getBagSlot(b, s);
if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0)
gameHandler.sellItemInBag(b, s);
}
}
}
}
ImGui::Separator();
const auto& buyback = gameHandler.getBuybackItems();
if (!buyback.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Buy Back");
if (ImGui::BeginTable("BuybackTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f);
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f);
ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f);
ImGui::TableHeadersRow();
// Show all buyback items (most recently sold first)
for (int i = 0; i < static_cast<int>(buyback.size()); ++i) {
const auto& entry = buyback[i];
gameHandler.ensureItemInfo(entry.item.itemId);
auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId);
uint32_t sellPrice = entry.item.sellPrice;
if (sellPrice == 0) {
if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice;
}
uint64_t price = static_cast<uint64_t>(sellPrice) *
static_cast<uint64_t>(entry.count > 0 ? entry.count : 1);
uint32_t g = static_cast<uint32_t>(price / 10000);
uint32_t s = static_cast<uint32_t>((price / 100) % 100);
uint32_t c = static_cast<uint32_t>(price % 100);
bool canAfford = money >= price;
ImGui::TableNextRow();
ImGui::PushID(8000 + i);
ImGui::TableSetColumnIndex(0);
{
uint32_t dispId = entry.item.displayInfoId;
if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId;
if (dispId != 0) {
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId);
if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18));
}
}
ImGui::TableSetColumnIndex(1);
game::ItemQuality bbQuality = entry.item.quality;
if (bbInfo && bbInfo->valid) bbQuality = static_cast<game::ItemQuality>(bbInfo->quality);
ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality);
const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str();
if (entry.count > 1) {
ImGui::TextColored(bbQc, "%s x%u", name, entry.count);
} else {
ImGui::TextColored(bbQc, "%s", name);
}
if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid)
inventoryScreen.renderItemTooltip(*bbInfo);
ImGui::TableSetColumnIndex(2);
if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::Text("%ug %us %uc", g, s, c);
if (!canAfford) ImGui::PopStyleColor();
ImGui::TableSetColumnIndex(3);
if (!canAfford) ImGui::BeginDisabled();
char bbLabel[32];
snprintf(bbLabel, sizeof(bbLabel), "Buy Back##bb%d", i);
if (ImGui::SmallButton(bbLabel)) {
gameHandler.buyBackItem(static_cast<uint32_t>(i));
}
if (!canAfford) ImGui::EndDisabled();
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::Separator();
}
if (vendor.items.empty()) {
ImGui::TextDisabled("This vendor has nothing for sale.");
} else {
// Search + quantity controls on one row
ImGui::SetNextItemWidth(200.0f);
ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_));
ImGui::SameLine();
ImGui::Text("Qty:");
ImGui::SameLine();
ImGui::SetNextItemWidth(60.0f);
static int vendorBuyQty = 1;
ImGui::InputInt("##VendorQty", &vendorBuyQty, 1, 5);
if (vendorBuyQty < 1) vendorBuyQty = 1;
if (vendorBuyQty > 99) vendorBuyQty = 99;
ImGui::Spacing();
if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f);
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f);
ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f);
ImGui::TableHeadersRow();
std::string vendorFilter(vendorSearchFilter_);
// Lowercase filter for case-insensitive match
for (char& c : vendorFilter) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (int vi = 0; vi < static_cast<int>(vendor.items.size()); ++vi) {
const auto& item = vendor.items[vi];
// Proactively ensure vendor item info is loaded
gameHandler.ensureItemInfo(item.itemId);
auto* info = gameHandler.getItemInfo(item.itemId);
// Apply search filter
if (!vendorFilter.empty()) {
std::string nameLC = info && info->valid ? info->name : ("Item " + std::to_string(item.itemId));
for (char& c : nameLC) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (nameLC.find(vendorFilter) == std::string::npos) {
ImGui::PushID(vi);
ImGui::PopID();
continue;
}
}
ImGui::TableNextRow();
ImGui::PushID(vi);
// Icon column
ImGui::TableSetColumnIndex(0);
{
uint32_t dispId = item.displayInfoId;
if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId;
if (dispId != 0) {
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId);
if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18));
}
}
// Name column
ImGui::TableSetColumnIndex(1);
if (info && info->valid) {
ImVec4 qc = InventoryScreen::getQualityColor(static_cast<game::ItemQuality>(info->quality));
ImGui::TextColored(qc, "%s", info->name.c_str());
if (ImGui::IsItemHovered()) {
inventoryScreen.renderItemTooltip(*info);
}
// Shift-click: insert item link into chat
if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
} else {
ImGui::Text("Item %u", item.itemId);
}
ImGui::TableSetColumnIndex(2);
if (item.buyPrice == 0 && item.extendedCost != 0) {
// Token-only item (no gold cost)
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]");
} else {
uint32_t g = item.buyPrice / 10000;
uint32_t s = (item.buyPrice / 100) % 100;
uint32_t c = item.buyPrice % 100;
bool canAfford = money >= item.buyPrice;
if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::Text("%ug %us %uc", g, s, c);
if (!canAfford) ImGui::PopStyleColor();
}
ImGui::TableSetColumnIndex(3);
if (item.maxCount < 0) {
ImGui::TextDisabled("Inf");
} else if (item.maxCount == 0) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Out");
} else if (item.maxCount <= 5) {
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), "%d", item.maxCount);
} else {
ImGui::Text("%d", item.maxCount);
}
ImGui::TableSetColumnIndex(4);
bool outOfStock = (item.maxCount == 0);
if (outOfStock) ImGui::BeginDisabled();
std::string buyBtnId = "Buy##vendor_" + std::to_string(vi);
if (ImGui::SmallButton(buyBtnId.c_str())) {
int qty = vendorBuyQty;
if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount;
gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot,
static_cast<uint32_t>(qty));
}
if (outOfStock) ImGui::EndDisabled();
ImGui::PopID();
}
ImGui::EndTable();
}
}
}
ImGui::End();
if (!open) {
gameHandler.closeVendor();
}
}
// ============================================================
// Trainer
// ============================================================
void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isTrainerWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
auto* assetMgr = core::Application::getInstance().getAssetManager();
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing);
bool open = true;
if (ImGui::Begin("Trainer", &open)) {
const auto& trainer = gameHandler.getTrainerSpells();
// NPC name
auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid);
if (npcEntity && npcEntity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(npcEntity);
if (!unit->getName().empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", unit->getName().c_str());
}
}
// Greeting
if (!trainer.greeting.empty()) {
ImGui::TextWrapped("%s", trainer.greeting.c_str());
}
ImGui::Separator();
// Player money
uint64_t money = gameHandler.getMoneyCopper();
uint32_t mg = static_cast<uint32_t>(money / 10000);
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(money % 100);
ImGui::Text("Your money: %ug %us %uc", mg, ms, mc);
// Filter controls
static bool showUnavailable = false;
ImGui::Checkbox("Show unavailable spells", &showUnavailable);
ImGui::SameLine();
ImGui::SetNextItemWidth(-1.0f);
ImGui::InputTextWithHint("##TrainerSearch", "Search...", trainerSearchFilter_, sizeof(trainerSearchFilter_));
ImGui::Separator();
if (trainer.spells.empty()) {
ImGui::TextDisabled("This trainer has nothing to teach you.");
} else {
// Known spells for checking
const auto& knownSpells = gameHandler.getKnownSpells();
auto isKnown = [&](uint32_t id) {
if (id == 0) return true;
// Check if spell is in knownSpells list
bool found = knownSpells.count(id);
if (found) return true;
// Also check if spell is in trainer list with state=2 (explicitly known)
// state=0 means unavailable (could be no prereqs, wrong level, etc.) - don't count as known
for (const auto& ts : trainer.spells) {
if (ts.spellId == id && ts.state == 2) {
return true;
}
}
return false;
};
uint32_t playerLevel = gameHandler.getPlayerLevel();
// Renders spell rows into the current table
auto renderSpellRows = [&](const std::vector<const game::TrainerSpell*>& spells) {
for (const auto* spell : spells) {
// Check prerequisites client-side first
bool prereq1Met = isKnown(spell->chainNode1);
bool prereq2Met = isKnown(spell->chainNode2);
bool prereq3Met = isKnown(spell->chainNode3);
bool prereqsMet = prereq1Met && prereq2Met && prereq3Met;
bool levelMet = (spell->reqLevel == 0 || playerLevel >= spell->reqLevel);
bool alreadyKnown = isKnown(spell->spellId);
// Dynamically determine effective state based on current prerequisites
// Server sends state, but we override if prerequisites are now met
uint8_t effectiveState = spell->state;
if (spell->state == 1 && prereqsMet && levelMet) {
// Server said unavailable, but we now meet all requirements
effectiveState = 0; // Treat as available
}
// Filter: skip unavailable spells if checkbox is unchecked
// Use effectiveState so spells with newly met prereqs aren't filtered
if (!showUnavailable && effectiveState == 1) {
continue;
}
// Apply text search filter
if (trainerSearchFilter_[0] != '\0') {
std::string trainerFilter(trainerSearchFilter_);
for (char& c : trainerFilter) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const std::string& spellName = gameHandler.getSpellName(spell->spellId);
std::string nameLC = spellName.empty() ? std::to_string(spell->spellId) : spellName;
for (char& c : nameLC) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (nameLC.find(trainerFilter) == std::string::npos) {
ImGui::PushID(static_cast<int>(spell->spellId));
ImGui::PopID();
continue;
}
}
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(spell->spellId));
ImVec4 color;
const char* statusLabel;
// WotLK trainer states: 0=available, 1=unavailable, 2=known
if (effectiveState == 2 || alreadyKnown) {
color = ImVec4(0.3f, 0.9f, 0.3f, 1.0f);
statusLabel = "Known";
} else if (effectiveState == 0) {
color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
statusLabel = "Available";
} else {
color = ImVec4(0.6f, 0.3f, 0.3f, 1.0f);
statusLabel = "Unavailable";
}
// Icon column
ImGui::TableSetColumnIndex(0);
{
VkDescriptorSet spellIcon = getSpellIcon(spell->spellId, assetMgr);
if (spellIcon) {
if (effectiveState == 1 && !alreadyKnown) {
ImGui::ImageWithBg((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18),
ImVec2(0, 0), ImVec2(1, 1),
ImVec4(0, 0, 0, 0), ImVec4(0.5f, 0.5f, 0.5f, 0.6f));
} else {
ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18));
}
}
}
// Spell name
ImGui::TableSetColumnIndex(1);
const std::string& name = gameHandler.getSpellName(spell->spellId);
const std::string& rank = gameHandler.getSpellRank(spell->spellId);
if (!name.empty()) {
if (!rank.empty())
ImGui::TextColored(color, "%s (%s)", name.c_str(), rank.c_str());
else
ImGui::TextColored(color, "%s", name.c_str());
} else {
ImGui::TextColored(color, "Spell #%u", spell->spellId);
}
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
if (!name.empty()) {
ImGui::Text("%s", name.c_str());
if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str());
}
ImGui::Text("Status: %s", statusLabel);
if (spell->reqLevel > 0) {
ImVec4 lvlColor = levelMet ? ImVec4(0.7f, 0.7f, 0.7f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel);
}
if (spell->reqSkill > 0) ImGui::Text("Required Skill: %u (value %u)", spell->reqSkill, spell->reqSkillValue);
auto showPrereq = [&](uint32_t node) {
if (node == 0) return;
bool met = isKnown(node);
const std::string& pname = gameHandler.getSpellName(node);
ImVec4 pcolor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
if (!pname.empty())
ImGui::TextColored(pcolor, "Requires: %s%s", pname.c_str(), met ? " (known)" : "");
else
ImGui::TextColored(pcolor, "Requires: Spell #%u%s", node, met ? " (known)" : "");
};
showPrereq(spell->chainNode1);
showPrereq(spell->chainNode2);
showPrereq(spell->chainNode3);
ImGui::EndTooltip();
}
// Level
ImGui::TableSetColumnIndex(2);
ImGui::TextColored(color, "%u", spell->reqLevel);
// Cost
ImGui::TableSetColumnIndex(3);
if (spell->spellCost > 0) {
uint32_t g = spell->spellCost / 10000;
uint32_t s = (spell->spellCost / 100) % 100;
uint32_t c = spell->spellCost % 100;
bool canAfford = money >= spell->spellCost;
ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
ImGui::TextColored(costColor, "%ug %us %uc", g, s, c);
} else {
ImGui::TextColored(color, "Free");
}
// Train button - only enabled if available, affordable, prereqs met
ImGui::TableSetColumnIndex(4);
// Use effectiveState so newly available spells (after learning prereqs) can be trained
bool canTrain = !alreadyKnown && effectiveState == 0
&& prereqsMet && levelMet
&& (money >= spell->spellCost);
// Debug logging for first 3 spells to see why buttons are disabled
static int logCount = 0;
static uint64_t lastTrainerGuid = 0;
if (trainer.trainerGuid != lastTrainerGuid) {
logCount = 0;
lastTrainerGuid = trainer.trainerGuid;
}
if (logCount < 3) {
LOG_INFO("Trainer button debug: spellId=", spell->spellId,
" alreadyKnown=", alreadyKnown, " state=", (int)spell->state,
" prereqsMet=", prereqsMet, " (", prereq1Met, ",", prereq2Met, ",", prereq3Met, ")",
" levelMet=", levelMet,
" reqLevel=", spell->reqLevel, " playerLevel=", playerLevel,
" chain1=", spell->chainNode1, " chain2=", spell->chainNode2, " chain3=", spell->chainNode3,
" canAfford=", (money >= spell->spellCost),
" canTrain=", canTrain);
logCount++;
}
if (!canTrain) ImGui::BeginDisabled();
if (ImGui::SmallButton("Train")) {
gameHandler.trainSpell(spell->spellId);
}
if (!canTrain) ImGui::EndDisabled();
ImGui::PopID();
}
};
auto renderSpellTable = [&](const char* tableId, const std::vector<const game::TrainerSpell*>& spells) {
if (ImGui::BeginTable(tableId, 5,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f);
ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f);
ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 55.0f);
ImGui::TableHeadersRow();
renderSpellRows(spells);
ImGui::EndTable();
}
};
const auto& tabs = gameHandler.getTrainerTabs();
if (tabs.size() > 1) {
// Multiple tabs - show tab bar
if (ImGui::BeginTabBar("TrainerTabs")) {
for (size_t i = 0; i < tabs.size(); i++) {
char tabLabel[64];
snprintf(tabLabel, sizeof(tabLabel), "%s (%zu)",
tabs[i].name.c_str(), tabs[i].spells.size());
if (ImGui::BeginTabItem(tabLabel)) {
char tableId[32];
snprintf(tableId, sizeof(tableId), "TT%zu", i);
renderSpellTable(tableId, tabs[i].spells);
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
} else {
// Single tab or no categorization - flat list
std::vector<const game::TrainerSpell*> allSpells;
allSpells.reserve(trainer.spells.size());
for (const auto& spell : trainer.spells) {
allSpells.push_back(&spell);
}
renderSpellTable("TrainerTable", allSpells);
}
// Count how many spells are trainable right now
int trainableCount = 0;
uint64_t totalCost = 0;
for (const auto& spell : trainer.spells) {
bool prereq1Met = isKnown(spell.chainNode1);
bool prereq2Met = isKnown(spell.chainNode2);
bool prereq3Met = isKnown(spell.chainNode3);
bool prereqsMet = prereq1Met && prereq2Met && prereq3Met;
bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel);
bool alreadyKnown = isKnown(spell.spellId);
uint8_t effectiveState = spell.state;
if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0;
bool canTrain = !alreadyKnown && effectiveState == 0
&& prereqsMet && levelMet
&& (money >= spell.spellCost);
if (canTrain) {
++trainableCount;
totalCost += spell.spellCost;
}
}
ImGui::Separator();
bool canAffordAll = (money >= totalCost);
bool hasTrainable = (trainableCount > 0) && canAffordAll;
if (!hasTrainable) ImGui::BeginDisabled();
uint32_t tag = static_cast<uint32_t>(totalCost / 10000);
uint32_t tas = static_cast<uint32_t>((totalCost / 100) % 100);
uint32_t tac = static_cast<uint32_t>(totalCost % 100);
char trainAllLabel[80];
if (trainableCount == 0) {
snprintf(trainAllLabel, sizeof(trainAllLabel), "Train All Available (none)");
} else {
snprintf(trainAllLabel, sizeof(trainAllLabel),
"Train All Available (%d spell%s, %ug %us %uc)",
trainableCount, trainableCount == 1 ? "" : "s",
tag, tas, tac);
}
if (ImGui::Button(trainAllLabel, ImVec2(-1.0f, 0.0f))) {
for (const auto& spell : trainer.spells) {
bool prereq1Met = isKnown(spell.chainNode1);
bool prereq2Met = isKnown(spell.chainNode2);
bool prereq3Met = isKnown(spell.chainNode3);
bool prereqsMet = prereq1Met && prereq2Met && prereq3Met;
bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel);
bool alreadyKnown = isKnown(spell.spellId);
uint8_t effectiveState = spell.state;
if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0;
bool canTrain = !alreadyKnown && effectiveState == 0
&& prereqsMet && levelMet
&& (money >= spell.spellCost);
if (canTrain) {
gameHandler.trainSpell(spell.spellId);
}
}
}
if (!hasTrainable) ImGui::EndDisabled();
}
}
ImGui::End();
if (!open) {
gameHandler.closeTrainer();
}
}
// ============================================================
// Teleporter Panel
// ============================================================
// ============================================================
// Escape Menu
// ============================================================
void GameScreen::renderEscapeMenu() {
if (!showEscapeMenu) return;
ImGuiIO& io = ImGui::GetIO();
float screenW = io.DisplaySize.x;
float screenH = io.DisplaySize.y;
ImVec2 size(260.0f, 248.0f);
ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f);
ImGui::SetNextWindowPos(pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar;
if (ImGui::Begin("##EscapeMenu", nullptr, flags)) {
ImGui::Text("Game Menu");
ImGui::Separator();
if (ImGui::Button("Logout", ImVec2(-1, 0))) {
core::Application::getInstance().logoutToLogin();
showEscapeMenu = false;
showEscapeSettingsNotice = false;
}
if (ImGui::Button("Quit", ImVec2(-1, 0))) {
2026-02-05 16:17:04 -08:00
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* music = renderer->getMusicManager()) {
music->stopMusic(0.0f);
}
}
core::Application::getInstance().shutdown();
}
if (ImGui::Button("Settings", ImVec2(-1, 0))) {
showEscapeSettingsNotice = false;
showSettingsWindow = true;
settingsInit = false;
showEscapeMenu = false;
}
if (ImGui::Button("Instance Lockouts", ImVec2(-1, 0))) {
showInstanceLockouts_ = true;
showEscapeMenu = false;
}
if (ImGui::Button("Help / GM Ticket", ImVec2(-1, 0))) {
showGmTicketWindow_ = true;
showEscapeMenu = false;
}
2026-02-05 16:21:17 -08:00
ImGui::Spacing();
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f));
if (ImGui::Button("Back to Game", ImVec2(-1, 0))) {
showEscapeMenu = false;
showEscapeSettingsNotice = false;
}
ImGui::PopStyleVar();
}
ImGui::End();
}
// ============================================================
// Taxi Window
// ============================================================
void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isTaxiWindowOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 200, 150), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_Always);
bool open = true;
if (ImGui::Begin("Flight Master", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
const auto& taxiData = gameHandler.getTaxiData();
const auto& nodes = gameHandler.getTaxiNodes();
uint32_t currentNode = gameHandler.getTaxiCurrentNode();
// Get current node's map to filter destinations
uint32_t currentMapId = 0;
auto curIt = nodes.find(currentNode);
if (curIt != nodes.end()) {
currentMapId = curIt->second.mapId;
ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), "Current: %s", curIt->second.name.c_str());
ImGui::Separator();
}
ImGui::Text("Select a destination:");
ImGui::Spacing();
2026-02-07 20:02:14 -08:00
static uint32_t selectedNodeId = 0;
int destCount = 0;
2026-02-07 20:02:14 -08:00
if (ImGui::BeginTable("TaxiNodes", 3, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Destination", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 60.0f);
ImGui::TableHeadersRow();
for (const auto& [nodeId, node] : nodes) {
if (nodeId == currentNode) continue;
if (node.mapId != currentMapId) continue;
if (!taxiData.isNodeKnown(nodeId)) continue;
uint32_t costCopper = gameHandler.getTaxiCostTo(nodeId);
uint32_t gold = costCopper / 10000;
uint32_t silver = (costCopper / 100) % 100;
uint32_t copper = costCopper % 100;
ImGui::PushID(static_cast<int>(nodeId));
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
bool isSelected = (selectedNodeId == nodeId);
if (ImGui::Selectable(node.name.c_str(), isSelected,
ImGuiSelectableFlags_SpanAllColumns |
ImGuiSelectableFlags_AllowDoubleClick)) {
2026-02-07 20:02:14 -08:00
selectedNodeId = nodeId;
LOG_INFO("Taxi UI: Selected dest=", nodeId);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
LOG_INFO("Taxi UI: Double-click activate dest=", nodeId);
gameHandler.activateTaxi(nodeId);
}
2026-02-07 20:02:14 -08:00
}
ImGui::TableSetColumnIndex(1);
if (gold > 0) {
ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper);
} else if (silver > 0) {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%us %uc", silver, copper);
} else {
ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.2f, 1.0f), "%uc", copper);
}
ImGui::TableSetColumnIndex(2);
if (ImGui::SmallButton("Fly")) {
selectedNodeId = nodeId;
LOG_INFO("Taxi UI: Fly clicked dest=", nodeId);
gameHandler.activateTaxi(nodeId);
}
ImGui::PopID();
destCount++;
}
2026-02-07 20:02:14 -08:00
ImGui::EndTable();
}
if (destCount == 0) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "No destinations available.");
}
ImGui::Spacing();
ImGui::Separator();
if (selectedNodeId != 0 && ImGui::Button("Fly Selected", ImVec2(-1, 0))) {
LOG_INFO("Taxi UI: Fly Selected dest=", selectedNodeId);
gameHandler.activateTaxi(selectedNodeId);
}
if (ImGui::Button("Close", ImVec2(-1, 0))) {
gameHandler.closeTaxi();
}
}
ImGui::End();
if (!open) {
gameHandler.closeTaxi();
}
}
// ============================================================
// Death Screen
// ============================================================
void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) {
if (!gameHandler.showDeathDialog()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Dark red overlay covering the whole screen
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(screenW, screenH));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.0f, 0.0f, 0.45f));
ImGui::Begin("##DeathOverlay", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing);
ImGui::End();
ImGui::PopStyleColor();
// "Release Spirit" dialog centered on screen
float dlgW = 280.0f;
float dlgH = 100.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.0f, 0.0f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
if (ImGui::Begin("##DeathDialog", nullptr,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Spacing();
// Center "You are dead." text
const char* deathText = "You are dead.";
float textW = ImGui::CalcTextSize(deathText).x;
ImGui::SetCursorPosX((dlgW - textW) / 2);
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText);
ImGui::Spacing();
ImGui::Spacing();
// Center the Release Spirit button
float btnW = 180.0f;
ImGui::SetCursorPosX((dlgW - btnW) / 2);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f));
if (ImGui::Button("Release Spirit", ImVec2(btnW, 30))) {
gameHandler.releaseSpirit();
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) {
if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float btnW = 220.0f, btnH = 36.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f));
if (ImGui::Begin("##ReclaimCorpse", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus)) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f));
if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) {
gameHandler.reclaimCorpse();
}
ImGui::PopStyleColor(2);
float corpDist = gameHandler.getCorpseDistance();
if (corpDist >= 0.0f) {
char distBuf[48];
snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist);
float dw = ImGui::CalcTextSize(distBuf).x;
ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f);
ImGui::TextDisabled("%s", distBuf);
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
}
void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) {
if (!gameHandler.showResurrectDialog()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float dlgW = 300.0f;
float dlgH = 110.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.8f, 1.0f));
if (ImGui::Begin("##ResurrectDialog", nullptr,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Spacing();
const std::string& casterName = gameHandler.getResurrectCasterName();
std::string text = casterName.empty()
? "Return to life?"
: casterName + " wishes to resurrect you.";
float textW = ImGui::CalcTextSize(text.c_str()).x;
ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2));
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str());
ImGui::Spacing();
ImGui::Spacing();
float btnW = 100.0f;
float spacing = 20.0f;
ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f));
if (ImGui::Button("Accept", ImVec2(btnW, 30))) {
gameHandler.acceptResurrect();
}
ImGui::PopStyleColor(2);
ImGui::SameLine(0, spacing);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f));
if (ImGui::Button("Decline", ImVec2(btnW, 30))) {
gameHandler.declineResurrect();
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
// ============================================================
// Talent Wipe Confirm Dialog
// ============================================================
void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) {
if (!gameHandler.showTalentWipeConfirmDialog()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float dlgW = 340.0f;
float dlgH = 130.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f));
if (ImGui::Begin("##TalentWipeDialog", nullptr,
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Spacing();
uint32_t cost = gameHandler.getTalentWipeCost();
uint32_t gold = cost / 10000;
uint32_t silver = (cost % 10000) / 100;
uint32_t copper = cost % 100;
char costStr[64];
if (gold > 0)
std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper);
else if (silver > 0)
std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper);
else
std::snprintf(costStr, sizeof(costStr), "%uc", copper);
std::string text = "Reset your talents for ";
text += costStr;
text += "?";
float textW = ImGui::CalcTextSize(text.c_str()).x;
ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2));
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str());
ImGui::Spacing();
ImGui::SetCursorPosX(8.0f);
ImGui::TextDisabled("All talent points will be refunded.");
ImGui::Spacing();
float btnW = 110.0f;
float spacing = 20.0f;
ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f));
if (ImGui::Button("Confirm", ImVec2(btnW, 30))) {
gameHandler.confirmTalentWipe();
}
ImGui::PopStyleColor(2);
ImGui::SameLine(0, spacing);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f));
if (ImGui::Button("Cancel", ImVec2(btnW, 30))) {
gameHandler.cancelTalentWipe();
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
// ============================================================
// Settings Window
// ============================================================
void GameScreen::renderSettingsWindow() {
if (!showSettingsWindow) return;
auto* window = core::Application::getInstance().getWindow();
2026-02-05 16:14:11 -08:00
auto* renderer = core::Application::getInstance().getRenderer();
if (!window) return;
static const int kResolutions[][2] = {
{1280, 720},
{1600, 900},
{1920, 1080},
{2560, 1440},
{3840, 2160},
};
static const int kResCount = sizeof(kResolutions) / sizeof(kResolutions[0]);
constexpr int kDefaultResW = 1920;
constexpr int kDefaultResH = 1080;
constexpr bool kDefaultFullscreen = false;
constexpr bool kDefaultVsync = true;
constexpr bool kDefaultShadows = true;
constexpr int kDefaultMusicVolume = 30;
constexpr float kDefaultMouseSensitivity = 0.2f;
constexpr bool kDefaultInvertMouse = false;
constexpr int kDefaultGroundClutterDensity = 100;
int defaultResIndex = 0;
for (int i = 0; i < kResCount; i++) {
if (kResolutions[i][0] == kDefaultResW && kResolutions[i][1] == kDefaultResH) {
defaultResIndex = i;
break;
}
}
if (!settingsInit) {
pendingFullscreen = window->isFullscreen();
pendingVsync = window->isVsyncEnabled();
2026-02-05 17:32:21 -08:00
if (renderer) {
renderer->setShadowsEnabled(pendingShadows);
renderer->setShadowDistance(pendingShadowDistance);
// Read non-volume settings from actual state (volumes come from saved settings)
if (auto* cameraController = renderer->getCameraController()) {
pendingMouseSensitivity = cameraController->getMouseSensitivity();
pendingInvertMouse = cameraController->isInvertMouse();
cameraController->setExtendedZoom(pendingExtendedZoom);
}
2026-02-05 17:32:21 -08:00
}
pendingResIndex = 0;
int curW = window->getWidth();
int curH = window->getHeight();
for (int i = 0; i < kResCount; i++) {
if (kResolutions[i][0] == curW && kResolutions[i][1] == curH) {
pendingResIndex = i;
break;
}
}
pendingUiOpacity = static_cast<int>(std::lround(uiOpacity_ * 100.0f));
pendingMinimapRotate = minimapRotate_;
pendingMinimapSquare = minimapSquare_;
pendingMinimapNpcDots = minimapNpcDots_;
pendingShowLatencyMeter = showLatencyMeter_;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setRotateWithCamera(minimapRotate_);
minimap->setSquareShape(minimapSquare_);
}
if (auto* zm = renderer->getZoneManager()) {
pendingUseOriginalSoundtrack = zm->getUseOriginalSoundtrack();
}
}
settingsInit = true;
}
ImGuiIO& io = ImGui::GetIO();
float screenW = io.DisplaySize.x;
float screenH = io.DisplaySize.y;
ImVec2 size(520.0f, std::min(screenH * 0.9f, 720.0f));
ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f);
ImGui::SetNextWindowPos(pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar;
if (ImGui::Begin("##SettingsWindow", nullptr, flags)) {
ImGui::Text("Settings");
ImGui::Separator();
if (ImGui::BeginTabBar("SettingsTabs", ImGuiTabBarFlags_None)) {
// ============================================================
// VIDEO TAB
// ============================================================
if (ImGui::BeginTabItem("Video")) {
ImGui::Spacing();
// Graphics Quality Presets
{
const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" };
int presetIdx = static_cast<int>(pendingGraphicsPreset);
if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) {
pendingGraphicsPreset = static_cast<GraphicsPreset>(presetIdx);
if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) {
applyGraphicsPreset(pendingGraphicsPreset);
saveSettings();
}
}
ImGui::TextDisabled("Adjust these for custom settings");
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) {
window->setFullscreen(pendingFullscreen);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (ImGui::Checkbox("VSync", &pendingVsync)) {
window->setVsync(pendingVsync);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (ImGui::Checkbox("Shadows", &pendingShadows)) {
if (renderer) renderer->setShadowsEnabled(pendingShadows);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (pendingShadows) {
ImGui::SameLine();
ImGui::SetNextItemWidth(150.0f);
if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) {
if (renderer) renderer->setShadowDistance(pendingShadowDistance);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
}
{
bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled());
if (!fsrActive && pendingWaterRefraction) {
// FSR was disabled while refraction was on — auto-disable
pendingWaterRefraction = false;
if (renderer) renderer->setWaterRefractionEnabled(false);
}
if (!fsrActive) ImGui::BeginDisabled();
if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) {
if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction);
saveSettings();
}
if (!fsrActive) ImGui::EndDisabled();
}
{
const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" };
bool fsr2Active = renderer && renderer->isFSR2Enabled();
if (fsr2Active) {
ImGui::BeginDisabled();
int disabled = 0;
ImGui::Combo("Anti-Aliasing (FSR3)", &disabled, "Off (FSR3 active)\0", 1);
ImGui::EndDisabled();
} else if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) {
static const VkSampleCountFlagBits aaSamples[] = {
VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT,
VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT
};
if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
}
// FSR Upscaling
{
// FSR mode selection: Off, FSR 1.0 (Spatial), FSR 3.x (Temporal)
const char* fsrModeLabels[] = { "Off", "FSR 1.0 (Spatial)", "FSR 3.x (Temporal)" };
int fsrMode = pendingUpscalingMode;
if (ImGui::Combo("Upscaling", &fsrMode, fsrModeLabels, 3)) {
pendingUpscalingMode = fsrMode;
pendingFSR = (fsrMode == 1);
if (renderer) {
renderer->setFSREnabled(fsrMode == 1);
renderer->setFSR2Enabled(fsrMode == 2);
}
saveSettings();
}
if (fsrMode > 0) {
if (fsrMode == 2 && renderer) {
ImGui::TextDisabled("FSR3 backend: %s",
renderer->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback");
if (renderer->isAmdFsr3FramegenSdkAvailable()) {
if (ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &pendingAMDFramegen)) {
renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen);
saveSettings();
}
const char* runtimeStatus = "Unavailable";
if (renderer->isAmdFsr3FramegenRuntimeActive()) {
runtimeStatus = "Active";
} else if (renderer->isAmdFsr3FramegenRuntimeReady()) {
runtimeStatus = "Ready";
} else {
runtimeStatus = "Unavailable";
}
ImGui::TextDisabled("Runtime: %s (%s)",
runtimeStatus, renderer->getAmdFsr3FramegenRuntimePath());
if (!renderer->isAmdFsr3FramegenRuntimeReady()) {
const std::string& runtimeErr = renderer->getAmdFsr3FramegenRuntimeError();
if (!runtimeErr.empty()) {
ImGui::TextDisabled("Reason: %s", runtimeErr.c_str());
}
}
} else {
ImGui::BeginDisabled();
bool disabledFg = false;
ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &disabledFg);
ImGui::EndDisabled();
ImGui::TextDisabled("Requires FidelityFX-SDK framegen headers.");
}
}
const char* fsrQualityLabels[] = { "Native (100%)", "Ultra Quality (77%)", "Quality (67%)", "Balanced (59%)" };
static const float fsrScaleFactors[] = { 0.77f, 0.67f, 0.59f, 1.00f };
static const int displayToInternal[] = { 3, 0, 1, 2 };
pendingFSRQuality = std::clamp(pendingFSRQuality, 0, 3);
int fsrQualityDisplay = 0;
for (int i = 0; i < 4; ++i) {
if (displayToInternal[i] == pendingFSRQuality) {
fsrQualityDisplay = i;
break;
}
}
if (ImGui::Combo("FSR Quality", &fsrQualityDisplay, fsrQualityLabels, 4)) {
pendingFSRQuality = displayToInternal[fsrQualityDisplay];
if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]);
saveSettings();
}
if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) {
if (renderer) renderer->setFSRSharpness(pendingFSRSharpness);
saveSettings();
}
if (fsrMode == 2) {
ImGui::SeparatorText("FSR3 Tuning");
if (ImGui::SliderFloat("Jitter Sign", &pendingFSR2JitterSign, -2.0f, 2.0f, "%.2f")) {
if (renderer) {
renderer->setFSR2DebugTuning(
pendingFSR2JitterSign,
pendingFSR2MotionVecScaleX,
pendingFSR2MotionVecScaleY);
}
saveSettings();
}
ImGui::TextDisabled("Tip: 0.38 is the current recommended default.");
}
}
}
if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) {
if (renderer) {
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(static_cast<float>(pendingGroundClutterDensity) / 100.0f);
}
}
saveSettings();
}
if (ImGui::Checkbox("Normal Mapping", &pendingNormalMapping)) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
}
}
saveSettings();
}
if (pendingNormalMapping) {
if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMapStrength(pendingNormalMapStrength);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMapStrength(pendingNormalMapStrength);
}
}
saveSettings();
}
}
if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setPOMEnabled(pendingPOM);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setPOMEnabled(pendingPOM);
}
}
saveSettings();
}
if (pendingPOM) {
const char* pomLabels[] = { "Low", "Medium", "High" };
if (ImGui::Combo("Parallax Quality", &pendingPOMQuality, pomLabels, 3)) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setPOMQuality(pendingPOMQuality);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setPOMQuality(pendingPOMQuality);
}
}
saveSettings();
}
}
const char* resLabel = "Resolution";
const char* resItems[kResCount];
char resBuf[kResCount][16];
for (int i = 0; i < kResCount; i++) {
snprintf(resBuf[i], sizeof(resBuf[i]), "%dx%d", kResolutions[i][0], kResolutions[i][1]);
resItems[i] = resBuf[i];
}
if (ImGui::Combo(resLabel, &pendingResIndex, resItems, kResCount)) {
window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]);
saveSettings();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button("Restore Video Defaults", ImVec2(-1, 0))) {
pendingFullscreen = kDefaultFullscreen;
pendingVsync = kDefaultVsync;
pendingShadows = kDefaultShadows;
pendingShadowDistance = 300.0f;
pendingGroundClutterDensity = kDefaultGroundClutterDensity;
pendingAntiAliasing = 0;
pendingNormalMapping = true;
2026-02-23 01:21:58 -08:00
pendingNormalMapStrength = 0.8f;
2026-02-23 01:23:24 -08:00
pendingPOM = true;
pendingPOMQuality = 1;
pendingResIndex = defaultResIndex;
window->setFullscreen(pendingFullscreen);
window->setVsync(pendingVsync);
window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]);
pendingWaterRefraction = false;
if (renderer) {
renderer->setShadowsEnabled(pendingShadows);
renderer->setShadowDistance(pendingShadowDistance);
}
if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction);
if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT);
if (renderer) {
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(static_cast<float>(pendingGroundClutterDensity) / 100.0f);
}
}
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping);
wr->setNormalMapStrength(pendingNormalMapStrength);
wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
cr->setNormalMapStrength(pendingNormalMapStrength);
cr->setPOMEnabled(pendingPOM);
cr->setPOMQuality(pendingPOMQuality);
}
}
saveSettings();
}
ImGui::EndTabItem();
}
// ============================================================
// INTERFACE TAB
// ============================================================
if (ImGui::BeginTabItem("Interface")) {
ImGui::Spacing();
ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true);
ImGui::SeparatorText("Action Bars");
ImGui::Spacing();
ImGui::SetNextItemWidth(200.0f);
if (ImGui::SliderFloat("Action Bar Scale", &pendingActionBarScale, 0.5f, 1.5f, "%.2fx")) {
saveSettings();
}
ImGui::Spacing();
if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) {
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(Shift+1 through Shift+=)");
if (pendingShowActionBar2) {
ImGui::Spacing();
ImGui::TextUnformatted("Second Bar Position Offset");
ImGui::SetNextItemWidth(160.0f);
if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) {
saveSettings();
}
ImGui::SetNextItemWidth(160.0f);
if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) {
saveSettings();
}
if (ImGui::Button("Reset Position##bar2")) {
pendingActionBar2OffsetX = 0.0f;
pendingActionBar2OffsetY = 0.0f;
saveSettings();
}
}
ImGui::Spacing();
if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) {
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(Slots 25-36)");
if (pendingShowRightBar) {
ImGui::SetNextItemWidth(160.0f);
if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) {
saveSettings();
}
}
ImGui::Spacing();
if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) {
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(Slots 37-48)");
if (pendingShowLeftBar) {
ImGui::SetNextItemWidth(160.0f);
if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) {
saveSettings();
}
}
ImGui::Spacing();
ImGui::SeparatorText("Nameplates");
ImGui::Spacing();
ImGui::SetNextItemWidth(200.0f);
if (ImGui::SliderFloat("Nameplate Scale", &nameplateScale_, 0.5f, 2.0f, "%.2fx")) {
saveSettings();
}
ImGui::Spacing();
ImGui::SeparatorText("Network");
ImGui::Spacing();
if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) {
showLatencyMeter_ = pendingShowLatencyMeter;
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(ms indicator near minimap)");
if (ImGui::Checkbox("Show DPS/HPS Meter", &showDPSMeter_)) {
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(damage/healing per second above action bar)");
ImGui::Spacing();
ImGui::SeparatorText("Screen Effects");
ImGui::Spacing();
if (ImGui::Checkbox("Damage Flash", &damageFlashEnabled_)) {
if (!damageFlashEnabled_) damageFlashAlpha_ = 0.0f;
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(red vignette on taking damage)");
ImGui::EndChild();
ImGui::EndTabItem();
}
// ============================================================
// AUDIO TAB
// ============================================================
if (ImGui::BeginTabItem("Audio")) {
ImGui::Spacing();
ImGui::BeginChild("AudioSettings", ImVec2(0, 360), true);
// Helper lambda to apply audio settings
auto applyAudioSettings = [&]() {
if (!renderer) return;
float masterScale = soundMuted_ ? 0.0f : static_cast<float>(pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(masterScale);
if (auto* music = renderer->getMusicManager()) {
music->setVolume(pendingMusicVolume);
}
if (auto* ambient = renderer->getAmbientSoundManager()) {
ambient->setVolumeScale(pendingAmbientVolume / 100.0f);
}
if (auto* ui = renderer->getUiSoundManager()) {
ui->setVolumeScale(pendingUiVolume / 100.0f);
}
if (auto* combat = renderer->getCombatSoundManager()) {
combat->setVolumeScale(pendingCombatVolume / 100.0f);
}
if (auto* spell = renderer->getSpellSoundManager()) {
spell->setVolumeScale(pendingSpellVolume / 100.0f);
}
if (auto* movement = renderer->getMovementSoundManager()) {
movement->setVolumeScale(pendingMovementVolume / 100.0f);
}
if (auto* footstep = renderer->getFootstepManager()) {
footstep->setVolumeScale(pendingFootstepVolume / 100.0f);
}
if (auto* npcVoice = renderer->getNpcVoiceManager()) {
npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f);
}
if (auto* mount = renderer->getMountSoundManager()) {
mount->setVolumeScale(pendingMountVolume / 100.0f);
}
if (auto* activity = renderer->getActivitySoundManager()) {
activity->setVolumeScale(pendingActivityVolume / 100.0f);
}
saveSettings();
};
ImGui::Text("Master Volume");
if (ImGui::SliderInt("##MasterVolume", &pendingMasterVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::Separator();
if (ImGui::Checkbox("Enable WoWee Music", &pendingUseOriginalSoundtrack)) {
if (renderer) {
if (auto* zm = renderer->getZoneManager()) {
zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack);
}
}
saveSettings();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Include WoWee music tracks in zone music rotation");
ImGui::Separator();
ImGui::Text("Music");
if (ImGui::SliderInt("##MusicVolume", &pendingMusicVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::Spacing();
ImGui::Text("Ambient Sounds");
if (ImGui::SliderInt("##AmbientVolume", &pendingAmbientVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Weather, zones, cities, emitters");
2026-02-05 17:32:21 -08:00
ImGui::Spacing();
ImGui::Text("UI Sounds");
if (ImGui::SliderInt("##UiVolume", &pendingUiVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Buttons, loot, quest complete");
ImGui::Spacing();
ImGui::Text("Combat Sounds");
if (ImGui::SliderInt("##CombatVolume", &pendingCombatVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Weapon swings, impacts, grunts");
ImGui::Spacing();
ImGui::Text("Spell Sounds");
if (ImGui::SliderInt("##SpellVolume", &pendingSpellVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Magic casting and impacts");
ImGui::Spacing();
ImGui::Text("Movement Sounds");
if (ImGui::SliderInt("##MovementVolume", &pendingMovementVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Water splashes, jump/land");
2026-02-05 17:32:21 -08:00
ImGui::Spacing();
ImGui::Text("Footsteps");
if (ImGui::SliderInt("##FootstepVolume", &pendingFootstepVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::Spacing();
ImGui::Text("NPC Voices");
if (ImGui::SliderInt("##NpcVoiceVolume", &pendingNpcVoiceVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::Spacing();
ImGui::Text("Mount Sounds");
if (ImGui::SliderInt("##MountVolume", &pendingMountVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::Spacing();
ImGui::Text("Activity Sounds");
if (ImGui::SliderInt("##ActivityVolume", &pendingActivityVolume, 0, 100, "%d%%")) {
applyAudioSettings();
}
ImGui::TextWrapped("Swimming, eating, drinking");
ImGui::EndChild();
if (ImGui::Button("Restore Audio Defaults", ImVec2(-1, 0))) {
pendingMasterVolume = 100;
pendingMusicVolume = kDefaultMusicVolume;
pendingAmbientVolume = 100;
pendingUiVolume = 100;
pendingCombatVolume = 100;
pendingSpellVolume = 100;
pendingMovementVolume = 100;
pendingFootstepVolume = 100;
pendingNpcVoiceVolume = 100;
pendingMountVolume = 100;
pendingActivityVolume = 100;
applyAudioSettings();
}
ImGui::EndTabItem();
}
// ============================================================
// GAMEPLAY TAB
// ============================================================
if (ImGui::BeginTabItem("Gameplay")) {
ImGui::Spacing();
ImGui::Text("Controls");
ImGui::Separator();
if (ImGui::SliderFloat("Mouse Sensitivity", &pendingMouseSensitivity, 0.05f, 1.0f, "%.2f")) {
if (renderer) {
if (auto* cameraController = renderer->getCameraController()) {
cameraController->setMouseSensitivity(pendingMouseSensitivity);
}
}
saveSettings();
}
if (ImGui::Checkbox("Invert Mouse", &pendingInvertMouse)) {
if (renderer) {
if (auto* cameraController = renderer->getCameraController()) {
cameraController->setInvertMouse(pendingInvertMouse);
}
}
saveSettings();
}
if (ImGui::Checkbox("Extended Camera Zoom", &pendingExtendedZoom)) {
if (renderer) {
if (auto* cameraController = renderer->getCameraController()) {
cameraController->setExtendedZoom(pendingExtendedZoom);
}
}
saveSettings();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Allow the camera to zoom out further than normal");
if (ImGui::SliderFloat("Field of View", &pendingFov, 45.0f, 110.0f, "%.0f°")) {
if (renderer) {
if (auto* camera = renderer->getCamera()) {
camera->setFov(pendingFov);
}
}
saveSettings();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Camera field of view in degrees (default: 70)");
ImGui::Spacing();
ImGui::Spacing();
ImGui::Text("Interface");
ImGui::Separator();
if (ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%")) {
uiOpacity_ = static_cast<float>(pendingUiOpacity) / 100.0f;
saveSettings();
}
if (ImGui::Checkbox("Rotate Minimap", &pendingMinimapRotate)) {
// Force north-up minimap.
minimapRotate_ = false;
pendingMinimapRotate = false;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setRotateWithCamera(false);
}
}
saveSettings();
}
if (ImGui::Checkbox("Square Minimap", &pendingMinimapSquare)) {
minimapSquare_ = pendingMinimapSquare;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setSquareShape(minimapSquare_);
}
}
saveSettings();
}
if (ImGui::Checkbox("Show Nearby NPC Dots", &pendingMinimapNpcDots)) {
minimapNpcDots_ = pendingMinimapNpcDots;
saveSettings();
}
// Zoom controls
ImGui::Text("Minimap Zoom:");
ImGui::SameLine();
if (ImGui::Button(" - ")) {
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->zoomOut();
saveSettings();
}
}
}
ImGui::SameLine();
if (ImGui::Button(" + ")) {
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->zoomIn();
saveSettings();
}
}
}
ImGui::Spacing();
ImGui::Text("Loot");
ImGui::Separator();
if (ImGui::Checkbox("Auto Loot", &pendingAutoLoot)) {
saveSettings(); // per-frame sync applies pendingAutoLoot to gameHandler
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Automatically pick up all items when looting");
ImGui::Spacing();
ImGui::Text("Bags");
ImGui::Separator();
if (ImGui::Checkbox("Separate Bag Windows", &pendingSeparateBags)) {
inventoryScreen.setSeparateBags(pendingSeparateBags);
saveSettings();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button("Restore Gameplay Defaults", ImVec2(-1, 0))) {
pendingMouseSensitivity = kDefaultMouseSensitivity;
pendingInvertMouse = kDefaultInvertMouse;
pendingExtendedZoom = false;
pendingUiOpacity = 65;
pendingMinimapRotate = false;
pendingMinimapSquare = false;
pendingMinimapNpcDots = false;
pendingSeparateBags = true;
inventoryScreen.setSeparateBags(true);
uiOpacity_ = 0.65f;
minimapRotate_ = false;
minimapSquare_ = false;
minimapNpcDots_ = false;
if (renderer) {
if (auto* cameraController = renderer->getCameraController()) {
cameraController->setMouseSensitivity(pendingMouseSensitivity);
cameraController->setInvertMouse(pendingInvertMouse);
cameraController->setExtendedZoom(pendingExtendedZoom);
}
if (auto* minimap = renderer->getMinimap()) {
minimap->setRotateWithCamera(minimapRotate_);
minimap->setSquareShape(minimapSquare_);
}
}
saveSettings();
}
ImGui::EndTabItem();
}
// ============================================================
// CONTROLS TAB
// ============================================================
if (ImGui::BeginTabItem("Controls")) {
ImGui::Spacing();
ImGui::Text("Keybindings");
ImGui::Separator();
auto& km = ui::KeybindingManager::getInstance();
int numActions = km.getActionCount();
for (int i = 0; i < numActions; ++i) {
auto action = static_cast<ui::KeybindingManager::Action>(i);
const char* actionName = km.getActionName(action);
ImGuiKey currentKey = km.getKeyForAction(action);
// Display current binding
ImGui::Text("%s:", actionName);
ImGui::SameLine(200);
// Get human-readable key name (basic implementation)
const char* keyName = "Unknown";
if (currentKey >= ImGuiKey_A && currentKey <= ImGuiKey_Z) {
static char keyBuf[16];
snprintf(keyBuf, sizeof(keyBuf), "%c", 'A' + (currentKey - ImGuiKey_A));
keyName = keyBuf;
} else if (currentKey >= ImGuiKey_0 && currentKey <= ImGuiKey_9) {
static char keyBuf[16];
snprintf(keyBuf, sizeof(keyBuf), "%c", '0' + (currentKey - ImGuiKey_0));
keyName = keyBuf;
} else if (currentKey == ImGuiKey_Escape) {
keyName = "Escape";
} else if (currentKey == ImGuiKey_Enter) {
keyName = "Enter";
} else if (currentKey == ImGuiKey_Tab) {
keyName = "Tab";
} else if (currentKey == ImGuiKey_Space) {
keyName = "Space";
} else if (currentKey >= ImGuiKey_F1 && currentKey <= ImGuiKey_F12) {
static char keyBuf[16];
snprintf(keyBuf, sizeof(keyBuf), "F%d", 1 + (currentKey - ImGuiKey_F1));
keyName = keyBuf;
}
ImGui::Text("[%s]", keyName);
// Rebind button
ImGui::SameLine(350);
if (ImGui::Button(awaitingKeyPress && pendingRebindAction == i ? "Waiting..." : "Rebind", ImVec2(100, 0))) {
pendingRebindAction = i;
awaitingKeyPress = true;
}
}
// Handle key press during rebinding
if (awaitingKeyPress && pendingRebindAction >= 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::Text("Press any key to bind to this action (Esc to cancel)...");
// Check for any key press
bool foundKey = false;
ImGuiKey newKey = ImGuiKey_None;
for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) {
if (ImGui::IsKeyPressed(static_cast<ImGuiKey>(k), false)) {
if (k == ImGuiKey_Escape) {
// Cancel rebinding
awaitingKeyPress = false;
pendingRebindAction = -1;
foundKey = true;
break;
}
newKey = static_cast<ImGuiKey>(k);
foundKey = true;
break;
}
}
if (foundKey && newKey != ImGuiKey_None) {
auto action = static_cast<ui::KeybindingManager::Action>(pendingRebindAction);
km.setKeyForAction(action, newKey);
awaitingKeyPress = false;
pendingRebindAction = -1;
saveSettings();
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button("Reset to Defaults", ImVec2(-1, 0))) {
km.resetToDefaults();
awaitingKeyPress = false;
pendingRebindAction = -1;
saveSettings();
}
ImGui::EndTabItem();
}
// ============================================================
// CHAT TAB
// ============================================================
if (ImGui::BeginTabItem("Chat")) {
ImGui::Spacing();
ImGui::Text("Appearance");
ImGui::Separator();
if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps_)) {
saveSettings();
}
ImGui::SetItemTooltip("Show [HH:MM] before each chat message");
const char* fontSizes[] = { "Small", "Medium", "Large" };
if (ImGui::Combo("Chat Font Size", &chatFontSize_, fontSizes, 3)) {
saveSettings();
}
ImGui::Spacing();
ImGui::Spacing();
ImGui::Text("Auto-Join Channels");
ImGui::Separator();
if (ImGui::Checkbox("General", &chatAutoJoinGeneral_)) saveSettings();
if (ImGui::Checkbox("Trade", &chatAutoJoinTrade_)) saveSettings();
if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense_)) saveSettings();
if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG_)) saveSettings();
if (ImGui::Checkbox("Local", &chatAutoJoinLocal_)) saveSettings();
ImGui::Spacing();
ImGui::Spacing();
ImGui::Text("Joined Channels");
ImGui::Separator();
ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels.");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) {
chatShowTimestamps_ = false;
chatFontSize_ = 1;
chatAutoJoinGeneral_ = true;
chatAutoJoinTrade_ = true;
chatAutoJoinLocalDefense_ = true;
chatAutoJoinLFG_ = true;
chatAutoJoinLocal_ = true;
saveSettings();
}
ImGui::EndTabItem();
}
// ============================================================
// ABOUT TAB
// ============================================================
if (ImGui::BeginTabItem("About")) {
ImGui::Spacing();
ImGui::Spacing();
ImGui::TextWrapped("WoWee - World of Warcraft Client Emulator");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::Text("Developer");
ImGui::Indent();
ImGui::Text("Kelsi Davis");
ImGui::Unindent();
ImGui::Spacing();
ImGui::Text("GitHub");
ImGui::Indent();
ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "https://github.com/Kelsidavis/WoWee");
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("Click to copy");
}
if (ImGui::IsItemClicked()) {
ImGui::SetClipboardText("https://github.com/Kelsidavis/WoWee");
}
ImGui::Unindent();
ImGui::Spacing();
ImGui::Text("Contact");
ImGui::Indent();
ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "github.com/Kelsidavis");
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip("Click to copy");
}
if (ImGui::IsItemClicked()) {
ImGui::SetClipboardText("https://github.com/Kelsidavis");
}
ImGui::Unindent();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::TextWrapped("A multi-expansion WoW client supporting Classic, TBC, and WotLK (3.3.5a).");
ImGui::Spacing();
ImGui::TextDisabled("Built with Vulkan, SDL2, and ImGui");
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::Spacing();
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f));
if (ImGui::Button("Back to Game", ImVec2(-1, 0))) {
showSettingsWindow = false;
}
ImGui::PopStyleVar();
}
ImGui::End();
}
void GameScreen::applyGraphicsPreset(GraphicsPreset preset) {
auto* renderer = core::Application::getInstance().getRenderer();
// Define preset values based on quality level
switch (preset) {
case GraphicsPreset::LOW: {
pendingShadows = false;
pendingShadowDistance = 100.0f;
pendingAntiAliasing = 0; // Off
pendingNormalMapping = false;
pendingPOM = false;
pendingGroundClutterDensity = 25;
if (renderer) {
renderer->setShadowsEnabled(false);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(false);
wr->setPOMEnabled(false);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(false);
cr->setPOMEnabled(false);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(0.25f);
}
}
break;
}
case GraphicsPreset::MEDIUM: {
pendingShadows = true;
pendingShadowDistance = 200.0f;
pendingAntiAliasing = 1; // 2x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 0.6f;
pendingPOM = true;
pendingPOMQuality = 0; // Low
pendingGroundClutterDensity = 60;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(200.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(0.6f);
wr->setPOMEnabled(true);
wr->setPOMQuality(0);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(0.6f);
cr->setPOMEnabled(true);
cr->setPOMQuality(0);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(0.60f);
}
}
break;
}
case GraphicsPreset::HIGH: {
pendingShadows = true;
pendingShadowDistance = 350.0f;
pendingAntiAliasing = 2; // 4x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 0.8f;
pendingPOM = true;
pendingPOMQuality = 1; // Medium
pendingGroundClutterDensity = 100;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(350.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(0.8f);
wr->setPOMEnabled(true);
wr->setPOMQuality(1);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(0.8f);
cr->setPOMEnabled(true);
cr->setPOMQuality(1);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(1.0f);
}
}
break;
}
case GraphicsPreset::ULTRA: {
pendingShadows = true;
pendingShadowDistance = 500.0f;
pendingAntiAliasing = 3; // 8x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 1.2f;
pendingPOM = true;
pendingPOMQuality = 2; // High
pendingGroundClutterDensity = 150;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(500.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(1.2f);
wr->setPOMEnabled(true);
wr->setPOMQuality(2);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(1.2f);
cr->setPOMEnabled(true);
cr->setPOMQuality(2);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(1.5f);
}
}
break;
}
default:
break;
}
currentGraphicsPreset = preset;
pendingGraphicsPreset = preset;
}
void GameScreen::updateGraphicsPresetFromCurrentSettings() {
// Check if current settings match any preset, otherwise mark as CUSTOM
// This is a simplified check; could be enhanced with more detailed matching
auto matchesPreset = [this](GraphicsPreset preset) -> bool {
switch (preset) {
case GraphicsPreset::LOW:
return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM &&
pendingGroundClutterDensity <= 30;
case GraphicsPreset::MEDIUM:
return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 &&
pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM &&
pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70;
case GraphicsPreset::HIGH:
return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 &&
pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM &&
pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110;
case GraphicsPreset::ULTRA:
return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 &&
pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140;
default:
return false;
}
};
// Try to match a preset, otherwise mark as custom
if (matchesPreset(GraphicsPreset::LOW)) {
pendingGraphicsPreset = GraphicsPreset::LOW;
} else if (matchesPreset(GraphicsPreset::MEDIUM)) {
pendingGraphicsPreset = GraphicsPreset::MEDIUM;
} else if (matchesPreset(GraphicsPreset::HIGH)) {
pendingGraphicsPreset = GraphicsPreset::HIGH;
} else if (matchesPreset(GraphicsPreset::ULTRA)) {
pendingGraphicsPreset = GraphicsPreset::ULTRA;
} else {
pendingGraphicsPreset = GraphicsPreset::CUSTOM;
}
}
void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) {
const auto& statuses = gameHandler.getNpcQuestStatuses();
if (statuses.empty()) return;
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (!camera || !window) return;
float screenW = static_cast<float>(window->getWidth());
float screenH = static_cast<float>(window->getHeight());
glm::mat4 viewProj = camera->getViewProjectionMatrix();
auto* drawList = ImGui::GetForegroundDrawList();
for (const auto& [guid, status] : statuses) {
// Only show markers for available (!) and reward/completable (?)
const char* marker = nullptr;
ImU32 color = IM_COL32(255, 210, 0, 255); // yellow
if (status == game::QuestGiverStatus::AVAILABLE) {
marker = "!";
} else if (status == game::QuestGiverStatus::AVAILABLE_LOW) {
marker = "!";
color = IM_COL32(160, 160, 160, 255); // gray
} else if (status == game::QuestGiverStatus::REWARD ||
status == game::QuestGiverStatus::REWARD_REP) {
marker = "?";
} else if (status == game::QuestGiverStatus::INCOMPLETE) {
marker = "?";
color = IM_COL32(160, 160, 160, 255); // gray
} else {
continue;
}
// Get entity position (canonical coords)
auto entity = gameHandler.getEntityManager().getEntity(guid);
if (!entity) continue;
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
// Get model height for offset
float heightOffset = 3.0f;
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
heightOffset = boundsRadius * 2.0f + 1.0f;
}
renderPos.z += heightOffset;
// Project to screen
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w <= 0.0f) continue;
glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w);
float sx = (ndc.x + 1.0f) * 0.5f * screenW;
float sy = (1.0f - ndc.y) * 0.5f * screenH;
// Skip if off-screen
if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue;
// Scale text size based on distance
float dist = clipPos.w;
float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f);
// Draw outlined text: 4 shadow copies then main text
ImFont* font = ImGui::GetFont();
ImU32 outlineColor = IM_COL32(0, 0, 0, 220);
float off = std::max(1.0f, fontSize * 0.06f);
ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker);
float tx = sx - textSize.x * 0.5f;
float ty = sy - textSize.y * 0.5f;
drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker);
drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker);
}
}
void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
const auto& statuses = gameHandler.getNpcQuestStatuses();
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
auto* minimap = renderer ? renderer->getMinimap() : nullptr;
auto* window = core::Application::getInstance().getWindow();
if (!camera || !minimap || !window) return;
float screenW = static_cast<float>(window->getWidth());
// Minimap parameters (matching minimap.cpp)
float mapSize = 200.0f;
float margin = 10.0f;
float mapRadius = mapSize * 0.5f;
float centerX = screenW - margin - mapRadius;
float centerY = margin + mapRadius;
float viewRadius = minimap->getViewRadius();
// Use the exact same minimap center as Renderer::renderWorld() to keep markers anchored.
glm::vec3 playerRender = camera->getPosition();
if (renderer->getCharacterInstanceId() != 0) {
playerRender = renderer->getCharacterPosition();
}
// Camera bearing for minimap rotation
float bearing = 0.0f;
float cosB = 1.0f;
float sinB = 0.0f;
if (minimap->isRotateWithCamera()) {
glm::vec3 fwd = camera->getForward();
bearing = std::atan2(-fwd.x, fwd.y);
cosB = std::cos(bearing);
sinB = std::sin(bearing);
}
auto* drawList = ImGui::GetForegroundDrawList();
auto projectToMinimap = [&](const glm::vec3& worldRenderPos, float& sx, float& sy) -> bool {
float dx = worldRenderPos.x - playerRender.x;
float dy = worldRenderPos.y - playerRender.y;
// Exact inverse of minimap display shader:
// shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2
// where rotated = R(bearing) * center, center in [-0.5, 0.5]
// Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2)
// With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south):
float rx = -(dx * cosB + dy * sinB);
float ry = dx * sinB - dy * cosB;
// Scale to minimap pixels
float px = rx / viewRadius * mapRadius;
float py = ry / viewRadius * mapRadius;
float distFromCenter = std::sqrt(px * px + py * py);
if (distFromCenter > mapRadius - 3.0f) {
return false;
}
sx = centerX + px;
sy = centerY + py;
return true;
};
// Player position marker — always drawn at minimap center with a directional arrow.
{
// The player is always at centerX, centerY on the minimap.
// Draw a yellow arrow pointing in the player's facing direction.
glm::vec3 fwd = camera->getForward();
float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north
float cosF = std::cos(facing - bearing);
float sinF = std::sin(facing - bearing);
float arrowLen = 8.0f;
float arrowW = 4.0f;
ImVec2 tip(centerX + sinF * arrowLen, centerY - cosF * arrowLen);
ImVec2 left(centerX - cosF * arrowW - sinF * arrowLen * 0.3f,
centerY - sinF * arrowW + cosF * arrowLen * 0.3f);
ImVec2 right(centerX + cosF * arrowW - sinF * arrowLen * 0.3f,
centerY + sinF * arrowW + cosF * arrowLen * 0.3f);
drawList->AddTriangleFilled(tip, left, right, IM_COL32(255, 220, 0, 255));
drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f);
// White dot at player center
drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220));
}
// Optional base nearby NPC dots (independent of quest status packets).
if (minimapNpcDots_) {
for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) {
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
auto unit = std::static_pointer_cast<game::Unit>(entity);
if (!unit || unit->getHealth() == 0) continue;
glm::vec3 npcRender = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(npcRender, sx, sy)) continue;
ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210);
drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot);
}
}
for (const auto& [guid, status] : statuses) {
ImU32 dotColor;
const char* marker = nullptr;
if (status == game::QuestGiverStatus::AVAILABLE) {
dotColor = IM_COL32(255, 210, 0, 255);
marker = "!";
} else if (status == game::QuestGiverStatus::AVAILABLE_LOW) {
dotColor = IM_COL32(160, 160, 160, 255);
marker = "!";
} else if (status == game::QuestGiverStatus::REWARD ||
status == game::QuestGiverStatus::REWARD_REP) {
dotColor = IM_COL32(255, 210, 0, 255);
marker = "?";
} else if (status == game::QuestGiverStatus::INCOMPLETE) {
dotColor = IM_COL32(160, 160, 160, 255);
marker = "?";
} else {
continue;
}
auto entity = gameHandler.getEntityManager().getEntity(guid);
if (!entity) continue;
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 npcRender = core::coords::canonicalToRender(canonical);
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(npcRender, sx, sy)) continue;
// Draw dot with marker text
drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, dotColor);
ImFont* font = ImGui::GetFont();
ImVec2 textSize = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, marker);
drawList->AddText(font, 11.0f,
ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f),
IM_COL32(0, 0, 0, 255), marker);
}
// Gossip POI markers (quest / NPC navigation targets)
for (const auto& poi : gameHandler.getGossipPois()) {
// Convert WoW canonical coords to render coords for minimap projection
glm::vec3 poiRender = core::coords::canonicalToRender(glm::vec3(poi.x, poi.y, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(poiRender, sx, sy)) continue;
// Draw as a cyan diamond with tooltip on hover
const float d = 5.0f;
ImVec2 pts[4] = {
{ sx, sy - d },
{ sx + d, sy },
{ sx, sy + d },
{ sx - d, sy },
};
drawList->AddConvexPolyFilled(pts, 4, IM_COL32(0, 210, 255, 220));
drawList->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 160), true, 1.0f);
// Show name label if cursor is within ~8px
ImVec2 cursorPos = ImGui::GetMousePos();
float dx = cursorPos.x - sx, dy = cursorPos.y - sy;
if (!poi.name.empty() && (dx * dx + dy * dy) < 64.0f) {
ImGui::SetTooltip("%s", poi.name.c_str());
}
}
// Minimap pings from party members
for (const auto& ping : gameHandler.getMinimapPings()) {
glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(pingRender, sx, sy)) continue;
float t = ping.age / game::GameHandler::MinimapPing::LIFETIME;
float alpha = 1.0f - t;
float pulse = 1.0f + 1.5f * t; // expands outward as it fades
ImU32 col = IM_COL32(255, 220, 0, static_cast<int>(alpha * 200));
ImU32 col2 = IM_COL32(255, 150, 0, static_cast<int>(alpha * 100));
float r1 = 4.0f * pulse;
float r2 = 8.0f * pulse;
drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f);
drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f);
drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col);
}
// Party member dots on minimap
{
const auto& partyData = gameHandler.getPartyData();
const uint64_t leaderGuid = partyData.leaderGuid;
for (const auto& member : partyData.members) {
if (!member.isOnline || !member.hasPartyStats) continue;
if (member.posX == 0 && member.posY == 0) continue;
// posX/posY follow same server axis convention as minimap pings:
// server posX = east/west axis → canonical Y (west)
// server posY = north/south axis → canonical X (north)
float wowX = static_cast<float>(member.posY);
float wowY = static_cast<float>(member.posX);
glm::vec3 memberRender = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(memberRender, sx, sy)) continue;
ImU32 dotColor = (member.guid == leaderGuid)
? IM_COL32(255, 210, 0, 235)
: IM_COL32(100, 180, 255, 235);
drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor);
drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f);
ImVec2 cursorPos = ImGui::GetMousePos();
float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy;
if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) {
ImGui::SetTooltip("%s", member.name.c_str());
}
}
}
// Corpse direction indicator — shown when player is a ghost
if (gameHandler.isPlayerGhost()) {
float corpseCanX = 0.0f, corpseCanY = 0.0f;
if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) {
glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f));
float csx = 0.0f, csy = 0.0f;
bool onMap = projectToMinimap(corpseRender, csx, csy);
if (onMap) {
// Draw a small skull-like X marker at the corpse position
const float r = 5.0f;
drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12);
drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f);
// Draw an X in the circle
drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f),
IM_COL32(180, 180, 220, 255), 1.5f);
drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f),
IM_COL32(180, 180, 220, 255), 1.5f);
// Tooltip on hover
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - csx, mdy = mouse.y - csy;
if (mdx * mdx + mdy * mdy < 64.0f) {
float dist = gameHandler.getCorpseDistance();
if (dist >= 0.0f)
ImGui::SetTooltip("Your corpse (%.0f yd)", dist);
else
ImGui::SetTooltip("Your corpse");
}
} else {
// Corpse is outside minimap — draw an edge arrow pointing toward it
float dx = corpseRender.x - playerRender.x;
float dy = corpseRender.y - playerRender.y;
// Rotate delta into minimap frame (same as projectToMinimap)
float rx = -(dx * cosB + dy * sinB);
float ry = dx * sinB - dy * cosB;
float len = std::sqrt(rx * rx + ry * ry);
if (len > 0.001f) {
float nx = rx / len;
float ny = ry / len;
// Place arrow at the minimap edge
float edgeR = mapRadius - 7.0f;
float ax = centerX + nx * edgeR;
float ay = centerY + ny * edgeR;
// Arrow pointing outward (toward corpse)
float arrowLen = 6.0f;
float arrowW = 3.5f;
ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen);
ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f,
ay + nx * arrowW - ny * arrowLen * 0.4f);
ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f,
ay - nx * arrowW - ny * arrowLen * 0.4f);
drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230));
drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f);
// Tooltip on hover
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - ax, mdy = mouse.y - ay;
if (mdx * mdx + mdy * mdy < 100.0f) {
float dist = gameHandler.getCorpseDistance();
if (dist >= 0.0f)
ImGui::SetTooltip("Your corpse (%.0f yd)", dist);
else
ImGui::SetTooltip("Your corpse");
}
}
}
}
}
2026-03-11 23:01:37 -07:00
// Scroll wheel over minimap → zoom in/out
{
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) {
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) {
if (wheel > 0.0f)
minimap->zoomIn();
else
minimap->zoomOut();
}
}
}
// Ctrl+click on minimap → send minimap ping to party
if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) {
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
float distSq = mdx * mdx + mdy * mdy;
if (distSq <= mapRadius * mapRadius) {
// Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius
float rx = mdx * viewRadius / mapRadius;
float ry = mdy * viewRadius / mapRadius;
// rx/ry are in rotated frame; unrotate to get world dx/dy
// rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB
// Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB)
float wdx = -(rx * cosB - ry * sinB);
float wdy = -(rx * sinB + ry * cosB);
// playerRender is in render coords; add delta to get render position then convert to canonical
glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f);
glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender);
gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y);
}
}
// Persistent coordinate display below the minimap
{
glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender);
char coordBuf[32];
std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y);
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf);
float tx = centerX - textSz.x * 0.5f;
float ty = centerY + mapRadius + 3.0f;
// Semi-transparent dark background pill
float pad = 3.0f;
drawList->AddRectFilled(
ImVec2(tx - pad, ty - pad),
ImVec2(tx + textSz.x + pad, ty + textSz.y + pad),
IM_COL32(0, 0, 0, 140), 4.0f);
// Coordinate text in warm yellow
drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf);
}
// Hover tooltip: show player's WoW coordinates (canonical X=North, Y=West)
{
ImVec2 mouse = ImGui::GetMousePos();
float mdx = mouse.x - centerX;
float mdy = mouse.y - centerY;
if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) {
ImGui::BeginTooltip();
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.5f, 1.0f), "Ctrl+click to ping");
ImGui::EndTooltip();
}
}
auto applyMuteState = [&]() {
auto* activeRenderer = core::Application::getInstance().getRenderer();
float masterScale = soundMuted_ ? 0.0f : static_cast<float>(pendingMasterVolume) / 100.0f;
audio::AudioEngine::instance().setMasterVolume(masterScale);
if (!activeRenderer) return;
if (auto* music = activeRenderer->getMusicManager()) {
music->setVolume(pendingMusicVolume);
}
if (auto* ambient = activeRenderer->getAmbientSoundManager()) {
ambient->setVolumeScale(pendingAmbientVolume / 100.0f);
}
if (auto* ui = activeRenderer->getUiSoundManager()) {
ui->setVolumeScale(pendingUiVolume / 100.0f);
}
if (auto* combat = activeRenderer->getCombatSoundManager()) {
combat->setVolumeScale(pendingCombatVolume / 100.0f);
}
if (auto* spell = activeRenderer->getSpellSoundManager()) {
spell->setVolumeScale(pendingSpellVolume / 100.0f);
}
if (auto* movement = activeRenderer->getMovementSoundManager()) {
movement->setVolumeScale(pendingMovementVolume / 100.0f);
}
if (auto* footstep = activeRenderer->getFootstepManager()) {
footstep->setVolumeScale(pendingFootstepVolume / 100.0f);
}
if (auto* npcVoice = activeRenderer->getNpcVoiceManager()) {
npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f);
}
if (auto* mount = activeRenderer->getMountSoundManager()) {
mount->setVolumeScale(pendingMountVolume / 100.0f);
}
if (auto* activity = activeRenderer->getActivitySoundManager()) {
activity->setVolumeScale(pendingActivityVolume / 100.0f);
}
};
// Zone name label above the minimap (centered, WoW-style)
{
const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{};
if (!zoneName.empty()) {
auto* fgDl = ImGui::GetForegroundDrawList();
float zoneTextY = centerY - mapRadius - 16.0f;
ImFont* font = ImGui::GetFont();
ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str());
float tzx = centerX - tsz.x * 0.5f;
fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f),
IM_COL32(0, 0, 0, 180), zoneName.c_str());
fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY),
IM_COL32(255, 220, 120, 230), zoneName.c_str());
}
}
// Speaker mute button at the minimap top-right corner
ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - 26.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always);
ImGuiWindowFlags muteFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapMute", nullptr, muteFlags)) {
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 p = ImGui::GetCursorScreenPos();
ImVec2 size(20.0f, 20.0f);
if (ImGui::InvisibleButton("##MinimapMuteButton", size)) {
soundMuted_ = !soundMuted_;
if (soundMuted_) {
preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume();
}
applyMuteState();
saveSettings();
}
bool hovered = ImGui::IsItemHovered();
ImU32 bg = soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210);
if (hovered) bg = soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220);
ImU32 fg = IM_COL32(255, 255, 255, 245);
draw->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), bg, 4.0f);
draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), ImVec2(p.x + size.x - 0.5f, p.y + size.y - 0.5f),
IM_COL32(255, 255, 255, 42), 4.0f);
draw->AddRectFilled(ImVec2(p.x + 4.0f, p.y + 8.0f), ImVec2(p.x + 7.0f, p.y + 12.0f), fg, 1.0f);
draw->AddTriangleFilled(ImVec2(p.x + 7.0f, p.y + 7.0f),
ImVec2(p.x + 7.0f, p.y + 13.0f),
ImVec2(p.x + 11.8f, p.y + 10.0f), fg);
if (soundMuted_) {
draw->AddLine(ImVec2(p.x + 13.5f, p.y + 6.2f), ImVec2(p.x + 17.2f, p.y + 13.8f), fg, 1.8f);
draw->AddLine(ImVec2(p.x + 17.2f, p.y + 6.2f), ImVec2(p.x + 13.5f, p.y + 13.8f), fg, 1.8f);
} else {
draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 3.6f, -0.7f, 0.7f, 12);
draw->PathStroke(fg, 0, 1.4f);
draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 5.5f, -0.7f, 0.7f, 12);
draw->PathStroke(fg, 0, 1.2f);
}
if (hovered) ImGui::SetTooltip(soundMuted_ ? "Unmute" : "Mute");
}
ImGui::End();
// Friends button at top-left of minimap
{
const auto& contacts = gameHandler.getContacts();
int onlineCount = 0;
for (const auto& c : contacts)
if (c.isFriend() && c.isOnline()) ++onlineCount;
ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always);
ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) {
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 p = ImGui::GetCursorScreenPos();
ImVec2 sz(20.0f, 20.0f);
if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) {
showSocialFrame_ = !showSocialFrame_;
}
bool hovered = ImGui::IsItemHovered();
ImU32 bg = showSocialFrame_
? IM_COL32(42, 100, 42, 230)
: IM_COL32(38, 38, 38, 210);
if (hovered) bg = showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220);
draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f);
draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f),
ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f),
IM_COL32(255, 255, 255, 42), 4.0f);
// Simple smiley-face dots as "social" icon
ImU32 fg = IM_COL32(255, 255, 255, 245);
draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f);
draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg);
draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg);
draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8);
draw->PathStroke(fg, 0, 1.2f);
// Small green dot if friends online
if (onlineCount > 0) {
draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f),
3.5f, IM_COL32(50, 220, 50, 255));
}
if (hovered) {
if (onlineCount > 0)
ImGui::SetTooltip("Friends (%d online)", onlineCount);
else
ImGui::SetTooltip("Friends");
}
}
ImGui::End();
}
// Zoom buttons at the bottom edge of the minimap
ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always);
ImGuiWindowFlags zoomFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground;
if (ImGui::Begin("##MinimapZoom", nullptr, zoomFlags)) {
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2, 0));
if (ImGui::SmallButton("-")) {
if (minimap) minimap->zoomOut();
}
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
if (minimap) minimap->zoomIn();
}
ImGui::PopStyleVar(2);
}
ImGui::End();
// Indicators below the minimap (stacked: new mail, then BG queue, then latency)
float indicatorX = centerX - mapRadius;
float nextIndicatorY = centerY + mapRadius + 4.0f;
const float indicatorW = mapRadius * 2.0f;
constexpr float kIndicatorH = 22.0f;
ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs;
// "New Mail" indicator
if (gameHandler.hasNewMail()) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!");
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
// BG queue status indicator (when in queue but not yet invited)
for (const auto& slot : gameHandler.getBgQueues()) {
if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only
std::string bgName;
if (slot.arenaType > 0) {
bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena";
} else {
switch (slot.bgTypeId) {
case 1: bgName = "AV"; break;
case 2: bgName = "WSG"; break;
case 3: bgName = "AB"; break;
case 7: bgName = "EotS"; break;
case 9: bgName = "SotA"; break;
case 11: bgName = "IoC"; break;
default: bgName = "BG"; break;
}
}
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.5f);
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"In Queue: %s", bgName.c_str());
}
ImGui::End();
nextIndicatorY += kIndicatorH;
break; // Show at most one queue slot indicator
}
// Latency indicator — centered at top of screen
uint32_t latMs = gameHandler.getLatencyMs();
if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
ImVec4 latColor;
if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f);
else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f);
else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f);
else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f);
char latBuf[32];
snprintf(latBuf, sizeof(latBuf), "%u ms", latMs);
ImVec2 textSize = ImGui::CalcTextSize(latBuf);
float latW = textSize.x + 16.0f;
float latH = textSize.y + 8.0f;
ImGuiIO& lio = ImGui::GetIO();
float latX = (lio.DisplaySize.x - latW) * 0.5f;
ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.45f);
if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) {
ImGui::TextColored(latColor, "%s", latBuf);
}
ImGui::End();
}
// Low durability warning — shown when any equipped item has < 20% durability
if (gameHandler.getState() == game::WorldState::IN_WORLD) {
const auto& inv = gameHandler.getInventory();
float lowestDurPct = 1.0f;
for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) {
const auto& slot = inv.getEquipSlot(static_cast<game::EquipSlot>(i));
if (slot.empty()) continue;
const auto& it = slot.item;
if (it.maxDurability > 0) {
float pct = static_cast<float>(it.curDurability) / static_cast<float>(it.maxDurability);
if (pct < lowestDurPct) lowestDurPct = pct;
}
}
if (lowestDurPct < 0.20f) {
bool critical = (lowestDurPct < 0.05f);
float pulse = critical
? (0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 4.0f))
: 1.0f;
ImVec4 durWarnColor = critical
? ImVec4(1.0f, 0.2f, 0.2f, pulse)
: ImVec4(1.0f, 0.65f, 0.1f, 0.9f);
const char* durWarnText = critical ? "Item breaking!" : "Low durability";
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) {
ImGui::TextColored(durWarnColor, "%s", durWarnText);
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
}
// Local time clock — always visible below minimap indicators
{
auto now = std::chrono::system_clock::now();
std::time_t tt = std::chrono::system_clock::to_time_t(now);
struct tm tmBuf;
#ifdef _WIN32
localtime_s(&tmBuf, &tt);
#else
localtime_r(&tt, &tmBuf);
#endif
char clockStr[16];
snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min);
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs;
if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) {
ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr);
if (ImGui::IsItemHovered()) {
char fullTime[32];
snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)",
tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec);
ImGui::SetTooltip("%s", fullTime);
}
}
ImGui::End();
}
}
std::string GameScreen::getSettingsPath() {
std::string dir;
#ifdef _WIN32
const char* appdata = std::getenv("APPDATA");
dir = appdata ? std::string(appdata) + "\\wowee" : ".";
#else
const char* home = std::getenv("HOME");
dir = home ? std::string(home) + "/.wowee" : ".";
#endif
return dir + "/settings.cfg";
}
std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) {
// Get player gender, pronouns, and name
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;
// Helper to trim whitespace
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/$G placeholders first.
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);
// Split by colons
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;
}
// Add the last part
std::string lastPart = placeholder.substr(start);
trim(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;
}
} 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::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;
}
result.replace(pos, endPos - pos + 1, replacement);
pos += replacement.length();
}
// Replace simple placeholders.
// $n = player name
// $p = subject pronoun (he/she/they)
// $o = object pronoun (him/her/them)
// $s = possessive adjective (his/her/their)
// $S = possessive pronoun (his/hers/theirs)
// $b/$B = line break
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 '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;
}
void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) {
if (chatBubbles_.empty()) return;
auto* renderer = core::Application::getInstance().getRenderer();
auto* camera = renderer ? renderer->getCamera() : nullptr;
if (!camera) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Get delta time from ImGui
float dt = ImGui::GetIO().DeltaTime;
glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
// Update and render bubbles
for (int i = static_cast<int>(chatBubbles_.size()) - 1; i >= 0; --i) {
auto& bubble = chatBubbles_[i];
bubble.timeRemaining -= dt;
if (bubble.timeRemaining <= 0.0f) {
chatBubbles_.erase(chatBubbles_.begin() + i);
continue;
}
// Get entity position
auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid);
if (!entity) continue;
// Convert canonical → render coordinates, offset up by 2.5 units for bubble above head
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f);
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
// Project to screen
glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f);
if (clipPos.w <= 0.0f) continue; // Behind camera
glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w);
float screenX = (ndc.x * 0.5f + 0.5f) * screenW;
float screenY = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Flip Y
// Skip if off-screen
if (screenX < -200.0f || screenX > screenW + 200.0f ||
screenY < -100.0f || screenY > screenH + 100.0f) continue;
// Fade alpha over last 2 seconds
float alpha = 1.0f;
if (bubble.timeRemaining < 2.0f) {
alpha = bubble.timeRemaining / 2.0f;
}
// Draw bubble window
std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid);
ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f));
ImGui::SetNextWindowBgAlpha(0.7f * alpha);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4));
ImGui::Begin(winId.c_str(), nullptr, flags);
ImVec4 textColor = bubble.isYell
? ImVec4(1.0f, 0.2f, 0.2f, alpha)
: ImVec4(1.0f, 1.0f, 1.0f, alpha);
ImGui::PushStyleColor(ImGuiCol_Text, textColor);
ImGui::PushTextWrapPos(200.0f);
ImGui::TextWrapped("%s", bubble.message.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::End();
ImGui::PopStyleVar(2);
}
}
void GameScreen::saveSettings() {
std::string path = getSettingsPath();
std::filesystem::path dir = std::filesystem::path(path).parent_path();
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::ofstream out(path);
if (!out.is_open()) {
LOG_WARNING("Could not save settings to ", path);
return;
}
// Interface
out << "ui_opacity=" << pendingUiOpacity << "\n";
out << "minimap_rotate=" << (pendingMinimapRotate ? 1 : 0) << "\n";
out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n";
out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n";
out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n";
out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n";
out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n";
out << "action_bar_scale=" << pendingActionBarScale << "\n";
out << "nameplate_scale=" << nameplateScale_ << "\n";
out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n";
out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n";
out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n";
out << "show_right_bar=" << (pendingShowRightBar ? 1 : 0) << "\n";
out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n";
out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n";
out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n";
out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n";
// Audio
out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n";
out << "use_original_soundtrack=" << (pendingUseOriginalSoundtrack ? 1 : 0) << "\n";
out << "master_volume=" << pendingMasterVolume << "\n";
out << "music_volume=" << pendingMusicVolume << "\n";
out << "ambient_volume=" << pendingAmbientVolume << "\n";
out << "ui_volume=" << pendingUiVolume << "\n";
out << "combat_volume=" << pendingCombatVolume << "\n";
out << "spell_volume=" << pendingSpellVolume << "\n";
out << "movement_volume=" << pendingMovementVolume << "\n";
out << "footstep_volume=" << pendingFootstepVolume << "\n";
out << "npc_voice_volume=" << pendingNpcVoiceVolume << "\n";
out << "mount_volume=" << pendingMountVolume << "\n";
out << "activity_volume=" << pendingActivityVolume << "\n";
// Gameplay
out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n";
out << "graphics_preset=" << static_cast<int>(currentGraphicsPreset) << "\n";
out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n";
out << "shadows=" << (pendingShadows ? 1 : 0) << "\n";
out << "shadow_distance=" << pendingShadowDistance << "\n";
out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n";
out << "antialiasing=" << pendingAntiAliasing << "\n";
out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n";
out << "normal_map_strength=" << pendingNormalMapStrength << "\n";
out << "pom=" << (pendingPOM ? 1 : 0) << "\n";
out << "pom_quality=" << pendingPOMQuality << "\n";
out << "upscaling_mode=" << pendingUpscalingMode << "\n";
out << "fsr=" << (pendingFSR ? 1 : 0) << "\n";
out << "fsr_quality=" << pendingFSRQuality << "\n";
out << "fsr_sharpness=" << pendingFSRSharpness << "\n";
out << "fsr2_jitter_sign=" << pendingFSR2JitterSign << "\n";
out << "fsr2_mv_scale_x=" << pendingFSR2MotionVecScaleX << "\n";
out << "fsr2_mv_scale_y=" << pendingFSR2MotionVecScaleY << "\n";
out << "amd_fsr3_framegen=" << (pendingAMDFramegen ? 1 : 0) << "\n";
// Controls
out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n";
out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n";
out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n";
out << "fov=" << pendingFov << "\n";
// Chat
out << "chat_active_tab=" << activeChatTab_ << "\n";
out << "chat_timestamps=" << (chatShowTimestamps_ ? 1 : 0) << "\n";
out << "chat_font_size=" << chatFontSize_ << "\n";
out << "chat_autojoin_general=" << (chatAutoJoinGeneral_ ? 1 : 0) << "\n";
out << "chat_autojoin_trade=" << (chatAutoJoinTrade_ ? 1 : 0) << "\n";
out << "chat_autojoin_localdefense=" << (chatAutoJoinLocalDefense_ ? 1 : 0) << "\n";
out << "chat_autojoin_lfg=" << (chatAutoJoinLFG_ ? 1 : 0) << "\n";
out << "chat_autojoin_local=" << (chatAutoJoinLocal_ ? 1 : 0) << "\n";
out.close();
// Save keybindings to the same config file (appends [Keybindings] section)
KeybindingManager::getInstance().saveToConfigFile(path);
LOG_INFO("Settings saved to ", path);
}
void GameScreen::loadSettings() {
std::string path = getSettingsPath();
std::ifstream in(path);
if (!in.is_open()) return;
std::string line;
while (std::getline(in, line)) {
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
try {
// Interface
if (key == "ui_opacity") {
int v = std::stoi(val);
if (v >= 20 && v <= 100) {
pendingUiOpacity = v;
uiOpacity_ = static_cast<float>(v) / 100.0f;
}
} else if (key == "minimap_rotate") {
// Ignore persisted rotate state; keep north-up.
minimapRotate_ = false;
pendingMinimapRotate = false;
} else if (key == "minimap_square") {
int v = std::stoi(val);
minimapSquare_ = (v != 0);
pendingMinimapSquare = minimapSquare_;
} else if (key == "minimap_npc_dots") {
int v = std::stoi(val);
minimapNpcDots_ = (v != 0);
pendingMinimapNpcDots = minimapNpcDots_;
} else if (key == "show_latency_meter") {
showLatencyMeter_ = (std::stoi(val) != 0);
pendingShowLatencyMeter = showLatencyMeter_;
} else if (key == "show_dps_meter") {
showDPSMeter_ = (std::stoi(val) != 0);
} else if (key == "separate_bags") {
pendingSeparateBags = (std::stoi(val) != 0);
inventoryScreen.setSeparateBags(pendingSeparateBags);
} else if (key == "action_bar_scale") {
pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f);
} else if (key == "nameplate_scale") {
nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f);
} else if (key == "show_action_bar2") {
pendingShowActionBar2 = (std::stoi(val) != 0);
} else if (key == "action_bar2_offset_x") {
pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f);
} else if (key == "action_bar2_offset_y") {
pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "show_right_bar") {
pendingShowRightBar = (std::stoi(val) != 0);
} else if (key == "show_left_bar") {
pendingShowLeftBar = (std::stoi(val) != 0);
} else if (key == "right_bar_offset_y") {
pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "left_bar_offset_y") {
pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f);
} else if (key == "damage_flash") {
damageFlashEnabled_ = (std::stoi(val) != 0);
}
// Audio
else if (key == "sound_muted") {
soundMuted_ = (std::stoi(val) != 0);
if (soundMuted_) {
// Apply mute on load; preMuteVolume_ will be set when AudioEngine is available
audio::AudioEngine::instance().setMasterVolume(0.0f);
}
}
else if (key == "use_original_soundtrack") pendingUseOriginalSoundtrack = (std::stoi(val) != 0);
else if (key == "master_volume") pendingMasterVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "music_volume") pendingMusicVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "ambient_volume") pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "ui_volume") pendingUiVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "combat_volume") pendingCombatVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "spell_volume") pendingSpellVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "movement_volume") pendingMovementVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "footstep_volume") pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "npc_voice_volume") pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "mount_volume") pendingMountVolume = std::clamp(std::stoi(val), 0, 100);
else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100);
// Gameplay
else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0);
else if (key == "graphics_preset") {
int presetVal = std::clamp(std::stoi(val), 0, 4);
currentGraphicsPreset = static_cast<GraphicsPreset>(presetVal);
pendingGraphicsPreset = currentGraphicsPreset;
}
else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150);
else if (key == "shadows") pendingShadows = (std::stoi(val) != 0);
else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f);
else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0);
else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3);
else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0);
else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f);
else if (key == "pom") pendingPOM = (std::stoi(val) != 0);
else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2);
else if (key == "upscaling_mode") {
pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2);
pendingFSR = (pendingUpscalingMode == 1);
} else if (key == "fsr") {
pendingFSR = (std::stoi(val) != 0);
// Backward compatibility: old configs only had fsr=0/1.
if (pendingUpscalingMode == 0 && pendingFSR) pendingUpscalingMode = 1;
}
else if (key == "fsr_quality") pendingFSRQuality = std::clamp(std::stoi(val), 0, 3);
else if (key == "fsr_sharpness") pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f);
else if (key == "fsr2_jitter_sign") pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "fsr2_mv_scale_x") pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "fsr2_mv_scale_y") pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f);
else if (key == "amd_fsr3_framegen") pendingAMDFramegen = (std::stoi(val) != 0);
// Controls
else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f);
else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0);
else if (key == "extended_zoom") pendingExtendedZoom = (std::stoi(val) != 0);
else if (key == "fov") {
pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov);
}
}
// Chat
else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3);
else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0);
else if (key == "chat_font_size") chatFontSize_ = std::clamp(std::stoi(val), 0, 2);
else if (key == "chat_autojoin_general") chatAutoJoinGeneral_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_trade") chatAutoJoinTrade_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_localdefense") chatAutoJoinLocalDefense_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_lfg") chatAutoJoinLFG_ = (std::stoi(val) != 0);
else if (key == "chat_autojoin_local") chatAutoJoinLocal_ = (std::stoi(val) != 0);
} catch (...) {}
}
// Load keybindings from the same config file
KeybindingManager::getInstance().loadFromConfigFile(path);
LOG_INFO("Settings loaded from ", path);
}
// ============================================================
// Mail Window
// ============================================================
void GameScreen::renderMailWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isMailboxOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 250, 80), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_Appearing);
bool open = true;
if (ImGui::Begin("Mailbox", &open)) {
const auto& inbox = gameHandler.getMailInbox();
// Top bar: money + compose button
uint64_t money = gameHandler.getMoneyCopper();
uint32_t mg = static_cast<uint32_t>(money / 10000);
uint32_t ms = static_cast<uint32_t>((money / 100) % 100);
uint32_t mc = static_cast<uint32_t>(money % 100);
ImGui::Text("Your money: %ug %us %uc", mg, ms, mc);
ImGui::SameLine(ImGui::GetWindowWidth() - 100);
if (ImGui::Button("Compose")) {
mailRecipientBuffer_[0] = '\0';
mailSubjectBuffer_[0] = '\0';
mailBodyBuffer_[0] = '\0';
mailComposeMoney_[0] = 0;
mailComposeMoney_[1] = 0;
mailComposeMoney_[2] = 0;
gameHandler.openMailCompose();
}
ImGui::Separator();
if (inbox.empty()) {
ImGui::TextDisabled("No mail.");
} else {
// Two-panel layout: left = mail list, right = selected mail detail
float listWidth = 220.0f;
// Left panel - mail list
ImGui::BeginChild("MailList", ImVec2(listWidth, 0), true);
for (size_t i = 0; i < inbox.size(); ++i) {
const auto& mail = inbox[i];
ImGui::PushID(static_cast<int>(i));
bool selected = (gameHandler.getSelectedMailIndex() == static_cast<int>(i));
std::string label = mail.subject.empty() ? "(No Subject)" : mail.subject;
// Unread indicator
if (!mail.read) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 0.5f, 1.0f));
}
if (ImGui::Selectable(label.c_str(), selected)) {
gameHandler.setSelectedMailIndex(static_cast<int>(i));
// Mark as read
if (!mail.read) {
gameHandler.mailMarkAsRead(mail.messageId);
}
}
if (!mail.read) {
ImGui::PopStyleColor();
}
// Sub-info line
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), " From: %s", mail.senderName.c_str());
if (mail.money > 0) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), " [G]");
}
if (!mail.attachments.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]");
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::SameLine();
// Right panel - selected mail detail
ImGui::BeginChild("MailDetail", ImVec2(0, 0), true);
int sel = gameHandler.getSelectedMailIndex();
if (sel >= 0 && sel < static_cast<int>(inbox.size())) {
const auto& mail = inbox[sel];
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%s",
mail.subject.empty() ? "(No Subject)" : mail.subject.c_str());
ImGui::Text("From: %s", mail.senderName.c_str());
if (mail.messageType == 2) {
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]");
}
ImGui::Separator();
// Body text
if (!mail.body.empty()) {
ImGui::TextWrapped("%s", mail.body.c_str());
ImGui::Separator();
}
// Money
if (mail.money > 0) {
uint32_t g = mail.money / 10000;
uint32_t s = (mail.money / 100) % 100;
uint32_t c = mail.money % 100;
ImGui::Text("Money: %ug %us %uc", g, s, c);
ImGui::SameLine();
if (ImGui::SmallButton("Take Money")) {
gameHandler.mailTakeMoney(mail.messageId);
}
}
// COD warning
if (mail.cod > 0) {
uint32_t g = mail.cod / 10000;
uint32_t s = (mail.cod / 100) % 100;
uint32_t c = mail.cod % 100;
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"COD: %ug %us %uc (you pay this to take items)", g, s, c);
}
// Attachments
if (!mail.attachments.empty()) {
ImGui::Text("Attachments: %zu", mail.attachments.size());
ImDrawList* mailDraw = ImGui::GetWindowDrawList();
constexpr float MAIL_SLOT = 34.0f;
for (size_t j = 0; j < mail.attachments.size(); ++j) {
const auto& att = mail.attachments[j];
ImGui::PushID(static_cast<int>(j));
auto* info = gameHandler.getItemInfo(att.itemId);
game::ItemQuality quality = game::ItemQuality::COMMON;
std::string name = "Item " + std::to_string(att.itemId);
uint32_t displayInfoId = 0;
if (info && info->valid) {
quality = static_cast<game::ItemQuality>(info->quality);
name = info->name;
displayInfoId = info->displayInfoId;
} else {
gameHandler.ensureItemInfo(att.itemId);
}
ImVec4 qc = InventoryScreen::getQualityColor(quality);
ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc);
ImVec2 pos = ImGui::GetCursorScreenPos();
VkDescriptorSet iconTex = displayInfoId
? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE;
if (iconTex) {
mailDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos,
ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT));
mailDraw->AddRect(pos, ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT),
borderCol, 0.0f, 0, 1.5f);
} else {
mailDraw->AddRectFilled(pos,
ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT),
IM_COL32(40, 35, 30, 220));
mailDraw->AddRect(pos,
ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT),
borderCol, 0.0f, 0, 1.5f);
}
if (att.stackCount > 1) {
char cnt[16];
snprintf(cnt, sizeof(cnt), "%u", att.stackCount);
float cw = ImGui::CalcTextSize(cnt).x;
mailDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f),
IM_COL32(0, 0, 0, 200), cnt);
mailDraw->AddText(
ImVec2(pos.x + MAIL_SLOT - cw - 2.0f, pos.y + MAIL_SLOT - 14.0f),
IM_COL32(255, 255, 255, 220), cnt);
}
ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT));
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
ImGui::SameLine();
ImGui::TextColored(qc, "%s", name.c_str());
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
ImGui::SameLine();
if (ImGui::SmallButton("Take")) {
gameHandler.mailTakeItem(mail.messageId, att.slot);
}
ImGui::PopID();
}
// "Take All" button when there are multiple attachments
if (mail.attachments.size() > 1) {
if (ImGui::SmallButton("Take All")) {
for (const auto& att2 : mail.attachments) {
gameHandler.mailTakeItem(mail.messageId, att2.slot);
}
}
}
}
ImGui::Spacing();
ImGui::Separator();
// Action buttons
if (ImGui::Button("Delete")) {
gameHandler.mailDelete(mail.messageId);
}
ImGui::SameLine();
if (mail.messageType == 0 && ImGui::Button("Reply")) {
// Pre-fill compose with sender as recipient
strncpy(mailRecipientBuffer_, mail.senderName.c_str(), sizeof(mailRecipientBuffer_) - 1);
mailRecipientBuffer_[sizeof(mailRecipientBuffer_) - 1] = '\0';
std::string reSubject = "Re: " + mail.subject;
strncpy(mailSubjectBuffer_, reSubject.c_str(), sizeof(mailSubjectBuffer_) - 1);
mailSubjectBuffer_[sizeof(mailSubjectBuffer_) - 1] = '\0';
mailBodyBuffer_[0] = '\0';
mailComposeMoney_[0] = 0;
mailComposeMoney_[1] = 0;
mailComposeMoney_[2] = 0;
gameHandler.openMailCompose();
}
} else {
ImGui::TextDisabled("Select a mail to read.");
}
ImGui::EndChild();
}
}
ImGui::End();
if (!open) {
gameHandler.closeMailbox();
}
}
void GameScreen::renderMailComposeWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isMailComposeOpen()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 250), ImGuiCond_Appearing);
ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_Appearing);
bool open = true;
if (ImGui::Begin("Send Mail", &open)) {
ImGui::Text("To:");
ImGui::SameLine(60);
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##MailTo", mailRecipientBuffer_, sizeof(mailRecipientBuffer_));
ImGui::Text("Subject:");
ImGui::SameLine(60);
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##MailSubject", mailSubjectBuffer_, sizeof(mailSubjectBuffer_));
ImGui::Text("Body:");
ImGui::InputTextMultiline("##MailBody", mailBodyBuffer_, sizeof(mailBodyBuffer_),
ImVec2(-1, 120));
// Attachments section
int attachCount = gameHandler.getMailAttachmentCount();
ImGui::Text("Attachments (%d/12):", attachCount);
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Right-click items in bags to attach");
const auto& attachments = gameHandler.getMailAttachments();
// Show attachment slots in a grid (6 per row)
for (int i = 0; i < game::GameHandler::MAIL_MAX_ATTACHMENTS; ++i) {
if (i % 6 != 0) ImGui::SameLine();
ImGui::PushID(i + 5000);
const auto& att = attachments[i];
if (att.occupied()) {
// Show item with quality color border
ImVec4 qualColor = ui::InventoryScreen::getQualityColor(att.item.quality);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qualColor.x * 0.3f, qualColor.y * 0.3f, qualColor.z * 0.3f, 0.8f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qualColor.x * 0.5f, qualColor.y * 0.5f, qualColor.z * 0.5f, 0.9f));
// Try to show icon
VkDescriptorSet icon = inventoryScreen.getItemIcon(att.item.displayInfoId);
bool clicked = false;
if (icon) {
clicked = ImGui::ImageButton("##att", (ImTextureID)icon, ImVec2(30, 30));
} else {
// Truncate name to fit
std::string label = att.item.name.substr(0, 4);
clicked = ImGui::Button(label.c_str(), ImVec2(36, 36));
}
ImGui::PopStyleColor(2);
if (clicked) {
gameHandler.detachMailAttachment(i);
}
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextColored(qualColor, "%s", att.item.name.c_str());
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Click to remove");
ImGui::EndTooltip();
}
} else {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.5f));
ImGui::Button("##empty", ImVec2(36, 36));
ImGui::PopStyleColor();
}
ImGui::PopID();
}
ImGui::Spacing();
ImGui::Text("Money:");
ImGui::SameLine(60);
ImGui::SetNextItemWidth(60);
ImGui::InputInt("##MailGold", &mailComposeMoney_[0], 0, 0);
if (mailComposeMoney_[0] < 0) mailComposeMoney_[0] = 0;
ImGui::SameLine();
ImGui::Text("g");
ImGui::SameLine();
ImGui::SetNextItemWidth(40);
ImGui::InputInt("##MailSilver", &mailComposeMoney_[1], 0, 0);
if (mailComposeMoney_[1] < 0) mailComposeMoney_[1] = 0;
if (mailComposeMoney_[1] > 99) mailComposeMoney_[1] = 99;
ImGui::SameLine();
ImGui::Text("s");
ImGui::SameLine();
ImGui::SetNextItemWidth(40);
ImGui::InputInt("##MailCopper", &mailComposeMoney_[2], 0, 0);
if (mailComposeMoney_[2] < 0) mailComposeMoney_[2] = 0;
if (mailComposeMoney_[2] > 99) mailComposeMoney_[2] = 99;
ImGui::SameLine();
ImGui::Text("c");
uint32_t totalMoney = static_cast<uint32_t>(mailComposeMoney_[0]) * 10000 +
static_cast<uint32_t>(mailComposeMoney_[1]) * 100 +
static_cast<uint32_t>(mailComposeMoney_[2]);
uint32_t sendCost = attachCount > 0 ? static_cast<uint32_t>(30 * attachCount) : 30u;
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Sending cost: %uc", sendCost);
ImGui::Spacing();
bool canSend = (strlen(mailRecipientBuffer_) > 0);
if (!canSend) ImGui::BeginDisabled();
if (ImGui::Button("Send", ImVec2(80, 0))) {
gameHandler.sendMail(mailRecipientBuffer_, mailSubjectBuffer_,
mailBodyBuffer_, totalMoney);
}
if (!canSend) ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(80, 0))) {
gameHandler.closeMailCompose();
}
}
ImGui::End();
if (!open) {
gameHandler.closeMailCompose();
}
}
// ============================================================
// Bank Window
// ============================================================
void GameScreen::renderBankWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isBankOpen()) return;
bool open = true;
ImGui::SetNextWindowSize(ImVec2(480, 420), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Bank", &open)) {
ImGui::End();
if (!open) gameHandler.closeBank();
return;
}
auto& inv = gameHandler.getInventory();
bool isHolding = inventoryScreen.isHoldingItem();
constexpr float SLOT_SIZE = 42.0f;
static constexpr float kBankPickupHold = 0.10f; // seconds
// Persistent pickup tracking for bank (mirrors inventory_screen's pickupPending_)
static bool bankPickupPending = false;
static float bankPickupPressTime = 0.0f;
static int bankPickupType = 0; // 0=main bank, 1=bank bag slot, 2=bank bag equip slot
static int bankPickupIndex = -1;
static int bankPickupBagIndex = -1;
static int bankPickupBagSlotIndex = -1;
// Helper: render a bank item slot with icon, click-and-hold pickup, drop, tooltip
auto renderBankItemSlot = [&](const game::ItemSlot& slot, int pickType, int mainIdx,
int bagIdx, int bagSlotIdx, uint8_t dstBag, uint8_t dstSlot) {
ImDrawList* drawList = ImGui::GetWindowDrawList();
ImVec2 pos = ImGui::GetCursorScreenPos();
if (slot.empty()) {
ImU32 bgCol = IM_COL32(30, 30, 30, 200);
ImU32 borderCol = IM_COL32(60, 60, 60, 200);
if (isHolding) {
bgCol = IM_COL32(20, 50, 20, 200);
borderCol = IM_COL32(0, 180, 0, 200);
}
drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol);
drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), borderCol);
ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE));
if (isHolding && ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot);
}
} else {
const auto& item = slot.item;
ImVec4 qc = InventoryScreen::getQualityColor(item.quality);
ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc);
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(item.displayInfoId);
if (iconTex) {
drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos,
ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE));
drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE),
borderCol, 0.0f, 0, 2.0f);
} else {
ImU32 bgCol = IM_COL32(40, 35, 30, 220);
drawList->AddRectFilled(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE), bgCol);
drawList->AddRect(pos, ImVec2(pos.x + SLOT_SIZE, pos.y + SLOT_SIZE),
borderCol, 0.0f, 0, 2.0f);
if (!item.name.empty()) {
char abbr[3] = { item.name[0], item.name.size() > 1 ? item.name[1] : '\0', '\0' };
float tw = ImGui::CalcTextSize(abbr).x;
drawList->AddText(ImVec2(pos.x + (SLOT_SIZE - tw) * 0.5f, pos.y + 2.0f),
ImGui::ColorConvertFloat4ToU32(qc), abbr);
}
}
if (item.stackCount > 1) {
char countStr[16];
snprintf(countStr, sizeof(countStr), "%u", item.stackCount);
float cw = ImGui::CalcTextSize(countStr).x;
drawList->AddText(ImVec2(pos.x + SLOT_SIZE - cw - 2.0f, pos.y + SLOT_SIZE - 14.0f),
IM_COL32(255, 255, 255, 220), countStr);
}
ImGui::InvisibleButton("slot", ImVec2(SLOT_SIZE, SLOT_SIZE));
if (!isHolding) {
// Start pickup tracking on mouse press
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
bankPickupPending = true;
bankPickupPressTime = ImGui::GetTime();
bankPickupType = pickType;
bankPickupIndex = mainIdx;
bankPickupBagIndex = bagIdx;
bankPickupBagSlotIndex = bagSlotIdx;
}
// Check if held long enough to pick up
if (bankPickupPending && ImGui::IsMouseDown(ImGuiMouseButton_Left) &&
(ImGui::GetTime() - bankPickupPressTime) >= kBankPickupHold) {
bool sameSlot = (bankPickupType == pickType);
if (pickType == 0)
sameSlot = sameSlot && (bankPickupIndex == mainIdx);
else if (pickType == 1)
sameSlot = sameSlot && (bankPickupBagIndex == bagIdx) && (bankPickupBagSlotIndex == bagSlotIdx);
else if (pickType == 2)
sameSlot = sameSlot && (bankPickupIndex == mainIdx);
if (sameSlot && ImGui::IsItemHovered()) {
bankPickupPending = false;
if (pickType == 0) {
inventoryScreen.pickupFromBank(inv, mainIdx);
} else if (pickType == 1) {
inventoryScreen.pickupFromBankBag(inv, bagIdx, bagSlotIdx);
} else if (pickType == 2) {
inventoryScreen.pickupFromBankBagEquip(inv, mainIdx);
}
}
}
} else {
// Drop/swap on mouse release
if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
inventoryScreen.dropIntoBankSlot(gameHandler, dstBag, dstSlot);
}
}
// Tooltip
if (ImGui::IsItemHovered() && !isHolding) {
auto* info = gameHandler.getItemInfo(item.itemId);
if (info && info->valid)
inventoryScreen.renderItemTooltip(*info);
else {
ImGui::BeginTooltip();
ImGui::TextColored(qc, "%s", item.name.c_str());
ImGui::EndTooltip();
}
// Shift-click to insert item link into chat
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift
&& !item.name.empty()) {
auto* info2 = gameHandler.getItemInfo(item.itemId);
uint8_t q = (info2 && info2->valid)
? static_cast<uint8_t>(info2->quality)
: static_cast<uint8_t>(item.quality);
const std::string& lname = (info2 && info2->valid && !info2->name.empty())
? info2->name : item.name;
std::string link = buildItemChatLink(item.itemId, q, lname);
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;
}
}
}
}
};
// Main bank slots (24 for Classic, 28 for TBC/WotLK)
int bankSlotCount = gameHandler.getEffectiveBankSlots();
int bankBagCount = gameHandler.getEffectiveBankBagSlots();
ImGui::Text("Bank Slots");
ImGui::Separator();
for (int i = 0; i < bankSlotCount; i++) {
if (i % 7 != 0) ImGui::SameLine();
ImGui::PushID(i + 1000);
renderBankItemSlot(inv.getBankSlot(i), 0, i, -1, -1, 0xFF, static_cast<uint8_t>(39 + i));
ImGui::PopID();
}
// Bank bag equip slots — show bag icon with pickup/drop, or "Buy Slot"
ImGui::Spacing();
ImGui::Separator();
ImGui::Text("Bank Bags");
uint8_t purchased = inv.getPurchasedBankBagSlots();
for (int i = 0; i < bankBagCount; i++) {
if (i > 0) ImGui::SameLine();
ImGui::PushID(i + 2000);
int bagSize = inv.getBankBagSize(i);
if (i < purchased || bagSize > 0) {
const auto& bagSlot = inv.getBankBagItem(i);
// Render as an item slot: icon with pickup/drop (pickType=2 for bag equip)
renderBankItemSlot(bagSlot, 2, i, -1, -1, 0xFF, static_cast<uint8_t>(67 + i));
} else {
if (ImGui::Button("Buy Slot", ImVec2(50, 30))) {
gameHandler.buyBankSlot();
}
}
ImGui::PopID();
}
// Show expanded bank bag contents
for (int bagIdx = 0; bagIdx < bankBagCount; bagIdx++) {
int bagSize = inv.getBankBagSize(bagIdx);
if (bagSize <= 0) continue;
ImGui::Spacing();
ImGui::Text("Bank Bag %d (%d slots)", bagIdx + 1, bagSize);
for (int s = 0; s < bagSize; s++) {
if (s % 7 != 0) ImGui::SameLine();
ImGui::PushID(3000 + bagIdx * 100 + s);
renderBankItemSlot(inv.getBankBagSlot(bagIdx, s), 1, -1, bagIdx, s,
static_cast<uint8_t>(67 + bagIdx), static_cast<uint8_t>(s));
ImGui::PopID();
}
}
ImGui::End();
if (!open) gameHandler.closeBank();
}
// ============================================================
// Guild Bank Window
// ============================================================
void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isGuildBankOpen()) return;
bool open = true;
ImGui::SetNextWindowSize(ImVec2(520, 500), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Guild Bank", &open)) {
ImGui::End();
if (!open) gameHandler.closeGuildBank();
return;
}
const auto& data = gameHandler.getGuildBankData();
uint8_t activeTab = gameHandler.getGuildBankActiveTab();
// Money display
uint32_t gold = static_cast<uint32_t>(data.money / 10000);
uint32_t silver = static_cast<uint32_t>((data.money / 100) % 100);
uint32_t copper = static_cast<uint32_t>(data.money % 100);
ImGui::Text("Guild Bank Money: ");
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper);
// Tab bar
if (!data.tabs.empty()) {
for (size_t i = 0; i < data.tabs.size(); i++) {
if (i > 0) ImGui::SameLine();
bool selected = (i == activeTab);
if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f));
std::string tabLabel = data.tabs[i].tabName.empty() ? ("Tab " + std::to_string(i + 1)) : data.tabs[i].tabName;
if (ImGui::Button(tabLabel.c_str())) {
gameHandler.queryGuildBankTab(static_cast<uint8_t>(i));
}
if (selected) ImGui::PopStyleColor();
}
}
// Buy tab button
if (data.tabs.size() < 6) {
ImGui::SameLine();
if (ImGui::Button("Buy Tab")) {
gameHandler.buyGuildBankTab();
}
}
ImGui::Separator();
// Tab items (98 slots = 14 columns × 7 rows)
constexpr float GB_SLOT = 34.0f;
ImDrawList* gbDraw = ImGui::GetWindowDrawList();
for (size_t i = 0; i < data.tabItems.size(); i++) {
if (i % 14 != 0) ImGui::SameLine(0.0f, 2.0f);
const auto& item = data.tabItems[i];
ImGui::PushID(static_cast<int>(i) + 5000);
ImVec2 pos = ImGui::GetCursorScreenPos();
if (item.itemEntry == 0) {
gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT),
IM_COL32(30, 30, 30, 200));
gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT),
IM_COL32(60, 60, 60, 180));
ImGui::InvisibleButton("##gbempty", ImVec2(GB_SLOT, GB_SLOT));
} else {
auto* info = gameHandler.getItemInfo(item.itemEntry);
game::ItemQuality quality = game::ItemQuality::COMMON;
std::string name = "Item " + std::to_string(item.itemEntry);
uint32_t displayInfoId = 0;
if (info) {
quality = static_cast<game::ItemQuality>(info->quality);
name = info->name;
displayInfoId = info->displayInfoId;
}
ImVec4 qc = InventoryScreen::getQualityColor(quality);
ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc);
VkDescriptorSet iconTex = displayInfoId ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE;
if (iconTex) {
gbDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos,
ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT));
gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT),
borderCol, 0.0f, 0, 1.5f);
} else {
gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT),
IM_COL32(40, 35, 30, 220));
gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT),
borderCol, 0.0f, 0, 1.5f);
if (!name.empty() && name[0] != 'I') {
char abbr[3] = { name[0], name.size() > 1 ? name[1] : '\0', '\0' };
float tw = ImGui::CalcTextSize(abbr).x;
gbDraw->AddText(ImVec2(pos.x + (GB_SLOT - tw) * 0.5f, pos.y + 2.0f),
borderCol, abbr);
}
}
if (item.stackCount > 1) {
char cnt[16];
snprintf(cnt, sizeof(cnt), "%u", item.stackCount);
float cw = ImGui::CalcTextSize(cnt).x;
gbDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), IM_COL32(0, 0, 0, 200), cnt);
gbDraw->AddText(ImVec2(pos.x + GB_SLOT - cw - 2.0f, pos.y + GB_SLOT - 14.0f),
IM_COL32(255, 255, 255, 220), cnt);
}
ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT));
if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) {
gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0);
}
if (ImGui::IsItemHovered()) {
if (info && info->valid)
inventoryScreen.renderItemTooltip(*info);
// Shift-click to insert item link into chat
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift
&& !name.empty() && item.itemEntry != 0) {
uint8_t q = static_cast<uint8_t>(quality);
std::string link = buildItemChatLink(item.itemEntry, q, name);
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;
}
}
}
}
ImGui::PopID();
}
// Money deposit/withdraw
ImGui::Separator();
ImGui::Text("Money:");
ImGui::SameLine();
ImGui::SetNextItemWidth(60);
ImGui::InputInt("##gbg", &guildBankMoneyInput_[0], 0); ImGui::SameLine(); ImGui::Text("g");
ImGui::SameLine();
ImGui::SetNextItemWidth(40);
ImGui::InputInt("##gbs", &guildBankMoneyInput_[1], 0); ImGui::SameLine(); ImGui::Text("s");
ImGui::SameLine();
ImGui::SetNextItemWidth(40);
ImGui::InputInt("##gbc", &guildBankMoneyInput_[2], 0); ImGui::SameLine(); ImGui::Text("c");
ImGui::SameLine();
if (ImGui::Button("Deposit")) {
uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2];
if (amount > 0) gameHandler.depositGuildBankMoney(amount);
}
ImGui::SameLine();
if (ImGui::Button("Withdraw")) {
uint32_t amount = guildBankMoneyInput_[0] * 10000 + guildBankMoneyInput_[1] * 100 + guildBankMoneyInput_[2];
if (amount > 0) gameHandler.withdrawGuildBankMoney(amount);
}
if (data.withdrawAmount >= 0) {
ImGui::Text("Remaining withdrawals: %d", data.withdrawAmount);
}
ImGui::End();
if (!open) gameHandler.closeGuildBank();
}
// ============================================================
// Auction House Window
// ============================================================
void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isAuctionHouseOpen()) return;
bool open = true;
ImGui::SetNextWindowSize(ImVec2(650, 500), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Auction House", &open)) {
ImGui::End();
if (!open) gameHandler.closeAuctionHouse();
return;
}
int tab = gameHandler.getAuctionActiveTab();
// Tab buttons
const char* tabNames[] = {"Browse", "Bids", "Auctions"};
for (int i = 0; i < 3; i++) {
if (i > 0) ImGui::SameLine();
bool selected = (tab == i);
if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f));
if (ImGui::Button(tabNames[i], ImVec2(100, 0))) {
gameHandler.setAuctionActiveTab(i);
if (i == 1) gameHandler.auctionListBidderItems();
else if (i == 2) gameHandler.auctionListOwnerItems();
}
if (selected) ImGui::PopStyleColor();
}
ImGui::Separator();
if (tab == 0) {
// Browse tab - Search filters
// --- Helper: resolve current UI filter state into wire-format search params ---
// WoW 3.3.5a item class IDs:
// 0=Consumable, 1=Container, 2=Weapon, 3=Gem, 4=Armor,
// 7=Projectile/TradeGoods, 9=Recipe, 11=Quiver, 15=Miscellaneous
struct AHClassMapping { const char* label; uint32_t classId; };
static const AHClassMapping classMappings[] = {
{"All", 0xFFFFFFFF},
{"Weapon", 2},
{"Armor", 4},
{"Container", 1},
{"Consumable", 0},
{"Trade Goods", 7},
{"Gem", 3},
{"Recipe", 9},
{"Quiver", 11},
{"Miscellaneous", 15},
};
static constexpr int NUM_CLASSES = 10;
// Weapon subclass IDs (WoW 3.3.5a)
struct AHSubMapping { const char* label; uint32_t subId; };
static const AHSubMapping weaponSubs[] = {
{"All", 0xFFFFFFFF}, {"Axe (1H)", 0}, {"Axe (2H)", 1}, {"Bow", 2},
{"Gun", 3}, {"Mace (1H)", 4}, {"Mace (2H)", 5}, {"Polearm", 6},
{"Sword (1H)", 7}, {"Sword (2H)", 8}, {"Staff", 10},
{"Fist Weapon", 13}, {"Dagger", 15}, {"Thrown", 16},
{"Crossbow", 18}, {"Wand", 19},
};
static constexpr int NUM_WEAPON_SUBS = 16;
// Armor subclass IDs
static const AHSubMapping armorSubs[] = {
{"All", 0xFFFFFFFF}, {"Cloth", 1}, {"Leather", 2}, {"Mail", 3},
{"Plate", 4}, {"Shield", 6}, {"Miscellaneous", 0},
};
static constexpr int NUM_ARMOR_SUBS = 7;
auto getSearchClassId = [&]() -> uint32_t {
if (auctionItemClass_ < 0 || auctionItemClass_ >= NUM_CLASSES) return 0xFFFFFFFF;
return classMappings[auctionItemClass_].classId;
};
auto getSearchSubClassId = [&]() -> uint32_t {
if (auctionItemSubClass_ < 0) return 0xFFFFFFFF;
uint32_t cid = getSearchClassId();
if (cid == 2 && auctionItemSubClass_ < NUM_WEAPON_SUBS)
return weaponSubs[auctionItemSubClass_].subId;
if (cid == 4 && auctionItemSubClass_ < NUM_ARMOR_SUBS)
return armorSubs[auctionItemSubClass_].subId;
return 0xFFFFFFFF;
};
auto doSearch = [&](uint32_t offset) {
auctionBrowseOffset_ = offset;
if (auctionLevelMin_ < 0) auctionLevelMin_ = 0;
if (auctionLevelMax_ < 0) auctionLevelMax_ = 0;
uint32_t q = auctionQuality_ > 0 ? static_cast<uint32_t>(auctionQuality_ - 1) : 0xFFFFFFFF;
gameHandler.auctionSearch(auctionSearchName_,
static_cast<uint8_t>(auctionLevelMin_),
static_cast<uint8_t>(auctionLevelMax_),
q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset);
};
// Row 1: Name + Level range
ImGui::SetNextItemWidth(200);
bool enterPressed = ImGui::InputText("Name", auctionSearchName_, sizeof(auctionSearchName_),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
ImGui::SetNextItemWidth(50);
ImGui::InputInt("Min Lv", &auctionLevelMin_, 0);
ImGui::SameLine();
ImGui::SetNextItemWidth(50);
ImGui::InputInt("Max Lv", &auctionLevelMax_, 0);
// Row 2: Quality + Category + Subcategory + Search button
const char* qualities[] = {"All", "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary"};
ImGui::SetNextItemWidth(100);
ImGui::Combo("Quality", &auctionQuality_, qualities, 7);
ImGui::SameLine();
// Build class label list from mappings
const char* classLabels[NUM_CLASSES];
for (int c = 0; c < NUM_CLASSES; c++) classLabels[c] = classMappings[c].label;
ImGui::SetNextItemWidth(120);
int classIdx = auctionItemClass_ < 0 ? 0 : auctionItemClass_;
if (ImGui::Combo("Category", &classIdx, classLabels, NUM_CLASSES)) {
if (classIdx != auctionItemClass_) auctionItemSubClass_ = -1;
auctionItemClass_ = classIdx;
}
// Subcategory (only for Weapon and Armor)
uint32_t curClassId = getSearchClassId();
if (curClassId == 2 || curClassId == 4) {
const AHSubMapping* subs = (curClassId == 2) ? weaponSubs : armorSubs;
int numSubs = (curClassId == 2) ? NUM_WEAPON_SUBS : NUM_ARMOR_SUBS;
const char* subLabels[20];
for (int s = 0; s < numSubs && s < 20; s++) subLabels[s] = subs[s].label;
int subIdx = auctionItemSubClass_ + 1; // -1 → 0 ("All")
if (subIdx < 0 || subIdx >= numSubs) subIdx = 0;
ImGui::SameLine();
ImGui::SetNextItemWidth(110);
if (ImGui::Combo("Subcat", &subIdx, subLabels, numSubs)) {
auctionItemSubClass_ = subIdx - 1; // 0 → -1 ("All")
}
}
ImGui::SameLine();
float delay = gameHandler.getAuctionSearchDelay();
if (delay > 0.0f) {
char delayBuf[32];
snprintf(delayBuf, sizeof(delayBuf), "Search (%.0fs)", delay);
ImGui::BeginDisabled();
ImGui::Button(delayBuf);
ImGui::EndDisabled();
} else {
if (ImGui::Button("Search") || enterPressed) {
doSearch(0);
}
}
ImGui::Separator();
// Results table
const auto& results = gameHandler.getAuctionBrowseResults();
constexpr uint32_t AH_PAGE_SIZE = 50;
ImGui::Text("%zu results (of %u total)", results.auctions.size(), results.totalCount);
// Pagination
if (results.totalCount > AH_PAGE_SIZE) {
ImGui::SameLine();
uint32_t page = auctionBrowseOffset_ / AH_PAGE_SIZE + 1;
uint32_t totalPages = (results.totalCount + AH_PAGE_SIZE - 1) / AH_PAGE_SIZE;
if (auctionBrowseOffset_ == 0) ImGui::BeginDisabled();
if (ImGui::SmallButton("< Prev")) {
uint32_t newOff = (auctionBrowseOffset_ >= AH_PAGE_SIZE) ? auctionBrowseOffset_ - AH_PAGE_SIZE : 0;
doSearch(newOff);
}
if (auctionBrowseOffset_ == 0) ImGui::EndDisabled();
ImGui::SameLine();
ImGui::Text("Page %u/%u", page, totalPages);
ImGui::SameLine();
if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::BeginDisabled();
if (ImGui::SmallButton("Next >")) {
doSearch(auctionBrowseOffset_ + AH_PAGE_SIZE);
}
if (auctionBrowseOffset_ + AH_PAGE_SIZE >= results.totalCount) ImGui::EndDisabled();
}
if (ImGui::BeginChild("AuctionResults", ImVec2(0, -110), true)) {
if (ImGui::BeginTable("AuctionTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40);
ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableHeadersRow();
for (size_t i = 0; i < results.auctions.size(); i++) {
const auto& auction = results.auctions[i];
auto* info = gameHandler.getItemInfo(auction.itemEntry);
std::string name = info ? info->name : ("Item #" + std::to_string(auction.itemEntry));
game::ItemQuality quality = info ? static_cast<game::ItemQuality>(info->quality) : game::ItemQuality::COMMON;
ImVec4 qc = InventoryScreen::getQualityColor(quality);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
// Item icon
if (info && info->valid && info->displayInfoId != 0) {
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId);
if (iconTex) {
ImGui::Image((void*)(intptr_t)iconTex, ImVec2(16, 16));
ImGui::SameLine();
}
}
ImGui::TextColored(qc, "%s", name.c_str());
// Item tooltip on hover; shift-click to insert chat link
if (ImGui::IsItemHovered() && info && info->valid) {
inventoryScreen.renderItemTooltip(*info);
}
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
ImGui::TableSetColumnIndex(1);
ImGui::Text("%u", auction.stackCount);
ImGui::TableSetColumnIndex(2);
// Time left display
uint32_t mins = auction.timeLeftMs / 60000;
if (mins > 720) ImGui::Text("Long");
else if (mins > 120) ImGui::Text("Medium");
else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short");
ImGui::TableSetColumnIndex(3);
{
uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid;
ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100);
}
ImGui::TableSetColumnIndex(4);
if (auction.buyoutPrice > 0) {
ImGui::Text("%ug%us%uc", auction.buyoutPrice / 10000,
(auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100);
} else {
ImGui::TextDisabled("--");
}
ImGui::TableSetColumnIndex(5);
ImGui::PushID(static_cast<int>(i) + 7000);
if (auction.buyoutPrice > 0 && ImGui::SmallButton("Buy")) {
gameHandler.auctionBuyout(auction.auctionId, auction.buyoutPrice);
}
if (auction.buyoutPrice > 0) ImGui::SameLine();
if (ImGui::SmallButton("Bid")) {
uint32_t bidAmt = auction.currentBid > 0
? auction.currentBid + auction.minBidIncrement
: auction.startBid;
gameHandler.auctionPlaceBid(auction.auctionId, bidAmt);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
ImGui::EndChild();
// Sell section
ImGui::Separator();
ImGui::Text("Sell Item:");
// Item picker from backpack
{
auto& inv = gameHandler.getInventory();
// Build list of non-empty backpack slots
std::string preview = (auctionSellSlotIndex_ >= 0)
? ([&]() -> std::string {
const auto& slot = inv.getBackpackSlot(auctionSellSlotIndex_);
if (!slot.empty()) {
std::string s = slot.item.name;
if (slot.item.stackCount > 1) s += " x" + std::to_string(slot.item.stackCount);
return s;
}
return "Select item...";
})()
: "Select item...";
ImGui::SetNextItemWidth(250);
if (ImGui::BeginCombo("##sellitem", preview.c_str())) {
for (int i = 0; i < game::Inventory::BACKPACK_SLOTS; i++) {
const auto& slot = inv.getBackpackSlot(i);
if (slot.empty()) continue;
ImGui::PushID(i + 9000);
// Item icon
if (slot.item.displayInfoId != 0) {
VkDescriptorSet sIcon = inventoryScreen.getItemIcon(slot.item.displayInfoId);
if (sIcon) {
ImGui::Image((void*)(intptr_t)sIcon, ImVec2(16, 16));
ImGui::SameLine();
}
}
std::string label = slot.item.name;
if (slot.item.stackCount > 1) label += " x" + std::to_string(slot.item.stackCount);
ImVec4 iqc = InventoryScreen::getQualityColor(slot.item.quality);
ImGui::PushStyleColor(ImGuiCol_Text, iqc);
if (ImGui::Selectable(label.c_str(), auctionSellSlotIndex_ == i)) {
auctionSellSlotIndex_ = i;
}
ImGui::PopStyleColor();
ImGui::PopID();
}
ImGui::EndCombo();
}
}
ImGui::Text("Bid:");
ImGui::SameLine();
ImGui::SetNextItemWidth(50);
ImGui::InputInt("##sbg", &auctionSellBid_[0], 0); ImGui::SameLine(); ImGui::Text("g");
ImGui::SameLine();
ImGui::SetNextItemWidth(35);
ImGui::InputInt("##sbs", &auctionSellBid_[1], 0); ImGui::SameLine(); ImGui::Text("s");
ImGui::SameLine();
ImGui::SetNextItemWidth(35);
ImGui::InputInt("##sbc", &auctionSellBid_[2], 0); ImGui::SameLine(); ImGui::Text("c");
ImGui::SameLine(0, 20);
ImGui::Text("Buyout:");
ImGui::SameLine();
ImGui::SetNextItemWidth(50);
ImGui::InputInt("##sbog", &auctionSellBuyout_[0], 0); ImGui::SameLine(); ImGui::Text("g");
ImGui::SameLine();
ImGui::SetNextItemWidth(35);
ImGui::InputInt("##sbos", &auctionSellBuyout_[1], 0); ImGui::SameLine(); ImGui::Text("s");
ImGui::SameLine();
ImGui::SetNextItemWidth(35);
ImGui::InputInt("##sboc", &auctionSellBuyout_[2], 0); ImGui::SameLine(); ImGui::Text("c");
const char* durations[] = {"12 hours", "24 hours", "48 hours"};
ImGui::SetNextItemWidth(90);
ImGui::Combo("##dur", &auctionSellDuration_, durations, 3);
ImGui::SameLine();
// Create Auction button
bool canCreate = auctionSellSlotIndex_ >= 0 &&
!gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_).empty() &&
(auctionSellBid_[0] > 0 || auctionSellBid_[1] > 0 || auctionSellBid_[2] > 0);
if (!canCreate) ImGui::BeginDisabled();
if (ImGui::Button("Create Auction")) {
uint32_t bidCopper = static_cast<uint32_t>(auctionSellBid_[0]) * 10000
+ static_cast<uint32_t>(auctionSellBid_[1]) * 100
+ static_cast<uint32_t>(auctionSellBid_[2]);
uint32_t buyoutCopper = static_cast<uint32_t>(auctionSellBuyout_[0]) * 10000
+ static_cast<uint32_t>(auctionSellBuyout_[1]) * 100
+ static_cast<uint32_t>(auctionSellBuyout_[2]);
const uint32_t durationMins[] = {720, 1440, 2880};
uint32_t dur = durationMins[auctionSellDuration_];
uint64_t itemGuid = gameHandler.getBackpackItemGuid(auctionSellSlotIndex_);
const auto& slot = gameHandler.getInventory().getBackpackSlot(auctionSellSlotIndex_);
uint32_t stackCount = slot.item.stackCount;
if (itemGuid != 0) {
gameHandler.auctionSellItem(itemGuid, stackCount, bidCopper, buyoutCopper, dur);
// Clear sell inputs
auctionSellSlotIndex_ = -1;
auctionSellBid_[0] = auctionSellBid_[1] = auctionSellBid_[2] = 0;
auctionSellBuyout_[0] = auctionSellBuyout_[1] = auctionSellBuyout_[2] = 0;
}
}
if (!canCreate) ImGui::EndDisabled();
} else if (tab == 1) {
// Bids tab
const auto& results = gameHandler.getAuctionBidderResults();
ImGui::Text("Your Bids: %zu items", results.auctions.size());
if (ImGui::BeginTable("BidTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40);
ImGui::TableSetupColumn("Your Bid", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableSetupColumn("##act", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableHeadersRow();
for (size_t bi = 0; bi < results.auctions.size(); bi++) {
const auto& a = results.auctions[bi];
auto* info = gameHandler.getItemInfo(a.itemEntry);
std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry));
game::ItemQuality quality = info ? static_cast<game::ItemQuality>(info->quality) : game::ItemQuality::COMMON;
ImVec4 bqc = InventoryScreen::getQualityColor(quality);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
if (info && info->valid && info->displayInfoId != 0) {
VkDescriptorSet bIcon = inventoryScreen.getItemIcon(info->displayInfoId);
if (bIcon) {
ImGui::Image((void*)(intptr_t)bIcon, ImVec2(16, 16));
ImGui::SameLine();
}
}
ImGui::TextColored(bqc, "%s", name.c_str());
// Tooltip and shift-click
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
ImGui::TableSetColumnIndex(1);
ImGui::Text("%u", a.stackCount);
ImGui::TableSetColumnIndex(2);
ImGui::Text("%ug%us%uc", a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100);
ImGui::TableSetColumnIndex(3);
if (a.buyoutPrice > 0)
ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100);
else
ImGui::TextDisabled("--");
ImGui::TableSetColumnIndex(4);
uint32_t mins = a.timeLeftMs / 60000;
if (mins > 720) ImGui::Text("Long");
else if (mins > 120) ImGui::Text("Medium");
else ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Short");
ImGui::TableSetColumnIndex(5);
ImGui::PushID(static_cast<int>(bi) + 7500);
if (a.buyoutPrice > 0 && ImGui::SmallButton("Buy")) {
gameHandler.auctionBuyout(a.auctionId, a.buyoutPrice);
}
if (a.buyoutPrice > 0) ImGui::SameLine();
if (ImGui::SmallButton("Bid")) {
uint32_t bidAmt = a.currentBid > 0
? a.currentBid + a.minBidIncrement
: a.startBid;
gameHandler.auctionPlaceBid(a.auctionId, bidAmt);
}
ImGui::PopID();
}
ImGui::EndTable();
}
} else if (tab == 2) {
// Auctions tab (your listings)
const auto& results = gameHandler.getAuctionOwnerResults();
ImGui::Text("Your Auctions: %zu items", results.auctions.size());
if (ImGui::BeginTable("OwnerTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Qty", ImGuiTableColumnFlags_WidthFixed, 40);
ImGui::TableSetupColumn("Bid", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("Buyout", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("##cancel", ImGuiTableColumnFlags_WidthFixed, 60);
ImGui::TableHeadersRow();
for (size_t i = 0; i < results.auctions.size(); i++) {
const auto& a = results.auctions[i];
auto* info = gameHandler.getItemInfo(a.itemEntry);
std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry));
game::ItemQuality quality = info ? static_cast<game::ItemQuality>(info->quality) : game::ItemQuality::COMMON;
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImVec4 oqc = InventoryScreen::getQualityColor(quality);
if (info && info->valid && info->displayInfoId != 0) {
VkDescriptorSet oIcon = inventoryScreen.getItemIcon(info->displayInfoId);
if (oIcon) {
ImGui::Image((void*)(intptr_t)oIcon, ImVec2(16, 16));
ImGui::SameLine();
}
}
ImGui::TextColored(oqc, "%s", name.c_str());
if (ImGui::IsItemHovered() && info && info->valid)
inventoryScreen.renderItemTooltip(*info);
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) {
std::string link = buildItemChatLink(info->entry, info->quality, info->name);
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;
}
}
ImGui::TableSetColumnIndex(1);
ImGui::Text("%u", a.stackCount);
ImGui::TableSetColumnIndex(2);
{
uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid;
ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100);
}
ImGui::TableSetColumnIndex(3);
if (a.buyoutPrice > 0)
ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100);
else
ImGui::TextDisabled("--");
ImGui::TableSetColumnIndex(4);
ImGui::PushID(static_cast<int>(i) + 8000);
if (ImGui::SmallButton("Cancel")) {
gameHandler.auctionCancelItem(a.auctionId);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
ImGui::End();
if (!open) gameHandler.closeAuctionHouse();
}
// ============================================================
// Level-Up Ding Animation
// ============================================================
void GameScreen::triggerDing(uint32_t newLevel) {
dingTimer_ = DING_DURATION;
dingLevel_ = newLevel;
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
sfx->playLevelUp();
}
renderer->playEmote("cheer");
}
}
void GameScreen::renderDingEffect() {
if (dingTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
dingTimer_ -= dt;
if (dingTimer_ < 0.0f) dingTimer_ = 0.0f;
// Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s.
// The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2).
constexpr float kFadeTime = 0.5f;
float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f;
if (alpha <= 0.0f) return;
ImGuiIO& io = ImGui::GetIO();
float cx = io.DisplaySize.x * 0.5f;
float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW
ImDrawList* draw = ImGui::GetForegroundDrawList();
ImFont* font = ImGui::GetFont();
float baseSize = ImGui::GetFontSize();
float fontSize = baseSize * 1.8f;
char buf[64];
snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_);
ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf);
float tx = cx - sz.x * 0.5f;
float ty = cy - sz.y * 0.5f;
// Slight black outline for readability
draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2),
IM_COL32(0, 0, 0, (int)(alpha * 180)), buf);
// Gold text
draw->AddText(font, fontSize, ImVec2(tx, ty),
IM_COL32(255, 210, 0, (int)(alpha * 255)), buf);
}
void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) {
achievementToastId_ = achievementId;
achievementToastName_ = std::move(name);
achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION;
// Play a UI sound if available
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
sfx->playAchievementAlert();
}
}
}
void GameScreen::renderAchievementToast() {
if (achievementToastTimer_ <= 0.0f) return;
float dt = ImGui::GetIO().DeltaTime;
achievementToastTimer_ -= dt;
if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.0f;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Slide in from the right — fully visible for most of the duration, slides out at end
constexpr float SLIDE_TIME = 0.4f;
float slideIn = std::min(achievementToastTimer_, ACHIEVEMENT_TOAST_DURATION - achievementToastTimer_);
float slideFrac = (ACHIEVEMENT_TOAST_DURATION > 0.0f && SLIDE_TIME > 0.0f)
? std::min(slideIn / SLIDE_TIME, 1.0f)
: 1.0f;
constexpr float TOAST_W = 280.0f;
constexpr float TOAST_H = 60.0f;
float xFull = screenW - TOAST_W - 20.0f;
float xHidden = screenW + 10.0f;
float toastX = xHidden + (xFull - xHidden) * slideFrac;
float toastY = screenH - TOAST_H - 80.0f; // above action bar area
float alpha = std::min(1.0f, achievementToastTimer_ / 0.5f); // fade at very end
ImDrawList* draw = ImGui::GetForegroundDrawList();
// Background panel (gold border, dark fill)
ImVec2 tl(toastX, toastY);
ImVec2 br(toastX + TOAST_W, toastY + TOAST_H);
draw->AddRectFilled(tl, br, IM_COL32(30, 20, 10, (int)(alpha * 230)), 6.0f);
draw->AddRect(tl, br, IM_COL32(200, 170, 50, (int)(alpha * 255)), 6.0f, 0, 2.0f);
// Title
ImFont* font = ImGui::GetFont();
float titleSize = 14.0f;
float bodySize = 12.0f;
const char* title = "Achievement Earned!";
float titleW = font->CalcTextSizeA(titleSize, FLT_MAX, 0.0f, title).x;
float titleX = toastX + (TOAST_W - titleW) * 0.5f;
draw->AddText(font, titleSize, ImVec2(titleX + 1, toastY + 8 + 1),
IM_COL32(0, 0, 0, (int)(alpha * 180)), title);
draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8),
IM_COL32(255, 215, 0, (int)(alpha * 255)), title);
// Achievement name (falls back to ID if name not available)
char idBuf[256];
const char* achText = achievementToastName_.empty()
? nullptr : achievementToastName_.c_str();
if (achText) {
std::snprintf(idBuf, sizeof(idBuf), "%s", achText);
} else {
std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_);
}
float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x;
float idX = toastX + (TOAST_W - idW) * 0.5f;
draw->AddText(font, bodySize, ImVec2(idX, toastY + 28),
IM_COL32(220, 200, 150, (int)(alpha * 255)), idBuf);
}
// ---------------------------------------------------------------------------
// Zone discovery text — "Entering: <ZoneName>" fades in/out at screen centre
// ---------------------------------------------------------------------------
void GameScreen::renderZoneText() {
// Poll the renderer for zone name changes
auto* appRenderer = core::Application::getInstance().getRenderer();
if (appRenderer) {
const std::string& zoneName = appRenderer->getCurrentZoneName();
if (!zoneName.empty() && zoneName != lastKnownZoneName_) {
lastKnownZoneName_ = zoneName;
zoneTextName_ = zoneName;
zoneTextTimer_ = ZONE_TEXT_DURATION;
}
}
if (zoneTextTimer_ <= 0.0f || zoneTextName_.empty()) return;
float dt = ImGui::GetIO().DeltaTime;
zoneTextTimer_ -= dt;
if (zoneTextTimer_ < 0.0f) zoneTextTimer_ = 0.0f;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
// Fade: ramp up in first 0.5 s, hold, fade out in last 1.0 s
float alpha;
if (zoneTextTimer_ > ZONE_TEXT_DURATION - 0.5f)
alpha = 1.0f - (zoneTextTimer_ - (ZONE_TEXT_DURATION - 0.5f)) / 0.5f;
else if (zoneTextTimer_ < 1.0f)
alpha = zoneTextTimer_;
else
alpha = 1.0f;
alpha = std::clamp(alpha, 0.0f, 1.0f);
ImFont* font = ImGui::GetFont();
// "Entering:" header
const char* header = "Entering:";
float headerSize = 16.0f;
float nameSize = 26.0f;
ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header);
ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, zoneTextName_.c_str());
float centreY = screenH * 0.30f; // upper third, like WoW
float headerX = (screenW - headerDim.x) * 0.5f;
float nameX = (screenW - nameDim.x) * 0.5f;
float headerY = centreY;
float nameY = centreY + headerDim.y + 4.0f;
ImDrawList* draw = ImGui::GetForegroundDrawList();
// "Entering:" in gold
draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1),
IM_COL32(0, 0, 0, (int)(alpha * 160)), header);
draw->AddText(font, headerSize, ImVec2(headerX, headerY),
IM_COL32(255, 215, 0, (int)(alpha * 255)), header);
// Zone name in white
draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1),
IM_COL32(0, 0, 0, (int)(alpha * 160)), zoneTextName_.c_str());
draw->AddText(font, nameSize, ImVec2(nameX, nameY),
IM_COL32(255, 255, 255, (int)(alpha * 255)), zoneTextName_.c_str());
}
// ---------------------------------------------------------------------------
// Dungeon Finder window (toggle with hotkey or bag-bar button)
// ---------------------------------------------------------------------------
void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) {
// Toggle Dungeon Finder (customizable keybind)
if (!chatInputActive && KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) {
showDungeonFinder_ = !showDungeonFinder_;
}
if (!showDungeonFinder_) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 175.0f, screenH * 0.2f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always);
bool open = true;
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize;
if (!ImGui::Begin("Dungeon Finder", &open, flags)) {
ImGui::End();
if (!open) showDungeonFinder_ = false;
return;
}
if (!open) {
ImGui::End();
showDungeonFinder_ = false;
return;
}
using LfgState = game::GameHandler::LfgState;
LfgState state = gameHandler.getLfgState();
// ---- Status banner ----
switch (state) {
case LfgState::None:
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Status: Not queued");
break;
case LfgState::RoleCheck:
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Status: Role check in progress...");
break;
case LfgState::Queued: {
int32_t avgSec = gameHandler.getLfgAvgWaitSec();
uint32_t qMs = gameHandler.getLfgTimeInQueueMs();
int qMin = static_cast<int>(qMs / 60000);
int qSec = static_cast<int>((qMs % 60000) / 1000);
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec);
if (avgSec >= 0) {
int aMin = avgSec / 60;
int aSec = avgSec % 60;
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f),
"Avg wait: %d:%02d", aMin, aSec);
}
break;
}
case LfgState::Proposal:
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!");
break;
case LfgState::Boot:
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress");
break;
case LfgState::InDungeon:
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon");
break;
case LfgState::FinishedDungeon:
ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete");
break;
case LfgState::RaidBrowser:
ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser");
break;
}
ImGui::Separator();
// ---- Proposal accept/decline ----
if (state == LfgState::Proposal) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f),
"A group has been found for your dungeon!");
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(120, 0))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true);
}
ImGui::SameLine();
if (ImGui::Button("Decline", ImVec2(120, 0))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false);
}
ImGui::Separator();
}
// ---- Vote-to-kick buttons ----
if (state == LfgState::Boot) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:");
uint32_t bootVotes = gameHandler.getLfgBootVotes();
uint32_t bootTotal = gameHandler.getLfgBootTotal();
uint32_t bootNeeded = gameHandler.getLfgBootNeeded();
uint32_t bootTimeLeft= gameHandler.getLfgBootTimeLeft();
if (bootNeeded > 0) {
ImGui::Text("Votes: %u / %u (need %u) %us left",
bootVotes, bootTotal, bootNeeded, bootTimeLeft);
}
ImGui::Spacing();
if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) {
gameHandler.lfgSetBootVote(true);
}
ImGui::SameLine();
if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) {
gameHandler.lfgSetBootVote(false);
}
ImGui::Separator();
}
// ---- Teleport button (in dungeon) ----
if (state == LfgState::InDungeon) {
if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) {
gameHandler.lfgTeleport(true);
}
ImGui::Separator();
}
// ---- Role selection (only when not queued/in dungeon) ----
bool canConfigure = (state == LfgState::None || state == LfgState::FinishedDungeon);
if (canConfigure) {
ImGui::Text("Role:");
ImGui::SameLine();
bool isTank = (lfgRoles_ & 0x02) != 0;
bool isHealer = (lfgRoles_ & 0x04) != 0;
bool isDps = (lfgRoles_ & 0x08) != 0;
if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0);
ImGui::SameLine();
if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0);
ImGui::SameLine();
if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0);
ImGui::Spacing();
// ---- Dungeon selection ----
ImGui::Text("Dungeon:");
struct DungeonEntry { uint32_t id; const char* name; };
static const DungeonEntry kDungeons[] = {
{ 861, "Random Dungeon" },
{ 862, "Random Heroic" },
// Vanilla classics
{ 36, "Deadmines" },
{ 43, "Ragefire Chasm" },
{ 47, "Razorfen Kraul" },
{ 48, "Blackfathom Deeps" },
{ 52, "Uldaman" },
{ 57, "Dire Maul: East" },
{ 70, "Onyxia's Lair" },
// TBC heroics
{ 264, "The Blood Furnace" },
{ 269, "The Shattered Halls" },
// WotLK normals/heroics
{ 576, "The Nexus" },
{ 578, "The Oculus" },
{ 595, "The Culling of Stratholme" },
{ 599, "Halls of Stone" },
{ 600, "Drak'Tharon Keep" },
{ 601, "Azjol-Nerub" },
{ 604, "Gundrak" },
{ 608, "Violet Hold" },
{ 619, "Ahn'kahet: Old Kingdom" },
{ 623, "Halls of Lightning" },
{ 632, "The Forge of Souls" },
{ 650, "Trial of the Champion" },
{ 658, "Pit of Saron" },
{ 668, "Halls of Reflection" },
};
// Find current index
int curIdx = 0;
for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) {
if (kDungeons[i].id == lfgSelectedDungeon_) { curIdx = i; break; }
}
ImGui::SetNextItemWidth(-1);
if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) {
for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) {
bool selected = (kDungeons[i].id == lfgSelectedDungeon_);
if (ImGui::Selectable(kDungeons[i].name, selected))
lfgSelectedDungeon_ = kDungeons[i].id;
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::Spacing();
// ---- Join button ----
bool rolesOk = (lfgRoles_ != 0);
if (!rolesOk) {
ImGui::BeginDisabled();
}
if (ImGui::Button("Join Dungeon Finder", ImVec2(-1, 0))) {
gameHandler.lfgJoin(lfgSelectedDungeon_, lfgRoles_);
}
if (!rolesOk) {
ImGui::EndDisabled();
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Select at least one role.");
}
}
// ---- Leave button (when queued or role check) ----
if (state == LfgState::Queued || state == LfgState::RoleCheck) {
if (ImGui::Button("Leave Queue", ImVec2(-1, 0))) {
gameHandler.lfgLeave();
}
}
ImGui::End();
}
// ============================================================
// Instance Lockouts
// ============================================================
void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) {
if (!showInstanceLockouts_) return;
ImGui::SetNextWindowSize(ImVec2(480, 0), ImGuiCond_Appearing);
ImGui::SetNextWindowPos(
ImVec2(ImGui::GetIO().DisplaySize.x / 2 - 240, 140), ImGuiCond_Appearing);
if (!ImGui::Begin("Instance Lockouts", &showInstanceLockouts_,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::End();
return;
}
const auto& lockouts = gameHandler.getInstanceLockouts();
if (lockouts.empty()) {
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts.");
} else {
// Build map name lookup from Map.dbc (cached after first call)
static std::unordered_map<uint32_t, std::string> sMapNames;
static bool sMapNamesLoaded = false;
if (!sMapNamesLoaded) {
sMapNamesLoaded = true;
if (auto* am = core::Application::getInstance().getAssetManager()) {
if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) {
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t id = dbc->getUInt32(i, 0);
// Field 2 = MapName_enUS (first localized), field 1 = InternalName
std::string name = dbc->getString(i, 2);
if (name.empty()) name = dbc->getString(i, 1);
if (!name.empty()) sMapNames[id] = std::move(name);
}
}
}
}
auto difficultyLabel = [](uint32_t diff) -> const char* {
switch (diff) {
case 0: return "Normal";
case 1: return "Heroic";
case 2: return "25-Man";
case 3: return "25-Man Heroic";
default: return "Unknown";
}
};
// Current UTC time for reset countdown
auto nowSec = static_cast<uint64_t>(std::time(nullptr));
if (ImGui::BeginTable("lockouts", 4,
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter)) {
ImGui::TableSetupColumn("Instance", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Difficulty", ImGuiTableColumnFlags_WidthFixed, 110.0f);
ImGui::TableSetupColumn("Resets In", ImGuiTableColumnFlags_WidthFixed, 100.0f);
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 60.0f);
ImGui::TableHeadersRow();
for (const auto& lo : lockouts) {
ImGui::TableNextRow();
// Instance name
ImGui::TableSetColumnIndex(0);
auto it = sMapNames.find(lo.mapId);
if (it != sMapNames.end()) {
ImGui::TextUnformatted(it->second.c_str());
} else {
ImGui::Text("Map %u", lo.mapId);
}
// Difficulty
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(difficultyLabel(lo.difficulty));
// Reset countdown
ImGui::TableSetColumnIndex(2);
if (lo.resetTime > nowSec) {
uint64_t remaining = lo.resetTime - nowSec;
uint64_t days = remaining / 86400;
uint64_t hours = (remaining % 86400) / 3600;
if (days > 0) {
ImGui::Text("%llud %lluh",
static_cast<unsigned long long>(days),
static_cast<unsigned long long>(hours));
} else {
uint64_t mins = (remaining % 3600) / 60;
ImGui::Text("%lluh %llum",
static_cast<unsigned long long>(hours),
static_cast<unsigned long long>(mins));
}
} else {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Expired");
}
// Locked / Extended status
ImGui::TableSetColumnIndex(3);
if (lo.extended) {
ImGui::TextColored(ImVec4(0.3f, 0.7f, 1.0f, 1.0f), "Ext");
} else if (lo.locked) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Locked");
} else {
ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Open");
}
}
ImGui::EndTable();
}
}
ImGui::End();
}
// ============================================================================
// Battleground score frame
//
// Displays the current score for the player's battleground using world states.
// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has
// been received for a known BG map. The layout adapts per battleground:
//
// WSG 489 Alliance / Horde flag captures (max 3)
// AB 529 Alliance / Horde resource scores (max 1600)
// AV 30 Alliance / Horde reinforcements
// EotS 566 Alliance / Horde resource scores (max 1600)
// ============================================================================
void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) {
// Only show when in a recognised battleground map
uint32_t mapId = gameHandler.getWorldStateMapId();
// World state key sets per battleground
// Keys from the WoW 3.3.5a WorldState.dbc / client source
struct BgScoreDef {
uint32_t mapId;
const char* name;
uint32_t allianceKey; // world state key for Alliance value
uint32_t hordeKey; // world state key for Horde value
uint32_t maxKey; // max score world state key (0 = use hardcoded)
uint32_t hardcodedMax; // used when maxKey == 0
const char* unit; // suffix label (e.g. "flags", "resources")
};
static constexpr BgScoreDef kBgDefs[] = {
// Warsong Gulch: 3 flag captures wins
{ 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" },
// Arathi Basin: 1600 resources wins
{ 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" },
// Alterac Valley: reinforcements count down from 600 / 800 etc.
{ 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" },
// Eye of the Storm: 1600 resources wins
{ 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" },
// Strand of the Ancients (WotLK)
{ 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" },
};
const BgScoreDef* def = nullptr;
for (const auto& d : kBgDefs) {
if (d.mapId == mapId) { def = &d; break; }
}
if (!def) return;
auto allianceOpt = gameHandler.getWorldState(def->allianceKey);
auto hordeOpt = gameHandler.getWorldState(def->hordeKey);
if (!allianceOpt && !hordeOpt) return;
uint32_t allianceScore = allianceOpt.value_or(0);
uint32_t hordeScore = hordeOpt.value_or(0);
uint32_t maxScore = def->hardcodedMax;
if (def->maxKey != 0) {
if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv;
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Width scales with screen but stays reasonable
float frameW = 260.0f;
float frameH = 60.0f;
float posX = screenW / 2.0f - frameW / 2.0f;
float posY = 4.0f;
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.75f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f));
if (ImGui::Begin("##BGScore", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoSavedSettings)) {
// BG name centred at top
float nameW = ImGui::CalcTextSize(def->name).x;
ImGui::SetCursorPosX((frameW - nameW) / 2.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "%s", def->name);
// Alliance score | separator | Horde score
float innerW = frameW - 12.0f;
float halfW = innerW / 2.0f - 4.0f;
ImGui::SetCursorPosX(6.0f);
ImGui::BeginGroup();
{
// Alliance (blue)
char aBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore);
else
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore);
ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "%s", aBuf);
}
ImGui::EndGroup();
ImGui::SameLine(halfW + 16.0f);
ImGui::BeginGroup();
{
// Horde (red)
char hBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore);
else
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore);
ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "%s", hBuf);
}
ImGui::EndGroup();
}
ImGui::End();
ImGui::PopStyleVar(2);
}
// ─── Achievement Window ───────────────────────────────────────────────────────
void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) {
if (!showAchievementWindow_) return;
ImGui::SetNextWindowSize(ImVec2(420, 480), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(200, 150), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Achievements", &showAchievementWindow_)) {
ImGui::End();
return;
}
const auto& earned = gameHandler.getEarnedAchievements();
const auto& criteria = gameHandler.getCriteriaProgress();
ImGui::SetNextItemWidth(180.0f);
ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_));
ImGui::SameLine();
if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0';
ImGui::Separator();
std::string filter(achievementSearchBuf_);
for (char& c : filter) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (ImGui::BeginTabBar("##achtabs")) {
// --- Earned tab ---
char earnedLabel[32];
snprintf(earnedLabel, sizeof(earnedLabel), "Earned (%u)###earned", (unsigned)earned.size());
if (ImGui::BeginTabItem(earnedLabel)) {
if (earned.empty()) {
ImGui::TextDisabled("No achievements earned yet.");
} else {
ImGui::BeginChild("##achlist", ImVec2(0, 0), false);
std::vector<uint32_t> ids(earned.begin(), earned.end());
std::sort(ids.begin(), ids.end());
for (uint32_t id : ids) {
const std::string& name = gameHandler.getAchievementName(id);
const std::string& display = name.empty() ? std::to_string(id) : name;
if (!filter.empty()) {
std::string lower = display;
for (char& c : lower) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (lower.find(filter) == std::string::npos) continue;
}
ImGui::PushID(static_cast<int>(id));
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\xE2\x98\x85");
ImGui::SameLine();
ImGui::TextUnformatted(display.c_str());
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::Text("ID: %u", id);
ImGui::EndTooltip();
}
ImGui::PopID();
}
ImGui::EndChild();
}
ImGui::EndTabItem();
}
// --- Criteria progress tab ---
char critLabel[32];
snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size());
if (ImGui::BeginTabItem(critLabel)) {
if (criteria.empty()) {
ImGui::TextDisabled("No criteria progress received yet.");
} else {
ImGui::BeginChild("##critlist", ImVec2(0, 0), false);
// Sort criteria by id for stable display
std::vector<std::pair<uint32_t, uint64_t>> clist(criteria.begin(), criteria.end());
std::sort(clist.begin(), clist.end());
for (const auto& [cid, cval] : clist) {
std::string label = std::to_string(cid);
if (!filter.empty()) {
std::string lower = label;
for (char& c : lower) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (lower.find(filter) == std::string::npos) continue;
}
ImGui::PushID(static_cast<int>(cid));
ImGui::TextDisabled("Criteria %u:", cid);
ImGui::SameLine();
ImGui::Text("%llu", static_cast<unsigned long long>(cval));
ImGui::PopID();
}
ImGui::EndChild();
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::End();
}
// ─── GM Ticket Window ─────────────────────────────────────────────────────────
void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
if (!showGmTicketWindow_) return;
ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::End();
return;
}
ImGui::TextWrapped("Describe your issue and a Game Master will contact you.");
ImGui::Spacing();
ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_),
ImVec2(-1, 160));
ImGui::Spacing();
bool hasText = (gmTicketBuf_[0] != '\0');
if (!hasText) ImGui::BeginDisabled();
if (ImGui::Button("Submit Ticket", ImVec2(160, 0))) {
gameHandler.submitGmTicket(gmTicketBuf_);
gmTicketBuf_[0] = '\0';
showGmTicketWindow_ = false;
}
if (!hasText) ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(80, 0))) {
showGmTicketWindow_ = false;
}
ImGui::SameLine();
if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) {
gameHandler.deleteGmTicket();
}
ImGui::End();
}
// ─── Threat Window ────────────────────────────────────────────────────────────
void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) {
if (!showThreatWindow_) return;
const auto* list = gameHandler.getTargetThreatList();
ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowBgAlpha(0.85f);
if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::End();
return;
}
if (!list || list->empty()) {
ImGui::TextDisabled("No threat data for current target.");
ImGui::End();
return;
}
uint32_t maxThreat = list->front().threat;
ImGui::TextDisabled("%-19s Threat", "Player");
ImGui::Separator();
uint64_t playerGuid = gameHandler.getPlayerGuid();
int rank = 0;
for (const auto& entry : *list) {
++rank;
bool isPlayer = (entry.victimGuid == playerGuid);
// Resolve name
std::string victimName;
auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid);
if (entity) {
if (entity->getType() == game::ObjectType::PLAYER) {
auto p = std::static_pointer_cast<game::Player>(entity);
victimName = p->getName().empty() ? "Player" : p->getName();
} else if (entity->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(entity);
victimName = u->getName().empty() ? "NPC" : u->getName();
}
}
if (victimName.empty())
victimName = "0x" + [&](){
char buf[20]; snprintf(buf, sizeof(buf), "%llX",
static_cast<unsigned long long>(entry.victimGuid)); return std::string(buf); }();
// Colour: gold for #1 (tank), red if player is highest, white otherwise
ImVec4 col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
if (rank == 1) col = ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // gold
if (isPlayer && rank == 1) col = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // red — you have aggro
// Threat bar
float pct = (maxThreat > 0) ? (float)entry.threat / (float)maxThreat : 0.0f;
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f));
char barLabel[48];
snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f);
ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat);
if (rank >= 10) break; // cap display at 10 entries
}
ImGui::End();
}
// ─── Quest Objective Tracker ──────────────────────────────────────────────────
void GameScreen::renderObjectiveTracker(game::GameHandler& gameHandler) {
if (gameHandler.getState() != game::WorldState::IN_WORLD) return;
const auto& questLog = gameHandler.getQuestLog();
const auto& tracked = gameHandler.getTrackedQuestIds();
// Collect quests to show: tracked ones first, then in-progress quests up to a max of 5 total.
std::vector<const game::GameHandler::QuestLogEntry*> toShow;
for (const auto& q : questLog) {
if (q.questId == 0) continue;
if (tracked.count(q.questId)) toShow.push_back(&q);
}
if (toShow.empty()) {
// No explicitly tracked quests — show up to 5 in-progress quests
for (const auto& q : questLog) {
if (q.questId == 0) continue;
if (!tracked.count(q.questId)) toShow.push_back(&q);
if (toShow.size() >= 5) break;
}
}
if (toShow.empty()) return;
ImVec2 display = ImGui::GetIO().DisplaySize;
float screenW = display.x > 0.0f ? display.x : 1280.0f;
float trackerW = 220.0f;
float trackerX = screenW - trackerW - 12.0f;
float trackerY = 230.0f; // below minimap
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoFocusOnAppearing;
ImGui::SetNextWindowPos(ImVec2(trackerX, trackerY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(trackerW, 0.0f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.5f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f));
if (ImGui::Begin("##ObjectiveTracker", nullptr, flags)) {
for (const auto* q : toShow) {
// Quest title
ImVec4 titleColor = q->complete ? ImVec4(0.45f, 1.0f, 0.45f, 1.0f)
: ImVec4(1.0f, 0.84f, 0.0f, 1.0f);
std::string titleStr = q->title.empty()
? ("Quest #" + std::to_string(q->questId)) : q->title;
// Truncate to fit
if (titleStr.size() > 26) { titleStr.resize(23); titleStr += "..."; }
ImGui::TextColored(titleColor, "%s", titleStr.c_str());
// Kill/entity objectives
bool hasObjectives = false;
for (const auto& ko : q->killObjectives) {
if (ko.npcOrGoId == 0 || ko.required == 0) continue;
hasObjectives = true;
uint32_t entry = (uint32_t)std::abs(ko.npcOrGoId);
auto it = q->killCounts.find(entry);
uint32_t cur = it != q->killCounts.end() ? it->second.first : 0;
std::string name = gameHandler.getCachedCreatureName(entry);
if (name.empty()) {
if (ko.npcOrGoId < 0) {
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
if (goInfo) name = goInfo->name;
}
if (name.empty()) name = "Objective";
}
if (name.size() > 20) { name.resize(17); name += "..."; }
bool done = (cur >= ko.required);
ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f);
ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, ko.required);
}
// Item objectives
for (const auto& io : q->itemObjectives) {
if (io.itemId == 0 || io.required == 0) continue;
hasObjectives = true;
auto it = q->itemCounts.find(io.itemId);
uint32_t cur = it != q->itemCounts.end() ? it->second : 0;
std::string name;
if (const auto* info = gameHandler.getItemInfo(io.itemId)) name = info->name;
if (name.empty()) name = "Item #" + std::to_string(io.itemId);
if (name.size() > 20) { name.resize(17); name += "..."; }
bool done = (cur >= io.required);
ImVec4 c = done ? ImVec4(0.5f, 0.9f, 0.5f, 1.0f) : ImVec4(0.75f, 0.75f, 0.75f, 1.0f);
ImGui::TextColored(c, " %s: %u/%u", name.c_str(), cur, io.required);
}
if (!hasObjectives && q->complete) {
ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), " Ready to turn in!");
}
ImGui::Dummy(ImVec2(0.0f, 2.0f));
}
}
ImGui::End();
ImGui::PopStyleVar(2);
}
// ─── Inspect Window ───────────────────────────────────────────────────────────
void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) {
if (!showInspectWindow_) return;
// Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server)
static const char* kSlotNames[19] = {
"Head", "Neck", "Shoulder", "Shirt", "Chest",
"Waist", "Legs", "Feet", "Wrist", "Hands",
"Finger 1", "Finger 2", "Trinket 1", "Trinket 2", "Back",
"Main Hand", "Off Hand", "Ranged", "Tabard"
};
ImGui::SetNextWindowSize(ImVec2(360, 440), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(350, 120), ImGuiCond_FirstUseEver);
const game::GameHandler::InspectResult* result = gameHandler.getInspectResult();
std::string title = result ? ("Inspect: " + result->playerName + "###InspectWin")
: "Inspect###InspectWin";
if (!ImGui::Begin(title.c_str(), &showInspectWindow_, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;
}
if (!result) {
ImGui::TextDisabled("No inspect data yet. Target a player and use Inspect.");
ImGui::End();
return;
}
// Talent summary
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.82f, 0.0f, 1.0f)); // gold
ImGui::Text("%s", result->playerName.c_str());
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled(" %u talent pts", result->totalTalents);
if (result->unspentTalents > 0) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "(%u unspent)", result->unspentTalents);
}
if (result->talentGroups > 1) {
ImGui::SameLine();
ImGui::TextDisabled(" Dual spec (active %u)", (unsigned)result->activeTalentGroup + 1);
}
ImGui::Separator();
// Equipment list
bool hasAnyGear = false;
for (int s = 0; s < 19; ++s) {
if (result->itemEntries[s] != 0) { hasAnyGear = true; break; }
}
if (!hasAnyGear) {
ImGui::TextDisabled("Equipment data not yet available.");
ImGui::TextDisabled("(Gear loads after the player is inspected in-range)");
} else {
if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) {
constexpr float kIconSz = 28.0f;
for (int s = 0; s < 19; ++s) {
uint32_t entry = result->itemEntries[s];
if (entry == 0) continue;
const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry);
if (!info) {
gameHandler.ensureItemInfo(entry);
ImGui::PushID(s);
ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]);
ImGui::PopID();
continue;
}
ImGui::PushID(s);
auto qColor = InventoryScreen::getQualityColor(
static_cast<game::ItemQuality>(info->quality));
// Item icon
VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId);
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz),
ImVec2(0,0), ImVec2(1,1),
ImVec4(1,1,1,1), qColor);
} else {
ImGui::GetWindowDrawList()->AddRectFilled(
ImGui::GetCursorScreenPos(),
ImVec2(ImGui::GetCursorScreenPos().x + kIconSz,
ImGui::GetCursorScreenPos().y + kIconSz),
IM_COL32(40, 40, 50, 200));
ImGui::Dummy(ImVec2(kIconSz, kIconSz));
}
bool hovered = ImGui::IsItemHovered();
ImGui::SameLine();
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f);
ImGui::BeginGroup();
ImGui::TextDisabled("%s", kSlotNames[s]);
ImGui::TextColored(qColor, "%s", info->name.c_str());
ImGui::EndGroup();
hovered = hovered || ImGui::IsItemHovered();
if (hovered && info->valid) {
inventoryScreen.renderItemTooltip(*info);
} else if (hovered) {
ImGui::SetTooltip("%s", info->name.c_str());
}
ImGui::PopID();
ImGui::Spacing();
}
}
ImGui::EndChild();
}
ImGui::End();
}
}} // namespace wowee::ui