Click to Attack (having trouble)

(Building off of Psyconius’s post)

I would also like to mandate my players click/press the attack button every time they wish to attack. Reading the previous post about this question, I tried to solve it using the available information. However (I don’t know if it’s because I’m new at C# and coding in general, or if its because I misinterpreted something), my code just doesn’t seem to want to work. Forgive me if it seems like I’m asking dumb questions but I’ve been hitting my head against the wall over this.

The two confusing parts for me were 1) when you said to subscribe and unsubscribe from the StateMachine.InputReader.OnAttack() in Enter and Exit and 2) when Psyconius described adding listeners to the FreeLook and Targeting states.

I’m having trouble seeing what these look like, even after Googling. Here’s what I have that Unity doesn’t like.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerAttackingState : PlayerBaseState
{
    private float previousFrameTime;
    private bool alreadyAppliedForce;
    private Attack attack;
    public PlayerAttackingState(PlayerStateMachine stateMachine, int attackIndex) : base(stateMachine)
    {
        attack = stateMachine.Attacks[attackIndex];
    }

    public override void Enter()
    {
        stateMachine.InputReader.AttackDown += OnAttack;
        
        stateMachine.Weapon.SetAttack(attack.Damage, attack.Knockback);
        stateMachine.Animator.CrossFadeInFixedTime(attack.AnimationName, attack.TransitionDuration);
        Debug.Log(attack.AnimationName);
    }

    public override void Tick(float deltaTime)
    {
        Move(deltaTime);
        FaceTarget();

        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
            
        if(normalizedTime >= previousFrameTime && normalizedTime < 1f)
        {
            if (normalizedTime >= attack.ForceTime)
            {
                TryApplyForce();
            }
            /*if(stateMachine.InputReader.OnAttack)
            {
                TryComboAttack(normalizedTime);
            }*/
        }
        else
        {
            if(stateMachine.Targeter.CurrentTarget != null)
            {
                stateMachine.SwitchState(new PlayerTargetingState(stateMachine));
            }
            else
            {
                stateMachine.SwitchState(new PlayerFreeLookState(stateMachine));
            }
        }
        
        previousFrameTime = normalizedTime;
    }
    public override void Exit()
    {
        stateMachine.InputReader.AttackDown -= OnAttack;
    }
    private void TryComboAttack(float normalizedTime)
    {
        if(attack.ComboStateIndex == -1) { return; }

        if(normalizedTime < attack.ComboAttackTime) { return; }

        stateMachine.SwitchState
        (
            new PlayerAttackingState
            (
                stateMachine,
                attack.ComboStateIndex
            )
        );
    }
    private void TryApplyForce()
    {
        if(alreadyAppliedForce) { return; }
        stateMachine.ForceReceiver.AddForce(stateMachine.transform.forward * attack.Force);
        alreadyAppliedForce = true;
    }

    void HandleAttackDown()
    {
        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
        TryComboAttack(normalizedTime);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerFreeLookState : PlayerBaseState
{
private bool shouldFade;
private readonly int FreeLookBlendTreeHash = Animator.StringToHash("FreeLookBlendTree");
private readonly int FreeLookSpeedHash = Animator.StringToHash("FreeLookSpeed");
private const float AnimatorDampTime = 0.1f;
private const float CrossFadeDuration = 0.1f;
public PlayerFreeLookState(PlayerStateMachine stateMachine, bool shouldFade = true) : base(stateMachine) 
{
    this.shouldFade = shouldFade;
}

    public override void Enter()
    {
        stateMachine.InputReader.TargetEvent += OnTarget;
        stateMachine.InputReader.DodgeEvent += OnDodge;
        stateMachine.InputReader.JumpEvent += OnJump;

        stateMachine.Animator.SetFloat(FreeLookSpeedHash, 0f);

        if (shouldFade)
        {
            stateMachine.Animator.CrossFadeInFixedTime(FreeLookBlendTreeHash, CrossFadeDuration);
        }
        else
        {
            stateMachine.Animator.Play(FreeLookBlendTreeHash);
        }
    }

    public override void Tick(float deltaTime)
    {
        Vector3 movement = CalculateMovement();
            
        Move(movement * stateMachine.FreeLookMovementSpeed, deltaTime);
            
        if(stateMachine.InputReader.OnAttack(AttackDown))
        {
            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
            return;
        } 

        if(stateMachine.InputReader.IsBlocking)
        {
            stateMachine.SwitchState(new PlayerBlockingState(stateMachine));
            return;
        }

        if (stateMachine.InputReader.MovementValue == Vector2.zero) 
        { 
            stateMachine.Animator.SetFloat(FreeLookSpeedHash, 0, AnimatorDampTime, deltaTime);
            return; 
        }

        stateMachine.Animator.SetFloat(FreeLookSpeedHash, 1, AnimatorDampTime, deltaTime);
            
        FaceMovementDirection(movement,deltaTime);
    }

    public override void Exit()
    {
        stateMachine.InputReader.TargetEvent -= OnTarget;
        stateMachine.InputReader.DodgeEvent -= OnDodge;
        stateMachine.InputReader.JumpEvent -= OnJump;
    }

    private void OnTarget()
    {
        if(!stateMachine.Targeter.SelectTarget()) { return; }

        stateMachine.SwitchState(new PlayerTargetingState(stateMachine));
    }

    private void OnJump()
    {
        stateMachine.SwitchState(new PlayerJumpingState(stateMachine));
    }

    private void OnDodge()
    {
        if(stateMachine.InputReader.MovementValue == Vector2.zero) { return; }
        
        stateMachine.SwitchState(new PlayerDodgingState(stateMachine, stateMachine.InputReader.MovementValue));
    }
    private Vector3 CalculateMovement()
    {
        Vector3 forward = stateMachine.MainCameraTransform.forward;
        Vector3 right = stateMachine.MainCameraTransform.right;

        forward.y = 0f;
        right.y = 0f;

        forward.Normalize();
        right.Normalize();

        return forward * stateMachine.InputReader.MovementValue.y + right * stateMachine.InputReader.MovementValue.x;
    } 
    private void FaceMovementDirection(Vector3 movement, float deltaTime)
    {
        stateMachine.transform.rotation = Quaternion.Lerp(
        stateMachine.transform.rotation, 
        Quaternion.LookRotation(movement),
        deltaTime * stateMachine.RotationDamping);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerTargetingState : PlayerBaseState
{
    private readonly int TargetingBlendTreeHash = Animator.StringToHash("TargetingBlendTree");

    private readonly int TargetingForwardSpeedHash = Animator.StringToHash("TargetingForwardSpeed");

    private readonly int TargetingRightSpeedHash = Animator.StringToHash("TargetingRightSpeed");

    private const float CrossFadeDuration = 0.1f;
        
    public PlayerTargetingState(PlayerStateMachine stateMachine) : base(stateMachine) { }
        
    public override void Enter()
    {
        stateMachine.InputReader.TargetEvent += OnTarget;
        stateMachine.InputReader.DodgeEvent += OnDodge;
        stateMachine.InputReader.JumpEvent += OnJump;

        stateMachine.Animator.CrossFadeInFixedTime(TargetingBlendTreeHash, CrossFadeDuration);
    }

    public override void Tick(float deltaTime)
    {
        if(stateMachine.InputReader.OnAttack(AttackDown))
        {
            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
            return;
        }

        if(stateMachine.InputReader.IsBlocking)
        {
            stateMachine.SwitchState(new PlayerBlockingState(stateMachine));
            return;
        }
            
        if(stateMachine.Targeter.CurrentTarget == null)
        {
            stateMachine.SwitchState(new PlayerFreeLookState(stateMachine));
            return; 
        }

    Vector3 movement = CalculateMovement(deltaTime);
            
    Move(movement * stateMachine.TargetingMovementSpeed, deltaTime);
            
    UpdateAnimator(deltaTime);
            
    FaceTarget();
    }

    public override void Exit()
    {
        stateMachine.InputReader.TargetEvent -= OnTarget;
        stateMachine.InputReader.DodgeEvent -= OnDodge;
        stateMachine.InputReader.JumpEvent -= OnJump;
    }

    private void OnTarget()
    {
        stateMachine.Targeter.Cancel();

        stateMachine.SwitchState(new PlayerFreeLookState(stateMachine));
    }

    private void OnDodge()
    {
        if(stateMachine.InputReader.MovementValue == Vector2.zero) { return; }
        
        stateMachine.SwitchState(new PlayerDodgingState(stateMachine, stateMachine.InputReader.MovementValue));
    }

    private void OnJump()
    {
        stateMachine.SwitchState(new PlayerJumpingState(stateMachine));
    }

    private Vector3 CalculateMovement(float deltaTime)
    {
        Vector3 movement = new Vector3();
            
        movement += stateMachine.transform.right * stateMachine.InputReader.MovementValue.x;
        movement += stateMachine.transform.forward * stateMachine.InputReader.MovementValue.y;
        
        return movement;
    }

    private void UpdateAnimator(float deltaTime)
    {
        if(stateMachine.InputReader.MovementValue.y == 0)
        {
            stateMachine.Animator.SetFloat(TargetingForwardSpeedHash, 0, 0.1f, deltaTime);
        }
        else
        {
            float value = stateMachine.InputReader.MovementValue.y > 0 ? 1f : -1f;
            stateMachine.Animator.SetFloat(TargetingForwardSpeedHash, value, 0.1f, deltaTime);
        }

        if(stateMachine.InputReader.MovementValue.x == 0)
        {
            stateMachine.Animator.SetFloat(TargetingRightSpeedHash, 0, 0.1f, deltaTime);
        }
        else
        {
            float value = stateMachine.InputReader.MovementValue.x > 0 ? 1f : -1f;
            stateMachine.Animator.SetFloat(TargetingRightSpeedHash, value, 0.1f, deltaTime);
        }
    }
}

I’m not 100% sure what you are struggling with. What I can see here, though, is that your PlayerAttackingState is subscribing to AttackDown, but I cannot see the OnAttack() to handle it. This would cause an issue. I see HandleAttackDown() which is not being used by anything. I suspect you wanted this to be the handler. You need to either change HandleAttackDown() to OnAttack()

    void OnAttack() // <- Change handler name
    {
        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
        TryComboAttack(normalizedTime);
    }

or set HandleAttackDown() to be the handler

    public override void Enter()
    {
        stateMachine.InputReader.AttackDown += HandleAttackDown; // <- Set correct handler
        
        stateMachine.Weapon.SetAttack(attack.Damage, attack.Knockback);
        stateMachine.Animator.CrossFadeInFixedTime(attack.AnimationName, attack.TransitionDuration);
        Debug.Log(attack.AnimationName);
    }

Thank you! That fixed the error message in my PlayerAttackingState, I used your second suggestion.

Now I’m just stuck on how to name these lines in my FreeLook and Targeting States.

Here was the original code from the course, this is from the FreeLookState.

public override void Tick(float deltaTime)
    {
        Vector3 movement = CalculateMovement();
            
        Move(movement * stateMachine.FreeLookMovementSpeed, deltaTime);
            
        if(stateMachine.InputReader.IsAttacking)
        {
            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
            return;
        }

And here’s mine that Unity wasn’t happy with.

public override void Tick(float deltaTime)
    {
        Vector3 movement = CalculateMovement();
            
        Move(movement * stateMachine.FreeLookMovementSpeed, deltaTime);
            
        if(stateMachine.InputReader.OnAttack(AttackDown))
        {
            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
            return;
        } 

And here’s my version from the TargetingState.

public override void Tick(float deltaTime)
    {
        if(stateMachine.InputReader.OnAttack(AttackDown))
        {
            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
            return;
        }

It seems when I switched it from IsAttacking to OnAttack(AttackDown), it wasn’t happy. My question can be boiled down to: What should I write instead to make my code smile?

(I also wish the preformatted text included the swiggly red error lines I see, or that I could highlight where the errors are showing)

I don’t know what this is. Can you show where this OnAttack(...) function is in InputReader?

Sure, here’s the public event near the top.

public event System.Action AttackDown; //Can combo into 3 normal attacks

And here’s the public void OnAttack

public void OnAttack(InputAction.CallbackContext context)
    {
        if (!context.performed) {return;}
        AttackDown?.Invoke();
    }
type or paste code here

OK, so you are calling the function that the Input system is supposed to use. This is not how it works at all. You are just meant to listen for the event.

In the course, we want the button to be held down to perform the combo attacks. This is why we check in Tick if the button is being pressed. You don’t want to have the button held down, but clicked for each attack so you changed the input behaviour and still expect it to work the same way.

You should now not be checking for the button in Tick. You should just subscribe to the AttackDown event, and then handle it if/when it happens. Technically, you could have left the Input as-is in order to trigger the first attack, but because you want a click per attack you would’ve had to have an event for subsequent attacks anyway, so this is fine.

Here’s your PlayerFreeLookState with the changed bits only

public class PlayerFreeLookState : PlayerBaseState
{
    public override void Enter()
    {
        // Subscribe to the event
        stateMachine.InputReader.AttackDown += OnAttackDown;
    }

    public override void Tick(float deltaTime)
    {
// Remove all this
//        if(stateMachine.InputReader.OnAttack(AttackDown))
//        {
//            stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
//            return;
//        } 
    }

    public override void Exit()
    {
        // Unsubscribe from the event
        stateMachine.InputReader.AttackDown -= OnAttackDown;
    }

    // Add the handler
    private void OnAttackDown()
    {
        // Change state when attack button is down
        stateMachine.SwitchState(new PlayerAttackingState(stateMachine, 0));
    }
}

Note: None of this is tested. I’m also not sure how your attack state is going to behave because it requires additional code to do what you want.


Edit
I just read through this again and it comes across as very condescending. I’m sorry, this was not my intention

1 Like

Thank you again! Those fixed my scripting issues. No major issues came up too, all this did was make it so my character can attack once per click. They can only do their first attack animation, but they cycle in and out of it just fine.

Now the only issue I have is figuring out how to make each mouse click combo into each other, and ideally, giving the player a flexible window so they can better time their follow-up strikes. (one of Sebastian Graves’ dark souls videos showed a great way to do this, but I wanted to explore solutions that were more in line with this course’s foundation).

Apology accepted! I acknowledge this was a base-level question and that with more coding experience, I would have been able to decipher some previous instructions in other posts (I just started the baseline 3D Unity course, probably should have taken that before finishing this one). Seeing the code visually rather than having it described to me helps me learn it better.

Now to look at other posts and hopefully figure out a way to (hope someone’s listening)

Make each button press trigger the next combo animation AND allow for flexible timing in between attacks. This would mean someone button mashing would cycle through their combo faster, whereas someone more precise would space out their attacks, even if by 0.1 seconds.

In the course each attack is played, and each frame we check if the button is pressed. If the button is pressed, we also check if the relevant amount of time has passed (ComboAttackTime) before actually doing anything with that button. For what you want, you probably want to do something similar; When the event fire, check if the ComboAttackTime has passed and if it has, trigger the next combo. What you’d want to ask yourself is “Do I want button mashing, or do I want precision?”. If you are ok with people just mashing buttons to do a combo, then this is all you need to do. However, if you want combo attacks to be timed to precision, you need to consider when the button was pressed. If it was too soon ignore all subsequent button presses. If it was too late, well, the state would’ve gone back to ‘locomotion’, so you’d be restarting the attack anyway.

In the attacking state you will - again - not be checking the button in Tick but subscribing to the AttackDown event. In the event handler you will handle what you want

Here is a sample for a button-mashing-friendly handler

private void OnAttackDown()
{
    float normalisedTime = GetNormalisedTime(stateMachine.Animator, "Attack");
    if (normalisedTime < currentAttack.ComboAttackTime) // not time for a click yet
        return;
    stateMachine.SwitchState(new PlayerAttackingState(stateMachine, currentAttack.ComboStateIndex));
}

And this one will only listen for clicks until one happens. Then it stops listening, so the clicks have to be timed properly. Only if the click was at the right time will the next combo happen

private void OnAttackDown()
{
    // Unsubscribe from the attack event so we don't handle the event anymore
    stateMachine.InputReader.AttackDown -= OnAttackDown;

    float normalisedTime = GetNormalisedTime(stateMachine.Animator, "Attack");
    if (normalisedTime < currentAttack.ComboAttackTime)
        return;
    stateMachine.SwitchState(new PlayerAttackingState(stateMachine, currentAttack.ComboStateIndex));
}

Again: Untested, but it should work.

2 Likes

From just looking at it, it looks good and I’ll probably have to tweak it some (I only want players to be able to trigger the 2nd animation AFTER the 1st one’s hotbox has passed.)

However after implementing it, I’m getting the error message “the name ‘currentAttack’ does not exist in the current context.” Where are we getting currentAttack from?

Update, I tried putting this

public string currentAttack;

in my Attack script, but that hasn’t worked. Will keep experimenting.

currentAttack is what I called the AttackData for the current attack. You’d use whatever yours is

1 Like

Thank you so much! I literally figured that out a minute ago after struggling for a half hour lol. Got rid of the red lines and then I saw the reply.

I’m still having issues with the attack only going once. Do you think it could have something to do with the settings I have in this Attacks window in the inspector?

What do you mean the attack is going once. Does it play the full combo and never again, or does it just play the first attack of the combo?

It only plays the first attack of the combo before reverting back to either the FreeLook or Targeting states

Ok, so did you subscribe to the AttackDown event n Enter() (and unsubscribe in Exit())? Can you show your PlayerAttackingState? Then I can have a quick look

I wrote the subscribe and unsubscribe in the Enter and Exit. Pretty sure I did that part right.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerAttackingState : PlayerBaseState
{
    private Attack attack;
    private float previousFrameTime; 
    private bool alreadyAppliedForce;
    public PlayerAttackingState(PlayerStateMachine stateMachine, int AttackIndex) : base(stateMachine)
    {
        attack = stateMachine.Attacks[AttackIndex];
    }

    public override void Enter()
    {
        stateMachine.InputReader.AttackDown += HandleAttackDown;
        
        stateMachine.Weapon.SetAttack(attack.Damage, attack.Knockback);
        stateMachine.Animator.CrossFadeInFixedTime(attack.AnimationName, attack.TransitionDuration);
        Debug.Log(attack.AnimationName);
    }

    public override void Tick(float deltaTime)
    {
        Move(deltaTime);
        FaceTarget();

        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
            
        if(normalizedTime >= previousFrameTime && normalizedTime < 1f)
        {
            if (normalizedTime >= attack.ForceTime)
            {
                TryApplyForce();
            }
        }
        else
        {
            if(stateMachine.Targeter.CurrentTarget != null)
            {
                stateMachine.SwitchState(new PlayerTargetingState(stateMachine));
            }
            else
            {
                stateMachine.SwitchState(new PlayerFreeLookState(stateMachine));
            }
        }
        
        previousFrameTime = normalizedTime;
    }
    public override void Exit()
    {
        stateMachine.InputReader.AttackDown -= HandleAttackDown;
    }
    private void TryComboAttack(float normalizedTime)
    {
        if(attack.ComboStateIndex == -1) { return; }

        if(normalizedTime < attack.ComboAttackTime) { return; }

        stateMachine.SwitchState
        (
            new PlayerAttackingState
            (
                stateMachine,
                attack.ComboStateIndex
            )
        );
    }
    private void TryApplyForce()
    {
        if(alreadyAppliedForce) { return; }
        stateMachine.ForceReceiver.AddForce(stateMachine.transform.forward * attack.Force);
        alreadyAppliedForce = true;
    }

    void HandleAttackDown()
    {
        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
        TryComboAttack(normalizedTime);
    }

    private void OnAttackDown()
    {
        float normalizedTime = GetNormalizedTime(stateMachine.Animator, "Attack");
        if (normalizedTime < attack.ComboAttackTime)
            return;
        stateMachine.SwitchState(new PlayerAttackingState(stateMachine, attack.ComboStateIndex));
    }
}

Note, in the console the Debug log reads that Attack 2 does play, but the animation for it does not.

First, you can remove my OnAttackDown(). You’re not using it and your HandleAttackDown() does the correct thing.

Second, if the console shows the second attack then this code is correct and working as it should. Silly question; Do you have an ‘Attack2’ animation? Just have to cover all the bases.

Yep, I have Attack1 Attack2 Attack3, all of which are named accordingly in the Inspector and in the Animator. All three attack animations also have the “Attack” tag on them.

My only theory right now is that the inspector values are interfering, but I can’t figure out a good setting. I also assumed those combo timing settings would mean nothing, because (ideally) we gave the player flexibility in how quickly those animations play after one another.

The way it is set up now is that Attack2 can be triggered by the player (clicking attack) after 60% of Attack1’s animation has played. You say the console shows that we transitioned to Attack2 so this is fine.

Can you switch the first two attacks and check if the same thing happens?

I switched the names of all three attacks and they all play their appropriate animations. The Attack2 debug always came up too. (renamed one to Attack2 to check)

These combo values also work flawlessly on my build that uses the course’s IsAttacking event (hold the button)

I just updated my own project with the changes we discussed here and it works fine. I’ve also updated my attack settings to match yours.

Then, I copied your PlayerAttackingState and pasted it into my project. Then it stopped working. I compared your code to mine and it was virtually identical so I was a little stumped. The only real difference between your code and mine was this:

if(normalizedTime >= previousFrameTime && normalizedTime < 1f)

In my code I do have the previousFrameTime = normalisedTime bit at the bottom, but for some reason - that I cannot remember - I don’t use it for anything. My code only checks normalisedTime

if (normalisedTime < 1f)

I updated your code to do the same and the combos started working again

2 Likes

Privacy & Terms