618 lines
16 KiB
GDScript
618 lines
16 KiB
GDScript
class_name NetworkManager
|
|
extends Node
|
|
|
|
## NetworkManager - Singleton for handling network communication
|
|
## Manages authentication, WebSocket connection, and game messaging
|
|
|
|
# ======= SIGNALS =======
|
|
# Connection and auth
|
|
signal connection_state_changed(state: ConnectionState)
|
|
signal authenticated(user_data: Dictionary)
|
|
signal authentication_failed(error: String)
|
|
signal logged_out
|
|
|
|
# Matchmaking - maps from server messages: queue_joined, queue_left, match_found, room_*
|
|
signal matchmaking_update(data: Dictionary)
|
|
signal queue_joined
|
|
signal queue_left
|
|
signal match_found(game_data: Dictionary)
|
|
signal room_created(room_data: Dictionary)
|
|
signal room_joined(room_data: Dictionary)
|
|
signal room_updated(room_data: Dictionary)
|
|
|
|
# Game messages - maps from server 'opponent_action' message
|
|
signal opponent_action_received(action: Dictionary)
|
|
# Maps from server 'turn_timer' message
|
|
signal turn_timer_update(seconds_remaining: int)
|
|
signal game_started(game_data: Dictionary)
|
|
signal game_ended(result: Dictionary)
|
|
signal phase_changed(phase_data: Dictionary)
|
|
signal action_confirmed(action_type: String)
|
|
signal action_failed(action_type: String, error: String)
|
|
signal opponent_disconnected(reconnect_timeout: int)
|
|
signal opponent_reconnected
|
|
signal game_state_sync(state: Dictionary)
|
|
|
|
# Error handling
|
|
signal network_error(error: String)
|
|
|
|
# ======= ENUMS =======
|
|
enum ConnectionState {
|
|
DISCONNECTED,
|
|
CONNECTING,
|
|
CONNECTED,
|
|
AUTHENTICATING,
|
|
AUTHENTICATED,
|
|
IN_QUEUE,
|
|
IN_ROOM,
|
|
IN_GAME
|
|
}
|
|
|
|
# ======= CONSTANTS =======
|
|
const DEFAULT_HTTP_URL = "http://localhost:3000"
|
|
const DEFAULT_WS_URL = "ws://localhost:3001"
|
|
const HEARTBEAT_INTERVAL = 10.0 # seconds
|
|
const RECONNECT_DELAY = 5.0
|
|
const MAX_RECONNECT_ATTEMPTS = 3
|
|
const TOKEN_FILE = "user://auth_token.dat"
|
|
|
|
# ======= STATE =======
|
|
var connection_state: ConnectionState = ConnectionState.DISCONNECTED
|
|
var http_base_url: String = DEFAULT_HTTP_URL
|
|
var ws_url: String = DEFAULT_WS_URL
|
|
|
|
# Auth state
|
|
var auth_token: String = ""
|
|
var current_user: Dictionary = {}
|
|
var is_authenticated: bool = false
|
|
|
|
# WebSocket
|
|
var _websocket: WebSocketPeer = null
|
|
var _heartbeat_timer: Timer = null
|
|
var _reconnect_attempts: int = 0
|
|
|
|
# HTTP request pool
|
|
var _http_requests: Array[HTTPRequest] = []
|
|
|
|
# Game session
|
|
var current_game_id: String = ""
|
|
var current_room_code: String = ""
|
|
var opponent_info: Dictionary = {}
|
|
var local_player_index: int = 0
|
|
|
|
|
|
func _ready() -> void:
|
|
# Try to load saved token on startup
|
|
_load_token()
|
|
|
|
# Setup heartbeat timer
|
|
_heartbeat_timer = Timer.new()
|
|
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
|
|
_heartbeat_timer.timeout.connect(_on_heartbeat_timeout)
|
|
add_child(_heartbeat_timer)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
# Poll WebSocket if connected
|
|
if _websocket:
|
|
_websocket.poll()
|
|
|
|
var state = _websocket.get_ready_state()
|
|
match state:
|
|
WebSocketPeer.STATE_OPEN:
|
|
while _websocket.get_available_packet_count() > 0:
|
|
var packet = _websocket.get_packet()
|
|
_on_websocket_message(packet)
|
|
WebSocketPeer.STATE_CLOSING:
|
|
pass # Wait for close
|
|
WebSocketPeer.STATE_CLOSED:
|
|
var code = _websocket.get_close_code()
|
|
var reason = _websocket.get_close_reason()
|
|
print("WebSocket closed: ", code, " - ", reason)
|
|
_on_websocket_closed()
|
|
|
|
|
|
# ======= CONFIGURATION =======
|
|
|
|
func configure(http_url: String, ws_url_param: String) -> void:
|
|
http_base_url = http_url
|
|
ws_url = ws_url_param
|
|
|
|
|
|
# ======= HTTP AUTH API =======
|
|
|
|
func register(email: String, password: String, username: String) -> Dictionary:
|
|
var result = await _http_post("/api/auth/register", {
|
|
"email": email,
|
|
"password": password,
|
|
"username": username
|
|
})
|
|
return result
|
|
|
|
|
|
func login(email: String, password: String) -> Dictionary:
|
|
var result = await _http_post("/api/auth/login", {
|
|
"email": email,
|
|
"password": password
|
|
})
|
|
|
|
if result.success:
|
|
auth_token = result.token
|
|
current_user = result.user
|
|
is_authenticated = true
|
|
_save_token()
|
|
authenticated.emit(current_user)
|
|
else:
|
|
authentication_failed.emit(result.message)
|
|
|
|
return result
|
|
|
|
|
|
func logout() -> void:
|
|
auth_token = ""
|
|
current_user = {}
|
|
is_authenticated = false
|
|
_clear_token()
|
|
disconnect_websocket()
|
|
logged_out.emit()
|
|
|
|
|
|
func verify_email(token: String) -> Dictionary:
|
|
return await _http_post("/api/auth/verify-email", { "token": token })
|
|
|
|
|
|
func forgot_password(email: String) -> Dictionary:
|
|
return await _http_post("/api/auth/forgot-password", { "email": email })
|
|
|
|
|
|
func reset_password(token: String, new_password: String) -> Dictionary:
|
|
return await _http_post("/api/auth/reset-password", {
|
|
"token": token,
|
|
"newPassword": new_password
|
|
})
|
|
|
|
|
|
func resend_verification(email: String) -> Dictionary:
|
|
return await _http_post("/api/auth/resend-verification", { "email": email })
|
|
|
|
|
|
func get_profile() -> Dictionary:
|
|
return await _http_get("/api/user/profile", true)
|
|
|
|
|
|
func get_match_history(limit: int = 20, offset: int = 0) -> Dictionary:
|
|
return await _http_get("/api/user/match-history?limit=%d&offset=%d" % [limit, offset], true)
|
|
|
|
|
|
func get_leaderboard(limit: int = 50, offset: int = 0) -> Dictionary:
|
|
return await _http_get("/api/leaderboard?limit=%d&offset=%d" % [limit, offset], false)
|
|
|
|
|
|
func save_deck(name: String, card_ids: Array) -> Dictionary:
|
|
return await _http_post("/api/user/decks", {
|
|
"name": name,
|
|
"cardIds": card_ids
|
|
}, true)
|
|
|
|
|
|
func delete_deck(deck_id: String) -> Dictionary:
|
|
return await _http_delete("/api/user/decks/" + deck_id)
|
|
|
|
|
|
# ======= HTTP HELPERS =======
|
|
|
|
func _get_http_request() -> HTTPRequest:
|
|
# Reuse or create HTTP request node
|
|
for req in _http_requests:
|
|
if not req.is_inside_tree():
|
|
add_child(req)
|
|
# Check if request is not busy (simplified check)
|
|
return req
|
|
|
|
var new_req = HTTPRequest.new()
|
|
_http_requests.append(new_req)
|
|
add_child(new_req)
|
|
return new_req
|
|
|
|
|
|
func _http_post(endpoint: String, body: Dictionary, auth_required: bool = false) -> Dictionary:
|
|
var http = _get_http_request()
|
|
var url = http_base_url + endpoint
|
|
var headers = PackedStringArray(["Content-Type: application/json"])
|
|
|
|
if auth_required and auth_token != "":
|
|
headers.append("Authorization: Bearer " + auth_token)
|
|
|
|
var json_body = JSON.stringify(body)
|
|
var error = http.request(url, headers, HTTPClient.METHOD_POST, json_body)
|
|
|
|
if error != OK:
|
|
return { "success": false, "message": "HTTP request failed" }
|
|
|
|
var result = await http.request_completed
|
|
return _parse_http_response(result)
|
|
|
|
|
|
func _http_get(endpoint: String, auth_required: bool = false) -> Dictionary:
|
|
var http = _get_http_request()
|
|
var url = http_base_url + endpoint
|
|
var headers = PackedStringArray()
|
|
|
|
if auth_required and auth_token != "":
|
|
headers.append("Authorization: Bearer " + auth_token)
|
|
|
|
var error = http.request(url, headers, HTTPClient.METHOD_GET)
|
|
|
|
if error != OK:
|
|
return { "success": false, "message": "HTTP request failed" }
|
|
|
|
var result = await http.request_completed
|
|
return _parse_http_response(result)
|
|
|
|
|
|
func _http_delete(endpoint: String) -> Dictionary:
|
|
var http = _get_http_request()
|
|
var url = http_base_url + endpoint
|
|
var headers = PackedStringArray()
|
|
|
|
if auth_token != "":
|
|
headers.append("Authorization: Bearer " + auth_token)
|
|
|
|
var error = http.request(url, headers, HTTPClient.METHOD_DELETE)
|
|
|
|
if error != OK:
|
|
return { "success": false, "message": "HTTP request failed" }
|
|
|
|
var result = await http.request_completed
|
|
return _parse_http_response(result)
|
|
|
|
|
|
func _parse_http_response(result: Array) -> Dictionary:
|
|
var response_code = result[1]
|
|
var body = result[3]
|
|
|
|
if response_code == 0:
|
|
return { "success": false, "message": "Connection failed" }
|
|
|
|
var json = JSON.new()
|
|
var parse_result = json.parse(body.get_string_from_utf8())
|
|
|
|
if parse_result != OK:
|
|
return { "success": false, "message": "Invalid response" }
|
|
|
|
var data = json.data
|
|
if data is Dictionary:
|
|
return data
|
|
|
|
return { "success": false, "message": "Unexpected response format" }
|
|
|
|
|
|
# ======= WEBSOCKET =======
|
|
|
|
func connect_websocket() -> void:
|
|
if _websocket != null:
|
|
disconnect_websocket()
|
|
|
|
_set_connection_state(ConnectionState.CONNECTING)
|
|
|
|
_websocket = WebSocketPeer.new()
|
|
var error = _websocket.connect_to_url(ws_url)
|
|
|
|
if error != OK:
|
|
print("WebSocket connection error: ", error)
|
|
_set_connection_state(ConnectionState.DISCONNECTED)
|
|
network_error.emit("Failed to connect to server")
|
|
return
|
|
|
|
# Wait for connection
|
|
await get_tree().create_timer(0.5).timeout
|
|
|
|
if _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
|
_on_websocket_connected()
|
|
|
|
|
|
func disconnect_websocket() -> void:
|
|
if _websocket:
|
|
_websocket.close()
|
|
_websocket = null
|
|
|
|
_heartbeat_timer.stop()
|
|
_set_connection_state(ConnectionState.DISCONNECTED)
|
|
current_game_id = ""
|
|
current_room_code = ""
|
|
|
|
|
|
func _on_websocket_connected() -> void:
|
|
print("WebSocket connected")
|
|
_set_connection_state(ConnectionState.CONNECTED)
|
|
_reconnect_attempts = 0
|
|
|
|
# Authenticate with JWT
|
|
if auth_token != "":
|
|
_set_connection_state(ConnectionState.AUTHENTICATING)
|
|
_send_ws_message("auth", { "token": auth_token })
|
|
|
|
_heartbeat_timer.start()
|
|
|
|
|
|
func _on_websocket_message(data: PackedByteArray) -> void:
|
|
var json = JSON.new()
|
|
var parse_result = json.parse(data.get_string_from_utf8())
|
|
|
|
if parse_result != OK:
|
|
push_error("Failed to parse WebSocket message: " + str(parse_result))
|
|
network_error.emit("Invalid message from server")
|
|
return
|
|
|
|
var message = json.data
|
|
if not message is Dictionary or not message.has("type"):
|
|
push_error("Invalid message format: missing 'type' field")
|
|
network_error.emit("Invalid message format from server")
|
|
return
|
|
|
|
_handle_ws_message(message)
|
|
|
|
|
|
func _on_websocket_closed() -> void:
|
|
_websocket = null
|
|
_heartbeat_timer.stop()
|
|
|
|
var was_authenticated = connection_state == ConnectionState.AUTHENTICATED or connection_state == ConnectionState.IN_GAME
|
|
_set_connection_state(ConnectionState.DISCONNECTED)
|
|
|
|
# Try to reconnect if we were authenticated
|
|
if was_authenticated and _reconnect_attempts < MAX_RECONNECT_ATTEMPTS:
|
|
_reconnect_attempts += 1
|
|
print("Attempting reconnect... (attempt ", _reconnect_attempts, ")")
|
|
await get_tree().create_timer(RECONNECT_DELAY).timeout
|
|
connect_websocket()
|
|
|
|
|
|
func _handle_ws_message(message: Dictionary) -> void:
|
|
var msg_type = message.get("type", "")
|
|
var payload = message.get("payload", {})
|
|
|
|
match msg_type:
|
|
"auth_success":
|
|
print("WebSocket authenticated as: ", payload.get("username", ""))
|
|
_set_connection_state(ConnectionState.AUTHENTICATED)
|
|
|
|
"auth_error":
|
|
print("WebSocket auth error: ", payload.get("message", ""))
|
|
_set_connection_state(ConnectionState.CONNECTED)
|
|
authentication_failed.emit(payload.get("message", "Authentication failed"))
|
|
|
|
"pong":
|
|
pass # Heartbeat response
|
|
|
|
"error":
|
|
print("Server error: ", payload.get("message", ""))
|
|
network_error.emit(payload.get("message", "Unknown error"))
|
|
|
|
"disconnected":
|
|
print("Disconnected: ", payload.get("message", ""))
|
|
disconnect_websocket()
|
|
|
|
# Matchmaking messages
|
|
"queue_joined":
|
|
_set_connection_state(ConnectionState.IN_QUEUE)
|
|
queue_joined.emit()
|
|
matchmaking_update.emit({ "type": "queue_joined", "position": payload.get("position", 0) })
|
|
|
|
"queue_left":
|
|
_set_connection_state(ConnectionState.AUTHENTICATED)
|
|
queue_left.emit()
|
|
matchmaking_update.emit({ "type": "queue_left" })
|
|
|
|
"match_found":
|
|
_set_connection_state(ConnectionState.IN_GAME)
|
|
current_game_id = payload.get("game_id", "")
|
|
opponent_info = payload.get("opponent", {})
|
|
local_player_index = payload.get("your_player_index", 0)
|
|
match_found.emit(payload)
|
|
|
|
"room_created":
|
|
_set_connection_state(ConnectionState.IN_ROOM)
|
|
current_room_code = payload.get("code", "")
|
|
room_created.emit(payload)
|
|
|
|
"room_joined":
|
|
_set_connection_state(ConnectionState.IN_ROOM)
|
|
current_room_code = payload.get("code", "")
|
|
room_joined.emit(payload)
|
|
|
|
"room_updated":
|
|
room_updated.emit(payload)
|
|
|
|
"room_left":
|
|
_set_connection_state(ConnectionState.AUTHENTICATED)
|
|
current_room_code = ""
|
|
matchmaking_update.emit({ "type": "room_left" })
|
|
|
|
# Game messages
|
|
"game_start":
|
|
_set_connection_state(ConnectionState.IN_GAME)
|
|
current_game_id = payload.get("game_id", "")
|
|
game_started.emit(payload)
|
|
|
|
"opponent_action":
|
|
opponent_action_received.emit(payload)
|
|
|
|
"turn_timer":
|
|
turn_timer_update.emit(payload.get("seconds_remaining", 0))
|
|
|
|
"phase_changed":
|
|
phase_changed.emit(payload)
|
|
|
|
"action_confirmed":
|
|
action_confirmed.emit(payload.get("action_type", ""))
|
|
|
|
"action_failed":
|
|
action_failed.emit(payload.get("action_type", ""), payload.get("error", "Unknown error"))
|
|
network_error.emit("Action failed: " + payload.get("error", "Unknown error"))
|
|
|
|
"opponent_disconnected":
|
|
opponent_disconnected.emit(payload.get("reconnect_timeout_seconds", 60))
|
|
|
|
"opponent_reconnected":
|
|
opponent_reconnected.emit()
|
|
|
|
"game_state_sync":
|
|
game_state_sync.emit(payload)
|
|
|
|
"game_ended":
|
|
game_ended.emit(payload)
|
|
_set_connection_state(ConnectionState.AUTHENTICATED)
|
|
current_game_id = ""
|
|
|
|
_:
|
|
print("Unknown message type: ", msg_type)
|
|
|
|
|
|
func _send_ws_message(type: String, payload: Dictionary) -> void:
|
|
if _websocket == null or _websocket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
|
print("Cannot send message - WebSocket not connected")
|
|
return
|
|
|
|
var message = {
|
|
"type": type,
|
|
"payload": payload
|
|
}
|
|
|
|
var json = JSON.stringify(message)
|
|
_websocket.send_text(json)
|
|
|
|
|
|
func _on_heartbeat_timeout() -> void:
|
|
if _websocket and _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
|
_send_ws_message("ping", { "client_time": Time.get_unix_time_from_system() })
|
|
|
|
|
|
func _set_connection_state(new_state: ConnectionState) -> void:
|
|
if connection_state != new_state:
|
|
connection_state = new_state
|
|
connection_state_changed.emit(new_state)
|
|
|
|
|
|
# ======= MATCHMAKING =======
|
|
|
|
func join_queue(deck_id: String) -> void:
|
|
_send_ws_message("queue_join", { "deck_id": deck_id })
|
|
|
|
|
|
func leave_queue() -> void:
|
|
_send_ws_message("queue_leave", {})
|
|
|
|
|
|
func create_room(deck_id: String) -> void:
|
|
_send_ws_message("room_create", { "deck_id": deck_id })
|
|
|
|
|
|
func join_room(room_code: String, deck_id: String) -> void:
|
|
_send_ws_message("room_join", { "room_code": room_code.to_upper(), "deck_id": deck_id })
|
|
|
|
|
|
func leave_room() -> void:
|
|
_send_ws_message("room_leave", {})
|
|
|
|
|
|
func set_room_ready(ready: bool) -> void:
|
|
_send_ws_message("room_ready", { "ready": ready })
|
|
|
|
|
|
# ======= GAME ACTIONS =======
|
|
|
|
func send_game_action(action_type: String, payload: Dictionary) -> void:
|
|
payload["game_id"] = current_game_id
|
|
_send_ws_message("action_" + action_type, payload)
|
|
|
|
|
|
func send_play_card(card_instance_id: int) -> void:
|
|
send_game_action("play_card", { "card_instance_id": card_instance_id })
|
|
|
|
|
|
func send_attack(attacker_instance_id: int) -> void:
|
|
send_game_action("attack", { "attacker_instance_id": attacker_instance_id })
|
|
|
|
|
|
func send_block(blocker_instance_id) -> void: # Can be int or null
|
|
send_game_action("block", { "blocker_instance_id": blocker_instance_id })
|
|
|
|
|
|
func send_pass() -> void:
|
|
send_game_action("pass", {})
|
|
|
|
|
|
func send_concede() -> void:
|
|
send_game_action("concede", {})
|
|
|
|
|
|
func send_discard_for_cp(card_instance_id: int) -> void:
|
|
send_game_action("discard_cp", { "card_instance_id": card_instance_id })
|
|
|
|
|
|
func send_dull_backup_for_cp(card_instance_id: int) -> void:
|
|
send_game_action("dull_backup_cp", { "card_instance_id": card_instance_id })
|
|
|
|
|
|
func send_attack_resolved() -> void:
|
|
send_game_action("attack_resolved", {})
|
|
|
|
|
|
func send_report_game_end(winner_id: String, reason: String) -> void:
|
|
send_game_action("report_game_end", { "winner_id": winner_id, "reason": reason })
|
|
|
|
|
|
# ======= TOKEN PERSISTENCE =======
|
|
|
|
func _save_token() -> void:
|
|
if auth_token == "":
|
|
return
|
|
|
|
var file = FileAccess.open(TOKEN_FILE, FileAccess.WRITE)
|
|
if file:
|
|
file.store_string(auth_token)
|
|
file.close()
|
|
|
|
|
|
func _load_token() -> void:
|
|
if not FileAccess.file_exists(TOKEN_FILE):
|
|
return
|
|
|
|
var file = FileAccess.open(TOKEN_FILE, FileAccess.READ)
|
|
if file:
|
|
auth_token = file.get_as_text().strip_edges()
|
|
file.close()
|
|
|
|
if auth_token != "":
|
|
# Validate token by fetching profile
|
|
var profile = await get_profile()
|
|
if profile.success:
|
|
current_user = profile.user
|
|
is_authenticated = true
|
|
authenticated.emit(current_user)
|
|
else:
|
|
# Token invalid, clear it
|
|
_clear_token()
|
|
|
|
|
|
func _clear_token() -> void:
|
|
auth_token = ""
|
|
if FileAccess.file_exists(TOKEN_FILE):
|
|
DirAccess.remove_absolute(TOKEN_FILE)
|
|
|
|
|
|
# ======= UTILITY =======
|
|
|
|
func get_connection_state_name() -> String:
|
|
match connection_state:
|
|
ConnectionState.DISCONNECTED: return "Disconnected"
|
|
ConnectionState.CONNECTING: return "Connecting"
|
|
ConnectionState.CONNECTED: return "Connected"
|
|
ConnectionState.AUTHENTICATING: return "Authenticating"
|
|
ConnectionState.AUTHENTICATED: return "Online"
|
|
ConnectionState.IN_QUEUE: return "In Queue"
|
|
ConnectionState.IN_ROOM: return "In Room"
|
|
ConnectionState.IN_GAME: return "In Game"
|
|
return "Unknown"
|