Files
FFCardGame/scripts/network/NetworkManager.gd
2026-02-02 16:28:53 -05:00

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"