- KI-Bewertung mit Bedrohung und Führenden-Bremse (62:38 gegen die alte im Duell, gleichauf zu viert) - Balance: Sand +1/+1 statt Schild, Erde+Luft und Erde geschwächt, Wasser angehoben, Messlauf mit N=200 möglich - Einzelspieler-Kämpfe werden laufend gespeichert (voller Spielstand, viewer -1) und im Menü fortgesetzt - Karten, Deckzeilen und Kartenrücken fokussierbar: Gamepad in Deckbau, Werkstatt und Booster - Kartennamen (name_en) und Kartentexte übersetzbar, Übersetzungstest deckt cards.gd mit ab - Deck-Code DINGER1:id*anzahl mit Zwischenablage; acht neue Karten; Kampagne Kapitel 3; Elementbooster 120 Münzen - Netzwerk: Nachfolge-Host mit vollem Spielstand, Neuverbindung der anderen, Zuschauer (Sicht -2); Bot kann hosten - Große Schrift: Kartennamen, Kartentext, Knopfleisten, Gegnervorschau, Dialogtext - Anleitung 1 garantiert ein 1-Energie-Wesen; Tests: DINGER_ONLY für einzelne Oberflächentests - Oberflächentest auch auf Xephyr mit llvmpipe (wie Xvfb in der CI) grün Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
429 lines
13 KiB
GDScript
429 lines
13 KiB
GDScript
class_name Net
|
|
extends Node
|
|
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
|
|
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
|
|
|
|
signal lobby_changed
|
|
signal started(seat: int)
|
|
signal snapshot_received(snap: Dictionary)
|
|
signal action_applied
|
|
signal ended(reason: String)
|
|
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
|
|
signal status_changed
|
|
|
|
const PORT := 24455
|
|
|
|
var peer: ENetMultiplayerPeer
|
|
var is_host := false
|
|
var connected := false
|
|
var my_name := "Spieler"
|
|
var my_deck: Array = [] # Karten-IDs
|
|
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
|
|
var decks := {} # peer_id -> Karten-IDs (Host)
|
|
var seats := {} # peer_id -> Spielerindex (Host)
|
|
var my_seat := -1
|
|
var game: Game # Host
|
|
var status := ""
|
|
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
|
|
var rejoin_seconds := 60.0
|
|
var spectator := false # eigene Rolle: nur zuschauen
|
|
var spectators := {} # Host: peer_id -> Name
|
|
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
|
|
var i_am_successor := false
|
|
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
|
|
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
|
|
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
|
|
var reconnecting := false
|
|
|
|
|
|
func _ready() -> void:
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected)
|
|
multiplayer.connection_failed.connect(func():
|
|
if not reconnecting:
|
|
_fail("Verbindung fehlgeschlagen"))
|
|
multiplayer.server_disconnected.connect(_on_server_lost)
|
|
|
|
|
|
func host(port: int = PORT) -> Error:
|
|
peer = ENetMultiplayerPeer.new()
|
|
var err := peer.create_server(port, Game.MAX_OPPONENTS)
|
|
if err != OK:
|
|
status = "Kann nicht hosten (Port %d belegt?)" % port
|
|
return err
|
|
multiplayer.multiplayer_peer = peer
|
|
is_host = true
|
|
connected = true
|
|
names = {1: my_name}
|
|
decks = {1: my_deck}
|
|
spectators = {}
|
|
status = "Warte auf Mitspieler …"
|
|
lobby_changed.emit()
|
|
return OK
|
|
|
|
|
|
func join(ip: String, port: int = PORT) -> Error:
|
|
peer = ENetMultiplayerPeer.new()
|
|
var err := peer.create_client(ip, port)
|
|
if err != OK:
|
|
status = "Adresse ungültig"
|
|
return err
|
|
multiplayer.multiplayer_peer = peer
|
|
is_host = false
|
|
status = "Verbinde mit %s …" % ip
|
|
return OK
|
|
|
|
|
|
func leave() -> void:
|
|
if multiplayer.multiplayer_peer != null:
|
|
multiplayer.multiplayer_peer.close()
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
peer = null
|
|
is_host = false
|
|
connected = false
|
|
names = {}
|
|
decks = {}
|
|
seats = {}
|
|
waiting = {}
|
|
my_seat = -1
|
|
game = null
|
|
status = ""
|
|
spectator = false
|
|
spectators = {}
|
|
successor_ip = ""
|
|
i_am_successor = false
|
|
backup = {}
|
|
backup_names = {}
|
|
last_winner = ""
|
|
|
|
|
|
func local_addresses() -> Array:
|
|
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
|
|
|
|
|
|
func _fail(reason: String) -> void:
|
|
status = reason
|
|
ended.emit(reason)
|
|
leave()
|
|
|
|
|
|
func _on_connected() -> void:
|
|
connected = true
|
|
status = "Verbunden"
|
|
register.rpc_id(1, my_name, my_deck, spectator)
|
|
|
|
|
|
# --- Host-Wechsel ---------------------------------------------------------------
|
|
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
|
|
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
|
|
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
|
|
|
|
func _successor_id() -> int:
|
|
var ids: Array = seats.keys().filter(func(id): return id != 1)
|
|
ids.sort()
|
|
return ids[0] if not ids.is_empty() else -1
|
|
|
|
|
|
func _update_successor() -> void:
|
|
if not is_host or game == null:
|
|
return
|
|
var succ := _successor_id()
|
|
var ip := ""
|
|
if succ > 0 and peer != null and peer.get_peer(succ) != null:
|
|
ip = peer.get_peer(succ).get_remote_address()
|
|
for id in seats:
|
|
if id != 1:
|
|
set_successor.rpc_id(id, ip, id == succ)
|
|
|
|
|
|
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
|
|
func _seat_names() -> Dictionary:
|
|
var out := {}
|
|
for id in seats:
|
|
out[seats[id]] = names.get(id, "")
|
|
for pname in waiting:
|
|
out[waiting[pname].seat] = pname
|
|
return out
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func set_successor(ip: String, me: bool) -> void:
|
|
successor_ip = ip
|
|
i_am_successor = me
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
|
|
backup = snap
|
|
backup_names = seat_names
|
|
|
|
|
|
func _on_server_lost() -> void:
|
|
if is_host:
|
|
return
|
|
if last_winner != "":
|
|
_fail("Host hat das Spiel beendet")
|
|
elif i_am_successor and not backup.is_empty():
|
|
_become_host()
|
|
elif successor_ip != "":
|
|
status = "Host weg, verbinde mit dem Nachfolger …"
|
|
status_changed.emit()
|
|
_reconnect(successor_ip)
|
|
else:
|
|
_fail("Host hat das Spiel beendet")
|
|
|
|
|
|
func _become_host() -> void:
|
|
var snap := backup
|
|
var seat_names := backup_names
|
|
var seat := my_seat
|
|
if multiplayer.multiplayer_peer != null:
|
|
multiplayer.multiplayer_peer.close()
|
|
var err := FAILED
|
|
for attempt in 5:
|
|
err = host()
|
|
if err == OK:
|
|
break
|
|
await get_tree().create_timer(1.0).timeout
|
|
if err != OK:
|
|
_fail("Host weg, Übernahme fehlgeschlagen")
|
|
return
|
|
game = Game.from_snapshot(snap)
|
|
seats = {1: seat}
|
|
my_seat = seat
|
|
waiting = {}
|
|
for s in seat_names:
|
|
if int(s) != seat:
|
|
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
|
|
game.say("%s übernimmt als Host." % my_name)
|
|
status = "Du bist jetzt Host, die anderen verbinden neu …"
|
|
successor_ip = ""
|
|
i_am_successor = false
|
|
backup = {}
|
|
migrated.emit()
|
|
|
|
|
|
func _reconnect(ip: String) -> void:
|
|
reconnecting = true
|
|
await get_tree().create_timer(2.0).timeout
|
|
for attempt in 5:
|
|
if join(ip) == OK:
|
|
var t := 0.0
|
|
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
|
|
await get_tree().process_frame
|
|
t += get_process_delta_time()
|
|
if connected:
|
|
reconnecting = false
|
|
return
|
|
await get_tree().create_timer(1.0).timeout
|
|
reconnecting = false
|
|
_fail("Nachfolge-Host nicht erreichbar")
|
|
|
|
|
|
func _on_peer_connected(_id: int) -> void:
|
|
pass
|
|
|
|
|
|
func _on_peer_disconnected(id: int) -> void:
|
|
if not is_host:
|
|
return
|
|
var pname: String = names.get(id, "")
|
|
names.erase(id)
|
|
decks.erase(id)
|
|
spectators.erase(id)
|
|
if game != null and seats.has(id):
|
|
var p: Game.Player = game.players[seats[id]]
|
|
if game.winner == "":
|
|
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
|
|
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
|
|
seats.erase(id)
|
|
_update_successor()
|
|
action_applied.emit()
|
|
lobby_update.rpc(names, spectators)
|
|
lobby_changed.emit()
|
|
|
|
|
|
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
|
|
func tick() -> void:
|
|
if not is_host or game == null or game.winner != "":
|
|
return
|
|
var now := Time.get_unix_time_from_system()
|
|
for pname in waiting.keys():
|
|
var w: Dictionary = waiting[pname]
|
|
var p: Game.Player = game.players[w.seat]
|
|
if now - float(w.since) > rejoin_seconds:
|
|
waiting.erase(pname)
|
|
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
|
|
game.damage(p, 999)
|
|
game.check_end()
|
|
action_applied.emit()
|
|
elif game.current() == p:
|
|
p.mulligan_done = true
|
|
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
|
|
game.end_turn()
|
|
action_applied.emit()
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
|
|
if not is_host:
|
|
return
|
|
var id := multiplayer.get_remote_sender_id()
|
|
if as_spectator:
|
|
spectators[id] = name
|
|
lobby_update.rpc(names, spectators)
|
|
lobby_changed.emit()
|
|
if game != null:
|
|
start_game.rpc_id(id, -1)
|
|
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
|
return
|
|
if game != null:
|
|
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
|
|
if waiting.has(name):
|
|
seats[id] = waiting[name].seat
|
|
names[id] = name
|
|
waiting.erase(name)
|
|
game.say("%s ist zurück." % name)
|
|
start_game.rpc_id(id, seats[id])
|
|
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
|
_update_successor()
|
|
action_applied.emit()
|
|
else:
|
|
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
|
return
|
|
names[id] = name
|
|
decks[id] = deck_ids
|
|
lobby_update.rpc(names, spectators)
|
|
lobby_changed.emit()
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
|
|
names = n
|
|
spectators = sp
|
|
lobby_changed.emit()
|
|
|
|
|
|
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
|
|
func start_battle(n_ai: int) -> void:
|
|
var ids: Array = names.keys()
|
|
ids.sort()
|
|
var r := Game.make_rng()
|
|
var players := []
|
|
seats = {}
|
|
for id in ids:
|
|
var deck_ids: Array = decks.get(id, [])
|
|
var deck := []
|
|
for cid in deck_ids:
|
|
if Cards.has_id(cid):
|
|
deck.append(Cards.by_id(cid))
|
|
if Game.validate_deck(deck)[0] == false:
|
|
var pool: Array = Cards.ELEMENTS.duplicate()
|
|
Game.shuffle(pool, r)
|
|
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
|
|
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
|
|
for i in n_ai:
|
|
players.append(Game.random_opponent(r, i + 1))
|
|
game = Game.new(players[0], players.slice(1), r)
|
|
game.start()
|
|
for id in ids:
|
|
seats[id] = game.players.find(players[ids.find(id)])
|
|
my_seat = seats[1]
|
|
for id in ids:
|
|
if id != 1:
|
|
start_game.rpc_id(id, seats[id])
|
|
for id in spectators:
|
|
start_game.rpc_id(id, -1)
|
|
_update_successor()
|
|
started.emit(my_seat)
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func start_game(seat: int) -> void:
|
|
my_seat = seat
|
|
started.emit(seat)
|
|
|
|
|
|
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
|
|
## der Nachfolger zusätzlich den vollen Spielstand).
|
|
func broadcast() -> void:
|
|
if not is_host or game == null:
|
|
return
|
|
for id in seats:
|
|
if id != 1:
|
|
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
|
for id in spectators:
|
|
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
|
var succ := _successor_id()
|
|
if succ > 0:
|
|
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func receive_snapshot(snap: Dictionary) -> void:
|
|
last_winner = str(snap.get("winner", ""))
|
|
snapshot_received.emit(snap)
|
|
|
|
|
|
## Mitspieler: Aktion an den Host schicken.
|
|
func send_action(action: Dictionary) -> void:
|
|
if is_host:
|
|
_apply(1, action)
|
|
else:
|
|
request_action.rpc_id(1, action)
|
|
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func request_action(action: Dictionary) -> void:
|
|
if not is_host:
|
|
return
|
|
_apply(multiplayer.get_remote_sender_id(), action)
|
|
|
|
|
|
func _apply(id: int, action: Dictionary) -> void:
|
|
if game == null or not seats.has(id):
|
|
return
|
|
var p: Game.Player = game.players[seats[id]]
|
|
if game.current() != p or game.winner != "":
|
|
return
|
|
match str(action.get("type", "")):
|
|
"play":
|
|
var idx := int(action.get("hand", -1))
|
|
if idx < 0 or idx >= p.hand.size():
|
|
return
|
|
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
|
|
"attack":
|
|
var cr := game.find_creature(int(action.get("uid", -1)))
|
|
var t = resolve_ref(action.get("target"))
|
|
if cr != null and t != null:
|
|
game.attack(cr, t)
|
|
"mulligan":
|
|
var cards := []
|
|
for i in action.get("hand", []):
|
|
if int(i) >= 0 and int(i) < p.hand.size():
|
|
cards.append(p.hand[int(i)])
|
|
game.mulligan(p, cards)
|
|
"end":
|
|
game.end_turn()
|
|
_:
|
|
return
|
|
action_applied.emit()
|
|
|
|
|
|
func resolve_ref(ref):
|
|
if ref == null or not (ref is Dictionary):
|
|
return null
|
|
if ref.get("kind") == "creature":
|
|
return game.find_creature(int(ref.get("uid", -1)))
|
|
var i := int(ref.get("index", -1))
|
|
if i >= 0 and i < game.players.size():
|
|
return game.players[i]
|
|
return null
|
|
|
|
|
|
static func ref_dict(target) -> Dictionary:
|
|
if target is Game.Creature:
|
|
return {"kind": "creature", "uid": target.uid}
|
|
return {"kind": "player", "index": target.order}
|