Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+31
View File
@@ -0,0 +1,31 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
+18
View File
@@ -0,0 +1,18 @@
Copyright 2023 Gálffy Tamás
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+37
View File
@@ -0,0 +1,37 @@
# netfox
The core addon of [netfox], providing responsive multiplayer features for the
[Godot Engine].
## Features
* ⏲️ Synchronized time
* Runs game logic at a fixed, configurable tickrate
* Time synchronized to game host
* 🧈 State interpolation
* Render 24fps tickrate at buttery smooth 60fps or more
* Add a `TickInterpolator` node and it just works
* 💨 Lag compensation with CSP
* Implement responsive player motion with little to no extra code
* Just use the `RollbackSynchronizer` node for state synchronization
## Install
See the root [README](../../README.md).
## Usage
See the [docs](https://foxssake.github.io/netfox/).
## License
netfox is under the [MIT license](LICENSE).
## Issues
In case of any issues, comments, or questions, please feel free to [open an issue]!
[netfox]: https://github.com/foxssake/netfox
[source]: https://github.com/foxssake/netfox/archive/refs/heads/main.zip
[Godot engine]: https://godotengine.org/
[open an issue]: https://github.com/foxssake/netfox/issues
@@ -0,0 +1,136 @@
extends RefCounted
class_name _DiffHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _full_snapshot := {}
var _encoded_snapshot := {}
var _property_indexes := _BiMap.new()
var _version := 0
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("DiffHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func add_properties(properties: Array[PropertyEntry]) -> void:
var has_new_properties := false
for property_entry in properties:
var is_new := _ensure_property_idx(property_entry.to_string())
has_new_properties = has_new_properties or is_new
# If we added any new properties, increment version
if has_new_properties:
_version = (_version + 1) % 256
func encode(tick: int, reference_tick: int, properties: Array[PropertyEntry]) -> PackedByteArray:
assert(properties.size() <= 255, "Property indices may not fit into bytes!")
var snapshot := _history.get_snapshot(tick)
var property_strings := properties.map(func(it): return it.to_string())
var reference_snapshot := _history.get_history(reference_tick)
var diff_snapshot := reference_snapshot.make_patch(snapshot)
_full_snapshot = snapshot.as_dictionary()
_encoded_snapshot = diff_snapshot.as_dictionary()
if diff_snapshot.is_empty():
return PackedByteArray()
var buffer := StreamPeerBuffer.new()
buffer.put_u8(_version)
for property in diff_snapshot.properties():
var property_idx := _property_indexes.get_by_value(property) as int
var property_value = diff_snapshot.get_value(property)
buffer.put_u8(property_idx)
buffer.put_var(property_value)
return buffer.data_array
func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
if data.is_empty():
return result
var buffer := StreamPeerBuffer.new()
buffer.data_array = data
var packet_version := buffer.get_u8()
if packet_version != _version:
if not _has_received:
# This is the first time we receive data
# Assume the version is OK
_version = packet_version
else:
# Since we don't remove entries, only add, we can still parse what
# we can
_logger.warning("Property config version mismatch - own %d != received %d", [_version, packet_version])
_has_received = true
while buffer.get_available_bytes() > 0:
var property_idx := buffer.get_u8()
var property_value := buffer.get_var()
if not _property_indexes.has_key(property_idx):
_logger.warning("Received unknown property index %d, ignoring!", [property_idx])
continue
var property_entry := _property_indexes.get_by_key(property_idx)
result.set_value(property_entry, property_value)
return result
func apply(tick: int, snapshot: _PropertySnapshot, reference_tick: int, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error(
"Received diff snapshot for @%d, rejecting because older than %s frames",
[tick, NetworkRollback.history_limit]
)
return false
if snapshot.is_empty():
return true
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
_logger.warning("Received invalid diff from #%s for @%s", [sender, tick])
return false
if not _history.has(reference_tick):
# Reference tick missing, hope for the best
_logger.warning("Reference tick %d missing for #%s applying %d", [reference_tick, sender, tick])
var reference_snapshot := _history.get_snapshot(reference_tick)
_history.set_snapshot(tick, reference_snapshot.merge(snapshot))
return true
# TODO: Rework metrics so these are not needed
func get_encoded_snapshot() -> Dictionary:
return _encoded_snapshot
func get_full_snapshot() -> Dictionary:
return _full_snapshot
func _ensure_property_idx(property: String) -> bool:
if _property_indexes.has_value(property):
return false
assert(_property_indexes.size() < 256, "Property index map is full, can't add new property!")
var idx := hash(property) % 256
while _property_indexes.has_key(idx):
idx = hash(idx + 1) % 256
_property_indexes.put(idx, property)
return true
@@ -0,0 +1 @@
uid://dc73evbedbmvs
@@ -0,0 +1,116 @@
extends RefCounted
class_name _RedundantHistoryEncoder
var redundancy: int = 4:
get = get_redundancy,
set = set_redundancy
var _history: _PropertyHistoryBuffer
var _properties: Array[PropertyEntry]
var _property_cache: PropertyCache
var _version := 0
var _has_received := false
var _logger := NetfoxLogger._for_netfox("RedundantHistoryEncoder")
func get_redundancy() -> int:
return redundancy
func set_redundancy(p_redundancy: int):
if p_redundancy <= 0:
_logger.warning(
"Attempting to set redundancy to %d, which would send no data!", [p_redundancy]
)
return
redundancy = p_redundancy
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
if _history.is_empty():
return []
var data := []
for i in range(mini(redundancy, _history.size())):
var offset_tick := tick - i
if offset_tick < _history.get_earliest_tick():
break
var snapshot := _history.get_snapshot(offset_tick)
for property in properties:
data.append(snapshot.get_value(property.to_string()))
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> Array[_PropertySnapshot]:
if data.is_empty() or properties.is_empty():
return []
var packet_version := data.pop_back() as int
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return []
var result: Array[_PropertySnapshot] = []
var redundancy := data.size() / properties.size()
result.assign(range(redundancy)
.map(func(__): return _PropertySnapshot.new())
)
for i in range(data.size()):
var offset_idx := i / properties.size()
var prop_idx := i % properties.size()
result[offset_idx].set_value(properties[prop_idx].to_string(), data[i])
_has_received = true
return result
# Returns earliest new tick as int, or -1 if no new ticks applied
func apply(tick: int, snapshots: Array[_PropertySnapshot], sender: int = 0) -> int:
var earliest_new_tick = -1
for i in range(snapshots.size()):
var offset_tick := tick - i
var snapshot := snapshots[i]
if offset_tick < NetworkRollback.history_start:
# Data too old
_logger.warning(
"Received data for %s, rejecting because older than %s frames",
[offset_tick, NetworkRollback.history_limit]
)
continue
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
# No valid properties ( probably after sanitize )
_logger.warning("Received invalid data from %d for tick %d", [sender, tick])
continue
var known_snapshot := _history.get_snapshot(offset_tick)
if not known_snapshot.equals(snapshot):
# Received a new snapshot, store and emit signal
_history.set_snapshot(offset_tick, snapshot)
earliest_new_tick = offset_tick
return earliest_new_tick
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
@@ -0,0 +1 @@
uid://ytn07qahpatv
@@ -0,0 +1,67 @@
extends RefCounted
class_name _SnapshotHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _properties: Array[PropertyEntry]
var _version := -1
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("_SnapshotHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
var snapshot := _history.get_snapshot(tick)
var data := []
data.resize(properties.size())
for i in range(properties.size()):
data[i] = snapshot.get_value(properties[i].to_string())
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
var packet_version = data.pop_back()
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return result
if properties.size() != data.size():
_logger.warning("Received snapshot with %d entries, with %d known - parsing as much as possible", [data.size(), properties.size()])
for i in range(0, mini(data.size(), properties.size())):
result.set_value(properties[i].to_string(), data[i])
_has_received = true
return result
func apply(tick: int, snapshot: _PropertySnapshot, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error("Received full snapshot for %s, rejecting because older than %s frames", [tick, NetworkRollback.history_limit])
return false
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty(): return false
_history.set_snapshot(tick, snapshot)
return true
@@ -0,0 +1 @@
uid://dwhlghjsgdy4o
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="rewindable-action.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
width="16"
height="16"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="4.78125"
inkscape:cy="8.953125"
inkscape:window-width="2560"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"
showgrid="true"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="true" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
.st2{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
.st3{fill:#5FFF97;}
.st4{fill:#FF5F5F;}
</style>
<circle
id="path4"
class="st0"
cx="8"
cy="8"
style="stroke-width:2;stroke-dasharray:none"
r="5" />
<path
id="path1"
class="st3"
d="M 8,9.8843749 5.95,13.984375 3.9,9.8843749 Z"
style="stroke-width:0.0625;stroke-dasharray:none" />
<path
id="path2"
class="st4"
d="M 12.125,13.984375 10.075,9.8843749 8,13.984375 Z"
style="stroke-width:0.0625;stroke-dasharray:none" />
<path
id="path3"
class="st0"
d="M 9.71875,5.9999999 V 4.5705319"
sodipodi:nodetypes="cc"
style="stroke-width:2;stroke-dasharray:none" /><circle
style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-miterlimit:6;stroke-dasharray:none"
id="path5"
cx="9.71875"
cy="8"
r="0.875" /><path
id="path6"
class="st0"
d="M 8,5.9999999 V 4.5705319"
sodipodi:nodetypes="cc"
style="opacity:0.66;stroke-width:2;stroke-dasharray:none" /><circle
style="opacity:0.66;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-miterlimit:6;stroke-dasharray:none"
id="circle6"
cx="8"
cy="8"
r="0.875" /><path
id="path7"
class="st0"
d="M 6.28125,5.9999999 V 4.5705319"
sodipodi:nodetypes="cc"
style="opacity:0.33;stroke-width:2;stroke-dasharray:none" /><circle
style="opacity:0.33;fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-miterlimit:6;stroke-dasharray:none"
id="circle7"
cx="6.28125"
cy="8"
r="0.875" /></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="rollback-synchronizer.svg"
width="16"
height="16"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="true"
inkscape:zoom="48.8125"
inkscape:cx="7.3956466"
inkscape:cy="8.4302177"
inkscape:window-width="2560"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="true" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
.st2{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
.st3{fill:#5FFF97;}
.st4{fill:#FF5F5F;}
</style>
<path
id="path6"
class="st0"
d="M 8,8 10.425,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path7"
class="st1"
d="M 8,8 V 4.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path8"
class="st2"
d="M 8,8 5.575,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<circle
id="path4"
class="st0"
cx="8"
cy="8"
style="stroke-width:2;stroke-dasharray:none"
r="5" />
<path
id="path1"
class="st3"
d="M 8,9.55 5.95,13.65 3.9,9.55 Z"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path2"
class="st4"
d="M 12.125,13.65 10.075,9.55 8,13.65 Z"
style="stroke-width:2;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="state-synchronizer.svg"
width="16"
height="16"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="true"
inkscape:zoom="48.8125"
inkscape:cx="7.1293214"
inkscape:cy="9.0243278"
inkscape:window-width="2560"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="true" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:#5FFF97;}
.st2{fill:#FF5F5F;}
.st3{opacity:0.8;}
</style>
<circle
id="path4_00000145755642355381971890000012750770255243041471_"
class="st0"
cx="8"
cy="8"
style="stroke-width:2;stroke-dasharray:none"
r="5" />
<path
id="path1_00000101101429107098172700000017609064003007162547_"
class="st1"
d="M 8,9.9000001 5.95,14 3.9,9.9000001 Z"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path2_00000126280959979031010980000006949781421479937947_"
class="st2"
d="M 12.1,14 10.05,9.9000001 8,14 Z"
style="stroke-width:2;stroke-dasharray:none" />
<g
id="g3"
transform="matrix(0.25,0,0,0.25,0.044012,-0.08749956)"
class="st3"
style="stroke-width:8;stroke-dasharray:none">
<g
id="g1"
style="stroke-width:8;stroke-dasharray:none">
<path
id="path6_00000155856079621265361740000011086747546737189515_"
class="st0"
d="m 22.2,32 9.7,-9.7"
style="stroke-width:8;stroke-dasharray:none" />
<path
id="path3_00000068640062030745169160000008672866801517693375_"
class="st0"
d="M 41.5,32 31.8,22.3"
style="stroke-width:8;stroke-dasharray:none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="tick-interpolator.svg"
width="16"
height="16"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1">
</defs><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="37.1673"
inkscape:cx="5.7577494"
inkscape:cy="8.4079284"
inkscape:window-width="2560"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="false" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:8.25;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
.st1{fill:none;stroke:#E0E0E0;stroke-width:8.25;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
.st2{fill:none;stroke:#E0E0E0;stroke-width:8.25;stroke-linecap:round;stroke-linejoin:round;}
.st3{fill:#5FFF97;}
.st4{fill:#FF5F5F;}
</style>
<circle
id="circle5"
class="st0"
cx="6.0499997"
cy="7.4906249"
r="4.0999999"
style="stroke-width:2;stroke-dasharray:none" /><circle
id="circle4"
class="st1"
cx="8.0500002"
cy="7.4906249"
r="4.0999999"
style="stroke-width:2;stroke-dasharray:none" /><circle
id="path4_00000013181098631343428540000005966887173574773150_"
class="st2"
cx="10.05"
cy="7.4906249"
r="4.0999999"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path1_00000174580785134469184010000016548563214013514156_"
class="st3"
d="m 8,9.540625 -2.05,4.1 -2.05,-4.1 z"
style="stroke-width:0.25" />
<path
id="path2_00000099645594608698939230000006510534976739617970_"
class="st4"
d="m 12.1,13.640625 -2.05,-4.1 -2.05,4.1 z"
style="stroke-width:0.25" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+115
View File
@@ -0,0 +1,115 @@
extends Object
class_name Interpolators
class Interpolator:
var is_applicable: Callable
var apply: Callable
static func make(is_applicable: Callable, apply: Callable) -> Interpolator:
var result := Interpolator.new()
result.is_applicable = is_applicable
result.apply = apply
return result
static var DEFAULT_INTERPOLATOR := Interpolator.make(
func (v): return true,
func (a, b, f): return a if f < 0.5 else b
)
static var interpolators: Array[Interpolator]
static var default_apply: Callable = func(a, b, f): a if f < 0.5 else b
## Register an interpolator.
##
## New interpolators are pushed to the front of the list, making them have
## precedence over existing ones. This can be useful in case you want to override
## the built-in interpolators.
static func register(is_applicable: Callable, apply: Callable) -> void:
interpolators.push_front(Interpolator.make(is_applicable, apply))
## Find the appropriate interpolator for the given value.
##
## If none was found, the default interpolator is returned.
static func find_for(value) -> Callable:
for interpolator in interpolators:
if interpolator.is_applicable.call(value):
return interpolator.apply
return DEFAULT_INTERPOLATOR.apply
## Interpolate between two values.
##
## Note, that it is usually faster to just cache the Callable returned by find_for
## and call that, instead of calling interpolate repeatedly. The latter will have
## to lookup the appropriate interpolator on every call.
static func interpolate(a, b, f: float):
return find_for(a).call(a, b, f)
static func _static_init() -> void:
# Register built-in interpolators
# Float
register(
func(a): return a is float,
func(a: float, b: float, f: float): return lerpf(a, b, f)
)
# Int
register(
func(a): return a is int,
func(a: int, b: int, f: float): return int(lerpf(a, b, f))
)
# Vector
register(
func(a): return a is Vector2,
func(a: Vector2, b: Vector2, f: float): return a.lerp(b, f)
)
register(
func(a): return a is Vector3,
func(a: Vector3, b: Vector3, f: float): return a.lerp(b, f)
)
register(
func(a): return a is Vector4,
func(a: Vector4, b: Vector4, f: float): return a.lerp(b, f)
)
register(
func(a): return a is Vector2i,
func(a: Vector2i, b: Vector2i, f: float): return Vector2i(Vector2(a).lerp(b, f))
)
register(
func(a): return a is Vector3i,
func(a: Vector3i, b: Vector3i, f: float): return Vector3i(Vector3(a).lerp(b, f))
)
register(
func(a): return a is Vector4i,
func(a: Vector4i, b: Vector4i, f: float): return Vector4i(Vector4(a).lerp(b, f))
)
# Transform
register(
func(a): return a is Transform2D,
func(a: Transform2D, b: Transform2D, f: float): return a.interpolate_with(b, f)
)
register(
func(a): return a is Transform3D,
func(a: Transform3D, b: Transform3D, f: float): return a.interpolate_with(b, f)
)
# Quaternion
register(
func(a): return a is Quaternion,
func(a: Quaternion, b: Quaternion, f: float): return a.slerp(b, f)
)
# Basis
register(
func(a): return a is Basis,
func(a: Basis, b: Basis, f: float): return a.slerp(b, f)
)
# Color
register(
func(a): return a is Color,
func(a: Color, b: Color, f: float): return a.lerp(b, f)
)
+1
View File
@@ -0,0 +1 @@
uid://wsx6ajtb42c2
+216
View File
@@ -0,0 +1,216 @@
@tool
extends EditorPlugin
const ROOT := "res://addons/netfox"
var SETTINGS: Array[Dictionary] = [
{
# Setting this to false will make Netfox keep its settings even when
# disabling the plugin. Useful for developing the plugin.
"name": "netfox/general/clear_settings",
"value": true,
"type": TYPE_BOOL
},
# Logging
NetfoxLogger._make_setting("netfox/logging/netfox_log_level"),
# Time settings
{
"name": "netfox/time/tickrate",
"value": 30,
"type": TYPE_INT
},
{
"name": "netfox/time/max_ticks_per_frame",
"value": 8,
"type": TYPE_INT
},
{
"name": "netfox/time/recalibrate_threshold",
"value": 8.0,
"type": TYPE_FLOAT
},
{
"name": "netfox/time/stall_threshold",
"value": 1.0,
"type": TYPE_FLOAT
},
{
# Time to wait between time syncs
"name": "netfox/time/sync_interval",
"value": 0.25,
"type": TYPE_FLOAT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "%s,2,or_greater" % [_NetworkTimeSynchronizer.MIN_SYNC_INTERVAL]
},
{
"name": "netfox/time/sync_samples",
"value": 8,
"type": TYPE_INT
},
{
"name": "netfox/time/sync_adjust_steps",
"value": 8,
"type": TYPE_INT
},
{
# !! Deprecated
# Time to wait between time sync samples
"name": "netfox/time/sync_sample_interval",
"value": 0.1,
"type": TYPE_FLOAT
},
{
"name": "netfox/time/sync_to_physics",
"value": false,
"type": TYPE_BOOL
},
{
"name": "netfox/time/max_time_stretch",
"value": 1.25,
"type": TYPE_FLOAT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "1,2,0.05,or_greater"
},
{
"name": "netfox/time/tickrate_mismatch_action",
"value": NetworkTickrateHandshake.WARN,
"type": TYPE_INT,
"hint": PROPERTY_HINT_ENUM,
"hint_string": "Warn,Disconnect,Adjust,Signal"
},
{
"name": "netfox/time/suppress_offline_peer_warning",
"value": false,
"type": TYPE_BOOL
},
# Rollback settings
{
"name": "netfox/rollback/enabled",
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/rollback/history_limit",
"value": 64,
"type": TYPE_INT
},
{
"name": "netfox/rollback/input_redundancy",
"value": 3,
"type": TYPE_INT
},
{
"name": "netfox/rollback/display_offset",
"value": 0,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,4,or_greater"
},
{
"name": "netfox/rollback/input_delay",
"value": 0,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,4,or_greater"
},
{
"name": "netfox/rollback/enable_diff_states",
"value": true,
"type": TYPE_BOOL
},
# Events
{
"name": "netfox/events/enabled",
"value": true,
"type": TYPE_BOOL
}
]
const AUTOLOADS: Array[Dictionary] = [
{
"name": "NetworkTime",
"path": ROOT + "/network-time.gd"
},
{
"name": "NetworkTimeSynchronizer",
"path": ROOT + "/network-time-synchronizer.gd"
},
{
"name": "NetworkRollback",
"path": ROOT + "/rollback/network-rollback.gd"
},
{
"name": "NetworkEvents",
"path": ROOT + "/network-events.gd"
},
{
"name": "NetworkPerformance",
"path": ROOT + "/network-performance.gd"
}
]
const TYPES: Array[Dictionary] = [
{
"name": "RollbackSynchronizer",
"base": "Node",
"script": ROOT + "/rollback/rollback-synchronizer.gd",
"icon": ROOT + "/icons/rollback-synchronizer.svg"
},
{
"name": "StateSynchronizer",
"base": "Node",
"script": ROOT + "/state-synchronizer.gd",
"icon": ROOT + "/icons/state-synchronizer.svg"
},
{
"name": "TickInterpolator",
"base": "Node",
"script": ROOT + "/tick-interpolator.gd",
"icon": ROOT + "/icons/tick-interpolator.svg"
},
{
"name": "RewindableAction",
"base": "Node",
"script": ROOT + "/rewindable-action.gd",
"icon": ROOT + "/icons/rewindable-action.svg"
},
]
func _enter_tree():
for setting in SETTINGS:
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
for type in TYPES:
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
func _exit_tree() -> void:
if ProjectSettings.get_setting(&"netfox/general/clear_settings", false):
for setting in SETTINGS:
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
for type in TYPES:
remove_custom_type(type.name)
func add_setting(setting: Dictionary) -> void:
if ProjectSettings.has_setting(setting.name):
return
ProjectSettings.set_setting(setting.name, setting.value)
ProjectSettings.set_initial_value(setting.name, setting.value)
ProjectSettings.add_property_info({
"name": setting.get("name"),
"type": setting.get("type"),
"hint": setting.get("hint", PROPERTY_HINT_NONE),
"hint_string": setting.get("hint_string", "")
})
func remove_setting(setting: Dictionary) -> void:
if not ProjectSettings.has_setting(setting.name):
return
ProjectSettings.clear(setting.name)
+1
View File
@@ -0,0 +1 @@
uid://cjrtjdfhdtjlm
+148
View File
@@ -0,0 +1,148 @@
extends Node
## This class provides convenience signals for multiplayer games.
##
## While the client start/stop and peer join/leave events are trivial, the
## server side has no similar events. This means that if you'd like to add some
## funcionality that should happen on server start, you either have to couple
## the code ( i.e. call it wherever you start the server ) or introduce a custom
## event to decouple your code from your network init code.
##
## By providing these convenience events, you can forego all that and instead
## just listen to a single signal that should work no matter what.
##
## [i]Note:[/i] This class also manages [NetworkTime] start/stop, so as long as
## network events are enabled, you don't need to manually call start/stop.
## Event emitted when the [MultiplayerAPI] is changed
signal on_multiplayer_change(old: MultiplayerAPI, new: MultiplayerAPI)
## Event emitted when the server starts
signal on_server_start()
## Event emitted when the server stops for any reason
signal on_server_stop()
## Event emitted when the client starts
signal on_client_start(id: int)
## Event emitted when the client stops.
##
## This can happen due to either the client itself or the server disconnecting
## for whatever reason.
signal on_client_stop()
## Event emitted when a new peer joins the game.
signal on_peer_join(id: int)
## Event emitted when a peer leaves the game.
signal on_peer_leave(id: int)
## Whether the events are enabled.
##
## Events are only emitted when it's enabled. Disabling this can free up some
## performance, as when enabled, the multiplayer API and the host are
## continuously checked for changes.
##
## The initial value is taken from the Netfox project settings.
var enabled: bool:
get: return _enabled
set(v): _set_enabled(v)
var _is_server: bool = false
var _multiplayer: MultiplayerAPI
var _enabled: bool = false
## Check if we're running as server.
func is_server() -> bool:
if multiplayer == null:
return false
var peer := multiplayer.multiplayer_peer
if peer == null:
return false
if peer is OfflineMultiplayerPeer:
return false
if peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return false
if not multiplayer.is_server():
return false
return true
func _ready() -> void:
NetfoxLogger.register_tag(_get_peer_id_tag, -99)
enabled = ProjectSettings.get_setting(&"netfox/events/enabled", true)
# Automatically start ticking when entering multiplayer and stop when
# leaving multiplayer
on_server_start.connect(NetworkTime.start)
on_server_stop.connect(NetworkTime.stop)
on_client_start.connect(func(id): NetworkTime.start())
on_client_stop.connect(NetworkTime.stop)
func _exit_tree() -> void:
NetfoxLogger.free_tag(_get_peer_id_tag)
func _get_peer_id_tag() -> String:
return "#%d" % multiplayer.get_unique_id()
func _process(_delta: float) -> void:
if multiplayer != _multiplayer:
_disconnect_handlers(_multiplayer)
_connect_handlers(multiplayer)
on_multiplayer_change.emit(_multiplayer, multiplayer)
_multiplayer = multiplayer
if not _is_server and is_server():
_is_server = true
on_server_start.emit()
if _is_server and not is_server():
_is_server = false
on_server_stop.emit()
func _connect_handlers(mp: MultiplayerAPI) -> void:
if mp == null:
return
mp.connected_to_server.connect(_handle_connected_to_server)
mp.server_disconnected.connect(_handle_server_disconnected)
mp.peer_connected.connect(_handle_peer_connected)
mp.peer_disconnected.connect(_handle_peer_disconnected)
func _disconnect_handlers(mp: MultiplayerAPI) -> void:
if mp == null:
return
mp.connected_to_server.disconnect(_handle_connected_to_server)
mp.server_disconnected.disconnect(_handle_server_disconnected)
mp.peer_connected.disconnect(_handle_peer_connected)
mp.peer_disconnected.disconnect(_handle_peer_disconnected)
func _handle_connected_to_server() -> void:
on_client_start.emit(multiplayer.get_unique_id())
func _handle_server_disconnected() -> void:
on_client_stop.emit()
func _handle_peer_connected(id: int) -> void:
on_peer_join.emit(id)
func _handle_peer_disconnected(id: int) -> void:
on_peer_leave.emit(id)
func _set_enabled(enable: bool) -> void:
if _enabled and not enable:
_disconnect_handlers(_multiplayer)
_multiplayer = null
if not _enabled and enable:
_multiplayer = multiplayer
_connect_handlers(_multiplayer)
_enabled = enable
set_process(enable)
+1
View File
@@ -0,0 +1 @@
uid://bdmobxxptryj0
+175
View File
@@ -0,0 +1,175 @@
extends Node
class_name _NetworkPerformance
const NETWORK_LOOP_DURATION_MONITOR: StringName = &"netfox/Network loop duration (ms)"
const ROLLBACK_LOOP_DURATION_MONITOR: StringName = &"netfox/Rollback loop duration (ms)"
const NETWORK_TICKS_MONITOR: StringName = &"netfox/Network ticks simulated"
const ROLLBACK_TICKS_MONITOR: StringName = &"netfox/Rollback ticks simulated"
const ROLLBACK_TICK_DURATION_MONITOR: StringName = &"netfox/Rollback tick duration (ms)"
const ROLLBACK_NODES_SIMULATED_MONITOR: StringName = &"netfox/Rollback nodes simulated"
const ROLLBACK_NODES_SIMULATED_PER_TICK_MONITOR: StringName = &"netfox/Rollback nodes simulated per tick (avg)"
const FULL_STATE_PROPERTIES_COUNT: StringName = &"netfox/Full state properties count"
const SENT_STATE_PROPERTIES_COUNT: StringName = &"netfox/Sent state properties count"
const SENT_STATE_PROPERTIES_RATIO: StringName = &"netfox/Sent state properties ratio"
var _network_loop_start: float = 0
var _network_loop_duration: float = 0
var _network_ticks: int = 0
var _network_ticks_accum: int = 0
var _rollback_loop_start: float = 0
var _rollback_loop_duration: float = 0
var _rollback_ticks: int = 0
var _rollback_ticks_accum: int = 0
var _rollback_nodes_simulated: int = 0
var _rollback_nodes_simulated_accum: int = 0
var _full_state_props: int = 0
var _full_state_props_accum: int = 0
var _sent_state_props: int = 0
var _sent_state_props_accum: int = 0
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkPerformance")
## Check if performance monitoring is enabled.
## [br][br]
## By default, monitoring is only enabled in debug builds
## ( see [method OS.is_debug_build] ). [br]
## Can be forced on with the [code]netfox_perf[/code] feature tag. [br]
## Can be forced off with the [code]netfox_noperf[/code] feature tag.
func is_enabled() -> bool:
if OS.has_feature("netfox_noperf"):
return false
if OS.has_feature("netfox_perf"):
return true
# This returns true in the editor too
return OS.is_debug_build()
## Get time spent in the last network tick loop, in millisec.
## [br]
## Note that this also includes time spent in the rollback tick loop.
func get_network_loop_duration_ms() -> float:
return _network_loop_duration * 1000
## Get the number of ticks simulated in the last network tick loop.
func get_network_ticks() -> int:
return _network_ticks
## Get time spent in the last rollback tick loop, in millisec.
func get_rollback_loop_duration_ms() -> float:
return _rollback_loop_duration * 1000
## Get the number of ticks resimulated in the last rollback tick loop.
func get_rollback_ticks() -> int:
return _rollback_ticks
## Get the average amount of time spent in a rollback tick during the last
## rollback loop, in millisec.
func get_rollback_tick_duration_ms() -> float:
return _rollback_loop_duration * 1000 / maxi(_rollback_ticks, 1)
## Get the number of nodes simulated during the last rollback loop.
func get_rollback_nodes_simulated() -> int:
return _rollback_nodes_simulated
## Get the number of nodes simulated per tick on average during the last
## rollback loop.
func get_rollback_nodes_simulated_per_tick() -> float:
return _rollback_nodes_simulated / maxf(1., _rollback_ticks)
func push_rollback_nodes_simulated(count: int):
_rollback_nodes_simulated_accum += count
## Get the number of properties in the full state recorded during the last tick
## loop.
func get_full_state_props_count() -> int:
return _full_state_props
## Get the number of properties actually sent during the last tick loop.
func get_sent_state_props_count() -> int:
return _sent_state_props
## Get the ratio of sent properties count to full state properties count.
##
## See [member get_full_state_props_count][br]
## See [member get_sent_state_props_count]
func get_sent_state_props_ratio() -> float:
return _sent_state_props / maxf(1., _full_state_props)
func push_full_state(state: Dictionary) -> void:
_full_state_props_accum += state.size()
func push_full_state_broadcast(state: Dictionary) -> void:
_full_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
func push_sent_state(state: Dictionary) -> void:
_sent_state_props_accum += state.size()
func push_sent_state_broadcast(state: Dictionary) -> void:
_sent_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
func _ready() -> void:
if not is_enabled():
_logger.debug("Network performance disabled")
return
_logger.debug("Network performance enabled, registering performance monitors")
Performance.add_custom_monitor(NETWORK_LOOP_DURATION_MONITOR, get_network_loop_duration_ms)
Performance.add_custom_monitor(ROLLBACK_LOOP_DURATION_MONITOR, get_rollback_loop_duration_ms)
Performance.add_custom_monitor(NETWORK_TICKS_MONITOR, get_network_ticks)
Performance.add_custom_monitor(ROLLBACK_TICKS_MONITOR, get_rollback_ticks)
Performance.add_custom_monitor(ROLLBACK_TICK_DURATION_MONITOR, get_rollback_tick_duration_ms)
Performance.add_custom_monitor(ROLLBACK_NODES_SIMULATED_MONITOR, get_rollback_nodes_simulated)
Performance.add_custom_monitor(ROLLBACK_NODES_SIMULATED_PER_TICK_MONITOR, get_rollback_nodes_simulated_per_tick)
Performance.add_custom_monitor(FULL_STATE_PROPERTIES_COUNT, get_full_state_props_count)
Performance.add_custom_monitor(SENT_STATE_PROPERTIES_COUNT, get_sent_state_props_count)
Performance.add_custom_monitor(SENT_STATE_PROPERTIES_RATIO, get_sent_state_props_ratio)
NetworkTime.before_tick_loop.connect(_before_tick_loop)
NetworkTime.on_tick.connect(_on_network_tick)
NetworkTime.after_tick_loop.connect(_after_tick_loop)
NetworkRollback.before_loop.connect(_before_rollback_loop)
NetworkRollback.on_process_tick.connect(_on_rollback_tick)
NetworkRollback.after_loop.connect(_after_rollback_loop)
func _before_tick_loop() -> void:
_network_loop_start = _time()
_network_ticks_accum = 0
func _on_network_tick(_dt, _t) -> void:
_network_ticks_accum += 1
func _after_tick_loop() -> void:
_network_loop_duration = _time() - _network_loop_start
_network_ticks = _network_ticks_accum
_full_state_props = _full_state_props_accum
_full_state_props_accum = 0
_sent_state_props = _sent_state_props_accum
_sent_state_props_accum = 0
func _before_rollback_loop() -> void:
_rollback_loop_start = _time()
_rollback_ticks_accum = 0
_rollback_nodes_simulated_accum = 0
func _on_rollback_tick(_t: int) -> void:
_rollback_ticks_accum += 1
func _after_rollback_loop() -> void:
_rollback_loop_duration = _time() - _rollback_loop_start
_rollback_ticks = _rollback_ticks_accum
_rollback_nodes_simulated = _rollback_nodes_simulated_accum
func _time() -> float:
return Time.get_unix_time_from_system()
+1
View File
@@ -0,0 +1 @@
uid://cqgf7aqmjfoa3
+276
View File
@@ -0,0 +1,276 @@
extends Node
class_name _NetworkTimeSynchronizer
## Continuously synchronizes time to the host's remote clock.
##
## Make sure to read the [i]NetworkTimeSynchronizer Guide[/i] to understand the
## different clocks that the class docs refer to.
##
## @tutorial(NetworkTimeSynchronizer Guide): https://foxssake.github.io/netfox/netfox/guides/network-time-synchronizer/
## The minimum time in seconds between two sync samples.
##
## See [member sync_interval]
const MIN_SYNC_INTERVAL := 0.1
## Time between sync samples, in seconds.
## Cannot be less than [member MIN_SYNC_INTERVAL]
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var sync_interval: float:
get:
return maxf(
_sync_interval,
MIN_SYNC_INTERVAL
)
set(v):
push_error("Trying to set read-only variable sync_interval")
## Number of measurements ( samples ) to use for time synchronization.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var sync_samples: int:
get:
return _sync_samples
set(v):
push_error("Trying to set read-only variable sync_samples")
## Number of iterations to nudge towards the host's remote clock.
##
## Lower values result in more aggressive changes in clock and may be more
## sensitive to jitter. Larger values may end up approaching the remote clock
## too slowly.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var adjust_steps: int:
get:
return _adjust_steps
set(v):
push_error("Trying to set read-only variable adjust_steps")
## Largest tolerated offset from the host's remote clock before panicking.
##
## Once this threshold is reached, the clock will be reset to the remote clock's
## value, and the nudge process will start from scratch.
## [br][br]
## [i]read-only[/i], you can change this in the Netfox project settings
var panic_threshold: float:
get:
return _panic_threshold
set(v):
push_error("Trying to set read-only variable panic_threshold")
## Measured roundtrip time measured to the host.
##
## This value is calculated from multiple samples. The actual roundtrip times
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
## [br][br]
## [i]read-only[/i]
var rtt: float:
get:
return _rtt
set(v):
push_error("Trying to set read-only variable rtt")
## Measured jitter in the roundtrip time to the host remote.
##
## This value is calculated from multiple samples. The actual roundtrip times
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
## [br][br]
## [i]read-only[/i]
var rtt_jitter: float:
get:
return _rtt_jitter
set(v):
push_error("Trying to set read-only variable rtt_jitter")
## Estimated offset from the host's remote clock.
##
## Positive values mean that the host's remote clock is ahead of ours, while
## negative values mean that our clock is behind the host's remote.
## [br][br]
## [i]read-only[/i]
var remote_offset: float:
get:
return _offset
set(v):
push_error("Trying to set read-only variable remote_offset")
# Settings
var _sync_interval: float = ProjectSettings.get_setting(&"netfox/time/sync_interval", 0.25)
var _sync_samples: int = ProjectSettings.get_setting(&"netfox/time/sync_samples", 8)
var _adjust_steps: int =ProjectSettings.get_setting(&"netfox/time/sync_adjust_steps", 8)
var _panic_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 2.)
var _active: bool = false
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTimeSynchronizer")
# Samples are stored in a ring buffer
var _sample_buffer: _RingBuffer
var _sample_idx: int = 0
var _awaiting_samples: Dictionary = {}
var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new()
var _offset: float = 0.
var _rtt: float = 0.
var _rtt_jitter: float = 0.
## Emitted after the initial time sync.
##
## At the start of the game, clients request an initial timestamp to kickstart
## their time sync loop. This event is emitted once that initial timestamp is
## received.
signal on_initial_sync()
## Emitted when clocks get overly out of sync and a time sync panic occurs.
##
## Panic means that the difference between clocks is too large. The time sync
## will reset the clock to the remote clock's time and restart the time sync loop
## from there.
## [br][br]
## Use this event in case you need to react to clock changes in your game.
signal on_panic(offset: float)
## Start the time synchronization loop.
##
## Starting multiple times has no effect.
func start() -> void:
if _active:
return
_clock.set_time(0.)
if not multiplayer.is_server():
_active = true
_sample_idx = 0
_sample_buffer = _RingBuffer.new(sync_samples)
_request_timestamp.rpc_id(1)
## Stop the time synchronization loop.
func stop() -> void:
_active = false
## Get the current time from the reference clock.
##
## Returns a timestamp in seconds, with a fractional part for extra precision.
func get_time() -> float:
return _clock.get_time()
func _loop() -> void:
_logger.info("Time sync loop started! Initial timestamp: %ss", [_clock.get_time()])
on_initial_sync.emit()
while _active:
if multiplayer.is_server():
return stop()
var sample := NetworkClockSample.new()
_awaiting_samples[_sample_idx] = sample
sample.ping_sent = _clock.get_time()
_send_ping.rpc_id(1, _sample_idx)
_sample_idx += 1
await get_tree().create_timer(sync_interval).timeout
func _discipline_clock() -> void:
var sorted_samples := _sample_buffer.get_data()
if sorted_samples.is_empty():
# Should never happen
_logger.warning("Trying to discipline the clock with no samples available!")
return
# Sort samples by latency
sorted_samples.sort_custom(
func(a: NetworkClockSample, b: NetworkClockSample):
return a.get_rtt() < b.get_rtt()
)
_logger.trace("Using sorted samples: \n%s", [
"\n".join(sorted_samples.map(func(it: NetworkClockSample): return "\t" + it.to_string() + " (%.4fs)" % [get_time() - it.ping_sent]))
])
# Calculate rtt bounds
var rtt_min := sorted_samples.front().get_rtt() as float
var rtt_max := sorted_samples.back().get_rtt() as float
_rtt = (rtt_max + rtt_min) / 2.
_rtt_jitter = (rtt_max - rtt_min) / 2.
# Calculate offset
var offset := 0.
var offsets := sorted_samples.map(func(it): return it.get_offset())
var offset_weight := 0.
for i in range(offsets.size()):
var w = log(1 + sorted_samples[i].get_rtt())
offset += offsets[i] * w
offset_weight += w
if not is_zero_approx(offset_weight):
offset /= offset_weight
else:
# RTT is so good it's basically zero, which means offset_weight is zero
# Use a simple average instead
offset /= sorted_samples.size()
# Panic / Adjust
if abs(offset) > panic_threshold:
# Reset clock, throw away all samples
_clock.adjust(offset)
_sample_buffer.clear()
# Also drop in-flight samples
_awaiting_samples.clear()
_offset = 0.
_logger.warning("Offset %ss is above panic threshold %ss! Resetting clock", [offset, panic_threshold])
on_panic.emit(offset)
else:
# Nudge clock towards estimated time
var nudge := offset / adjust_steps
_clock.adjust(nudge)
_logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
_offset = offset - nudge
@rpc("any_peer", "call_remote", "unreliable")
func _send_ping(idx: int) -> void:
var ping_received := _clock.get_time()
var sender := multiplayer.get_remote_sender_id()
_send_pong.rpc_id(sender, idx, ping_received, _clock.get_time())
@rpc("any_peer", "call_remote", "unreliable")
func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
var pong_received := _clock.get_time()
if not _awaiting_samples.has(idx):
# Sample was dropped mid-flight during a panic episode
return
var sample := _awaiting_samples[idx] as NetworkClockSample
sample.ping_received = ping_received
sample.pong_sent = pong_sent
sample.pong_received = pong_received
_logger.trace("Received sample: %s", [sample])
# Once a sample is done, remove from in-flight samples and move to sample buffer
_awaiting_samples.erase(idx)
_sample_buffer.push(sample)
# Discipline clock based on new sample
_discipline_clock()
@rpc("any_peer", "call_remote", "reliable")
func _request_timestamp() -> void:
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time())
@rpc("any_peer", "call_remote", "reliable")
func _set_timestamp(timestamp: float) -> void:
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_clock.set_time(timestamp)
_loop()
@@ -0,0 +1 @@
uid://5rmn2ik3kjo8
+601
View File
@@ -0,0 +1,601 @@
extends Node
class_name _NetworkTime
## This class handles timing.
##
## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-time/
## Number of ticks per second.
##
## [i]read-only[/i], you can change this in the project settings
var tickrate: int:
get:
if sync_to_physics:
return Engine.physics_ticks_per_second
else:
return _tickrate
set(v):
push_error("Trying to set read-only variable tickrate")
## Whether to sync the network ticks to physics updates.
##
## When set to true, tickrate will be the same as the physics ticks per second,
## and the network tick loop will be run inside the physics update process.
##
## [i]read-only[/i], you can change this in the project settings
var sync_to_physics: bool:
get:
return _sync_to_physics
set(v):
push_error("Trying to set read-only variable sync_to_physics")
## Maximum number of ticks to simulate per frame.
##
## If the game itself runs slower than the configured tickrate, multiple ticks
## will be run in a single go. However, to avoid an endless feedback loop of
## running too many ticks in a frame, which makes the game even slower, which
## results in even more ticks and so on, this setting is an upper limit on how
## many ticks can be simulated in a single go.
##
## [i]read-only[/i], you can change this in the project settings
var max_ticks_per_frame: int:
get:
return _max_ticks_per_frame
set(v):
push_error("Trying to set read-only variable max_ticks_per_frame")
## Current network time in seconds.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var time: float:
get:
return float(_tick) / tickrate
set(v):
push_error("Trying to set read-only variable time")
## Current network time in ticks.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var tick: int:
get:
return _tick
set(v):
push_error("Trying to set read-only variable tick")
## Threshold before recalibrating [member tick] and [member time].
##
## Time is continuously synced to the server. In case the time difference is
## excessive between local and the server, both [code]tick[/code] and
## [code]time[/code] will be reset to the estimated server values.
## [br][br]
## This property determines the difference threshold in seconds for
## recalibration.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
## [br][br]
## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead.
var recalibrate_threshold: float:
get:
return _recalibrate_threshold
set(v):
push_error("Trying to set read-only variable recalibrate_threshold")
## Seconds required to pass before considering the game stalled.
##
## If the game becomes unresponsive for some time - e.g. it becomes minimized,
## unfocused, or freezes -, the game time needs to be readjusted. These stalls
## are detected by checking how much time passes between frames. If it's more
## than this threshold, it's considered a stall, and will be compensated
## against.
var stall_threshold: float:
get:
return _stall_threshold
set(v):
push_error("Trying to set read-only variable stall_threshold")
## Current network time in ticks on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var remote_tick: int:
get:
return tick
set(v):
push_error("Trying to set read-only variable remote_tick")
## Current network time in seconds on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var remote_time: float:
get:
return time
set(v):
push_error("Trying to set read-only variable remote_time")
## Estimated roundtrip time to server.
##
## This value is updated regularly, during server time sync. Latency can be
## estimated as half of the roundtrip time. Returns the same as [member
## _NetworkTimeSynchronizer.rtt].
## [br][br]
## Will always be 0 on servers.
## [br][br]
## [i]read-only[/i]
var remote_rtt: float:
get:
return NetworkTimeSynchronizer.rtt
set(v):
push_error("Trying to set read-only variable remote_rtt")
## Current network time in ticks.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the number of ticks since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var local_tick: int:
get:
return tick
set(v):
push_error("Trying to set read-only variable local_tick")
## Current network time in seconds.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the seconds elapsed since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var local_time: float:
get:
return time
set(v):
push_error("Trying to set read-only variable local_time")
## Amount of time a single tick takes, in seconds.
##
## This is the reverse of tickrate
##
## [i]read-only[/i]
var ticktime: float:
get:
return 1.0 / tickrate
set(v):
push_error("Trying to set read-only variable ticktime")
## Percentage of where we are in time for the current tick.
##
## 0.0 - the current tick just happened[br]
## 0.5 - we're halfway to the next tick[br]
## 1.0 - the next tick is right about to be simulated[br]
##
## [i]read-only[/i]
var tick_factor: float:
get:
if not sync_to_physics:
return 1.0 - clampf((_next_tick_time - _last_process_time) * tickrate, 0, 1)
else:
return Engine.get_physics_interpolation_fraction()
set(v):
push_error("Trying to set read-only variable tick_factor")
## Multiplier to get from physics process speeds to tick speeds.
##
## Some methods, like CharacterBody's move_and_slide take velocity in units/sec
## and figure out the time delta on their own. However, they are not aware of
## netfox's time, so motion is all wrong in a network tick. For example, the
## network ticks run at 30 fps, while the game is running at 60fps, thus
## move_and_slide will also assume that it's running on 60fps, resulting in
## slower than expected movement.
##
## To circument this, you can multiply any velocities with this variable, and
## get the desired speed. Don't forget to then divide by this value if it's a
## persistent variable ( e.g. CharacterBody's velocity ).
##
## NOTE: This works correctly both in regular and in physics frames, but may
## yield different values.
##
## [i]read-only[/i]
var physics_factor: float:
get:
if Engine.is_in_physics_frame():
return Engine.physics_ticks_per_second / tickrate
else:
return ticktime / _process_delta
set(v):
push_error("Trying to set read-only variable physics_factor")
## The maximum clock stretch factor allowed.
##
## For more context on clock stretch, see [member clock_stretch_factor]. The
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
## Setting this to larger values will allow for quicker clock adjustment at the
## cost of bigger deviations in game speed.
## [br][br]
## Make sure to adjust this value based on the game's needs.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var clock_stretch_max: float:
get:
return _clock_stretch_max
set(v):
push_error("Trying to set read-only variable stretch_max")
## Suppress warning when calling [member start] with an [OfflineMultiplayerPeer]
## active.
var suppress_offline_peer_warning: bool:
get:
return _suppress_offline_peer_warning
set(v):
push_error("Trying to set read-only variable suppress_offline_peer_warning")
## The currently used clock stretch factor.
##
## As the game progresses, the simulation clock may be ahead of, or behind the
## host's remote clock. To compensate, whenever the simulation clock is ahead of
## the remote clock, the game will slightly slow down, to allow the remote clock
## to catch up. When the remote clock is ahead of the simulation clock, the game
## will run slightly faster to catch up with the remote clock.
## [br][br]
## This value indicates the current clock speed multiplier. Values over 1.0
## indicate speeding up, under 1.0 indicate slowing down.
## [br][br]
## See [member clock_stretch_max] for clock stretch bounds.[br]
## See [_NetworkTimeSynchronizer] for more on the reference- and simulation
## clock.
## [br][br]
## [i]read-only[/i]
var clock_stretch_factor: float:
get:
return _clock_stretch_factor
## The current estimated offset between the reference clock and the simulation
## clock.
##
## Positive values mean the simulation clock is behind, and needs to run
## slightly faster to catch up. Negative values mean the simulation clock is
## ahead, and needs to slow down slightly.
## [br][br]
## See [member clock_stretch] for more clock speed adjustment.
## [br][br]
## [i]read-only[/i]
var clock_offset: float:
get:
# Offset is synced time - local time
return NetworkTimeSynchronizer.get_time() - _clock.get_time()
## The current estimated offset between the reference clock and the remote
## clock.
##
## Positive values mean the reference clock is behind the remote clock.
## Negative values mean the reference clock is ahead of the remote clock.
## [br][br]
## Returns the same as [member _NetworkTimeSynchronizer.remote_offset].
## [br][br]
## [i]read-only[/i]
var remote_clock_offset: float:
get:
return NetworkTimeSynchronizer.remote_offset
## Emitted before a tick loop is run.
signal before_tick_loop()
## Emitted before a tick is run.
signal before_tick(delta: float, tick: int)
## Emitted for every network tick.
signal on_tick(delta: float, tick: int)
## Emitted after every network tick.
signal after_tick(delta: float, tick: int)
## Emitted after the tick loop is run.
signal after_tick_loop()
## Emitted after time is synchronized.
##
## This happens once the NetworkTime is started, and the first time sync process
## concludes. When running as server, this is emitted instantly after started.
signal after_sync()
## Emitted after a client synchronizes their time.
##
## This is only emitted on the server, and is emitted when the client concludes
## their time sync process. This is useful as this event means that the client
## is ticking and gameplay has started on their end.
signal after_client_sync(peer_id: int)
## Emitted when a tickrate mismatch is encountered, and
## [member NetworkTickrateHandshake.mismatch_action] is set to
## [constant NetworkTickrateHandshake.SIGNAL].
signal on_tickrate_mismatch(peer: int, tickrate: int)
# NetworkTime states
const _STATE_INACTIVE := 0
const _STATE_SYNCING := 1
const _STATE_ACTIVE := 2
# Settings
var _tickrate: int = ProjectSettings.get_setting(&"netfox/time/tickrate", 30)
var _sync_to_physics: bool = ProjectSettings.get_setting(&"netfox/time/sync_to_physics", false)
var _max_ticks_per_frame: int = ProjectSettings.get_setting(&"netfox/time/max_ticks_per_frame", 8)
var _recalibrate_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 8.0)
var _stall_threshold: float = ProjectSettings.get_setting(&"netfox/time/stall_threshold", 1.0)
var _clock_stretch_max: float = ProjectSettings.get_setting(&"netfox/time/max_time_stretch", 1.25)
var _suppress_offline_peer_warning: bool = ProjectSettings.get_setting(&"netfox/time/suppress_offline_peer_warning", false)
var _state: int = _STATE_INACTIVE
# Timing
var _tick: int = 0
var _was_paused: bool = false
var _initial_sync_done = false
var _process_delta: float = 0
var _next_tick_time: float = 0
var _last_process_time: float = 0.
var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new()
var _clock_stretch_factor: float = 1.
var _tickrate_handshake: NetworkTickrateHandshake
var _synced_peers: Dictionary = {}
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTime")
## Start NetworkTime.
##
## Once this is called, time will be synchronized and ticks will be consistently
## emitted.
## [br][br]
## On clients, the initial time sync must complete before any ticks are emitted.
## [br][br]
## To check if this initial sync is done, see [method is_initial_sync_done]. If
## you need a signal, see [signal after_sync].
## [br][br]
## Returns [constant OK] on success.[br]
## Returns [constant ERR_ALREADY_IN_USE] if the tick loop is currently active.[br]
## Returns [constant ERR_UNAVAILABLE] if there's no available [MultiplayerPeer].
func start() -> int:
# Check if time loop can be started
if _state != _STATE_INACTIVE:
_logger.warning(
"Multiple calls to NetworkTime.start()! " +
"Are you manually calling *and* have NetworkEvents enabled?")
return ERR_ALREADY_IN_USE
if not multiplayer.has_multiplayer_peer():
_logger.error("Starting time loop without a multiplayer peer!")
return ERR_UNAVAILABLE
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer and not suppress_offline_peer_warning:
_logger.warning("Starting time loop with an offline peer! " +
"If this is intended, suppress this warning in the project settings, " +
"under netfox/Time/Suppress Offline Peer Warning.")
# Reset state
_tick = 0
_initial_sync_done = false
# Host is always synced, as their time is considered ground truth
_synced_peers[1] = true
# Start sync
NetworkTimeSynchronizer.start()
_state = _STATE_SYNCING
if not multiplayer.is_server():
await NetworkTimeSynchronizer.on_initial_sync
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
_initial_sync_done = true
_state = _STATE_ACTIVE
_submit_sync_success.rpc()
else:
_state = _STATE_ACTIVE
_initial_sync_done = true
# Remove clients from the synced cache when disconnected
multiplayer.peer_disconnected.connect(_handle_peer_disconnect)
# Set initial clock state
_clock.set_time(NetworkTimeSynchronizer.get_time())
_last_process_time = _clock.get_time()
_next_tick_time = _clock.get_time()
after_sync.emit()
# Handle tickrate handshake
_tickrate_handshake.run()
return OK
## Stop NetworkTime.
##
## This will stop the time sync in the background, and no more ticks will be
## emitted until the next start.
func stop() -> void:
NetworkTimeSynchronizer.stop()
_tickrate_handshake.stop()
_state = _STATE_INACTIVE
_synced_peers.clear()
if multiplayer.peer_disconnected.is_connected(_handle_peer_disconnect):
multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)
## Check if the initial time sync is done.
func is_initial_sync_done() -> bool:
return _initial_sync_done
## Check if client's time sync is complete.
##
## Using this from a client is considered an error.
func is_client_synced(peer_id: int) -> bool:
return _synced_peers.has(peer_id)
## Convert a duration of ticks to seconds.
func ticks_to_seconds(ticks: int) -> float:
return ticks * ticktime
## Convert a duration of seconds to ticks.
func seconds_to_ticks(seconds: float) -> int:
return int(seconds * tickrate)
## Calculate the duration between two ticks in seconds
##
## [i]Note:[/i] Returns negative values if tick_to is smaller than tick_from
func seconds_between(tick_from: int, tick_to: int) -> float:
return ticks_to_seconds(tick_to - tick_from)
## Calculate the duration between two points in time as ticks
##
## [i]Note:[/i] Returns negative values if seconds_to is smaller than seconds_from
func ticks_between(seconds_from: float, seconds_to: float) -> int:
return seconds_to_ticks(seconds_to - seconds_from)
func _ready() -> void:
NetfoxLogger.register_tag(_get_tick_tag, -100)
_tickrate_handshake = NetworkTickrateHandshake.new()
add_child(_tickrate_handshake)
# Proxy tickrate mismatch event
_tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate):
on_tickrate_mismatch.emit(peer, tickrate)
)
func _exit_tree() -> void:
NetfoxLogger.free_tag(_get_tick_tag)
func _get_tick_tag() -> String:
return "@%d" % tick
func _loop() -> void:
# Adjust local clock
_clock.step(_clock_stretch_factor)
var clock_diff := NetworkTimeSynchronizer.get_time() - _clock.get_time()
# Ignore diffs under 1ms
clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.)
var clock_stretch_min := 1. / clock_stretch_max
# var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2.
var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff)
clock_stretch_f = clampf(clock_stretch_f, 0., 1.)
var previous_stretch_factor := _clock_stretch_factor
_clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f)
# Detect editor pause
var clock_step := _clock.get_time() - _last_process_time
var clock_step_raw := clock_step / previous_stretch_factor
if clock_step_raw > stall_threshold:
# Game stalled for a while, probably paused, don't run extra ticks
# to catch up
_was_paused = true
_logger.debug("Game stalled for %.4fs, assuming it was a pause", [clock_step_raw])
# Handle pause
if _was_paused:
_was_paused = false
_next_tick_time += clock_step
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
# Run tick loop if needed
var ticks_in_loop := 0
_last_process_time = _clock.get_time()
while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame:
if ticks_in_loop == 0:
before_tick_loop.emit()
before_tick.emit(ticktime, tick)
on_tick.emit(ticktime, tick)
after_tick.emit(ticktime, tick)
_tick += 1
ticks_in_loop += 1
_next_tick_time += ticktime
if ticks_in_loop > 0:
after_tick_loop.emit()
func _process(delta: float) -> void:
_process_delta = delta
if _is_active() and not sync_to_physics:
_loop()
func _physics_process(delta: float) -> void:
if _is_active() and sync_to_physics:
_loop()
func _notification(what) -> void:
if what == NOTIFICATION_UNPAUSED:
_was_paused = true
func _is_active() -> bool:
return _state == _STATE_ACTIVE
func _handle_peer_disconnect(peer: int) -> void:
_synced_peers.erase(peer)
@rpc("any_peer", "reliable", "call_local")
func _submit_sync_success() -> void:
var peer_id := multiplayer.get_remote_sender_id()
_logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()])
if not _synced_peers.has(peer_id):
_synced_peers[peer_id] = true
after_client_sync.emit(peer_id)
_logger.debug("Peer #%s is now on time!", [peer_id])
+1
View File
@@ -0,0 +1 @@
uid://cnqdxyjptkfvp
+189
View File
@@ -0,0 +1,189 @@
extends Node
class_name PeerVisibilityFilter
## Tracks visibility for multiplayer peers
##
## Similar in how [MultiplayerSynchronizer] handles visibility. It decides peer
## visibility based on individual overrides and filters.
## [br][br]
## By default, each peer's visibility is determined by
## [member default_visibility]. [br][br]
## The default visibility can be overridden for individual peers using
## [method set_visibility_for] and [method unset_visibility_for].
## [br][br]
## Individual overrides can still be rejected by [i]filters[/i], which are
## callables that can dynamically determine the visibility for each peer. If any
## of the registered filters return [code]false[/code], the peer will not be
## visible. Filters can be managed using [member add_visibility_filter] and
## [member remove_visibility_filter].
## [br][br]
## To avoid taking up too much CPU time, visibilities are only recalculated on
## a peer join or peer leave event by default. This can be changed by setting
## [member update_mode]. Visibilities can also be manually updated using
## [member update_visibility].
## Contains different options for when to automatically update visibility
enum UpdateMode {
NEVER, ## Only update visibility when manually triggered
ON_PEER, ## Update visibility when a peer joins or leaves
PER_TICK_LOOP, ## Update visibility before each tick loop
PER_TICK, ## Update visibility before each network tick
PER_ROLLBACK_TICK ## Update visibility [b]after[/b] each rollback tick
}
## Make all peers visible by default if true
var default_visibility: bool = true
## Sets whether and when automatic visibility updates should happen
var update_mode: UpdateMode = UpdateMode.ON_PEER:
get = get_update_mode, set = set_update_mode
var _visibility_filters: Array[Callable] = []
var _visibility_overrides: Dictionary = {}
var _update_mode: UpdateMode = UpdateMode.ON_PEER
var _visible_peers: Array[int] = []
var _rpc_target_peers: Array[int] = []
## Register a visibility filter
## [br][br]
## The [param filter] must take a single [code]peer_id[/code] parameter, and
## return true if the given peer should be visible. The same [param filter]
## won't be added multiple times.
func add_visibility_filter(filter: Callable) -> void:
if not _visibility_filters.has(filter):
_visibility_filters.append(filter)
## Remove a visibility filter
## [br][br]
## If the visibility filter wasn't already registered, nothing happens.
func remove_visibility_filter(filter: Callable) -> void:
_visibility_filters.erase(filter)
## Remove all previously registered visibility filters
func clear_visibility_filters() -> void:
_visibility_filters.clear()
## Return true if the peer is visible
## [br][br]
## This method always reevaluates visibility.
func get_visibility_for(peer: int) -> bool:
for filter in _visibility_filters:
if not filter.call(peer):
return false
return _visibility_overrides.get(peer, default_visibility)
## Set visibility override for a given [param peer]
func set_visibility_for(peer: int, visibility: bool) -> void:
if peer == 0:
default_visibility = visibility
else:
_visibility_overrides[peer] = visibility
## Remove visibility override for a given [param peer]
## [br][br]
## If the [param peer] had no override previously, nothing happens.
func unset_visibility_for(peer: int) -> void:
_visibility_overrides.erase(peer)
## Recalculate visibility for each known peer
func update_visibility(peers: PackedInt32Array = multiplayer.get_peers()) -> void:
# Find visible peers
_visible_peers.clear()
for peer in peers:
if get_visibility_for(peer):
_visible_peers.append(peer)
# Decide how many RPC calls are needed to cover visible peers
if _visible_peers.size() == peers.size():
# Everyone is visible -> broadcast
_rpc_target_peers = [MultiplayerPeer.TARGET_PEER_BROADCAST]
elif _visible_peers.size() == peers.size() - 1:
# Only a single peer is missing, exclude that
for peer in peers:
if not _visible_peers.has(peer):
_rpc_target_peers = [-peer]
break
else:
# Custom list, can't optimize RPC call count
_rpc_target_peers = _visible_peers
# Don't include self in RPC target list
if multiplayer:
_rpc_target_peers.erase(multiplayer.get_unique_id())
## Return a list of visible peers
## [br][br]
## This list is only recalculated when [method update_visibility] runs, either
## by calling it manually, or via [member update_mode].
func get_visible_peers() -> Array[int]:
return _visible_peers
## Return a list of visible peers for use with RPCs
## [br][br]
## In contrast to [method get_visible_peers], this method will utilize Godot's
## RPC target peer rules to produce a shorter list if possible. For example, if
## all peers are visible, it will simply return [code][0][/code], indicating
## a broadcast.
## [br][br]
## This list will never explicitly include the local peer.
func get_rpc_target_peers() -> Array[int]:
return _rpc_target_peers
## Set update mode
func set_update_mode(mode: UpdateMode) -> void:
_disconnect_update_handlers(_update_mode)
_connect_update_handlers(mode)
_update_mode = mode
## Return the update mode
func get_update_mode() -> UpdateMode:
return _update_mode
func _enter_tree():
_connect_update_handlers(update_mode)
if multiplayer:
update_visibility()
func _exit_tree():
_disconnect_update_handlers(update_mode)
func _disconnect_update_handlers(mode: UpdateMode) -> void:
match mode:
UpdateMode.NEVER: pass
UpdateMode.ON_PEER:
multiplayer.peer_connected.disconnect(_handle_peer_connect)
multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)
UpdateMode.PER_TICK_LOOP:
NetworkTime.before_tick_loop.disconnect(update_visibility)
UpdateMode.PER_TICK:
NetworkTime.before_tick.disconnect(_handle_tick)
UpdateMode.PER_ROLLBACK_TICK:
NetworkRollback.after_process_tick.disconnect(_handle_rollback_tick)
_:
assert(false, "Unhandled update mode! %d" % [update_mode])
func _connect_update_handlers(mode: UpdateMode) -> void:
match mode:
UpdateMode.NEVER: pass
UpdateMode.ON_PEER:
multiplayer.peer_connected.connect(_handle_peer_connect)
multiplayer.peer_disconnected.connect(_handle_peer_disconnect)
UpdateMode.PER_TICK_LOOP:
NetworkTime.before_tick_loop.connect(update_visibility)
UpdateMode.PER_TICK:
NetworkTime.before_tick.connect(_handle_tick)
UpdateMode.PER_ROLLBACK_TICK:
NetworkRollback.after_process_tick.connect(_handle_rollback_tick)
_:
assert(false, "Unhandled update mode! %d" % [update_mode])
func _handle_peer_connect(__) -> void:
update_visibility()
func _handle_peer_disconnect(__) -> void:
update_visibility()
func _handle_tick(_dt, _t) -> void:
update_visibility()
func _handle_rollback_tick(__) -> void:
update_visibility()
@@ -0,0 +1 @@
uid://cp3pkerfb0mxk
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="netfox"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.35.3"
script="netfox.gd"
@@ -0,0 +1,28 @@
extends RefCounted
class_name PropertyCache
var root: Node
var _cache: Dictionary = {}
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("PropertyCache")
func _init(p_root: Node):
root = p_root
func get_entry(path: String) -> PropertyEntry:
if not _cache.has(path):
var parsed = PropertyEntry.parse(root, path)
if not parsed.is_valid():
_logger.warning("Invalid property path: %s", [path])
_cache[path] = parsed
return _cache[path]
func properties() -> Array:
var result: Array[PropertyEntry]
# Can be slow, but no other way to do this with type-safety
# See: https://github.com/godotengine/godot/issues/72627
result.assign(_cache.values())
return result
func clear():
_cache.clear()
@@ -0,0 +1 @@
uid://cjb41satp02xy
@@ -0,0 +1,36 @@
extends RefCounted
class_name _PropertyConfig
var _properties: Array[PropertyEntry] = []
var _auth_properties: Dictionary = {} # Peer (int) to owned properties (Array[PropertyEntry])
var local_peer_id: int
func clear() -> void:
_properties.clear()
_auth_properties.clear()
func set_properties(p_properties: Array[PropertyEntry]) -> void:
clear()
_properties.assign(p_properties)
func set_properties_from_paths(property_paths: Array[String], property_cache: PropertyCache) -> void:
clear()
for path in property_paths:
_properties.append(property_cache.get_entry(path))
func get_properties() -> Array[PropertyEntry]:
return _properties
func get_owned_properties() -> Array[PropertyEntry]:
return get_properties_owned_by(local_peer_id)
func get_properties_owned_by(peer: int) -> Array[PropertyEntry]:
if not _auth_properties.has(peer):
var owned_properties: Array[PropertyEntry] = []
for property_entry in _properties:
if property_entry.node.get_multiplayer_authority() == peer:
owned_properties.append(property_entry)
_auth_properties[peer] = owned_properties
return _auth_properties[peer]
@@ -0,0 +1 @@
uid://b58ojksbbkrvh
@@ -0,0 +1,51 @@
extends RefCounted
class_name PropertyEntry
var _path: String
var node: Node
var property: String
static var _logger := NetfoxLogger._for_netfox("PropertyEntry")
func get_value() -> Variant:
return node.get_indexed(property)
func set_value(value):
node.set_indexed(property, value)
func is_valid() -> bool:
if not node or not is_instance_valid(node):
# Node is invalid
return false
# Return true if node has given property
return node.get_property_list()\
.any(func(it): return it["name"] == property)
func _to_string() -> String:
return _path
static func parse(root: Node, path: String) -> PropertyEntry:
var result = PropertyEntry.new()
result.node = root.get_node(NodePath(path))
result.property = path.erase(0, path.find(":") + 1)
result._path = path
return result
static func make_path(root: Node, node: Variant, property: String) -> String:
var node_path := ""
if node is String:
node_path = node
elif node is NodePath:
node_path = str(node)
elif node is Node:
node_path = str(root.get_path_to(node))
else:
_logger.error("Can't stringify node reference: %s", [node])
return ""
if node_path == ".":
node_path = ""
return "%s:%s" % [node_path, property]
@@ -0,0 +1 @@
uid://cixo40ot0fqqv
@@ -0,0 +1,28 @@
extends _HistoryBuffer
class_name _PropertyHistoryBuffer
func get_snapshot(tick: int) -> _PropertySnapshot:
if _buffer.has(tick):
return _buffer[tick]
else:
return _PropertySnapshot.new()
func set_snapshot(tick: int, data) -> void:
if data is Dictionary:
var snapshot := _PropertySnapshot.from_dictionary(data)
super(tick, snapshot)
elif data is _PropertySnapshot:
super(tick, data)
else:
push_error("Data not a PropertSnapshot! %s" % [data])
func get_history(tick: int) -> _PropertySnapshot:
var snapshot = super(tick)
return snapshot if snapshot else _PropertySnapshot.new()
func trim(earliest_tick_to_keep: int = NetworkRollback.history_start) -> void:
super(earliest_tick_to_keep)
func merge(data: _PropertySnapshot, tick:int) -> void:
set_snapshot(tick, get_snapshot(tick).merge(data))
@@ -0,0 +1 @@
uid://dlkog3qntq03x
@@ -0,0 +1,86 @@
extends RefCounted
class_name _PropertySnapshot
# Maps property paths to their values
# Dictionary[String, Variant]
var _snapshot: Dictionary = {}
static var _logger := NetfoxLogger._for_netfox("PropertySnapshot")
func as_dictionary() -> Dictionary:
return _snapshot.duplicate()
static func from_dictionary(data: Dictionary) -> _PropertySnapshot:
return _PropertySnapshot.new(data)
func set_value(property_path: String, data: Variant) -> void:
_snapshot[property_path] = data
func get_value(property_path: String) -> Variant:
return _snapshot.get(property_path)
func properties() -> Array:
return _snapshot.keys()
func has(property_path: String) -> bool:
return _snapshot.has(property_path)
func size() -> int:
return _snapshot.size()
func equals(other: _PropertySnapshot):
return _snapshot == other._snapshot
func is_empty() -> bool:
return _snapshot.is_empty()
func apply(cache: PropertyCache) -> void:
for property_path in _snapshot:
var property_entry := cache.get_entry(property_path)
var value = _snapshot[property_path]
property_entry.set_value(value)
func merge(data: _PropertySnapshot) -> _PropertySnapshot:
var result := _snapshot.duplicate()
for key in data.as_dictionary():
result[key] = data._snapshot[key]
return _PropertySnapshot.from_dictionary(result)
func make_patch(data: _PropertySnapshot) -> _PropertySnapshot:
var result := {}
for property_path in data.properties():
var old_property = get_value(property_path)
var new_property = data.get_value(property_path)
if old_property != new_property:
result[property_path] = new_property
return _PropertySnapshot.from_dictionary(result)
func sanitize(sender: int, property_cache: PropertyCache) -> void:
var sanitized := {}
for property in _snapshot.keys():
var property_entry := property_cache.get_entry(property)
var authority := property_entry.node.get_multiplayer_authority()
if authority == sender:
sanitized[property] = _snapshot[property]
else:
_logger.warning(
"Received data for property %s, owned by %s, from sender %s",
[ property, authority, sender ]
)
_snapshot = sanitized
static func extract(properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result = {}
for property in properties:
result[property.to_string()] = property.get_value()
return _PropertySnapshot.from_dictionary(result)
func _init(p_snapshot: Dictionary = {}) -> void:
_snapshot = p_snapshot
@@ -0,0 +1 @@
uid://d2dgbafx6338f
+237
View File
@@ -0,0 +1,237 @@
extends Node
class_name RewindableAction
## Represents actions that may or may not happen, in a way compatible with
## rollback.
##
## @experimental: This class is experimental!
## @tutorial(RewindableAction Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/rewindable-action/
# Status enum
enum {
INACTIVE,
CONFIRMING,
ACTIVE,
CANCELLING
}
var _active_ticks: _Set = _Set.new()
var _last_set_tick: int = -1
# Maps the set of changed ticks ( int ) to their state ( active / inactive )
var _state_changes: Dictionary = {}
# Maps the set of ticks queued for change ( int ) to their state ( active / inactive )
var _queued_changes: Dictionary = {}
var _has_confirmed: bool = false
var _has_cancelled: bool = false
# Maps ticks ( int ) to context values ( any type )
var _context: Dictionary = {}
var _mutated_objects: _Set = _Set.new()
var _logger := NetfoxLogger._for_netfox("RewindableAction")
# Process:
# @0: Client sends an input with fire @0
# Client toggles action to true @0
# Client runs logic locally @0
# @1: Server receives input with fire @0
# Server toggles action to true @0
# Server broadcasts toggle @0
# @2: Client receives confirm @0
# Other clients receive toggle @0
# Other clients show effect @0
## Returns the [param status] enum as string
static func status_string(status: int) -> String:
match status:
INACTIVE: return "INACTIVE"
CONFIRMING: return "CONFIRMING"
ACTIVE: return "ACTIVE"
CANCELLING: return "CANCELLING"
_: return "?"
## Toggles the action for a given [param tick]
func set_active(active: bool, tick: int = NetworkRollback.tick) -> void:
_last_set_tick = tick
if is_active(tick) == active:
return
# Update local state
if active: _active_ticks.add(tick)
else: _active_ticks.erase(tick)
# Save changes for a single loop
_state_changes[tick] = active
## Check if the action is happening for the given [param tick]
func is_active(tick: int = NetworkRollback.tick) -> bool:
return _active_ticks.has(tick)
## Check the action's status for the given [param tick]
## [br][br]
## Returns [constant ACTIVE] if the action is happening.[br]
## Returns [constant INACTIVE] if the action is not happening.[br]
## Returns [constant CONFIRMING] if the action was previously known as not
## happening, but now it is.[br]
## Returns [constant CANCELLING] if the action was previously known to be
## happening, but now it is not.[br]
## [br]
## The [constant CONFIRMING] and [constant CANCELLING] statuses may occur if the
## action was just toggled, or data was received from the action's authority.
func get_status(tick: int = NetworkRollback.tick) -> int:
var currently_active := is_active(tick)
var state_change = _state_changes.get(tick)
var queued_change = _queued_changes.get(tick)
if queued_change != null:
return CONFIRMING if queued_change else CANCELLING
if state_change != null:
return CONFIRMING if state_change else CANCELLING
return ACTIVE if currently_active else INACTIVE
## Returns true if the action has been in [constant CONFIRMING] status during
## the last tick loop
func has_confirmed() -> bool:
return _has_confirmed
## Returns true if the action has been in [constant CANCELLING] status during
## the last tick loop
func has_cancelled() -> bool:
return _has_cancelled
## Get the action's current status as a string
## [br][br]
## See also: [member get_status]
func get_status_string(tick: int = NetworkRollback.tick) -> String:
return status_string(get_status(tick))
## Returns true if the action has any stored context for the given [param tick]
func has_context(tick: int = NetworkRollback.tick) -> bool:
return _context.has(tick)
## Get the context stored for the given [param tick], or null
func get_context(tick: int = NetworkRollback.tick) -> Variant:
return _context.get(tick)
## Store [param value] as the context for the given [param tick]
func set_context(value: Variant, tick: int = NetworkRollback.tick) -> void:
_context[tick] = value
## Erase the context for the given [param tick]
func erase_context(tick: int = NetworkRollback.tick) -> void:
_context.erase(tick)
## Whenever the action happens, mutate the [param target] object
## [br][br]
## See also: [method _NetworkRollback.mutate]
func mutate(target: Object) -> void:
_mutated_objects.add(target)
## Remove the [param target] object from the list of objects to [method mutate]
## [br][br]
## See also: [method _NetworkRollback.mutate]
func dont_mutate(target: Object) -> void:
_mutated_objects.erase(target)
func _connect_signals() -> void:
NetworkRollback.before_loop.connect(_before_rollback_loop)
NetworkRollback.after_loop.connect(_after_loop)
func _disconnect_signals() -> void:
NetworkRollback.before_loop.disconnect(_before_rollback_loop)
NetworkRollback.after_loop.disconnect(_after_loop)
func _enter_tree() -> void:
_connect_signals()
func _exit_tree() -> void:
_disconnect_signals()
func _notification(what: int) -> void:
match what:
NOTIFICATION_PATH_RENAMED, NOTIFICATION_ENTER_TREE:
# Update logger name
_logger.name = "RewindableAction:" + name
func _before_rollback_loop() -> void:
_last_set_tick = -1
if not _queued_changes.is_empty():
# Resimulate from earliest change
var earliest_change = _queued_changes.keys().min()
NetworkRollback.notify_resimulation_start(earliest_change)
_logger.trace("Submitted earliest tick %d from %s", [earliest_change, _queued_changes])
# Queue mutations
for mutated in _mutated_objects:
NetworkRollback.mutate(mutated, earliest_change)
# Apply queue
for tick in _queued_changes:
set_active(_queued_changes[tick], tick)
# Queue earliest event
if not _active_ticks.is_empty():
var earliest_active = _active_ticks.min()
for mutated in _mutated_objects:
NetworkRollback.mutate(mutated, earliest_active)
func _after_loop() -> void:
# Trim history
for tick in _active_ticks:
if tick < NetworkRollback.history_start:
_active_ticks.erase(tick)
for tick in _context.keys():
if tick < NetworkRollback.history_start:
_context.erase(tick)
# Update confirmed / cancelled flags
_has_confirmed =\
_state_changes.values().any(func(it): return it == true) or \
_queued_changes.values().any(func(it): return it == true)
_has_cancelled =\
_state_changes.values().any(func(it): return it == false) or \
_queued_changes.values().any(func(it): return it == false)
# Clear changes
_state_changes.clear()
_queued_changes.clear()
# Submit
if is_multiplayer_authority() and _last_set_tick >= 0:
var serialize_from := NetworkRollback.history_start
var serialize_to := _last_set_tick
if not _active_ticks.is_empty():
serialize_to = maxi(_active_ticks.max(), serialize_to)
var active_tick_bytes = _TicksetSerializer.serialize(serialize_from, serialize_to, _active_ticks)
_submit_state.rpc(active_tick_bytes)
@rpc("authority", "unreliable_ordered", "call_remote")
func _submit_state(bytes: PackedByteArray) -> void:
# Decode incoming data
var parsed := _TicksetSerializer.deserialize(bytes)
var history_start: int = parsed[0]
var last_known_tick: int = parsed[1]
var active_ticks: _Set = parsed[2]
# Find differences and queue changes
var earliest_tick := maxi(history_start, NetworkRollback.history_start)
# Don't compare past last event, as to not cancel events the host simply doesn't know about
var latest_tick = maxi(last_known_tick, NetworkRollback.history_start)
# Add a tolerance of 4 ticks for checking if the tickset is in the future
# Server time might be ahead a tick or two under really small latencies,
# e.g. LAN
if earliest_tick > NetworkTime.tick + 4 or latest_tick > NetworkTime.tick + 4:
_logger.debug("Received tickset for range @%d>%d, which has ticks in the future!", [earliest_tick, latest_tick])
for tick in range(earliest_tick, latest_tick + 1):
var is_tick_active = active_ticks.has(tick)
if is_tick_active != is_active(tick):
_queued_changes[tick] = is_tick_active
+1
View File
@@ -0,0 +1 @@
uid://chr4omg2hy3yu
@@ -0,0 +1,111 @@
extends RefCounted
class_name _RollbackHistoryRecorder
# Provided externally by RBS
var _state_history: _PropertyHistoryBuffer
var _input_history: _PropertyHistoryBuffer
var _state_property_config: _PropertyConfig
var _input_property_config: _PropertyConfig
var _property_cache: PropertyCache
var _latest_state_tick: int
var _skipset: _Set
func configure(
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
p_property_cache: PropertyCache,
p_skipset: _Set
) -> void:
_state_history = p_state_history
_input_history = p_input_history
_state_property_config = p_state_property_config
_input_property_config = p_input_property_config
_property_cache = p_property_cache
_skipset = p_skipset
func set_latest_state_tick(p_latest_state_tick: int) -> void:
_latest_state_tick = p_latest_state_tick
func apply_state(tick: int) -> void:
# Apply state for tick
var state = _state_history.get_history(tick)
state.apply(_property_cache)
func apply_display_state() -> void:
apply_state(NetworkRollback.display_tick)
func apply_tick(tick: int) -> void:
var state := _state_history.get_history(tick)
var input := _input_history.get_history(tick)
state.apply(_property_cache)
input.apply(_property_cache)
func trim_history() -> void:
# Trim history
_state_history.trim()
_input_history.trim()
func record_input(tick: int) -> void:
# Record input
if not _get_recorded_input_props().is_empty():
var input = _PropertySnapshot.extract(_get_recorded_input_props())
var input_tick: int = tick + NetworkRollback.input_delay
_input_history.set_snapshot(input_tick, input)
func record_state(tick: int) -> void:
# Record state for specified tick ( current + 1 )
# Check if any of the managed nodes were mutated
var is_mutated := _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1))
var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick))
if record_state.size():
var merge_state := _state_history.get_history(tick - 1)
_state_history.set_snapshot(tick, merge_state.merge(record_state))
func _should_record_tick(tick: int) -> bool:
if _get_recorded_state_props().is_empty():
# Don't record tick if there's no props to record
return false
if _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1)):
# If there's any node that was mutated, there's something to record
return true
# Otherwise, record only if we don't have authoritative state for the tick
return tick > _latest_state_tick
func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]:
if not _should_record_tick(tick):
return []
if _skipset.is_empty():
return _get_recorded_state_props()
return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick))
func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool:
if NetworkRollback.is_mutated(property_entry.node, tick - 1):
return true
if _skipset.has(property_entry.node):
return false
return true
# =============================================================================
# Shared utils, extract later
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -0,0 +1 @@
uid://bn6fsqxbfhihk
@@ -0,0 +1,282 @@
extends Node
class_name _RollbackHistoryTransmitter
var root: Node
var enable_input_broadcast: bool = true
var full_state_interval: int
var diff_ack_interval: int
# Provided externally by RBS
var _state_history: _PropertyHistoryBuffer
var _input_history: _PropertyHistoryBuffer
var _visibility_filter: PeerVisibilityFilter
var _state_property_config: _PropertyConfig
var _input_property_config: _PropertyConfig
var _property_cache: PropertyCache
var _skipset: _Set
# Collaborators
var _input_encoder: _RedundantHistoryEncoder
var _full_state_encoder: _SnapshotHistoryEncoder
var _diff_state_encoder: _DiffHistoryEncoder
# State
var _ackd_state: Dictionary = {}
var _next_full_state_tick: int
var _next_diff_ack_tick: int
var _earliest_input_tick: int
var _latest_state_tick: int
var _is_predicted_tick: bool
var _is_initialized: bool
# Signals
signal _on_transmit_state(state: Dictionary, tick: int)
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackHistoryTransmitter")
func get_earliest_input_tick() -> int:
return _earliest_input_tick
func get_latest_state_tick() -> int:
return _latest_state_tick
func set_predicted_tick(p_is_predicted_tick) -> void:
_is_predicted_tick = p_is_predicted_tick
func sync_settings(p_root: Node, p_enable_input_broadcast: bool, p_full_state_interval: int, p_diff_ack_interval: int) -> void:
root = p_root
enable_input_broadcast = p_enable_input_broadcast
full_state_interval = p_full_state_interval
diff_ack_interval = p_diff_ack_interval
func configure(
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
p_visibility_filter: PeerVisibilityFilter,
p_property_cache: PropertyCache,
p_skipset: _Set
) -> void:
_state_history = p_state_history
_input_history = p_input_history
_state_property_config = p_state_property_config
_input_property_config = p_input_property_config
_visibility_filter = p_visibility_filter
_property_cache = p_property_cache
_skipset = p_skipset
_input_encoder = _RedundantHistoryEncoder.new(_input_history, _property_cache)
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
_is_initialized = true
reset()
func reset() -> void:
_ackd_state.clear()
_latest_state_tick = NetworkTime.tick - 1
_earliest_input_tick = NetworkTime.tick
_next_full_state_tick = NetworkTime.tick
_next_diff_ack_tick = NetworkTime.tick
# Scatter full state sends, so not all nodes send at the same tick
if is_inside_tree():
_next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval)
else:
_next_full_state_tick += hash(root.name) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval)
_diff_state_encoder.add_properties(_state_property_config.get_properties())
_full_state_encoder.set_properties(_get_owned_state_props())
_input_encoder.set_properties(_get_owned_input_props())
func conclude_tick_loop() -> void:
_earliest_input_tick = NetworkTime.tick
func transmit_input(tick: int) -> void:
if not _get_owned_input_props().is_empty():
var input_tick: int = tick + NetworkRollback.input_delay
var input_data := _input_encoder.encode(input_tick, _get_owned_input_props())
var state_owning_peer := root.get_multiplayer_authority()
NetworkRollback.register_input_submission(root, tick)
if enable_input_broadcast:
for peer in _visibility_filter.get_rpc_target_peers():
_submit_input.rpc_id(peer, input_tick, input_data)
elif state_owning_peer != multiplayer.get_unique_id():
_submit_input.rpc_id(state_owning_peer, input_tick, input_data)
func transmit_state(tick: int) -> void:
if _get_owned_state_props().is_empty():
# We don't own state, don't transmit anything
return
if _is_predicted_tick and not _input_property_config.get_properties().is_empty():
# Don't transmit anything if we're predicting
# EXCEPT when we're running inputless
return
# Include properties we own
var full_state := _PropertySnapshot.new()
for property in _get_owned_state_props():
if _should_broadcast(property, tick):
full_state.set_value(property.to_string(), property.get_value())
_on_transmit_state.emit(full_state, tick)
# No properties to send?
if full_state.is_empty():
return
_latest_state_tick = max(_latest_state_tick, tick)
var is_sending_diffs := NetworkRollback.enable_diff_states
var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick)
if is_full_state_tick:
# Broadcast new full state
for peer in _visibility_filter.get_rpc_target_peers():
_send_full_state(tick, peer)
# Adjust next full state if sending diffs
if is_sending_diffs:
_next_full_state_tick = tick + full_state_interval
else:
# Send diffs to each peer
for peer in _visibility_filter.get_visible_peers():
var reference_tick := _ackd_state.get(peer, -1) as int
if reference_tick < 0 or not _state_history.has(reference_tick):
# Peer hasn't ack'd any tick, or we don't have the ack'd tick
# Send full state
_send_full_state(tick, peer)
continue
# Prepare diff
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _get_owned_state_props())
if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size():
# State is completely different, send full state
_send_full_state(tick, peer)
else:
# Send only diff
_submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick)
# Push metrics
NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot())
NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot())
func _should_broadcast(property: PropertyEntry, tick: int) -> bool:
# Only broadcast if we've simulated the node
# NOTE: _can_simulate checks mutations, but to override _skipset
# we check first
if NetworkRollback.is_mutated(property.node, tick - 1):
return true
if _skipset.has(property.node):
return false
if NetworkRollback.is_rollback_aware(property.node):
return NetworkRollback.is_simulated(property.node)
# Node is not rollback-aware, broadcast updates only if we own it
return property.node.is_multiplayer_authority()
func _send_full_state(tick: int, peer: int = 0) -> void:
var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary()
var full_state_data := _full_state_encoder.encode(tick, _get_owned_state_props())
_submit_full_state.rpc_id(peer, full_state_data, tick)
if peer <= 0:
NetworkPerformance.push_full_state_broadcast(full_state_snapshot)
NetworkPerformance.push_sent_state_broadcast(full_state_snapshot)
else:
NetworkPerformance.push_full_state(full_state_snapshot)
NetworkPerformance.push_sent_state(full_state_snapshot)
func _notification(what):
if what == NOTIFICATION_PREDELETE:
NetworkRollback.free_input_submission_data_for(root)
@rpc("any_peer", "unreliable", "call_remote")
func _submit_input(tick: int, data: Array) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender := multiplayer.get_remote_sender_id()
var snapshots := _input_encoder.decode(data, _input_property_config.get_properties_owned_by(sender))
var earliest_received_input = _input_encoder.apply(tick, snapshots, sender)
if earliest_received_input >= 0:
_earliest_input_tick = mini(_earliest_input_tick, earliest_received_input)
NetworkRollback.register_input_submission(root, tick)
# `serialized_state` is a serialized _PropertySnapshot
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_full_state(data: Array, tick: int) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender := multiplayer.get_remote_sender_id()
var snapshot := _full_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
if not _full_state_encoder.apply(tick, snapshot, sender):
# Invalid data
return
_latest_state_tick = tick
if NetworkRollback.enable_diff_states:
_ack_full_state.rpc_id(sender, tick)
# State is a serialized _PropertySnapshot (Dictionary[String, Variant])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender = multiplayer.get_remote_sender_id()
var diff_snapshot := _diff_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender):
# Invalid data
return
_latest_state_tick = tick
if NetworkRollback.enable_diff_states:
if diff_ack_interval > 0 and tick > _next_diff_ack_tick:
_ack_diff_state.rpc_id(sender, tick)
_next_diff_ack_tick = tick + diff_ack_interval
@rpc("any_peer", "reliable", "call_remote")
func _ack_full_state(tick: int) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _ack_diff_state(tick: int) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick])
# =============================================================================
# Shared utils, extract later
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -0,0 +1 @@
uid://ofadsxmvd3e3
+382
View File
@@ -0,0 +1,382 @@
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)
## Whether diff states are enabled.
## [br][br]
## Diff states send only the state properties that have changed.
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. The drawback is, with higher history
## limit comes more history data stored, thus higher memory usage.
## [br][br]
## Rollback won't go further than this limit, regardless of inputs received.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var history_limit: int:
get:
return _history_limit
set(v):
push_error("Trying to set read-only variable history_limit")
## The earliest tick that history is retained for.
## [br][br]
## Determined by [member history_limit].
## [br][br]
## [i]read-only[/i]
var history_start: int:
get:
return maxi(0, NetworkTime.tick - history_limit)
set(v):
push_error("Trying to set read-only variable history_start")
## Offset into the past for display, in ticks.
## [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
## state to arrive before we try to display it.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var display_offset: int:
get:
return _display_offset
set(v):
push_error("Trying to set read-only variable display_offset")
## The currently displayed tick.
## [br][br]
## This is the current tick as returned by [member _NetworkTime.tick], minus
## the [member display_offset]. By configuring the [member display_offset], a
## past tick may be displayed to the player, so that updates from the server
## have slightly more time to arrive, masking latency.
## [br][br]
## [i]read-only[/i]
var display_tick: int:
get:
if enabled:
return maxi(0, NetworkTime.tick - NetworkRollback.display_offset)
else:
return NetworkTime.tick
set(v):
push_error("Trying to set read-only variable display_tick")
## Offset into the future to submit inputs, in ticks.
##
## By submitting inputs into the future, they don't happen instantly, but with
## some delay. This can help hiding latency - even if input takes some time to
## arrive, it will still be up to date, as it was timestamped into the future.
## This only works if the input delay is greater than the network latency.
## [br][br]
## In cases where the latency is greater than the input delay, this can still
## reduce the amount of resimulated frames, resulting in less compute.
## [br][br]
## [b]Note:[/b] the [code]is_fresh[/code] parameter may not work as expected
## with input latency higher than network latency.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_delay: int:
get:
return _input_delay
set(v):
push_error("Trying to set read-only variable input_delay")
## How many previous input frames to send along with the current one.
## [br][br]
## As inputs are sent over an unreliable channel, packets may get lost or appear
## out of order. To mitigate packet loss, we send the current and previous n
## ticks of input data. This way, even if the input for a given tick gets lost
## in transmission, the next (n-1) packets will contain the data for it.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_redundancy: int:
get:
return max(1, _input_redundancy)
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
set(v):
push_error("Trying to set read-only variable tick")
## Event emitted before running the network rollback loop.
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 after the given rollback tick was processed.
signal after_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)
## Event emitted after running the network rollback loop.
signal after_loop()
# Settings
var _history_limit: int = ProjectSettings.get_setting(&"netfox/rollback/history_limit", 64)
var _display_offset: int = ProjectSettings.get_setting(&"netfox/rollback/display_offset", 0)
var _input_delay: int = ProjectSettings.get_setting(&"netfox/rollback/input_delay", 0)
var _input_redundancy: int = ProjectSettings.get_setting(&"netfox/rollback/input_redundancy", 3)
# Timing
var _tick: int = 0
var _resim_from: int
var _rollback_from: int = -1
var _rollback_to: int = -1
var _rollback_stage: String = ""
# Resim + mutations
var _is_rollback: bool = false
var _simulated_nodes: _Set = _Set.new()
var _mutated_nodes: Dictionary = {}
var _input_submissions: Dictionary = {}
const _STAGE_BEFORE := "B"
const _STAGE_PREPARE := "P"
const _STAGE_SIMULATE := "S"
const _STAGE_RECORD := "R"
const _STAGE_AFTER := "A"
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkRollback")
## Submit the resimulation start tick for the current loop.
##
## This is used to determine the resimulation range during each loop.
func notify_resimulation_start(tick: int) -> void:
_resim_from = min(_resim_from, tick)
## Submit node for simulation.
##
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
## submit each affected node while preparing the tick, and then run only the
## nodes that need to be resimulated.
func notify_simulated(node: Node) -> void:
_simulated_nodes.add(node)
## Check if node was submitted for simulation.
##
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
## submit each affected node while preparing the tick, and then use
## [member is_simulated] to run only the nodes that need to be resimulated.
func is_simulated(node: Node) -> bool:
return _simulated_nodes.has(node)
## Check if a network rollback is currently active.
func is_rollback() -> bool:
return _is_rollback
## Checks if a given object is rollback-aware, i.e. has the
## [code]_rollback_tick[/code] method implemented.
##
## This is used by [RollbackSynchronizer] to see if it should simulate the
## given object during rollback.
func is_rollback_aware(what: Object) -> bool:
return what.has_method(&"_rollback_tick")
## 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
## is not repeated all over the place.
## [br][br]
## [i]Note:[/i] Make sure to check if the target is rollback-aware, because if
## it's not, this method will run into an error.
func process_rollback(target: Object, delta: float, p_tick: int, is_fresh: bool) -> void:
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.
## [br][br]
## @experimental: The Mutations API is experimental!
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].
## [br][br]
## @experimental: The Mutations API is experimental!
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].
## [br][br]
## @experimental: The Mutations API is experimental!
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
## Register that a node has submitted its input for a specific tick
func register_input_submission(root_node: Node, tick: int) -> void:
if not _input_submissions.has(root_node):
_input_submissions[root_node] = tick
else:
_input_submissions[root_node] = maxi(_input_submissions[root_node], tick)
## Get the latest input tick submitted by a specific root node
## [br][br]
## Returns [code]-1[/code] if no input was submitted for the node, ever.
func get_latest_input_tick(root_node: Node) -> int:
if _input_submissions.has(root_node):
return _input_submissions[root_node]
return -1
## Check if a node has submitted input for a specific tick (or later)
func has_input_for_tick(root_node: Node, tick: int) -> bool:
return _input_submissions.has(root_node) and _input_submissions[root_node] >= tick
## Free all input submission data for a node
## [br][br]
## Use this once the node is freed.
func free_input_submission_data_for(root_node: Node) -> void:
_input_submissions.erase(root_node)
func _ready():
NetfoxLogger.register_tag(_get_rollback_tag)
NetworkTime.after_tick_loop.connect(_rollback)
func _exit_tree():
NetfoxLogger.free_tag(_get_rollback_tag)
func _get_rollback_tag() -> String:
if _is_rollback:
return "%s@%d|%d>%d" % [_rollback_stage, _tick, _rollback_from, _rollback_to]
else:
return "_"
func _rollback() -> void:
if not enabled:
return
# Ask all rewindables to submit their earliest inputs
_resim_from = NetworkTime.tick
before_loop.emit()
# Only set _is_rollback *after* emitting before_loop
_is_rollback = true
_rollback_stage = _STAGE_BEFORE
# from = Earliest input amongst all rewindables
var from := _resim_from
# to = Current tick
var to := NetworkTime.tick
# Limit number of rollback ticks
if to - from > history_limit:
_logger.warning(
"Trying to run rollback for ticks %d to %d, past the history limit of %d",
[from, to, history_limit]
)
from = NetworkTime.tick - history_limit
# for tick in from .. to:
_rollback_from = from
_rollback_to = to
for tick in range(from, to):
_tick = tick
_simulated_nodes.clear()
# Prepare state
# Done individually by Rewindables ( usually Rollback Synchronizers )
# Restore input and state for tick
_rollback_stage = _STAGE_PREPARE
on_prepare_tick.emit(tick)
after_prepare_tick.emit(tick)
# Simulate rollback tick
# Method call on rewindables
# Rollback synchronizers go through each node they manage
# If current tick is in node's range, tick
# If authority: Latest input >= tick >= Latest state
# If not: Latest input >= tick >= Earliest input
_rollback_stage = _STAGE_SIMULATE
on_process_tick.emit(tick)
after_process_tick.emit(tick)
# Record state for tick + 1
_rollback_stage = _STAGE_RECORD
on_record_tick.emit(tick + 1)
# Restore display state
_rollback_stage = _STAGE_AFTER
after_loop.emit()
# Cleanup
_mutated_nodes.clear()
_is_rollback = false
# Insight 1:
# state(x) = simulate(state(x - 1), input(x - 1))
# state(x + 1) = simulate(state(x), input(x))
# Insight 2:
# Server is authorative over all state, client over its own input, i.e.
# Server broadcasts state
# Client sends input to server
# Flow:
# Clients send in their inputs
# Server simulates frames from earliest input to current
# Server broadcasts simulated frames
# Clients receive authorative states
# Clients simulate local frames
@@ -0,0 +1 @@
uid://gas743comroj
@@ -0,0 +1,34 @@
extends RefCounted
class_name RollbackFreshnessStore
## This class tracks nodes and whether they have processed any given tick during
## a rollback.
# TODO: _Set
var _data: Dictionary = {}
func is_fresh(node: Node, tick: int) -> bool:
if not _data.has(tick):
return true
if not _data[tick].has(node):
return true
return false
func notify_processed(node: Node, tick: int) -> void:
if not _data.has(tick):
_data[tick] = {}
_data[tick][node] = true
func trim() -> void:
while not _data.is_empty():
var earliest_tick := _data.keys().min()
if earliest_tick < NetworkRollback.history_start:
_data.erase(earliest_tick)
else:
break
func clear():
_data.clear()
@@ -0,0 +1 @@
uid://bjpcq3jhfbqeh
@@ -0,0 +1,451 @@
@tool
extends Node
class_name RollbackSynchronizer
## Similar to [MultiplayerSynchronizer], this class is responsible for
## synchronizing data between players, but with support for rollback.
## [br][br]
## @tutorial(RollbackSynchronizer Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/rollback-synchronizer/
## The root node for resolving node paths in properties. Defaults to the parent
## node.
@export var root: Node = get_parent()
## Toggle prediction.
## [br][br]
## Enabling this will run [code]_rollback_tick[/code] on nodes under
## [member root] even if there's no current input available for the tick.
@export var enable_prediction: bool = false
@export_group("State")
## Properties that define the game state.
## [br][br]
## State properties are recorded for each tick and restored during rollback.
## State is restored before every rollback tick, and recorded after simulating
## the tick.
@export var state_properties: Array[String]
## Ticks to wait between sending full states.
## [br][br]
## If set to 0, full states will never be sent. If set to 1, only full states
## will be sent. If set higher, full states will be sent regularly, but not
## for every tick.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
@export_range(0, 128, 1, "or_greater")
var full_state_interval: int = 24
## Ticks to wait between unreliably acknowledging diff states.
## [br][br]
## This can reduce the amount of properties sent in diff states, due to clients
## more often acknowledging received states. To avoid introducing hickups, these
## are sent unreliably.
## [br][br]
## If set to 0, diff states will never be acknowledged. If set to 1, all diff
## states will be acknowledged. If set higher, ack's will be sent regularly, but
## not for every diff state.
## [br][br]
## If enabled, it's worth to tune this setting until network traffic is actually
## reduced.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
@export_range(0, 128, 1, "or_greater")
var diff_ack_interval: int = 0
@export_group("Inputs")
## Properties that define the input for the game simulation.
## [br][br]
## Input properties drive the simulation, which in turn results in updated state
## properties. Input is recorded after every network tick.
@export var input_properties: Array[String]
## This will broadcast input to all peers, turning this off will limit to
## sending it to the server only. Turning this off is recommended to save
## bandwidth and reduce cheating risks.
@export var enable_input_broadcast: bool = true
# Make sure this exists from the get-go, just not in the scene tree
## Decides which peers will receive updates
var visibility_filter := PeerVisibilityFilter.new()
var _state_property_config: _PropertyConfig = _PropertyConfig.new()
var _input_property_config: _PropertyConfig = _PropertyConfig.new()
var _nodes: Array[Node] = []
var _simset: _Set = _Set.new()
var _skipset: _Set = _Set.new()
var _properties_dirty: bool = false
var _property_cache := PropertyCache.new(root)
var _freshness_store := RollbackFreshnessStore.new()
var _states := _PropertyHistoryBuffer.new()
var _inputs := _PropertyHistoryBuffer.new()
var _last_simulated_tick: int
var _has_input: bool
var _input_tick: int
var _is_predicted_tick: bool
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronizer")
# Composition
var _history_transmitter: _RollbackHistoryTransmitter
var _history_recorder: _RollbackHistoryRecorder
## Process settings.
##
## Call this after any change to configuration. Updates based on authority too
## ( calls process_authority ).
func process_settings() -> void:
_property_cache.root = root
_property_cache.clear()
_freshness_store.clear()
_nodes.clear()
_states.clear()
_inputs.clear()
process_authority()
# Gather all rollback-aware nodes to simulate during rollbacks
_nodes = root.find_children("*")
_nodes.push_front(root)
_nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
_nodes.erase(self)
_history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval)
_history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset)
_history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset)
## Process settings based on authority.
##
## Call this whenever the authority of any of the nodes managed by
## RollbackSynchronizer changes. Make sure to do this at the same time on all
## peers.
func process_authority():
_state_property_config.local_peer_id = multiplayer.get_unique_id()
_input_property_config.local_peer_id = multiplayer.get_unique_id()
_state_property_config.set_properties_from_paths(state_properties, _property_cache)
_input_property_config.set_properties_from_paths(input_properties, _property_cache)
## Add a state property.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already tracked, this method does nothing.
func add_state(node: Variant, property: String):
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or state_properties.has(property_path):
return
state_properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
## Add an input property.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already tracked, this method does nothing.
func add_input(node: Variant, property: String) -> void:
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or input_properties.has(property_path):
return
input_properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
## Check if input is available for the current tick.
##
## This input is not always current, it may be from multiple ticks ago.
## [br][br]
## Returns true if input is available.
func has_input() -> bool:
return _has_input
## Get the age of currently available input in ticks.
##
## The available input may be from the current tick, or from multiple ticks ago.
## This number of tick is the input's age.
## [br][br]
## Calling this when [member has_input] is false will yield an error.
func get_input_age() -> int:
if has_input():
return NetworkRollback.tick - _input_tick
else:
_logger.error("Trying to check input age without having input!")
return -1
## Check if the current tick is predicted.
##
## A tick becomes predicted if there's no up-to-date input available. It will be
## simulated and recorded, but will not be broadcast, nor considered
## authoritative.
func is_predicting() -> bool:
return _is_predicted_tick
## Ignore a node's prediction for the current rollback tick.
##
## Call this when the input is too old to base predictions on. This call is
## ignored if [member enable_prediction] is false.
func ignore_prediction(node: Node) -> void:
if enable_prediction:
_skipset.add(node)
## Get the tick of the last known input.
## [br][br]
## This is the latest tick where input information is available. If there's
## locally owned input for this instance ( e.g. running as client ), this value
## will be the current tick. Otherwise, this will be the latest tick received
## from the input owner.
## [br][br]
## If [member enable_input_broadcast] is false, there may be no input available
## for peers who own neither state nor input.
## [br][br]
## Returns -1 if there's no known input.
func get_last_known_input() -> int:
# If we own input, it is updated regularly, this will be the current tick
# If we don't own input, _inputs is only updated when input data is received
if not _inputs.is_empty():
return _inputs.keys().max()
return -1
## Get the tick of the last known state.
## [br][br]
## This is the latest tick where information is available for state. For state
## owners ( usually the host ), this is the current tick. Note that even this
## data may change as new input arrives. For peers that don't own state, this
## will be the tick of the latest state received from the state owner.
func get_last_known_state() -> int:
# If we own state, this will be updated when recording and broadcasting
# state, this will be the current tick
# If we don't own state, this will be updated when state data is received
return _history_transmitter.get_latest_state_tick()
func _ready() -> void:
if Engine.is_editor_hint():
return
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
process_settings.call_deferred()
func _connect_signals() -> void:
NetworkTime.before_tick.connect(_before_tick)
NetworkTime.after_tick.connect(_after_tick)
NetworkRollback.on_prepare_tick.connect(_on_prepare_tick)
NetworkRollback.on_process_tick.connect(_process_tick)
NetworkRollback.on_record_tick.connect(_on_record_tick)
NetworkRollback.before_loop.connect(_before_rollback_loop)
NetworkRollback.after_loop.connect(_after_rollback_loop)
func _disconnect_signals() -> void:
NetworkTime.before_tick.disconnect(_before_tick)
NetworkTime.after_tick.disconnect(_after_tick)
NetworkRollback.on_prepare_tick.disconnect(_on_prepare_tick)
NetworkRollback.on_process_tick.disconnect(_process_tick)
NetworkRollback.on_record_tick.disconnect(_on_record_tick)
NetworkRollback.before_loop.disconnect(_before_rollback_loop)
NetworkRollback.after_loop.disconnect(_after_rollback_loop)
func _before_tick(_dt: float, tick: int) -> void:
_history_recorder.apply_state(tick)
func _after_tick(_dt: float, tick: int) -> void:
_history_recorder.record_input(tick)
_history_transmitter.transmit_input(tick)
_history_recorder.trim_history()
_freshness_store.trim()
func _before_rollback_loop() -> void:
_notify_resim()
func _on_prepare_tick(tick: int) -> void:
_history_recorder.apply_tick(tick)
_prepare_tick_process(tick)
func _process_tick(tick: int) -> void:
_run_rollback_tick(tick)
_push_simset_metrics()
func _on_record_tick(tick: int) -> void:
_history_recorder.record_state(tick)
_history_transmitter.transmit_state(tick)
func _after_rollback_loop() -> void:
_history_recorder.apply_display_state()
_history_transmitter.conclude_tick_loop()
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore state and input properties
if not root:
return ["No valid root node found!"]
var result := PackedStringArray()
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_state_properties",
func(node, prop):
add_state(node, prop)
))
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_input_properties",
func(node, prop):
add_input(node, prop)
))
return result
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if not visibility_filter:
visibility_filter = PeerVisibilityFilter.new()
if not visibility_filter.get_parent():
add_child(visibility_filter)
if _history_transmitter == null:
_history_transmitter = _RollbackHistoryTransmitter.new()
add_child(_history_transmitter, true)
_history_transmitter.set_multiplayer_authority(get_multiplayer_authority())
if _history_recorder == null:
_history_recorder = _RollbackHistoryRecorder.new()
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
_connect_signals.call_deferred()
process_settings.call_deferred()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_disconnect_signals()
func _notify_resim() -> void:
if _get_owned_input_props().is_empty():
# We don't have any inputs we own, simulate from earliest we've received
NetworkRollback.notify_resimulation_start(_history_transmitter.get_earliest_input_tick())
else:
# We own inputs, simulate from latest authorative state
NetworkRollback.notify_resimulation_start(_history_transmitter.get_latest_state_tick())
func _prepare_tick_process(tick: int) -> void:
_history_recorder.set_latest_state_tick(_history_transmitter._latest_state_tick)
# Save data for input prediction
var retrieved_tick := _inputs.get_closest_tick(tick)
# These are used as input for input age ( i.e. do we even have input, and if so, how old? )
_has_input = retrieved_tick != -1
_input_tick = retrieved_tick
# Used to explicitly determine if this is a predicted tick
# ( even if we could grab *some* input )
_is_predicted_tick = _is_predicted_tick_for(null, tick)
_history_transmitter.set_predicted_tick(_is_predicted_tick)
# Reset the set of simulated and ignored nodes
_simset.clear()
_skipset.clear()
# Gather nodes that can be simulated
for node in _nodes:
if _can_simulate(node, tick):
NetworkRollback.notify_simulated(node)
func _can_simulate(node: Node, tick: int) -> bool:
if not enable_prediction and _is_predicted_tick_for(node, tick):
# Don't simulate if prediction is not allowed and tick is predicted
return false
if NetworkRollback.is_mutated(node, tick):
# Mutated nodes are always resimulated
return true
if input_properties.is_empty():
# If we're running inputless and own the node, simulate it if we haven't
if node.is_multiplayer_authority():
return tick > _last_simulated_tick
# If we're running inputless and don't own the node, only run as prediction
return enable_prediction
if node.is_multiplayer_authority():
# Simulate from earliest input
# Don't simulate frames we don't have input for
return tick >= _history_transmitter.get_earliest_input_tick()
else:
# Simulate ONLY if we have state from server
# Simulate from latest authorative state - anything the server confirmed we don't rerun
# Don't simulate frames we don't have input for
return tick >= _history_transmitter.get_latest_state_tick()
# `node` can be set to null, in case we're not simulating a specific node
func _is_predicted_tick_for(node: Node, tick: int) -> bool:
if input_properties.is_empty() and node != null:
# We're running without inputs
# It's only predicted if we don't own the node
return not node.is_multiplayer_authority()
else:
# We have input properties, it's only predicted if we don't have the input for the tick
return not _inputs.has(tick)
func _run_rollback_tick(tick: int) -> void:
# Simulate rollback tick
# Method call on rewindables
# Rollback synchronizers go through each node they manage
# If current tick is in node's range, tick
# If authority: Latest input >= tick >= Latest state
# If not: Latest input >= tick >= Earliest input
for node in _nodes:
if not NetworkRollback.is_simulated(node):
continue
var is_fresh := _freshness_store.is_fresh(node, tick)
_is_predicted_tick = _is_predicted_tick_for(node, tick)
NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh)
if _skipset.has(node):
continue
_freshness_store.notify_processed(node, tick)
_simset.add(node)
func _push_simset_metrics():
# Push metrics
NetworkPerformance.push_rollback_nodes_simulated(_simset.size())
func _reprocess_settings() -> void:
if not _properties_dirty or Engine.is_editor_hint():
return
_properties_dirty = false
process_settings()
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -0,0 +1 @@
uid://d350u8evihs1u
@@ -0,0 +1,37 @@
extends Object
class_name _TicksetSerializer
static func serialize(earliest_tick: int, latest_tick: int, active_ticks: _Set) -> PackedByteArray:
var buffer := StreamPeerBuffer.new()
var tickset_duration = latest_tick - earliest_tick
assert(latest_tick >= earliest_tick, "Tickset ends before it starts!")
assert(tickset_duration <= 255, "Tickset covers more than supported 255 ticks!")
buffer.put_u32(earliest_tick)
buffer.put_u8(tickset_duration)
for tick in active_ticks:
# Don't serialize before range
if tick < earliest_tick: continue
# Don't serialize past range
if tick > latest_tick: break
buffer.put_u8(tick - earliest_tick)
return buffer.data_array
static func deserialize(bytes: PackedByteArray) -> Array:
var buffer := StreamPeerBuffer.new()
buffer.data_array = bytes
var earliest_tick := buffer.get_u32()
var tickset_duration := buffer.get_u8()
var latest_tick := earliest_tick + tickset_duration
var active_ticks := _Set.new()
while buffer.get_available_bytes() > 0:
var tick = earliest_tick + buffer.get_u8()
active_ticks.add(tick)
return [earliest_tick, latest_tick, active_ticks]
@@ -0,0 +1 @@
uid://b88vxloxp2wff
+264
View File
@@ -0,0 +1,264 @@
@tool
extends Node
class_name StateSynchronizer
## Synchronizes state from authority.
##
## Similar to Godot's [MultiplayerSynchronizer], but is tied to the network tick loop. Works well
## with [TickInterpolator].
## [br][br]
## @tutorial(StateSynchronizer Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/state-synchronizer/
## The root node for resolving node paths in properties.
@export var root: Node
## Properties to record and broadcast.
@export var properties: Array[String]
## Ticks to wait between sending full states.
## [br][br]
## If set to 0, full states will never be sent. If set to 1, only full states
## will be sent. If set higher, full states will be sent regularly, but not
## for every tick.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
@export_range(0, 128, 1, "or_greater")
var full_state_interval: int = 24 # TODO: Don't tie to a network rollback setting?
## Ticks to wait between unreliably acknowledging diff states.
## [br][br]
## This can reduce the amount of properties sent in diff states, due to clients
## more often acknowledging received states. To avoid introducing hickups, these
## are sent unreliably.
## [br][br]
## If set to 0, diff states will never be acknowledged. If set to 1, all diff
## states will be acknowledged. If set higher, ack's will be sent regularly, but
## not for every diff state.
## [br][br]
## If enabled, it's worth to tune this setting until network traffic is actually
## reduced.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
@export_range(0, 128, 1, "or_greater")
var diff_ack_interval: int = 0 # TODO: Don't tie to a network rollback setting?
## Decides which peers will receive updates
var visibility_filter := PeerVisibilityFilter.new()
var _property_cache: PropertyCache
var _property_config: _PropertyConfig = _PropertyConfig.new()
var _properties_dirty: bool = false
var _state_history := _PropertyHistoryBuffer.new()
# Collaborators
var _full_state_encoder: _SnapshotHistoryEncoder
var _diff_state_encoder: _DiffHistoryEncoder
# State
var _ackd_state: Dictionary = {}
var _next_full_state_tick: int
var _next_diff_ack_tick: int
var _is_initialized: bool = false
static var _logger := NetfoxLogger._for_netfox("StateSynchronizer")
## Process settings.
## [br][br]
## Call this after any change to configuration.
func process_settings() -> void:
_property_cache = PropertyCache.new(root)
_property_config.set_properties_from_paths(properties, _property_cache)
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder.add_properties(_property_config.get_properties())
_next_full_state_tick = NetworkTime.tick
_next_diff_ack_tick = NetworkTime.tick
# Scatter full state sends, so not all nodes send at the same tick
if is_inside_tree():
_next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval)
else:
_next_full_state_tick += hash(root.name) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval)
_is_initialized = true
## Add a state property.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already tracked, this method does nothing.
func add_state(node: Variant, property: String) -> void:
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or properties.has(property_path):
return
properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
func _notification(what) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore state properties
if not root:
return ["No valid root node found!"]
return _NetfoxEditorUtils.gather_properties(root, "_get_synchronized_state_properties",
func(node, prop):
add_state(node, prop)
)
func _connect_signals() -> void:
NetworkTime.after_tick.connect(_after_tick)
NetworkTime.after_tick_loop.connect(_after_loop)
func _disconnect_signals() -> void:
NetworkTime.after_tick.disconnect(_after_tick)
NetworkTime.after_tick_loop.disconnect(_after_loop)
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if not visibility_filter:
visibility_filter = PeerVisibilityFilter.new()
if not visibility_filter.get_parent():
add_child(visibility_filter)
_connect_signals.call_deferred()
process_settings.call_deferred()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_disconnect_signals()
func _after_tick(_dt: float, tick: int) -> void:
if is_multiplayer_authority():
# Submit snapshot
var state := _PropertySnapshot.extract(_property_config.get_properties())
_state_history.set_snapshot(tick, state)
_broadcast_state(tick, state)
elif not _state_history.is_empty():
var state := _state_history.get_history(tick)
state.apply(_property_cache)
func _after_loop() -> void:
_state_history.trim(NetworkTime.tick - NetworkRollback.history_limit) # TODO: Don't tie to rollback?
func _reprocess_settings() -> void:
if not _properties_dirty:
return
_properties_dirty = false
process_settings()
func _broadcast_state(tick: int, state: _PropertySnapshot) -> void:
var is_sending_diffs := NetworkRollback.enable_diff_states # TODO: Don't tie to a rollback setting?
var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick)
if is_full_state_tick:
# Broadcast new full state
for peer in visibility_filter.get_rpc_target_peers():
_send_full_state(tick, peer)
# Adjust next full state if sending diffs
if is_sending_diffs:
_next_full_state_tick = tick + full_state_interval
else:
# Send diffs to each peer
for peer in visibility_filter.get_visible_peers():
var reference_tick := _ackd_state.get(peer, -1) as int
if reference_tick < 0 or not _state_history.has(reference_tick):
# Peer hasn't ack'd any tick, or we don't have the ack'd tick
# Send full state
_logger.trace("Reference tick @%d not found for peer #%s, sending full tick", [reference_tick, peer])
_send_full_state(tick, peer)
continue
# Prepare diff
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _property_config.get_properties())
if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size():
# State is completely different, send full state
_send_full_state(tick, peer)
else:
# Send only diff
_submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick)
# Push metrics
NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot())
NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot())
func _send_full_state(tick: int, peer: int = 0) -> void:
var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary()
var full_state_data := _full_state_encoder.encode(tick, _property_config.get_properties())
_submit_full_state.rpc_id(peer, full_state_data, tick)
if peer <= 0:
NetworkPerformance.push_full_state_broadcast(full_state_snapshot)
NetworkPerformance.push_sent_state_broadcast(full_state_snapshot)
else:
NetworkPerformance.push_full_state(full_state_snapshot)
NetworkPerformance.push_sent_state(full_state_snapshot)
# `serialized_state` is a serialized _PropertySnapshot
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_full_state(data: Array, tick: int) -> void:
if not _is_initialized: return
var sender := multiplayer.get_remote_sender_id()
var snapshot := _full_state_encoder.decode(data, _property_config.get_properties_owned_by(sender))
if not _full_state_encoder.apply(tick, snapshot, sender):
# Invalid data
return
if NetworkRollback.enable_diff_states:
_ack_full_state.rpc_id(sender, tick)
# State is a serialized _PropertySnapshot (Dictionary[String, Variant])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void:
if not _is_initialized: return
var sender = multiplayer.get_remote_sender_id()
var diff_snapshot := _diff_state_encoder.decode(data, _property_config.get_properties_owned_by(sender))
if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender):
# Invalid data
return
if NetworkRollback.enable_diff_states:
if diff_ack_interval > 0 and tick > _next_diff_ack_tick:
_ack_diff_state.rpc_id(sender, tick)
_next_diff_ack_tick = tick + diff_ack_interval
@rpc("any_peer", "reliable", "call_remote")
func _ack_full_state(tick: int) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _ack_diff_state(tick: int) -> void:
if not _is_initialized: return
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick])
+1
View File
@@ -0,0 +1 @@
uid://3lbngqcexe0l
+169
View File
@@ -0,0 +1,169 @@
@tool
extends Node
class_name TickInterpolator
## Interpolates between network ticks for smooth motion.
## [br][br]
## @tutorial(TickInterpolator Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/tick-interpolator/
## The root node for resolving node paths in properties.
@export var root: Node
## Toggles interpolation.
@export var enabled: bool = true
## Properties to interpolate.
@export var properties: Array[String]
## If enabled, takes a snapshot immediately upon instantiation, instead of
## waiting for the first network tick. Useful for objects that start moving
## instantly, like projectiles.
@export var record_first_state: bool = true
## Toggle automatic state recording. When enabled, the node will take a new
## snapshot on every network tick. When disabled, call [member push_state]
## whenever properties are updated.
@export var enable_recording: bool = true
var _state_from: _PropertySnapshot
var _state_to: _PropertySnapshot
var _property_entries: Array[PropertyEntry] = []
var _properties_dirty: bool = false
var _interpolators: Dictionary = {}
var _is_teleporting: bool = false
var _property_cache: PropertyCache
## Process settings.
## [br][br]
## Call this after any change to configuration.
func process_settings():
_property_cache = PropertyCache.new(root)
_property_entries.clear()
_interpolators.clear()
_state_from = _PropertySnapshot.new()
_state_to = _PropertySnapshot.new()
for property in properties:
var property_entry = _property_cache.get_entry(property)
_property_entries.push_back(property_entry)
_interpolators[property] = Interpolators.find_for(property_entry.get_value())
## Add a property to interpolate.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already interpolated, this method does nothing.
func add_property(node: Variant, property: String):
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or properties.has(property_path):
return
properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
## Check if interpolation can be done.
## [br][br]
## Even if it's enabled, no interpolation will be done if there are no
## properties to interpolate.
func can_interpolate() -> bool:
return enabled and not properties.is_empty() and not _is_teleporting
## Record current state for interpolation.
## [br][br]
## Note that this will rotate the states, so the previous target becomes the new
## starting point for the interpolation. This is automatically called if
## [code]enable_recording[/code] is true.
func push_state() -> void:
_state_from = _state_to
_state_to = _PropertySnapshot.extract(_property_entries)
## Record current state and transition without interpolation.
func teleport() -> void:
if _is_teleporting:
return
_state_from = _PropertySnapshot.extract(_property_entries)
_state_to = _state_from
_is_teleporting = true
func _notification(what) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore interpolated properties
if not root:
return ["No valid root node found!"]
return _NetfoxEditorUtils.gather_properties(root, "_get_interpolated_properties",
func(node, prop):
add_property(node, prop)
)
func _connect_signals() -> void:
NetworkTime.before_tick_loop.connect(_before_tick_loop)
NetworkTime.after_tick_loop.connect(_after_tick_loop)
func _disconnect_signals() -> void:
NetworkTime.before_tick_loop.disconnect(_before_tick_loop)
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
process_settings.call_deferred()
_connect_signals.call_deferred()
# Wait a frame for any initial setup before recording first state
if record_first_state:
await get_tree().process_frame
teleport()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_disconnect_signals()
func _process(_delta: float) -> void:
if Engine.is_editor_hint():
return
_interpolate(_state_from, _state_to, NetworkTime.tick_factor)
func _reprocess_settings() -> void:
if not _properties_dirty or Engine.is_editor_hint():
return
_properties_dirty = false
process_settings()
func _before_tick_loop() -> void:
_is_teleporting = false
_state_to.apply(_property_cache)
func _after_tick_loop() -> void:
if enable_recording and not _is_teleporting:
push_state()
_state_from.apply(_property_cache)
func _interpolate(from: _PropertySnapshot, to: _PropertySnapshot, f: float) -> void:
if not can_interpolate():
return
for property in from.properties():
if not to.has(property): continue
var property_entry := _property_cache.get_entry(property)
var a := from.get_value(property)
var b := to.get_value(property)
var interpolate = _interpolators[property] as Callable
property_entry.set_value(interpolate.call(a, b, f))
+1
View File
@@ -0,0 +1 @@
uid://dour8fehaaugp
@@ -0,0 +1,21 @@
extends RefCounted
class_name NetworkClockSample
var ping_sent: float
var ping_received: float
var pong_sent: float
var pong_received: float
func get_rtt() -> float:
return pong_received - ping_sent
func get_offset() -> float:
# See: https://datatracker.ietf.org/doc/html/rfc5905#section-8
# See: https://support.huawei.com/enterprise/en/doc/EDOC1100278232/729da750/ntp-fundamentals
return ((ping_received - ping_sent) + (pong_sent - pong_received)) / 2.
func _to_string() -> String:
return "(theta=%.2fms; delta=%.2fms; t1=%.4fs; t2=%.4fs; t3=%.4fs; t4=%.4fs)" % [
get_offset() * 1000., get_rtt() * 1000.,
ping_sent, ping_received, pong_sent, pong_received
]
@@ -0,0 +1 @@
uid://cdmch34dx2uhe
+41
View File
@@ -0,0 +1,41 @@
extends Object
class_name NetworkClocks
class SystemClock:
var offset: float = 0.
func get_raw_time() -> float:
return Time.get_unix_time_from_system()
func get_time() -> float:
return get_raw_time() + offset
func adjust(p_offset: float) -> void:
offset += p_offset
func set_time(p_time: float) -> void:
offset = p_time - get_raw_time()
class SteppingClock:
var time: float = 0.
var last_step: float = get_raw_time()
func get_raw_time() -> float:
return Time.get_unix_time_from_system()
func get_time() -> float:
return time
func adjust(p_offset: float) -> void:
time += p_offset
func set_time(p_time: float) -> void:
last_step = get_raw_time()
time = p_time
func step(p_multiplier: float = 1.) -> void:
var current_step := get_raw_time()
var step_duration := current_step - last_step
last_step = current_step
adjust(step_duration * p_multiplier)
+1
View File
@@ -0,0 +1 @@
uid://dnp07ns6cpc70
@@ -0,0 +1,98 @@
extends Node
class_name NetworkTickrateHandshake
## Internal class to manage the tickrate handshake.
##
## Whenever a new peer joins, they exchange their configured tickrate with the
## host. If the tickrate mismatches, a warning is emitted by default, as this is
## assumed to be a developer mistake.
## [br][br]
## However, if this is expected, different actions can be configured.
## Emit a warning on tickrate mismatch
const WARN := 0
## Disconnect peer on tickrate mismatch[br]
## This is enforced by the host.
const DISCONNECT := 1
## Adjust tickrate to the host's on mismatch
const ADJUST := 2
## Emit [signal on_tickrate_mismatch] on mismatch[br]
## This is emitted on both host and client.
const SIGNAL := 3
## Configures what happens on a tickrate mismatch.[br]
## Defaults to [constant WARN], based on project settings.
var mismatch_action: int = ProjectSettings.get_setting(&"netfox/time/tickrate_mismatch_action", WARN)
static var _logger := NetfoxLogger._for_netfox("NetworkTickrateHandshake")
## Emitted when a tickrate mismatch is encountered, and [member mismatch_action] is set to
## [constant SIGNAL].
signal on_tickrate_mismatch(peer: int, tickrate: int)
## Run the tickrate handshake.
## [br][br]
## This will connect to signals, so that every new peer receives tickrate info
## from the host.
## [br][br]
## Called by [_NetworkTime], no need to call manually.
func run() -> void:
if multiplayer.is_server():
# Broadcast tickrate
_submit_tickrate.rpc(NetworkTime.tickrate)
# Submit tickrate to anyone joining
multiplayer.peer_connected.connect(_handle_new_peer)
else:
# Submit tickrate to host
_submit_tickrate.rpc_id(1, NetworkTime.tickrate)
## Stop the tickrate handshake.
## [br][br]
## Called by [_NetworkTime], no need to call manually.
func stop() -> void:
if multiplayer.peer_connected.is_connected(_handle_new_peer):
multiplayer.peer_connected.disconnect(_handle_new_peer)
func _ready() -> void:
name = "NetworkTickrateHandshake"
func _handle_new_peer(peer: int) -> void:
if multiplayer.is_server():
_submit_tickrate.rpc_id(peer, NetworkTime.tickrate)
func _handle_tickrate_mismatch(peer: int, tickrate: int) -> void:
match mismatch_action:
WARN:
_logger.warning(
"Local tickrate %dtps differs from tickrate of peer #%d at %dtps! " +
"Make sure that tickrates are correctly configured in the Project settings! " +
"See netfox/Time/Tickrate.", [
NetworkTime.tickrate, peer, tickrate
])
DISCONNECT:
if multiplayer.is_server():
_logger.warning("Peer #%d's tickrate of %dtps differs from expected %dtps! Disconnecting.", [
peer, tickrate, NetworkTime.tickrate
])
multiplayer.multiplayer_peer.disconnect_peer(peer)
ADJUST:
if not multiplayer.is_server():
_logger.info("Local tickrate %dtps differs from tickrate of host at %dtps! Adjusting.", [
NetworkTime.tickrate, tickrate
])
# TODO: Make tickrate mutable at user's digression
ProjectSettings.set_setting(&"netfox/time/tickrate", tickrate)
SIGNAL:
on_tickrate_mismatch.emit(peer, tickrate)
@rpc("any_peer", "reliable", "call_remote")
func _submit_tickrate(tickrate: int) -> void:
var sender := multiplayer.get_remote_sender_id()
_logger.debug("Received tickrate %d from peer %d", [tickrate, sender])
if tickrate != NetworkTime.tickrate:
_handle_tickrate_mismatch(sender, tickrate)
@@ -0,0 +1 @@
uid://ododt6lhpbvq