932 lines
33 KiB
GDScript
932 lines
33 KiB
GDScript
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)
|
|
signal friends_changed()
|
|
signal match_invite_received(invite: Dictionary)
|
|
|
|
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
|
|
const MAX_PENDING_SNAPSHOTS := 5
|
|
|
|
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
|
|
var snapshot_previous_ms := 0
|
|
var snapshot_report_ms := 0
|
|
var snapshot_count := 0
|
|
var snapshot_gap_max_ms := 0
|
|
var snapshot_bytes_max := 0
|
|
var snapshot_parse_max_us := 0
|
|
var snapshot_large_gap_count := 0
|
|
var snapshot_coalesced_count := 0
|
|
var snapshot_queue_dropped_count := 0
|
|
var snapshot_queue_max := 0
|
|
var snapshot_bytes_total := 0
|
|
var snapshot_tick_previous := -1
|
|
var snapshot_tick_jump_max := 0
|
|
var snapshot_tick_jumps := 0
|
|
var snapshot_gap_samples: Array[int] = []
|
|
var diagnostic_file: FileAccess
|
|
var diagnostic_path := ""
|
|
var diagnostic_match_key := ""
|
|
var diagnostic_match_id := ""
|
|
var diagnostic_finished_match_key := ""
|
|
var diagnostic_open_failed := false
|
|
var diagnostic_slot := -1
|
|
var diagnostic_frame_max_ms := 0
|
|
var diagnostic_frame_stalls := 0
|
|
var diagnostic_apply_max_us := 0
|
|
var diagnostic_enemy_count := 0
|
|
var diagnostic_tower_count := 0
|
|
var diagnostic_last_summary := ""
|
|
var diagnostic_records: Array[Dictionary] = []
|
|
var pending_snapshot_payloads: Array[String] = []
|
|
var friends: Array[Dictionary] = []
|
|
var friend_statuses: Dictionary = {}
|
|
var presence_status := ""
|
|
var player_event_flush_busy := false
|
|
var player_event_flush_again := false
|
|
|
|
|
|
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 _process(_delta: float) -> void:
|
|
_flush_pending_snapshots()
|
|
if diagnostic_file != null and Time.get_ticks_msec() - snapshot_report_ms >= 10000:
|
|
_report_diagnostic_window()
|
|
|
|
|
|
func _flush_pending_snapshots() -> void:
|
|
if pending_snapshot_payloads.is_empty():
|
|
return
|
|
var payloads := pending_snapshot_payloads.duplicate()
|
|
pending_snapshot_payloads.clear()
|
|
var latest: Dictionary = {}
|
|
var recent_shots: Array = []
|
|
for payload in payloads:
|
|
var parse_start_us := Time.get_ticks_usec()
|
|
var parsed = JSON.parse_string(payload)
|
|
snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us)
|
|
if not parsed is Dictionary:
|
|
continue
|
|
var server_tick := int(parsed.get("tick", -1))
|
|
if server_tick >= 0:
|
|
if snapshot_tick_previous >= 0:
|
|
var tick_jump := server_tick - snapshot_tick_previous
|
|
snapshot_tick_jump_max = maxi(snapshot_tick_jump_max, tick_jump)
|
|
if tick_jump > 1:
|
|
snapshot_tick_jumps += 1
|
|
if tick_jump > 3:
|
|
_write_diagnostic_record("tick_jump", {"from_tick": snapshot_tick_previous, "to_tick": server_tick, "steps": tick_jump})
|
|
snapshot_tick_previous = server_tick
|
|
latest = parsed
|
|
var shots = parsed.get("shots", [])
|
|
if shots is Array:
|
|
recent_shots.append_array(shots)
|
|
if latest.is_empty():
|
|
return
|
|
if payloads.size() > 1:
|
|
latest["shots"] = recent_shots
|
|
snapshot_coalesced_count += payloads.size() - 1
|
|
match_snapshot_received.emit(latest)
|
|
|
|
|
|
func _begin_match_diagnostics() -> void:
|
|
if current_match == null:
|
|
return
|
|
var match_key := String(current_match.match_id).sha256_text().substr(0, 12)
|
|
if match_key == diagnostic_finished_match_key:
|
|
return
|
|
if diagnostic_match_key == match_key and (diagnostic_file != null or diagnostic_open_failed):
|
|
return
|
|
if diagnostic_file != null:
|
|
_finish_match_diagnostics("new_match")
|
|
diagnostic_match_key = match_key
|
|
diagnostic_match_id = String(current_match.match_id)
|
|
diagnostic_finished_match_key = ""
|
|
diagnostic_open_failed = false
|
|
diagnostic_slot = -1
|
|
diagnostic_records.clear()
|
|
snapshot_previous_ms = 0
|
|
snapshot_report_ms = Time.get_ticks_msec()
|
|
snapshot_tick_previous = -1
|
|
_reset_diagnostic_window()
|
|
var stamp := Time.get_datetime_string_from_system(true).replace(":", "-")
|
|
diagnostic_path = "user://online-diag-%s-%s-%d.jsonl" % [stamp, match_key, Time.get_ticks_usec()]
|
|
diagnostic_file = FileAccess.open(diagnostic_path, FileAccess.WRITE)
|
|
if diagnostic_file == null:
|
|
diagnostic_open_failed = true
|
|
push_warning("Online-Diagnose konnte nicht gespeichert werden: %s" % diagnostic_path)
|
|
return
|
|
_write_diagnostic_record("start", {
|
|
"match": match_key,
|
|
"host": current_host,
|
|
"version": str(ProjectSettings.get_setting("application/config/version", "")),
|
|
"transport": "nakama_websocket",
|
|
"expected_snapshots_per_second": 10
|
|
})
|
|
print("ONLINE DIAG file=%s" % ProjectSettings.globalize_path(diagnostic_path))
|
|
|
|
|
|
func set_diagnostic_slot(slot: int, viewing_right: bool) -> void:
|
|
if diagnostic_slot == slot:
|
|
return
|
|
diagnostic_slot = slot
|
|
_write_diagnostic_record("side", {"slot": slot, "viewing_right": viewing_right})
|
|
|
|
|
|
func record_online_frame(delta: float) -> void:
|
|
if diagnostic_file == null:
|
|
return
|
|
var frame_ms := roundi(delta * 1000.0)
|
|
diagnostic_frame_max_ms = maxi(diagnostic_frame_max_ms, frame_ms)
|
|
if frame_ms > 200:
|
|
diagnostic_frame_stalls += 1
|
|
if frame_ms > 1000:
|
|
_write_diagnostic_record("frame_stall", {"duration_ms": frame_ms, "last_server_tick": snapshot_tick_previous})
|
|
|
|
|
|
func record_snapshot_apply(duration_us: int, enemy_count: int, tower_count: int) -> void:
|
|
diagnostic_apply_max_us = maxi(diagnostic_apply_max_us, duration_us)
|
|
diagnostic_enemy_count = enemy_count
|
|
diagnostic_tower_count = tower_count
|
|
|
|
|
|
func _write_diagnostic_record(event_name: String, details: Dictionary) -> void:
|
|
var record := details.duplicate(true)
|
|
record["event"] = event_name
|
|
record["utc"] = Time.get_datetime_string_from_system(true)
|
|
record["slot"] = diagnostic_slot
|
|
if event_name != "start":
|
|
diagnostic_records.append(record)
|
|
if diagnostic_records.size() > 160:
|
|
diagnostic_records.pop_front()
|
|
if diagnostic_file == null:
|
|
return
|
|
diagnostic_file.store_line(JSON.stringify(record))
|
|
diagnostic_file.flush()
|
|
|
|
|
|
func _report_diagnostic_window() -> void:
|
|
var now_ms := Time.get_ticks_msec()
|
|
var sorted_gaps := snapshot_gap_samples.duplicate()
|
|
sorted_gaps.sort()
|
|
var p95_gap := 0
|
|
if not sorted_gaps.is_empty():
|
|
p95_gap = sorted_gaps[clampi(int(ceil(float(sorted_gaps.size()) * 0.95)) - 1, 0, sorted_gaps.size() - 1)]
|
|
var seconds := maxf(0.001, float(now_ms - snapshot_report_ms) / 1000.0)
|
|
var record := {
|
|
"seconds": snappedf(seconds, 0.001),
|
|
"snapshots": snapshot_count,
|
|
"snapshots_per_second": snappedf(float(snapshot_count) / seconds, 0.01),
|
|
"gap_p95_ms": p95_gap,
|
|
"gap_max_ms": snapshot_gap_max_ms,
|
|
"gaps_over_500_ms": snapshot_large_gap_count,
|
|
"last_server_tick": snapshot_tick_previous,
|
|
"tick_jump_max": snapshot_tick_jump_max,
|
|
"tick_jumps_over_1": snapshot_tick_jumps,
|
|
"bytes_avg": snapshot_bytes_total / maxi(1, snapshot_count),
|
|
"bytes_max": snapshot_bytes_max,
|
|
"parse_max_us": snapshot_parse_max_us,
|
|
"apply_max_us": diagnostic_apply_max_us,
|
|
"frame_max_ms": diagnostic_frame_max_ms,
|
|
"frame_stalls_over_200_ms": diagnostic_frame_stalls,
|
|
"coalesced": snapshot_coalesced_count,
|
|
"queue_dropped": snapshot_queue_dropped_count,
|
|
"queue_max": snapshot_queue_max,
|
|
"enemies": diagnostic_enemy_count,
|
|
"towers": diagnostic_tower_count
|
|
}
|
|
diagnostic_last_summary = JSON.stringify(record)
|
|
_write_diagnostic_record("window", record)
|
|
print("ONLINE DIAG %s" % diagnostic_last_summary)
|
|
snapshot_report_ms = now_ms
|
|
_reset_diagnostic_window()
|
|
|
|
|
|
func _reset_diagnostic_window() -> void:
|
|
snapshot_count = 0
|
|
snapshot_gap_max_ms = 0
|
|
snapshot_bytes_max = 0
|
|
snapshot_parse_max_us = 0
|
|
snapshot_large_gap_count = 0
|
|
snapshot_coalesced_count = 0
|
|
snapshot_queue_dropped_count = 0
|
|
snapshot_queue_max = 0
|
|
snapshot_bytes_total = 0
|
|
snapshot_tick_jump_max = 0
|
|
snapshot_tick_jumps = 0
|
|
snapshot_gap_samples.clear()
|
|
diagnostic_frame_max_ms = 0
|
|
diagnostic_frame_stalls = 0
|
|
diagnostic_apply_max_us = 0
|
|
|
|
|
|
func _finish_match_diagnostics(reason: String) -> void:
|
|
if diagnostic_match_key.is_empty():
|
|
return
|
|
if snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000:
|
|
_report_diagnostic_window()
|
|
_write_diagnostic_record("end", {"reason": reason})
|
|
var match_id := diagnostic_match_id
|
|
if not match_id.is_empty() and is_authenticated() and not diagnostic_records.is_empty():
|
|
var report := {"match_id": match_id, "version": str(ProjectSettings.get_setting("application/config/version", "development")),
|
|
"records": diagnostic_records.duplicate(true)}
|
|
while JSON.stringify(report).to_utf8_buffer().size() > 60000 and report["records"].size() > 1:
|
|
report["records"].pop_front()
|
|
_upload_match_diagnostics.call_deferred(report)
|
|
diagnostic_file = null
|
|
diagnostic_finished_match_key = diagnostic_match_key
|
|
diagnostic_match_key = ""
|
|
diagnostic_match_id = ""
|
|
diagnostic_open_failed = false
|
|
diagnostic_records.clear()
|
|
|
|
|
|
func _upload_match_diagnostics(report: Dictionary) -> void:
|
|
for attempt in 3:
|
|
if not is_authenticated():
|
|
break
|
|
var result = await client.rpc_async(session, "submit_online_diagnostics", JSON.stringify(report))
|
|
var parsed := _parse_rpc_result(result)
|
|
if bool(parsed.get("accepted", false)):
|
|
print("ONLINE DIAG hochgeladen: Runde %s, Seite %d" % [String(parsed.get("round_id", "")), int(parsed.get("slot", -1))])
|
|
return
|
|
await get_tree().create_timer(2.0 * (attempt + 1)).timeout
|
|
push_warning("Online-Diagnose konnte nicht hochgeladen werden; lokale Datei bleibt erhalten.")
|
|
|
|
|
|
func get_diagnostic_bundle() -> String:
|
|
if diagnostic_file != null and (snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000):
|
|
_report_diagnostic_window()
|
|
if diagnostic_path.is_empty() or not FileAccess.file_exists(diagnostic_path):
|
|
return "Noch keine Online-Diagnose vorhanden."
|
|
return "Void Bastion Online-Diagnose\nDatei: %s\n\n%s" % [ProjectSettings.globalize_path(diagnostic_path), FileAccess.get_file_as_string(diagnostic_path)]
|
|
|
|
|
|
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:
|
|
await _record_activity()
|
|
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")
|
|
var parent_game = get_parent()
|
|
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu")
|
|
authenticated.emit(session.user_id, session.username)
|
|
await flush_pending_player_events()
|
|
await flush_pending_scores()
|
|
|
|
|
|
func _record_activity() -> void:
|
|
if not is_authenticated():
|
|
return
|
|
var payload := {
|
|
"platform": OS.get_name().to_lower(),
|
|
"version": str(ProjectSettings.get_setting("application/config/version", "development"))
|
|
}
|
|
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
|
|
|
|
|
func record_local_play_start() -> void:
|
|
_queue_player_event("play_start")
|
|
|
|
|
|
func record_local_tower_build(tower_type: int) -> void:
|
|
if tower_type < 0 or tower_type >= 7:
|
|
return
|
|
_queue_player_event("tower_build", tower_type)
|
|
|
|
|
|
func _queue_player_event(event_name: String, tower_type := -1) -> void:
|
|
if not account_manager:
|
|
return
|
|
account_manager.queue_player_event(event_name, tower_type)
|
|
if is_authenticated():
|
|
flush_pending_player_events()
|
|
|
|
|
|
func flush_pending_player_events() -> void:
|
|
if player_event_flush_busy:
|
|
player_event_flush_again = true
|
|
return
|
|
if not account_manager or not is_authenticated():
|
|
return
|
|
player_event_flush_busy = true
|
|
while is_authenticated() and not account_manager.pending_player_events.is_empty():
|
|
var event: Dictionary = account_manager.pending_player_events[0]
|
|
var result = await client.rpc_async(session, "record_player_event", JSON.stringify(event))
|
|
var parsed := _parse_rpc_result(result)
|
|
if not bool(parsed.get("accepted", false)):
|
|
break
|
|
account_manager.mark_player_event_complete(int(event.get("sequence", -1)))
|
|
player_event_flush_busy = false
|
|
if player_event_flush_again:
|
|
player_event_flush_again = false
|
|
call_deferred("flush_pending_player_events")
|
|
|
|
|
|
func _bind_socket_signals() -> void:
|
|
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
|
socket.received_match_state.connect(_on_match_state)
|
|
socket.received_status_presence.connect(_on_status_presence)
|
|
socket.received_notification.connect(_on_notification)
|
|
socket.closed.connect(_on_socket_closed)
|
|
|
|
|
|
func disconnect_from_server() -> void:
|
|
intentional_disconnect = true
|
|
pending_snapshot_payloads.clear()
|
|
if socket:
|
|
socket.close()
|
|
socket = null
|
|
session = null
|
|
current_match = null
|
|
friends.clear()
|
|
friend_statuses.clear()
|
|
presence_status = ""
|
|
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 fetch_game_balance() -> Dictionary:
|
|
if not is_authenticated():
|
|
return {}
|
|
var result = await client.rpc_async(session, "get_game_balance", "{}")
|
|
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"] = str(ProjectSettings.get_setting("application/config/version", "development"))
|
|
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 update_display_name(value: String) -> Dictionary:
|
|
if not account_manager:
|
|
return {"accepted": false, "error": "Lokales Profil fehlt."}
|
|
var validation_error := account_manager.validate_display_name(value)
|
|
if not validation_error.is_empty():
|
|
return {"accepted": false, "error": validation_error}
|
|
var normalized := account_manager.normalize_display_name(value)
|
|
if is_authenticated():
|
|
var result = await client.rpc_async(session, "update_display_name", JSON.stringify({"display_name": normalized}))
|
|
var parsed := _parse_rpc_result(result)
|
|
if parsed.is_empty() or not bool(parsed.get("accepted", false)):
|
|
return {"accepted": false, "error": str(parsed.get("error", "Der Name konnte online nicht gespeichert werden."))}
|
|
normalized = str(parsed.get("display_name", normalized))
|
|
account_manager.set_display_name(normalized)
|
|
return {"accepted": true, "display_name": normalized, "online": is_authenticated()}
|
|
|
|
|
|
func delete_online_account() -> Dictionary:
|
|
if not is_authenticated():
|
|
return {"accepted": false, "error": "Für die Kontolöschung ist eine Serververbindung erforderlich."}
|
|
var result = await client.rpc_async(session, "delete_account", "{}")
|
|
var parsed := _parse_rpc_result(result)
|
|
if parsed.is_empty() or not bool(parsed.get("accepted", false)):
|
|
return {"accepted": false, "error": str(parsed.get("error", "Das Online-Konto konnte nicht gelöscht werden."))}
|
|
disconnect_from_server()
|
|
if account_manager:
|
|
account_manager.reset_after_account_deletion()
|
|
return {"accepted": true}
|
|
|
|
|
|
func redeem_unlock_code(code: String) -> Dictionary:
|
|
if not is_authenticated():
|
|
return {"accepted": false, "error": "offline"}
|
|
var result = await client.rpc_async(session, "redeem_unlock_code", JSON.stringify({"code": code}))
|
|
var parsed := _parse_rpc_result(result)
|
|
if parsed.is_empty():
|
|
return {"accepted": false, "error": "invalid"}
|
|
return parsed
|
|
|
|
|
|
func get_promo_unlocks() -> Dictionary:
|
|
if not is_authenticated():
|
|
return {}
|
|
var result = await client.rpc_async(session, "get_promo_unlocks", "{}")
|
|
var parsed := _parse_rpc_result(result)
|
|
return parsed if bool(parsed.get("accepted", false)) else {}
|
|
|
|
|
|
func set_presence_status(value: String) -> void:
|
|
if not is_online() or value == presence_status:
|
|
return
|
|
var safe_value := "playing" if value == "playing" else "menu"
|
|
var result = await socket.update_status_async(safe_value)
|
|
if result != null and not result.is_exception():
|
|
presence_status = safe_value
|
|
|
|
|
|
func refresh_friends() -> bool:
|
|
if not is_authenticated():
|
|
return false
|
|
var result = await client.list_friends_async(session, null, 100)
|
|
if result == null or result.is_exception():
|
|
return false
|
|
var rows: Array[Dictionary] = []
|
|
var follow_ids := PackedStringArray()
|
|
for item in result.friends:
|
|
if item.user == null:
|
|
continue
|
|
var user_id: String = item.user.id
|
|
var state: int = item.state
|
|
rows.append({"id": user_id, "username": item.user.username,
|
|
"name": item.user.display_name if not item.user.display_name.is_empty() else item.user.username,
|
|
"state": state, "online": item.user.online})
|
|
if state == 0:
|
|
follow_ids.append(user_id)
|
|
friends = rows
|
|
friend_statuses.clear()
|
|
if is_online() and not follow_ids.is_empty():
|
|
var statuses = await socket.follow_users_async(follow_ids, PackedStringArray())
|
|
if statuses != null and not statuses.is_exception():
|
|
for presence in statuses.presences:
|
|
friend_statuses[presence.user_id] = presence.status
|
|
friends_changed.emit()
|
|
return true
|
|
|
|
|
|
func add_friend_by_display_name(display_name: String) -> Dictionary:
|
|
var normalized := display_name.strip_edges()
|
|
if not is_authenticated() or normalized.is_empty():
|
|
return {"accepted": false, "error": "offline"}
|
|
var lookup_result = await client.rpc_async(session, "find_player_by_name", JSON.stringify({"display_name": normalized}))
|
|
var found := _parse_rpc_result(lookup_result)
|
|
if not bool(found.get("found", false)):
|
|
return {"accepted": false, "error": String(found.get("error", "not_found"))}
|
|
var user_id := String(found.get("user_id", ""))
|
|
if user_id.is_empty() or user_id == session.user_id:
|
|
return {"accepted": false, "error": "self"}
|
|
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
|
|
if result == null or result.is_exception():
|
|
return {"accepted": false, "error": "send_failed"}
|
|
await refresh_friends()
|
|
return {"accepted": true, "display_name": String(found.get("display_name", normalized))}
|
|
|
|
|
|
func accept_friend(user_id: String) -> bool:
|
|
if not is_authenticated() or user_id.is_empty():
|
|
return false
|
|
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
|
|
if result == null or result.is_exception():
|
|
return false
|
|
await refresh_friends()
|
|
return true
|
|
|
|
|
|
func remove_friend(user_id: String) -> bool:
|
|
if not is_authenticated() or user_id.is_empty():
|
|
return false
|
|
var result = await client.delete_friends_async(session, PackedStringArray([user_id]))
|
|
if result == null or result.is_exception():
|
|
return false
|
|
friend_statuses.erase(user_id)
|
|
await refresh_friends()
|
|
return true
|
|
|
|
|
|
func invite_friend(user_id: String, room_code: String) -> bool:
|
|
if not is_authenticated() or user_id.is_empty() or room_code.length() != 6:
|
|
return false
|
|
var result = await client.rpc_async(session, "invite_friend", JSON.stringify({"friend_id": user_id, "room_code": room_code}))
|
|
return bool(_parse_rpc_result(result).get("accepted", false))
|
|
|
|
|
|
func _on_status_presence(event) -> void:
|
|
for presence in event.joins:
|
|
friend_statuses[presence.user_id] = presence.status
|
|
for presence in event.leaves:
|
|
friend_statuses.erase(presence.user_id)
|
|
friends_changed.emit()
|
|
|
|
|
|
func _on_notification(notification) -> void:
|
|
if notification.code in [-2, -3]:
|
|
refresh_friends()
|
|
return
|
|
if notification.code != 101:
|
|
return
|
|
var content = JSON.parse_string(notification.content)
|
|
if not content is Dictionary:
|
|
return
|
|
var code := String(content.get("room_code", ""))
|
|
if code.length() != 6:
|
|
return
|
|
match_invite_received.emit({"room_code": code, "from": String(content.get("from", "Mitspieler"))})
|
|
|
|
|
|
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 owner_ids := PackedStringArray()
|
|
for record in result.records:
|
|
if not record.owner_id.is_empty() and not owner_ids.has(record.owner_id):
|
|
owner_ids.append(record.owner_id)
|
|
var current_names := {}
|
|
if not owner_ids.is_empty():
|
|
var users_result = await client.get_users_async(session, owner_ids)
|
|
if users_result != null and not users_result.is_exception():
|
|
for user in users_result.users:
|
|
if not user.display_name.is_empty():
|
|
current_names[user.id] = user.display_name
|
|
var rows: Array = []
|
|
for record in result.records:
|
|
rows.append({
|
|
"rank": int(record.rank),
|
|
"username": str(current_names.get(record.owner_id, record.username)),
|
|
"score": int(record.score),
|
|
"wave": int(record.subscore),
|
|
"owner_id": record.owner_id,
|
|
"map": map_index,
|
|
"difficulty": difficulty_index,
|
|
"mode": "endless" if endless else "mission"
|
|
})
|
|
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
|
|
pending_snapshot_payloads.clear()
|
|
_finish_match_diagnostics("cancelled_matchmaking")
|
|
var match_to_leave = current_match
|
|
current_match = null
|
|
if socket and not matchmaker_ticket.is_empty():
|
|
await socket.remove_matchmaker_async(matchmaker_ticket)
|
|
matchmaker_ticket = ""
|
|
if socket and match_to_leave != null:
|
|
await socket.leave_match_async(match_to_leave.match_id)
|
|
private_room_code = ""
|
|
matchmaking_state_changed.emit("idle")
|
|
|
|
|
|
func create_private_room(difficulty := 1) -> bool:
|
|
intentional_disconnect = false
|
|
pending_snapshot_payloads.clear()
|
|
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
|
|
pending_snapshot_payloads.clear()
|
|
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
|
|
pending_snapshot_payloads.clear()
|
|
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:
|
|
pending_snapshot_payloads.clear()
|
|
_finish_match_diagnostics("left_match")
|
|
var match_to_leave = current_match
|
|
current_match = null
|
|
if socket and match_to_leave != null:
|
|
await socket.leave_match_async(match_to_leave.match_id)
|
|
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
|
|
if data.op_code == OP_SNAPSHOT:
|
|
_begin_match_diagnostics()
|
|
var now_ms := Time.get_ticks_msec()
|
|
if snapshot_previous_ms > 0:
|
|
var gap_ms := now_ms - snapshot_previous_ms
|
|
snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms)
|
|
snapshot_gap_samples.append(gap_ms)
|
|
if gap_ms > 500:
|
|
snapshot_large_gap_count += 1
|
|
_write_diagnostic_record("snapshot_gap", {"duration_ms": gap_ms, "last_server_tick": snapshot_tick_previous})
|
|
snapshot_previous_ms = now_ms
|
|
snapshot_count += 1
|
|
var payload_bytes := String(data.data).to_utf8_buffer().size()
|
|
snapshot_bytes_max = maxi(snapshot_bytes_max, payload_bytes)
|
|
snapshot_bytes_total += payload_bytes
|
|
pending_snapshot_payloads.append(String(data.data))
|
|
snapshot_queue_max = maxi(snapshot_queue_max, pending_snapshot_payloads.size())
|
|
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
|
|
pending_snapshot_payloads.pop_front()
|
|
snapshot_queue_dropped_count += 1
|
|
elif data.op_code == OP_EVENT:
|
|
var parsed = JSON.parse_string(data.data)
|
|
if parsed is Dictionary:
|
|
match_event_received.emit(parsed)
|
|
|
|
|
|
func _on_socket_closed() -> void:
|
|
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
|
|
_write_diagnostic_record("socket_closed", {"reconnect": not intentional_disconnect and current_match != null})
|
|
pending_snapshot_payloads.clear()
|
|
presence_status = ""
|
|
friend_statuses.clear()
|
|
friends_changed.emit()
|
|
snapshot_previous_ms = 0
|
|
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)
|
|
elif not intentional_disconnect and reconnect_match_id.is_empty() and not reconnect_in_progress and is_authenticated():
|
|
reconnect_in_progress = true
|
|
_recover_presence.call_deferred()
|
|
|
|
|
|
func _recover_presence() -> 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
|
|
reconnect_in_progress = false
|
|
_set_connection_state("online")
|
|
var parent_game = get_parent()
|
|
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu")
|
|
await refresh_friends()
|
|
return
|
|
reconnect_in_progress = false
|
|
|
|
|
|
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():
|
|
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
|
|
_write_diagnostic_record("socket_reconnected", {"attempt": attempt + 1})
|
|
current_match = rejoined
|
|
intentional_disconnect = false
|
|
reconnect_in_progress = false
|
|
_set_connection_state("online")
|
|
var parent_game = get_parent()
|
|
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over"] else "menu")
|
|
matchmaking_state_changed.emit("reconnected")
|
|
return
|
|
reconnect_in_progress = false
|
|
print("ONLINE DIAG websocket_reconnect_failed")
|
|
_write_diagnostic_record("socket_reconnect_failed", {})
|
|
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, 6), 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)
|