class_name OnlineManager extends Node signal connection_state_changed(state: String) signal authenticated(user_id: String, username: String) signal leaderboard_loaded(leaderboard_id: String, records: Array) signal score_submitted(result: Dictionary) signal matchmaking_state_changed(state: String) signal match_found(match_data) signal match_snapshot_received(snapshot: Dictionary) signal match_event_received(event: Dictionary) signal private_room_created(room_code: String) const SERVER_KEY := "voidbastion-public-v1" const PRODUCTION_HOST := "api.void-bastion.gergames.de" const DEVELOPMENT_HOST := "192.168.178.20" const DEFAULT_PORT := 7350 const OP_SNAPSHOT := 1 const OP_EVENT := 2 const OP_BUILD := 10 const OP_UPGRADE := 11 const OP_SELL := 12 const OP_START_WAVE := 13 const OP_READY := 14 var client: NakamaClient var session: NakamaSession var socket: NakamaSocket var connection_state := "offline" var last_error := "" var current_host := "" var current_match = null var matchmaker_ticket := "" var private_room_code := "" var lobby_ready := false var intentional_disconnect := false var reconnect_in_progress := false var account_manager: AccountManager var auto_connect_enabled := true func _ready() -> void: account_manager = get_parent().get_node_or_null("AccountManager") as AccountManager if _is_headless_or_editor(): return call_deferred("connect_and_authenticate") func _is_headless_or_editor() -> bool: return DisplayServer.get_name() == "headless" or Engine.is_editor_hint() func connect_and_authenticate(force_production := false) -> bool: if connection_state in ["connecting", "online"]: return connection_state == "online" _set_connection_state("connecting") last_error = "" var production_ok := await _authenticate_on(PRODUCTION_HOST, 443, "https") if production_ok: await _after_authentication() return true if OS.is_debug_build() and not force_production: var development_ok := await _authenticate_on(DEVELOPMENT_HOST, DEFAULT_PORT, "http") if development_ok: await _after_authentication() return true _set_connection_state("offline") return false func _authenticate_on(host: String, port: int, scheme: String) -> bool: current_host = host client = Nakama.create_client(SERVER_KEY, host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) var device_id := _device_id() var username = "pilot_%s" % device_id.md5_text().left(20) var result: NakamaSession = await client.authenticate_device_async(device_id, username, true) if result == null or result.is_exception(): last_error = "Server %s nicht erreichbar" % host return false session = result if account_manager: await client.update_account_async(session, null, account_manager.display_name) account_manager.attach_online_identity(session.user_id, session.username) return true func _after_authentication() -> void: socket = Nakama.create_socket_from(client) _bind_socket_signals() var result = await socket.connect_async(session, true, 5) if result == null or result.is_exception(): last_error = "Echtzeitverbindung konnte nicht aufgebaut werden" socket = null _set_connection_state("authenticated") else: intentional_disconnect = false _set_connection_state("online") authenticated.emit(session.user_id, session.username) await flush_pending_scores() func _bind_socket_signals() -> void: socket.received_matchmaker_matched.connect(_on_matchmaker_matched) socket.received_match_state.connect(_on_match_state) socket.closed.connect(_on_socket_closed) func disconnect_from_server() -> void: intentional_disconnect = true if socket: socket.close() socket = null session = null current_match = null matchmaker_ticket = "" private_room_code = "" lobby_ready = false _set_connection_state("offline") func server_info() -> Dictionary: if not is_authenticated(): return {} var result = await client.rpc_async(session, "server_info", "{}") return _parse_rpc_result(result) func submit_run(record: Dictionary) -> bool: if not is_authenticated(): return false var payload := record.duplicate(true) payload["client_version"] = "0.21" var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload)) var parsed := _parse_rpc_result(result) if parsed.is_empty() or not bool(parsed.get("accepted", false)): return false if account_manager: account_manager.mark_submission_complete(record) score_submitted.emit(parsed) return true func flush_pending_scores() -> void: if not account_manager or not is_authenticated(): return var queued: Array = account_manager.pending_submissions.duplicate(true) for record in queued: if record is Dictionary: await submit_run(record) func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array: if not is_authenticated(): return [] var leaderboard_id := make_leaderboard_id(map_index, difficulty_index, "endless" if endless else "mission") var result = await client.list_leaderboard_records_async(session, leaderboard_id, null, null, clampi(limit, 1, 100)) if result == null or result.is_exception(): return [] var rows: Array = [] for record in result.records: rows.append({ "rank": int(record.rank), "username": record.username, "score": int(record.score), "wave": int(record.subscore), "owner_id": record.owner_id }) leaderboard_loaded.emit(leaderboard_id, rows) return rows func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false if not matchmaker_ticket.is_empty(): return true var string_properties := {"game": "void_bastion", "mode": mode} var numeric_properties := {"difficulty": float(difficulty)} var query := "+properties.game:void_bastion +properties.mode:%s +properties.difficulty:%d" % [mode, difficulty] var ticket = await socket.add_matchmaker_async(query, 2, 2, string_properties, numeric_properties) if ticket == null or ticket.is_exception(): matchmaking_state_changed.emit("error") return false matchmaker_ticket = ticket.ticket matchmaking_state_changed.emit("searching") return true func cancel_matchmaking() -> void: intentional_disconnect = true if socket and not matchmaker_ticket.is_empty(): await socket.remove_matchmaker_async(matchmaker_ticket) matchmaker_ticket = "" if current_match != null and not private_room_code.is_empty(): await socket.leave_match_async(current_match.match_id) current_match = null private_room_code = "" matchmaking_state_changed.emit("idle") func create_private_room(difficulty := 1) -> bool: intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false matchmaking_state_changed.emit("creating_room") var result = await client.rpc_async(session, "create_private_room", JSON.stringify({"difficulty": clampi(difficulty, 0, 2)})) var data := _parse_rpc_result(result) var match_id := String(data.get("match_id", "")) private_room_code = String(data.get("room_code", "")) if match_id.is_empty() or private_room_code.length() != 6: private_room_code = "" matchmaking_state_changed.emit("error") return false current_match = await socket.join_match_async(match_id) if current_match == null or current_match.is_exception(): current_match = null private_room_code = "" matchmaking_state_changed.emit("error") return false private_room_created.emit(private_room_code) matchmaking_state_changed.emit("hosting") lobby_ready = false await set_lobby_ready(false) return true func join_private_room(room_code: String) -> bool: intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false private_room_code = room_code.to_upper().strip_edges() matchmaking_state_changed.emit("joining_room") var result = await client.rpc_async(session, "join_private_room", JSON.stringify({"room_code": private_room_code})) var data := _parse_rpc_result(result) var match_id := String(data.get("match_id", "")) if not bool(data.get("found", false)) or match_id.is_empty(): private_room_code = "" matchmaking_state_changed.emit("room_not_found") return false current_match = await socket.join_match_async(match_id) if current_match == null or current_match.is_exception(): current_match = null private_room_code = "" matchmaking_state_changed.emit("room_not_found") return false matchmaking_state_changed.emit("matched") lobby_ready = false await set_lobby_ready(false) return true func _on_matchmaker_matched(matched) -> void: intentional_disconnect = false matchmaker_ticket = "" current_match = await socket.join_matched_async(matched) if current_match == null or current_match.is_exception(): matchmaking_state_changed.emit("error") return matchmaking_state_changed.emit("matched") match_found.emit(current_match) lobby_ready = false await set_lobby_ready(false) func set_lobby_ready(value: bool) -> bool: lobby_ready = value var player_name := account_manager.display_name if account_manager else session.username return await send_match_command(OP_READY, {"ready": value, "name": player_name}) func send_match_command(op_code: int, payload: Dictionary) -> bool: if not is_online() or current_match == null: return false var result = await socket.send_match_state_async(current_match.match_id, op_code, JSON.stringify(payload)) return result != null and not result.is_exception() func request_tower_build(tower_type: int, position: Vector2) -> bool: return await send_match_command(OP_BUILD, {"tower_type": tower_type, "x": position.x, "y": position.y}) func request_tower_upgrade(tower_id: int) -> bool: return await send_match_command(OP_UPGRADE, {"tower_id": tower_id}) func request_tower_sell(tower_id: int) -> bool: return await send_match_command(OP_SELL, {"tower_id": tower_id}) func request_wave_start() -> bool: return await send_match_command(OP_START_WAVE, {}) func leave_current_match() -> void: if socket and current_match != null: await socket.leave_match_async(current_match.match_id) current_match = null private_room_code = "" lobby_ready = false func _on_match_state(data) -> void: if current_match == null or data.match_id != current_match.match_id: return var parsed = JSON.parse_string(data.data) if not parsed is Dictionary: return if data.op_code == OP_SNAPSHOT: match_snapshot_received.emit(parsed) elif data.op_code == OP_EVENT: match_event_received.emit(parsed) func _on_socket_closed() -> void: var reconnect_match_id := "" if not intentional_disconnect and current_match != null: reconnect_match_id = String(current_match.match_id) current_match = null if connection_state != "offline": _set_connection_state("authenticated") if not reconnect_match_id.is_empty() and not reconnect_in_progress: reconnect_in_progress = true matchmaking_state_changed.emit("reconnecting") _recover_match.call_deferred(reconnect_match_id) func _recover_match(match_id: String) -> void: for attempt in 5: await get_tree().create_timer(1.5 + attempt).timeout if intentional_disconnect or not is_authenticated(): break socket = Nakama.create_socket_from(client) _bind_socket_signals() var connected = await socket.connect_async(session, true, 5) if connected == null or connected.is_exception(): continue var rejoined = await socket.join_match_async(match_id) if rejoined != null and not rejoined.is_exception(): current_match = rejoined intentional_disconnect = false reconnect_in_progress = false _set_connection_state("online") matchmaking_state_changed.emit("reconnected") return reconnect_in_progress = false matchmaking_state_changed.emit("connection_lost") func _device_id() -> String: if account_manager and not account_manager.player_id.is_empty(): return account_manager.player_id var fallback := "voidbastion_%s" % Crypto.new().generate_random_bytes(16).hex_encode() return fallback func is_authenticated() -> bool: return session != null and session.is_valid() and not session.expired func is_online() -> bool: return is_authenticated() and socket != null and socket.is_connected_to_host() func make_leaderboard_id(map_index: int, difficulty_index: int, mode: String) -> String: var safe_mode := mode if mode in ["mission", "endless", "coop_ai", "coop_online"] else "mission" return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 2), clampi(difficulty_index, 0, 2)] func _parse_rpc_result(result) -> Dictionary: if result == null or result.is_exception(): return {} var parsed = JSON.parse_string(result.payload) return parsed if parsed is Dictionary else {} func _set_connection_state(value: String) -> void: if connection_state == value: return connection_state = value connection_state_changed.emit(connection_state)