b0c83af092
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
433 lines
15 KiB
GDScript
433 lines
15 KiB
GDScript
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 _earliest_input := -1
|
|
var _earliest_state := -1
|
|
|
|
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
|
|
## @deprecated
|
|
func register_rollback_input_submission(_node: Node, _tick: int) -> void:
|
|
pass
|
|
|
|
## Get the latest input tick submitted for a specific node
|
|
## [br][br]
|
|
## Returns [code]-1[/code] if no input was submitted for the node, ever.
|
|
func get_latest_input_tick(node: Node) -> int:
|
|
var input_nodes := RollbackSimulationServer._get_inputs_of(node)
|
|
var reference_tick := NetworkTime.tick
|
|
|
|
return NetworkHistoryServer.get_latest_input_for(input_nodes, reference_tick)
|
|
|
|
## Check if a node has submitted input for a specific tick (or later)
|
|
func has_input_for_tick(node: Node, tick: int) -> bool:
|
|
var latest_input := get_latest_input_tick(node)
|
|
return latest_input != -1 and latest_input >= tick
|
|
|
|
## Free all input submission data for a node
|
|
## [br][br]
|
|
## Use this once the node is freed.
|
|
## @deprecated
|
|
func free_input_submission_data_for(_node: Node) -> void:
|
|
pass
|
|
|
|
func _get_rollback_method(object: Object) -> Callable:
|
|
return object._rollback_tick
|
|
|
|
func _ready():
|
|
NetfoxLogger.register_tag(_get_rollback_tag)
|
|
NetworkTime.after_tick_loop.connect(_rollback)
|
|
NetworkTime.after_tick.connect(func(_dt, tick):
|
|
NetworkHistoryServer._record_rollback_input(tick + input_delay)
|
|
NetworkSynchronizationServer._synchronize_input(tick + input_delay)
|
|
)
|
|
|
|
NetworkSynchronizationServer._on_input.connect(_handle_input)
|
|
NetworkSynchronizationServer._on_state.connect(_handle_state)
|
|
|
|
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()
|
|
|
|
# Figure out where to start rollback from
|
|
var range_source = "notif"
|
|
if _earliest_input >= 0 and _earliest_input <= _resim_from:
|
|
range_source = "earliest input"
|
|
_resim_from = _earliest_input
|
|
if _earliest_state >= 0 and _earliest_state <= _resim_from:
|
|
range_source = "latest state"
|
|
_resim_from = _earliest_state
|
|
_resim_from = mini(_resim_from, NetworkTime.tick - 1)
|
|
_logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source])
|
|
|
|
# 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
|
|
|
|
_earliest_input = -1
|
|
_earliest_state = -1
|
|
|
|
# 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)
|
|
NetworkHistoryServer._restore_rollback_input(tick)
|
|
NetworkHistoryServer._restore_rollback_state(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)
|
|
RollbackSimulationServer.simulate(NetworkTime.ticktime, tick)
|
|
after_process_tick.emit(tick)
|
|
|
|
# Record state for tick + 1
|
|
_rollback_stage = _STAGE_RECORD
|
|
on_record_tick.emit(tick + 1)
|
|
NetworkHistoryServer._record_rollback_state(tick + 1)
|
|
NetworkSynchronizationServer._synchronize_state(tick + 1)
|
|
|
|
# Restore display state
|
|
_rollback_stage = _STAGE_AFTER
|
|
after_loop.emit()
|
|
NetworkHistoryServer._restore_rollback_state(display_tick)
|
|
RollbackSimulationServer._trim_ticks_simulated(history_start)
|
|
|
|
# Cleanup
|
|
_mutated_nodes.clear()
|
|
_is_rollback = false
|
|
|
|
func _handle_input(snapshot: _Snapshot):
|
|
if snapshot.is_empty():
|
|
return
|
|
if _earliest_input < 0 or snapshot.tick < _earliest_input:
|
|
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick])
|
|
_earliest_input = snapshot.tick
|
|
else:
|
|
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input])
|
|
|
|
func _handle_state(snapshot: _Snapshot):
|
|
if snapshot.is_empty():
|
|
return
|
|
if _earliest_state < 0 or snapshot.tick < _earliest_state:
|
|
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, snapshot.tick])
|
|
_earliest_state = snapshot.tick
|
|
else:
|
|
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, _earliest_state])
|
|
|
|
# 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
|