diff --git a/addons/JsonClassConverter/AssetIcon.jpg b/addons/JsonClassConverter/AssetIcon.jpg new file mode 100644 index 0000000..73f4827 Binary files /dev/null and b/addons/JsonClassConverter/AssetIcon.jpg differ diff --git a/addons/JsonClassConverter/AssetIcon.jpg.import b/addons/JsonClassConverter/AssetIcon.jpg.import new file mode 100644 index 0000000..112628a --- /dev/null +++ b/addons/JsonClassConverter/AssetIcon.jpg.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cbt51sclatky7" +path="res://.godot/imported/AssetIcon.jpg-09518bcc644832efd4eedeb1abc08598.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/JsonClassConverter/AssetIcon.jpg" +dest_files=["res://.godot/imported/AssetIcon.jpg-09518bcc644832efd4eedeb1abc08598.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/JsonClassConverter/JsonClassConverter.gd b/addons/JsonClassConverter/JsonClassConverter.gd new file mode 100644 index 0000000..d81bab6 --- /dev/null +++ b/addons/JsonClassConverter/JsonClassConverter.gd @@ -0,0 +1,310 @@ +class_name JsonClassConverter + +# Flag to control whether to save nested resources as separate .tres files +static var save_temp_resources_tres: bool = false + +## Checks if the provided class is valid (not null) +static func _check_cast_class(castClass: GDScript) -> bool: + if typeof(castClass) == Variant.Type.TYPE_NIL: + printerr("The provided class is null.") + return false + return true + +## Checks if the directory for the given file path exists, creating it if necessary. +static func check_dir(file_path: String) -> void: + if !DirAccess.dir_exists_absolute(file_path.get_base_dir()): + DirAccess.make_dir_absolute(file_path.get_base_dir()) + +#region Json to Class + +## Loads a JSON file and parses it into a Dictionary. +## Supports optional decryption using a security key. +static func json_file_to_dict(file_path: String, security_key: String = "") -> Dictionary: + var file: FileAccess + if FileAccess.file_exists(file_path): + if security_key.length() == 0: + file = FileAccess.open(file_path, FileAccess.READ) + else: + file = FileAccess.open_encrypted_with_pass(file_path, FileAccess.READ, security_key) + if not file: + printerr("Error opening file: ", file_path) + return {} + var parsed_results: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if parsed_results is Dictionary or parsed_results is Array: + return parsed_results + return {} + +## Loads a JSON file and converts its contents into a Godot class instance. +## Uses the provided GDScript (castClass) as a template for the class. +static func json_file_to_class(castClass: GDScript, file_path: String, security_key: String = "") -> Object: + if not _check_cast_class(castClass): + printerr("The provided class is null.") + return null + var parsed_results = json_file_to_dict(file_path, security_key) + if parsed_results == null: + return castClass.new() + return json_to_class(castClass, parsed_results) + +## Converts a JSON string into a Godot class instance. +static func json_string_to_class(castClass: GDScript, json_string: String) -> Object: + if not _check_cast_class(castClass): + printerr("The provided class is null.") + return null + var json: JSON = JSON.new() + var parse_result: Error = json.parse(json_string) + if parse_result == Error.OK: + return json_to_class(castClass, json.data) + return castClass.new() + +## Converts a JSON dictionary into a Godot class instance. +## This is the core deserialization function. +static func json_to_class(castClass: GDScript, json: Dictionary) -> Object: + # Create an instance of the target class + var _class: Object = castClass.new() as Object + var properties: Array = _class.get_property_list() + + # Iterate through each key-value pair in the JSON dictionary + for key: String in json.keys(): + var value: Variant = json[key] + + # Special handling for Vector types (stored as strings in JSON) + if type_string(typeof(value)) == "String" and value.begins_with("Vector"): + value = str_to_var(value) + + # Find the matching property in the target class + for property: Dictionary in properties: + # Skip the 'script' property (built-in) + if property.name == "script": + continue + + # Get the current value of the property in the class instance + var property_value: Variant = _class.get(property.name) + + # If the property name matches the JSON key and is a script variable: + if property.name == key and property.usage >= PROPERTY_USAGE_SCRIPT_VARIABLE: + # Case 1: Property is an Object (not an array) + if not property_value is Array and property.type == TYPE_OBJECT: + var inner_class_path: String = "" + if property_value: + # If the property already holds an object, try to get its script path + for inner_property: Dictionary in property_value.get_property_list(): + if inner_property.has("hint_string") and inner_property["hint_string"].contains(".gd"): + inner_class_path = inner_property["hint_string"] + # Recursively deserialize nested objects + _class.set(property.name, json_to_class(load(inner_class_path), value)) + elif value: + var script_type: GDScript = null + # Determine the script type for the nested object + if value is Dictionary and value.has("script_inheritance"): + script_type = get_gdscript(value["script_inheritance"]) + else: + script_type = get_gdscript(property. class_name ) + + # If the value is a resource path, load the resource + if value is String and value.is_absolute_path(): + _class.set(property.name, ResourceLoader.load(get_main_tres_path(value))) + else: + # Recursively deserialize nested objects + _class.set(property.name, json_to_class(script_type, value)) + + # Case 2: Property is an Array + elif property_value is Array: + if property.has("hint_string"): + var class_hint: String = property["hint_string"] + if class_hint.contains(":"): + # Extract class name from hint string (e.g., "24/34:ClassName") + class_hint = class_hint.split(":")[1] + + # Recursively convert the JSON array to a Godot array + var arrayTemp: Array = convert_json_to_array(value, get_gdscript(class_hint)) + + # Handle Vector arrays (convert string elements back to Vectors) + if type_string(property_value.get_typed_builtin()).begins_with("Vector"): + for obj_array: Variant in arrayTemp: + _class.get(property.name).append(str_to_var(obj_array)) + else: + _class.get(property.name).assign(arrayTemp) + + # Case 3: Property is a simple type (not an object or array) + else: + # Special handling for Color type (stored as a hex string) + if property.type == TYPE_COLOR: + value = Color(value) + if property.type == TYPE_INT and property.hint == PROPERTY_HINT_ENUM: + var enum_strs: Array = property.hint_string.split(",") + var enum_value: int = 0 + for enum_str: String in enum_strs: + if enum_str.contains(":"): + var enum_keys: Array = enum_str.split(":") + for i: int in enum_keys.size(): + if enum_keys[i].to_lower() == value.to_lower(): + enum_value = int(enum_keys[i + 1]) + _class.set(property.name, enum_value) + else: + _class.set(property.name, value) + + # Return the fully deserialized class instance + return _class + +## Helper function to find a GDScript by its class name. +static func get_gdscript(hint_class: String) -> GDScript: + for className: Dictionary in ProjectSettings.get_global_class_list(): + if className. class == hint_class: + return load(className.path) + return null + +## Helper function to recursively convert JSON arrays to Godot arrays. +static func convert_json_to_array(json_array: Array, cast_class: GDScript = null) -> Array: + var godot_array: Array = [] + for element: Variant in json_array: + if typeof(element) == TYPE_DICTIONARY: + # If json element has a script_inheritance, get the script (for inheritance or for untyped array/dictionary) + if "script_inheritance" in element: + cast_class = get_gdscript(element["script_inheritance"]) + godot_array.append(json_to_class(cast_class, element)) + elif typeof(element) == TYPE_ARRAY: + godot_array.append(convert_json_to_array(element)) + else: + godot_array.append(element) + return godot_array + +#endregion + +#region Class to Json +## Stores a JSON dictionary to a file, optionally with encryption. +static func store_json_file(file_path: String, data: Dictionary, security_key: String = "") -> bool: + check_dir(file_path) + var file: FileAccess + if security_key.length() == 0: + file = FileAccess.open(file_path, FileAccess.WRITE) + else: + file = FileAccess.open_encrypted_with_pass(file_path, FileAccess.WRITE, security_key) + if not file: + printerr("Error writing to a file") + return false + var json_string: String = JSON.stringify(data, "\t") + file.store_string(json_string) + file.close() + return true + +## Converts a Godot class instance into a JSON string. +static func class_to_json_string(_class: Object, save_temp_res: bool = false) -> String: + return JSON.stringify(class_to_json(_class, save_temp_res)) + +## Converts a Godot class instance into a JSON dictionary. +## This is the core serialization function. +static func class_to_json(_class: Object, save_temp_res: bool = false, inheritance: bool = false) -> Dictionary: + var dictionary: Dictionary = {} + save_temp_resources_tres = save_temp_res + # Store the script name for reference during deserialization if inheritance exists + if inheritance: + dictionary["script_inheritance"] = _class.get_script().get_global_name() + var properties: Array = _class.get_property_list() + + # Iterate through each property of the class + for property: Dictionary in properties: + var property_name: String = property["name"] + # Skip the built-in 'script' property + if property_name == "script": + continue + var property_value: Variant = _class.get(property_name) + + # Only serialize properties that are exported or marked for storage + if not property_name.is_empty() and property.usage >= PROPERTY_USAGE_SCRIPT_VARIABLE and property.usage & PROPERTY_USAGE_STORAGE > 0: + if property_value is Array: + # Recursively convert arrays to JSON + dictionary[property_name] = convert_array_to_json(property_value) + elif property_value is Dictionary: + # Recursively convert dictionaries to JSON + dictionary[property_name] = convert_dictionary_to_json(property_value) + # If the property is a Resource: + elif property["type"] == TYPE_OBJECT and property_value != null and property_value.get_property_list(): + if property_value is Resource and ResourceLoader.exists(property_value.resource_path): + var main_src: String = get_main_tres_path(property_value.resource_path) + if main_src.get_extension() != "tres": + # Store the resource path if it's not a .tres file + dictionary[property.name] = property_value.resource_path + elif save_temp_resources_tres: + # Save the resource as a separate .tres file + var tempfile = "user://temp_resource/" + check_dir(tempfile) + var nodePath: String = get_node_tres_path(property_value.resource_path) + if not nodePath.is_empty(): + tempfile += nodePath + tempfile += ".tres" + else: + tempfile += property_value.resource_path.get_file() + dictionary[property.name] = tempfile + ResourceSaver.save(property_value, tempfile) + else: + # Recursively serialize the nested resource + dictionary[property.name] = class_to_json(property_value, save_temp_resources_tres) + else: + dictionary[property.name] = class_to_json(property_value, save_temp_resources_tres, property. class_name != property_value.get_script().get_global_name()) + # Special handling for Vector types (store as strings) + elif type_string(typeof(property_value)).begins_with("Vector"): + dictionary[property_name] = var_to_str(property_value) + elif property["type"] == TYPE_COLOR: + # Store Color as a hex string + dictionary[property_name] = property_value.to_html() + else: + # Store other basic types directly + if property.type == TYPE_INT and property.hint == PROPERTY_HINT_ENUM: + var enum_value: String = property.hint_string.split(",")[property_value] + if enum_value.contains(":"): + dictionary[property.name] = enum_value.split(":")[0] + else: + dictionary[property.name] = enum_value + else: + dictionary[property.name] = property_value + return dictionary + +## Extracts the main path from a resource path (removes node path if present). +static func get_main_tres_path(path: String) -> String: + var path_parts: PackedStringArray = path.split("::", true, 1) + if path_parts.size() > 0: + return path_parts[0] + else: + return path + +## Extracts the node path from a resource path. +static func get_node_tres_path(path: String) -> String: + var path_parts: PackedStringArray = path.split("::", true, 1) + if path_parts.size() > 1: + return path_parts[1] + else: + return "" + + +## Helper function to recursively convert Godot arrays to JSON arrays. +static func convert_array_to_json(array: Array) -> Array: + var json_array: Array = [] + for element: Variant in array: + if element is Object: + json_array.append(class_to_json(element, save_temp_resources_tres,!array.is_typed())) + elif element is Array: + json_array.append(convert_array_to_json(element)) + elif element is Dictionary: + json_array.append(convert_dictionary_to_json(element)) + elif type_string(typeof(element)).begins_with("Vector"): + json_array.append(var_to_str(element)) + else: + json_array.append(element) + return json_array + +## Helper function to recursively convert Godot dictionaries to JSON dictionaries. +static func convert_dictionary_to_json(dictionary: Dictionary) -> Dictionary: + var json_dictionary: Dictionary = {} + for key: Variant in dictionary.keys(): + var value: Variant = dictionary[key] + if value is Object: + json_dictionary[key] = class_to_json(value, save_temp_resources_tres) + elif value is Array: + json_dictionary[key] = convert_array_to_json(value) + elif value is Dictionary: + json_dictionary[key] = convert_dictionary_to_json(value) + else: + json_dictionary[key] = value + return json_dictionary +#endregion diff --git a/addons/JsonClassConverter/JsonClassConverter.gd.uid b/addons/JsonClassConverter/JsonClassConverter.gd.uid new file mode 100644 index 0000000..4b825d8 --- /dev/null +++ b/addons/JsonClassConverter/JsonClassConverter.gd.uid @@ -0,0 +1 @@ +uid://ch1r4c54yetfx diff --git a/project.godot b/project.godot index 788a8aa..27843ab 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,10 @@ run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") config/icon="res://textures/icon.svg" +[autoload] + +ConnectionChannel="*res://scripts/channel/connection/connection_channel.gd" + [editor_plugins] enabled=PackedStringArray("res://addons/format_on_save/plugin.cfg") diff --git a/scenes/theme_test.tscn b/scenes/theme_test.tscn index 7f0cfaf..7964c82 100644 --- a/scenes/theme_test.tscn +++ b/scenes/theme_test.tscn @@ -1,7 +1,9 @@ -[gd_scene load_steps=7 format=3 uid="uid://ctqxikky2g0nj"] +[gd_scene load_steps=9 format=3 uid="uid://ctqxikky2g0nj"] [ext_resource type="Script" path="res://scripts/ui/switch_to_scene.gd" id="1_7goww"] [ext_resource type="Script" path="res://scripts/ui/websocket_time.gd" id="1_qy4dl"] +[ext_resource type="Resource" uid="uid://cdixdbu3sqgjn" path="res://config/api_config.tres" id="3_bphu2"] +[ext_resource type="Script" path="res://scripts/ui/login.gd" id="4_pkjxs"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ujhhp"] bg_color = Color(0.289228, 0.413265, 0.44363, 1) @@ -1612,8 +1614,31 @@ layout_mode = 2 text = "Important Video!" uri = "https://www.youtube.com/watch?v=PXqcHi2fkXI" -[node name="RichTextLabel" type="RichTextLabel" parent="HBoxContainer/ScrollContainer/VBoxContainer"] +[node name="UsernameInput" type="LineEdit" parent="HBoxContainer/ScrollContainer/VBoxContainer"] +layout_mode = 2 +text = "Player1" +placeholder_text = "Username" + +[node name="PasswordInput" type="LineEdit" parent="HBoxContainer/ScrollContainer/VBoxContainer"] +layout_mode = 2 +text = "1234" +placeholder_text = "Password" +secret = true + +[node name="LoginButton" type="Button" parent="HBoxContainer/ScrollContainer/VBoxContainer" node_paths=PackedStringArray("username_field", "password_field")] +layout_mode = 2 +text = "Login" +script = ExtResource("4_pkjxs") +username_field = NodePath("../UsernameInput") +password_field = NodePath("../PasswordInput") +api_config = ExtResource("3_bphu2") + +[node name="HTTPRequest" type="HTTPRequest" parent="HBoxContainer/ScrollContainer/VBoxContainer/LoginButton"] + +[node name="CurrentTimeDisplay" type="RichTextLabel" parent="HBoxContainer/ScrollContainer/VBoxContainer" node_paths=PackedStringArray("login")] layout_mode = 2 text = "..." fit_content = true script = ExtResource("1_qy4dl") +login = NodePath("../LoginButton") +api_config = ExtResource("3_bphu2") diff --git a/scripts/channel/channel.gd b/scripts/channel/channel.gd new file mode 100644 index 0000000..86b24de --- /dev/null +++ b/scripts/channel/channel.gd @@ -0,0 +1,19 @@ +class_name Channel +extends Node + +const SOCKET_FALLBACK_URL := "ws://localhost:8080/ws" + +var socket = WebSocketPeer.new() +var socket_url := OS.get_environment("TD_SERVER_WS") + + +func get_channel_location() -> String: + push_error("Not Implemented") + return "" + + +func connect_socket(token: String): + socket.handshake_headers = PackedStringArray(["Authorization: " + token]) + if socket_url == "": + socket_url = SOCKET_FALLBACK_URL + socket.connect_to_url(socket_url + "/" + get_channel_location()) diff --git a/scripts/channel/connection/connection_channel.gd b/scripts/channel/connection/connection_channel.gd new file mode 100644 index 0000000..d488750 --- /dev/null +++ b/scripts/channel/connection/connection_channel.gd @@ -0,0 +1,42 @@ +extends Channel + +signal on_channel_token_received(msg: ProvidedConnectionTokenMessage) + +var queue: Array[Message.Channels] + + +func get_channel_location() -> String: + return "connection" + + +func connect_to_channel(token: String) -> void: + if self.socket.get_ready_state() != WebSocketPeer.STATE_CLOSED: + return + self.connect_socket(token) + + +func request_channel_token(channel: Message.Channels) -> void: + if not queue.has(channel): + queue.push_back(channel) + + +func _process(_delta: float) -> void: + self.socket.poll() + if self.socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + for i in queue.size(): + var msg := RequestConnectionTokenMessage.new() + msg.channel = queue[i] + self.socket.send_text(Message.serialize(msg)) + queue.remove_at(i) + while self.socket.get_available_packet_count(): + var msg: ProvidedConnectionTokenMessage = ( + Message + . deserialize( + self.socket.get_packet().get_string_from_utf8(), + [ProvidedConnectionTokenMessage], + ) + ) + if msg == null: + continue + on_channel_token_received.emit(msg) diff --git a/scripts/channel/connection/provided_connection_token_message.gd b/scripts/channel/connection/provided_connection_token_message.gd new file mode 100644 index 0000000..3a01ae8 --- /dev/null +++ b/scripts/channel/connection/provided_connection_token_message.gd @@ -0,0 +1,9 @@ +class_name ProvidedConnectionTokenMessage +extends Message + +@export var channel: Channels +var token: String + + +func get_message_id() -> String: + return "ProvidedConnectionToken" diff --git a/scripts/channel/connection/request_connection_token_message.gd b/scripts/channel/connection/request_connection_token_message.gd new file mode 100644 index 0000000..bb4371b --- /dev/null +++ b/scripts/channel/connection/request_connection_token_message.gd @@ -0,0 +1,8 @@ +class_name RequestConnectionTokenMessage +extends Message + +@export var channel: Channels + + +func get_message_id() -> String: + return "RequestConnectionToken" diff --git a/scripts/channel/message.gd b/scripts/channel/message.gd new file mode 100644 index 0000000..11e6250 --- /dev/null +++ b/scripts/channel/message.gd @@ -0,0 +1,30 @@ +class_name Message + +enum Channels { CONNECTION, TIME } + + +func get_message_id() -> String: + push_error("Not Implemented") + return "" + + +static func serialize(message: Message) -> String: + var msg: Dictionary = JsonClassConverter.class_to_json(message) + msg["$id"] = message.get_message_id() + return JSON.stringify(msg) + + +static func deserialize(payload: String, messages: Array[GDScript]) -> Message: + var json := JSON.new() + var err = json.parse(payload) + if err != OK: + return null + var data: Variant = json.data + if data == null: + return null + var msg_id: String = data.get("$id") + for msg in messages: + if msg_id != msg.new().get_message_id(): + continue + return JsonClassConverter.json_to_class(msg, data) + return null diff --git a/scripts/channel/time/current_unix_time_message.gd b/scripts/channel/time/current_unix_time_message.gd new file mode 100644 index 0000000..68a1ae7 --- /dev/null +++ b/scripts/channel/time/current_unix_time_message.gd @@ -0,0 +1,8 @@ +class_name CurrentUnixTimeMessage +extends Message + +var time: int + + +func get_message_id() -> String: + return "CurrentUnixTime" diff --git a/scripts/ui/login.gd b/scripts/ui/login.gd index c66809a..cdfee98 100644 --- a/scripts/ui/login.gd +++ b/scripts/ui/login.gd @@ -1,5 +1,8 @@ +class_name Login extends Button +signal login_successful(session: PlayerLoginSession) + @export var username_field: LineEdit @export var password_field: LineEdit @export var api_config: ApiConfig @@ -34,9 +37,7 @@ func login() -> void: func on_success(response: ApiResponse) -> void: - var session: PlayerLoginSession = response.data - print("username: ", session.username) - print("token: ", session.token.to_utf8_buffer()) + login_successful.emit(response.data) func on_error(error: ApiError) -> void: diff --git a/scripts/ui/websocket_time.gd b/scripts/ui/websocket_time.gd index 046778f..e25985a 100644 --- a/scripts/ui/websocket_time.gd +++ b/scripts/ui/websocket_time.gd @@ -1,28 +1,50 @@ extends RichTextLabel +@export var login: Login +@export var api_config: ApiConfig +var api := ServerApi.new(api_config) + # docs.redotengine.org/en/stable/tutorials/networking/websocket.html -@export var fallpack_websocket_url = "ws://localhost:8080/ws/server" -var websocket_url = OS.get_environment("TD_SERVER_WS") -var socket = WebSocketPeer.new() +var time_channel = WebSocketPeer.new() func _ready() -> void: - if websocket_url.is_empty(): - websocket_url = fallpack_websocket_url - var err = socket.connect_to_url(websocket_url) - if err != OK: - error_string(err) - set_process(false) + login.connect("login_successful", on_login) + ( + ConnectionChannel + . connect( + "on_channel_token_received", + on_channel_token_received, + ) + ) + + +func on_login(session: PlayerLoginSession): + ConnectionChannel.connect_to_channel(session.token) + ConnectionChannel.request_channel_token(Message.Channels.TIME) + + +func on_channel_token_received(msg: ProvidedConnectionTokenMessage) -> void: + if time_channel.get_ready_state() != WebSocketPeer.STATE_CLOSED: + return + if msg.channel != Message.Channels.TIME: + return + time_channel.handshake_headers = PackedStringArray( + ["Authorization: " + msg.token], + ) + time_channel.connect_to_url("ws://localhost:8080/ws/time") func _process(_delta: float) -> void: - socket.poll() - var state = socket.get_ready_state() + time_channel.poll() + var state = time_channel.get_ready_state() - if state == WebSocketPeer.STATE_CLOSED: - self.text = "Disconnected" + if state != WebSocketPeer.STATE_OPEN: return - - if state == WebSocketPeer.STATE_OPEN: - while socket.get_available_packet_count(): - self.text = "Current Unixtime: " + socket.get_packet().get_string_from_utf8() + while time_channel.get_available_packet_count(): + var msg: CurrentUnixTimeMessage = Message.deserialize( + time_channel.get_packet().get_string_from_utf8(), [CurrentUnixTimeMessage] + ) + if msg == null: + continue + self.text = str(msg.time)