|
| 1 | +extends Node |
| 2 | + |
| 3 | +## Network Simulator |
| 4 | +## |
| 5 | +## Auto connects launched instances and simulates network conditions like |
| 6 | +## latency and packet loss. To use simply add this node to your scene tree and |
| 7 | +## hook up the signals. |
| 8 | + |
| 9 | +## Signal emitted on the instance that successfully started a server. |
| 10 | +## Can be used for custom initialization logic, e.g. automatic login during |
| 11 | +## testing. |
| 12 | +signal server_created |
| 13 | + |
| 14 | +## Signal emitted on instances that successfully connected as a client. |
| 15 | +## Can be used for custom initialization logic, e.g. automatic login during |
| 16 | +## testing. |
| 17 | +signal client_connected |
| 18 | + |
| 19 | +## Enable to automatically host and connect on start |
| 20 | +var enabled: bool = false |
| 21 | +## Server listening address. Use [code]*[/code] for all interfaces, or |
| 22 | +#3 [code]127.0.0.1[/code] for localhost. |
| 23 | +var hostname: String = "127.0.0.1" |
| 24 | + |
| 25 | +## Server port to listen on, UDP proxy will use port + 1 if simulating latency |
| 26 | +## or packet loss |
| 27 | +var server_port: int = 9999 |
| 28 | + |
| 29 | +## Use ENet's built-in range encoding for compression |
| 30 | +var use_compression: bool = true |
| 31 | + |
| 32 | +## Simulated latency in milliseconds. Total ping time will be double this value |
| 33 | +## (to and from) |
| 34 | +var latency_ms: int = 0 |
| 35 | +## Simulated packet loss percentage |
| 36 | +var packet_loss_percent: float = 0.0 |
| 37 | + |
| 38 | +static var _logger: _NetfoxLogger = _NetfoxLogger.for_extras("NetworkSimulator") |
| 39 | + |
| 40 | +var _enet_peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new() |
| 41 | + |
| 42 | +# UDP proxy |
| 43 | +var _proxy_thread: Thread |
| 44 | +var _udp_proxy_server: PacketPeerUDP |
| 45 | +var _udp_proxy_port: int |
| 46 | +var _rng_packet_loss: RandomNumberGenerator = RandomNumberGenerator.new() |
| 47 | + |
| 48 | +# Connection tracking |
| 49 | +var _client_peers: Dictionary = {} # port to PacketPeerUDP |
| 50 | +var _client_to_server_queue: Array[QueueEntry] = [] |
| 51 | +var _server_to_client_queue: Array[QueueEntry] = [] |
| 52 | + |
| 53 | +class QueueEntry: |
| 54 | + var packet_data: PackedByteArray |
| 55 | + var queued_at: int |
| 56 | + var source_port: int # Which client port this came from |
| 57 | + |
| 58 | + func _init(packet: PackedByteArray, timestamp: int, port: int) -> void: |
| 59 | + self.packet_data = packet |
| 60 | + self.queued_at = timestamp |
| 61 | + self.source_port = port |
| 62 | + |
| 63 | +func _ready() -> void: |
| 64 | + # Check if enabled |
| 65 | + if not OS.has_feature("editor"): |
| 66 | + _logger.debug("Running outside editor, disabling") |
| 67 | + return |
| 68 | + |
| 69 | + _load_project_settings() |
| 70 | + if not enabled: |
| 71 | + _logger.debug("Feature disabled") |
| 72 | + return |
| 73 | + |
| 74 | + for env_var in ["CI", "NETFOX_CI", "NETFOX_NO_AUTOCONNECT"]: |
| 75 | + if OS.get_environment(env_var) != "": |
| 76 | + _logger.debug("Environment variable %s set, disabling", [env_var]) |
| 77 | + return |
| 78 | + |
| 79 | + await get_tree().process_frame |
| 80 | + _udp_proxy_port = server_port + 1 |
| 81 | + |
| 82 | + var status = _try_and_host() |
| 83 | + if status == Error.ERR_CANT_CREATE: |
| 84 | + _try_and_join() |
| 85 | + elif status != OK: |
| 86 | + _logger.error("Autoconnect failed with error - %s", [error_string(status)]) |
| 87 | + |
| 88 | + if use_compression: |
| 89 | + _enet_peer.host.compress(ENetConnection.COMPRESS_RANGE_CODER) |
| 90 | + |
| 91 | + multiplayer.multiplayer_peer = _enet_peer |
| 92 | + |
| 93 | +func _is_proxy_required() -> bool: |
| 94 | + return latency_ms > 0 or packet_loss_percent > 0.0 |
| 95 | + |
| 96 | +func _try_and_host() -> Error: |
| 97 | + var status = _enet_peer.create_server(server_port) |
| 98 | + if status == OK: |
| 99 | + if _is_proxy_required(): |
| 100 | + _start_udp_proxy() |
| 101 | + server_created.emit() |
| 102 | + _logger.info("Server started on port %s", [server_port]) |
| 103 | + return status |
| 104 | + |
| 105 | +func _try_and_join() -> Error: |
| 106 | + var connect_port = server_port |
| 107 | + if _is_proxy_required(): |
| 108 | + connect_port = _udp_proxy_port |
| 109 | + var status = _enet_peer.create_client(hostname, connect_port) |
| 110 | + if status == OK: |
| 111 | + client_connected.emit() |
| 112 | + _logger.info("Client connected to %s:%s", [hostname, connect_port]) |
| 113 | + return status |
| 114 | + |
| 115 | +# Starts a UDP proxy server to simulate network conditions |
| 116 | +# This will listen on _udp_proxy_port and forward packets to the server_port |
| 117 | +# Runs on its own thread to avoid blocking the main thread |
| 118 | +func _start_udp_proxy() -> void: |
| 119 | + _proxy_thread = Thread.new() |
| 120 | + _udp_proxy_server = PacketPeerUDP.new() |
| 121 | + |
| 122 | + var bind_status = _udp_proxy_server.bind(_udp_proxy_port, hostname) |
| 123 | + if bind_status != OK: |
| 124 | + _logger.error("Failed to bind UDP proxy port: ", bind_status) |
| 125 | + return |
| 126 | + |
| 127 | + _proxy_thread.start(_process_loop) |
| 128 | + |
| 129 | +func _process_packets() -> void: |
| 130 | + var current_time: int = Time.get_ticks_msec() |
| 131 | + var send_threshold: int = current_time - latency_ms |
| 132 | + |
| 133 | + _read_client_to_server_packets(current_time) |
| 134 | + _process_client_to_server_packets(send_threshold) |
| 135 | + |
| 136 | + if not _client_peers.is_empty(): |
| 137 | + _read_server_to_client_packets(current_time) |
| 138 | + _process_server_to_client_queue(send_threshold) |
| 139 | + |
| 140 | +func _process_loop(): |
| 141 | + while true: |
| 142 | + _process_packets() |
| 143 | + OS.delay_msec(1) |
| 144 | + |
| 145 | +func _load_project_settings() -> void: |
| 146 | + enabled = ProjectSettings.get_setting(&"netfox/autoconnect/enabled", false) |
| 147 | + hostname = ProjectSettings.get_setting(&"netfox/autoconnect/host", "127.0.0.1") |
| 148 | + server_port = ProjectSettings.get_setting(&"netfox/autoconnect/port", 9999) |
| 149 | + use_compression = ProjectSettings.get_setting(&"netfox/autoconnect/use_compression", false) |
| 150 | + latency_ms = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_latency_ms", 0) |
| 151 | + packet_loss_percent = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_packet_loss_chance", 0.0) |
| 152 | + |
| 153 | +func _is_data_available() -> bool: |
| 154 | + if _udp_proxy_server.get_available_packet_count() > 0: |
| 155 | + return true |
| 156 | + |
| 157 | + if not _client_to_server_queue.is_empty() or not _server_to_client_queue.is_empty(): |
| 158 | + return true |
| 159 | + |
| 160 | + # Check if any client peers have packets waiting |
| 161 | + for client_peer in _client_peers.values(): |
| 162 | + if client_peer.get_available_packet_count() > 0: |
| 163 | + return true |
| 164 | + |
| 165 | + return false |
| 166 | + |
| 167 | +func _read_client_to_server_packets(current_time: int) -> void: |
| 168 | + while _udp_proxy_server.get_available_packet_count() > 0: |
| 169 | + var packet = _udp_proxy_server.get_packet() |
| 170 | + var err = _udp_proxy_server.get_packet_error() |
| 171 | + |
| 172 | + if err != OK: |
| 173 | + _logger.error("UDP proxy incoming packet error: ", err) |
| 174 | + continue |
| 175 | + |
| 176 | + var from_port = _udp_proxy_server.get_packet_port() |
| 177 | + _register_client_if_new(from_port) |
| 178 | + |
| 179 | + _client_to_server_queue.push_back(QueueEntry.new(packet, current_time, from_port)) |
| 180 | + |
| 181 | +func _register_client_if_new(port: int) -> void: |
| 182 | + if _client_peers.has(port): |
| 183 | + return |
| 184 | + |
| 185 | + # Create a dedicated peer for this client |
| 186 | + var client_peer = PacketPeerUDP.new() |
| 187 | + client_peer.set_dest_address(hostname, server_port) |
| 188 | + _client_peers[port] = client_peer |
| 189 | + |
| 190 | +func _process_client_to_server_packets(send_threshold: int) -> void: |
| 191 | + var packets_to_keep: Array[QueueEntry] = [] |
| 192 | + |
| 193 | + for entry in _client_to_server_queue: |
| 194 | + if send_threshold < entry.queued_at: |
| 195 | + packets_to_keep.append(entry) |
| 196 | + else: |
| 197 | + if _should_send_packet(): |
| 198 | + var peer = _client_peers[entry.source_port] as PacketPeerUDP |
| 199 | + peer.put_packet(entry.packet_data) |
| 200 | + |
| 201 | + _client_to_server_queue = packets_to_keep |
| 202 | + |
| 203 | +func _read_server_to_client_packets(current_time: int) -> void: |
| 204 | + for client_port in _client_peers.keys(): |
| 205 | + var client_peer = _client_peers[client_port] as PacketPeerUDP |
| 206 | + |
| 207 | + while client_peer.get_available_packet_count() > 0: |
| 208 | + var packet = client_peer.get_packet() |
| 209 | + var err = client_peer.get_packet_error() |
| 210 | + |
| 211 | + if err != OK: |
| 212 | + _logger.error("UDP proxy server-to-client packet error from port %s : %s", [client_port, err]) |
| 213 | + continue |
| 214 | + |
| 215 | + _server_to_client_queue.push_back(QueueEntry.new(packet, current_time, client_port)) |
| 216 | + |
| 217 | +func _process_server_to_client_queue(send_threshold: int) -> void: |
| 218 | + var packets_to_keep: Array[QueueEntry] = [] |
| 219 | + |
| 220 | + for entry in _server_to_client_queue: |
| 221 | + if send_threshold < entry.queued_at: |
| 222 | + packets_to_keep.append(entry) |
| 223 | + else: |
| 224 | + if _should_send_packet(): |
| 225 | + _udp_proxy_server.set_dest_address(hostname, entry.source_port) |
| 226 | + _udp_proxy_server.put_packet(entry.packet_data) |
| 227 | + |
| 228 | + _server_to_client_queue = packets_to_keep |
| 229 | + |
| 230 | +# Send packet or simulate loss |
| 231 | +func _should_send_packet() -> bool: |
| 232 | + return packet_loss_percent <= 0.0 or _rng_packet_loss.randf() >= (packet_loss_percent / 100.0) |
| 233 | + |
| 234 | +func _exit_tree() -> void: |
| 235 | + if _proxy_thread and _proxy_thread.is_started(): |
| 236 | + _proxy_thread.wait_to_finish() |
0 commit comments