Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Mutations #369

Merged
merged 17 commits into from
Jan 9, 2025
2 changes: 1 addition & 1 deletion addons/netfox/network-time.gd
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class_name _NetworkTime

## This class handles timing.
##
## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/netfox/guides/network-time/
## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-time/

## Number of ticks per second.
##
Expand Down
64 changes: 56 additions & 8 deletions addons/netfox/rollback/network-rollback.gd
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
extends Node
class_name _NetworkRollback

## Orchestrates the rollback loop.
##
## @tutorial(NetworkRollback Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-rollback/
## @tutorial(Modifying objects during rollback): https://foxssake.github.io/netfox/latest/netfox/tutorials/modifying-objects-during-rollback/

## Whether rollback is enabled.
var enabled: bool = ProjectSettings.get_setting("netfox/rollback/enabled", true)

Expand All @@ -10,7 +15,7 @@ var enabled: bool = ProjectSettings.get_setting("netfox/rollback/enabled", true)
var enable_diff_states: bool = ProjectSettings.get_setting("netfox/rollback/enable_diff_states", true)

## How many ticks to store as history.
##
## [br][br]
## The larger the history limit, the further we can roll back into the past,
## thus the more latency we can manage.
## [br][br]
Expand All @@ -24,7 +29,7 @@ var history_limit: int:
push_error("Trying to set read-only variable history_limit")

## Offset into the past for display.
##
## [br][br]
## After the rollback, we have the option to not display the absolute latest
## state of the game, but let's say the state two frames ago ( offset = 2 ).
## This can help with hiding latency, by giving more time for an up-to-date
Expand All @@ -38,7 +43,7 @@ var display_offset: int:
push_error("Trying to set read-only variable display_offset")

## How many previous input frames to send along with the current one.
##
## [br][br]
## With UDP - packets may be lost, arrive late or out of order.
## To mitigate this, we send the current and previous n ticks of input data.
## [br][br]
Expand All @@ -50,6 +55,10 @@ var input_redundancy: int:
set(v):
push_error("Trying to set read-only variable input_redundancy")

## The current [i]rollback[/i] tick.
## [br][br]
## Note that this is different from [member _NetworkTime.tick], and only makes
## sense in the context of a rollback loop.
var tick: int:
get:
return _tick
Expand All @@ -60,25 +69,25 @@ var tick: int:
signal before_loop()

## Event emitted in preparation of each rollback tick.
##
## [br][br]
## Handlers should apply the state and input corresponding to the given tick.
signal on_prepare_tick(tick: int)

## Event emitted after preparing each rollback tick.
##
## [br][br]
## Handlers may process the prepared tick, e.g. modulating the input by its age
## to implement input prediction.
signal after_prepare_tick(tick: int)

## Event emitted to process the given rollback tick.
##
## [br][br]
## Handlers should check if they *need* to resimulate the given tick, and if so,
## generate the next state based on the current data ( applied in the prepare
## tick phase ).
signal on_process_tick(tick: int)

## Event emitted to record the given rollback tick.
##
## [br][br]
## By this time, the tick is advanced from the simulation, handlers should save
## their resulting states for the given tick.
signal on_record_tick(tick: int)
Expand All @@ -91,6 +100,7 @@ var _resim_from: int

var _is_rollback: bool = false
var _simulated_nodes: Dictionary = {}
var _mutated_nodes: Dictionary = {}

static var _logger: _NetfoxLogger = _NetfoxLogger.for_netfox("NetworkRollback")

Expand Down Expand Up @@ -131,7 +141,7 @@ func is_rollback_aware(what: Object) -> bool:

## Calls the [code]_rollback_tick[/code] method on the target, running its
## simulation for the given rollback tick.
##
## [br][br]
## This is used by [RollbackSynchronizer] to resimulate ticks during rollback.
## While the [code]_rollback_tick[/code] method could be called directly as
## well, this method exists to future-proof the code a bit, so the method name
Expand All @@ -142,6 +152,41 @@ func is_rollback_aware(what: Object) -> bool:
func process_rollback(target: Object, delta: float, p_tick: int, is_fresh: bool):
target._rollback_tick(delta, p_tick, is_fresh)

## Marks the target object as mutated.
## [br][br]
## Mutated objects will be re-recorded for the specified tick, and resimulated
## from the given tick onwards.
## [br][br]
## For special cases, you can specify the tick when the mutation happened. Since
## it defaults to the current rollback [member tick], this parameter rarely
## needs to be specified.
## [br][br]
## Note that registering a mutation into the past will yield a warning.
func mutate(target: Object, p_tick: int = tick) -> void:
_mutated_nodes[target] = mini(p_tick, _mutated_nodes.get(target, p_tick))

if is_rollback() and p_tick < tick:
_logger.warning(
"Trying to mutate object %s in the past, for tick %d!",
[target, p_tick]
)

## Check whether the target object was mutated in or after the given tick via
## [method mutate].
func is_mutated(target: Object, p_tick: int = tick) -> bool:
if _mutated_nodes.has(target):
return p_tick >= _mutated_nodes.get(target)
else:
return false

## Check whether the target object was mutated specifically in the given tick
## via [method mutate].
func is_just_mutated(target: Object, p_tick: int = tick) -> bool:
if _mutated_nodes.has(target):
return _mutated_nodes.get(target) == p_tick
else:
return false

func _ready():
NetworkTime.after_tick_loop.connect(_rollback)

Expand Down Expand Up @@ -193,6 +238,9 @@ func _rollback():

# Restore display state
after_loop.emit()

# Cleanup
_mutated_nodes.clear()
_is_rollback = false

# Insight 1:
Expand Down
13 changes: 11 additions & 2 deletions addons/netfox/rollback/rollback-synchronizer.gd
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ func _can_simulate(node: Node, tick: int) -> bool:
if not enable_prediction and not _inputs.has(tick):
# Don't simulate if prediction is not allowed and input is unknown
return false
if NetworkRollback.is_mutated(node, tick):
# Mutated nodes are always resimulated
return true
if node.is_multiplayer_authority():
# Simulate from earliest input
# Don't simulate frames we don't have input for
Expand Down Expand Up @@ -368,8 +371,12 @@ func _record_tick(tick: int):
var full_state: Dictionary = {}

for property in _auth_state_property_entries:
if _can_simulate(property.node, tick - 1) and not _skipset.has(property.node):
if _can_simulate(property.node, tick - 1) \
and not _skipset.has(property.node) \
or NetworkRollback.is_mutated(property.node, tick - 1):
# Only broadcast if we've simulated the node
# NOTE: _can_simulate checks mutations, but to override _skipset
# we check a second time
full_state[property.to_string()] = property.get_value()

_on_transmit_state.emit(full_state, tick)
Expand Down Expand Up @@ -428,7 +435,9 @@ func _record_tick(tick: int):
_states[tick] = PropertySnapshot.extract(_record_state_property_entries)
else:
var record_properties = _record_state_property_entries\
.filter(func(pe): return not _skipset.has(pe.node))
.filter(func(pe): return \
not _skipset.has(pe.node) or \
NetworkRollback.is_mutated(pe.node, tick - 1))

var merge_state = _get_history(_states, tick - 1)
var record_state = PropertySnapshot.extract(record_properties)
Expand Down
1 change: 0 additions & 1 deletion addons/vest/vest-plugin.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ var bottom_control: VestUI
func _enter_tree():
bottom_control = (load("res://addons/vest/ui/vest-ui.tscn") as PackedScene).instantiate() as VestUI
resource_saved.connect(bottom_control.handle_resource_saved)
print("handler connected!")

add_control_to_bottom_panel(bottom_control, "Vest")

Expand Down
3 changes: 0 additions & 3 deletions addons/vest/vest-runner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ func run_tests(directory: String = "res://test/") -> Array[VestTest.Result]:

for test_suite in test_suites:
var test_cases := test_suite._get_test_cases()
if test_cases.is_empty():
# Empty suite, skip
continue

# Run suite
test_suite.before()
Expand Down
9 changes: 4 additions & 5 deletions docs/netfox.extras/guides/rewindable-state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,10 @@ extends RewindableState
@export var input: PlayerInputStateMachine

func tick(delta, tick, is_fresh):
if input.movement != Vector3.ZERO:
state_machine.transition(&"Move")
elif input.jump:
state_machine.transition(&"Jump")

if input.movement != Vector3.ZERO:
state_machine.transition(&"Move")
elif input.jump:
state_machine.transition(&"Jump")
```

Transitions are based on *node names*, i.e. calling `transition(&"Move")` will
Expand Down
154 changes: 154 additions & 0 deletions docs/netfox/tutorials/modifying-objects-during-rollback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Modifying objects during rollback

There are cases where two objects interact and modify each other during
rollback. For example:

* Players shoving another
* An explosion displacing objects around it
* Two cars colliding
* A player shooting at another - if player stats are managed as part of
rollback

## Using Mutations

Mutations enable modifying objects during rollback, in a way that is taken into
account by netfox.

When an object is modified during rollback, call `NetworkRollback.mutate()`,
passing said object as an argument.

As a result, the changes made to the object in the current tick will be
recorded. Since its history has changed, it will be resimulated from the point
of change - i.e. for all ticks after the change was made.

!!!note
Make sure that `mutate()` is only called on objects that need it - otherwise,
ticks will be resimulated for objects that don't need it, resulting in worse
performance.

### Example code

To see this in action, take a snippet from Forest Brawl:

```gdscript
for brawler in _get_overlapping_brawlers():
var diff := brawler.global_position - global_position
var f := clampf(1.0 / (1.0 + diff.length_squared()), 0.0, 1.0)

var offset := Vector3(diff.x, max(0, diff.y), diff.z).normalized()
offset *= strength_factor * strength * f * NetworkTime.ticktime

brawler.shove(offset)
NetworkRollback.mutate(brawler)
```

The script calculates which direction to shove the player in, and with what
force. This is then applied by calling `shove()`.

Then, on the last line, these changes are saved by calling
`NetworkRollback.mutate(brawler)`.

Calling `mutate()` is all that's needed to use this feature.

## The problem with naive implementations

The simplest way to implement these mechanics is to just update the affected
object, without using mutations. For example, when one player shoves another,
the shove direction can simply be added to the target player's position. Doing
this will not work unfortunately.

Let's say that Player A is shoving Player B. With Player A being the local
player, we have input for its actions. With Player B being a remote player, it
won't be simulated. So even though its position was modified, this change will
not be recorded, and will be overridden by its last *known* position.

```puml
@startuml

concise "Player A" as PA
concise "Player B" as PB

@0
PA is Restored
PB is Restored

@8
PA is Simulated

@10
PA -> PB: shove()

@enduml
```

In the example above, even though Player A shoved Player B on tick 10, Player B
is not simulated in that given tick, so it won't be recorded. Player A's shove
is not saved to history.

This may partially be fixed by enabling [prediction] for players.

Take another case, where Player B wants to shove Player A. With Player B being
a remote player, we only receive its input a few ticks after the fact. So we
need to resimulate Player B from an earlier tick. In one of these earlier tick,
Player A gets shoved.

```puml
@startuml

concise "Player A" as PA
concise "Player B" as PB

@0
PA is Restored
PB is Restored

@6
PB is Simulated

@7
PB -> PA: shove()

@8
PA is Simulated

@enduml
```

In this example, we've received input for Player B for tick 6 onwards. On tick
7, Player B shoves Player A. Since we've already simulated Player A for the
given tick, we don't need to simulate it again. This means that any changes for
the tick will not be recorded. The shove will not be saved to history.

Since Player A was already simulated and recorded for this earlier tick, it
being shoved will not be recorded.

In both cases, we need to use mutations to tell netfox that a given object has
been modified ( *mutated* ), and its state history should be updated.

Let's try the previous example, but now with `mutate()` added:

```puml
@startuml

concise "Player A" as PA
concise "Player B" as PB

@0
PA is Restored
PB is Restored

@6
PB is Simulated

@7
PB -> PA: shove()\nmutate()

PA is Simulated

@enduml
```

Player A will be resimulated from the point of shoving, and the shove itself
will be recorded.

[prediction]: ./predicting-input.md
Loading
Loading