#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" #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 "game/character.hpp" #include "core/logger.hpp" #include #include #include #include #include #include #include #include #include #include #include #include namespace { // Build a WoW-format item link string for chat insertion. // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|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(std::tolower(c)); }); return s; } // Render gold/silver/copper amounts in WoW-canonical colors on the current ImGui line. // Skips zero-value denominations (except copper, which is always shown when gold=silver=0). void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { bool any = false; if (g > 0) { ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); any = true; } if (s > 0 || g > 0) { if (any) ImGui::SameLine(0, 3); ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); any = true; } if (any) ImGui::SameLine(0, 3); ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); } // Return the canonical Blizzard class color as ImVec4. // classId is byte 1 of UNIT_FIELD_BYTES_0 (or CharacterData::classId). // Returns a neutral light-gray for unknown / class 0. ImVec4 classColorVec4(uint8_t classId) { switch (classId) { case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); // unknown } } // ImU32 variant with alpha in [0,255]. ImU32 classColorU32(uint8_t classId, int alpha = 255) { ImVec4 c = classColorVec4(classId); return IM_COL32(static_cast(c.x * 255), static_cast(c.y * 255), static_cast(c.z * 255), alpha); } // Extract class id from a unit's UNIT_FIELD_BYTES_0 update field. // Returns 0 if the entity pointer is null or field is unset. uint8_t entityClassId(const wowee::game::Entity* entity) { if (!entity) return 0; using UF = wowee::game::UF; uint32_t bytes0 = entity->getField(wowee::game::fieldIndex(UF::UNIT_FIELD_BYTES_0)); return static_cast((bytes0 >> 8) & 0xFF); } // Return the English class name for a class ID (1-11), or "Unknown". const char* classNameStr(uint8_t classId) { static const char* kNames[] = { "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", "Death Knight","Shaman","Mage","Warlock","","Druid" }; return (classId < 12) ? kNames[classId] : "Unknown"; } 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& entity) { if (entity->getType() == wowee::game::ObjectType::PLAYER) { auto player = std::static_pointer_cast(entity); if (!player->getName().empty()) return player->getName(); } else if (entity->getType() == wowee::game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); if (!unit->getName().empty()) return unit->getName(); } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(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", ~0ULL}); // Combat tab: system, loot, skills, achievements, and NPC speech/emotes chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | (1ULL << static_cast(game::ChatType::LOOT)) | (1ULL << static_cast(game::ChatType::SKILL)) | (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); // Whispers tab chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); // Guild tab: guild and officer chat chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | (1ULL << static_cast(game::ChatType::OFFICER)) | (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); // Trade/LFG tab: channel messages chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); // Reset unread counts to match new tab list chatTabUnread_.assign(chatTabs_.size(), 0); chatTabSeenCount_ = 0; } bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; const auto& tab = chatTabs_[tabIndex]; if (tab.typeMask == ~0ULL) return true; // General tab shows all uint64_t typeBit = 1ULL << static_cast(msg.type); // For Trade/LFG tab (now index 4), also filter by channel name if (tabIndex == 4 && 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(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; } // Set up quest completion toast callback (once) if (!questCompleteCallbackSet_) { gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { questCompleteToasts_.push_back({id, title, 0.0f}); if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); }); questCompleteCallbackSet_ = 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(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(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(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); // Zone entry detection — fire a toast when the renderer's zone name changes if (auto* rend = core::Application::getInstance().getRenderer()) { const std::string& curZone = rend->getCurrentZoneName(); if (!curZone.empty() && curZone != lastKnownZone_) { if (!lastKnownZone_.empty()) { // Genuine zone change (not first entry) zoneToasts_.push_back({curZone, 0.0f}); if (zoneToasts_.size() > 3) zoneToasts_.erase(zoneToasts_.begin()); } lastKnownZone_ = curZone; } } // 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); } // Totem frame (Shaman only, when any totem is active) if (gameHandler.getPlayerClass() == 7) { renderTotemFrame(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); renderRepBar(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); renderDurabilityWarning(gameHandler); renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); renderRepToasts(ImGui::GetIO().DeltaTime); renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); renderZoneToasts(ImGui::GetIO().DeltaTime); renderAreaTriggerToasts(ImGui::GetIO().DeltaTime, gameHandler); if (showRaidFrames_) { renderPartyFrames(gameHandler); } renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); renderDuelCountdown(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); renderWhoWindow(gameHandler); renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderThreatWindow(gameHandler); renderBgScoreboard(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(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(mobLv) - static_cast(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(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(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); } } // Persistent low-health vignette — pulsing red edges when HP < 20% { auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); bool isDead = gameHandler.isPlayerDead(); float hpPct = 1.0f; if (!isDead && playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { auto unit = std::static_pointer_cast(playerEntity); if (unit->getMaxHealth() > 0) hpPct = static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()); } // Only show when alive and below 20% HP; intensity increases as HP drops if (lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz float danger = (0.20f - hpPct) / 0.20f; float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); int alpha = static_cast(danger * pulse * 90.0f); // max ~90 alpha, subtle if (alpha > 0) { ImDrawList* fg = ImGui::GetForegroundDrawList(); ImGuiIO& io = ImGui::GetIO(); const float W = io.DisplaySize.x; const float H = io.DisplaySize.y; const float thickness = std::min(W, H) * 0.15f; const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), edgeCol, edgeCol, fadeCol, fadeCol); fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), fadeCol, fadeCol, edgeCol, edgeCol); fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), edgeCol, fadeCol, fadeCol, edgeCol); 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(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(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(entity); ImGui::Text("%s", player->getName().c_str()); } else if (entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(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(); auto* assetMgr = core::Application::getInstance().getAssetManager(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(); } // Update unread counts: scan any new messages since last frame { const auto& history = gameHandler.getChatHistory(); // Ensure unread array is sized correctly (guards against late init) if (chatTabUnread_.size() != chatTabs_.size()) chatTabUnread_.assign(chatTabs_.size(), 0); // If history shrank (e.g. cleared), reset if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { const auto& msg = history[mi]; // For each non-General (non-0) tab that isn't currently active, check visibility for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { if (ti == activeChatTab_) continue; if (shouldShowMessage(msg, ti)) { chatTabUnread_[ti]++; } } } chatTabSeenCount_ = history.size(); } // Chat tabs if (ImGui::BeginTabBar("ChatTabs")) { for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { // Build label with unread count suffix for non-General tabs std::string tabLabel = chatTabs_[i].name; if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; } // Use ImGuiTabItemFlags_NoPushId so label changes don't break tab identity if (ImGui::BeginTabItem(tabLabel.c_str())) { if (activeChatTab_ != i) { activeChatTab_ = i; // Clear unread count when tab becomes active if (i < static_cast(chatTabUnread_.size())) chatTabUnread_[i] = 0; } 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(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(static_cast(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(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::TextDisabled("Sell:"); ImGui::SameLine(0, 4); renderCoinsText(g, s, c); } if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { if (const auto* eq = findComparableEquipped(static_cast(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(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 link (may be colored with |c prefix or bare |H) size_t linkStart = text.find("|c", pos); // Also handle bare |H links without color prefix size_t bareItem = text.find("|Hitem:", pos); size_t bareSpell = text.find("|Hspell:", pos); size_t bareQuest = text.find("|Hquest:", pos); size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); // 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); // Find the nearest |H link of any supported type size_t hItem = text.find("|Hitem:", linkStart + 10); size_t hSpell = text.find("|Hspell:", linkStart + 10); size_t hQuest = text.find("|Hquest:", linkStart + 10); size_t hAch = text.find("|Hachievement:", linkStart + 10); hStart = std::min({hItem, hSpell, hQuest, hAch}); } else if (nextSpecial == bareLinkStart) { hStart = bareLinkStart; } if (hStart != std::string::npos) { // Determine link type const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); // Default: item link // Parse the first numeric ID after |Htype: size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); size_t entryStart = hStart + idOffset; size_t entryEnd = text.find(':', entryStart); uint32_t linkId = 0; if (entryEnd != std::string::npos) { linkId = static_cast(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 linkName = isSpellLink ? "Unknown Spell" : isQuestLink ? "Unknown Quest" : isAchievLink ? "Unknown Achievement" : "Unknown Item"; if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { linkName = 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 + idOffset; size_t resetPos = text.find("|r", linkEnd); if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { linkEnd = resetPos + 2; } if (!isSpellLink && !isQuestLink && !isAchievLink) { // --- Item link --- uint32_t itemEntry = linkId; 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 = "[" + linkName + "]"; 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); } } } else if (isSpellLink) { // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- // Small icon (use spell icon cache if available) VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; if (spellIcon) { ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); } ImGui::SameLine(0, 2); } std::string display = "[" + linkName + "]"; ImGui::PushStyleColor(ImGuiCol_Text, linkColor); ImGui::TextWrapped("%s", display.c_str()); ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (linkId > 0) { spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); } } } else if (isQuestLink) { // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- std::string display = "[" + linkName + "]"; ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.84f, 0.0f, 1.0f)); // gold ImGui::TextWrapped("%s", display.c_str()); ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::BeginTooltip(); ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%s", linkName.c_str()); // Parse quest level (second field after questId) if (entryEnd != std::string::npos) { size_t lvlEnd = text.find(':', entryEnd + 1); if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); if (lvlEnd != std::string::npos) { uint32_t qLvl = static_cast(strtoul( text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); } } ImGui::TextDisabled("Click quest log to view details"); ImGui::EndTooltip(); } // Click: open quest log and select this quest if we have it if (ImGui::IsItemClicked() && linkId > 0) { questLogScreen.openAndSelectQuest(linkId); } } else { // --- Achievement link --- std::string display = "[" + linkName + "]"; ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); // gold ImGui::TextWrapped("%s", display.c_str()); ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::SetTooltip("Achievement: %s", linkName.c_str()); } } // Shift-click: insert entire link back 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; } } }; // Determine local player name for mention detection (case-insensitive) std::string selfNameLower; { const auto* ch = gameHandler.getActiveCharacter(); if (ch && !ch->name.empty()) { selfNameLower = ch->name; for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); } } // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { const auto& mMsg = chatHistory[mi]; // Skip outgoing whispers, system, and monster messages if (mMsg.type == game::ChatType::WHISPER_INFORM || mMsg.type == game::ChatType::SYSTEM) continue; // Case-insensitive search in message body std::string bodyLower = mMsg.message; for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); if (bodyLower.find(selfNameLower) != std::string::npos) { if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ui = renderer->getUiSoundManager()) ui->playWhisperReceived(); } break; // play at most once per scan pass } } chatMentionSeenCount_ = chatHistory.size(); } else if (chatHistory.size() <= chatMentionSeenCount_) { chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared } 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: , , from chatTag bitmask std::string tagPrefix; if (msg.chatTag & 0x04) tagPrefix = " "; else if (msg.chatTag & 0x01) tagPrefix = " "; else if (msg.chatTag & 0x02) tagPrefix = " "; // 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; } } // Detect mention: does this message contain the local player's name? bool isMention = false; if (!selfNameLower.empty() && msg.type != game::ChatType::WHISPER_INFORM && msg.type != game::ChatType::SYSTEM) { std::string msgLower = fullMsg; for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); isMention = (msgLower.find(selfNameLower) != std::string::npos); } // Render message in a group so we can attach a right-click context menu ImGui::PushID(chatMsgIdx++); if (isMention) { // Golden highlight strip behind the text ImVec2 groupMin = ImGui::GetCursorScreenPos(); float availW = ImGui::GetContentRegionAvail().x; float lineH = ImGui::GetTextLineHeightWithSpacing(); ImGui::GetWindowDrawList()->AddRectFilled( groupMin, ImVec2(groupMin.x + availW, groupMin.y + lineH), IM_COL32(255, 200, 50, 45)); // soft golden tint } ImGui::BeginGroup(); renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : 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", "CHANNEL" }; ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 11); // 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(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)); } // Show channel picker if CHANNEL is selected if (selectedChatType == 10) { const auto& channels = gameHandler.getJoinedChannels(); if (channels.empty()) { ImGui::SameLine(); ImGui::TextDisabled("(no channels joined)"); } else { ImGui::SameLine(); if (selectedChannelIdx >= static_cast(channels.size())) selectedChannelIdx = 0; ImGui::SetNextItemWidth(140); if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx].c_str())) { for (int ci = 0; ci < static_cast(channels.size()); ++ci) { bool selected = (ci == selectedChannelIdx); if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx = ci; if (selected) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } } 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; else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. if (detected >= 0 && (selectedChatType != detected || detected == 10)) { // For channel shortcuts, also update selectedChannelIdx if (detected == 10) { int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. const auto& chans = gameHandler.getJoinedChannels(); if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { selectedChannelIdx = chanIdx; } } 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 case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan 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(data->UserData); if (!self) return 0; // Cursor-to-end after channel switch if (self->chatInputMoveCursorToEnd) { int len = static_cast(std::strlen(data->Buf)); data->CursorPos = len; data->SelectionStart = len; data->SelectionEnd = len; self->chatInputMoveCursorToEnd = false; } // Tab: slash-command autocomplete if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { if (data->BufTextLen > 0 && data->Buf[0] == '/') { // Split buffer into command word and trailing args std::string fullBuf(data->Buf, data->BufTextLen); size_t spacePos = fullBuf.find(' '); std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; // Normalize to lowercase for matching std::string lowerWord = word; for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); static const std::vector kCmds = { "/afk", "/away", "/cast", "/chathelp", "/clear", "/dance", "/do", "/dnd", "/e", "/emote", "/cl", "/combatlog", "/equip", "/follow", "/g", "/guild", "/guildinfo", "/gmticket", "/grouploot", "/i", "/instance", "/invite", "/j", "/join", "/kick", "/l", "/leave", "/local", "/me", "/p", "/party", "/r", "/raid", "/raidwarning", "/random", "/reply", "/roll", "/s", "/say", "/setloot", "/shout", "/stopattack", "/stopfollow", "/t", "/time", "/trade", "/uninvite", "/use", "/w", "/whisper", "/who", "/wts", "/wtb", "/y", "/yell", "/zone" }; // New session if prefix changed if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { self->chatTabPrefix_ = lowerWord; self->chatTabMatches_.clear(); for (const auto& cmd : kCmds) { if (cmd.size() >= lowerWord.size() && cmd.compare(0, lowerWord.size(), lowerWord) == 0) self->chatTabMatches_.push_back(cmd); } self->chatTabMatchIdx_ = 0; } else { // Cycle forward through matches ++self->chatTabMatchIdx_; if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) self->chatTabMatchIdx_ = 0; } if (!self->chatTabMatches_.empty()) { std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; // Append trailing space when match is unambiguous if (self->chatTabMatches_.size() == 1 && rest.empty()) match += ' '; std::string newBuf = match + rest; data->DeleteChars(0, data->BufTextLen); data->InsertChars(0, newBuf.c_str()); } } return 0; } // Up/Down arrow: cycle through sent message history if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { // Any history navigation resets autocomplete self->chatTabMatchIdx_ = -1; self->chatTabMatches_.clear(); const int histSize = static_cast(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 | ImGuiInputTextFlags_CallbackCompletion; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // 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 character screen (C) and inventory/bags (I) if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { inventoryScreen.toggleCharacter(); } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { inventoryScreen.toggle(); } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) { if (inventoryScreen.isSeparateBags()) { inventoryScreen.openAllBags(); } else { 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); } } } } // 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(window->getWidth()); float screenH = static_cast(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(window->getWidth()); float screenH = static_cast(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(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(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(window->getWidth()); float screenH = static_cast(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(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(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(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(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 via shared helper ImVec4 classColor = activeChar ? classColorVec4(static_cast(activeChar->characterClass)) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // 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"); } // Group leader crown on self frame when you lead the party/raid if (gameHandler.isInGroup() && gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); } if (gameHandler.isAfk()) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); 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), ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); } if (inCombatConfirmed && !isDead) { float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); } // 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(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(playerHp) / static_cast(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(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(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(power) / static_cast(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(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(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)); } } } // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air if (gameHandler.getPlayerClass() == 7) { static const ImVec4 kTotemColors[] = { ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky }; static const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; ImGui::Spacing(); ImVec2 cursor = ImGui::GetCursorScreenPos(); float totalW = ImGui::GetContentRegionAvail().x; float spacing = 3.0f; float slotW = (totalW - spacing * 3.0f) / 4.0f; float slotH = 14.0f; ImDrawList* tdl = ImGui::GetWindowDrawList(); for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { const auto& ts = gameHandler.getTotemSlot(i); float x0 = cursor.x + i * (slotW + spacing); float y0 = cursor.y; float x1 = x0 + slotW; float y1 = y0 + slotH; // Background tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); if (ts.active()) { float rem = ts.remainingMs(); float frac = rem / static_cast(ts.durationMs); float fillX = x0 + (x1 - x0) * frac; tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); // Remaining seconds label char secBuf[8]; snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); ImVec2 tsz = ImGui::CalcTextSize(secBuf); float lx = x0 + (slotW - tsz.x) * 0.5f; float ly = y0 + (slotH - tsz.y) * 0.5f; tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); } else { // Inactive — show element letter const char* letter = kTotemNames[i]; char single[2] = { letter[0], '\0' }; ImVec2 tsz = ImGui::CalcTextSize(single); float lx = x0 + (slotW - tsz.x) * 0.5f; float ly = y0 + (slotH - tsz.y) * 0.5f; tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); } // Border ImU32 borderCol = ts.active() ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) : IM_COL32(60, 60, 60, 160); tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); // Tooltip on hover ImGui::SetCursorScreenPos(ImVec2(x0, y0)); ImGui::InvisibleButton(("##totem" + std::to_string(i)).c_str(), ImVec2(slotW, slotH)); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); if (ts.active()) { const std::string& spellNm = gameHandler.getSpellName(ts.spellId); ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, kTotemColors[i].z, 1.0f), "%s Totem", kTotemNames[i]); if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); } else { ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); } ImGui::EndTooltip(); } } ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 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(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(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(hp) / static_cast(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(power) / static_cast(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(); } // Happiness bar — hunter pets store happiness as power type 4 { uint32_t happiness = petUnit->getPowerByType(4); uint32_t maxHappiness = petUnit->getMaxPowerByType(4); if (maxHappiness > 0 && happiness > 0) { float hapPct = static_cast(happiness) / static_cast(maxHappiness); // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); ImGui::PopStyleColor(); } } // Pet cast bar if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { float castPct = (pcs->timeTotal > 0.0f) ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; // Orange color to distinguish from health/power bars ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); char petCastLabel[48]; const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); if (!spellNm.empty()) snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); else snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); ImGui::PopStyleColor(); } // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned { static const char* kReactLabels[] = { "Psv", "Def", "Agg" }; static const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; static const ImVec4 kReactColors[] = { ImVec4(0.4f, 0.6f, 1.0f, 1.0f), // passive — blue ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green ImVec4(1.0f, 0.35f, 0.35f, 1.0f),// aggressive — red }; static const ImVec4 kReactDimColors[] = { ImVec4(0.15f, 0.2f, 0.4f, 0.8f), ImVec4(0.1f, 0.3f, 0.1f, 0.8f), ImVec4(0.4f, 0.1f, 0.1f, 0.8f), }; uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive // Find each react-type slot in the action bar by known built-in IDs: // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; uint32_t reactSlotVals[3] = { 0, 0, 0 }; const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; for (int i = 0; i < slotTotal; ++i) { uint32_t sv = gameHandler.getPetActionSlot(i); uint32_t aid = sv & 0x00FFFFFFu; for (int r = 0; r < 3; ++r) { if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } } } for (int r = 0; r < 3; ++r) { if (r > 0) ImGui::SameLine(0.0f, 3.0f); bool active = (curReact == static_cast(r)); ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; ImGui::PushID(r + 1000); ImGui::PushStyleColor(ImGuiCol_Button, btnCol); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { // Use server-provided slot value if available; fall back to raw ID uint32_t action = (reactSlotVals[r] != 0) ? reactSlotVals[r] : kReactActionIds[r]; gameHandler.sendPetAction(action, 0); } ImGui::PopStyleColor(3); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", kReactTooltips[r]); ImGui::PopID(); } // Dismiss button right-aligned on the same row ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.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 == 1) builtinLabel = "Psv"; else if (actionId == 2) builtinLabel = "Fol"; else if (actionId == 3) builtinLabel = "Sty"; else if (actionId == 4) builtinLabel = "Def"; else if (actionId == 5) builtinLabel = "Atk"; else if (actionId == 6) builtinLabel = "Agg"; 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: rich spell info for pet spells, simple label for built-in commands if (ImGui::IsItemHovered()) { if (builtinLabel) { const char* tip = nullptr; if (actionId == 1) tip = "Passive"; else if (actionId == 2) tip = "Follow"; else if (actionId == 3) tip = "Stay"; else if (actionId == 4) tip = "Defensive"; else if (actionId == 5) tip = "Attack"; else if (actionId == 6) tip = "Aggressive"; if (tip) ImGui::SetTooltip("%s", tip); } else if (actionId > 6) { auto* spellAsset = core::Application::getInstance().getAssetManager(); ImGui::BeginTooltip(); bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); if (!richOk) { std::string nm = gameHandler.getSpellName(actionId); if (nm.empty()) nm = "Spell #" + std::to_string(actionId); ImGui::Text("%s", nm.c_str()); } if (autocastOn) ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Autocast: On"); ImGui::EndTooltip(); } } ImGui::PopID(); ++rendered; } } } ImGui::End(); ImGui::PopStyleColor(2); ImGui::PopStyleVar(); } // ============================================================ // Totem Frame (Shaman — below pet frame / player frame) // ============================================================ void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { // Only show if at least one totem is active bool anyActive = false; for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } } if (!anyActive) return; static const struct { const char* name; ImU32 color; } kTotemInfo[4] = { { "Earth", IM_COL32(139, 90, 43, 255) }, // brown { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange { "Water", IM_COL32( 30,120, 220, 255) }, // blue { "Air", IM_COL32(180,220, 255, 255) }, // light blue }; // Position: below pet frame / player frame, left side // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 // We anchor relative to screen left edge like pet frame ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar; ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); if (ImGui::Begin("##TotemFrame", nullptr, flags)) { ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); ImGui::Separator(); for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { const auto& slot = gameHandler.getTotemSlot(i); if (!slot.active()) continue; ImGui::PushID(i); // Colored element dot ImVec2 dotPos = ImGui::GetCursorScreenPos(); dotPos.x += 4.0f; dotPos.y += 6.0f; ImGui::GetWindowDrawList()->AddCircleFilled( ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); // Totem name or spell name const std::string& spellName = gameHandler.getSpellName(slot.spellId); const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); ImGui::Text("%s", displayName); // Duration countdown bar float remMs = slot.remainingMs(); float totMs = static_cast(slot.durationMs); float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; float remSec = remMs / 1000.0f; // Color bar with totem element tint ImVec4 barCol( static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, 0.9f); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); char timeBuf[16]; snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); ImGui::PopStyleColor(); ImGui::PopID(); } } ImGui::End(); ImGui::PopStyleColor(); 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(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(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(mobLv) - static_cast(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); // Player targets: use class color instead of the generic green ImVec4 nameColor = hostileColor; if (target->getType() == game::ObjectType::PLAYER) { uint8_t cid = entityClassId(target.get()); if (cid != 0) nameColor = classColorVec4(cid); } 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); // Group leader crown — golden ♛ when the targeted player is the party/raid leader if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); } } // Quest giver indicator — "!" for available quests, "?" for completable quests { using QGS = game::QuestGiverStatus; QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); if (qgs == QGS::AVAILABLE) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); } else if (qgs == QGS::AVAILABLE_LOW) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); } else if (qgs == QGS::INCOMPLETE) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); } } // Creature subtitle (e.g. "", "Captain of the Guard") if (target->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(target); const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); if (!sub.empty()) { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); } } // 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(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(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()); // Classification badge: Elite / Rare Elite / Boss / Rare if (target->getType() == game::ObjectType::UNIT) { int rank = gameHandler.getCreatureRank(unit->getEntry()); if (rank == 1) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); } else if (rank == 2) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); } else if (rank == 3) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[Boss]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); } else if (rank == 4) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); } } if (confirmedCombatWithTarget) { float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); } // Health bar uint32_t hp = unit->getHealth(); uint32_t maxHp = unit->getMaxHealth(); if (maxHp > 0) { float pct = static_cast(hp) / static_cast(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(targetPower) / static_cast(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(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); { auto* tcastAsset = core::Application::getInstance().getAssetManager(); VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; if (tIcon) { ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); ImGui::SameLine(0, 2); ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); } else { 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(); // Build sorted index list: debuffs before buffs, shorter duration first uint64_t tNowSort = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); std::vector sortedIdx; sortedIdx.reserve(targetAuras.size()); for (size_t i = 0; i < targetAuras.size(); ++i) if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; bool aDebuff = (aa.flags & 0x80) != 0; bool bDebuff = (ab.flags & 0x80) != 0; if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first int32_t ra = aa.getRemainingMs(tNowSort); int32_t rb = ab.getRemainingMs(tNowSort); // Permanent (-1) goes last; shorter remaining goes first if (ra < 0 && rb < 0) return false; if (ra < 0) return false; if (rb < 0) return true; return ra < rb; }); int shown = 0; for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { size_t i = sortedIdx[si]; const auto& aura = targetAuras[i]; if (aura.isEmpty()) continue; if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); ImGui::PushID(static_cast(10000 + i)); bool isBuff = (aura.flags & 0x80) == 0; ImVec4 auraBorderColor; if (isBuff) { auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); } else { // Debuff: color by dispel type, matching player buff bar convention uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); switch (dt) { case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red } } 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( std::chrono::duration_cast( 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(ImGui::GetTime()) * 6.0f); tTimerColor = IM_COL32( static_cast(255 * pulse), static_cast(80 * pulse), static_cast(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(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(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); // Class color for players; gray for NPCs ImVec4 totNameColor = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); if (totEntity->getType() == game::ObjectType::PLAYER) { uint8_t cid = entityClassId(totEntity.get()); if (cid != 0) totNameColor = classColorVec4(cid); } // Selectable so we can attach a right-click context menu ImGui::PushStyleColor(ImGuiCol_Text, totNameColor); 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(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(hp) / static_cast(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(); } // ToT cast bar — orange-yellow, pulses when near completion if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { float totCastPct = (totCs->timeTotal > 0.0f) ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; ImVec4 tcColor; if (totCastPct > 0.8f) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); tcColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); } else { tcColor = ImVec4(0.8f, 0.5f, 0.1f, 1.0f); } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); char tcLabel[48]; const std::string& tcName = gameHandler.getSpellName(totCs->spellId); if (!tcName.empty()) snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); else snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); ImGui::PopStyleColor(); } // ToT aura row — compact icons, debuffs first { const std::vector* totAuras = nullptr; if (totGuid == gameHandler.getPlayerGuid()) totAuras = &gameHandler.getPlayerAuras(); else if (totGuid == gameHandler.getTargetGuid()) totAuras = &gameHandler.getTargetAuras(); else totAuras = gameHandler.getUnitAuras(totGuid); if (totAuras) { int totActive = 0; for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; if (totActive > 0) { auto* totAsset = core::Application::getInstance().getAssetManager(); constexpr float TA_ICON = 16.0f; constexpr int TA_PER_ROW = 8; ImGui::Separator(); uint64_t taNowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); std::vector taIdx; taIdx.reserve(totAuras->size()); for (size_t i = 0; i < totAuras->size(); ++i) if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { bool aD = ((*totAuras)[a].flags & 0x80) != 0; bool bD = ((*totAuras)[b].flags & 0x80) != 0; if (aD != bD) return aD > bD; int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); if (ra < 0 && rb < 0) return false; if (ra < 0) return false; if (rb < 0) return true; return ra < rb; }); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); int taShown = 0; for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { const auto& aura = (*totAuras)[taIdx[si]]; bool isBuff = (aura.flags & 0x80) == 0; if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); ImGui::PushID(static_cast(taIdx[si]) + 5000); ImVec4 borderCol; if (isBuff) { borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); } else { uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); switch (dt) { case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; } } VkDescriptorSet taIcon = (totAsset) ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; if (taIcon) { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); ImGui::ImageButton("##taura", (ImTextureID)(uintptr_t)taIcon, ImVec2(TA_ICON - 2, TA_ICON - 2)); ImGui::PopStyleVar(); ImGui::PopStyleColor(); } else { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); char lab[8]; snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); ImGui::PopStyleColor(); } // Duration overlay int32_t taRemain = aura.getRemainingMs(taNowMs); if (taRemain > 0) { ImVec2 imin = ImGui::GetItemRectMin(); ImVec2 imax = ImGui::GetItemRectMax(); char ts[12]; int s = (taRemain + 999) / 1000; if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); else snprintf(ts, sizeof(ts), "%d", s); ImVec2 tsz = ImGui::CalcTextSize(ts); float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; float cy = imax.y - tsz.y; ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); } // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); bool richOk = spellbookScreen.renderSpellInfoTooltip( aura.spellId, gameHandler, totAsset); if (!richOk) { std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); ImGui::Text("%s", nm.c_str()); } if (taRemain > 0) { int s = taRemain / 1000; char db[32]; if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); } ImGui::EndTooltip(); } ImGui::PopID(); taShown++; } ImGui::PopStyleVar(); } } } } } 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(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) { // Use class color for player focus targets uint8_t cid = entityClassId(focus.get()); focusColor = (cid != 0) ? classColorVec4(cid) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } else if (focus->getType() == game::ObjectType::UNIT) { auto u = std::static_pointer_cast(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(mobLv) - static_cast(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(); // Raid mark icon (star, circle, diamond, …) preceding the name { static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) { "\xe2\x97\x8f", IM_COL32(255, 103, 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 fmark = gameHandler.getEntityRaidMark(focus->getGuid()); if (fmark < game::GameHandler::kRaidMarkCount) { ImGui::GetWindowDrawList()->AddText( ImGui::GetCursorScreenPos(), kFocusMarks[fmark].col, kFocusMarks[fmark].sym); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); } } 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); // Group leader crown — golden ♛ when the focused player is the party/raid leader if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); } } // Quest giver indicator and classification badge for NPC focus targets if (focus->getType() == game::ObjectType::UNIT) { auto focusUnit = std::static_pointer_cast(focus); // Quest indicator: ! / ? { using QGS = game::QuestGiverStatus; QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); if (qgs == QGS::AVAILABLE) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); } else if (qgs == QGS::AVAILABLE_LOW) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); } else if (qgs == QGS::INCOMPLETE) { ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); } } // Classification badge int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } // Creature subtitle const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); if (!fSub.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); } 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(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(hp) / static_cast(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(pwr) / static_cast(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(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); { auto* fcAsset = core::Application::getInstance().getAssetManager(); VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; if (fcIcon) { ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); ImGui::SameLine(0, 2); ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); } else { ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); } } ImGui::PopStyleColor(); } } // Focus auras — buffs first, then debuffs, up to 8 icons wide { const std::vector* focusAuras = (focus->getGuid() == gameHandler.getTargetGuid()) ? &gameHandler.getTargetAuras() : gameHandler.getUnitAuras(focus->getGuid()); if (focusAuras) { int activeCount = 0; for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; if (activeCount > 0) { auto* focusAsset = core::Application::getInstance().getAssetManager(); constexpr float FA_ICON = 20.0f; constexpr int FA_PER_ROW = 10; ImGui::Separator(); uint64_t faNowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); // Sort: debuffs first (so hostile-caster info is prominent), then buffs std::vector faIdx; faIdx.reserve(focusAuras->size()); for (size_t i = 0; i < focusAuras->size(); ++i) if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { bool aD = ((*focusAuras)[a].flags & 0x80) != 0; bool bD = ((*focusAuras)[b].flags & 0x80) != 0; if (aD != bD) return aD > bD; // debuffs first int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); if (ra < 0 && rb < 0) return false; if (ra < 0) return false; if (rb < 0) return true; return ra < rb; }); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); int faShown = 0; for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { const auto& aura = (*focusAuras)[faIdx[si]]; bool isBuff = (aura.flags & 0x80) == 0; if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); ImGui::PushID(static_cast(faIdx[si]) + 3000); ImVec4 borderCol; if (isBuff) { borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); } else { uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); switch (dt) { case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; } } VkDescriptorSet faIcon = (focusAsset) ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; if (faIcon) { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); ImGui::ImageButton("##faura", (ImTextureID)(uintptr_t)faIcon, ImVec2(FA_ICON - 2, FA_ICON - 2)); ImGui::PopStyleVar(); ImGui::PopStyleColor(); } else { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); char lab[8]; snprintf(lab, sizeof(lab), "%u", aura.spellId); ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); ImGui::PopStyleColor(); } // Duration overlay int32_t faRemain = aura.getRemainingMs(faNowMs); if (faRemain > 0) { ImVec2 imin = ImGui::GetItemRectMin(); ImVec2 imax = ImGui::GetItemRectMax(); char ts[12]; int s = (faRemain + 999) / 1000; if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); else snprintf(ts, sizeof(ts), "%d", s); ImVec2 tsz = ImGui::CalcTextSize(ts); float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; float cy = imax.y - tsz.y - 1.0f; ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); } // Stack / charge count — upper-left corner (parity with target frame) if (aura.charges > 1) { ImVec2 faMin = ImGui::GetItemRectMin(); char chargeStr[8]; snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), IM_COL32(0, 0, 0, 200), chargeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), IM_COL32(255, 220, 50, 255), chargeStr); } // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); bool richOk = spellbookScreen.renderSpellInfoTooltip( aura.spellId, gameHandler, focusAsset); if (!richOk) { std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); ImGui::Text("%s", nm.c_str()); } if (faRemain > 0) { int s = faRemain / 1000; char db[32]; if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); } ImGui::EndTooltip(); } ImGui::PopID(); faShown++; } ImGui::PopStyleVar(); } } } // Distance to focus target { const auto& mv = gameHandler.getMovementInfo(); float fdx = focus->getX() - mv.x; float fdy = focus->getY() - mv.y; float fdz = focus->getZ() - mv.z; float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); ImGui::TextDisabled("%.1f yd", fdist); } // 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(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") { 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; } // /score command — BG scoreboard if (cmdLower == "score") { gameHandler.requestPvpLog(); showBgScoreboard_ = true; chatInputBuffer[0] = '\0'; return; } // /time command if (cmdLower == "time") { gameHandler.queryServerTime(); chatInputBuffer[0] = '\0'; return; } // /zone command — print current zone name if (cmdLower == "zone") { std::string zoneName; if (auto* rend = core::Application::getInstance().getRenderer()) zoneName = rend->getCurrentZoneName(); game::MessageChatData sysMsg; sysMsg.type = game::ChatType::SYSTEM; sysMsg.language = game::ChatLanguage::UNIVERSAL; sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; gameHandler.addLocalChatMessage(sysMsg); 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; } // /chathelp command — list chat-channel slash commands if (cmdLower == "chathelp") { static const char* kChatHelp[] = { "--- Chat Channel Commands ---", "/s [msg] Say to nearby players", "/y [msg] Yell to a wider area", "/w [msg] Whisper to player", "/r [msg] Reply to last whisper", "/p [msg] Party chat", "/g [msg] Guild chat", "/o [msg] Guild officer chat", "/raid [msg] Raid chat", "/rw [msg] Raid warning", "/bg [msg] Battleground chat", "/1 [msg] General channel", "/2 [msg] Trade channel (also /wts /wtb)", "/ [msg] Channel by number", "/join Join a channel", "/leave Leave a channel", "/afk [msg] Set AFK status", "/dnd [msg] Set Do Not Disturb", }; for (const char* line : kChatHelp) { game::MessageChatData helpMsg; helpMsg.type = game::ChatType::SYSTEM; helpMsg.language = game::ChatLanguage::UNIVERSAL; helpMsg.message = line; gameHandler.addLocalChatMessage(helpMsg); } 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 [msg] /r [msg]", "Social: /who [filter] /whois /friend add/remove ", " /ignore /unignore ", "Party: /invite /uninvite /leave /readycheck", " /maintank /mainassist /roll [min-max]", "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", " /gleader /groster /ginfo /gcreate /gdisband", "Combat: /startattack /stopattack /stopcasting /cast /duel /pvp", " /forfeit /follow /stopfollow /assist", "Items: /use /equip ", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", " /score /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 "; 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 , /online"; gameHandler.addLocalChatMessage(msg); chatInputBuffer[0] = '\0'; return; } gameHandler.queryWho(query); showWhoWindow_ = true; chatInputBuffer[0] = '\0'; return; } // /combatlog command if (cmdLower == "combatlog" || cmdLower == "cl") { showCombatLog_ = !showCombatLog_; 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 or /friend remove "; 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 "; 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 "; 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 "; 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; } // /stopfollow command if (cmdLower == "stopfollow") { gameHandler.cancelFollow(); 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 "; 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 "; 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 "; 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 "; 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 "; 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 "; 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 "; 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 "; 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; } if (cmdLower == "cast" && spacePos != std::string::npos) { std::string spellArg = command.substr(spacePos + 1); // Trim leading/trailing whitespace while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" int requestedRank = -1; // -1 = highest rank std::string spellName = spellArg; { auto rankPos = spellArg.find('('); if (rankPos != std::string::npos) { std::string rankStr = spellArg.substr(rankPos + 1); // Strip closing paren and whitespace auto closePos = rankStr.find(')'); if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); // Expect "rank N" if (rankStr.rfind("rank ", 0) == 0) { try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} } spellName = spellArg.substr(0, rankPos); while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); } } std::string spellNameLower = spellName; for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); // Search known spells for a name match; pick highest rank (or specific rank) uint32_t bestSpellId = 0; int bestRank = -1; for (uint32_t sid : gameHandler.getKnownSpells()) { const std::string& sName = gameHandler.getSpellName(sid); if (sName.empty()) continue; std::string sNameLower = sName; for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); if (sNameLower != spellNameLower) continue; // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) int sRank = 0; const std::string& rankStr = gameHandler.getSpellRank(sid); if (!rankStr.empty()) { std::string rLow = rankStr; for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); if (rLow.rfind("rank ", 0) == 0) { try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} } } if (requestedRank >= 0) { if (sRank == requestedRank) { bestSpellId = sid; break; } } else { if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } } } if (bestSpellId) { uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; gameHandler.castSpell(bestSpellId, targetGuid); } else { game::MessageChatData sysMsg; sysMsg.type = game::ChatType::SYSTEM; sysMsg.language = game::ChatLanguage::UNIVERSAL; sysMsg.message = requestedRank >= 0 ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." : "Unknown spell: '" + spellName + "'."; gameHandler.addLocalChatMessage(sysMsg); } chatInputBuffer[0] = '\0'; return; } // /use — use an item from backpack/bags by name if (cmdLower == "use" && spacePos != std::string::npos) { std::string useArg = command.substr(spacePos + 1); while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); std::string useArgLower = useArg; for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); bool found = false; const auto& inv = gameHandler.getInventory(); // Search backpack for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { const auto& slot = inv.getBackpackSlot(s); if (slot.empty()) continue; const auto* info = gameHandler.getItemInfo(slot.item.itemId); if (!info) continue; std::string nameLow = info->name; for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); if (nameLow == useArgLower) { gameHandler.useItemBySlot(s); found = true; } } // Search bags for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { const auto& slot = inv.getBagSlot(b, s); if (slot.empty()) continue; const auto* info = gameHandler.getItemInfo(slot.item.itemId); if (!info) continue; std::string nameLow = info->name; for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); if (nameLow == useArgLower) { gameHandler.useItemInBag(b, s); found = true; } } } if (!found) { game::MessageChatData sysMsg; sysMsg.type = game::ChatType::SYSTEM; sysMsg.language = game::ChatLanguage::UNIVERSAL; sysMsg.message = "Item not found: '" + useArg + "'."; gameHandler.addLocalChatMessage(sysMsg); } chatInputBuffer[0] = '\0'; return; } // /equip — auto-equip an item from backpack/bags by name if (cmdLower == "equip" && spacePos != std::string::npos) { std::string equipArg = command.substr(spacePos + 1); while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); std::string equipArgLower = equipArg; for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); bool found = false; const auto& inv = gameHandler.getInventory(); // Search backpack for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { const auto& slot = inv.getBackpackSlot(s); if (slot.empty()) continue; const auto* info = gameHandler.getItemInfo(slot.item.itemId); if (!info) continue; std::string nameLow = info->name; for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); if (nameLow == equipArgLower) { gameHandler.autoEquipItemBySlot(s); found = true; } } // Search bags for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { const auto& slot = inv.getBagSlot(b, s); if (slot.empty()) continue; const auto* info = gameHandler.getItemInfo(slot.item.itemId); if (!info) continue; std::string nameLow = info->name; for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); if (nameLow == equipArgLower) { gameHandler.autoEquipItemInBag(b, s); found = true; } } } if (!found) { game::MessageChatData sysMsg; sysMsg.type = game::ChatType::SYSTEM; sysMsg.language = game::ChatLanguage::UNIVERSAL; sysMsg.message = "Item not found: '" + equipArg + "'."; gameHandler.addLocalChatMessage(sysMsg); } 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(std::tolower(static_cast(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(entity); name = unit->getName(); } if (name.empty()) continue; std::string nameLower = name; for (char& c : nameLower) c = static_cast(std::tolower(static_cast(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 == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { // /wts and /wtb — send to Trade channel // Prefix with [WTS] / [WTB] and route to the Trade channel const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; const std::string body = command.substr(spacePos + 1); // Find the Trade channel among joined channels (case-insensitive prefix match) std::string tradeChan; for (const auto& ch : gameHandler.getJoinedChannels()) { std::string chLow = ch; for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } } if (tradeChan.empty()) { game::MessageChatData errMsg; errMsg.type = game::ChatType::SYSTEM; errMsg.language = game::ChatLanguage::UNIVERSAL; errMsg.message = "You are not in the Trade channel."; gameHandler.addLocalChatMessage(errMsg); chatInputBuffer[0] = '\0'; return; } message = tag + body; type = game::ChatType::CHANNEL; target = tradeChan; isChannelCommand = true; } 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) { 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; 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 case 10: { // CHANNEL const auto& chans = gameHandler.getJoinedChannels(); if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { type = game::ChatType::CHANNEL; target = chans[selectedChannelIdx]; } else { type = game::ChatType::SAY; } break; } 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 case 10: { // CHANNEL const auto& chans = gameHandler.getJoinedChannels(); if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { type = game::ChatType::CHANNEL; target = chans[selectedChannelIdx]; } else { type = game::ChatType::SAY; } break; } 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 . 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 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(recIdx), 7 + groupField); }; // Helper: find first equipped item matching inventoryType, return its displayInfoId auto findEquippedDisplayId = [&](std::initializer_list types) -> uint32_t { for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(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 types) -> bool { for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(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 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((ch->appearanceBytes >> 16) & 0xFF); facialId = ch->facialFeatures; } } geosets.insert(static_cast(100 + hairStyleId + 1)); // Group 1 hair geosets.insert(static_cast(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(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(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(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(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(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> regionLayers; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(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(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(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(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(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(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(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(); const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); // Out-of-range check: red tint when a targeted spell cannot reach the current target. // Only applies to SPELL slots with a known max range (>5 yd) and an active target. bool outOfRange = false; if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 && !onCooldown && gameHandler.hasTarget()) { uint32_t maxRange = spellbookScreen.getSpellMaxRange(slot.id, assetMgr); if (maxRange > 5) { // >5 yd = not melee/self auto& em = gameHandler.getEntityManager(); auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); if (playerEnt && targetEnt) { float dx = playerEnt->getX() - targetEnt->getX(); float dy = playerEnt->getY() - targetEnt->getY(); float dz = playerEnt->getZ() - targetEnt->getZ(); float dist = std::sqrt(dx * dx + dy * dy + dz * dz); if (dist > static_cast(maxRange)) outOfRange = true; } } } // Insufficient-power check: orange tint when player doesn't have enough power to cast. // Only applies to SPELL slots with a known power cost and when not already on cooldown. bool insufficientPower = false; if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 && !onCooldown) { uint32_t spellCost = 0, spellPowerType = 0; spellbookScreen.getSpellPowerInfo(slot.id, assetMgr, spellCost, spellPowerType); if (spellCost > 0) { auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || playerEnt->getType() == game::ObjectType::UNIT)) { auto unit = std::static_pointer_cast(playerEnt); if (unit->getPowerType() == static_cast(spellPowerType)) { if (unit->getPower() < spellCost) insufficientPower = true; } } } } 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(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); } // Item-missing check: grey out item slots whose item is not in the player's inventory. const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 && barItemDef == nullptr && !onCooldown); 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); } else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } 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 (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); 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 (outOfRange) { ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Out of range"); } if (insufficientPower) { ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); } 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(); // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); if (itemQueryInfo && itemQueryInfo->valid) { inventoryScreen.renderItemTooltip(*itemQueryInfo); } else 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(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); } // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) if (onGCD) { 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 gcdRem = gameHandler.getGCDRemaining(); float gcdTotal = gameHandler.getGCDTotal(); if (gcdTotal > 0.0f) { float elapsed = gcdTotal - gcdRem; float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); if (elapsedFrac > 0.005f) { constexpr int N_SEGS = 24; float startAngle = -IM_PI * 0.5f; float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; float fanR = r * 1.4f; 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(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, 110)); } } } // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 && gameHandler.isAutoAttacking()) { ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMax = ImGui::GetItemRectMax(); float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); ImU32 glowCol = IM_COL32( static_cast(255), static_cast(200 * pulse), static_cast(0), static_cast(200 * pulse)); ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); } // 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); } } // Ready glow: animate a gold border for ~1.5s when a cooldown just expires { static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state float dt = ImGui::GetIO().DeltaTime; bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) if (wasOnCd && !onCooldown && !slot.isEmpty()) { slotGlowTimers[absSlot] = 1.5f; } slotWasOnCooldown[absSlot] = onCooldown; auto git = slotGlowTimers.find(absSlot); if (git != slotGlowTimers.end() && git->second > 0.0f) { git->second -= dt; float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime // Pulse: bright when fresh, fading out float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); if (alpha > 0) { ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMax = ImGui::GetItemRectMax(); auto* gdl = ImGui::GetWindowDrawList(); // Gold glow border (2px inset, 3px thick) gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), ImVec2(bMax.x + 2, bMax.y + 2), IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); } if (git->second <= 0.0f) slotGlowTimers.erase(git); } } // 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(static_cast(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( static_cast(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(static_cast(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( static_cast(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(currentXp) / static_cast(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(currentXp + restedXp) / static_cast(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(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); // Tooltip with XP-to-level and rested details if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); ImGui::Separator(); ImGui::Text("Current: %u / %u XP", currentXp, nextLevelXp); ImGui::Text("To next level: %u XP", xpToLevel); if (restedXp > 0) { float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); ImGui::Spacing(); ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); if (isResting) ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), "Resting — accumulating bonus XP"); } ImGui::EndTooltip(); } } ImGui::End(); ImGui::PopStyleColor(2); ImGui::PopStyleVar(2); } // ============================================================ // Reputation Bar // ============================================================ void GameScreen::renderRepBar(game::GameHandler& gameHandler) { uint32_t factionId = gameHandler.getWatchedFactionId(); if (factionId == 0) return; const auto& standings = gameHandler.getFactionStandings(); auto it = standings.find(factionId); if (it == standings.end()) return; int32_t standing = it->second; // WoW reputation rank thresholds struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; static const RepRank kRanks[] = { { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, }; constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); int rankIdx = kNumRanks - 1; // default to Exalted for (int i = 0; i < kNumRanks; ++i) { if (standing <= kRanks[i].max) { rankIdx = i; break; } } const RepRank& rank = kRanks[rankIdx]; float fraction = 1.0f; if (rankIdx < kNumRanks - 1) { float range = static_cast(rank.max - rank.min + 1); fraction = static_cast(standing - rank.min) / range; fraction = std::max(0.0f, std::min(1.0f, fraction)); } const std::string& factionName = gameHandler.getFactionNamePublic(factionId); // Position directly above the XP bar 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 slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; float barH_ab = slotSize + 24.0f; float xpBarH = 20.0f; float repBarH = 12.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; float bar1TopY = screenH - barH_ab; float xpBarY; if (pendingShowActionBar2) { float bar2TopY = bar1TopY - barH_ab - 2.0f + pendingActionBar2OffsetY; xpBarY = bar2TopY - xpBarH - 2.0f; } else { xpBarY = bar1TopY - xpBarH - 2.0f; } float repBarY = xpBarY - repBarH - 2.0f; ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 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("##RepBar", nullptr, flags)) { ImVec2 barMin = ImGui::GetCursorScreenPos(); ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); auto* dl = ImGui::GetWindowDrawList(); dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); float fillW = barSize.x * fraction; if (fillW > 0.0f) dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); // Label: "FactionName - Rank" char label[96]; snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); ImVec2 textSize = ImGui::CalcTextSize(label); float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); // Tooltip with exact values on hover ImGui::Dummy(barSize); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); float cr = ((rank.color ) & 0xFF) / 255.0f; float cg = ((rank.color >> 8) & 0xFF) / 255.0f; float cb = ((rank.color >> 16) & 0xFF) / 255.0f; ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); int32_t rankMin = rank.min; int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); ImGui::EndTooltip(); } } ImGui::End(); ImGui::PopStyleColor(2); ImGui::PopStyleVar(2); } // ============================================================ // Cast Bar (Phase 3) // ============================================================ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) return; auto* assetMgr = core::Application::getInstance().getAssetManager(); 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; uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; 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]; 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()); } if (iconTex) { // Spell icon to the left of the progress bar ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(20, 20)); ImGui::SameLine(0, 4); ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); } else { 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(t.value) / static_cast(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(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(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 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(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(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(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 (gameHandler.isInGroup() && !q.complete) { if (ImGui::MenuItem("Share Quest")) { gameHandler.shareQuestWithParty(q.questId); } } 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)); if (info && info->valid && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); inventoryScreen.renderItemTooltip(*info); ImGui::EndTooltip(); } ImGui::SameLine(0, 3); ImGui::TextColored(objColor, "%s: %u/%u", itemName ? itemName : "Item", count, required); if (info && info->valid && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); inventoryScreen.renderItemTooltip(*info); ImGui::EndTooltip(); } } else if (itemName) { ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); if (info && info->valid && ImGui::IsItemHovered()) { ImGui::BeginTooltip(); inventoryScreen.renderItemTooltip(*info); ImGui::EndTooltip(); } } 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(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; auto* renderer = core::Application::getInstance().getRenderer(); 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()); } // Whisper audio notification if (msg.type == game::ChatType::WHISPER && renderer) { if (auto* ui = renderer->getUiSoundManager()) ui->playWhisperReceived(); } } 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(alpha * 200)); ImU32 mainCol; if (e.isBossEmote) { mainCol = IM_COL32(255, 185, 60, static_cast(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(alpha * 255)); else mainCol = IM_COL32(255, 220, 50, static_cast(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(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(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; case game::CombatTextEntry::PROC_TRIGGER: { const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; if (!procName.empty()) snprintf(text, sizeof(text), "%s!", procName.c_str()); else snprintf(text, sizeof(text), "PROC!"); color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc break; } 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; // Crits render at 1.35× normal font size for visual impact bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || entry.type == game::CombatTextEntry::CRIT_HEAL); ImFont* font = ImGui::GetFont(); float baseFontSize = ImGui::GetFontSize(); float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); ImVec2 screenPos = ImGui::GetCursorScreenPos(); // Drop shadow for readability over complex backgrounds ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), shadowCol, text); dl->AddText(font, renderFontSize, screenPos, textCol, text); // Reserve space so ImGui doesn't clip the window prematurely ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); ImGui::Dummy(ts); } } ImGui::End(); } // ============================================================ // 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 && !dpsWasInCombat_) { // Just entered combat — reset encounter accumulators dpsEncounterDamage_ = 0.0f; dpsEncounterHeal_ = 0.0f; dpsLogSeenCount_ = gameHandler.getCombatLog().size(); dpsCombatAge_ = 0.0f; } if (inCombat) { dpsCombatAge_ += dt; // Scan any new log entries since last frame const auto& log = gameHandler.getCombatLog(); while (dpsLogSeenCount_ < log.size()) { const auto& e = log[dpsLogSeenCount_++]; 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: dpsEncounterDamage_ += static_cast(e.amount); break; case game::CombatTextEntry::HEAL: case game::CombatTextEntry::CRIT_HEAL: case game::CombatTextEntry::PERIODIC_HEAL: dpsEncounterHeal_ += static_cast(e.amount); break; default: break; } } } else if (dpsWasInCombat_) { // Just left combat — keep encounter totals but stop accumulating } 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(e.amount); break; case game::CombatTextEntry::HEAL: case game::CombatTextEntry::CRIT_HEAL: case game::CombatTextEntry::PERIODIC_HEAL: totalHeal += static_cast(e.amount); break; default: break; } } // Only show if there's something to report (rolling window or lingering encounter data) if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) 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(appWin->getWidth()) : 1280.0f; float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; // Show encounter row when fight has been going long enough (> 3s) bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; char encDpsBuf[16], encHpsBuf[16]; fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); constexpr float WIN_W = 90.0f; // Extra rows for encounter DPS/HPS if active int extraRows = 0; if (showEnc && encDPS > 0.5f) ++extraRows; if (showEnc && encHPS > 0.5f) ++extraRows; float WIN_H = 18.0f + extraRows * 14.0f; if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(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"); } // Encounter totals (full-fight average, shown when fight > 3s) if (showEnc && encDPS > 0.5f) { ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); ImGui::SameLine(0, 2); ImGui::TextDisabled("enc"); } if (showEnc && encHPS > 0.5f) { ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); ImGui::SameLine(0, 2); ImGui::TextDisabled("enc"); } } 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(window->getWidth()); const float screenH = static_cast(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 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(obj.npcOrGoId)); if (it == q.killCounts.end() || it->second.first < it->second.second) { questKillEntries.insert(static_cast(obj.npcOrGoId)); } } } } } ImDrawList* drawList = ImGui::GetBackgroundDrawList(); for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { if (!entityPtr || guid == playerGuid) continue; auto* unit = dynamic_cast(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(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(unit->getHealth()) / static_cast(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(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; float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots { 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(ImGui::GetTime()) * 8.0f); cbFill = IM_COL32(static_cast(255 * pulse), static_cast(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); nameplateBottom = castBarBaseY + cbH + 2.0f; } } // Debuff dot indicators: small colored squares below the nameplate showing // player-applied auras on the current hostile target. // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey if (isTarget && unit->isHostile() && !isCorpse) { const auto& auras = gameHandler.getTargetAuras(); const uint64_t pguid = gameHandler.getPlayerGuid(); const float dotSize = 6.0f * nameplateScale_; const float dotGap = 2.0f; float dotX = barX; for (const auto& aura : auras) { if (aura.isEmpty() || aura.casterGuid != pguid) continue; uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); ImU32 dotCol; switch (dispelType) { case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey } drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), IM_COL32(0, 0, 0, A(150)), 1.0f); // Spell name tooltip on hover { ImVec2 mouse = ImGui::GetMousePos(); if (mouse.x >= dotX && mouse.x < dotX + dotSize && mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); if (!dotSpellName.empty()) ImGui::SetTooltip("%s", dotSpellName.c_str()); } } dotX += dotSize + dotGap; if (dotX + dotSize > barX + barW) break; } } // 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: players get WoW class colors; NPCs use hostility (red/yellow) ImU32 nameColor; if (isPlayer) { // Class color with cyan fallback for unknown class uint8_t cid = entityClassId(unit); ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), static_cast(cc.z*255), A(230)); } else { nameColor = 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); // Group leader crown to the right of the name on player nameplates if (isPlayer && gameHandler.isInGroup() && gameHandler.getPartyData().leaderGuid == guid) { float crownX = nameX + textSize.x + 3.0f; const char* crownSym = "\xe2\x99\x9b"; // ♛ drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); } // 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 float questIconX = nameX + textSize.x + 4.0f; if (!isPlayer && questKillEntries.count(unit->getEntry())) { const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; } // Quest giver indicator: "!" for available quests, "?" for completable/incomplete if (!isPlayer) { using QGS = game::QuestGiverStatus; QGS qgs = gameHandler.getQuestGiverStatus(guid); const char* qSym = nullptr; ImU32 qCol = IM_COL32(255, 210, 0, A(255)); if (qgs == QGS::AVAILABLE) { qSym = "!"; } else if (qgs == QGS::AVAILABLE_LOW) { qSym = "!"; qCol = IM_COL32(160, 160, 160, A(220)); } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { qSym = "?"; } else if (qgs == QGS::INCOMPLETE) { qSym = "?"; qCol = IM_COL32(160, 160, 160, A(220)); } if (qSym) { drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); } } } // 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("Trade")) gameHandler.initiateTrade(nameplateCtxGuid_); if (ImGui::MenuItem("Duel")) gameHandler.proposeDuel(nameplateCtxGuid_); if (ImGui::MenuItem("Inspect")) { gameHandler.setTarget(nameplateCtxGuid_); gameHandler.inspectTarget(); showInspectWindow_ = true; } ImGui::Separator(); 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; auto* assetMgr = core::Application::getInstance().getAssetManager(); 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 subgroups[MAX_SUBGROUPS]; for (const auto& m : partyData.members) { int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0; if (static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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; // Out-of-range check (40 yard threshold) bool isOOR = false; if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); if (playerEnt) { float dx = playerEnt->getX() - static_cast(m.posX); float dy = playerEnt->getY() - static_cast(m.posY); isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); } } // Dim cell overlay when out of range if (isOOR) draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); // Name text (truncated) — class color when alive+online, gray when dead/offline char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); bool isMemberLeader = (m.guid == partyData.leaderGuid); ImU32 nameCol; if (!isOnline || isDead || isGhost) { nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline } else { // Default: gold for leader, light gray for others nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); // Override with WoW class color if entity is loaded auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); uint8_t cid = entityClassId(mEnt.get()); if (cid != 0) nameCol = classColorU32(cid); } 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), "*"); // Raid mark symbol — small, just to the left of the leader crown { static const struct { const char* sym; ImU32 col; } kCellMarks[] = { { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, }; uint8_t rmk = gameHandler.getEntityRaidMark(m.guid); if (rmk < game::GameHandler::kRaidMarkCount) { ImFont* rmFont = ImGui::GetFont(); ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; draw->AddText(rmFont, 9.0f, ImVec2(rmX, cellMin.y + 2.0f), kCellMarks[rmk].col, kCellMarks[rmk].sym); } } // 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(hp) / static_cast(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 = isOOR ? IM_COL32(100, 100, 100, 160) : 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 or OOR text centered on bar char hpPct[8]; if (isOOR) snprintf(hpPct, sizeof(hpPct), "OOR"); else snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(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(m.curPower) / static_cast(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); } // Dispellable debuff dots at the bottom of the raid cell // Mirrors party frame debuff indicators for healers in 25/40-man raids if (!isDead && !isGhost) { const std::vector* unitAuras = nullptr; if (m.guid == gameHandler.getPlayerGuid()) unitAuras = &gameHandler.getPlayerAuras(); else if (m.guid == gameHandler.getTargetGuid()) unitAuras = &gameHandler.getTargetAuras(); else unitAuras = gameHandler.getUnitAuras(m.guid); if (unitAuras) { bool shown[5] = {}; float dotX = cellMin.x + 4.0f; const float dotY = cellMax.y - 5.0f; const float DOT_R = 3.5f; ImVec2 mouse = ImGui::GetMousePos(); for (const auto& aura : *unitAuras) { if (aura.isEmpty()) continue; if ((aura.flags & 0x80) == 0) continue; // debuffs only uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); if (dt == 0 || dt > 4 || shown[dt]) continue; shown[dt] = true; ImVec4 dc; switch (dt) { case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green default: continue; } ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); float mdx = mouse.x - dotX, mdy = mouse.y - dotY; if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; ImGui::BeginTooltip(); ImGui::TextColored(dc, "%s", kDispelNames[dt]); for (const auto& da : *unitAuras) { if (da.isEmpty() || (da.flags & 0x80) == 0) continue; if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; const std::string& dName = gameHandler.getSpellName(da.spellId); if (!dName.empty()) ImGui::Text(" %s", dName.c_str()); } ImGui::EndTooltip(); } dotX += 9.0f; } } } // Clickable invisible region over the whole cell ImGui::SetCursorScreenPos(cellMin); ImGui::PushID(static_cast(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(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(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 — use WoW class colors when entity is loaded, // fall back to gold for leader / light gray for others ImVec4 nameColor = isLeader ? ImVec4(1.0f, 0.85f, 0.0f, 1.0f) : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); { auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); uint8_t cid = entityClassId(memberEntity.get()); if (cid != 0) nameColor = classColorVec4(cid); } ImGui::PushStyleColor(ImGuiCol_Text, nameColor); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } 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]"); } } // Raid mark symbol — shown on same line as name when this party member has a mark { static const struct { const char* sym; ImU32 col; } kPartyMarks[] = { { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull }; uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); if (pmk < game::GameHandler::kRaidMarkCount) { ImGui::SameLine(); ImGui::TextColored( ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), "%s", kPartyMarks[pmk].sym); } } // 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(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; } // Out-of-range check: compare player position to member's reported position // Range threshold: 40 yards (standard heal/spell range) bool memberOutOfRange = false; if (member.hasPartyStats && !memberOffline && !memberDead && member.zoneId != 0) { // Same map: use 2D Euclidean distance in WoW coordinates (yards) auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); if (playerEntity) { float dx = playerEntity->getX() - static_cast(member.posX); float dy = playerEntity->getY() - static_cast(member.posY); float distSq = dx * dx + dy * dy; memberOutOfRange = (distSq > 40.0f * 40.0f); } } 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(hp) / static_cast(maxHp); // Out-of-range: desaturate health bar to gray ImVec4 hpBarColor = memberOutOfRange ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) : (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)); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); char hpText[32]; if (memberOutOfRange) { snprintf(hpText, sizeof(hpText), "OOR"); } else 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/OOR if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { float powerPct = static_cast(member.curPower) / static_cast(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(); } // Dispellable debuff indicators — small colored dots for party member debuffs // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable if (!memberDead && !memberOffline) { const std::vector* unitAuras = nullptr; if (member.guid == gameHandler.getPlayerGuid()) unitAuras = &gameHandler.getPlayerAuras(); else if (member.guid == gameHandler.getTargetGuid()) unitAuras = &gameHandler.getTargetAuras(); else unitAuras = gameHandler.getUnitAuras(member.guid); if (unitAuras) { bool anyDebuff = false; for (const auto& aura : *unitAuras) { if (aura.isEmpty()) continue; if ((aura.flags & 0x80) == 0) continue; // only debuffs uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); if (dt == 0) continue; // skip non-dispellable anyDebuff = true; break; } if (anyDebuff) { // Render one dot per unique dispel type present bool shown[5] = {}; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); for (const auto& aura : *unitAuras) { if (aura.isEmpty()) continue; if ((aura.flags & 0x80) == 0) continue; uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); if (dt == 0 || dt > 4 || shown[dt]) continue; shown[dt] = true; ImVec4 dotCol; switch (dt) { case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green default: break; } ImGui::PushStyleColor(ImGuiCol_Button, dotCol); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); ImGui::Button("##d", ImVec2(8.0f, 8.0f)); ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) { static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; // Find spell name(s) of this dispel type ImGui::BeginTooltip(); ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); for (const auto& da : *unitAuras) { if (da.isEmpty() || (da.flags & 0x80) == 0) continue; if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; const std::string& dName = gameHandler.getSpellName(da.spellId); if (!dName.empty()) ImGui::Text(" %s", dName.c_str()); } ImGui::EndTooltip(); } ImGui::SameLine(); } ImGui::NewLine(); ImGui::PopStyleVar(); } } } // 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); { VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; if (pIcon) { ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); ImGui::SameLine(0, 2); ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); } else { 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(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(); } // ============================================================ // Durability Warning (equipment damage indicator) // ============================================================ void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { if (gameHandler.getPlayerGuid() == 0) return; const auto& inv = gameHandler.getInventory(); // Scan all equipment slots (skip bag slots which have no durability) float minDurPct = 1.0f; bool hasBroken = false; for (int i = static_cast(game::EquipSlot::HEAD); i < static_cast(game::EquipSlot::BAG1); ++i) { const auto& slot = inv.getEquipSlot(static_cast(i)); if (slot.empty() || slot.item.maxDurability == 0) continue; if (slot.item.curDurability == 0) { hasBroken = true; } float pct = static_cast(slot.item.curDurability) / static_cast(slot.item.maxDurability); if (pct < minDurPct) minDurPct = pct; } // Only show warning below 20% if (minDurPct >= 0.2f && !hasBroken) return; ImGuiIO& io = ImGui::GetIO(); const float screenW = io.DisplaySize.x; const float screenH = io.DisplaySize.y; // Position: just above the XP bar / action bar area (bottom-center) const float warningW = 220.0f; const float warningH = 26.0f; const float posX = (screenW - warningW) * 0.5f; const float posY = screenH - 140.0f; // above action bar ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); ImGui::SetNextWindowBgAlpha(0.75f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus; if (ImGui::Begin("##durability_warn", nullptr, flags)) { if (hasBroken) { ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), "\xef\x94\x9b Gear broken! Visit a repair NPC"); } else { int pctInt = static_cast(minDurPct * 100.0f); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xef\x94\x9b Low durability: %d%%", pctInt); } if (ImGui::IsWindowHovered()) ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); } ImGui::End(); ImGui::PopStyleVar(3); } // ============================================================ // 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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(alpha * 255.0f); ImU32 textCol = IM_COL32(255, 50, 50, a8); ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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); } } void GameScreen::renderQuestCompleteToasts(float deltaTime) { for (auto& e : questCompleteToasts_) e.age += deltaTime; questCompleteToasts_.erase( std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), questCompleteToasts_.end()); if (questCompleteToasts_.empty()) return; auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; const float toastW = 260.0f; const float toastH = 40.0f; const float padY = 4.0f; const float baseY = screenH - 220.0f; // above rep toasts ImDrawList* draw = ImGui::GetForegroundDrawList(); ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { const auto& e = questCompleteToasts_[i]; constexpr float kSlideDur = 0.3f; float slideIn = std::min(e.age, kSlideDur) / kSlideDur; float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; float slide = std::min(slideIn, slideOut); float alpha = std::clamp(slide, 0.0f, 1.0f); float xFull = screenW - 14.0f - 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 + gold border (quest completion) draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, (int)(alpha * 210)), 5.0f); draw->AddRect(tl, br, IM_COL32(220, 180, 30, (int)(alpha * 230)), 5.0f, 0, 1.5f); // Scroll icon placeholder (gold diamond) float iconCx = tl.x + 18.0f; float iconCy = tl.y + toastH * 0.5f; draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, (int)(alpha * 230))); draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, (int)(alpha * 200))); // "Quest Complete" header in gold const char* header = "Quest Complete"; draw->AddText(font, fontSize * 0.78f, ImVec2(tl.x + 34.0f, tl.y + 4.0f), IM_COL32(240, 200, 40, (int)(alpha * 240)), header); // Quest title in off-white const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); draw->AddText(font, fontSize * 0.82f, ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), IM_COL32(220, 215, 195, (int)(alpha * 220)), titleStr); } } // ============================================================ // Zone Entry Toast // ============================================================ void GameScreen::renderZoneToasts(float deltaTime) { for (auto& e : zoneToasts_) e.age += deltaTime; zoneToasts_.erase( std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), zoneToasts_.end()); if (zoneToasts_.empty()) return; auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; ImDrawList* draw = ImGui::GetForegroundDrawList(); ImFont* font = ImGui::GetFont(); for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { const auto& e = zoneToasts_[i]; constexpr float kSlideDur = 0.35f; float slideIn = std::min(e.age, kSlideDur) / kSlideDur; float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; float slide = std::min(slideIn, slideOut); float alpha = std::clamp(slide, 0.0f, 1.0f); // Measure text to size the toast ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); const char* header = "Entering:"; ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; float toastH = 42.0f; // Center the toast horizontally, appear just below the zone name area (top-center) float toastX = (screenW - toastW) * 0.5f; float toastY = 56.0f + i * (toastH + 4.0f); // Slide down from above float offY = (1.0f - slide) * (-toastH - 10.0f); toastY += offY; ImVec2 tl(toastX, toastY); ImVec2 br(toastX + toastW, toastY + toastH); draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, (int)(alpha * 200)), 6.0f); draw->AddRect(tl, br, IM_COL32(160, 140, 80, (int)(alpha * 220)), 6.0f, 0, 1.2f); float cx = tl.x + toastW * 0.5f; draw->AddText(font, 11.0f, ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), IM_COL32(180, 170, 120, (int)(alpha * 200)), header); draw->AddText(font, 14.0f, ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), IM_COL32(255, 230, 140, (int)(alpha * 240)), e.zoneName.c_str()); } } // ─── Area Trigger Message Toasts ───────────────────────────────────────────── void GameScreen::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { // Drain any pending messages from GameHandler while (gameHandler.hasAreaTriggerMsg()) { AreaTriggerToast t; t.text = gameHandler.popAreaTriggerMsg(); t.age = 0.0f; areaTriggerToasts_.push_back(std::move(t)); if (areaTriggerToasts_.size() > 4) areaTriggerToasts_.erase(areaTriggerToasts_.begin()); } // Age and prune constexpr float kLifetime = 4.5f; for (auto& t : areaTriggerToasts_) t.age += deltaTime; areaTriggerToasts_.erase( std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), areaTriggerToasts_.end()); if (areaTriggerToasts_.empty()) return; auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; ImDrawList* draw = ImGui::GetForegroundDrawList(); ImFont* font = ImGui::GetFont(); constexpr float kSlideDur = 0.35f; for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { const auto& t = areaTriggerToasts_[i]; float slideIn = std::min(t.age, kSlideDur) / kSlideDur; float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); // Measure text ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); float toastW = txtSz.x + 30.0f; float toastH = 30.0f; // Center horizontally, place below zone text (center of lower-third) float toastX = (screenW - toastW) * 0.5f; float toastY = screenH * 0.62f + i * (toastH + 3.0f); // Slide up from below float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); toastY += offY; ImVec2 tl(toastX, toastY); ImVec2 br(toastX + toastW, toastY + toastH); draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, (int)(alpha * 190)), 5.0f); draw->AddRect(tl, br, IM_COL32(100, 160, 220, (int)(alpha * 200)), 5.0f, 0, 1.0f); float cx = tl.x + toastW * 0.5f; // Shadow draw->AddText(font, 13.0f, ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), IM_COL32(0, 0, 0, (int)(alpha * 180)), t.text.c_str()); // Text in light blue draw->AddText(font, 13.0f, ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), IM_COL32(180, 220, 255, (int)(alpha * 240)), t.text.c_str()); } } // ============================================================ // Boss Encounter Frames // ============================================================ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { auto* assetMgr = core::Application::getInstance().getAssetManager(); // Collect active boss unit slots struct BossSlot { uint32_t slot; uint64_t guid; }; std::vector 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(bs.guid)); // Try to resolve name, health, and power from entity manager std::string name = "Boss"; uint32_t hp = 0, maxHp = 0; uint8_t bossPowerType = 0; uint32_t bossPower = 0, bossMaxPower = 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(entity); const auto& n = unit->getName(); if (!n.empty()) name = n; hp = unit->getHealth(); maxHp = unit->getMaxHealth(); bossPowerType = unit->getPowerType(); bossPower = unit->getPower(); bossMaxPower = unit->getMaxPower(); } // 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(hp) / static_cast(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 power bar — shown when boss has a non-zero power pool // Energy bosses (type 3) are particularly important: full energy signals ability use if (bossMaxPower > 0 && bossPower > 0) { float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); ImVec4 bpColor; switch (bossPowerType) { case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue case 1: bpColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage: red case 2: bpColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus: orange case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); char bpLabel[24]; std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); 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(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); { VkDescriptorSet bIcon = (bspell != 0 && assetMgr) ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; if (bIcon) { ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); ImGui::SameLine(0, 2); ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); } else { ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); } } ImGui::PopStyleColor(); } // Boss aura row: debuffs first (player DoTs), then boss buffs { const std::vector* bossAuras = nullptr; if (bs.guid == gameHandler.getTargetGuid()) bossAuras = &gameHandler.getTargetAuras(); else bossAuras = gameHandler.getUnitAuras(bs.guid); if (bossAuras) { int bossActive = 0; for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; if (bossActive > 0) { constexpr float BA_ICON = 16.0f; constexpr int BA_PER_ROW = 10; uint64_t baNowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); // Sort: player-applied debuffs first (most relevant), then others const uint64_t pguid = gameHandler.getPlayerGuid(); std::vector baIdx; baIdx.reserve(bossAuras->size()); for (size_t i = 0; i < bossAuras->size(); ++i) if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { const auto& aa = (*bossAuras)[a]; const auto& ab = (*bossAuras)[b]; bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; bool aDebuff = (aa.flags & 0x80) != 0; bool bDebuff = (ab.flags & 0x80) != 0; if (aDebuff != bDebuff) return aDebuff > bDebuff; int32_t ra = aa.getRemainingMs(baNowMs); int32_t rb = ab.getRemainingMs(baNowMs); if (ra < 0 && rb < 0) return false; if (ra < 0) return false; if (rb < 0) return true; return ra < rb; }); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); int baShown = 0; for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { const auto& aura = (*bossAuras)[baIdx[si]]; bool isBuff = (aura.flags & 0x80) == 0; bool isPlayerCast = (aura.casterGuid == pguid); if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); ImGui::PushID(static_cast(baIdx[si]) + 7000); ImVec4 borderCol; if (isBuff) { // Boss buffs: gold for important enrage/shield types borderCol = ImVec4(0.8f, 0.6f, 0.1f, 0.9f); } else { uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); switch (dt) { case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; default: borderCol = isPlayerCast ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red break; } } VkDescriptorSet baIcon = assetMgr ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; if (baIcon) { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); ImGui::ImageButton("##baura", (ImTextureID)(uintptr_t)baIcon, ImVec2(BA_ICON - 2, BA_ICON - 2)); ImGui::PopStyleVar(); ImGui::PopStyleColor(); } else { ImGui::PushStyleColor(ImGuiCol_Button, borderCol); char lab[8]; snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); ImGui::Button(lab, ImVec2(BA_ICON, BA_ICON)); ImGui::PopStyleColor(); } // Duration overlay int32_t baRemain = aura.getRemainingMs(baNowMs); if (baRemain > 0) { ImVec2 imin = ImGui::GetItemRectMin(); ImVec2 imax = ImGui::GetItemRectMax(); char ts[12]; int s = (baRemain + 999) / 1000; if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); else snprintf(ts, sizeof(ts), "%d", s); ImVec2 tsz = ImGui::CalcTextSize(ts); float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; float cy = imax.y - tsz.y; ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); } // Stack / charge count — upper-left corner (parity with target/focus frames) if (aura.charges > 1) { ImVec2 baMin = ImGui::GetItemRectMin(); char chargeStr[8]; snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), IM_COL32(0, 0, 0, 200), chargeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), IM_COL32(255, 220, 50, 255), chargeStr); } // Tooltip if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); bool richOk = spellbookScreen.renderSpellInfoTooltip( aura.spellId, gameHandler, assetMgr); if (!richOk) { std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); ImGui::Text("%s", nm.c_str()); } if (isPlayerCast && !isBuff) ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); if (baRemain > 0) { int s = baRemain / 1000; char db[32]; if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); } ImGui::EndTooltip(); } ImGui::PopID(); baShown++; } ImGui::PopStyleVar(); } } } 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(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(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::renderDuelCountdown(game::GameHandler& gameHandler) { float remaining = gameHandler.getDuelCountdownRemaining(); if (remaining <= 0.0f) 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; auto* dl = ImGui::GetForegroundDrawList(); ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); // Show integer countdown or "Fight!" when under 0.5s char buf[32]; if (remaining > 0.5f) { snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); } else { snprintf(buf, sizeof(buf), "Fight!"); } // Large font by scaling — use 4x font size for dramatic effect float scale = 4.0f; float scaledSize = fontSize * scale; ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); float tx = (screenW - textSz.x) * 0.5f; float ty = screenH * 0.35f - textSz.y * 0.5f; // Pulsing alpha: fades in and out per second float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); uint8_t alpha = static_cast(255 * pulse); // Color: golden countdown, red "Fight!" ImU32 color = (remaining > 0.5f) ? IM_COL32(255, 200, 50, alpha) : IM_COL32(255, 60, 60, alpha); // Drop shadow dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); } void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { if (!gameHandler.isItemTextOpen()) return; auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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(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(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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& 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(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(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(i), 255u, static_cast(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(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]; // Countdown bar { auto now = std::chrono::steady_clock::now(); float elapsedMs = static_cast( std::chrono::duration_cast(now - roll.rollStartedAt).count()); float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); float remainSec = (totalMs - elapsedMs) / 1000.0f; if (remainSec < 0.0f) remainSec = 0.0f; // Color: green → yellow → red ImVec4 barColor; if (fraction > 0.5f) barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); else if (fraction > 0.2f) barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); else { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); } char timeBuf[16]; std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); ImGui::PopStyleColor(); } 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); } // Live roll results from group members if (!roll.playerRolls.empty()) { ImGui::Separator(); ImGui::TextDisabled("Rolls so far:"); // Roll-type label + color static const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; static const ImVec4 kRollColors[] = { ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple ImVec4(0.5f, 0.5f, 0.5f, 1.0f), // Pass — gray }; auto rollTypeIndex = [](uint8_t t) -> int { if (t == 0) return 0; if (t == 1) return 1; if (t == 2) return 2; return 3; // pass (96 or unknown) }; if (ImGui::BeginTable("##lootrolls", 3, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); for (const auto& r : roll.playerRolls) { int ri = rollTypeIndex(r.rollType); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r.playerName.c_str()); ImGui::TableSetColumnIndex(1); ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); ImGui::TableSetColumnIndex(2); if (r.rollType != 96) { ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); } else { ImGui::TextDisabled("—"); } } ImGui::EndTable(); } } } ImGui::End(); } void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) { if (!gameHandler.hasPendingGuildInvite()) return; auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(); } // Live player responses const auto& results = gameHandler.getReadyCheckResults(); if (!results.empty()) { ImGui::Separator(); if (ImGui::BeginTable("##rcresults", 2, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); for (const auto& r : results) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r.name.c_str()); ImGui::TableSetColumnIndex(1); if (r.ready) { ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); } else { ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); } } ImGui::EndTable(); } } } 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(now - slot->inviteReceivedTime).count(); double remaining = static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(remaining)); ImGui::Spacing(); // Countdown progress bar float frac = static_cast(remaining / static_cast(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(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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::TextDisabled("Cost:"); ImGui::SameLine(0, 4); renderCoinsText(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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; }); 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); ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; ImGui::TableNextColumn(); ImGui::TextColored(nameColor, "%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 = classNameStr(m.classId); ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; ImGui::TextColored(classCol, "%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(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, class, and status if (c.isOnline()) { ImGui::SameLine(150.0f); const char* statusLabel = (c.status == 2) ? " (AFK)" : (c.status == 3) ? " (DND)" : ""; // Class color for the level/class display ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); const char* friendClassName = classNameStr(static_cast(c.classId)); if (c.level > 0 && c.classId > 0) { ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); } else if (c.level > 0) { ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { ImGui::TextDisabled("%s", statusLabel + 1); } // Tooltip: zone info if (ImGui::IsItemHovered() && c.areaId != 0) { ImGui::BeginTooltip(); if (zoneManager) { const auto* zi = zoneManager->getZoneInfo(c.areaId); if (zi && !zi->name.empty()) ImGui::Text("Zone: %s", zi->name.c_str()); else ImGui::TextDisabled("Area ID: %u", c.areaId); } else { ImGui::TextDisabled("Area ID: %u", c.areaId); } ImGui::EndTooltip(); } } 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(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(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)); // State for "Set Note" inline editing static int noteEditContactIdx = -1; static char noteEditBuf[128] = {}; 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)) { // Get zone manager for area name lookups game::ZoneManager* socialZoneMgr = nullptr; if (auto* rend = core::Application::getInstance().getRenderer()) socialZoneMgr = rend->getZoneManager(); 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(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() ? classColorVec4(static_cast(c.classId)) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); ImGui::TextColored(nameCol, "%s", displayName); if (c.isOnline() && c.level > 0) { ImGui::SameLine(); // Show level and class name in class color ImGui::TextColored(classColorVec4(static_cast(c.classId)), "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); } // Tooltip: zone info and note if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { ImGui::BeginTooltip(); if (c.areaId != 0) { const char* zoneName = nullptr; if (socialZoneMgr) { const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); } if (zoneName) ImGui::Text("Zone: %s", zoneName); else ImGui::Text("Area ID: %u", c.areaId); } if (!c.note.empty()) ImGui::TextDisabled("Note: %s", c.note.c_str()); ImGui::EndTooltip(); } } // 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 (c.guid != 0 && ImGui::MenuItem("Trade")) gameHandler.initiateTrade(c.guid); } if (ImGui::MenuItem("Set Note")) { noteEditContactIdx = static_cast(ci); strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; ImGui::OpenPopup("##SetFriendNote"); } 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(); // "Set Note" modal popup if (ImGui::BeginPopup("##SetFriendNote")) { const std::string& noteName = (noteEditContactIdx >= 0 && noteEditContactIdx < static_cast(contacts.size())) ? contacts[noteEditContactIdx].name : ""; ImGui::TextDisabled("Note for %s:", noteName.c_str()); ImGui::SetNextItemWidth(180.0f); bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); if (confirm || ImGui::Button("OK")) { if (!noteName.empty()) gameHandler.setFriendNote(noteName, noteEditBuf); noteEditContactIdx = -1; ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel")) { noteEditContactIdx = -1; ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } 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(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(); } // ---- Arena tab (WotLK: shows per-team rating/record) ---- const auto& arenaStats = gameHandler.getArenaTeamStats(); if (!arenaStats.empty()) { if (ImGui::BeginTabItem("Arena")) { ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false); for (size_t ai = 0; ai < arenaStats.size(); ++ai) { const auto& ts = arenaStats[ai]; ImGui::PushID(static_cast(ai)); // Team header with rating char teamLabel[48]; snprintf(teamLabel, sizeof(teamLabel), "Team #%u", ts.teamId); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel); ImGui::Indent(8.0f); // Rating and rank ImGui::Text("Rating: %u", ts.rating); if (ts.rank > 0) { ImGui::SameLine(0, 6); ImGui::TextDisabled("(Rank #%u)", ts.rank); } // Weekly record uint32_t weekLosses = ts.weekGames > ts.weekWins ? ts.weekGames - ts.weekWins : 0; ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); // Season record uint32_t seasLosses = ts.seasonGames > ts.seasonWins ? ts.seasonGames - ts.seasonWins : 0; ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); ImGui::Unindent(8.0f); if (ai + 1 < arenaStats.size()) ImGui::Separator(); ImGui::PopID(); } ImGui::EndChild(); 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)) { // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first uint64_t buffNowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); std::vector buffSortedIdx; buffSortedIdx.reserve(auras.size()); for (size_t i = 0; i < auras.size(); ++i) if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { const auto& aa = auras[a]; const auto& ab = auras[b]; bool aDebuff = (aa.flags & 0x80) != 0; bool bDebuff = (ab.flags & 0x80) != 0; if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first int32_t ra = aa.getRemainingMs(buffNowMs); int32_t rb = ab.getRemainingMs(buffNowMs); if (ra < 0 && rb < 0) return false; if (ra < 0) return false; if (rb < 0) return true; return ra < rb; }); // 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 si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { size_t i = buffSortedIdx[si]; 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(i) + (pass * 256)); // Determine border color: buffs = green; debuffs use WoW dispel-type colors ImVec4 borderColor; if (isBuff) { borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green } else { // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, // 3=disease/brown, 4=poison/green, other=dark-red) uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); switch (dt) { case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red } } // 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( std::chrono::duration_cast( 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(ImGui::GetTime()) * 6.0f); timerColor = IM_COL32( static_cast(255 * pulse), static_cast(80 * pulse), static_cast(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(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(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 (auto-looted on open; shown for feedback) if (loot.gold > 0) { ImGui::TextDisabled("Gold:"); ImGui::SameLine(0, 4); renderCoinsText(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(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(lootSlotClicked)); } if (loot.items.empty() && loot.gold == 0) { gameHandler.closeLoot(); } ImGui::Spacing(); 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(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(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 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(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(npcEntity); std::string name = unit->getName(); std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c){ return static_cast(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(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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; ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); renderCoinsText(gold, silver, 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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::TextDisabled("Required money:"); ImGui::SameLine(0, 4); renderCoinsText(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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 { 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(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(i)); ImGui::PushID(static_cast(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(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; ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); renderCoinsText(g, s, 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(quest.choiceRewards.size())) { // Server expects the original slot index from its fixed-size reward array. rewardIdx = quest.choiceRewards[static_cast(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(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(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); renderCoinsText(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(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(sellPrice) * static_cast(entry.count > 0 ? entry.count : 1); uint32_t g = static_cast(price / 10000); uint32_t s = static_cast((price / 100) % 100); uint32_t c = static_cast(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(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) { renderCoinsText(g, s, c); } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); } 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(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(std::tolower(static_cast(c))); for (int vi = 0; vi < static_cast(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(std::tolower(static_cast(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(info->quality)); ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); } // 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) { renderCoinsText(g, s, c); } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); } } 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(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(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(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(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); renderCoinsText(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& 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(std::tolower(static_cast(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(std::tolower(static_cast(c))); if (nameLC.find(trainerFilter) == std::string::npos) { ImGui::PushID(static_cast(spell->spellId)); ImGui::PopID(); continue; } } ImGui::TableNextRow(); ImGui::PushID(static_cast(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::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", name.c_str()); if (!rank.empty()) ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "%s", rank.c_str()); } const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); if (!spDesc.empty()) { ImGui::Spacing(); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); ImGui::TextWrapped("%s", spDesc.c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); } ImGui::TextDisabled("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; if (canAfford) { renderCoinsText(g, s, c); } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%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& 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 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(totalCost / 10000); uint32_t tas = static_cast((totalCost / 100) % 100); uint32_t tac = static_cast(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))) { 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; } 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(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(); static uint32_t selectedNodeId = 0; int destCount = 0; 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(nodeId)); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); bool isSelected = (selectedNodeId == nodeId); if (ImGui::Selectable(node.name.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { 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); } } ImGui::TableSetColumnIndex(1); renderCoinsText(gold, silver, copper); ImGui::TableSetColumnIndex(2); if (ImGui::SmallButton("Fly")) { selectedNodeId = nodeId; LOG_INFO("Taxi UI: Fly clicked dest=", nodeId); gameHandler.activateTaxi(nodeId); } ImGui::PopID(); destCount++; } 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()) { deathTimerRunning_ = false; deathElapsed_ = 0.0f; return; } float dt = ImGui::GetIO().DeltaTime; if (!deathTimerRunning_) { deathElapsed_ = 0.0f; deathTimerRunning_ = true; } else { deathElapsed_ += dt; } auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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 = 130.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); // Respawn timer: show how long until forced release float timeLeft = kForcedReleaseSec - deathElapsed_; if (timeLeft > 0.0f) { int mins = static_cast(timeLeft) / 60; int secs = static_cast(timeLeft) % 60; char timerBuf[48]; snprintf(timerBuf, sizeof(timerBuf), "Release in %d:%02d", mins, secs); float tw = ImGui::CalcTextSize(timerBuf).x; ImGui::SetCursorPosX((dlgW - tw) / 2); ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); } 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(); 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(); 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); } } 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(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(pendingGraphicsPreset); if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) { pendingGraphicsPreset = static_cast(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(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; pendingNormalMapStrength = 0.8f; 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(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)"); if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { saveSettings(); } ImGui::SameLine(); ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); 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(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"); 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"); 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(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(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(k), false)) { if (k == ImGuiKey_Escape) { // Cancel rebinding awaitingKeyPress = false; pendingRebindAction = -1; foundKey = true; break; } newKey = static_cast(k); foundKey = true; break; } } if (foundKey && newKey != ImGuiKey_None) { auto action = static_cast(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(window->getWidth()); float screenH = static_cast(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(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(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); } } // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. // Shown whenever NPC dots are enabled (or always, since they're always useful). { constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; auto unit = std::static_pointer_cast(entity); if (!unit) continue; // Must be dead (health == 0) and marked lootable if (unit->getHealth() != 0) continue; if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) 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; // Draw a small diamond (rotated square) in light yellow-green const float dr = 3.5f; ImVec2 top (sx, sy - dr); ImVec2 right(sx + dr, sy ); ImVec2 bot (sx, sy + dr); ImVec2 left (sx - dr, sy ); drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); // Tooltip on hover if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { const std::string& nm = unit->getName(); ImGui::BeginTooltip(); ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", nm.empty() ? "Lootable corpse" : nm.c_str()); ImGui::EndTooltip(); } } } 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); // Show NPC name and quest status on hover { ImVec2 mouse = ImGui::GetMousePos(); float mdx = mouse.x - sx, mdy = mouse.y - sy; if (mdx * mdx + mdy * mdy < 64.0f) { std::string npcName; if (entity->getType() == game::ObjectType::UNIT) { auto npcUnit = std::static_pointer_cast(entity); npcName = npcUnit->getName(); } if (!npcName.empty()) { bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || status == game::QuestGiverStatus::AVAILABLE_LOW); ImGui::SetTooltip("%s\n%s", npcName.c_str(), hasQuest ? "Has a quest for you" : "Quest ready to turn in"); } } } } // Quest kill objective markers — highlight live NPCs matching active quest kill objectives { // Build map of NPC entry → (quest title, current, required) for tooltips struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; std::unordered_map killInfoMap; const auto& trackedIds = gameHandler.getTrackedQuestIds(); for (const auto& quest : gameHandler.getQuestLog()) { if (quest.complete) continue; if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; for (const auto& obj : quest.killObjectives) { if (obj.npcOrGoId <= 0 || obj.required == 0) continue; uint32_t npcEntry = static_cast(obj.npcOrGoId); auto it = quest.killCounts.find(npcEntry); uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; if (current < obj.required) { killInfoMap[npcEntry] = { quest.title, current, obj.required }; } } } if (!killInfoMap.empty()) { ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; auto unit = std::static_pointer_cast(entity); if (!unit || unit->getHealth() == 0) continue; auto infoIt = killInfoMap.find(unit->getEntry()); if (infoIt == killInfoMap.end()) continue; glm::vec3 unitRender = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(unitRender, sx, sy)) continue; // Gold circle with a dark "x" mark — indicates a quest kill target drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), IM_COL32(20, 20, 20, 230), 1.2f); drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), IM_COL32(20, 20, 20, 230), 1.2f); // Tooltip on hover float mdx = mouse.x - sx, mdy = mouse.y - sy; if (mdx * mdx + mdy * mdy < 64.0f) { const auto& ki = infoIt->second; const std::string& npcName = unit->getName(); if (!npcName.empty()) { ImGui::SetTooltip("%s\n%s: %u/%u", npcName.c_str(), ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), ki.current, ki.required); } else { ImGui::SetTooltip("%s: %u/%u", ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), ki.current, ki.required); } } } } } // 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(alpha * 200)); ImU32 col2 = IM_COL32(255, 150, 0, static_cast(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(member.posY); float wowY = static_cast(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; { auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); uint8_t cid = entityClassId(mEnt.get()); dotColor = (cid != 0) ? classColorU32(cid, 235) : (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); // Raid mark: tiny symbol drawn above the dot { static const struct { const char* sym; ImU32 col; } kMMMarks[] = { { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, }; uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); if (pmk < game::GameHandler::kRaidMarkCount) { ImFont* mmFont = ImGui::GetFont(); ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); drawList->AddText(mmFont, 9.0f, ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), kMMMarks[pmk].col, kMMMarks[pmk].sym); } } ImVec2 cursorPos = ImGui::GetMousePos(); float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); if (pmk2 < game::GameHandler::kRaidMarkCount) { static const char* kMarkNames[] = { "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull" }; ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); } else { 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"); } } } } } // 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); } // Zone name display — drawn inside the top edge of the minimap circle { auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; uint32_t zoneId = gameHandler.getWorldStateZoneId(); const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; if (zi && !zi->name.empty()) { ImFont* font = ImGui::GetFont(); float fontSize = ImGui::GetFontSize(); ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); float tx = centerX - ts.x * 0.5f; float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle float pad = 2.0f; drawList->AddRectFilled( ImVec2(tx - pad, ty - pad), ImVec2(tx + ts.x + pad, ty + ts.y + pad), IM_COL32(0, 0, 0, 160), 2.0f); drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), zi->name.c_str()); drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(255, 230, 150, 220), zi->name.c_str()); } } // Hover tooltip and right-click context menu { ImVec2 mouse = ImGui::GetMousePos(); float mdx = mouse.x - centerX; float mdy = mouse.y - centerY; bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); if (overMinimap) { ImGui::BeginTooltip(); // Compute the world coordinate under the mouse cursor // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical float rxW = mdx / mapRadius * viewRadius; float ryW = mdy / mapRadius * viewRadius; // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB float hoverDx = -cosB * rxW + sinB * ryW; float hoverDy = -sinB * rxW - cosB * ryW; glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "Ctrl+click to ping"); ImGui::EndTooltip(); if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { ImGui::OpenPopup("##minimapContextMenu"); } } if (ImGui::BeginPopup("##minimapContextMenu")) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Minimap"); ImGui::Separator(); // Zoom controls if (ImGui::MenuItem("Zoom In")) { minimap->zoomIn(); } if (ImGui::MenuItem("Zoom Out")) { minimap->zoomOut(); } ImGui::Separator(); // Toggle options with checkmarks bool rotWithCam = minimap->isRotateWithCamera(); if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { minimap->setRotateWithCamera(!rotWithCam); } bool squareShape = minimap->isSquareShape(); if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { minimap->setSquareShape(!squareShape); } bool npcDots = minimapNpcDots_; if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { minimapNpcDots_ = !minimapNpcDots_; } ImGui::EndPopup(); } } auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(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(); // Weather icon appended to zone name when active uint32_t wType = gameHandler.getWeatherType(); float wIntensity = gameHandler.getWeatherIntensity(); const char* weatherIcon = nullptr; ImU32 weatherColor = IM_COL32(255, 255, 255, 200); if (wType == 1 && wIntensity > 0.05f) { // Rain weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ weatherColor = IM_COL32(140, 180, 240, 220); } else if (wType == 2 && wIntensity > 0.05f) { // Snow weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ weatherColor = IM_COL32(210, 230, 255, 220); } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ weatherColor = IM_COL32(160, 160, 190, 220); } std::string displayName = zoneName; // Build combined string if weather active std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); float tzx = centerX - tsz.x * 0.5f; // Shadow pass fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), IM_COL32(0, 0, 0, 180), zoneName.c_str()); // Zone name in gold fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), IM_COL32(255, 220, 120, 230), zoneName.c_str()); // Weather symbol in its own color appended after if (weatherIcon) { ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); } } } // 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(ImGui::GetTime()) * 3.0f); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); } ImGui::End(); nextIndicatorY += kIndicatorH; } // Unspent talent points indicator { uint8_t unspent = gameHandler.getUnspentTalentPoints(); if (unspent > 0) { ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); char talentBuf[40]; snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", static_cast(unspent), unspent == 1 ? "" : "s"); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); } 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(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 } // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) { using LfgState = game::GameHandler::LfgState; LfgState lfgSt = gameHandler.getLfgState(); if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { if (lfgSt == LfgState::RoleCheck) { float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); } else { uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); int qMin = static_cast(qMs / 60000); int qSec = static_cast((qMs % 60000) / 1000); float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), "LFG: %d:%02d", qMin, qSec); } } ImGui::End(); nextIndicatorY += kIndicatorH; } } // 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(i)); if (slot.empty()) continue; const auto& it = slot.item; if (it.maxDurability > 0) { float pct = static_cast(it.curDurability) / static_cast(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(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 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(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; // Camera bakes the Vulkan Y-flip into the projection matrix: // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. float screenY = (ndc.y * 0.5f + 0.5f) * screenH; // 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"; out << "low_health_vignette=" << (lowHealthVignetteEnabled_ ? 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(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(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); } else if (key == "low_health_vignette") { lowHealthVignetteEnabled_ = (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(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(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(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); renderCoinsText(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(i)); bool selected = (gameHandler.getSelectedMailIndex() == static_cast(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(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]"); } // Expiry warning if within 3 days if (mail.expirationTime > 0.0f) { auto nowSec = static_cast(std::time(nullptr)); float secsLeft = mail.expirationTime - nowSec; if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { ImGui::SameLine(); int daysLeft = static_cast(secsLeft / 86400.0f); if (daysLeft == 0) { ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), " [expires today!]"); } else { ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), " [expires in %dd]", daysLeft); } } } 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(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]"); } // Show expiry date in the detail panel if (mail.expirationTime > 0.0f) { auto nowSec = static_cast(std::time(nullptr)); float secsLeft = mail.expirationTime - nowSec; // Format absolute expiry as a date using struct tm time_t expT = static_cast(mail.expirationTime); struct tm* tmExp = std::localtime(&expT); if (tmExp) { static const char* kMon[12] = { "Jan","Feb","Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec" }; const char* mname = kMon[tmExp->tm_mon]; int daysLeft = static_cast(secsLeft / 86400.0f); if (secsLeft <= 0.0f) { ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); } else if (secsLeft < 3.0f * 86400.0f) { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Expires: %s %d, %d (%d day%s!)", mname, tmExp->tm_mday, 1900 + tmExp->tm_year, daysLeft, daysLeft == 1 ? "" : "s"); } else { ImGui::TextDisabled("Expires: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); } } } 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::TextDisabled("Money:"); ImGui::SameLine(0, 4); renderCoinsText(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(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(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(mailComposeMoney_[0]) * 10000 + static_cast(mailComposeMoney_[1]) * 100 + static_cast(mailComposeMoney_[2]); uint32_t sendCost = attachCount > 0 ? static_cast(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(info2->quality) : static_cast(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(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(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(67 + bagIdx), static_cast(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(data.money / 10000); uint32_t silver = static_cast((data.money / 100) % 100); uint32_t copper = static_cast(data.money % 100); ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); renderCoinsText(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(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(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(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(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(auctionQuality_ - 1) : 0xFFFFFFFF; gameHandler.auctionSearch(auctionSearchName_, static_cast(auctionLevelMin_), static_cast(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(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; renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(4); if (auction.buyoutPrice > 0) { renderCoinsText(auction.buyoutPrice / 10000, (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); } else { ImGui::TextDisabled("--"); } ImGui::TableSetColumnIndex(5); ImGui::PushID(static_cast(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(auctionSellBid_[0]) * 10000 + static_cast(auctionSellBid_[1]) * 100 + static_cast(auctionSellBid_[2]); uint32_t buyoutCopper = static_cast(auctionSellBuyout_[0]) * 10000 + static_cast(auctionSellBuyout_[1]) * 100 + static_cast(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(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); renderCoinsText(a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) renderCoinsText(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(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(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; renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); ImGui::PushID(static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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: " 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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(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(qMs / 60000); int qSec = static_cast((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:"); const std::string& bootTarget = gameHandler.getLfgBootTargetName(); const std::string& bootReason = gameHandler.getLfgBootReason(); if (!bootTarget.empty()) { ImGui::Text("Player: "); ImGui::SameLine(); ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); } if (!bootReason.empty()) { ImGui::Text("Reason: "); ImGui::SameLine(); ImGui::TextWrapped("%s", bootReason.c_str()); } 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 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(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(days), static_cast(hours)); } else { uint64_t mins = (remaining % 3600) / 60; ImGui::Text("%lluh %llum", static_cast(hours), static_cast(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(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); } // ─── Who Results Window ─────────────────────────────────────────────────────── void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { if (!showWhoWindow_) return; const auto& results = gameHandler.getWhoResults(); ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); char title[64]; uint32_t onlineCount = gameHandler.getWhoOnlineCount(); if (onlineCount > 0) snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); else snprintf(title, sizeof(title), "Who###WhoWindow"); if (!ImGui::Begin(title, &showWhoWindow_)) { ImGui::End(); return; } // Search bar with Send button static char whoSearchBuf[64] = {}; bool doSearch = false; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), ImGuiInputTextFlags_EnterReturnsTrue)) doSearch = true; ImGui::SameLine(); if (ImGui::Button("Search", ImVec2(-1, 0))) doSearch = true; if (doSearch) { gameHandler.queryWho(std::string(whoSearchBuf)); } ImGui::Separator(); if (results.empty()) { ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); ImGui::End(); return; } // Table: Name | Guild | Level | Class | Zone if (ImGui::BeginTable("##WhoTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, ImVec2(0, 0))) { ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); ImGui::TableHeadersRow(); for (size_t i = 0; i < results.size(); ++i) { const auto& e = results[i]; ImGui::TableNextRow(); ImGui::PushID(static_cast(i)); // Name (class-colored if class is known) ImGui::TableSetColumnIndex(0); uint8_t cid = static_cast(e.classId); ImVec4 nameCol = classColorVec4(cid); ImGui::TextColored(nameCol, "%s", e.name.c_str()); // Right-click context menu on the name if (ImGui::BeginPopupContextItem("##WhoCtx")) { ImGui::TextDisabled("%s", e.name.c_str()); ImGui::Separator(); if (ImGui::MenuItem("Whisper")) { selectedChatType = 4; strncpy(whisperTargetBuffer, e.name.c_str(), sizeof(whisperTargetBuffer) - 1); whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; refocusChatInput = true; } if (ImGui::MenuItem("Invite to Group")) gameHandler.inviteToGroup(e.name); if (ImGui::MenuItem("Add Friend")) gameHandler.addFriend(e.name); if (ImGui::MenuItem("Ignore")) gameHandler.addIgnore(e.name); ImGui::EndPopup(); } // Guild ImGui::TableSetColumnIndex(1); if (!e.guildName.empty()) ImGui::TextDisabled("<%s>", e.guildName.c_str()); // Level ImGui::TableSetColumnIndex(2); ImGui::Text("%u", e.level); // Class ImGui::TableSetColumnIndex(3); const char* className = game::getClassName(static_cast(e.classId)); ImGui::TextColored(nameCol, "%s", className); // Zone ImGui::TableSetColumnIndex(4); if (e.zoneId != 0) { std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); ImGui::TextUnformatted(zoneName.empty() ? "Unknown" : zoneName.c_str()); } ImGui::PopID(); } ImGui::EndTable(); } ImGui::End(); } // ─── Combat Log Window ──────────────────────────────────────────────────────── void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { if (!showCombatLog_) return; const auto& log = gameHandler.getCombatLog(); ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); char title[64]; snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); if (!ImGui::Begin(title, &showCombatLog_)) { ImGui::End(); return; } // Filter toggles static bool filterDamage = true; static bool filterHeal = true; static bool filterMisc = true; static bool autoScroll = true; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); ImGui::Checkbox("Auto-scroll", &autoScroll); ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); if (ImGui::SmallButton("Clear")) gameHandler.clearCombatLog(); ImGui::PopStyleVar(); ImGui::Separator(); // Helper: categorize entry auto isDamageType = [](game::CombatTextEntry::Type t) { using T = game::CombatTextEntry; return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || t == T::ENVIRONMENTAL; }; auto isHealType = [](game::CombatTextEntry::Type t) { using T = game::CombatTextEntry; return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; }; // Two-column table: Time | Event description ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit; float availH = ImGui::GetContentRegionAvail().y; if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { ImGui::TableSetupScrollFreeze(0, 0); ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); for (const auto& e : log) { // Apply filters bool isDmg = isDamageType(e.type); bool isHeal = isHealType(e.type); bool isMisc = !isDmg && !isHeal; if (isDmg && !filterDamage) continue; if (isHeal && !filterHeal) continue; if (isMisc && !filterMisc) continue; // Format timestamp as HH:MM:SS char timeBuf[10]; { struct tm* tm_info = std::localtime(&e.timestamp); if (tm_info) snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); else snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); } // Build event description and choose color char desc[256]; ImVec4 color; using T = game::CombatTextEntry; const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); const char* spell = spellName.empty() ? nullptr : spellName.c_str(); switch (e.type) { case T::MELEE_DAMAGE: snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); break; case T::CRIT_DAMAGE: snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : ImVec4(1.0f, 0.2f, 0.2f, 1.0f); break; case T::SPELL_DAMAGE: if (spell) snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); else snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); break; case T::PERIODIC_DAMAGE: if (spell) snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); else snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); break; case T::HEAL: if (spell) snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); else snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); color = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); break; case T::CRIT_HEAL: if (spell) snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); else snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); color = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; case T::PERIODIC_HEAL: if (spell) snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); else snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); break; case T::MISS: snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::DODGE: snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::PARRY: snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); break; case T::BLOCK: snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); break; case T::IMMUNE: snprintf(desc, sizeof(desc), "%s is immune", tgt); color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); break; case T::ABSORB: snprintf(desc, sizeof(desc), "%d absorbed", e.amount); color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); break; case T::RESIST: snprintf(desc, sizeof(desc), "%d resisted", e.amount); color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); break; case T::ENVIRONMENTAL: snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); break; case T::ENERGIZE: if (spell) snprintf(desc, sizeof(desc), "%s gains %d power (%s)", tgt, e.amount, spell); else snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; case T::XP_GAIN: snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); break; case T::PROC_TRIGGER: if (spell) snprintf(desc, sizeof(desc), "%s procs!", spell); else snprintf(desc, sizeof(desc), "Proc triggered"); color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); break; default: snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); break; } ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("%s", timeBuf); ImGui::TableSetColumnIndex(1); ImGui::TextColored(color, "%s", desc); // Hover tooltip: show rich spell info for entries with a known spell if (e.spellId != 0 && ImGui::IsItemHovered()) { auto* assetMgrLog = core::Application::getInstance().getAssetManager(); ImGui::BeginTooltip(); bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); if (!richOk) { ImGui::Text("%s", spellName.c_str()); } ImGui::EndTooltip(); } } // Auto-scroll to bottom if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); ImGui::EndTable(); } ImGui::End(); } // ─── 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(tolower(static_cast(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 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(tolower(static_cast(c))); if (lower.find(filter) == std::string::npos) continue; } ImGui::PushID(static_cast(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(); // Points badge uint32_t pts = gameHandler.getAchievementPoints(id); if (pts > 0) { ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); ImGui::Separator(); } // Description const std::string& desc = gameHandler.getAchievementDescription(id); if (!desc.empty()) { ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); ImGui::TextUnformatted(desc.c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); } // Earn date uint32_t packed = gameHandler.getAchievementDate(id); if (packed != 0) { // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] int minute = (packed >> 3) & 0x3F; int hour = (packed >> 9) & 0x1F; int day = (packed >> 17) & 0x1F; int month = (packed >> 21) & 0x0F; int year = ((packed >> 25) & 0x7F) + 2000; static const char* kMonths[12] = { "Jan","Feb","Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec" }; const char* mname = (month >= 1 && month <= 12) ? kMonths[month - 1] : "?"; ImGui::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); } 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)) { // Lazy-load AchievementCriteria.dbc for descriptions struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; static std::unordered_map s_criteriaData; static bool s_criteriaDataLoaded = false; if (!s_criteriaDataLoaded) { s_criteriaDataLoaded = true; auto* am = core::Application::getInstance().getAssetManager(); if (am && am->isInitialized()) { auto dbc = am->loadDBC("AchievementCriteria.dbc"); if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { const auto* acL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; uint32_t achField = acL ? acL->field("AchievementID") : 1u; uint32_t qtyField = acL ? acL->field("Quantity") : 4u; uint32_t descField = acL ? acL->field("Description") : 9u; if (achField == 0xFFFFFFFF) achField = 1; if (qtyField == 0xFFFFFFFF) qtyField = 4; if (descField == 0xFFFFFFFF) descField = 9; uint32_t fc = dbc->getFieldCount(); for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { uint32_t cid = dbc->getUInt32(r, 0); if (cid == 0) continue; CriteriaEntry ce; ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; s_criteriaData[cid] = std::move(ce); } } } } if (criteria.empty()) { ImGui::TextDisabled("No criteria progress received yet."); } else { ImGui::BeginChild("##critlist", ImVec2(0, 0), false); std::vector> clist(criteria.begin(), criteria.end()); std::sort(clist.begin(), clist.end()); for (const auto& [cid, cval] : clist) { auto ceIt = s_criteriaData.find(cid); // Build display text for filtering std::string display; if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { display = ceIt->second.description; } else { display = std::to_string(cid); } if (!filter.empty()) { std::string lower = display; for (char& c : lower) c = static_cast(tolower(static_cast(c))); // Also allow filtering by achievement name if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); std::string achLower = achName; for (char& c : achLower) c = static_cast(tolower(static_cast(c))); if (achLower.find(filter) == std::string::npos) continue; } else if (lower.find(filter) == std::string::npos) { continue; } } ImGui::PushID(static_cast(cid)); if (ceIt != s_criteriaData.end()) { // Show achievement name as header (dim) const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); if (!achName.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); ImGui::SameLine(); ImGui::TextDisabled(">"); ImGui::SameLine(); } if (!ceIt->second.description.empty()) { ImGui::TextUnformatted(ceIt->second.description.c_str()); } else { ImGui::TextDisabled("Criteria %u", cid); } ImGui::SameLine(); if (ceIt->second.quantity > 0) { ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%llu/%llu", static_cast(cval), static_cast(ceIt->second.quantity)); } else { ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%llu", static_cast(cval)); } } else { ImGui::TextDisabled("Criteria %u:", cid); ImGui::SameLine(); ImGui::Text("%llu", static_cast(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; // Pre-scan to find the player's rank and threat percentage uint64_t playerGuid = gameHandler.getPlayerGuid(); int playerRank = 0; float playerPct = 0.0f; { int scan = 0; for (const auto& e : *list) { ++scan; if (e.victimGuid == playerGuid) { playerRank = scan; playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; break; } if (scan >= 10) break; } } // Status bar: aggro alert or rank summary if (playerRank == 1) { // Player has aggro — persistent red warning ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); } else if (playerRank > 1 && playerPct >= 0.8f) { // Close to pulling — pulsing warning float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); } else if (playerRank > 0) { ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); } ImGui::TextDisabled("%-19s Threat", "Player"); ImGui::Separator(); 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(entity); victimName = p->getName().empty() ? "Player" : p->getName(); } else if (entity->getType() == game::ObjectType::UNIT) { auto u = std::static_pointer_cast(entity); victimName = u->getName().empty() ? "NPC" : u->getName(); } } if (victimName.empty()) victimName = "0x" + [&](){ char buf[20]; snprintf(buf, sizeof(buf), "%llX", static_cast(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(); } // ─── BG Scoreboard ──────────────────────────────────────────────────────────── void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { if (!showBgScoreboard_) return; const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); const char* title = "Battleground Score###BgScore"; if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); return; } if (!data) { ImGui::TextDisabled("No score data yet."); ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); ImGui::End(); return; } // Winner banner if (data->hasWinner) { const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); ImGui::TextColored(winnerColor, "%s", winnerStr); ImGui::SameLine(0, 4); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!"); ImGui::Separator(); } // Refresh button if (ImGui::SmallButton("Refresh")) { gameHandler.requestPvpLog(); } ImGui::SameLine(); ImGui::TextDisabled("%zu players", data->players.size()); // Score table constexpr ImGuiTableFlags kTableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; // Build dynamic column count based on what BG-specific stats are present int numBgCols = 0; std::vector bgColNames; for (const auto& ps : data->players) { for (const auto& [fieldName, val] : ps.bgStats) { // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") std::string shortName = fieldName; auto dotPos = fieldName.rfind('.'); if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); bool found = false; for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } if (!found) bgColNames.push_back(shortName); } } numBgCols = static_cast(bgColNames.size()); // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific int totalCols = 6 + numBgCols; float tableH = ImGui::GetContentRegionAvail().y; if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); for (const auto& col : bgColNames) ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); ImGui::TableHeadersRow(); // Sort: Alliance first, then Horde; within each team by KB desc std::vector sorted; sorted.reserve(data->players.size()); for (const auto& ps : data->players) sorted.push_back(&ps); std::stable_sort(sorted.begin(), sorted.end(), [](const game::GameHandler::BgPlayerScore* a, const game::GameHandler::BgPlayerScore* b) { if (a->team != b->team) return a->team > b->team; // Alliance(1) first return a->killingBlows > b->killingBlows; }); uint64_t playerGuid = gameHandler.getPlayerGuid(); for (const auto* ps : sorted) { ImGui::TableNextRow(); // Team ImGui::TableNextColumn(); if (ps->team == 1) ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance"); else ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde"); // Name (highlight player's own row) ImGui::TableNextColumn(); bool isSelf = (ps->guid == playerGuid); if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); ImGui::TextUnformatted(nameStr); if (isSelf) ImGui::PopStyleColor(); ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); for (const auto& col : bgColNames) { ImGui::TableNextColumn(); uint32_t val = 0; for (const auto& [fieldName, fval] : ps->bgStats) { std::string shortName = fieldName; auto dotPos = fieldName.rfind('.'); if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); if (shortName == col) { val = fval; break; } } if (val > 0) ImGui::Text("%u", val); else ImGui::TextDisabled("-"); } } ImGui::EndTable(); } 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 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; // Lazy-load SpellItemEnchantment.dbc for enchant name lookup static std::unordered_map s_enchantNames; static bool s_enchantDbLoaded = false; auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { s_enchantDbLoaded = true; auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); if (dbc && dbc->isLoaded()) { const auto* layout = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; uint32_t idField = layout ? (*layout)["ID"] : 0; uint32_t nameField = layout ? (*layout)["Name"] : 8; for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, idField); if (id == 0) continue; std::string nm = dbc->getString(i, nameField); if (!nm.empty()) s_enchantNames[id] = std::move(nm); } } } // 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; } // Player name — class-colored if entity is loaded, else gold { auto ent = gameHandler.getEntityManager().getEntity(result->guid); uint8_t cid = entityClassId(ent.get()); ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ImVec4(1.0f, 0.82f, 0.0f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, nameColor); ImGui::Text("%s", result->playerName.c_str()); ImGui::PopStyleColor(); if (cid != 0) { ImGui::SameLine(); ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); } } 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 { // Average item level (only slots that have loaded info and are not shirt/tabard) // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention uint32_t iLevelSum = 0; int iLevelCount = 0; for (int s = 0; s < 19; ++s) { if (s == 3 || s == 18) continue; // shirt, tabard uint32_t entry = result->itemEntries[s]; if (entry == 0) continue; const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); if (info && info->valid && info->itemLevel > 0) { iLevelSum += info->itemLevel; ++iLevelCount; } } if (iLevelCount > 0) { float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); ImGui::SameLine(); ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); } 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(info->quality)); uint16_t enchantId = result->enchantIds[s]; // 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()); // Enchant indicator on the same row as the name if (enchantId != 0) { auto enchIt = s_enchantNames.find(enchantId); const std::string& enchName = (enchIt != s_enchantNames.end()) ? enchIt->second : std::string{}; ImGui::SameLine(); if (!enchName.empty()) { ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ } else { ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); } } 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