From 751e6fdbdef6c5ea9b5b33a61eeac2e806e10a3d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Feb 2026 15:29:19 -0800 Subject: [PATCH] Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision - Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets) - Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing - Fix stale character data between logins by replacing static init flag with per-character GUID tracking - Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer) - Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes - Reduce camera ground samples from 5 to 3 movement-aligned probes - Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support --- include/game/game_handler.hpp | 6 +- include/game/world_packets.hpp | 2 +- include/rendering/wmo_renderer.hpp | 20 +- include/ui/character_screen.hpp | 8 + include/ui/game_screen.hpp | 10 +- src/game/game_handler.cpp | 136 +++++++++- src/game/world_packets.cpp | 4 +- src/rendering/camera_controller.cpp | 27 +- src/rendering/wmo_renderer.cpp | 376 +++++++++++++++++++-------- src/ui/character_screen.cpp | 386 ++++++++++++++++------------ src/ui/game_screen.cpp | 73 +++++- 11 files changed, 741 insertions(+), 307 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 706dfb55..fa054628 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -324,6 +324,10 @@ public: const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); + void saveCharacterConfig(); + void loadCharacterConfig(); + static std::string getCharacterConfigDir(); + // Auras const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } @@ -449,7 +453,7 @@ public: // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); - void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); + void buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count); void sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count); void sellItemBySlot(int backpackIndex); void autoEquipItemBySlot(int backpackIndex); diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 5c43c2ea..01346c73 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1637,7 +1637,7 @@ public: /** CMSG_BUY_ITEM packet builder */ class BuyItemPacket { public: - static network::Packet build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count); + static network::Packet build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count); }; /** CMSG_SELL_ITEM packet builder */ diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 45fa79fd..46bf008d 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -245,6 +245,8 @@ private: glm::vec3 boundingBoxMin; glm::vec3 boundingBoxMax; + uint32_t groupFlags = 0; + // Material batches (start index, count, material ID) struct Batch { uint32_t startIndex; // First index in EBO @@ -258,6 +260,8 @@ private: GLuint texId; bool hasTexture; bool alphaTest; + bool unlit = false; + uint32_t blendMode = 0; std::vector counts; std::vector offsets; }; @@ -302,6 +306,9 @@ private: // Material blend modes (materialId -> blendMode; 1 = alpha-test cutout) std::vector materialBlendModes; + // Material flags (materialId -> flags; 0x01 = unlit) + std::vector materialFlags; + // Portal visibility data std::vector portals; std::vector portalVertices; @@ -339,7 +346,7 @@ private: /** * Create GPU resources for a WMO group */ - bool createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources); + bool createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources, uint32_t groupFlags = 0); /** * Render a single group @@ -492,6 +499,17 @@ private: mutable std::vector candidateScratch; mutable std::unordered_set candidateIdScratch; + // Parallel visibility culling + uint32_t numCullThreads_ = 1; + + struct InstanceDrawList { + size_t instanceIndex; + std::vector visibleGroups; // group indices that passed culling + uint32_t portalCulled = 0; + uint32_t distanceCulled = 0; + uint32_t occlusionCulled = 0; + }; + // Collision query profiling (per frame). mutable double queryTimeMs = 0.0; mutable uint32_t queryCallCount = 0; diff --git a/include/ui/character_screen.hpp b/include/ui/character_screen.hpp index e9a9b7f1..f9f3b12e 100644 --- a/include/ui/character_screen.hpp +++ b/include/ui/character_screen.hpp @@ -54,6 +54,7 @@ private: int selectedCharacterIndex = -1; bool characterSelected = false; uint64_t selectedCharacterGuid = 0; + bool restoredLastCharacter = false; // Status std::string statusMessage; @@ -69,6 +70,13 @@ private: * Get faction color based on race */ ImVec4 getFactionColor(game::Race race) const; + + /** + * Persist / restore last selected character GUID + */ + static std::string getConfigDir(); + void saveLastCharacter(uint64_t guid); + uint64_t loadLastCharacter(); }; }} // namespace wowee::ui diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 8cea8981..241abf10 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -37,6 +37,9 @@ public: */ bool isChatInputActive() const { return chatInputActive; } + void saveSettings(); + void loadSettings(); + private: // Chat state char chatInputBuffer[512] = ""; @@ -66,10 +69,10 @@ private: int pendingSfxVolume = 100; float pendingMouseSensitivity = 0.2f; bool pendingInvertMouse = false; - int pendingUiOpacity = 100; + int pendingUiOpacity = 65; // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) - float uiOpacity_ = 1.0f; + float uiOpacity_ = 0.65f; /** * Render player info window @@ -152,6 +155,7 @@ private: void renderWorldMap(game::GameHandler& gameHandler); InventoryScreen inventoryScreen; + uint64_t inventoryScreenCharGuid_ = 0; // GUID of character inventory screen was initialized for QuestLogScreen questLogScreen; SpellbookScreen spellbookScreen; TalentScreen talentScreen; @@ -174,6 +178,8 @@ private: int actionBarDragSlot_ = -1; GLuint actionBarDragIcon_ = 0; + static std::string getSettingsPath(); + // Left-click targeting: distinguish click from camera drag glm::vec2 leftClickPressPos_ = glm::vec2(0.0f); bool leftClickWasPress_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5aa88707..7bdc519a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -903,6 +903,43 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { // Store player GUID playerGuid = characterGuid; + // Reset per-character state so previous character data doesn't bleed through + inventory = Inventory(); + onlineItems_.clear(); + pendingItemQueries_.clear(); + equipSlotGuids_ = {}; + backpackSlotGuids_ = {}; + invSlotBase_ = -1; + packSlotBase_ = -1; + lastPlayerFields_.clear(); + onlineEquipDirty_ = false; + playerMoneyCopper_ = 0; + knownSpells.clear(); + spellCooldowns.clear(); + actionBar = {}; + playerAuras.clear(); + targetAuras.clear(); + playerXp_ = 0; + playerNextLevelXp_ = 0; + serverPlayerLevel_ = 1; + playerSkills_.clear(); + questLog_.clear(); + npcQuestStatus_.clear(); + hostileAttackers_.clear(); + combatText.clear(); + autoAttacking = false; + autoAttackTarget = 0; + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + castTimeTotal = 0.0f; + playerDead_ = false; + targetGuid = 0; + focusGuid = 0; + lastTargetGuid = 0; + tabCycleStale = true; + entityManager = EntityManager(); + // Build CMSG_PLAYER_LOGIN packet auto packet = PlayerLoginPacket::build(characterGuid); @@ -3058,6 +3095,7 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; actionBar[slot].type = type; actionBar[slot].id = id; + saveCharacterConfig(); } float GameHandler::getSpellCooldown(uint32_t spellId) const { @@ -3086,11 +3124,12 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { } } - // Auto-populate action bar: Attack in slot 1, Hearthstone in slot 12 + // Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12) actionBar[0].type = ActionBarSlot::SPELL; actionBar[0].id = 6603; // Attack actionBar[11].type = ActionBarSlot::SPELL; actionBar[11].id = 8690; // Hearthstone + loadCharacterConfig(); LOG_INFO("Learned ", knownSpells.size(), " spells"); } @@ -3502,7 +3541,7 @@ void GameHandler::closeVendor() { currentVendorItems = ListInventoryData{}; } -void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) { +void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count); socket->send(packet); @@ -4060,5 +4099,98 @@ void GameHandler::extractSkillFields(const std::map& fields) playerSkills_ = std::move(newSkills); } +std::string GameHandler::getCharacterConfigDir() { + std::string dir; +#ifdef _WIN32 + const char* appdata = std::getenv("APPDATA"); + dir = appdata ? std::string(appdata) + "\\wowee\\characters" : "characters"; +#else + const char* home = std::getenv("HOME"); + dir = home ? std::string(home) + "/.wowee/characters" : "characters"; +#endif + return dir; +} + +void GameHandler::saveCharacterConfig() { + const Character* ch = getActiveCharacter(); + if (!ch || ch->name.empty()) return; + + std::string dir = getCharacterConfigDir(); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + + std::string path = dir + "/" + ch->name + ".cfg"; + std::ofstream out(path); + if (!out.is_open()) { + LOG_WARNING("Could not save character config to ", path); + return; + } + + out << "character_guid=" << playerGuid << "\n"; + for (int i = 0; i < ACTION_BAR_SLOTS; i++) { + out << "action_bar_" << i << "_type=" << static_cast(actionBar[i].type) << "\n"; + out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; + } + LOG_INFO("Character config saved to ", path); +} + +void GameHandler::loadCharacterConfig() { + const Character* ch = getActiveCharacter(); + if (!ch || ch->name.empty()) return; + + std::string path = getCharacterConfigDir() + "/" + ch->name + ".cfg"; + std::ifstream in(path); + if (!in.is_open()) return; + + uint64_t savedGuid = 0; + std::array types{}; + std::array ids{}; + bool hasSlots = false; + + 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); + + if (key == "character_guid") { + try { savedGuid = std::stoull(val); } catch (...) {} + } else if (key.rfind("action_bar_", 0) == 0) { + // Parse action_bar_N_type or action_bar_N_id + size_t firstUnderscore = 11; // length of "action_bar_" + size_t secondUnderscore = key.find('_', firstUnderscore); + if (secondUnderscore == std::string::npos) continue; + int slot = -1; + try { slot = std::stoi(key.substr(firstUnderscore, secondUnderscore - firstUnderscore)); } catch (...) { continue; } + if (slot < 0 || slot >= ACTION_BAR_SLOTS) continue; + std::string suffix = key.substr(secondUnderscore + 1); + try { + if (suffix == "type") { + types[slot] = std::stoi(val); + hasSlots = true; + } else if (suffix == "id") { + ids[slot] = static_cast(std::stoul(val)); + hasSlots = true; + } + } catch (...) {} + } + } + + // Validate guid matches current character + if (savedGuid != 0 && savedGuid != playerGuid) { + LOG_WARNING("Character config guid mismatch for ", ch->name, ", using defaults"); + return; + } + + if (hasSlots) { + for (int i = 0; i < ACTION_BAR_SLOTS; i++) { + actionBar[i].type = static_cast(types[i]); + actionBar[i].id = ids[i]; + } + LOG_INFO("Character config loaded from ", path); + } +} + } // namespace game } // namespace wowee diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index a0bbb3c8..8a81329b 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2530,12 +2530,12 @@ network::Packet ListInventoryPacket::build(uint64_t npcGuid) { return packet; } -network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint8_t count) { +network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { network::Packet packet(static_cast(Opcode::CMSG_BUY_ITEM)); packet.writeUInt64(vendorGuid); packet.writeUInt32(itemId); packet.writeUInt32(slot); - packet.writeUInt8(count); + packet.writeUInt32(count); packet.writeUInt8(0); // bag slot (0 = find any available bag slot) return packet; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 6ee8d9cd..eaa561ab 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -577,18 +577,23 @@ void CameraController::update(float deltaTime) { return base; }; - // Sample center + footprint to avoid slipping through narrow floor pieces. + // Sample center + movement-aligned offsets to avoid slipping through narrow floor pieces. + // Use 3 samples instead of 5 — center plus two movement-direction probes. std::optional groundH; - constexpr float FOOTPRINT = 0.4f; // Larger footprint for better floor detection - const glm::vec2 offsets[] = { - {0.0f, 0.0f}, - {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, - {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} - }; - for (const auto& o : offsets) { - auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y); - if (h && (!groundH || *h > *groundH)) { - groundH = h; + groundH = sampleGround(targetPos.x, targetPos.y); + { + constexpr float FOOTPRINT = 0.4f; + glm::vec2 moveXY(targetPos.x - followTarget->x, targetPos.y - followTarget->y); + float moveLen = glm::length(moveXY); + if (moveLen > 0.01f) { + glm::vec2 moveDir2 = moveXY / moveLen; + glm::vec2 perpDir(-moveDir2.y, moveDir2.x); + auto h1 = sampleGround(targetPos.x + moveDir2.x * FOOTPRINT, + targetPos.y + moveDir2.y * FOOTPRINT); + if (h1 && (!groundH || *h1 > *groundH)) groundH = h1; + auto h2 = sampleGround(targetPos.x + perpDir.x * FOOTPRINT, + targetPos.y + perpDir.y * FOOTPRINT); + if (h2 && (!groundH || *h2 > *groundH)) groundH = h2; } } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 3e3dcea5..933c113c 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -14,7 +14,9 @@ #include #include #include +#include #include +#include #include namespace wowee { @@ -38,6 +40,8 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { assetManager = assets; + numCullThreads_ = std::min(4u, std::max(1u, std::thread::hardware_concurrency() - 1)); + // Create WMO shader with texture support const char* vertexSrc = R"( #version 330 core @@ -83,6 +87,8 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { uniform sampler2D uTexture; uniform bool uHasTexture; uniform bool uAlphaTest; + uniform bool uUnlit; + uniform bool uIsInterior; uniform vec3 uFogColor; uniform float uFogStart; @@ -96,32 +102,54 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { out vec4 FragColor; void main() { + // Sample texture or use vertex color + vec4 texColor; + float alpha = 1.0; + if (uHasTexture) { + texColor = texture(uTexture, TexCoord); + // Alpha test only for cutout materials (lattice, grating, etc.) + if (uAlphaTest && texColor.a < 0.5) discard; + // Multiply vertex color (MOCV baked lighting/AO) into texture + texColor.rgb *= VertexColor.rgb; + alpha = texColor.a; + } else { + // MOCV vertex color alpha is a lighting blend factor, not transparency + texColor = vec4(VertexColor.rgb, 1.0); + } + + // Unlit materials (windows, lamps) — emit texture color directly + if (uUnlit) { + // Apply fog only + float fogDist = length(uViewPos - FragPos); + float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); + vec3 result = mix(uFogColor, texColor.rgb, fogFactor); + FragColor = vec4(result, alpha); + return; + } + vec3 normal = normalize(Normal); vec3 lightDir = normalize(uLightDir); + // Interior vs exterior lighting + vec3 ambient; + float dirScale; + if (uIsInterior) { + ambient = vec3(0.7, 0.7, 0.7); + dirScale = 0.3; + } else { + ambient = uAmbientColor; + dirScale = 1.0; + } + // Diffuse lighting float diff = max(dot(normal, lightDir), 0.0); - vec3 diffuse = diff * vec3(1.0); - - // Ambient - vec3 ambient = uAmbientColor; + vec3 diffuse = diff * vec3(1.0) * dirScale; // Blinn-Phong specular vec3 viewDir = normalize(uViewPos - FragPos); vec3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity; - - // Sample texture or use vertex color - vec4 texColor; - if (uHasTexture) { - texColor = texture(uTexture, TexCoord); - // Alpha test only for cutout materials (lattice, grating, etc.) - if (uAlphaTest && texColor.a < 0.5) discard; - } else { - // MOCV vertex color alpha is a lighting blend factor, not transparency - texColor = vec4(VertexColor.rgb, 1.0); - } + vec3 specular = spec * uLightColor * uSpecularIntensity * dirScale; // Shadow mapping float shadow = 1.0; @@ -153,7 +181,7 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); result = mix(uFogColor, result, fogFactor); - FragColor = vec4(result, 1.0); + FragColor = vec4(result, alpha); } )"; @@ -289,6 +317,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { modelData.materialTextureIndices.push_back(texIndex); modelData.materialBlendModes.push_back(mat.blendMode); + modelData.materialFlags.push_back(mat.flags); } // Create GPU resources for each group @@ -300,7 +329,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } GroupResources resources; - if (createGroupResources(wmoGroup, resources)) { + if (createGroupResources(wmoGroup, resources, wmoGroup.flags)) { modelData.groups.push_back(resources); loadedGroups++; } @@ -328,16 +357,28 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } bool alphaTest = false; + uint32_t blendMode = 0; if (batch.materialId < modelData.materialBlendModes.size()) { - alphaTest = (modelData.materialBlendModes[batch.materialId] == 1); + blendMode = modelData.materialBlendModes[batch.materialId]; + alphaTest = (blendMode == 1); } - uint64_t key = (static_cast(texId) << 1) | (alphaTest ? 1 : 0); + bool unlit = false; + if (batch.materialId < modelData.materialFlags.size()) { + unlit = (modelData.materialFlags[batch.materialId] & 0x01) != 0; + } + + // Merge key: texture ID + alphaTest + unlit (unlit batches must not merge with lit) + uint64_t key = (static_cast(texId) << 2) + | (alphaTest ? 1ULL : 0ULL) + | (unlit ? 2ULL : 0ULL); auto& mb = batchMap[key]; if (mb.counts.empty()) { mb.texId = texId; mb.hasTexture = hasTexture; mb.alphaTest = alphaTest; + mb.unlit = unlit; + mb.blendMode = blendMode; } mb.counts.push_back(static_cast(batch.indexCount)); mb.offsets.push_back(reinterpret_cast(batch.startIndex * sizeof(uint16_t))); @@ -746,6 +787,10 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: glActiveTexture(GL_TEXTURE0); shader->setUniform("uTexture", 0); + // Initialize new uniforms to defaults + shader->setUniform("uUnlit", false); + shader->setUniform("uIsInterior", false); + // Enable wireframe if requested if (wireframeMode) { glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); @@ -778,82 +823,139 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: } } - // Render all instances with instance-level culling - for (const auto& instance : instances) { - // NOTE: Disabled hard instance-distance culling for WMOs. - // Large city WMOs can have instance origins far from local camera position, - // causing whole city sections to disappear unexpectedly. - - auto modelIt = loadedModels.find(instance.modelId); - if (modelIt == loadedModels.end()) { + // ── Phase 1: Parallel visibility culling ────────────────────────── + // Build list of instances that pass the coarse instance-level frustum test. + std::vector visibleInstances; + visibleInstances.reserve(instances.size()); + for (size_t i = 0; i < instances.size(); ++i) { + const auto& instance = instances[i]; + if (loadedModels.find(instance.modelId) == loadedModels.end()) continue; - } if (frustumCulling) { glm::vec3 instMin = instance.worldBoundsMin - glm::vec3(0.5f); glm::vec3 instMax = instance.worldBoundsMax + glm::vec3(0.5f); - if (!frustum.intersectsAABB(instMin, instMax)) { + if (!frustum.intersectsAABB(instMin, instMax)) continue; - } } + visibleInstances.push_back(i); + } - const ModelData& model = modelIt->second; + // Per-instance cull lambda — produces an InstanceDrawList for one instance. + // Reads only const data; each invocation writes to its own output. + glm::vec3 camPos = camera.getPosition(); + bool doPortalCull = portalCulling; + bool doOcclusionCull = occlusionCulling; + bool doFrustumCull = frustumCulling; - // Run occlusion queries for this instance (pre-pass) - if (occlusionCulling && occlusionShader && bboxVao != 0) { - runOcclusionQueries(instance, model, view, projection); - // Re-bind main shader after occlusion pass - shader->use(); - } + auto cullInstance = [&](size_t instIdx) -> InstanceDrawList { + const auto& instance = instances[instIdx]; + const ModelData& model = loadedModels.find(instance.modelId)->second; - shader->setUniform("uModel", instance.modelMatrix); + InstanceDrawList result; + result.instanceIndex = instIdx; - // Portal-based visibility culling + // Portal-based visibility std::unordered_set portalVisibleGroups; - bool usePortalCulling = portalCulling && !model.portals.empty() && !model.portalRefs.empty(); - + bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { - // Transform camera position to model's local space - glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camera.getPosition(), 1.0f); - glm::vec3 cameraLocalPos(localCamPos); - - getVisibleGroupsViaPortals(model, cameraLocalPos, frustum, instance.modelMatrix, portalVisibleGroups); + glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); + getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, + instance.modelMatrix, portalVisibleGroups); } - // Render all groups using cached world-space bounds - glm::vec3 camPos = camera.getPosition(); for (size_t gi = 0; gi < model.groups.size(); ++gi) { - // Portal culling check - if (usePortalCulling && portalVisibleGroups.find(static_cast(gi)) == portalVisibleGroups.end()) { - lastPortalCulledGroups++; + // Portal culling + if (usePortalCulling && + portalVisibleGroups.find(static_cast(gi)) == portalVisibleGroups.end()) { + result.portalCulled++; continue; } - // Occlusion culling check first (uses previous frame results) - if (occlusionCulling && isGroupOccluded(instance.id, static_cast(gi))) { - lastOcclusionCulledGroups++; + // Occlusion culling (reads previous-frame results, read-only map) + if (doOcclusionCull && isGroupOccluded(instance.id, static_cast(gi))) { + result.occlusionCulled++; continue; } if (gi < instance.worldGroupBounds.size()) { const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; - // Hard distance cutoff - skip groups entirely if closest point is too far + // Hard distance cutoff glm::vec3 closestPoint = glm::clamp(camPos, gMin, gMax); float distSq = glm::dot(closestPoint - camPos, closestPoint - camPos); - if (distSq > 25600.0f) { // Beyond 160 units - hard skip - lastDistanceCulledGroups++; + if (distSq > 25600.0f) { + result.distanceCulled++; continue; } // Frustum culling - if (frustumCulling && !frustum.intersectsAABB(gMin, gMax)) { + if (doFrustumCull && !frustum.intersectsAABB(gMin, gMax)) continue; - } } - renderGroup(model.groups[gi], model, instance.modelMatrix, view, projection); + result.visibleGroups.push_back(static_cast(gi)); } + return result; + }; + + // Dispatch culling — parallel when enough instances, sequential otherwise. + std::vector drawLists; + drawLists.reserve(visibleInstances.size()); + + if (visibleInstances.size() >= 4 && numCullThreads_ > 1) { + const size_t numThreads = std::min(static_cast(numCullThreads_), + visibleInstances.size()); + const size_t chunkSize = visibleInstances.size() / numThreads; + const size_t remainder = visibleInstances.size() % numThreads; + + // Each future returns a vector of InstanceDrawList for its chunk. + std::vector>> futures; + futures.reserve(numThreads); + + size_t start = 0; + for (size_t t = 0; t < numThreads; ++t) { + size_t end = start + chunkSize + (t < remainder ? 1 : 0); + futures.push_back(std::async(std::launch::async, + [&, start, end]() { + std::vector chunk; + chunk.reserve(end - start); + for (size_t j = start; j < end; ++j) + chunk.push_back(cullInstance(visibleInstances[j])); + return chunk; + })); + start = end; + } + + for (auto& f : futures) { + auto chunk = f.get(); + for (auto& dl : chunk) + drawLists.push_back(std::move(dl)); + } + } else { + for (size_t idx : visibleInstances) + drawLists.push_back(cullInstance(idx)); + } + + // ── Phase 2: Sequential GL draw ──────────────────────────────── + for (const auto& dl : drawLists) { + const auto& instance = instances[dl.instanceIndex]; + const ModelData& model = loadedModels.find(instance.modelId)->second; + + // Occlusion query pre-pass (GL calls — must be main thread) + if (occlusionCulling && occlusionShader && bboxVao != 0) { + runOcclusionQueries(instance, model, view, projection); + shader->use(); + } + + shader->setUniform("uModel", instance.modelMatrix); + + for (uint32_t gi : dl.visibleGroups) + renderGroup(model.groups[gi], model, instance.modelMatrix, view, projection); + + lastPortalCulledGroups += dl.portalCulled; + lastDistanceCulledGroups += dl.distanceCulled; + lastOcclusionCulledGroups += dl.occlusionCulled; } // Restore polygon mode @@ -898,11 +1000,13 @@ uint32_t WMORenderer::getTotalTriangleCount() const { return total; } -bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources) { +bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources, uint32_t groupFlags) { if (group.vertices.empty() || group.indices.empty()) { return false; } + resources.groupFlags = groupFlags; + resources.vertexCount = group.vertices.size(); resources.indexCount = group.indices.size(); resources.boundingBoxMin = group.boundingBoxMin; @@ -1012,11 +1116,16 @@ void WMORenderer::renderGroup(const GroupResources& group, [[maybe_unused]] cons [[maybe_unused]] const glm::mat4& projection) { glBindVertexArray(group.vao); + // Set interior flag once per group (0x2000 = interior) + bool isInterior = (group.groupFlags & 0x2000) != 0; + shader->setUniform("uIsInterior", isInterior); + // Use pre-computed merged batches (built at load time) // Track bound state to avoid redundant GL calls static GLuint lastBoundTex = 0; static bool lastHasTexture = false; static bool lastAlphaTest = false; + static bool lastUnlit = false; for (const auto& mb : group.mergedBatches) { if (mb.texId != lastBoundTex) { @@ -1031,10 +1140,25 @@ void WMORenderer::renderGroup(const GroupResources& group, [[maybe_unused]] cons shader->setUniform("uAlphaTest", mb.alphaTest); lastAlphaTest = mb.alphaTest; } + if (mb.unlit != lastUnlit) { + shader->setUniform("uUnlit", mb.unlit); + lastUnlit = mb.unlit; + } + + // Enable alpha blending for translucent materials (blendMode >= 2) + bool needsBlend = (mb.blendMode >= 2); + if (needsBlend) { + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } glMultiDrawElements(GL_TRIANGLES, mb.counts.data(), GL_UNSIGNED_SHORT, mb.offsets.data(), static_cast(mb.counts.size())); lastDrawCalls++; + + if (needsBlend) { + glDisable(GL_BLEND); + } } glBindVertexArray(0); @@ -1407,12 +1531,6 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ glm::vec3 worldOrigin(glX, glY, glZ + 500.0f); glm::vec3 worldDir(0.0f, 0.0f, -1.0f); - // Debug: log when no instances - static int debugCounter = 0; - if (instances.empty() && (debugCounter++ % 300 == 0)) { - core::Logger::getInstance().warning("WMO getFloorHeight: no instances loaded!"); - } - glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f); gatherCandidates(queryMin, queryMax, candidateScratch); @@ -1436,23 +1554,41 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ const ModelData& model = it->second; + // World-space pre-pass: check which groups' world XY bounds contain + // the query point. For a vertical ray this eliminates most groups + // before any local-space math. + bool anyGroupOverlaps = false; + for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX >= gMin.x && glX <= gMax.x && + glY >= gMin.y && glY <= gMax.y && + glZ - 4.0f <= gMax.z) { + anyGroupOverlaps = true; + break; + } + } + if (!anyGroupOverlaps) continue; + // Use cached inverse matrix glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f)); glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f))); - int groupsChecked = 0; - int groupsSkipped = 0; - int trianglesHit = 0; + for (size_t gi = 0; gi < model.groups.size(); ++gi) { + // World-space group cull — vertical ray at (glX, glY) + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX < gMin.x || glX > gMax.x || + glY < gMin.y || glY > gMax.y || + glZ - 4.0f > gMax.z) { + continue; + } + } - for (const auto& group : model.groups) { - // Quick bounding box check + const auto& group = model.groups[gi]; if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) { - groupsSkipped++; continue; } - groupsChecked++; - // Raycast against triangles const auto& verts = group.collisionVertices; const auto& indices = group.collisionIndices; @@ -1461,22 +1597,15 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ const glm::vec3& v1 = verts[indices[i + 1]]; const glm::vec3& v2 = verts[indices[i + 2]]; - // Try both winding orders (two-sided collision) float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2); if (t <= 0.0f) { - // Try reverse winding t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1); } if (t > 0.0f) { - trianglesHit++; - // Hit point in local space -> world space glm::vec3 hitLocal = localOrigin + localDir * t; glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); - // Only use floors below or near the query point. - // Callers already elevate glZ by +5..+6; keep buffer small - // to avoid selecting ceilings above the player. if (hitWorld.z <= glZ + 0.5f) { if (!bestFloor || hitWorld.z > *bestFloor) { bestFloor = hitWorld.z; @@ -1485,14 +1614,6 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ } } } - - // Debug logging (every ~5 seconds at 60fps) - static int logCounter = 0; - if ((logCounter++ % 300 == 0) && (groupsChecked > 0 || groupsSkipped > 0)) { - core::Logger::getInstance().debug("Floor check: ", groupsChecked, " groups checked, ", - groupsSkipped, " skipped, ", trianglesHit, " hits, best=", - bestFloor ? std::to_string(*bestFloor) : "none"); - } } // Cache the result in persistent grid. @@ -1519,11 +1640,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, const float PLAYER_HEIGHT = 2.0f; // Player height for wall checks const float MAX_STEP_HEIGHT = 1.0f; // Allow stepping up stairs - // Debug logging - static int wallDebugCounter = 0; - int groupsChecked = 0; - int wallsHit = 0; - glm::vec3 queryMin = glm::min(from, to) - glm::vec3(8.0f, 8.0f, 5.0f); glm::vec3 queryMax = glm::max(from, to) + glm::vec3(8.0f, 8.0f, 5.0f); gatherCandidates(queryMin, queryMax, candidateScratch); @@ -1548,19 +1664,43 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, const ModelData& model = it->second; + // World-space pre-pass: skip instances where no groups are near the movement + const float wallMargin = PLAYER_RADIUS + 2.0f; + bool anyGroupNear = false; + for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (to.x >= gMin.x - wallMargin && to.x <= gMax.x + wallMargin && + to.y >= gMin.y - wallMargin && to.y <= gMax.y + wallMargin && + to.z + PLAYER_HEIGHT >= gMin.z && to.z <= gMax.z + wallMargin) { + anyGroupNear = true; + break; + } + } + if (!anyGroupNear) continue; + // Transform positions into local space using cached inverse glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); glm::vec3 localTo = glm::vec3(instance.invModelMatrix * glm::vec4(to, 1.0f)); float localFeetZ = localTo.z; - for (const auto& group : model.groups) { - // Quick bounding box check + for (size_t gi = 0; gi < model.groups.size(); ++gi) { + // World-space group cull + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (to.x < gMin.x - wallMargin || to.x > gMax.x + wallMargin || + to.y < gMin.y - wallMargin || to.y > gMax.y + wallMargin || + to.z > gMax.z + PLAYER_HEIGHT || to.z + PLAYER_HEIGHT < gMin.z) { + continue; + } + } + + const auto& group = model.groups[gi]; + // Local-space AABB check float margin = PLAYER_RADIUS + 2.0f; if (localTo.x < group.boundingBoxMin.x - margin || localTo.x > group.boundingBoxMax.x + margin || localTo.y < group.boundingBoxMin.y - margin || localTo.y > group.boundingBoxMax.y + margin || localTo.z < group.boundingBoxMin.z - margin || localTo.z > group.boundingBoxMax.z + margin) { continue; } - groupsChecked++; const auto& verts = group.collisionVertices; const auto& indices = group.collisionIndices; @@ -1631,7 +1771,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3 delta = localTo - closest; float horizDist = glm::length(glm::vec2(delta.x, delta.y)); if (horizDist <= PLAYER_RADIUS) { - wallsHit++; float pushDist = PLAYER_RADIUS - horizDist + 0.02f; glm::vec2 pushDir2; if (horizDist > 1e-4f) { @@ -1643,10 +1782,8 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, } glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f); - // Transform push vector back to world space glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f)); - // Only horizontal push adjustedPos.x += pushWorld.x; adjustedPos.y += pushWorld.y; blocked = true; @@ -1655,12 +1792,6 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, } } - // Debug logging every ~5 seconds - if ((wallDebugCounter++ % 300 == 0) && !instances.empty()) { - core::Logger::getInstance().debug("Wall collision: ", instances.size(), " instances, ", - groupsChecked, " groups checked, ", wallsHit, " walls hit, blocked=", blocked); - } - return blocked; } @@ -1687,9 +1818,21 @@ bool WMORenderer::isInsideWMO(float glX, float glY, float glZ, uint32_t* outMode if (it == loadedModels.end()) continue; const ModelData& model = it->second; - glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); - // Check if inside any group's bounding box + // World-space pre-check: skip instance if no group's world bounds contain point + bool anyGroupContains = false; + for (size_t gi = 0; gi < model.groups.size() && gi < instance.worldGroupBounds.size(); ++gi) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX >= gMin.x && glX <= gMax.x && + glY >= gMin.y && glY <= gMax.y && + glZ >= gMin.z && glZ <= gMax.z) { + anyGroupContains = true; + break; + } + } + if (!anyGroupContains) continue; + + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); for (const auto& group : model.groups) { if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x && localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y && @@ -1746,8 +1889,17 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f)); glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f))); - for (const auto& group : model.groups) { - // Broad-phase cull with local AABB first. + for (size_t gi = 0; gi < model.groups.size(); ++gi) { + // World-space group cull — skip groups whose world AABB doesn't intersect the ray + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (!rayIntersectsAABB(origin, direction, gMin, gMax)) { + continue; + } + } + + const auto& group = model.groups[gi]; + // Local-space AABB cull if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) { continue; } diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 5e2ddcf9..1f563c45 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -1,5 +1,8 @@ #include "ui/character_screen.hpp" #include +#include +#include +#include #include #include @@ -9,12 +12,67 @@ CharacterScreen::CharacterScreen() { } void CharacterScreen::render(game::GameHandler& gameHandler) { - ImGui::SetNextWindowSize(ImVec2(800, 600), ImGuiCond_FirstUseEver); + // Size the window to fill most of the viewport + ImVec2 vpSize = ImGui::GetMainViewport()->Size; + ImVec2 winSize(vpSize.x * 0.6f, vpSize.y * 0.7f); + if (winSize.x < 700.0f) winSize.x = 700.0f; + if (winSize.y < 500.0f) winSize.y = 500.0f; + ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos( + ImVec2(vpSize.x * 0.5f, vpSize.y * 0.5f), + ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f)); + ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse); - ImGui::Text("Select a Character"); - ImGui::Separator(); - ImGui::Spacing(); + // Get character list + const auto& characters = gameHandler.getCharacters(); + + // Request character list if not available + if (characters.empty() && gameHandler.getState() == game::WorldState::READY) { + ImGui::Text("Loading characters..."); + gameHandler.requestCharacterList(); + ImGui::End(); + return; + } + + if (characters.empty()) { + ImGui::Text("No characters available."); + // Bottom buttons even when empty + ImGui::Spacing(); + if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); } + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(120, 36))) { + if (gameHandler.getState() == game::WorldState::READY || + gameHandler.getState() == game::WorldState::CHAR_LIST_RECEIVED) { + gameHandler.requestCharacterList(); + setStatus("Refreshing character list..."); + } + } + ImGui::SameLine(); + if (ImGui::Button("Create Character", ImVec2(160, 36))) { if (onCreateCharacter) onCreateCharacter(); } + ImGui::End(); + return; + } + + // Restore last-selected character (once per screen visit) + if (!restoredLastCharacter) { + uint64_t lastGuid = loadLastCharacter(); + if (lastGuid != 0) { + for (size_t i = 0; i < characters.size(); ++i) { + if (characters[i].guid == lastGuid) { + selectedCharacterIndex = static_cast(i); + selectedCharacterGuid = lastGuid; + break; + } + } + } + // Fall back to first character if nothing matched + if (selectedCharacterIndex < 0) { + selectedCharacterIndex = 0; + selectedCharacterGuid = characters[0].guid; + } + restoredLastCharacter = true; + } // Status message if (!statusMessage.empty()) { @@ -24,200 +82,164 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Spacing(); } - // Get character list - const auto& characters = gameHandler.getCharacters(); + // ── Two-column layout: character list (left) | details (right) ── + float availW = ImGui::GetContentRegionAvail().x; + float detailPanelW = 260.0f; + float listW = availW - detailPanelW - ImGui::GetStyle().ItemSpacing.x; + if (listW < 300.0f) { listW = availW; detailPanelW = 0.0f; } - // Request character list if not available - if (characters.empty() && gameHandler.getState() == game::WorldState::READY) { - ImGui::Text("Loading characters..."); - gameHandler.requestCharacterList(); - } else if (characters.empty()) { - ImGui::Text("No characters available."); - } else { - // Auto-highlight the first character if none selected yet - if (selectedCharacterIndex < 0 && !characters.empty()) { - selectedCharacterIndex = 0; - selectedCharacterGuid = characters[0].guid; - } + float listH = ImGui::GetContentRegionAvail().y - 50.0f; // reserve bottom row for buttons - // Character table - if (ImGui::BeginTable("CharactersTable", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 50.0f); - ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthFixed, 100.0f); - ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 120.0f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthFixed, 80.0f); - ImGui::TableHeadersRow(); + // ── Left: Character list ── + ImGui::BeginChild("CharList", ImVec2(listW, listH), true); + ImGui::Text("Characters"); + ImGui::Separator(); - for (size_t i = 0; i < characters.size(); ++i) { - const auto& character = characters[i]; + if (ImGui::BeginTable("CharactersTable", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 2.0f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f); + ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableHeadersRow(); - ImGui::TableNextRow(); + for (size_t i = 0; i < characters.size(); ++i) { + const auto& character = characters[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); - // Name column (selectable) - ImGui::TableSetColumnIndex(0); - bool isSelected = (selectedCharacterIndex == static_cast(i)); + bool isSelected = (selectedCharacterIndex == static_cast(i)); + ImVec4 factionColor = getFactionColor(character.race); + ImGui::PushStyleColor(ImGuiCol_Text, factionColor); - // Apply faction color to character name - ImVec4 factionColor = getFactionColor(character.race); - ImGui::PushStyleColor(ImGuiCol_Text, factionColor); - - if (ImGui::Selectable(character.name.c_str(), isSelected, ImGuiSelectableFlags_SpanAllColumns)) { - selectedCharacterIndex = static_cast(i); - selectedCharacterGuid = character.guid; - } - - ImGui::PopStyleColor(); - - // Level column - ImGui::TableSetColumnIndex(1); - ImGui::Text("%d", character.level); - - // Race column - ImGui::TableSetColumnIndex(2); - ImGui::Text("%s", game::getRaceName(character.race)); - - // Class column - ImGui::TableSetColumnIndex(3); - ImGui::Text("%s", game::getClassName(character.characterClass)); - - // Zone column - ImGui::TableSetColumnIndex(4); - ImGui::Text("%d", character.zoneId); - - // Guild column - ImGui::TableSetColumnIndex(5); - if (character.hasGuild()) { - ImGui::Text("Yes"); - } else { - ImGui::TextDisabled("No"); - } + ImGui::PushID(static_cast(i)); + if (ImGui::Selectable(character.name.c_str(), isSelected, + ImGuiSelectableFlags_SpanAllColumns)) { + selectedCharacterIndex = static_cast(i); + selectedCharacterGuid = character.guid; + saveLastCharacter(character.guid); } - ImGui::EndTable(); + // Double-click to enter world + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { + selectedCharacterIndex = static_cast(i); + selectedCharacterGuid = character.guid; + saveLastCharacter(character.guid); + characterSelected = true; + gameHandler.selectCharacter(character.guid); + if (onCharacterSelected) onCharacterSelected(character.guid); + } + ImGui::PopID(); + ImGui::PopStyleColor(); + + ImGui::TableSetColumnIndex(1); + ImGui::Text("%d", character.level); + + ImGui::TableSetColumnIndex(2); + ImGui::Text("%s", game::getRaceName(character.race)); + + ImGui::TableSetColumnIndex(3); + ImGui::Text("%s", game::getClassName(character.characterClass)); + + ImGui::TableSetColumnIndex(4); + ImGui::Text("%d", character.zoneId); + } + + ImGui::EndTable(); + } + ImGui::EndChild(); + + // ── Right: Details panel ── + if (detailPanelW > 0.0f && + selectedCharacterIndex >= 0 && + selectedCharacterIndex < static_cast(characters.size())) { + + const auto& character = characters[selectedCharacterIndex]; + + ImGui::SameLine(); + ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true); + + ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str()); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Text("Level %d", character.level); + ImGui::Text("%s", game::getRaceName(character.race)); + ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::Text("%s", game::getGenderName(character.gender)); + ImGui::Spacing(); + ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId); + + if (character.hasGuild()) { + ImGui::Text("Guild ID: %d", character.guildId); + } else { + ImGui::TextDisabled("No Guild"); + } + + if (character.hasPet()) { + ImGui::Spacing(); + ImGui::Text("Pet Lv%d (Family %d)", character.pet.level, character.pet.family); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - // Selected character details - if (selectedCharacterIndex >= 0 && selectedCharacterIndex < static_cast(characters.size())) { - const auto& character = characters[selectedCharacterIndex]; + // Enter World button — full width + float btnW = ImGui::GetContentRegionAvail().x; + if (ImGui::Button("Enter World", ImVec2(btnW, 44))) { + characterSelected = true; + saveLastCharacter(character.guid); + std::stringstream ss; + ss << "Entering world with " << character.name << "..."; + setStatus(ss.str()); + gameHandler.selectCharacter(character.guid); + if (onCharacterSelected) onCharacterSelected(character.guid); + } - ImGui::Text("Character Details:"); - ImGui::Separator(); + ImGui::Spacing(); - ImGui::Columns(2, nullptr, false); - - // Left column - ImGui::Text("Name:"); - ImGui::Text("Level:"); - ImGui::Text("Race:"); - ImGui::Text("Class:"); - ImGui::Text("Gender:"); - ImGui::Text("Location:"); - ImGui::Text("Guild:"); - if (character.hasPet()) { - ImGui::Text("Pet:"); + // Delete + if (!confirmDelete) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f)); + if (ImGui::Button("Delete Character", ImVec2(btnW, 36))) { + confirmDelete = true; } - - ImGui::NextColumn(); - - // Right column - ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str()); - ImGui::Text("%d", character.level); - ImGui::Text("%s", game::getRaceName(character.race)); - ImGui::Text("%s", game::getClassName(character.characterClass)); - ImGui::Text("%s", game::getGenderName(character.gender)); - ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId); - if (character.hasGuild()) { - ImGui::Text("Guild ID: %d", character.guildId); - } else { - ImGui::TextDisabled("None"); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.0f, 0.0f, 1.0f)); + if (ImGui::Button("Confirm Delete?", ImVec2(btnW, 36))) { + if (onDeleteCharacter) onDeleteCharacter(character.guid); + confirmDelete = false; + selectedCharacterIndex = -1; + selectedCharacterGuid = 0; } - if (character.hasPet()) { - ImGui::Text("Level %d (Family %d)", character.pet.level, character.pet.family); - } - - ImGui::Columns(1); - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - // Enter World button - if (ImGui::Button("Enter World", ImVec2(150, 40))) { - characterSelected = true; - std::stringstream ss; - ss << "Entering world with " << character.name << "..."; - setStatus(ss.str()); - - // Send CMSG_PLAYER_LOGIN to server - gameHandler.selectCharacter(character.guid); - - // Call callback - if (onCharacterSelected) { - onCharacterSelected(character.guid); - } - } - - ImGui::SameLine(); - - // Delete Character button - if (!confirmDelete) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f)); - if (ImGui::Button("Delete Character", ImVec2(150, 40))) { - confirmDelete = true; - } - ImGui::PopStyleColor(); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.0f, 0.0f, 1.0f)); - if (ImGui::Button("Confirm Delete?", ImVec2(150, 40))) { - if (onDeleteCharacter) { - onDeleteCharacter(character.guid); - } - confirmDelete = false; - selectedCharacterIndex = -1; - selectedCharacterGuid = 0; - } - ImGui::PopStyleColor(); - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(80, 40))) { - confirmDelete = false; - } + ImGui::PopStyleColor(); + if (ImGui::Button("Cancel", ImVec2(btnW, 30))) { + confirmDelete = false; } } + + ImGui::EndChild(); } + // ── Bottom button row ── ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - // Back/Refresh/Create buttons - if (ImGui::Button("Back", ImVec2(120, 0))) { - if (onBack) { - onBack(); - } - } - + if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); } ImGui::SameLine(); - - if (ImGui::Button("Refresh", ImVec2(120, 0))) { + if (ImGui::Button("Refresh", ImVec2(120, 36))) { if (gameHandler.getState() == game::WorldState::READY || gameHandler.getState() == game::WorldState::CHAR_LIST_RECEIVED) { gameHandler.requestCharacterList(); setStatus("Refreshing character list..."); } } - ImGui::SameLine(); - - if (ImGui::Button("Create Character", ImVec2(150, 0))) { - if (onCreateCharacter) { - onCreateCharacter(); - } + if (ImGui::Button("Create Character", ImVec2(160, 36))) { + if (onCreateCharacter) onCreateCharacter(); } ImGui::End(); @@ -234,7 +256,7 @@ ImVec4 CharacterScreen::getFactionColor(game::Race race) const { race == game::Race::NIGHT_ELF || race == game::Race::GNOME || race == game::Race::DRAENEI) { - return ImVec4(0.3f, 0.5f, 1.0f, 1.0f); // Blue + return ImVec4(0.3f, 0.5f, 1.0f, 1.0f); } // Horde races: red @@ -243,11 +265,35 @@ ImVec4 CharacterScreen::getFactionColor(game::Race race) const { race == game::Race::TAUREN || race == game::Race::TROLL || race == game::Race::BLOOD_ELF) { - return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red + return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); } - // Default: white return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); } +std::string CharacterScreen::getConfigDir() { +#ifdef _WIN32 + const char* appdata = std::getenv("APPDATA"); + return appdata ? std::string(appdata) + "\\wowee" : "."; +#else + const char* home = std::getenv("HOME"); + return home ? std::string(home) + "/.wowee" : "."; +#endif +} + +void CharacterScreen::saveLastCharacter(uint64_t guid) { + std::string dir = getConfigDir(); + std::filesystem::create_directories(dir); + std::ofstream f(dir + "/last_character.cfg"); + if (f) f << guid; +} + +uint64_t CharacterScreen::loadLastCharacter() { + std::string path = getConfigDir() + "/last_character.cfg"; + std::ifstream f(path); + uint64_t guid = 0; + if (f) f >> guid; + return guid; +} + }} // namespace wowee::ui diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 356efde0..b422d6d6 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20,6 +20,9 @@ #include #include #include +#include +#include +#include #include namespace { @@ -51,6 +54,7 @@ namespace { namespace wowee { namespace ui { GameScreen::GameScreen() { + loadSettings(); } void GameScreen::render(game::GameHandler& gameHandler) { @@ -114,10 +118,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Talents (N key toggle handled inside) talentScreen.render(gameHandler); - // Set up inventory screen asset manager + player appearance (once) + // Set up inventory screen asset manager + player appearance (re-init on character switch) { - static bool inventoryScreenInit = false; - if (!inventoryScreenInit) { + uint64_t activeGuid = gameHandler.getActiveCharacterGuid(); + if (activeGuid != 0 && activeGuid != inventoryScreenCharGuid_) { auto* am = core::Application::getInstance().getAssetManager(); if (am) { inventoryScreen.setAssetManager(am); @@ -130,7 +134,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { inventoryScreen.setPlayerAppearance( ch->race, ch->gender, skin, face, hairStyle, hairColor, ch->facialFeatures); - inventoryScreenInit = true; + inventoryScreenCharGuid_ = activeGuid; } } } @@ -397,6 +401,11 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatWindowPos_ = ImGui::GetWindowPos(); } + // Click anywhere in chat window → focus the input field + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && ImGui::IsMouseClicked(0)) { + refocusChatInput = true; + } + // Chat history const auto& chatHistory = gameHandler.getChatHistory(); @@ -3533,7 +3542,7 @@ void GameScreen::renderSettingsWindow() { ImGui::Text("Interface"); ImGui::SliderInt("UI Opacity", &pendingUiOpacity, 20, 100, "%d%%"); if (ImGui::Button("Restore Interface Defaults", ImVec2(-1, 0))) { - pendingUiOpacity = 100; + pendingUiOpacity = 65; } ImGui::Spacing(); @@ -3542,6 +3551,7 @@ void GameScreen::renderSettingsWindow() { if (ImGui::Button("Apply", ImVec2(-1, 0))) { uiOpacity_ = static_cast(pendingUiOpacity) / 100.0f; + saveSettings(); window->setVsync(pendingVsync); window->setFullscreen(pendingFullscreen); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); @@ -3742,4 +3752,57 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } +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"; +} + +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; + } + + out << "ui_opacity=" << pendingUiOpacity << "\n"; + 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); + + if (key == "ui_opacity") { + try { + int v = std::stoi(val); + if (v >= 20 && v <= 100) { + pendingUiOpacity = v; + uiOpacity_ = static_cast(v) / 100.0f; + } + } catch (...) {} + } + } + LOG_INFO("Settings loaded from ", path); +} + }} // namespace wowee::ui