derkork / godot-statecharts

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

Using state charts for stepping in/out of menus #54

Closed thely closed 9 months ago

thely commented 9 months ago

I'm not sure if my "issue" here is with this plugin or with state charts, but here goes.

I'm in the very early stages of doing a tile-based battler, similar to Fire Emblem or FFTA. During a single turn of combat, the menu works like this:

MoveState
- on spacebar, accept position and go to ActionMenu

ActionMenuState
- on clicking Attack, proceed to TargetSelect
- on clicking Spells, proceed to TargetSelect
- on escape, revert to MoveState
TargetSelectState
- on selecting an attack direction, proceed to TargetConfirm
- on escape, revert to ActionMenu
TargetConfirm
- on confirming, revert to MoveState
- on escape, remove everything from the stack and jump back to MoveState

I had previously set up my menu structure as a fairly simple FSM with an undo stack, so that I could keep menus onscreen (similar to something like the original Pokemon games, where successive menus appear on top of previous ones). A given menu's exit() method only got called if the undo was called, as otherwise I wanted to keep them all visible.

I switched my previous implementation to StateCharts instead. In the new implementation, the latter three states are grouped under a parent CompoundState, as I initially wanted all their "Cancel on escape" transitions to move to a HistoryState that never worked (it always went back to its default, not to the previous state).

HistoryState working or no, the problem I'm having now is that while there's only one state_exited() signal, all the attack-selection menus have two ways of being "exited:" moving to the next state via spacebar, or reverting to a previous state with escape. In the first case, I want to keep the exiting menu onscreen but disable its inputs, and in the other case I want to hide the menu entirely.

It occurs to me that to make this work, the state would need to know the transition that caused it, but that feels like it's beyond the scope of a state chart. That, or every menu would need two states somehow, but that feels like it's inviting state explosion.

Any thoughts? I know this example is fairly simple, but I'd like to be able to use this for more complicated menus later on.

derkork commented 9 months ago

I don't think you will need a history state for that. I was able to rig this up with a fairly simple state chart like this:

image

Then a minimal script which just shows/hides the menus depending on which state we are in:

extends Control

@onready var move_menu = %MoveMenu
@onready var action_menu = %ActionMenu
@onready var target_select = %TargetSelect
@onready var target_confirm = %TargetConfirm

func _on_moving_state_entered():
    move_menu.visible = true
    action_menu.visible = false
    target_select.visible = false
    target_confirm.visible = false

func _on_action_selecting_state_entered():
    move_menu.visible = true
    action_menu.visible = true
    target_select.visible = false
    target_confirm.visible = false

func _on_target_selecting_state_entered():
    move_menu.visible = true
    action_menu.visible = true
    target_select.visible = true
    target_confirm.visible = false

func _on_attacking_state_entered():
    move_menu.visible = true
    action_menu.visible = true
    target_select.visible = true
    target_confirm.visible = true

The buttons can just send events to the state chart to switch state and then you get something like this:

https://github.com/derkork/godot-statecharts/assets/327257/578d4ed8-ca6d-4eb9-b52e-cdb270b4130d

Here's a ZIP-File with the scene if you want to have a look.

menus.zip

I think your approach can be simplified by not caring about how you come into a certain state. When a state is entered, you just set up things how they are supposed to be in that state. So if you are in the target select state, you just show the menus that should be visible in that state and disable input to all menus that should not have input. This way you don't need to think of all variants how a state can be entered. You just say "when i am in this state, things should be like this".

I may be oversimplifying the case here, but from the requirements you've given, this approach should do the trick nicely.

thely commented 9 months ago

I appreciate this response! I had considered this before, but it felt somewhat counter to the idea of state charts, and felt like it would absolutely balloon as my menu needs grew.

But your idea sparked something else for me, which I'll leave here if it helps anyone else. I felt like there must be some way for the individual states to determine their visibility. I gave my base menu class an enum that could be STATE_NONE, STATE_ESCAPE, or STATE_SELECT, which is changed with these two functions:

func press_continue():
    exit_state = STATE_SELECT
    menu_select.emit()

func press_cancel():
    exit_state = STATE_ESCAPE
    menu_cancel.emit()

Then I changed my exit function to work with that:

func exit(complete: bool = false):
    if exit_state == STATE_ESCAPE or complete:
        self.visible = false

So if the state is exiting from pressing escape to cancel, hide the window. Otherwise, stay open. Then the inherited menus can call this super.exit() first, then disable their inputs.

As for complete: I made a final state, Action Confirmed, that auto-transitions back to the MoveState after 0.25s, sending exit() to all the open menus with a binds of true. That way, once an action is confirmed, all the open menus hide themselves.

derkork commented 9 months ago

Glad you found a solution that works for you!