Der Client steckte die Partie in einen SubViewport. Der rendert mit der
Basisaufloesung 1280x720 und wurde danach hochskaliert - das Bild wurde
matschig und die Kantenglaettung fiel weg. Den eigenen Physikraum braucht
aber nur der Server, weil dort viele Partien nebeneinander laufen. Also
bekommt nur er SubViewports; der Client haengt die Partie direkt in die
Szene. Die Knotennamen bleiben gleich, daran haengt das RPC-Ziel.
Der Healthcheck oeffnete alle 30 Sekunden eine nackte TCP-Verbindung auf
den WebSocket-Port. Der Server lehnte sie ab ("Missing or invalid header
'upgrade'"), und dabei ging reproduzierbar ein Paket an einen echten
Spieler verloren. Traefik braucht den Check nicht, also raus damit.
Blieb ein seltener Verlust des Startpakets: der Spieler sass in der Lobby,
waehrend die anderen spielten. Der Server legt jetzt nach, bis jeder den
Start quittiert hat.
Ein Verbindungsabbruch im Menue lud ausserdem die Szene neu und warf die
Meldung dazu gleich wieder weg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
9.3 KiB
GDScript
305 lines
9.3 KiB
GDScript
extends Node
|
|
## Netzwerk: ein Server, viele Räume, Beitritt per Code.
|
|
##
|
|
## Der Server hält alle Räume in einem einzigen Prozess und lauscht auf einem
|
|
## einzigen WebSocket-Port. Damit braucht es weder einen Portbereich noch einen
|
|
## Prozess je Partie. Ein Raum ist ein Knoten unter /root/Main/Raeume; sein
|
|
## SubViewport bringt einen eigenen Physikraum mit, sonst würden die Würfel
|
|
## aller Partien im selben Raum liegen.
|
|
##
|
|
## Die Knotenpfade müssen auf Server und Client gleich heißen - daran hängt
|
|
## das Ziel jedes RPC.
|
|
|
|
signal lobby_changed
|
|
signal game_started(code: String, cfg: Dictionary)
|
|
signal room_closed(code: String)
|
|
signal connection_failed(reason: String)
|
|
signal server_closed
|
|
|
|
const MAX_HUMANS := 8
|
|
const STANDARD_SERVER := "wss://dice.marha.de"
|
|
## Ohne I, O, 0 und 1: die verwechselt man beim Vorlesen
|
|
const ALPHABET := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
const CODE_LAENGE := 4
|
|
const SOLO := "SOLO"
|
|
|
|
# ── Client ───────────────────────────────────────────────────────────────────
|
|
var player_name := "Spieler"
|
|
var lobby: Dictionary = {}
|
|
var owner_id := 0
|
|
var is_owner := false
|
|
var room_code := ""
|
|
var in_game := false
|
|
|
|
# ── Server ───────────────────────────────────────────────────────────────────
|
|
var dedicated := false
|
|
## code -> {lobby: {peer: name}, owner: int, in_game: bool}
|
|
var raeume: Dictionary = {}
|
|
## peer -> code
|
|
var peer_raum: Dictionary = {}
|
|
|
|
var _wunsch: Dictionary = {}
|
|
var _rng := RandomNumberGenerator.new()
|
|
|
|
|
|
func _ready() -> void:
|
|
_rng.randomize()
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected)
|
|
multiplayer.connection_failed.connect(_on_connect_fail)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
|
|
## Zum Entwickeln auf den lokalen Server zeigen lassen:
|
|
## PIRATE_DICE_SERVER=ws://127.0.0.1:8000 godot
|
|
func server_url() -> String:
|
|
var eigen := OS.get_environment("PIRATE_DICE_SERVER")
|
|
return eigen if eigen != "" else STANDARD_SERVER
|
|
|
|
|
|
# ── Verbinden ────────────────────────────────────────────────────────────────
|
|
|
|
func create_room(pname: String) -> void:
|
|
_verbinden({"art": "erstellen"}, pname)
|
|
|
|
|
|
func join_room(code: String, pname: String) -> void:
|
|
code = code.strip_edges().to_upper()
|
|
if code == "":
|
|
connection_failed.emit("Kein Code eingegeben.")
|
|
return
|
|
_verbinden({"art": "beitreten", "code": code}, pname)
|
|
|
|
|
|
## Der Wunsch wartet, bis die Verbindung steht - vorher kann er nicht raus.
|
|
func _verbinden(wunsch: Dictionary, pname: String) -> void:
|
|
player_name = pname
|
|
_wunsch = wunsch
|
|
var peer := WebSocketMultiplayerPeer.new()
|
|
if peer.create_client(server_url()) != OK:
|
|
connection_failed.emit("Server nicht erreichbar.")
|
|
return
|
|
multiplayer.multiplayer_peer = peer
|
|
|
|
|
|
## Nur die headless Instanz auf dem Server ruft das auf.
|
|
func serve(port: int) -> bool:
|
|
var peer := WebSocketMultiplayerPeer.new()
|
|
if peer.create_server(port) != OK:
|
|
push_error("Port %d ist belegt." % port)
|
|
return false
|
|
multiplayer.multiplayer_peer = peer
|
|
dedicated = true
|
|
print("Server lauscht auf Port %d." % port)
|
|
return true
|
|
|
|
|
|
func leave() -> void:
|
|
if multiplayer.multiplayer_peer != null:
|
|
multiplayer.multiplayer_peer.close()
|
|
multiplayer.multiplayer_peer = null
|
|
lobby.clear()
|
|
owner_id = 0
|
|
is_owner = false
|
|
room_code = ""
|
|
in_game = false
|
|
lobby_changed.emit()
|
|
|
|
|
|
func is_online() -> bool:
|
|
var peer := multiplayer.multiplayer_peer
|
|
return peer != null \
|
|
and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
|
|
|
|
|
|
func _on_connected() -> void:
|
|
if _wunsch.get("art", "") == "erstellen":
|
|
_req_create.rpc_id(1, player_name)
|
|
else:
|
|
_req_join.rpc_id(1, String(_wunsch.get("code", "")), player_name)
|
|
_wunsch = {}
|
|
|
|
|
|
func _on_connect_fail() -> void:
|
|
multiplayer.multiplayer_peer = null
|
|
connection_failed.emit("Server nicht erreichbar.")
|
|
|
|
|
|
func _on_server_disconnected() -> void:
|
|
multiplayer.multiplayer_peer = null
|
|
lobby.clear()
|
|
owner_id = 0
|
|
is_owner = false
|
|
in_game = false
|
|
server_closed.emit()
|
|
|
|
|
|
# ── Räume (nur Server) ───────────────────────────────────────────────────────
|
|
|
|
func _neuer_code() -> String:
|
|
while true:
|
|
var code := ""
|
|
for i in CODE_LAENGE:
|
|
code += ALPHABET[_rng.randi_range(0, ALPHABET.length() - 1)]
|
|
if not raeume.has(code):
|
|
return code
|
|
return ""
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _req_create(pname: String) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var id := multiplayer.get_remote_sender_id()
|
|
var code := _neuer_code()
|
|
raeume[code] = {"lobby": {id: pname.substr(0, 16)}, "owner": id, "in_game": false,
|
|
"bestaetigt": {}}
|
|
peer_raum[id] = code
|
|
print("Raum %s aufgemacht (%d offen)." % [code, raeume.size()])
|
|
_lobby_senden(code)
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _req_join(code: String, pname: String) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var id := multiplayer.get_remote_sender_id()
|
|
code = code.strip_edges().to_upper()
|
|
var raum: Dictionary = raeume.get(code, {})
|
|
if raum.is_empty():
|
|
_abgewiesen.rpc_id(id, "Den Code %s gibt es nicht." % code)
|
|
return
|
|
if raum.in_game:
|
|
_abgewiesen.rpc_id(id, "In dem Raum läuft schon eine Partie.")
|
|
return
|
|
if raum.lobby.size() >= MAX_HUMANS:
|
|
_abgewiesen.rpc_id(id, "Der Raum ist voll.")
|
|
return
|
|
raum.lobby[id] = pname.substr(0, 16)
|
|
peer_raum[id] = code
|
|
_lobby_senden(code)
|
|
|
|
|
|
## Ein Broadcast würde alle Räume treffen, also gezielt an die eigenen Peers.
|
|
func _lobby_senden(code: String) -> void:
|
|
var raum: Dictionary = raeume.get(code, {})
|
|
if raum.is_empty():
|
|
return
|
|
for peer: int in raum.lobby:
|
|
_push_lobby.rpc_id(peer, code, raum.lobby, raum.owner)
|
|
|
|
|
|
func _on_peer_disconnected(id: int) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var code: String = peer_raum.get(id, "")
|
|
peer_raum.erase(id)
|
|
var raum: Dictionary = raeume.get(code, {})
|
|
if raum.is_empty():
|
|
return
|
|
raum.lobby.erase(id)
|
|
# Der letzte macht das Licht aus: leerer Raum, weg damit.
|
|
if raum.lobby.is_empty():
|
|
raeume.erase(code)
|
|
room_closed.emit(code)
|
|
print("Raum %s zu (%d offen)." % [code, raeume.size()])
|
|
return
|
|
# Geht der Host, rückt der nächste nach - sonst startet niemand mehr
|
|
if id == raum.owner:
|
|
raum.owner = int(raum.lobby.keys()[0])
|
|
_lobby_senden(code)
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _abgewiesen(grund: String) -> void:
|
|
connection_failed.emit(grund)
|
|
leave()
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
|
|
room_code = code
|
|
lobby = data
|
|
owner_id = besitzer
|
|
is_owner = besitzer == multiplayer.get_unique_id()
|
|
lobby_changed.emit()
|
|
|
|
|
|
# ── Start ────────────────────────────────────────────────────────────────────
|
|
|
|
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
|
|
func start_singleplayer(pname: String, ai_count: int) -> void:
|
|
player_name = pname
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
room_code = SOLO
|
|
lobby = {1: pname}
|
|
owner_id = 1
|
|
is_owner = true
|
|
in_game = true
|
|
lobby_changed.emit()
|
|
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count})
|
|
|
|
|
|
## Der Host bittet den Server um den Start.
|
|
func request_start(ai_count: int) -> void:
|
|
_req_start.rpc_id(1, ai_count)
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _req_start(ai_count: int) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var id := multiplayer.get_remote_sender_id()
|
|
var code: String = peer_raum.get(id, "")
|
|
var raum: Dictionary = raeume.get(code, {})
|
|
if raum.is_empty() or raum.in_game or id != raum.owner:
|
|
return
|
|
var order: Array = raum.lobby.keys()
|
|
order.sort()
|
|
if order.size() + ai_count < 2:
|
|
return
|
|
raum.in_game = true
|
|
var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count}
|
|
_start_zustellen(code, cfg)
|
|
game_started.emit(code, cfg)
|
|
|
|
|
|
## Ein verlorenes Startpaket lässt den Spieler in der Lobby sitzen, während
|
|
## die anderen spielen. Deshalb wird nachgelegt, bis jeder quittiert hat.
|
|
func _start_zustellen(code: String, cfg: Dictionary) -> void:
|
|
for versuch in 4:
|
|
var raum: Dictionary = raeume.get(code, {})
|
|
if raum.is_empty():
|
|
return
|
|
var offen: Array = []
|
|
for peer: int in raum.lobby:
|
|
if not raum.bestaetigt.has(peer):
|
|
offen.append(peer)
|
|
if offen.is_empty():
|
|
return
|
|
if versuch > 0:
|
|
print("Start an %s noch einmal (Versuch %d)." % [str(offen), versuch + 1])
|
|
for peer: int in offen:
|
|
_begin.rpc_id(peer, code, cfg)
|
|
await get_tree().create_timer(1.5).timeout
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _start_erhalten() -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var id := multiplayer.get_remote_sender_id()
|
|
var raum: Dictionary = raeume.get(peer_raum.get(id, ""), {})
|
|
if not raum.is_empty():
|
|
raum.bestaetigt[id] = true
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _begin(code: String, cfg: Dictionary) -> void:
|
|
_start_erhalten.rpc_id(1)
|
|
if in_game and room_code == code:
|
|
return # Nachzügler-Paket, die Partie läuft hier schon
|
|
in_game = true
|
|
room_code = code
|
|
game_started.emit(code, cfg)
|