Verbindung über ENet

NetLink verbindet Host und Gäste über ENet: Nachrichten des Kerns
zuverlässig und in Reihenfolge, Positionen der Figuren unzuverlässig.
Eine Verbindung schließt erst, wenn wartende Nachrichten draußen sind.
SessionLink trägt die Nachrichten einer Sitzung über die Verbindung und
reicht Positionen weiter. Die Tests verbinden zwei Seiten über die
Loopback-Schnittstelle.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 19:53:04 +02:00
parent 6c7c5bffc2
commit 0917450014
8 changed files with 533 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
class_name NetLink
extends RefCounted
## The network connection of a session, over ENet. A host listens for guests;
## a guest connects to its host. Messages of the core travel on a reliable,
## ordered channel. Positions of figures travel on a second, unreliable
## channel, because a lost one is soon replaced by the next.
## Port a host listens on unless told otherwise.
const DEFAULT_PORT := 24680
## Most guests that may connect to a host. More than can play, so that the
## core can tell the extra ones why it turns them away.
const MAX_CONNECTIONS := 8
const CHANNEL_CORE := 0
const CHANNEL_FIGURES := 1
## Seconds a guest waits for its host to answer before it gives up.
const CONNECT_SECONDS := 8.0
## Seconds this guest waits for its host. Tests shorten it.
var connect_seconds := CONNECT_SECONDS
var _peer: ENetMultiplayerPeer
var _peers: Dictionary[int, bool] = {}
var _events: Array[Dictionary] = []
var _connecting := false
var _waited := 0.0
## Splits "host" or "host:port" into [host, port], with DEFAULT_PORT if the
## text names none. Returns an empty array if `text` is no such address.
static func split_address(text: String) -> Array:
var parts := text.strip_edges().split(":")
if parts[0].is_empty() or parts.size() > 2:
return []
if parts.size() == 1:
return [parts[0], DEFAULT_PORT]
if not parts[1].is_valid_int() or parts[1].to_int() < 1 or parts[1].to_int() > 65535:
return []
return [parts[0], parts[1].to_int()]
## Starts listening for guests on `port`, or on a free port that the system
## chooses if `port` is 0; see local_port(). `address` limits listening to
## one network interface, for example "127.0.0.1"; "*" listens on all.
func host(port: int, address := "*") -> Error:
_start()
_peer.set_bind_ip(address)
return _peer.create_server(port, MAX_CONNECTIONS, 2)
## The port a host listens on, or 0.
func local_port() -> int:
if not is_open() or _peer.host == null:
return 0
return _peer.host.get_local_port()
## Starts connecting to the host at `address` and `port`.
func join(address: String, port: int) -> Error:
_start()
var error := _peer.create_client(address, port, 2)
_connecting = error == OK
_waited = 0.0
return error
func _start() -> void:
close()
_peer = ENetMultiplayerPeer.new()
_peers.clear()
_peer.peer_connected.connect(_on_connected)
_peer.peer_disconnected.connect(_on_disconnected)
func _on_connected(peer: int) -> void:
_peers[peer] = true
_events.append({"type": "connected", "peer": peer})
func _on_disconnected(peer: int) -> void:
_peers.erase(peer)
_events.append({"type": "disconnected", "peer": peer})
## True while the connection is up or being made.
func is_open() -> bool:
return _peer != null and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
## True when a guest has reached its host, or a host is listening.
func is_connected_to_peers() -> bool:
return _peer != null and _peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
## Returns what happened since the last call, oldest first, as dictionaries
## with a `type` entry: "connected" and "disconnected" (entry `peer`),
## "message" and "figure" (entries `peer` and `bytes`), and "failed" when a
## guest could not reach its host. `delta` is the time since the last call.
func poll(delta: float) -> Array[Dictionary]:
if is_open():
_peer.poll()
while _peer.get_available_packet_count() > 0:
var from := _peer.get_packet_peer()
var channel := _peer.get_packet_channel()
var bytes := _peer.get_packet()
var type := "message" if channel == CHANNEL_CORE else "figure"
_events.append({"type": type, "peer": from, "bytes": bytes})
if _connecting:
_waited += delta
if is_connected_to_peers():
_connecting = false
elif not is_open() or _waited > connect_seconds:
_connecting = false
close()
_events.append({"type": "failed"})
var events := _events
_events = []
return events
## Sends a message of the core to `peer`, reliably and in order. A message
## to a connection that is gone is dropped.
func send(peer: int, bytes: PackedByteArray) -> void:
_put(peer, bytes, CHANNEL_CORE, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
## Sends the position of a figure to `peer`, to every connection if `peer`
## is 0, or to every connection but one if `peer` is that one negated.
func send_figure(peer: int, bytes: PackedByteArray) -> void:
_put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
if not is_connected_to_peers() or _peers.is_empty():
return
if peer > 0 and not _peers.has(peer):
return
_peer.set_target_peer(peer)
_peer.set_transfer_channel(channel)
_peer.set_transfer_mode(mode)
_peer.put_packet(bytes)
## Closes the connection to `peer` once the messages waiting for it are out.
func close_peer(peer: int) -> void:
if _peers.has(peer):
_peer.get_peer(peer).peer_disconnect_later()
## Closes everything at once.
func close() -> void:
if is_open():
_peer.close()
_peers.clear()
_connecting = false
+1
View File
@@ -0,0 +1 @@
uid://dmyy8qbtuhhdh
+103
View File
@@ -0,0 +1,103 @@
class_name SessionLink
extends RefCounted
## Carries the messages of a GwSession over a NetLink, and the positions of
## the figures, which are not part of the shared world. Tells the game what
## happened on the way.
## Bytes of a position: x and y as 32-bit floats.
const POSITION_BYTES := 8
var session: GwSession
var link: NetLink
func _init(game_session: GwSession, net_link: NetLink) -> void:
session = game_session
link = net_link
## Moves messages in both directions and returns notices, oldest first, as
## dictionaries with a `type` entry:
## - "guest_arrived" and "guest_left" (entry `peer`), at the host
## - "world_received" (entry `resync`), "refused" (entry `reason`),
## "protocol_error", "host_lost" and "failed", at a guest
## - "divergence" (entries `peer`, `tick`, `hash`), at the host
## - "figure" (entries `player`, `position`), everywhere
## `delta` is the time since the last call.
func pump(delta: float) -> Array[Dictionary]:
var notices: Array[Dictionary] = []
for event in link.poll(delta):
match event["type"]:
"connected":
if session.is_host():
session.connect_peer(event["peer"])
notices.append({"type": "guest_arrived", "peer": event["peer"]})
"disconnected":
if session.is_host():
session.disconnect_peer(event["peer"])
notices.append({"type": "guest_left", "peer": event["peer"]})
else:
notices.append({"type": "host_lost"})
"failed":
notices.append({"type": "failed"})
"message":
notices.append_array(session.receive(event["peer"], event["bytes"]))
"figure":
_receive_figure(event["peer"], event["bytes"], notices)
for divergence in session.take_divergences():
divergence["type"] = "divergence"
notices.append(divergence)
flush()
return notices
## Sends what the session has for the network and closes the connections
## the host gave up. A guest's messages wait until its host is reached.
func flush() -> void:
if not link.is_connected_to_peers():
return
for message in session.take_messages():
link.send(message["peer"], message["bytes"])
for peer in session.take_closing():
link.close_peer(peer)
## Tells the other machines where this machine's figure stands.
func send_figure(position: Vector2) -> void:
if session.is_host():
var player := session.local_player()
if player >= 0:
link.send_figure(0, _figure_bytes(player, position))
elif session.local_player() >= 0:
link.send_figure(GwSession.HOST_PEER, _position_bytes(position))
func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionary]) -> void:
if session.is_host():
# A guest sends only a position; the host knows whose it is and
# passes it on to every other guest.
var player := session.player_of_peer(peer)
if player < 0 or bytes.size() != POSITION_BYTES:
return
var position := Vector2(bytes.decode_float(0), bytes.decode_float(4))
notices.append({"type": "figure", "player": player, "position": position})
link.send_figure(-peer, _figure_bytes(player, position))
elif bytes.size() == 1 + POSITION_BYTES:
var player := bytes.decode_u8(0)
if player != session.local_player():
var position := Vector2(bytes.decode_float(1), bytes.decode_float(5))
notices.append({"type": "figure", "player": player, "position": position})
func _position_bytes(position: Vector2) -> PackedByteArray:
var bytes := PackedByteArray()
bytes.resize(POSITION_BYTES)
bytes.encode_float(0, position.x)
bytes.encode_float(4, position.y)
return bytes
func _figure_bytes(player: int, position: Vector2) -> PackedByteArray:
var bytes := PackedByteArray([player])
bytes.append_array(_position_bytes(position))
return bytes
+1
View File
@@ -0,0 +1 @@
uid://dpau4ls365tsj