Files
pirate-dice/scripts/net.gd
2026-09-16 12:09:47 +02:00

309 lines
9.5 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
## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein.
var rematch: Dictionary = {}
# ── 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, stufe := 1) -> 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,
"stufe": stufe})
## Der Host bittet den Server um den Start.
func request_start(ai_count: int, stufe := 1) -> void:
_req_start.rpc_id(1, ai_count, stufe)
@rpc("any_peer", "reliable")
func _req_start(ai_count: int, stufe: 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,
"stufe": clampi(stufe, 0, 2)}
_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)