Files
shawn e7299b17e9 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)
2026-07-02 17:39:22 -04:00

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]