using System; using System.Collections.Generic; using ACE.DatLoader.Entity.AnimationHooks; using ACE.Entity.Enum; using ACE.Server.Entity; using ACE.Server.Entity.Actions; using ACE.Server.Managers; using ACE.Server.Network.GameEvent.Events; using ACE.Server.Physics; using ACE.Server.Physics.Animation; namespace ACE.Server.WorldObjects { /// /// Player melee attack /// partial class Player { /// /// The target this player is currently performing a melee attack on /// public Creature MeleeTarget; private float _powerLevel; /// /// The power bar level, a value between 0-1 /// public float PowerLevel { get => IsExhausted ? 0.0f : _powerLevel; set => _powerLevel = value; } public override PowerAccuracy GetPowerRange() { if (PowerLevel < 0.33f) return PowerAccuracy.Low; else if (PowerLevel < 0.66f) return PowerAccuracy.Medium; else return PowerAccuracy.High; } public AttackQueue AttackQueue; /// /// Called when a player first initiates a melee attack /// public void HandleActionTargetedMeleeAttack(uint targetGuid, uint attackHeight, float powerLevel) { //log.Info($"-"); if (CombatMode != CombatMode.Melee) { log.Warn($"{Name}.HandleActionTargetedMeleeAttack({targetGuid:X8}, {attackHeight}, {powerLevel}) - CombatMode mismatch {CombatMode}, LastCombatMode {LastCombatMode}"); if (LastCombatMode == CombatMode.Melee) CombatMode = CombatMode.Melee; else { OnAttackDone(); return; } } if (IsBusy || Teleporting || suicideInProgress) { SendWeenieError(WeenieError.YoureTooBusy); OnAttackDone(); return; } if (IsJumping) { SendWeenieError(WeenieError.YouCantDoThatWhileInTheAir); OnAttackDone(); return; } if (PKLogout) { SendWeenieError(WeenieError.YouHaveBeenInPKBattleTooRecently); OnAttackDone(); return; } // verify input powerLevel = Math.Clamp(powerLevel, 0.0f, 1.0f); AttackHeight = (AttackHeight)attackHeight; AttackQueue.Add(powerLevel); if (MeleeTarget == null) PowerLevel = AttackQueue.Fetch(); // already in melee loop? if (Attacking || MeleeTarget != null && MeleeTarget.IsAlive) return; // get world object for target creature var target = CurrentLandblock?.GetObject(targetGuid); if (target == null) { //log.DebugFormat("{0}.HandleActionTargetedMeleeAttack({1:X8}, {2}, {3}) - couldn't find target guid", Name, targetGuid, AttackHeight, powerLevel); OnAttackDone(); return; } var creatureTarget = target as Creature; if (creatureTarget == null) { log.Warn($"{Name}.HandleActionTargetedMeleeAttack({targetGuid:X8}, {AttackHeight}, {powerLevel}) - target guid not creature"); OnAttackDone(); return; } if (!CanDamage(creatureTarget)) { SendTransientError($"You cannot attack {creatureTarget.Name}"); OnAttackDone(); return; } if (!creatureTarget.IsAlive) { OnAttackDone(); return; } //log.Info($"{Name}.HandleActionTargetedMeleeAttack({targetGuid:X8}, {attackHeight}, {powerLevel})"); MeleeTarget = creatureTarget; AttackTarget = MeleeTarget; // reset PrevMotionCommand / DualWieldAlternate each time button is clicked PrevMotionCommand = MotionCommand.Invalid; DualWieldAlternate = false; var attackSequence = ++AttackSequence; if (NextRefillTime > DateTime.UtcNow) { var delayTime = (float)(NextRefillTime - DateTime.UtcNow).TotalSeconds; var actionChain = new ActionChain(); actionChain.AddDelaySeconds(delayTime); actionChain.AddAction(this, () => { if (!creatureTarget.IsAlive) { OnAttackDone(); return; } HandleActionTargetedMeleeAttack_Inner(target, attackSequence); }); actionChain.EnqueueChain(); } else HandleActionTargetedMeleeAttack_Inner(target, attackSequence); } public const float MeleeDistance = 0.6f; public const float StickyDistance = 4.0f; public const float RepeatDistance = 16.0f; public void HandleActionTargetedMeleeAttack_Inner(WorldObject target, int attackSequence) { var dist = GetCylinderDistance(target); if (dist <= MeleeDistance || dist <= StickyDistance && IsMeleeVisible(target)) { // sticky melee var angle = GetAngle(target); if (angle > PropertyManager.GetDouble("melee_max_angle").Item) { var rotateTime = Rotate(target); var actionChain = new ActionChain(); actionChain.AddDelaySeconds(rotateTime); actionChain.AddAction(this, () => Attack(target, attackSequence)); actionChain.EnqueueChain(); } else Attack(target, attackSequence); } else { // turn / move to required if (GetCharacterOption(CharacterOption.UseChargeAttack)) { //log.Info($"{Name}.MoveTo({target.Name})"); // charge attack MoveTo(target); } else { //log.Info($"{Name}.CreateMoveToChain({target.Name})"); CreateMoveToChain(target, (success) => { if (success) Attack(target, attackSequence); else OnAttackDone(); }); } } } public void OnAttackDone(WeenieError error = WeenieError.None) { // this function is called at the very end of an attack sequence, // and not between the repeat attacks // it sends action cancelled so the power / accuracy meter // is reset, and doesn't start refilling again // the werror for this network message is not displayed to the client -- // if you wish to display a message, a separate GameEventWeenieError should also be sent Session.Network.EnqueueSend(new GameEventAttackDone(Session, WeenieError.ActionCancelled)); AttackTarget = null; MeleeTarget = null; MissileTarget = null; AttackQueue.Clear(); AttackCancelled = false; } /// /// called when client sends the 'Cancel attack' network message /// public void HandleActionCancelAttack(WeenieError error = WeenieError.None) { //Console.WriteLine($"{Name}.HandleActionCancelAttack()"); if (Attacking) AttackCancelled = true; else if (AttackTarget != null) OnAttackDone(); PhysicsObj.cancel_moveto(); } /// /// Performs a player melee attack against a target /// public void Attack(WorldObject target, int attackSequence, bool subsequent = false) { //log.Info($"{Name}.Attack({target.Name}, {attackSequence})"); if (AttackSequence != attackSequence) return; if (CombatMode != CombatMode.Melee || MeleeTarget == null || IsBusy || !IsAlive || suicideInProgress) { OnAttackDone(); return; } var creature = target as Creature; if (creature == null || !creature.IsAlive) { OnAttackDone(); return; } var animLength = DoSwingMotion(target, out var attackFrames); if (animLength == 0) { OnAttackDone(); return; } // point of no return beyond this point -- cannot be cancelled Attacking = true; if (subsequent) { // client shows hourglass, until attack done is received // retail only did this for subsequent attacks w/ repeat attacks on Session.Network.EnqueueSend(new GameEventCombatCommenceAttack(Session)); } var weapon = GetEquippedMeleeWeapon(); var attackType = GetWeaponAttackType(weapon); var numStrikes = GetNumStrikes(attackType); var swingTime = animLength / numStrikes / 1.5f; var actionChain = new ActionChain(); // stamina usage // TODO: ensure enough stamina for attack var staminaCost = GetAttackStamina(GetPowerRange()); UpdateVitalDelta(Stamina, -staminaCost); if (numStrikes != attackFrames.Count) { //log.Warn($"{Name}.GetAttackFrames(): MotionTableId: {MotionTableId:X8}, MotionStance: {CurrentMotionState.Stance}, Motion: {GetSwingAnimation()}, AttackFrames.Count({attackFrames.Count}) != NumStrikes({numStrikes})"); numStrikes = attackFrames.Count; } // handle self-procs TryProcEquippedItems(this, this, true, weapon); var prevTime = 0.0f; bool targetProc = false; for (var i = 0; i < numStrikes; i++) { // are there animation hooks for damage frames? //if (numStrikes > 1 && !TwoHandedCombat) //actionChain.AddDelaySeconds(swingTime); actionChain.AddDelaySeconds(attackFrames[i].time * animLength - prevTime); prevTime = attackFrames[i].time * animLength; actionChain.AddAction(this, () => { if (IsDead) { Attacking = false; OnAttackDone(); return; } var damageEvent = DamageTarget(creature, weapon); // handle target procs if (damageEvent != null && damageEvent.HasDamage && !targetProc) { TryProcEquippedItems(this, creature, false, weapon); targetProc = true; } if (weapon != null && weapon.IsCleaving) { var cleave = GetCleaveTarget(creature, weapon); foreach (var cleaveHit in cleave) { // target procs don't happen for cleaving DamageTarget(cleaveHit, weapon); } } }); //if (numStrikes == 1 || TwoHandedCombat) //actionChain.AddDelaySeconds(swingTime); } //actionChain.AddDelaySeconds(animLength - swingTime * numStrikes); actionChain.AddDelaySeconds(animLength - prevTime); actionChain.AddAction(this, () => { Attacking = false; // powerbar refill timing var refillMod = IsDualWieldAttack ? 0.8f : 1.0f; // dual wield powerbar refills 20% faster PowerLevel = AttackQueue.Fetch(); var nextRefillTime = PowerLevel * refillMod; NextRefillTime = DateTime.UtcNow.AddSeconds(nextRefillTime); var dist = GetCylinderDistance(target); if (creature.IsAlive && GetCharacterOption(CharacterOption.AutoRepeatAttacks) && (dist <= MeleeDistance || dist <= StickyDistance && IsMeleeVisible(target)) && !IsBusy && !AttackCancelled) { // client starts refilling power meter Session.Network.EnqueueSend(new GameEventAttackDone(Session)); var nextAttack = new ActionChain(); nextAttack.AddDelaySeconds(nextRefillTime); nextAttack.AddAction(this, () => Attack(target, attackSequence, true)); nextAttack.EnqueueChain(); } else OnAttackDone(); }); actionChain.EnqueueChain(); if (UnderLifestoneProtection) LifestoneProtectionDispel(); } /// /// Performs the player melee swing animation /// public float DoSwingMotion(WorldObject target, out List<(float time, AttackHook attackHook)> attackFrames) { // get the proper animation speed for this attack, // based on weapon speed and player quickness var baseSpeed = GetAnimSpeed(); var animSpeedMod = IsDualWieldAttack ? 1.2f : 1.0f; // dual wield swing animation 20% faster var animSpeed = baseSpeed * animSpeedMod; var swingAnimation = GetSwingAnimation(); var animLength = MotionTable.GetAnimationLength(MotionTableId, CurrentMotionState.Stance, swingAnimation, animSpeed); //Console.WriteLine($"AnimSpeed: {animSpeed}, AnimLength: {animLength}"); attackFrames = MotionTable.GetAttackFrames(MotionTableId, CurrentMotionState.Stance, swingAnimation); //Console.WriteLine($"Attack frames: {string.Join(",", attackFrames)}"); // broadcast player swing animation to clients var motion = new Motion(this, swingAnimation, animSpeed); if (PropertyManager.GetBool("persist_movement").Item) { motion.Persist(CurrentMotionState); } motion.MotionState.TurnSpeed = 2.25f; motion.MotionFlags |= MotionFlags.StickToObject; motion.TargetGuid = target.Guid; CurrentMotionState = motion; EnqueueBroadcastMotion(motion); if (FastTick) PhysicsObj.stick_to_object(target.Guid.Full); return animLength; } public const float KickThreshold = 0.75f; public MotionCommand PrevMotionCommand; /// /// Returns the melee swing animation - based on weapon, /// current stance, power bar, and attack height /// public MotionCommand GetSwingAnimation() { if (IsDualWieldAttack) DualWieldAlternate = !DualWieldAlternate; var offhand = IsDualWieldAttack && !DualWieldAlternate; var weapon = GetEquippedMeleeWeapon(); // for reference: https://www.youtube.com/watch?v=MUaD53D9c74 // a player with 1/2 power bar, or slightly below half // doing the backswing, well above 33% var subdivision = 0.33f; if (weapon != null) { AttackType = weapon.GetAttackType(CurrentMotionState.Stance, PowerLevel, offhand); if (weapon.IsThrustSlash) subdivision = 0.66f; } else { AttackType = PowerLevel > KickThreshold && !IsDualWieldAttack ? AttackType.Kick : AttackType.Punch; } var motions = CombatTable.GetMotion(CurrentMotionState.Stance, AttackHeight.Value, AttackType, PrevMotionCommand); // higher-powered animation always in first slot ? var motion = motions.Count > 1 && PowerLevel < subdivision ? motions[1] : motions[0]; PrevMotionCommand = motion; //Console.WriteLine($"{motion}"); return motion; } } }