diff --git a/src/core/application.cpp b/src/core/application.cpp index 7a3c577a..1d72d4a5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1340,7 +1340,27 @@ void Application::update(float deltaTime) { updateCheckpoint = "in_game: auto-unsheathe"; if (gameHandler) { const bool autoAttacking = gameHandler->isAutoAttacking(); - if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) { + // Keep the attachment state consistent with the ongoing attack, not + // just the initial false -> true transition. Z can be pressed after + // combat has already started, and pre-WotLK servers briefly send + // ATTACKSTOP while the client retains attack intent for a retry. + const bool attackWeaponNeeded = autoAttacking || gameHandler->hasAutoAttackIntent(); + const auto& inventory = gameHandler->getInventory(); + const auto& mainHand = inventory.getEquipSlot(game::EquipSlot::MAIN_HAND); + const auto& offHand = inventory.getEquipSlot(game::EquipSlot::OFF_HAND); + const auto& ranged = inventory.getEquipSlot(game::EquipSlot::RANGED); + const bool hasOffHandWeapon = !offHand.empty() && + offHand.item.inventoryType == game::InvType::ONE_HAND; + const bool hasRangedWeapon = !ranged.empty() && + (ranged.item.inventoryType == game::InvType::RANGED_BOW || + ranged.item.inventoryType == game::InvType::RANGED_GUN || + ranged.item.inventoryType == game::InvType::THROWN); + const bool hasDrawableWeapon = !mainHand.empty() || hasOffHandWeapon || hasRangedWeapon; + if (attackWeaponNeeded && hasDrawableWeapon && appearanceComposer_ && + appearanceComposer_->isWeaponsSheathed()) { + if (renderer && renderer->getAnimationController()) { + renderer->getAnimationController()->playWeaponSheathAnimation(false); + } appearanceComposer_->setWeaponsSheathed(false); appearanceComposer_->loadEquippedWeapons(); } diff --git a/src/rendering/animation/character_animator.cpp b/src/rendering/animation/character_animator.cpp index 912aace1..facdaf49 100644 --- a/src/rendering/animation/character_animator.cpp +++ b/src/rendering/animation/character_animator.cpp @@ -147,8 +147,14 @@ AnimOutput CharacterAnimator::resolveAnimation() { combatIn.currentAnimTime = fi.currentAnimTime; combatIn.currentAnimDuration = fi.currentAnimDuration; combatIn.haveAnimState = fi.haveAnimState; - combatIn.hasUnsheathe = caps_.resolvedUnsheathe != 0; - combatIn.hasSheathe = caps_.resolvedSheathe != 0; + // A character model supporting draw/sheath animations does not mean the + // character currently has something to draw. Empty-handed combat must go + // directly to READY_UNARMED / ATTACK_UNARMED. Equipped fist weapons still + // have inventoryType ONE_HAND and retain their dedicated fist animation path. + const bool hasDrawableWeapon = loadout_.inventoryType != game::InvType::NON_EQUIP || + (rangedWeaponActive_ && loadout_.rangedType != RangedWeaponType::NONE); + combatIn.hasUnsheathe = hasDrawableWeapon && caps_.resolvedUnsheathe != 0; + combatIn.hasSheathe = hasDrawableWeapon && caps_.resolvedSheathe != 0; // ── Combat FSM (highest priority for non-mount) ───────────────────── auto combatOut = combat_.resolve(combatIn, caps_, loadout_); diff --git a/tests/test_combat_fsm.cpp b/tests/test_combat_fsm.cpp index 714a308d..730f104b 100644 --- a/tests/test_combat_fsm.cpp +++ b/tests/test_combat_fsm.cpp @@ -14,6 +14,7 @@ static AnimCapabilitySet makeCombatCaps() { caps.resolvedMelee1H = anim::ATTACK_1H; caps.resolvedMelee2H = anim::ATTACK_2H; caps.resolvedMeleeUnarmed = anim::ATTACK_UNARMED; + caps.resolvedReadyUnarmed = anim::READY_UNARMED; caps.resolvedStun = anim::STUN; caps.resolvedUnsheathe = anim::UNSHEATHE; caps.resolvedSheathe = anim::SHEATHE; @@ -38,6 +39,42 @@ TEST_CASE("CombatFSM: INACTIVE by default", "[combat]") { CHECK_FALSE(fsm.isActive()); } +TEST_CASE("CombatFSM: unarmed combat uses fist-ready and fist-swing animations", "[combat][unarmed]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + auto loadout = unarmedLoadout(); + auto in = combatInput(); + in.hasUnsheathe = false; + + auto ready = fsm.resolve(in, caps, loadout); + REQUIRE(ready.valid); + CHECK(fsm.getState() == CombatFSM::State::COMBAT_IDLE); + CHECK(ready.animId == anim::READY_UNARMED); + + in.meleeSwingTimer = 0.5f; + auto swing = fsm.resolve(in, caps, loadout); + REQUIRE(swing.valid); + CHECK(fsm.getState() == CombatFSM::State::MELEE_SWING); + CHECK(swing.animId == anim::ATTACK_UNARMED); +} + +TEST_CASE("CombatFSM: equipped fist weapons retain fist-weapon animations", "[combat][fist-weapon]") { + CombatFSM fsm; + auto caps = makeCombatCaps(); + caps.resolvedReadyFist = anim::READY_FIST; + caps.resolvedMeleeFist = anim::ATTACK_FIST_1H; + WeaponLoadout loadout; + loadout.inventoryType = wowee::game::InvType::ONE_HAND; + loadout.isFist = true; + auto in = combatInput(); + + fsm.setState(CombatFSM::State::COMBAT_IDLE); + CHECK(fsm.resolve(in, caps, loadout).animId == anim::READY_FIST); + + in.meleeSwingTimer = 0.5f; + CHECK(fsm.resolve(in, caps, loadout).animId == anim::ATTACK_FIST_1H); +} + TEST_CASE("CombatFSM: INACTIVE → UNSHEATHE on COMBAT_ENTER event", "[combat]") { CombatFSM fsm; fsm.onEvent(AnimEvent::COMBAT_ENTER);