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:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://wsx6ajtb42c2
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjrtjdfhdtjlm
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdmobxxptryj0
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqgf7aqmjfoa3
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnqdxyjptkfvp
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://3lbngqcexe0l
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user