diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0fd83186..cf032f6b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1465,8 +1465,14 @@ private: uint64_t guid = 0; float timer = 0.0f; uint8_t remainingRetries = 0; + bool sendLoot = false; }; std::vector pendingGameObjectLootRetries_; + struct PendingLootOpen { + uint64_t guid = 0; + float timer = 0.0f; + }; + std::vector pendingGameObjectLootOpens_; uint64_t pendingLootMoneyGuid_ = 0; uint32_t pendingLootMoneyAmount_ = 0; float pendingLootMoneyNotifyTimer_ = 0.0f; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e1160c86..d38ea3f7 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -616,10 +616,14 @@ void GameHandler::update(float deltaTime) { it->timer -= deltaTime; if (it->timer <= 0.0f) { if (it->remainingRetries > 0 && state == WorldState::IN_WORLD && socket) { + // Keep server-side position/facing fresh before retrying GO use. + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); auto usePacket = GameObjectUsePacket::build(it->guid); socket->send(usePacket); - auto lootPacket = LootPacket::build(it->guid); - socket->send(lootPacket); + if (it->sendLoot) { + auto lootPacket = LootPacket::build(it->guid); + socket->send(lootPacket); + } --it->remainingRetries; it->timer = 0.20f; } @@ -631,6 +635,18 @@ void GameHandler::update(float deltaTime) { } } + for (auto it = pendingGameObjectLootOpens_.begin(); it != pendingGameObjectLootOpens_.end();) { + it->timer -= deltaTime; + if (it->timer <= 0.0f) { + if (state == WorldState::IN_WORLD && socket) { + lootTarget(it->guid); + } + it = pendingGameObjectLootOpens_.erase(it); + } else { + ++it; + } + } + if (pendingLootMoneyNotifyTimer_ > 0.0f) { pendingLootMoneyNotifyTimer_ -= deltaTime; if (pendingLootMoneyNotifyTimer_ <= 0.0f) { @@ -712,7 +728,7 @@ void GameHandler::update(float deltaTime) { // Update cast timer (Phase 3) if (pendingGameObjectInteractGuid_ != 0 && - (autoAttacking || !hostileAttackers_.empty())) { + (autoAttacking || autoAttackRequested_)) { pendingGameObjectInteractGuid_ = 0; casting = false; currentCastSpellId = 0; @@ -9671,15 +9687,13 @@ void GameHandler::interactWithNpc(uint64_t guid) { void GameHandler::interactWithGameObject(uint64_t guid) { if (guid == 0) return; if (state != WorldState::IN_WORLD || !socket) return; - if (casting && currentCastSpellId != 0) return; // don't overlap spell cast bar - if (autoAttacking) { - stopAutoAttack(); - } - pendingGameObjectInteractGuid_ = guid; - casting = true; - currentCastSpellId = 0; - castTimeTotal = 1.5f; - castTimeRemaining = castTimeTotal; + // Do not overlap an actual spell cast. + if (casting && currentCastSpellId != 0) return; + // Always clear melee intent before GO interactions. + stopAutoAttack(); + // Interact immediately; server drives any real cast/channel feedback. + pendingGameObjectInteractGuid_ = 0; + performGameObjectInteractionNow(guid); } void GameHandler::performGameObjectInteractionNow(uint64_t guid) { @@ -9691,19 +9705,45 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { static uint64_t lastInteractGuid = 0; static std::chrono::steady_clock::time_point lastInteractTime{}; auto now = std::chrono::steady_clock::now(); - int64_t minRepeatMs = turtleMode ? 250 : 1000; + // Keep duplicate suppression, but allow quick retry clicks. + int64_t minRepeatMs = turtleMode ? 150 : 150; if (guid == lastInteractGuid && std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { - return; // Ignore repeated clicks within 1 second + return; } lastInteractGuid = guid; lastInteractTime = now; - // Ensure chest interaction isn't blocked by our own auto-attack state. - if (autoAttacking) { - stopAutoAttack(); - } + // Ensure GO interaction isn't blocked by stale or active melee state. + stopAutoAttack(); auto entity = entityManager.getEntity(guid); + uint32_t goEntry = 0; + uint32_t goType = 0; + std::string goName; + + if (entity) { + if (entity->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + goEntry = go->getEntry(); + goName = go->getName(); + if (auto* info = getCachedGameObjectInfo(goEntry)) goType = info->type; + } + // Face object and send heartbeat before use so strict servers don't require + // a nudge movement to accept interaction. + float dx = entity->getX() - movementInfo.x; + float dy = entity->getY() - movementInfo.y; + float dz = entity->getZ() - movementInfo.z; + float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist3d > 6.0f) { + addSystemChatMessage("Too far away."); + return; + } + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + movementInfo.orientation = std::atan2(-dy, dx); + sendMovement(Opcode::MSG_MOVE_SET_FACING); + } + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); + } auto packet = GameObjectUsePacket::build(guid); socket->send(packet); @@ -9712,7 +9752,10 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { // In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends // animation/sound and expects the client to request the mail list. bool isMailbox = false; - bool shouldSendLoot = (entity == nullptr); + bool chestLike = false; + // Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only + // as Classic/Turtle fallback behavior. + bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle"); if (entity && entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); auto* info = getCachedGameObjectInfo(go->getEntry()); @@ -9726,29 +9769,36 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { selectedMailIndex_ = -1; showMailCompose_ = false; refreshMailList(); - } else { - // Keep non-Turtle behavior constrained to known lootable GO types. - if (!turtleMode) { - if (info && (info->type == 3 || info->type == 25)) { - shouldSendLoot = true; - } else if (info) { - shouldSendLoot = false; - } else { - shouldSendLoot = true; - } - } else { - // Turtle compatibility: aggressively pair use+loot for chest-like objects. - shouldSendLoot = true; - } + } else if (info && info->type == 3) { + chestLike = true; + } else if (turtleMode) { + // Turtle compatibility: keep eager loot open behavior. + shouldSendLoot = true; + } + } + if (!chestLike && !goName.empty()) { + std::string lower = goName; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + chestLike = (lower.find("chest") != std::string::npos); + } + // For WotLK chest-like gameobjects, report use but let server open loot. + if (!isMailbox && chestLike) { + if (isActiveExpansion("wotlk")) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); } } if (shouldSendLoot) { - LOG_INFO("GameObject interaction: sent CMSG_GAMEOBJ_USE + CMSG_LOOT for guid=0x", std::hex, guid, std::dec, - " mailbox=", (isMailbox ? 1 : 0), " turtle=", (turtleMode ? 1 : 0)); lootTarget(guid); - if (turtleMode) { - pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.20f, 2}); - } + } + // Retry use briefly to survive packet loss/order races. Keep loot retries only + // when we intentionally use eager loot-open mode. + const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic")); + const bool retryUse = turtleMode || isActiveExpansion("classic"); + if (retryUse || retryLoot) { + pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); } } @@ -9993,14 +10043,11 @@ void GameHandler::acceptQuest() { return; } - // WotLK/TBC expect an additional trailing flag on CMSG_QUESTGIVER_ACCEPT_QUEST. - // Classic/Turtle use the short form (guid + questId only). + // Keep quest accept payload minimal and expansion-safe: guid + questId. + // Some server cores reject trailing bytes and throw ByteBufferException. network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); packet.writeUInt64(npcGuid); packet.writeUInt32(questId); - if (!isActiveExpansion("classic") && !isActiveExpansion("turtle")) { - packet.writeUInt8(1); // from-gossip / auto-accept continuation flag - } socket->send(packet); pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 30f24194..d6929edb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1317,10 +1317,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { bool hoverInteractableGo = false; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (entity->getType() != game::ObjectType::GAMEOBJECT) continue; - auto go = std::static_pointer_cast(entity); - auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); - uint32_t goType = goInfo ? goInfo->type : 0; - if (goType == 5) continue; // decoration/non-interactable generic glm::vec3 hitCenter; float hitRadius = 0.0f; @@ -1377,7 +1373,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && - t != game::ObjectType::PLAYER) continue; + t != game::ObjectType::PLAYER && + t != game::ObjectType::GAMEOBJECT) continue; if (guid == myGuid) continue; // Don't target self glm::vec3 hitCenter; @@ -1394,6 +1391,9 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { 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; @@ -1423,6 +1423,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { // 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(); @@ -1456,12 +1467,9 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { heightOffset = 0.3f; } } else if (t == game::ObjectType::GAMEOBJECT) { - // Check GO type — skip non-interactable decorations - auto go = std::static_pointer_cast(entity); - auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); - uint32_t goType = goInfo ? goInfo->type : 0; - // Type 5 = GENERIC (decorations), skip - if (goType == 5) continue; + // Do not hard-filter by GO type here. Some realms/content + // classify usable objects (including some chests) with types + // that look decorative in cache data. hitRadius = 2.5f; heightOffset = 1.2f; } @@ -1482,6 +1490,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (closestGuid != 0) { if (closestType == game::ObjectType::GAMEOBJECT) { + gameHandler.setTarget(closestGuid); gameHandler.interactWithGameObject(closestGuid); return; }