derkork / godot-statecharts

A state charts extension for Godot 4
MIT License
734 stars 35 forks source link

add signals to CompoundState for child state enter and exit #36

Closed uzkbwza closed 11 months ago

uzkbwza commented 11 months ago

Often when dealing with state transitions, I've found that it's convenient to have common logic for all states of a particular category. Currently, you can accomplish this for state logic that runs every frame by using state_processing and state_physics_processing signals emitted by the CompoundState node. But there is no simple way to define behavior that is triggered by any transition between child states of a CompundState. I feel that this is an essential behavior that is trivial to implement, easy to understand and has little to no impact on performance.

derkork commented 11 months ago

So this is essentially the same as connecting the state_entered/state_exited signals of all child states of a compound state to the same method, just with less clicking - right? Could you maybe give an example for a case where this would be useful. This would be nice to have for the documentation of this.

Nrosa01 commented 11 months ago

I would also like to see an example on this, but the idea overall sounds good. Also I want to ask, will this be merged and added to the doc if the example proves this have a common use case?

derkork commented 11 months ago

Well yes I will merge it and update the manual when it is merged. Right now I don't understand the use case though so that makes it difficult to write a proper documentation for it.

uzkbwza commented 11 months ago

So this is essentially the same as connecting the state_entered/state_exited signals of all child states of a compound state to the same method, just with less clicking - right? Could you maybe give an example for a case where this would be useful. This would be nice to have for the documentation of this.

Ok. For example, in my game Your Only Move Is HUSTLE (which uses a traditional state machine implementation), characters and objects have dozens of states. This is typical for a fighting game. Character states have a lot of shared logic on enter: the animation is changed, sound effects are played, the "action frame counter" is refreshed, and so on. When characters exit states, it is ensured that any state-related invulnerability is ended, hitboxes are terminated, and so on.

Here are the actual enter and exit methods that are shared for all character states.

func _enter_shared():
    if force_same_direction_as_previous_state:
        host.reverse_state = false
    ._enter_shared()
    started_during_combo = false
    if dynamic_iasa:
        interruptible_on_opponent_turn = start_interruptible_on_opponent_turn
    hit_yet = false
    hit_anything = false
    was_blocked = false
    started_in_air = false
    host.update_grounded()
    if change_stance_to:
        host.change_stance_to(change_stance_to)
    if !host.is_grounded() or air_type == AirType.Aerial:
        started_in_air = true
    if uses_air_movement:
        if !host.infinite_resources and host.gravity_enabled:
            host.air_movements_left -= 1
    if host.hitlag_ticks == 0 and host.blockstun_ticks == 0:
        call_deferred("update_sprite_frame")
    if has_hitboxes:
        var dir = host.get_move_dir()
        if dir == 0 or dir == host.get_opponent_dir():
            host.add_penalty(-8)
        host.gain_super_meter(fixed.round(fixed.mul(str(WHIFF_SUPER_GAIN), whiff_meter_gain_multiplier)))

func _exit_shared():
    beats_backdash = false
    if feinting and end_feint:
        host.update_facing()
        host.feinting = false
    feinting = false
    host.melee_attack_combo_scaling_applied = false
    ._exit_shared()
    if update_facing_on_exit:
        host.update_facing()
    else:
        host.reverse_state = false
        host.set_facing(host.get_facing_int())
    terminate_hitboxes()
    host.end_invulnerability()
    host.end_projectile_invulnerability()
    host.end_throw_invulnerability()
    if release_opponent_on_exit:
        host.release_opponent()
    host.got_parried = false
    host.colliding_with_opponent = true
    host.state_interruptable = false
    host.projectile_hit_cancelling = false
    host.has_hyper_armor = false
    host.has_projectile_armor = false
    host.state_hit_cancellable = false
    host.clipping_wall = false
    emit_signal("state_ended")
    host.z_index = 0

and the base object methods:

func _enter_shared():
    if force_same_direction_as_previous_state:
        var prev = _previous_state()
        if prev:
            host.set_facing(prev.last_facing)
    if reset_momentum:
        host.reset_momentum()
    if reset_x_momentum:
        var vel = host.get_vel()
        host.set_vel("0", vel.y)
    if reset_y_momentum:
        var vel = host.get_vel()
        host.set_vel(vel.x, "0")

    current_tick = -1
    current_real_tick = -1
    start_tick = host.current_tick
    if enter_sfx_player and !ReplayManager.resimulating:
        enter_sfx_player.play()
    emit_signal("state_started")

func _exit_shared():
    if current_hurtbox:
        current_hurtbox.end(host)
    last_facing = host.get_facing_int()
    host.reset_hurtbox()

Here is how many states one character may have.

https://github.com/derkork/godot-statecharts/assets/43023911/815a941e-4971-4b51-90c2-a29ec2ff2cb0

As you can tell it would become pretty tedious to connect these all by hand, and I feel that programmatically doing so is a less elegant solution. I think it simply makes sense to include a way to tell if a compound states' internal state has changed, for these reasons, and there is not an exposed way to straightforwardly do so currently.

derkork commented 11 months ago

I see, thanks a lot for the explanation. I'll update the manual and then merge this.

derkork commented 11 months ago

Rebased & Merged, thanks a lot!