diff --git a/include/core/appearance_composer.hpp b/include/core/appearance_composer.hpp index d38ab53c..4e5e2c6f 100644 --- a/include/core/appearance_composer.hpp +++ b/include/core/appearance_composer.hpp @@ -74,6 +74,10 @@ public: bool isWeaponsSheathed() const { return weaponsSheathed_; } void toggleWeaponsSheathed() { weaponsSheathed_ = !weaponsSheathed_; } + // Ranged weapon swap: temporarily show ranged weapon in right hand + void showRangedWeapon(bool show); + bool isShowingRanged() const { return showingRanged_; } + // Saved skin state accessors (used by game_screen.cpp for equipment re-compositing) const std::string& getBodySkinPath() const { return bodySkinPath_; } const std::vector& getUnderwearPaths() const { return underwearPaths_; } @@ -96,6 +100,7 @@ private: uint32_t cloakTextureSlotIndex_ = 0; bool weaponsSheathed_ = false; + bool showingRanged_ = false; }; } // namespace core diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f93b8a62..b59eec47 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -943,6 +943,10 @@ public: using MeleeSwingCallback = std::function; void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } + // Ranged weapon swap callback — show=true: swap to ranged weapon, false: back to melee + using RangedWeaponSwapCallback = std::function; + void setRangedWeaponSwapCallback(RangedWeaponSwapCallback cb) { rangedWeaponSwapCallback_ = std::move(cb); } + // Spell cast animation callbacks — true=start cast/channel, false=finish/cancel // guid: caster (may be player or another unit), isChannel: channel vs regular cast // castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE) @@ -2399,6 +2403,13 @@ public: auto& knockBackCallbackRef() { return knockBackCallback_; } auto& lootWindowCallbackRef() { return lootWindowCallback_; } auto& meleeSwingCallbackRef() { return meleeSwingCallback_; } + auto& rangedWeaponSwapCallbackRef() { return rangedWeaponSwapCallback_; } + void suppressNextMeleeSwingAnim() { suppressMeleeSwingAnim_ = true; } + bool consumeSuppressMeleeSwingAnim() { + bool v = suppressMeleeSwingAnim_; + suppressMeleeSwingAnim_ = false; + return v; + } auto& mountCallbackRef() { return mountCallback_; } auto& npcAggroCallbackRef() { return npcAggroCallback_; } auto& npcDeathCallbackRef() { return npcDeathCallback_; } @@ -3436,6 +3447,8 @@ private: AppearanceChangedCallback appearanceChangedCallback_; GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + RangedWeaponSwapCallback rangedWeaponSwapCallback_; + bool suppressMeleeSwingAnim_ = false; // lastMeleeSwingMs_ moved to CombatHandler SpellCastAnimCallback spellCastAnimCallback_; SpellCastFailedCallback spellCastFailedCallback_; diff --git a/src/core/appearance_composer.cpp b/src/core/appearance_composer.cpp index 9f14bd32..d7d38cc2 100644 --- a/src/core/appearance_composer.cpp +++ b/src/core/appearance_composer.cpp @@ -324,6 +324,7 @@ bool AppearanceComposer::loadWeaponM2(const std::string& m2Path, pipeline::M2Mod } void AppearanceComposer::loadEquippedWeapons() { + showingRanged_ = false; if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; if (!gameHandler_) return; @@ -354,9 +355,12 @@ void AppearanceComposer::loadEquippedWeapons() { for (const auto& ws : weaponSlots) { charRenderer->detachWeapon(charInstanceId, ws.attachmentId); } + charRenderer->detachWeapon(charInstanceId, 1); // ranged may also use right hand return; } + bool rightHandFilled = false; + for (const auto& ws : weaponSlots) { const auto& equipSlot = inventory.getEquipSlot(ws.slot); @@ -421,8 +425,123 @@ void AppearanceComposer::loadEquippedWeapons() { weaponModel, weaponModelId, texturePath); if (ok) { LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId); + if (ws.attachmentId == 1) rightHandFilled = true; } } + + // --- RANGED slot (bow, gun, crossbow, thrown) --- + // Show ranged weapon in right hand when main hand is empty. + const auto& rangedSlot = inventory.getEquipSlot(game::EquipSlot::RANGED); + if (!rightHandFilled && !rangedSlot.empty() && rangedSlot.item.displayInfoId != 0) { + uint32_t displayInfoId = rangedSlot.item.displayInfoId; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx >= 0) { + const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1); + std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3); + + if (!modelName.empty()) { + std::string modelFile = modelName; + { + size_t dotPos = modelFile.rfind('.'); + if (dotPos != std::string::npos) { + modelFile = modelFile.substr(0, dotPos) + ".m2"; + } else { + modelFile += ".m2"; + } + } + + std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; + pipeline::M2Model weaponModel; + if (!loadWeaponM2(m2Path, weaponModel)) { + m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; + loadWeaponM2(m2Path, weaponModel); + } + + if (weaponModel.vertices.size() > 0) { + std::string texturePath; + if (!textureName.empty()) { + texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) { + texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; + } + } + + uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId(); + bool ok = charRenderer->attachWeapon(charInstanceId, 1, + weaponModel, weaponModelId, texturePath); + if (ok) { + LOG_INFO("Equipped ranged weapon: ", m2Path, " at attachment 1 (right hand)"); + } + } + } + } + } +} + +void AppearanceComposer::showRangedWeapon(bool show) { + if (show == showingRanged_) return; + showingRanged_ = show; + + if (!renderer_ || !renderer_->getCharacterRenderer() || !gameHandler_ || !assetManager_ || !assetManager_->isInitialized()) + return; + + auto* charRenderer = renderer_->getCharacterRenderer(); + uint32_t charInstanceId = renderer_->getCharacterInstanceId(); + if (charInstanceId == 0) return; + + if (!show) { + // Swap back to normal melee weapons + loadEquippedWeapons(); + return; + } + + auto& inventory = gameHandler_->getInventory(); + const auto& rangedSlot = inventory.getEquipSlot(game::EquipSlot::RANGED); + if (rangedSlot.empty() || rangedSlot.item.displayInfoId == 0) return; + + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return; + + uint32_t displayInfoId = rangedSlot.item.displayInfoId; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return; + + const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1); + std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3); + if (modelName.empty()) return; + + std::string modelFile = modelName; + { + size_t dotPos = modelFile.rfind('.'); + if (dotPos != std::string::npos) + modelFile = modelFile.substr(0, dotPos) + ".m2"; + else + modelFile += ".m2"; + } + + std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; + pipeline::M2Model weaponModel; + if (!loadWeaponM2(m2Path, weaponModel)) { + m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; + if (!loadWeaponM2(m2Path, weaponModel)) return; + } + + std::string texturePath; + if (!textureName.empty()) { + texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) + texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; + } + + // Detach current right-hand weapon and attach ranged weapon + charRenderer->detachWeapon(charInstanceId, 1); + uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId(); + bool ok = charRenderer->attachWeapon(charInstanceId, 1, weaponModel, weaponModelId, texturePath); + if (ok) { + LOG_INFO("Swapped to ranged weapon: ", m2Path, " at attachment 1 (right hand)"); + } } } // namespace core diff --git a/src/core/application.cpp b/src/core/application.cpp index 966bbba5..8cdcfcfb 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -980,14 +980,23 @@ void Application::setState(AppState newState) { if (renderer) { // Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764) if (spellId == 75 || spellId == 5019 || spellId == 2764) { + if (appearanceComposer_ && !appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(true); if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot(); } else if (spellId != 0) { + if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(false); if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId); } else { + if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(false); if (auto* ac = renderer->getAnimationController()) ac->triggerMeleeSwing(); } } }); + gameHandler->setRangedWeaponSwapCallback([this](bool show) { + if (appearanceComposer_) appearanceComposer_->showRangedWeapon(show); + }); gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); @@ -1216,6 +1225,10 @@ void Application::update(float deltaTime) { appearanceComposer_->setWeaponsSheathed(false); appearanceComposer_->loadEquippedWeapons(); } + // Swap back to melee weapon when auto-attack stops + if (!autoAttacking && wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isShowingRanged()) { + appearanceComposer_->showRangedWeapon(false); + } wasAutoAttacking_ = autoAttacking; } diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 6dc004f6..31648bdf 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -206,14 +206,21 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) { owner_.dismount(); } - // Client-side melee range gate to avoid starting "swing forever" loops when + // Client-side range gate to avoid starting "swing forever" loops when // target is already clearly out of range. if (auto target = owner_.getEntityManager().getEntity(targetGuid)) { float dx = owner_.movementInfoRef().x - target->getLatestX(); float dy = owner_.movementInfoRef().y - target->getLatestY(); float dz = owner_.movementInfoRef().z - target->getLatestZ(); float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); - if (dist3d > 8.0f) { + // Use longer range limit when a ranged weapon is equipped + const auto& rangedSlot = owner_.getInventory().getEquipSlot(game::EquipSlot::RANGED); + bool hasRangedWeapon = !rangedSlot.empty() && + (rangedSlot.item.inventoryType == game::InvType::RANGED_BOW || + rangedSlot.item.inventoryType == game::InvType::RANGED_GUN || + rangedSlot.item.inventoryType == game::InvType::THROWN); + float maxRange = hasRangedWeapon ? 40.0f : 8.0f; + if (dist3d > maxRange) { if (autoAttackRangeWarnCooldown_ <= 0.0f) { owner_.addSystemChatMessage("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; @@ -443,7 +450,14 @@ void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { lastMeleeSwingMs_ = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); - if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0); + // Skip melee animation if a ranged shot was just triggered from + // SMSG_SPELL_GO (Auto Shot / Shoot / Throw). The ranged animation + // is already playing; firing the melee callback here would override it. + if (owner_.consumeSuppressMeleeSwingAnim()) { + LOG_DEBUG("Suppressed melee swing anim — ranged shot already triggered"); + } else { + if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0); + } } if (!isPlayerAttacker && owner_.npcSwingCallbackRef()) { owner_.npcSwingCallbackRef()(data.attackerGuid); diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 4ce04d9f..72476c3c 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -1025,8 +1025,16 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { if (!owner_.isProfessionSpell(data.spellId)) playSpellCastSound(data.spellId); - // Instant melee abilities → trigger attack animation + // Ranged auto-attack spells (Auto Shot, Shoot, Throw) complete as timed + // casts and are NOT classified as instant melee abilities, so trigger the + // ranged shot animation explicitly here. uint32_t sid = data.spellId; + if (sid == 75 || sid == 5019 || sid == 2764) { + if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(sid); + owner_.suppressNextMeleeSwingAnim(); + } + + // Instant melee abilities → trigger attack animation bool isMeleeAbility = false; if (!owner_.isProfessionSpell(sid)) { owner_.loadSpellNameCache();