Skip to content

Commit 9883f3f

Browse files
feat: RewindableState signals (#494)
Co-authored-by: Tamás Gálffy <ezittgtx@gmail.com>
1 parent 62184da commit 9883f3f

File tree

10 files changed

+255
-39
lines changed

10 files changed

+255
-39
lines changed

addons/netfox.extras/plugin.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
name="netfox.extras"
44
description="Game-specific utilities for Netfox"
55
author="Tamas Galffy and contributors"
6-
version="1.32.3"
6+
version="1.33.0"
77
script="netfox-extras.gd"

addons/netfox.extras/state-machine/rewindable-state-machine.gd

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ static var _logger: _NetfoxLogger = _NetfoxLogger.for_extras("RewindableStateMac
5050
var _state_object: RewindableState = null
5151
var _previous_state_object: RewindableState = null
5252
var _available_states: Dictionary = {}
53+
var _prevent_transition: bool = false
54+
var _prevent_callable: Callable = func(): _prevent_transition = true
5355

54-
## Transition to a new state specified by [param new_state_name].
56+
## Transition to a new state specified by [param new_state_name] and return
57+
## true.
5558
##
5659
## Finds the given state by name and transitions to it if possible. The new
5760
## state's [method RewindableState.can_enter] callback decides if it can be
@@ -63,42 +66,67 @@ var _available_states: Dictionary = {}
6366
## [br][br]
6467
## Does nothing if transitioning to the currently active state. Emits a warning
6568
## and does nothing when transitioning to an unknown state.
66-
func transition(new_state_name: StringName) -> void:
69+
func transition(new_state_name: StringName) -> bool:
70+
# Check if target state is valid
6771
if state == new_state_name:
68-
return
72+
return false
6973

7074
if not _available_states.has(new_state_name):
7175
_logger.warning("Attempted to transition from state '%s' into unknown state '%s'", [state, new_state_name])
72-
return
76+
return false
7377

78+
var from_state = _state_object
7479
var new_state: RewindableState = _available_states[new_state_name]
75-
if _state_object:
80+
_prevent_transition = false
81+
82+
# Validate transition
83+
if from_state:
7684
if !new_state.can_enter(_state_object):
77-
return
85+
return false
86+
87+
# Emit exit signal, allow handlers to prevent transition
88+
_state_object.on_exit.emit(new_state, NetworkRollback.tick, _prevent_callable)
89+
if _prevent_transition: return false
90+
91+
new_state.on_enter.emit(from_state, NetworkRollback.tick, _prevent_callable)
92+
if _prevent_transition: return false
93+
94+
# Transition valid, run callbacks
95+
if is_instance_valid(from_state):
96+
from_state.exit(new_state, NetworkRollback.tick)
97+
new_state.enter(from_state, NetworkRollback.tick)
98+
99+
# Set new state
100+
_state_object = new_state
101+
on_state_changed.emit(from_state, new_state)
78102

79-
_state_object.exit(new_state, NetworkRollback.tick)
103+
return true
80104

81-
var _previous_state: RewindableState = _state_object
82-
_state_object = new_state
83-
on_state_changed.emit(_previous_state, new_state)
84-
_state_object.enter(_previous_state, NetworkRollback.tick)
105+
## Update the internal cache of known states
106+
## [br][br]
107+
## Automatically called on ready and when a child node is added or removed. Call
108+
## manually to force an update.
109+
func update_states() -> void:
110+
_available_states.clear()
111+
112+
for child in find_children("*", "RewindableState", false):
113+
_available_states[child.name] = child
85114

86115
func _notification(what: int):
87116
# Use notification instead of _ready, so users can write their own _ready
88117
# callback without having to call super()
89118
if Engine.is_editor_hint(): return
90119

91-
if what == NOTIFICATION_ENTER_TREE:
92-
# Gather known states if we haven't yet
93-
if _available_states.is_empty():
94-
for child in find_children("*", "RewindableState", false):
95-
_available_states[child.name] = child
96-
97-
# Compare states after tick loop
98-
NetworkTime.after_tick_loop.connect(_after_tick_loop)
99-
elif what == NOTIFICATION_EXIT_TREE:
100-
# Disconnect handlers
101-
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
120+
match what:
121+
NOTIFICATION_CHILD_ORDER_CHANGED:
122+
update_states()
123+
NOTIFICATION_ENTER_TREE:
124+
# Compare states after tick loop
125+
NetworkTime.after_tick_loop.connect(_after_tick_loop)
126+
update_states()
127+
NOTIFICATION_EXIT_TREE:
128+
# Disconnect handlers
129+
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
102130

103131
func _get_configuration_warnings():
104132
const MISSING_SYNCHRONIZER_ERROR := \
@@ -133,13 +161,17 @@ func _get_configuration_warnings():
133161
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
134162
if _state_object:
135163
_state_object.tick(delta, tick, is_fresh)
164+
_state_object.on_tick.emit(delta, tick, is_fresh)
136165

137166
func _after_tick_loop():
138167
if _state_object != _previous_state_object:
139168
on_display_state_changed.emit(_previous_state_object, _state_object)
140169

141170
if _previous_state_object:
171+
_previous_state_object.on_display_exit.emit(_state_object, NetworkTime.tick)
142172
_previous_state_object.display_exit(_state_object, NetworkTime.tick)
173+
174+
_state_object.on_display_enter.emit(_previous_state_object, NetworkTime.tick)
143175
_state_object.display_enter(_previous_state_object, NetworkTime.tick)
144176

145177
_previous_state_object = _state_object

addons/netfox.extras/state-machine/rewindable-state.gd

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ class_name RewindableState
1212
##
1313
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
1414

15+
## Emitted when entering the state
16+
signal on_enter(previous_state: RewindableState, tick: int, prevent: Callable)
17+
18+
## Emitted on every rollback tick while the state is active
19+
signal on_tick(delta: float, tick: int, is_fresh: bool)
20+
21+
## Emitted when exiting the state
22+
signal on_exit(next_state: RewindableState, tick: int, prevent: Callable)
23+
24+
## Emitted before displaying this state
25+
signal on_display_enter(previous_state: RewindableState, tick: int)
26+
27+
## Emitted before displaying another state
28+
signal on_display_exit(next_state: RewindableState, tick: int)
29+
1530
## The [RewindableStateMachine] this state belongs to.
1631
## [br][br]
1732
## [i]read-only[/i]

addons/netfox.internals/plugin.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
name="netfox.internals"
44
description="Shared internals for netfox addons"
55
author="Tamas Galffy and contributors"
6-
version="1.32.3"
6+
version="1.33.0"
77
script="plugin.gd"

addons/netfox.noray/plugin.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
name="netfox.noray"
44
description="Bulletproof your connectivity with noray integration for netfox"
55
author="Tamas Galffy and contributors"
6-
version="1.32.3"
6+
version="1.33.0"
77
script="netfox-noray.gd"

addons/netfox/plugin.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
name="netfox"
44
description="Shared internals for netfox addons"
55
author="Tamas Galffy and contributors"
6-
version="1.32.3"
6+
version="1.33.0"
77
script="netfox.gd"

addons/vest/mocks/vest-mock-generator.gd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ func generate_mock_source(script: Script) -> String:
3939

4040
var arg_def_string := ", ".join(arg_defs)
4141

42+
# TODO(vest): Handle method name shadowing param name
4243
mock_source.append(
4344
("func %s(%s):\n" +
44-
"\treturn __vest_mock_handler._handle(%s, [%s])\n\n") %
45+
"\treturn __vest_mock_handler._handle(self.%s, [%s])\n\n") %
4546
[method_name, arg_def_string, method_name, arg_def_string]
4647
)
4748

docs/netfox.extras/guides/rewindable-state-machine.md

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,24 @@ RewindableStateMachine.
3535

3636
States react to the game world using the following callbacks:
3737

38-
* `tick(delta, tick, is_fresh)` is called for every rollback tick.
39-
* `enter(previous_state, tick)` is called when entering the state.
40-
* `exit(next_state, tick)` is called when exiting the state.
41-
* `can_enter(previous_state)` is called before entering the state. The state is
42-
only entered if this method returns true.
43-
* `display_enter(previous_state, tick)` is called before displaying the state.
44-
* `display_exit(next_state, tick)` is called before displaying a different
45-
state.
38+
`tick(delta, tick, is_fresh)`
39+
: Called for every rollback tick the state is active.
40+
41+
`enter(previous_state, tick)`
42+
: Called when entering the state.
43+
44+
`exit(next_state, tick)`
45+
: Called when exiting the state.
46+
47+
`can_enter(previous_state)`
48+
: Called before entering the state. The state is only entered if this method
49+
returns true.
50+
51+
`display_enter(previous_state, tick)`
52+
: Called before displaying the state.
53+
54+
`display_exit(next_state, tick)`
55+
: Called before displaying a different state.
4656

4757
You can override any of these callbacks to implement your custom behaviors.
4858

@@ -69,6 +79,28 @@ machine](../assets/rewindable-state-children.png)
6979

7080
States must be added as children under a RewindableStateMachine to work.
7181

82+
## Using signals instead of classes
83+
84+
*RewindableState* nodes also emit signals during their lifetime. This enables
85+
an alternate style of implementing states, by connecting handlers to different
86+
signals. This can be useful if you want to keep all your logic in a single
87+
script, among others.
88+
89+
Each of these signals correspond to a callback explained above:
90+
91+
* `on_enter()``enter()`
92+
* `on_tick()``tick()`
93+
* `on_exit()``exit()`
94+
* `on_display_enter()``display_enter()`
95+
* `on_display_exit()``display_exit()`
96+
97+
## Adding states
98+
99+
Once implemented, add the state nodes as children of the
100+
*RewindableStateMachine* in the Scene Tree. When doing this programmatically,
101+
make sure to set the state's `owner` to the target *RewindableStateMachine*.
102+
Without the owner set, the *RewindableStateMachine* won't recognize the state.
103+
72104
## Display State vs State
73105

74106
There's two sets of callbacks for state transition - `enter()`/`exit()` and
@@ -109,10 +141,10 @@ P is Idle
109141

110142
This will trigger the following state changes:
111143

112-
* Tick@1: Idle -> Moving
113-
* Tick@3: Moving -> Jumping
114-
* Tick@5: Jumping -> Moving
115-
* Tick@8: Moving -> Idle
144+
* Tick@1: Idle Moving
145+
* Tick@3: Moving Jumping
146+
* Tick@5: Jumping Moving
147+
* Tick@8: Moving Idle
116148

117149
For each of the above, the `on_state_changed` signal will be emitted, and the
118150
`enter()`/`exit()` callbacks will be triggered.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
extends VestTest
2+
3+
func get_suite_name() -> String:
4+
return "RewindableStateMachine"
5+
6+
var state_machine: RewindableStateMachine
7+
var first_state: RewindableState
8+
var other_state: RewindableState
9+
10+
func before_case(__):
11+
state_machine = RewindableStateMachine.new()
12+
first_state = mock(RewindableState)
13+
other_state = mock(RewindableState)
14+
15+
# Setup mock answers
16+
when(first_state.can_enter).then_return(true)
17+
when(first_state.enter).then_answer(func(__): pass)
18+
when(first_state.exit).then_answer(func(__): pass)
19+
when(first_state.tick).then_answer(func(__): pass)
20+
when(first_state.display_enter).then_answer(func(__): pass)
21+
when(first_state.display_exit).then_answer(func(__): pass)
22+
23+
when(other_state.can_enter).then_return(true)
24+
when(other_state.enter).then_answer(func(__): pass)
25+
when(other_state.exit).then_answer(func(__): pass)
26+
when(other_state.tick).then_answer(func(__): pass)
27+
when(other_state.display_enter).then_answer(func(__): pass)
28+
when(other_state.display_exit).then_answer(func(__): pass)
29+
30+
# Set state names
31+
first_state.name = "First State"
32+
other_state.name = "Other State"
33+
34+
# Add states as children - RSM will pick them up when added
35+
# **NOTE**: Make sure to set owner if spawning states manually, otherwise
36+
# RSM won't pick them up
37+
state_machine.add_child(first_state); first_state.owner = state_machine
38+
state_machine.add_child(other_state); other_state.owner = state_machine
39+
40+
# TODO(vest): TestingSceneTree
41+
await Vest._scene_tree.process_frame
42+
Vest._scene_tree.root.add_child(state_machine, true)
43+
44+
func test_should_start_empty():
45+
expect_empty(state_machine.state)
46+
47+
func test_should_notify_new_state_on_enter():
48+
capture_signal(first_state.on_enter, 3)
49+
50+
# Enter first state
51+
state_machine.transition("First State")
52+
53+
# Check for event
54+
expect_not_empty(get_signal_emissions(first_state.on_enter))
55+
expect_empty(get_calls_of(first_state.can_enter))
56+
expect_equal(get_calls_of(first_state.enter), [[null, 0]])
57+
58+
func test_on_enter_should_prevent_transition():
59+
other_state.on_enter.connect(
60+
func(_new_state, _tick, prevent):
61+
prevent.call()
62+
)
63+
64+
# Enter first state
65+
state_machine.transition("First State")
66+
67+
# Try to enter second state
68+
expect_false(state_machine.transition("Other State"), "Transition should have failed!")
69+
expect_equal(state_machine.state, "First State")
70+
71+
func test_on_exit_should_prevent_transition():
72+
first_state.on_exit.connect(
73+
func(_new_state, _tick, prevent):
74+
prevent.call()
75+
)
76+
77+
# Enter first state
78+
state_machine.transition("First State")
79+
80+
# Try to enter second state
81+
expect_false(state_machine.transition("Other State"), "Transition should have failed!")
82+
expect_equal(state_machine.state, "First State")
83+
84+
func test_can_enter_should_prevent_transition():
85+
state_machine.state = "First State"
86+
87+
# Register a more specific answer so the mock will use that
88+
when(other_state.can_enter)\
89+
.with_args([first_state])\
90+
.then_return(func(__): return false)
91+
92+
# Try to enter second state
93+
expect_false(state_machine.transition("Other State"), "Transition should have failed!")
94+
expect_equal(state_machine.state, "First State")
95+
96+
func test_should_call_tick():
97+
capture_signal(first_state.on_tick, 3)
98+
99+
# Set state
100+
state_machine.transition("First State")
101+
102+
# Run a rollback tick
103+
state_machine._rollback_tick(0.16, 0, true)
104+
105+
# Tick should have been called
106+
expect_equal(get_calls_of(first_state.tick), [[0.16, 0, true]], "Wrong method call!")
107+
expect_equal(get_signal_emissions(first_state.on_tick), [[0.16, 0, true]], "Wrong signal!")
108+
109+
func test_should_notify_display_state():
110+
state_machine.state = "First State"
111+
112+
capture_signal(first_state.on_display_enter, 2)
113+
capture_signal(first_state.on_display_exit, 2)
114+
capture_signal(other_state.on_display_enter, 2)
115+
116+
# First loop, display enters First State
117+
NetworkMocks.in_network_tick_loop(func():
118+
state_machine.transition("First State")
119+
)
120+
expect_not_empty(get_signal_emissions(first_state.on_display_enter))
121+
expect_not_empty(get_calls_of(first_state.display_enter))
122+
123+
# Second loop, display enters Other State
124+
NetworkMocks.in_network_tick_loop(func():
125+
state_machine.transition("Other State")
126+
)
127+
128+
expect_not_empty(get_signal_emissions(first_state.on_display_exit))
129+
expect_not_empty(get_signal_emissions(other_state.on_display_enter))
130+
131+
expect_not_empty(get_calls_of(first_state.display_exit))
132+
expect_not_empty(get_calls_of(other_state.display_enter))
133+
134+
func after_case(__):
135+
state_machine.queue_free()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://cykemuvhqeq2r

0 commit comments

Comments
 (0)