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,75 @@
|
||||
extends Node2D
|
||||
class_name NetworkWeapon2D
|
||||
|
||||
## A 2D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node2D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node2D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node2D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node2D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node2D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node2D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform2D
|
||||
var loc_transform = local_data["global_transform"] as Transform2D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node2D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform2D
|
||||
var remote_transform = remote_data["global_transform"] as Transform2D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8pw311ku24we
|
||||
@@ -0,0 +1,75 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeapon3D
|
||||
|
||||
## A 3D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node3D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node3D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node3D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node3D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node3D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node3D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform3D
|
||||
var loc_transform = local_data["global_transform"] as Transform3D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node3D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform3D
|
||||
var remote_transform = remote_data["global_transform"] as Transform3D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1xyp6tx4hf3p
|
||||
@@ -0,0 +1,127 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeaponHitscan3D
|
||||
|
||||
## A 3D-specific implementation of a networked hitscan (raycast) weapon.
|
||||
|
||||
## Maximum distance to cast the ray
|
||||
@export var max_distance: float = 1000.0
|
||||
|
||||
## Mask used to detect raycast hits
|
||||
@export_flags_3d_physics var collision_mask: int = 0xFFFFFFFF
|
||||
|
||||
## Colliders excluded from raycast hits
|
||||
@export var exclude: Array[RID] = []
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns true if the weapon was fired.
|
||||
func fire() -> bool:
|
||||
if not can_fire():
|
||||
return false
|
||||
|
||||
_apply_data(_get_data())
|
||||
_after_fire()
|
||||
return true
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_fire() -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _after_fire():
|
||||
pass
|
||||
|
||||
func _spawn():
|
||||
# No projectile is spawned for a hitscan weapon.
|
||||
pass
|
||||
|
||||
func _get_data() -> Dictionary:
|
||||
# Collect data needed to synchronize the firing event.
|
||||
return {
|
||||
"origin": global_transform.origin,
|
||||
"direction": -global_transform.basis.z # Assuming forward direction.
|
||||
}
|
||||
|
||||
func _apply_data(data: Dictionary):
|
||||
# Reproduces the firing event on all peers.
|
||||
var origin = data["origin"] as Vector3
|
||||
var direction = data["direction"] as Vector3
|
||||
|
||||
# Perform the raycast from origin in the given direction.
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
|
||||
# Create a PhysicsRayQueryParameters3D object.
|
||||
var ray_params = PhysicsRayQueryParameters3D.new()
|
||||
ray_params.from = origin
|
||||
ray_params.to = origin + direction * max_distance
|
||||
|
||||
# Set collision masks or exclude objects:
|
||||
ray_params.collision_mask = collision_mask
|
||||
ray_params.exclude = exclude
|
||||
|
||||
var result = space_state.intersect_ray(ray_params)
|
||||
|
||||
if result:
|
||||
# Handle the hit result, such as spawning hit effects.
|
||||
_on_hit(result)
|
||||
|
||||
# Play firing effects on all peers.
|
||||
_on_fire()
|
||||
|
||||
func _is_reconcilable(request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
# Always reconcilable
|
||||
return true
|
||||
|
||||
func _reconcile(local_data: Dictionary, remote_data: Dictionary):
|
||||
# Nothing to do on reconcile
|
||||
pass
|
||||
|
||||
## Override to implement raycast hit logic.
|
||||
## [br][br]
|
||||
## The parameter is the result of a
|
||||
## [method PhysicsDirectSpaceState3D.intersect_ray] call.
|
||||
func _on_hit(result: Dictionary):
|
||||
# Implement hit effect logic here.
|
||||
# var hit_position = result.position
|
||||
# var hit_normal = result.normal
|
||||
# var collider = result.collider
|
||||
|
||||
# For example, you might emit a signal or instantiate a hit effect scene:
|
||||
# emit_signal("hit_detected", hit_position, hit_normal, collider)
|
||||
pass
|
||||
|
||||
## Override to implement firing effects, like muzzle flash or sound.
|
||||
func _on_fire():
|
||||
# Implement firing effect logic here.
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://daf6uabdxsfwq
|
||||
@@ -0,0 +1,35 @@
|
||||
extends NetworkWeapon
|
||||
class_name _NetworkWeaponProxy
|
||||
|
||||
var c_can_fire: Callable
|
||||
var c_can_peer_use: Callable
|
||||
var c_after_fire: Callable
|
||||
var c_spawn: Callable
|
||||
var c_get_data: Callable
|
||||
var c_apply_data: Callable
|
||||
var c_is_reconcilable: Callable
|
||||
var c_reconcile: Callable
|
||||
|
||||
func _can_fire() -> bool:
|
||||
return c_can_fire.call()
|
||||
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return c_can_peer_use.call(peer_id)
|
||||
|
||||
func _after_fire(projectile: Node):
|
||||
c_after_fire.call(projectile)
|
||||
|
||||
func _spawn() -> Node:
|
||||
return c_spawn.call()
|
||||
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return c_get_data.call(projectile)
|
||||
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
c_apply_data.call(projectile, data)
|
||||
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return c_is_reconcilable.call(projectile, request_data, local_data)
|
||||
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
c_reconcile.call(projectile, local_data, remote_data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://76n8gu7udnum
|
||||
@@ -0,0 +1,223 @@
|
||||
extends Node
|
||||
class_name NetworkWeapon
|
||||
|
||||
## Base class for creating responsive weapons, by spawning projectiles locally,
|
||||
## but keeping control on the server.
|
||||
|
||||
var _projectiles: Dictionary = {}
|
||||
var _projectile_data: Dictionary = {}
|
||||
var _reconcile_buffer: Array = []
|
||||
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
var _fired_tick: int = -1
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("NetworkWeapon")
|
||||
|
||||
func _ready():
|
||||
_rng.randomize()
|
||||
NetworkTime.before_tick_loop.connect(_before_tick_loop)
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _can_fire()
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns null if the weapon can't be fired.
|
||||
func fire() -> Node:
|
||||
if not can_fire():
|
||||
return null
|
||||
|
||||
var id: String = _generate_id()
|
||||
var projectile = _spawn()
|
||||
_save_projectile(projectile, id)
|
||||
var data = _projectile_data[id]
|
||||
|
||||
if not is_multiplayer_authority():
|
||||
_request_projectile.rpc_id(get_multiplayer_authority(), id, NetworkTime.tick, data)
|
||||
else:
|
||||
_accept_projectile.rpc(id, NetworkTime.tick, data)
|
||||
|
||||
_logger.debug("Calling after fire hook for %s", [projectile.name])
|
||||
_fired_tick = NetworkTime.tick
|
||||
_after_fire(projectile)
|
||||
|
||||
return projectile
|
||||
|
||||
## Get the tick when the weapon was fired.
|
||||
## [br][br]
|
||||
## Whenever a weapon gets fired, it takes time for that event to be transmitted
|
||||
## to the server. To account for this latency, the exact tick is sent along
|
||||
## with other data, so weapon implementations can compensate for the latency.
|
||||
## [br][br]
|
||||
## One way to use this is to manually simulate the projectile after it's
|
||||
## created:
|
||||
## [codeblock]
|
||||
## func _after_fire(projectile: Node3D):
|
||||
## last_fire = get_fired_tick()
|
||||
## sound.play()
|
||||
##
|
||||
## for t in range(get_fired_tick(), NetworkTime.tick):
|
||||
## if projectile.is_queued_for_deletion():
|
||||
## break
|
||||
## projectile._tick(NetworkTime.ticktime, t)
|
||||
## [/codeblock]
|
||||
func get_fired_tick() -> int:
|
||||
return _fired_tick
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## This can be used to implement e.g. firing cooldowns and ammo checks.
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## Usually this should check if the weapon's owner is trying to fire it, but
|
||||
## for some special cases this can be some different logic, e.g. weapons that
|
||||
## can be used by any player on a given team.
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## This can be used to e.g. reset the firing cooldown or deduct ammo.
|
||||
func _after_fire(projectile: Node):
|
||||
pass
|
||||
|
||||
## Override this method to spawn and initialize a projectile.
|
||||
## [br][br]
|
||||
## Make sure to return the projectile spawned!
|
||||
func _spawn() -> Node:
|
||||
return null
|
||||
|
||||
## Override this method to extract projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This will be captured both locally and on the server, and will be used for
|
||||
## reconciliation.
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return {}
|
||||
|
||||
## Override this method to apply projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This is used in cases where some other client fires a weapon and the server
|
||||
## instructs us to spawn a projectile for it.
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
pass
|
||||
|
||||
## Override this method to check if two projectile states can be reconciled.
|
||||
## [br][br]
|
||||
## This can be used to prevent cheating, for example by not allowing the client
|
||||
## to say it's firing from the other side of the map compared to its actual
|
||||
## position.
|
||||
## [br][br]
|
||||
## When this method returns false, the server will decline the projectile
|
||||
## request.
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to reconcile the initial local and remote projectile
|
||||
## state.
|
||||
## [br][br]
|
||||
## Let's say the projectile travels in a straight line from its origin, but we
|
||||
## receive a different origin from the server. In this reconciliation step,
|
||||
## the projectile's position can be adjusted to account for the different origin.
|
||||
## [br][br]
|
||||
## Unless the use case is niche, the best practice is to consider the server's
|
||||
## state as authorative.
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
pass
|
||||
|
||||
func _save_projectile(projectile: Node, id: String, data: Dictionary = {}):
|
||||
_projectiles[id] = projectile
|
||||
projectile.name += " " + id
|
||||
projectile.set_multiplayer_authority(get_multiplayer_authority())
|
||||
|
||||
if data.is_empty():
|
||||
data = _get_data(projectile)
|
||||
|
||||
_projectile_data[id] = data
|
||||
|
||||
func _before_tick_loop():
|
||||
# Reconcile projectiles
|
||||
for recon in _reconcile_buffer:
|
||||
var projectile = recon[0]
|
||||
var local_data = recon[1]
|
||||
var response_data = recon[2]
|
||||
var projectile_id = recon[3]
|
||||
|
||||
if is_instance_valid(projectile):
|
||||
_reconcile(projectile, local_data, response_data)
|
||||
else:
|
||||
_logger.warning("Projectile %s vanished by the time of reconciliation!", [projectile_id])
|
||||
|
||||
_reconcile_buffer.clear()
|
||||
|
||||
func _generate_id(length: int = 12, charset: String = "abcdefghijklmnopqrstuvwxyz0123456789") -> String:
|
||||
var result = ""
|
||||
|
||||
# Generate a random ID
|
||||
for i in range(length):
|
||||
var idx = _rng.randi_range(0, charset.length() - 1)
|
||||
result += charset[idx]
|
||||
|
||||
return result
|
||||
|
||||
@rpc("any_peer", "reliable", "call_remote")
|
||||
func _request_projectile(id: String, tick: int, request_data: Dictionary):
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
|
||||
# Reject if sender can't use this input
|
||||
_fired_tick = tick
|
||||
if not _can_peer_use(sender) or not _can_fire():
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Peer %s can't use this weapon now", [id, sender])
|
||||
return
|
||||
|
||||
# Validate incoming data
|
||||
var projectile = _spawn()
|
||||
var local_data: Dictionary = _get_data(projectile)
|
||||
|
||||
if not _is_reconcilable(projectile, request_data, local_data):
|
||||
projectile.queue_free()
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Can't reconcile states: [%s, %s]", [id, request_data, local_data])
|
||||
return
|
||||
|
||||
_save_projectile(projectile, id, local_data)
|
||||
_accept_projectile.rpc(id, tick, local_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_local")
|
||||
func _accept_projectile(id: String, tick: int, response_data: Dictionary):
|
||||
if multiplayer.get_unique_id() == multiplayer.get_remote_sender_id():
|
||||
# Projectile is local, nothing to do
|
||||
return
|
||||
|
||||
_logger.info("Accepting projectile %s from %s", [id, multiplayer.get_remote_sender_id()])
|
||||
|
||||
if _projectiles.has(id):
|
||||
var projectile = _projectiles[id]
|
||||
var local_data = _projectile_data[id]
|
||||
_reconcile_buffer.push_back([projectile, local_data, response_data, id])
|
||||
else:
|
||||
_fired_tick = tick
|
||||
var projectile = _spawn()
|
||||
_apply_data(projectile, response_data)
|
||||
_projectile_data.erase(id)
|
||||
_save_projectile(projectile, id, response_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_remote")
|
||||
func _decline_projectile(id: String):
|
||||
if not _projectiles.has(id):
|
||||
return
|
||||
|
||||
var p = _projectiles[id]
|
||||
if is_instance_valid(p):
|
||||
p.queue_free()
|
||||
|
||||
_projectiles.erase(id)
|
||||
_projectile_data.erase(id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cill8uva6thl5
|
||||
Reference in New Issue
Block a user