Files
Steven Wroblewski be22e9f358 Abschlusskorrekturen M1c: Schläge der Gäste, Figuren und Fragebogen
- Der Host hält einen Schlag eines Gastes, der während der Abklingzeit
  ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa
  jeder fünfte Schlag eines Gastes verloren.
- ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines
  von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und
  eine stille Verbindung gilt nach höchstens acht Sekunden als verloren.
- Positionen, die keine Zahlen sind, und Figuren von Spielern, die
  nicht online sind, werden verworfen.
- Ein Host mit angehaltener Simulation sagt das in der Statuszeile.
- Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink,
  verworfene Figuren, Neuabgleich über die Brücke.
- Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in
  der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der
  Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die
  Vertrauensregel um die Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 01:22:36 +02:00

188 lines
6.5 KiB
GDScript

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 channel that is
## unreliable but ordered: a lost position is soon replaced by the next, and
## an older one that arrives after a newer one is dropped.
## 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
## Milliseconds after which a connection whose packets stay unanswered counts
## as lost. ENet's own limit of 30 seconds would leave a guest whose host
## vanished in a standing world for that long.
const TIMEOUT_MSEC := 8000
## Seconds this guest waits for its host. Tests shorten it.
var connect_seconds := CONNECT_SECONDS
var _peer: ENetMultiplayerPeer
var _peers: Dictionary[int, bool] = {}
## Connections that close_peer is closing. Nothing more goes to them: Godot
## reports an error for every packet to a connection in that state.
var _closing: 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()
_closing.clear()
_peer.peer_connected.connect(_on_connected)
_peer.peer_disconnected.connect(_on_disconnected)
func _on_connected(peer: int) -> void:
_peers[peer] = true
var connection := _peer.get_peer(peer)
# ENet slows unreliable packets down after a spike in the round trip and
# can keep them at nothing for the rest of a session, so that the figures
# of one guest stop. Positions are a few bytes ten times a second; they
# are never slowed down.
connection.throttle_configure(5000, ENetPacketPeer.PACKET_THROTTLE_SCALE, 0)
connection.set_timeout(32, 5000, TIMEOUT_MSEC)
_events.append({"type": "connected", "peer": peer})
func _on_disconnected(peer: int) -> void:
_peers.erase(peer)
_closing.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 or being closed 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_ORDERED)
func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
if not is_connected_to_peers():
return
_peer.set_transfer_channel(channel)
_peer.set_transfer_mode(mode)
# One packet per connection, so that no packet goes to a connection that
# is gone or being closed; Godot's own sending to all would.
for target in _targets(peer):
_peer.set_target_peer(target)
_peer.put_packet(bytes)
## The connections a packet for `peer` goes to: that one, every one if
## `peer` is 0, or every one but -`peer` if `peer` is negative. Connections
## that are being closed get nothing.
func _targets(peer: int) -> Array[int]:
var targets: Array[int] = []
for open: int in _peers:
if _closing.has(open):
continue
if peer == 0 or open == peer or (peer < 0 and open != -peer):
targets.append(open)
return targets
## Closes the connection to `peer` once the messages waiting for it are out.
func close_peer(peer: int) -> void:
if _peers.has(peer) and not _closing.has(peer):
_closing[peer] = true
_peer.get_peer(peer).peer_disconnect_later()
## Closes everything at once.
func close() -> void:
if is_open():
_peer.close()
_peers.clear()
_closing.clear()
_connecting = false