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)