Das echte Spiel umging die Sitzung bisher bei Bereitschaft, Startaufstellung und Rückgängig, ließ Bots den Gast-Sitz planen und den Gast selbst simulieren. Jetzt rechnet nur der Host; Gäste zeigen den Kampf aus Momentaufnahmen. Dazu Protokollversion, Wiedereinstieg über den Anzeigenamen, verbindliches Bereit, Entwurfs-Privatsphäre und Bot-Planung einmal pro Runde. Geprüft: test_lune_net 43/43, net_rounds.py 12/12 (5 Runden, Wiedereinstieg, gleiche Prüfsummen), Regeln 91/91, M2 73/73, verify_m1 38/38, verify_m2 14/14. Offen und eingefroren: OpenGL-Modus, nativer Windows-11-Test, Demo-Neubau. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
699 lines
26 KiB
GDScript
699 lines
26 KiB
GDScript
class_name LuneSession
|
||
extends Node
|
||
## Authoritative network session for Champions of Luneraria.
|
||
## Must live in the scene tree (RPC + MultiplayerAPI live on Node).
|
||
## The host owns LuneMatch and every simulation result. Clients send intents only and
|
||
## show the host's combat through snapshots; they never simulate a combat themselves.
|
||
## The peer id maps to exactly one seat; a client never chooses another player's slot.
|
||
## A disconnect hands the seat to a bot. It never destroys a core and never migrates the host.
|
||
## Rejoin: a client that says hello with the name of a departed seat gets that seat back
|
||
## while it is still bot-held (no accounts, so the display name is the only key).
|
||
## Offline runs the same code path as a local host with bot-filled free seats.
|
||
|
||
const DEFAULT_PORT: int = 24571
|
||
const MAX_CLIENTS: int = 5
|
||
const PROTOCOL: int = 4
|
||
## Seconds between combat snapshots the host sends (clients interpolate in between).
|
||
const FRAME_INTERVAL: float = 0.05
|
||
## id, x, y, facing, hp, max_hp, flags, attack_progress, hit_count, attack_serial, ability_serial, hit_flash, shield
|
||
const FRAME_STRIDE: int = 13
|
||
|
||
enum Role { OFFLINE, HOST, CLIENT }
|
||
|
||
var role: int = Role.OFFLINE
|
||
var match_state: LuneMatch
|
||
var local_slot: int = -1
|
||
var display_name: String = ""
|
||
## slot -> peer id (1 = host). Absent slots are bots owned by the host.
|
||
var seat_peers: Dictionary = {}
|
||
## [{peer, slot, name, ready, bot}]
|
||
var lobby: Array[Dictionary] = []
|
||
## display name -> slot of a peer that left; used for rejoining.
|
||
var departed: Dictionary = {}
|
||
var last_error: String = ""
|
||
var connected: bool = false
|
||
## Client-side counters (tests and diagnostics).
|
||
var frames_received: int = 0
|
||
var states_received: int = 0
|
||
var _sent_unit_id: int = 0
|
||
var _frame_clock: float = 0.0
|
||
var _frame_serial: int = 0
|
||
var _last_frame_serial: int = -1
|
||
var _last_frame_msec: int = 0
|
||
var _mirror: Dictionary = {}
|
||
## slot -> round a bot already planned; a bot seat plans exactly once per planning phase.
|
||
var _bot_planned: Dictionary = {}
|
||
|
||
signal lobby_changed
|
||
signal match_resetup
|
||
signal session_message(text: String, is_error: bool)
|
||
signal phase_announced(phase: int, round_number: int)
|
||
signal state_received
|
||
signal host_lost
|
||
|
||
func is_authority() -> bool:
|
||
return role != Role.CLIENT
|
||
|
||
func is_networked() -> bool:
|
||
return role != Role.OFFLINE
|
||
|
||
func display_name_for(slot: int) -> String:
|
||
if match_state != null and slot >= 0 and slot < match_state.players.size():
|
||
return str(match_state.players[slot].name)
|
||
return "Slot %d" % slot
|
||
|
||
func seat_of_peer(peer: int) -> int:
|
||
for slot: int in seat_peers:
|
||
if int(seat_peers[slot]) == peer:
|
||
return slot
|
||
return -1
|
||
|
||
# ---------------------------------------------------------------- setup
|
||
func setup_match(options: Dictionary) -> void:
|
||
match_state = LuneMatch.new()
|
||
match_state.setup(options)
|
||
_mirror.clear()
|
||
_bot_planned.clear()
|
||
if is_authority():
|
||
_apply_seats()
|
||
match_resetup.emit()
|
||
_announce_phase()
|
||
|
||
## Seated peers are humans: they must ready up themselves and carry their lobby name.
|
||
func _apply_seats() -> void:
|
||
for player: Dictionary in match_state.players:
|
||
var slot: int = int(player.slot)
|
||
var human: bool = seat_peers.has(slot)
|
||
player.is_human = human
|
||
if match_state.phase == LuneMatch.Phase.PLANNING:
|
||
player.ready = not human
|
||
if human and int(seat_peers[slot]) != 1:
|
||
player.name = _lobby_name(slot, str(player.name))
|
||
|
||
func _lobby_name(slot: int, fallback: String) -> String:
|
||
for entry: Dictionary in lobby:
|
||
if int(entry.slot) == slot and not bool(entry.bot):
|
||
return str(entry.name)
|
||
return fallback
|
||
|
||
func _announce_phase() -> void:
|
||
if match_state == null:
|
||
return
|
||
phase_announced.emit(match_state.phase, match_state.round_number)
|
||
|
||
# ---------------------------------------------------------------- start modes
|
||
func host_offline(human_slot: int, options: Dictionary) -> void:
|
||
role = Role.OFFLINE
|
||
connected = true
|
||
local_slot = human_slot
|
||
seat_peers = {human_slot: 1} if human_slot >= 0 else {}
|
||
lobby = []
|
||
if human_slot >= 0:
|
||
lobby.append({"peer": 1, "slot": human_slot, "name": "Du", "ready": false, "bot": false})
|
||
for slot: int in range(6):
|
||
if slot != human_slot:
|
||
lobby.append({"peer": 0, "slot": slot, "name": "Bot %d" % slot, "ready": true, "bot": true})
|
||
options["human_slot"] = human_slot
|
||
setup_match(options)
|
||
|
||
func host_enet(port: int, options: Dictionary, host_name: String = "Host") -> Error:
|
||
var peer := ENetMultiplayerPeer.new()
|
||
var err := peer.create_server(port, MAX_CLIENTS)
|
||
if err != OK:
|
||
last_error = "Server auf Port %d nicht startbar (%s)." % [port, error_string(err)]
|
||
return err
|
||
_bind_peer(peer)
|
||
role = Role.HOST
|
||
connected = true
|
||
display_name = host_name
|
||
var human: int = int(options.get("human_slot", 0))
|
||
local_slot = human
|
||
seat_peers = {human: 1}
|
||
departed.clear()
|
||
lobby = [{"peer": 1, "slot": human, "name": host_name, "ready": false, "bot": false}]
|
||
for slot: int in range(6):
|
||
if slot != human:
|
||
lobby.append({"peer": 0, "slot": slot, "name": "Bot", "ready": true, "bot": true})
|
||
options["human_slot"] = human
|
||
setup_match(options)
|
||
lobby_changed.emit()
|
||
return OK
|
||
|
||
func join_enet(address: String, port: int, guest_name: String = "Gast") -> Error:
|
||
var peer := ENetMultiplayerPeer.new()
|
||
var err := peer.create_client(address, port)
|
||
if err != OK:
|
||
last_error = "Verbindung zu %s:%d nicht möglich (%s)." % [address, port, error_string(err)]
|
||
return err
|
||
_bind_peer(peer)
|
||
role = Role.CLIENT
|
||
connected = false
|
||
local_slot = -1
|
||
display_name = guest_name
|
||
if not multiplayer.connected_to_server.is_connected(_on_connected):
|
||
multiplayer.connected_to_server.connect(_on_connected)
|
||
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
|
||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||
if not multiplayer.server_disconnected.is_connected(_on_server_lost):
|
||
multiplayer.server_disconnected.connect(_on_server_lost)
|
||
return OK
|
||
|
||
func _on_connected() -> void:
|
||
connected = true
|
||
session_message.emit("Mit dem Host verbunden.", false)
|
||
rpc_id(1, "rpc_hello", PROTOCOL, display_name)
|
||
|
||
func _on_connection_failed() -> void:
|
||
last_error = "Verbindung zum Host fehlgeschlagen."
|
||
connected = false
|
||
session_message.emit(last_error, true)
|
||
host_lost.emit()
|
||
|
||
func _on_server_lost() -> void:
|
||
last_error = "Host hat die Verbindung getrennt."
|
||
connected = false
|
||
session_message.emit(last_error, true)
|
||
host_lost.emit()
|
||
|
||
func _bind_peer(peer: MultiplayerPeer) -> void:
|
||
if multiplayer.multiplayer_peer != null:
|
||
multiplayer.multiplayer_peer.close()
|
||
multiplayer.multiplayer_peer = peer
|
||
if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected):
|
||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||
|
||
func close() -> void:
|
||
if multiplayer != null and multiplayer.multiplayer_peer != null:
|
||
multiplayer.multiplayer_peer.close()
|
||
multiplayer.multiplayer_peer = null
|
||
role = Role.OFFLINE
|
||
connected = false
|
||
seat_peers.clear()
|
||
lobby.clear()
|
||
departed.clear()
|
||
|
||
# ---------------------------------------------------------------- intents
|
||
func submit(command: Dictionary) -> Dictionary:
|
||
if match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
|
||
return {"ok": false, "message": "Keine Planung."}
|
||
if role == Role.CLIENT:
|
||
if local_slot < 0:
|
||
return {"ok": false, "message": "Kein Sitzplatz."}
|
||
rpc_id(1, "rpc_submit", local_slot, command)
|
||
return {"ok": true, "message": "Befehl gesendet.", "pending": true}
|
||
return host_command(1, local_slot, command)
|
||
|
||
func ready_up() -> void:
|
||
if match_state == null:
|
||
return
|
||
if role == Role.CLIENT:
|
||
rpc_id(1, "rpc_ready", local_slot)
|
||
return
|
||
host_ready(local_slot)
|
||
|
||
## Testable host entry: peer must own slot. Duplicate submits of a purchase are
|
||
## rejected by LuneMatch through unique uids and gold, not by packet identity.
|
||
## A seat that reported ready has committed its plan; later commands are refused.
|
||
func host_command(peer: int, slot: int, command: Dictionary) -> Dictionary:
|
||
if not is_authority() or match_state == null:
|
||
return {"ok": false, "message": "Keine Autorität."}
|
||
if seat_of_peer(peer) < 0 or seat_of_peer(peer) != slot:
|
||
return _answer(peer, slot, {"ok": false, "message": "Fremder Sitz oder ungültige Einheiten-ID."})
|
||
if match_state.phase != LuneMatch.Phase.PLANNING:
|
||
return _answer(peer, slot, {"ok": false, "message": "Veralteter Befehl (Planung vorbei)."})
|
||
if bool(match_state.players[slot].ready):
|
||
return _answer(peer, slot, {"ok": false, "message": "Bereits bereit gemeldet – die Planung ist verbindlich."})
|
||
var result: Dictionary
|
||
if str(command.get("type", "")) == "undo":
|
||
result = match_state.undo(slot)
|
||
else:
|
||
result = match_state.submit(slot, command)
|
||
_answer(peer, slot, result)
|
||
_broadcast_state()
|
||
return result
|
||
|
||
func _answer(peer: int, slot: int, result: Dictionary) -> Dictionary:
|
||
if role == Role.HOST and peer > 1:
|
||
rpc_id(peer, "rpc_feedback", slot, result)
|
||
return result
|
||
|
||
func host_ready(slot: int) -> void:
|
||
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
|
||
return
|
||
match_state.set_ready(slot)
|
||
for entry: Dictionary in lobby:
|
||
if int(entry.slot) == slot:
|
||
entry.ready = true
|
||
_broadcast_lobby()
|
||
if match_state.all_ready():
|
||
host_begin_combat()
|
||
else:
|
||
_broadcast_state()
|
||
|
||
func plan_bot_seats() -> void:
|
||
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
|
||
return
|
||
for slot: int in range(6):
|
||
if not seat_peers.has(slot):
|
||
_plan_bot(slot)
|
||
|
||
func _plan_bot(slot: int) -> void:
|
||
if int(_bot_planned.get(slot, -1)) == match_state.round_number:
|
||
match_state.set_ready(slot)
|
||
return
|
||
_bot_planned[slot] = match_state.round_number
|
||
LuneBot.plan(match_state, slot)
|
||
|
||
func host_begin_combat() -> void:
|
||
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
|
||
return
|
||
plan_bot_seats()
|
||
match_state.begin_combat()
|
||
_sent_unit_id = 0
|
||
_frame_clock = 0.0
|
||
_announce_phase()
|
||
if role == Role.HOST:
|
||
_broadcast_state()
|
||
rpc("rpc_combat_begin", match_state.round_number, _new_unit_records(), _building_hp_array())
|
||
|
||
## Host: call once per rendered frame while combat runs; sends throttled snapshots.
|
||
func host_tick(delta: float) -> void:
|
||
if role != Role.HOST or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT:
|
||
return
|
||
_frame_clock += delta
|
||
if _frame_clock < FRAME_INTERVAL:
|
||
return
|
||
_frame_clock = 0.0
|
||
var spawned: Array = _new_unit_records()
|
||
if not spawned.is_empty():
|
||
rpc("rpc_units_spawned", spawned)
|
||
_frame_serial += 1
|
||
rpc("rpc_frame", _frame_serial, match_state.combat.elapsed, _unit_frame(), _building_hp_array())
|
||
|
||
## Host: call after the settlement (phase left COMBAT) so clients follow in the same step.
|
||
func host_after_combat() -> void:
|
||
if not is_authority() or match_state == null:
|
||
return
|
||
_announce_phase()
|
||
_broadcast_state()
|
||
|
||
func host_finish_combat() -> void:
|
||
if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT:
|
||
return
|
||
match_state.finish_combat()
|
||
host_after_combat()
|
||
|
||
## Host rematch: a new match with the same seats; clients relaunch on rpc_setup.
|
||
func host_rematch(options: Dictionary) -> void:
|
||
if not is_authority():
|
||
return
|
||
options["human_slot"] = local_slot
|
||
for entry: Dictionary in lobby:
|
||
entry.ready = bool(entry.bot)
|
||
setup_match(options)
|
||
if role == Role.HOST:
|
||
for slot: int in seat_peers:
|
||
var peer: int = int(seat_peers[slot])
|
||
if peer > 1:
|
||
_send_full_sync(peer, slot)
|
||
_broadcast_lobby()
|
||
|
||
# ---------------------------------------------------------------- rpc (client -> host)
|
||
@rpc("any_peer", "call_remote", "reliable")
|
||
func rpc_hello(protocol: int, guest_name: String) -> void:
|
||
if role != Role.HOST:
|
||
return
|
||
host_hello(multiplayer.get_remote_sender_id(), protocol, guest_name)
|
||
|
||
## Testable host entry for a new connection: protocol check, rejoin or first free seat.
|
||
func host_hello(peer: int, protocol: int, guest_name: String) -> int:
|
||
if protocol != PROTOCOL:
|
||
_refuse(peer, "Protokoll %d passt nicht zum Host (%d). Bitte dieselbe Version nutzen." % [protocol, PROTOCOL])
|
||
return -1
|
||
if seat_of_peer(peer) >= 0:
|
||
return seat_of_peer(peer)
|
||
var clean: String = guest_name.strip_edges().left(24)
|
||
if clean.is_empty():
|
||
clean = "Gast %d" % peer
|
||
var slot: int = -1
|
||
var rejoin: bool = departed.has(clean) and not seat_peers.has(int(departed[clean]))
|
||
if rejoin:
|
||
slot = int(departed[clean])
|
||
else:
|
||
# New guests prefer seats nobody may return to; a departed seat is the last resort.
|
||
var free: Array = range(6).filter(func(c: int) -> bool: return not seat_peers.has(c))
|
||
var open: Array = free.filter(func(c: int) -> bool: return not departed.values().has(c))
|
||
slot = open[0] if not open.is_empty() else (free[0] if not free.is_empty() else -1)
|
||
if slot < 0:
|
||
_refuse(peer, "Kein freier Sitzplatz.")
|
||
session_message.emit("Kein freier Sitzplatz für %s." % clean, true)
|
||
return -1
|
||
departed.erase(clean)
|
||
seat_peer(slot, peer, clean)
|
||
session_message.emit("%s %s Slot %d." % [clean, "kehrt zurück auf" if rejoin else "sitzt auf", slot], false)
|
||
return slot
|
||
|
||
func _refuse(peer: int, reason: String) -> void:
|
||
if role == Role.HOST:
|
||
rpc_id(peer, "rpc_refused", reason)
|
||
|
||
@rpc("any_peer", "call_remote", "reliable")
|
||
func rpc_submit(slot: int, command: Dictionary) -> void:
|
||
if not is_authority():
|
||
return
|
||
host_command(multiplayer.get_remote_sender_id(), slot, command)
|
||
|
||
@rpc("any_peer", "call_remote", "reliable")
|
||
func rpc_ready(slot: int) -> void:
|
||
if not is_authority():
|
||
return
|
||
var peer: int = multiplayer.get_remote_sender_id()
|
||
if seat_of_peer(peer) != slot:
|
||
return
|
||
host_ready(slot)
|
||
|
||
# ---------------------------------------------------------------- rpc (host -> client)
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_refused(reason: String) -> void:
|
||
last_error = reason
|
||
session_message.emit(reason, true)
|
||
host_lost.emit()
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_feedback(_slot: int, result: Dictionary) -> void:
|
||
session_message.emit(str(result.get("message", "")), not bool(result.get("ok", false)))
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_setup(options: Dictionary, seat: int, roster: Array) -> void:
|
||
local_slot = seat
|
||
lobby = []
|
||
seat_peers.clear()
|
||
for entry: Dictionary in roster:
|
||
lobby.append(entry.duplicate())
|
||
if not bool(entry.get("bot", false)):
|
||
seat_peers[int(entry.slot)] = int(entry.peer)
|
||
options["human_slot"] = seat
|
||
setup_match(options)
|
||
connected = true
|
||
lobby_changed.emit()
|
||
|
||
## Client: applies the host's public state. The combat phase itself only starts with
|
||
## rpc_combat_begin, so a state message can never bind views to a stale combat.
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_state(state: Dictionary) -> void:
|
||
if match_state == null:
|
||
return
|
||
var previous_phase: int = match_state.phase
|
||
match_state.round_number = int(state.round)
|
||
match_state.winner = int(state.winner)
|
||
match_state.end_note = str(state.end_note)
|
||
match_state.building_hp = (state.building_hp as Dictionary).duplicate()
|
||
match_state.boss = (state.boss as Dictionary).duplicate(true)
|
||
match_state.last_settlement = (state.last_settlement as Dictionary).duplicate(true)
|
||
for row: Dictionary in state.players:
|
||
var slot: int = int(row.get("slot", -1))
|
||
if slot < 0 or slot >= match_state.players.size():
|
||
continue
|
||
var player: Dictionary = match_state.players[slot]
|
||
player.name = "Du" if slot == local_slot else str(row.name)
|
||
player.is_human = bool(row.is_human)
|
||
player.gold = int(row.gold)
|
||
player.ready = bool(row.ready)
|
||
player.earned = int(row.earned)
|
||
player.spent = int(row.spent)
|
||
player.next_seq = int(row.next_seq)
|
||
player.roster = (row.roster as Array).duplicate(true)
|
||
player.hero = (row.hero as Dictionary).duplicate(true)
|
||
player.bookings = (row.bookings as Array).duplicate(true)
|
||
# Only the own seat receives its private draft; the others show committed state.
|
||
player.draft = (row.draft as Dictionary).duplicate(true) if row.has("draft") else match_state._snapshot(player)
|
||
var undo: Array = []
|
||
undo.resize(int(row.get("undo", 0)))
|
||
player.undo = undo
|
||
var phase: int = int(state.phase)
|
||
if phase != LuneMatch.Phase.COMBAT:
|
||
if match_state.combat != null:
|
||
match_state.combat.destroyed_cores.assign(state.destroyed_cores)
|
||
match_state.phase = phase
|
||
for entry: Dictionary in lobby:
|
||
entry.ready = bool(match_state.players[int(entry.slot)].ready)
|
||
states_received += 1
|
||
if previous_phase != match_state.phase:
|
||
_announce_phase()
|
||
state_received.emit()
|
||
lobby_changed.emit()
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_lobby(entries: Array) -> void:
|
||
lobby = []
|
||
for entry: Dictionary in entries:
|
||
lobby.append(entry.duplicate())
|
||
lobby_changed.emit()
|
||
|
||
## Client: builds a display mirror of the host's combat. Rules never run on it.
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_combat_begin(round_number: int, records: Array, building_hp: PackedFloat32Array) -> void:
|
||
if match_state == null:
|
||
return
|
||
var mirror := LuneCombat.new(match_state.map, match_state.balance, round_number)
|
||
for index: int in range(match_state.map.buildings.size()):
|
||
var hp: float = building_hp[index] if index < building_hp.size() else 0.0
|
||
if hp > 0.0:
|
||
mirror.add_building(match_state.map.buildings[index], hp)
|
||
_mirror.clear()
|
||
match_state.combat = mirror
|
||
match_state.round_number = round_number
|
||
_add_mirror_units(records)
|
||
_last_frame_msec = Time.get_ticks_msec()
|
||
match_state.phase = LuneMatch.Phase.COMBAT
|
||
_announce_phase()
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func rpc_units_spawned(records: Array) -> void:
|
||
if match_state != null and match_state.combat != null and match_state.phase == LuneMatch.Phase.COMBAT:
|
||
_add_mirror_units(records)
|
||
|
||
## Frame serials rise across rounds, so a late frame of the previous combat is ignored.
|
||
@rpc("authority", "call_remote", "unreliable_ordered")
|
||
func rpc_frame(serial: int, elapsed: float, data: PackedFloat32Array, building_hp: PackedFloat32Array) -> void:
|
||
if match_state == null or match_state.combat == null or match_state.phase != LuneMatch.Phase.COMBAT or serial <= _last_frame_serial:
|
||
return
|
||
_last_frame_serial = serial
|
||
_last_frame_msec = Time.get_ticks_msec()
|
||
frames_received += 1
|
||
var combat: LuneCombat = match_state.combat
|
||
combat.elapsed = elapsed
|
||
for base: int in range(0, data.size() - FRAME_STRIDE + 1, FRAME_STRIDE):
|
||
var unit: LuneUnit = _mirror.get(int(data[base]))
|
||
if unit == null:
|
||
continue
|
||
unit.previous_position = unit.position
|
||
unit.position = Vector2(data[base + 1], data[base + 2])
|
||
unit.facing = data[base + 3]
|
||
unit.hp = data[base + 4]
|
||
unit.max_hp = data[base + 5]
|
||
var flags: int = int(data[base + 6])
|
||
if unit.alive and flags & 1 == 0:
|
||
unit.death_time = elapsed
|
||
unit.alive = flags & 1 != 0
|
||
unit.in_range = flags & 2 != 0
|
||
unit.attack_progress = data[base + 7]
|
||
unit.hit_count = int(data[base + 8])
|
||
unit.attack_serial = int(data[base + 9])
|
||
unit.ability_serial = int(data[base + 10])
|
||
unit.hit_flash = data[base + 11]
|
||
unit.shield = data[base + 12]
|
||
for index: int in range(mini(building_hp.size(), match_state.map.buildings.size())):
|
||
var id: String = str(match_state.map.buildings[index].id)
|
||
if combat.buildings.has(id):
|
||
var building: LuneUnit = combat.buildings[id]
|
||
building.hp = building_hp[index]
|
||
building.alive = building.hp > 0.0
|
||
|
||
## Client interpolation factor between the last two snapshots.
|
||
func frame_alpha() -> float:
|
||
return clampf(float(Time.get_ticks_msec() - _last_frame_msec) / (FRAME_INTERVAL * 1000.0), 0.0, 1.0)
|
||
|
||
func _add_mirror_units(records: Array) -> void:
|
||
for record: Dictionary in records:
|
||
var id: int = int(record.id)
|
||
if _mirror.has(id):
|
||
continue
|
||
var unit := LuneUnit.new()
|
||
unit.id = id
|
||
unit.kind = str(record.kind)
|
||
unit.team = int(record.team)
|
||
unit.team_index = int(record.team_index)
|
||
unit.owner_slot = int(record.owner_slot)
|
||
unit.persistent_uid = str(record.uid)
|
||
unit.lane = str(record.lane)
|
||
unit.zone = str(record.zone)
|
||
unit.def = record.def
|
||
unit.position = Vector2(float(record.x), float(record.y))
|
||
unit.previous_position = unit.position
|
||
unit.facing = float(record.facing)
|
||
unit.hp = float(record.hp)
|
||
unit.max_hp = float(record.max_hp)
|
||
unit.alive = bool(record.alive)
|
||
match_state.combat.units.append(unit)
|
||
_mirror[id] = unit
|
||
|
||
# ---------------------------------------------------------------- host serialisation
|
||
func _new_unit_records() -> Array:
|
||
var records: Array = []
|
||
var highest: int = _sent_unit_id
|
||
for unit: LuneUnit in match_state.combat.units:
|
||
if not unit.is_mobile() or unit.id <= _sent_unit_id:
|
||
continue
|
||
highest = maxi(highest, unit.id)
|
||
records.append(_unit_record(unit))
|
||
_sent_unit_id = highest
|
||
return records
|
||
|
||
func _all_unit_records() -> Array:
|
||
var records: Array = []
|
||
for unit: LuneUnit in match_state.combat.units:
|
||
if unit.is_mobile():
|
||
records.append(_unit_record(unit))
|
||
return records
|
||
|
||
func _unit_record(unit: LuneUnit) -> Dictionary:
|
||
return {"id": unit.id, "kind": unit.kind, "team": unit.team, "team_index": unit.team_index, "owner_slot": unit.owner_slot,
|
||
"uid": unit.persistent_uid, "lane": unit.lane, "zone": unit.zone, "def": _plain(unit.def),
|
||
"x": unit.position.x, "y": unit.position.y, "facing": unit.facing, "hp": unit.hp, "max_hp": unit.max_hp, "alive": unit.alive}
|
||
|
||
## Keeps only plain data so a definition can cross the wire (objects are never sent).
|
||
static func _plain(value: Variant) -> Variant:
|
||
match typeof(value):
|
||
TYPE_DICTIONARY:
|
||
var result: Dictionary = {}
|
||
for key: Variant in value:
|
||
if typeof(value[key]) != TYPE_OBJECT:
|
||
result[key] = _plain(value[key])
|
||
return result
|
||
TYPE_ARRAY:
|
||
var items: Array = []
|
||
for item: Variant in value:
|
||
if typeof(item) != TYPE_OBJECT:
|
||
items.append(_plain(item))
|
||
return items
|
||
return value
|
||
|
||
func _unit_frame() -> PackedFloat32Array:
|
||
var data := PackedFloat32Array()
|
||
for unit: LuneUnit in match_state.combat.units:
|
||
if not unit.is_mobile():
|
||
continue
|
||
data.append_array([unit.id, unit.position.x, unit.position.y, unit.facing, unit.hp, unit.max_hp,
|
||
(1 if unit.alive else 0) | (2 if unit.in_range else 0), unit.attack_progress, unit.hit_count,
|
||
unit.attack_serial, unit.ability_serial, unit.hit_flash, unit.shield])
|
||
return data
|
||
|
||
func _building_hp_array() -> PackedFloat32Array:
|
||
var data := PackedFloat32Array()
|
||
for definition: Dictionary in match_state.map.buildings:
|
||
var hp: float = float(match_state.building_hp.get(definition.id, 0.0))
|
||
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id):
|
||
var building: LuneUnit = match_state.combat.buildings[definition.id]
|
||
hp = building.hp if building.alive else 0.0
|
||
data.append(hp)
|
||
return data
|
||
|
||
## Public state for one seat: every committed value, plus the private draft of that seat only.
|
||
func state_for(slot: int) -> Dictionary:
|
||
var rows: Array = []
|
||
for player: Dictionary in match_state.players:
|
||
var seat: int = int(player.slot)
|
||
var row := {"slot": seat, "name": _lobby_name(seat, str(player.name)) if seat_peers.has(seat) else str(player.name),
|
||
"is_human": player.is_human, "gold": player.gold, "ready": player.ready, "earned": player.earned, "spent": player.spent,
|
||
"next_seq": player.next_seq, "roster": player.roster, "hero": player.hero, "bookings": player.bookings}
|
||
if seat == slot:
|
||
row.draft = (player.draft as Dictionary).duplicate(true)
|
||
row.undo = (player.undo as Array).size()
|
||
rows.append(row)
|
||
return {"phase": match_state.phase, "round": match_state.round_number, "winner": match_state.winner, "end_note": match_state.end_note,
|
||
"building_hp": match_state.building_hp, "boss": match_state.boss, "last_settlement": _plain(match_state.last_settlement),
|
||
"destroyed_cores": (match_state.combat.destroyed_cores if match_state.combat != null else []) as Array, "players": rows}
|
||
|
||
func _broadcast_lobby() -> void:
|
||
if role == Role.HOST:
|
||
rpc("rpc_lobby", lobby)
|
||
|
||
func _broadcast_state() -> void:
|
||
if role != Role.HOST or match_state == null:
|
||
return
|
||
for slot: int in seat_peers:
|
||
var peer: int = int(seat_peers[slot])
|
||
if peer > 1:
|
||
rpc_id(peer, "rpc_state", state_for(slot))
|
||
|
||
func _send_full_sync(peer: int, slot: int) -> void:
|
||
rpc_id(peer, "rpc_setup", _public_options(), slot, lobby)
|
||
rpc_id(peer, "rpc_state", state_for(slot))
|
||
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
|
||
rpc_id(peer, "rpc_combat_begin", match_state.round_number, _all_unit_records(), _building_hp_array())
|
||
|
||
# ---------------------------------------------------------------- lobby / disconnect
|
||
func seat_peer(slot: int, peer: int, seat_name: String) -> void:
|
||
if not is_authority() or slot < 0 or slot > 5:
|
||
return
|
||
# One peer one seat; a seat held by another peer is never taken over.
|
||
var taken: int = seat_of_peer(peer)
|
||
if taken >= 0 and taken != slot:
|
||
return
|
||
if seat_peers.has(slot) and int(seat_peers[slot]) != peer:
|
||
return
|
||
seat_peers[slot] = peer
|
||
var found: bool = false
|
||
for entry: Dictionary in lobby:
|
||
if int(entry.slot) == slot:
|
||
entry.peer = peer
|
||
entry.name = seat_name
|
||
entry.bot = false
|
||
entry.ready = false
|
||
found = true
|
||
if not found:
|
||
lobby.append({"peer": peer, "slot": slot, "name": seat_name, "ready": false, "bot": false})
|
||
if match_state != null:
|
||
var player: Dictionary = match_state.players[slot]
|
||
player.is_human = true
|
||
player.name = seat_name
|
||
# A seat taken during planning drops the bot's uncommitted draft: the gold is the human's.
|
||
if match_state.phase == LuneMatch.Phase.PLANNING:
|
||
player.draft = match_state._snapshot(player)
|
||
player.undo = []
|
||
player.ready = false
|
||
if role == Role.HOST and peer > 1:
|
||
_send_full_sync(peer, slot)
|
||
_broadcast_lobby()
|
||
_broadcast_state()
|
||
lobby_changed.emit()
|
||
|
||
func _public_options() -> Dictionary:
|
||
return {"seed": match_state.seed_value if match_state != null else 1,
|
||
"bunds": match_state.players.map(func(p: Dictionary) -> String: return str(p.bund)) if match_state != null else []}
|
||
|
||
func _on_peer_disconnected(peer: int) -> void:
|
||
if not is_authority():
|
||
return
|
||
var slot: int = seat_of_peer(peer)
|
||
if slot < 0:
|
||
return
|
||
seat_peers.erase(slot)
|
||
var gone_name: String = display_name_for(slot)
|
||
departed[gone_name] = slot
|
||
for entry: Dictionary in lobby:
|
||
if int(entry.slot) == slot:
|
||
entry.bot = true
|
||
entry.peer = 0
|
||
entry.ready = true
|
||
session_message.emit("%s hat die Verbindung getrennt – Slot wird vom Bot übernommen." % gone_name, false)
|
||
if match_state != null:
|
||
match_state.players[slot].is_human = false
|
||
if match_state.phase == LuneMatch.Phase.PLANNING and is_authority():
|
||
_plan_bot(slot)
|
||
_broadcast_lobby()
|
||
if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and match_state.all_ready():
|
||
host_begin_combat()
|
||
else:
|
||
_broadcast_state()
|