e7299b17e9
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)
188 lines
7.1 KiB
GDScript
188 lines
7.1 KiB
GDScript
@tool
|
|
@icon("res://addons/netfox.extras/icons/rewindable-state-machine.svg")
|
|
extends Node
|
|
class_name RewindableStateMachine
|
|
|
|
## A state machine that can be used with rollback.
|
|
##
|
|
## It relies on [RollbackSynchronizer] to manage its state. State transitions
|
|
## are only triggered by gameplay code, and not by rollback reverting to an
|
|
## earlier state.
|
|
## [br][br]
|
|
## For this node to work correctly, a [RollbackSynchronizer] must be added as
|
|
## a sibling, and it must have the [RewindableStateMachine]'s [member state]
|
|
## property configured as a state property.
|
|
## [br][br]
|
|
## To implement states, extend the [RewindableState] class and add it as a child
|
|
## node.
|
|
##
|
|
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
|
|
|
|
## Name of the current state.
|
|
##
|
|
## Can be an empty string if no state is active. Only modify directly if you
|
|
## need to skip [method transition]'s callbacks.
|
|
@export var state: StringName = "":
|
|
get: return _state_object.name if _state_object != null else ""
|
|
set(v): _set_state(v)
|
|
|
|
## Emitted during state transitions.
|
|
##
|
|
## This signal can be used to run gameplay code on state changes.
|
|
## [br][br]
|
|
## This signal is emitted whenever a transition happens during rollback, which
|
|
## means it may be emitted multiple times for the same transition if it gets
|
|
## resimulated during rollback.
|
|
## [br][br]
|
|
## [b]State changes are not necessarily emitted on all peers.[/b]
|
|
## See: [url=https://foxssake.github.io/netfox/netfox.extras/guides/rewindable-state-machine/#caveats]RewindableStateMachine caveats[/url]
|
|
signal on_state_changed(old_state: RewindableState, new_state: RewindableState)
|
|
|
|
## Emitted after the displayed state has changed.
|
|
##
|
|
## This signal can be used to update visuals based on state changes.
|
|
## [br][br]
|
|
## This signal is emitted whenever the state after a tick loop has changed.
|
|
signal on_display_state_changed(old_state: RewindableState, new_state: RewindableState)
|
|
|
|
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("RewindableStateMachine")
|
|
|
|
var _state_object: RewindableState = null
|
|
var _previous_state_object: RewindableState = null
|
|
var _available_states: Dictionary = {}
|
|
var _prevent_transition: bool = false
|
|
var _prevent_callable: Callable = func(): _prevent_transition = true
|
|
|
|
## Transition to a new state specified by [param new_state_name] and return
|
|
## true.
|
|
##
|
|
## Finds the given state by name and transitions to it if possible. The new
|
|
## state's [method RewindableState.can_enter] callback decides if it can be
|
|
## entered from the current state.
|
|
## [br][br]
|
|
## Upon transitioning, [method RewindableState.exit] is called on the old state,
|
|
## and [method RewindableState.enter] is called on the new state. In addition,
|
|
## [signal on_state_changed] is emitted.
|
|
## [br][br]
|
|
## Does nothing if transitioning to the currently active state. Emits a warning
|
|
## and does nothing when transitioning to an unknown state.
|
|
func transition(new_state_name: StringName) -> bool:
|
|
# Check if target state is valid
|
|
if state == new_state_name:
|
|
return false
|
|
|
|
if not _available_states.has(new_state_name):
|
|
_logger.warning("Attempted to transition from state '%s' into unknown state '%s'", [state, new_state_name])
|
|
return false
|
|
|
|
var from_state = _state_object
|
|
var new_state: RewindableState = _available_states[new_state_name]
|
|
_prevent_transition = false
|
|
|
|
# Validate transition
|
|
if from_state:
|
|
if !new_state.can_enter(_state_object):
|
|
return false
|
|
|
|
# Emit exit signal, allow handlers to prevent transition
|
|
_state_object.on_exit.emit(new_state, NetworkRollback.tick, _prevent_callable)
|
|
if _prevent_transition: return false
|
|
|
|
new_state.on_enter.emit(from_state, NetworkRollback.tick, _prevent_callable)
|
|
if _prevent_transition: return false
|
|
|
|
# Transition valid, run callbacks
|
|
if is_instance_valid(from_state):
|
|
from_state.exit(new_state, NetworkRollback.tick)
|
|
new_state.enter(from_state, NetworkRollback.tick)
|
|
|
|
# Set new state
|
|
_state_object = new_state
|
|
on_state_changed.emit(from_state, new_state)
|
|
|
|
return true
|
|
|
|
## Update the internal cache of known states
|
|
## [br][br]
|
|
## Automatically called on ready and when a child node is added or removed. Call
|
|
## manually to force an update.
|
|
func update_states() -> void:
|
|
_available_states.clear()
|
|
|
|
for child in find_children("*", "RewindableState", false):
|
|
_available_states[child.name] = child
|
|
|
|
func _notification(what: int):
|
|
# Use notification instead of _ready, so users can write their own _ready
|
|
# callback without having to call super()
|
|
if Engine.is_editor_hint(): return
|
|
|
|
match what:
|
|
NOTIFICATION_CHILD_ORDER_CHANGED:
|
|
update_states()
|
|
NOTIFICATION_ENTER_TREE:
|
|
# Compare states after tick loop
|
|
NetworkTime.after_tick_loop.connect(_after_tick_loop)
|
|
update_states()
|
|
NOTIFICATION_EXIT_TREE:
|
|
# Disconnect handlers
|
|
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
|
|
|
|
func _get_configuration_warnings():
|
|
const MISSING_SYNCHRONIZER_ERROR := \
|
|
"RewindableStateMachine is not managed by a RollbackSynchronizer! Add it as a sibling node to fix this."
|
|
const INVALID_SYNCHRONIZER_CONFIG_ERROR := \
|
|
"RollbackSynchronizer configuration is invalid, it can't manage this state machine!" +\
|
|
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
|
const MISSING_PROPERTY_ERROR := \
|
|
"State is not managed by RollbackSynchronizer! Add `state` property to the synchronizer to fix this. " +\
|
|
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
|
|
|
# Check if there's a rollback synchronizer
|
|
var rollback_synchronizer_node = get_parent().find_children("*", "RollbackSynchronizer", false).pop_front()
|
|
if not rollback_synchronizer_node:
|
|
return [MISSING_SYNCHRONIZER_ERROR]
|
|
|
|
var rollback_synchronizer := rollback_synchronizer_node as RollbackSynchronizer
|
|
|
|
# Check if its configuration is valid
|
|
# TODO: Expose this as a property?
|
|
if not rollback_synchronizer.root:
|
|
return [INVALID_SYNCHRONIZER_CONFIG_ERROR]
|
|
|
|
# Check if it manages our `state` property
|
|
for state_property_path in rollback_synchronizer.state_properties:
|
|
var property = PropertyEntry.parse(rollback_synchronizer.root, state_property_path)
|
|
if property.node == self and property.property == "state":
|
|
return []
|
|
|
|
return [MISSING_PROPERTY_ERROR]
|
|
|
|
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
|
if _state_object:
|
|
_state_object.tick(delta, tick, is_fresh)
|
|
_state_object.on_tick.emit(delta, tick, is_fresh)
|
|
|
|
func _after_tick_loop():
|
|
if _state_object != _previous_state_object:
|
|
on_display_state_changed.emit(_previous_state_object, _state_object)
|
|
|
|
if _previous_state_object:
|
|
_previous_state_object.on_display_exit.emit(_state_object, NetworkTime.tick)
|
|
_previous_state_object.display_exit(_state_object, NetworkTime.tick)
|
|
|
|
_state_object.on_display_enter.emit(_previous_state_object, NetworkTime.tick)
|
|
_state_object.display_enter(_previous_state_object, NetworkTime.tick)
|
|
|
|
_previous_state_object = _state_object
|
|
|
|
func _set_state(new_state: StringName) -> void:
|
|
if not new_state:
|
|
return
|
|
|
|
if not _available_states.has(new_state):
|
|
_logger.warning("Attempted to jump to unknown state: %s", [new_state])
|
|
return
|
|
|
|
_state_object = _available_states[new_state]
|