Time window for linking combo attacks

Godot Version

4.3stable

Question

What is the simplest way to create combo window in code?

Hi there, I am looking for the most easy way to add a combo system in my code. I need to set a time window where the player could press again the attack button after already do it to trigger the second attack.

My combo have only 3 attacks, I created my State Machine with LimboAI, so in first should I code this window delay in my player script or the Attack1 script?

Secondly, I have a signal function when my attack animations finish in my player script, so I supose that the delay must be right here no? Because I need to finish the current attack for switch to the next!

  • Here a part of the player script with animation end signals
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
	if anim_name == "Attack1":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack2":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack3":
		state_machine.dispatch("to_idle")
  • Here the Attack1 script in the appropriate child node of the LimboHSM
extends LimboState

@export var animation_player : AnimationPlayer
@export var animation : StringName

# Called when the node enters the scene tree for the first time.
func _enter() -> void:
	animation_player.play(animation)

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	pass # Replace with function body.

func _update(_delta: float) -> void:
	pass

I look around timer but I do not really know how to implement this node… even if I think this is easy for some of you. Could you clearly explain me please?

As a reminder: there are 3 attacks, when I press X Attack1 start, now two possibilities: button not pressed again so back to idle or button is pressed in the following 1s, so state go to Attack2. Same thing between Attack2 and 3.

Thank you all for your time and consider it! I hope I was clear in my words.


Here you can see a preview of my combat, it missing the second attack, the first one can be triggered at any time, the third can be triggered when you run or after the second attack if you press X.

If I understand correctly you want to use a Timer for checking whether the attack is in a combo or not.

To do this you’d add a Timer node somewhere
image
And configure it so that:
1 - the delay fits
2 - it does not loop, but runs only once when you start it

Now, when you make an attack, you start the timer

func attack_1():
    $ComboTimer.start()
    play_animation_attack_1()

And then you can check if the Timer is running or not

func attack():
    if $ComboTimer.is_stopped():
        attack_1()
    else:
        attack_2()

If ComboTimer.is_stopped() is true it means attack_1() was called more than a second ago, so not in a combo.

func _input(event):
    if event.is_action_pressed("attack"):
        attack()

func attack():
    if $ComboTimer.is_stopped():
        attack_1()
    else:
        attack_2()

func attack_1():
    $ComboTimer.start()
    play_animation_attack_1()

func attack_2():
    $ComboTimer.stop()
    play_animation_attack_2()

You can also $ComboTimer.stop() in attack_2() to make sure that with 3 attacks in a row you won’t get attack_2() again

How can I make it fit with my current code? Is this required to create all this functions?

If this is it where can I put this: in the player script, the idle script (where every inputs dispatch in each state) or the attack1 script?

I would just use the animation of the first attack as my timer. If the first attack is playing when you press the attack button, queue up the second attack. If you use an AnimationTree with a state machine, you can make it so the next animation doesn’t trigger until the first is done. Very little coding involved.

I do not use the animation tree, I use the LimboAI addon for manage all my states.

This was just an example, but I don’t know how exactly you connect your user input to your animation, as I’m not familiar with the addon you are using

  • Here all the player script:
extends CharacterBody3D


@export var speed = 4.0
@export var jump_impulse = 18.0
@export var acceleration = 2
@export var fall_acceleration = 55.0

@export var state_machine : LimboHSM
# All states.
@onready var idle_state = $LimboHSM/Idle
@onready var walk_state = $LimboHSM/Walk
@onready var run_state = $LimboHSM/Run
@onready var jump_state = $LimboHSM/Jump
@onready var fall_state = $LimboHSM/Fall
@onready var roll_state = $LimboHSM/Roll
@onready var kick_state = $LimboHSM/Kick
@onready var block_state = $LimboHSM/Block
@onready var attack1_state = $LimboHSM/Attack1
@onready var attack2_state = $LimboHSM/Attack2
@onready var attack3_state = $LimboHSM/Attack3

# State Machine.
func _ready():
	_initialize_state_machine()
# Input for idle_state.
var movement_input : Vector2 = Vector2.ZERO

func _initialize_state_machine():
	# Setup.
	state_machine.initial_state = idle_state
	state_machine.initialize(self)
	state_machine.set_active(true)
	# Transitions.
	state_machine.add_transition(state_machine.ANYSTATE, idle_state, "to_idle")
	state_machine.add_transition(state_machine.ANYSTATE, walk_state, "to_walk")
	state_machine.add_transition(state_machine.ANYSTATE, jump_state, "to_jump")
	state_machine.add_transition(state_machine.ANYSTATE, fall_state, "to_fall")
	
	state_machine.add_transition(walk_state, run_state, "to_run")
	
	state_machine.add_transition(idle_state, block_state, "to_block")
	state_machine.add_transition(walk_state, block_state, "to_block")
	state_machine.add_transition(run_state, block_state, "to_block")
	
	state_machine.add_transition(idle_state, attack1_state, "to_attack1")
	state_machine.add_transition(walk_state, attack1_state, "to_attack1")
	state_machine.add_transition(block_state, attack1_state, "to_attack1")
	
	state_machine.add_transition(attack1_state, attack2_state, "to_attack2")
	
	state_machine.add_transition(attack2_state, attack3_state, "to_attack3")
	state_machine.add_transition(run_state, attack3_state, "to_attack3")

# End of animations.
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
	if anim_name == "Attack1":
			state_machine.dispatch("to_idle")
	elif anim_name == "Attack2":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack3":
		state_machine.dispatch("to_idle")

# Physic of the world.
func _physics_process(delta: float) -> void:
	var direction : Vector2 = Input.get_vector("left", "right", "down", "up")
	var direction3 := Vector3(direction.x, 0, -direction.y)
	
	# Input State Machine.
	movement_input = direction
	
	# Move and rotate the character in direction.
	if direction != Vector2.ZERO:
		if Input.is_action_pressed("speed"):
			rotation.y = lerp_angle(rotation.y, direction.angle(), 0.3)
		elif Input.is_action_pressed("block"):
				rotation.y = lerp_angle(rotation.y, direction.angle(), 0.1)
		else:
			rotation.y = lerp_angle(rotation.y, direction.angle(), 0.2)
	
	# Ground velocity.
	if Input.is_action_pressed("speed"):
		velocity.x = direction3.x * speed * acceleration
		velocity.z = direction3.z * speed * acceleration
	else:
		velocity.x = direction3.x * speed
		velocity.z = direction3.z * speed
	
	if is_on_floor() and Input.is_action_pressed("block"):
		velocity.x = 0
		velocity.z = 0
	
	# Jumping.
	if is_on_floor() and Input.is_action_just_pressed("jump"):
		velocity.y = jump_impulse
	
	# Air velocity.
	if not is_on_floor():
		velocity.y = velocity.y - (fall_acceleration * delta)
	
	move_and_slide()

	# Reload scene.
	if position.y < -30:
		get_tree().reload_current_scene()

  • Here the idle state script:
extends LimboState

@export var animation_player : AnimationPlayer
@export var animation : StringName

# Called when the node enters the scene tree for the first time.
func _enter() -> void:
	animation_player.play(animation)

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	pass # Replace with function body.

func _update(delta: float) -> void:
	agent._physics_process(delta)
		
	if agent.movement_input != Vector2.ZERO:
		get_root().dispatch("to_walk")
	
	if Input.is_action_just_pressed("jump"):
		get_root().dispatch("to_jump")
	
	if agent.velocity.y < 0:
		get_root().dispatch("to_fall")
	
	if Input.is_action_pressed("block"):
		get_root().dispatch("to_block")
	
	if Input.is_action_just_pressed("attack"):
		get_root().dispatch("to_attack1")
  • Here the attack1 script:
extends LimboState

@export var animation_player : AnimationPlayer
@export var animation : StringName

# Called when the node enters the scene tree for the first time.
func _enter() -> void:
	animation_player.play(animation)

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	pass # Replace with function body.    

func _update(_delta: float) -> void:
	pass

But I do not know if that can help…

I precise that I am a new comer in the coding world so lot of things are still confusing! For implement this state machine with this addon I followed the Chap.C Creates video:

Okay, so you can do everything in the idle state script

You can create the timer at the script root

@onready var combo_timer: Timer = Timer.new()

configure it in the _ready() function

func _ready() -> void:
	add_child(combo_timer)       #Add the Timer to the tree
	combo_timer.one_shot = true  #Makes the Timer not loop
	combo_timer.wait_time = 1.0  #One second

and then check everything in the _update() function

if combo_timer.is_stopped():            #If the Timer it stopped
	get_root().dispatch("to_attack1")   #    We start attack 1
	combo_timer.start()                 #    And start the timer
else:                                   #Else
	get_root().dispatch("to_attack2")   #    We start attack 2
	combo_timer.stop()                  #    And stop the timer

Full script:

extends LimboState

@export var animation_player : AnimationPlayer
@export var animation : StringName

@onready var combo_timer: Timer = Timer.new() #Create the timer

# Called when the node enters the scene tree for the first time.
func _enter() -> void:
	animation_player.play(animation)

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	add_child(combo_timer)       #Add the Timer to the tree
	combo_timer.one_shot = true  #Makes the Timer not loop
	combo_timer.wait_time = 1.0  #One second

func _update(delta: float) -> void:
	agent._physics_process(delta)
		
	if agent.movement_input != Vector2.ZERO:
		get_root().dispatch("to_walk")
	
	if Input.is_action_just_pressed("jump"):
		get_root().dispatch("to_jump")
	
	if agent.velocity.y < 0:
		get_root().dispatch("to_fall")
	
	if Input.is_action_pressed("block"):
		get_root().dispatch("to_block")
	
	if Input.is_action_just_pressed("attack"):
		if combo_timer.is_stopped():            #If the Timer it stopped
			get_root().dispatch("to_attack1")   #    We start attack 1
			combo_timer.start()                 #    And start the timer
		else:                                   #Else
			get_root().dispatch("to_attack2")   #    We start attack 2
			combo_timer.stop()                  #    And stop the timer

As far as I understand how this is setup, this should just work ?

Thanks again for your consideration your a master but I tried and something wrong, my character stay stuck after the first attack and do not switch to the next.

Hmmmm …

In the code you shared, in the player script, there is this

Maybe it’s the two tabs at the start of the second line that breaks it, so your character does not go back “idle” after “attack1” is finished?

otherwise I don’t know :grimacing:

Of course the thing I did is to remove this section for evict potential anomaly but this apparently not the issu…

Do you mean you removed the code I suggested or the contents of the _on_animation_player_animation_finished function ?

The animation_finished function for not go back to the idle state.

You do need this one :>

What I meant is that the tabulations at the start need to be the same:

# End of animations.
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
	if anim_name == "Attack1":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack2":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack3":
		state_machine.dispatch("to_idle")

What this does is that when an animation is finished, it makes the character go back to the “idle” state

Without it, the animation plays out … and then nothing happens

Since you had

	if anim_name == "Attack1":
			state_machine.dispatch("to_idle")
	elif anim_name == "Attack2":
		state_machine.dispatch("to_idle")

Here the tabulation at the start of the lines is broken, so after the animation “Attack1” it would not revert to the “idle” state

Yep I saw that when I tried your idea and tabulations are align but this is the same issue.

As a check, can you add a print() to the _on_animation_player_animation_finished() method, just to check if it is correctly called?

# End of animations.
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
	print("_on_animation_player_animation_finished called with argument %s" % anim_name)
	if anim_name == "Attack1":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack2":
		state_machine.dispatch("to_idle")
	elif anim_name == "Attack3":
		state_machine.dispatch("to_idle")

As far as I can see this is the only place where this issue could arise from - perhaps the signal got disconnected

Sorry for the delay but I needed to take a breath and go away of my script, and now my problem is solved, just with some rest and a fresh brain the solution pop up obviously. So moral is sometimes instead of going on about it, you need to know how to let go a little. Anyway thank you for all! o7