└── addons └── quiver_analytics ├── analytics.gd ├── analytics.tscn ├── consent_dialog.gd ├── consent_dialog.tscn ├── plugin.cfg └── plugin.gd /addons/quiver_analytics/analytics.gd: -------------------------------------------------------------------------------- 1 | extends Node 2 | ## Handles sending events to Quiver Analytics (https://quiver.dev/analytics/). 3 | ## 4 | ## This class manages a request queue, which the plugin user can populate with events. 5 | ## Events are sent to the Quiver server one at a time. 6 | ## This class manages spacing out requests so as to not overload the server 7 | ## and to prevent performance issues in the game. 8 | ## If events are not able to be sent due to network connection issues, 9 | ## the events are saved to disk when the game exits. 10 | ## 11 | ## This implementation favors performance over accuracy, so events may be dropped if 12 | ## they could lead to performance issues. 13 | 14 | 15 | ## Use this to pick a random player identifier 16 | const MAX_INT := 9223372036854775807 17 | 18 | ## The maximum rate we can add events to the queue. 19 | ## If this limit is exceeded, requests will be dropped. 20 | const MAX_ADD_TO_EVENT_QUEUE_RATE := 50 21 | 22 | ## This controls the maximum size of the request queue that is saved to disk 23 | ## in the situation the events weren't able to be successfully sent. 24 | ## In pathological cases, we may drop events if the queue grows too long. 25 | const MAX_QUEUE_SIZE_TO_SAVE_TO_DISK := 200 26 | 27 | ## The file to store queue events that weren't able to be sent due to network or server issues 28 | const QUEUE_FILE_NAME := "user://analytics_queue" 29 | 30 | ## The server host 31 | const SERVER_PATH := "https://quiver.dev" 32 | 33 | ## The URL for adding events 34 | const ADD_EVENT_PATH := "/analytics/events/add/" 35 | 36 | ## Event names can't exceed this length 37 | const MAX_EVENT_NAME_LENGTH := 50 38 | 39 | # The next two parameters guide how often we send artifical quit events. 40 | # We send these fake quit events because on certain platfomrms (mobile and web), 41 | # it can be hard to determine when a player ended the game (e.g. they background the app or close a tab). 42 | # So we just send periodic quit events with session IDs, which are reconciled by the server. 43 | 44 | # We send a quit event this many seconds after launching the game. 45 | # We set this fairly low to handle immediate bounces from the game. 46 | const INITIAL_QUIT_EVENT_INTERVAL_SECONDS := 10 47 | 48 | # This is the max interval between sending quit events 49 | const MAX_QUIT_EVENT_INTERVAL_SECONDS := 60 50 | 51 | ## Emitted when the sending the final events have been completed 52 | signal exit_handled 53 | 54 | 55 | var auth_token = ProjectSettings.get_setting("quiver/general/auth_token", "") 56 | var config_file_path := ProjectSettings.get_setting("quiver/analytics/config_file_path", "user://analytics.cfg") 57 | var consent_required = ProjectSettings.get_setting("quiver/analytics/player_consent_required", false) 58 | var consent_requested = false 59 | var consent_granted = false 60 | var consent_dialog_scene := preload("res://addons/quiver_analytics/consent_dialog.tscn") 61 | var consent_dialog_showing := false 62 | var data_collection_enabled := false 63 | var config = ConfigFile.new() 64 | var player_id: int 65 | var time_since_first_request_in_batch := Time.get_ticks_msec() 66 | var requests_in_batch_count := 0 67 | var request_in_flight := false 68 | var request_queue: Array[Dictionary] = [] 69 | var should_drain_request_queue := false 70 | var min_retry_time_seconds := 2.0 71 | var current_retry_time_seconds := min_retry_time_seconds 72 | var max_retry_time_seconds := 120.0 73 | var auto_add_event_on_launch := ProjectSettings.get_setting("quiver/analytics/auto_add_event_on_launch", true) 74 | var auto_add_event_on_quit := ProjectSettings.get_setting("quiver/analytics/auto_add_event_on_quit", true) 75 | var quit_event_interval_seconds := INITIAL_QUIT_EVENT_INTERVAL_SECONDS 76 | var session_id = abs(randi() << 32 | randi()) 77 | 78 | # Note that use_threads has to be turned off on this node because otherwise we get frame rate hitches 79 | # when the request is slow due to server issues. 80 | # Not sure why yet, but might be related to https://github.com/godotengine/godot/issues/33479. 81 | @onready var http_request := $HTTPRequest 82 | @onready var retry_timer := $RetryTimer 83 | @onready var quit_event_timer := $QuitEventTimer 84 | 85 | func _ready() -> void: 86 | # We attempt to load the saved configuration, if present 87 | var err = config.load(config_file_path) 88 | if err == OK: 89 | player_id = config.get_value("general", "player_id") 90 | consent_granted = config.get_value("general", "granted") 91 | if player_id and player_id is int: 92 | # We use the hash as a basic (but easily bypassable) protection to reduce 93 | # the chance that the player ID has been tampered with. 94 | var hash = str(player_id).sha256_text() 95 | if hash != config.get_value("general", "hash"): 96 | DirAccess.remove_absolute(config_file_path) 97 | _init_config() 98 | else: 99 | # If we don't have a config file, we create one now 100 | _init_config() 101 | 102 | # Check to see if data collection is possible 103 | if auth_token and (!consent_required or consent_granted): 104 | data_collection_enabled = true 105 | 106 | # Let's load any saved events from previous sessions 107 | # and start processing them, if available. 108 | _load_queue_from_disk() 109 | if not request_queue.is_empty(): 110 | DirAccess.remove_absolute(QUEUE_FILE_NAME) 111 | _process_requests() 112 | 113 | if auto_add_event_on_launch: 114 | add_event("Launched game") 115 | if auto_add_event_on_quit: 116 | quit_event_timer.start(quit_event_interval_seconds) 117 | 118 | # if auto_add_event_on_quit: 119 | # get_tree().set_auto_accept_quit(false) 120 | 121 | 122 | ## Whether we should be obligated to show the consent dialog to the player 123 | func should_show_consent_dialog() -> bool: 124 | return consent_required and not consent_requested 125 | 126 | 127 | ## Show the consent dialog to the user, using the passed in node as the parent 128 | func show_consent_dialog(parent: Node) -> void: 129 | if not consent_dialog_showing: 130 | consent_dialog_showing = true 131 | var consent_dialog: ConsentDialog = consent_dialog_scene.instantiate() 132 | parent.add_child(consent_dialog) 133 | consent_dialog.show_with_animation() 134 | 135 | 136 | ## Call this when consent has been granted. 137 | ## The ConsentDialog scene will manage this automatically. 138 | func approve_data_collection() -> void: 139 | consent_requested = true 140 | consent_granted = true 141 | config.set_value("general", "requested", consent_requested) 142 | config.set_value("general", "granted", consent_granted) 143 | config.save(config_file_path) 144 | 145 | 146 | ## Call this when consent has been denied. 147 | ## The ConsentDialog scene will manage this automatically. 148 | func deny_data_collection() -> void: 149 | if consent_granted: 150 | consent_requested = true 151 | consent_granted = false 152 | #if FileAccess.file_exists(CONFIG_FILE_PATH): 153 | # DirAccess.remove_absolute(CONFIG_FILE_PATH) 154 | config.set_value("general", "requested", consent_requested) 155 | config.set_value("general", "granted", consent_granted) 156 | config.save(config_file_path) 157 | 158 | 159 | ## Use this track an event. The name must be 50 characters or less. 160 | ## You can pass in an arbitrary dictionary of properties. 161 | func add_event(name: String, properties: Dictionary = {}) -> void: 162 | if not data_collection_enabled: 163 | _process_requests() 164 | return 165 | 166 | if name.length() > MAX_EVENT_NAME_LENGTH: 167 | printerr("[Quiver Analytics] Event name '%s' is too long. Must be %d characters or less." % [name, MAX_EVENT_NAME_LENGTH]) 168 | _process_requests() 169 | return 170 | 171 | # We limit big bursts of event tracking to reduce overusage due to buggy code 172 | # and to prevent overloading the server. 173 | var current_time_msec = Time.get_ticks_msec() 174 | if (current_time_msec - time_since_first_request_in_batch) > 60 * 1000: 175 | time_since_first_request_in_batch = current_time_msec 176 | requests_in_batch_count = 0 177 | else: 178 | requests_in_batch_count += 1 179 | if requests_in_batch_count > MAX_ADD_TO_EVENT_QUEUE_RATE: 180 | printerr("[Quiver Analytics] Event tracking was disabled temporarily because max event rate was exceeded.") 181 | return 182 | 183 | # Auto-add default properties 184 | properties["$platform"] = OS.get_name() 185 | properties["$session_id"] = session_id 186 | properties["$debug"] = OS.is_debug_build() 187 | properties["$export_template"] = OS.has_feature("template") 188 | 189 | # Add the request to the queue and process the queue 190 | var request := { 191 | "url": SERVER_PATH + ADD_EVENT_PATH, 192 | "headers": ["Authorization: Token " + auth_token], 193 | "body": {"name": name, "player_id": player_id, "properties": properties, "timestamp": Time.get_unix_time_from_system()}, 194 | } 195 | request_queue.append(request) 196 | _process_requests() 197 | 198 | 199 | ## Ideally, this should be called when a user exits the game, 200 | ## although it may be difficult on certain plaforms. 201 | ## This handles draining the request queue and saving the queue to disk, if necessary. 202 | func handle_exit(): 203 | quit_event_timer.stop() 204 | should_drain_request_queue = true 205 | if auto_add_event_on_quit: 206 | add_event("Quit game") 207 | else: 208 | _process_requests() 209 | return exit_handled 210 | 211 | 212 | func _save_queue_to_disk() -> void: 213 | var f = FileAccess.open(QUEUE_FILE_NAME, FileAccess.WRITE) 214 | if f: 215 | # If the queue is too big, we trim the queue, 216 | # favoring more recent events (i.e. the back of the queue). 217 | if request_queue.size() > MAX_QUEUE_SIZE_TO_SAVE_TO_DISK: 218 | request_queue = request_queue.slice(request_queue.size() - MAX_QUEUE_SIZE_TO_SAVE_TO_DISK) 219 | printerr("[Quiver Analytics] Request queue overloaded. Events were dropped.") 220 | f.store_var(request_queue, false) 221 | 222 | 223 | func _load_queue_from_disk() -> void: 224 | var f = FileAccess.open(QUEUE_FILE_NAME, FileAccess.READ) 225 | if f: 226 | request_queue.assign(f.get_var()) 227 | 228 | 229 | func _handle_request_failure(response_code: int): 230 | request_in_flight = false 231 | # Drop invalid 4xx events 232 | # 5xx and transient errors will be presumed to be fixed server-side. 233 | if response_code >= 400 and response_code <= 499: 234 | request_queue.pop_front() 235 | printerr("[Quiver Analytics] Event was dropped because it couldn't be processed by the server. Response code %d." % response_code) 236 | # If we are not in draining mode, we retry with exponential backoff 237 | if not should_drain_request_queue: 238 | retry_timer.start(current_retry_time_seconds) 239 | current_retry_time_seconds += min(current_retry_time_seconds * 2.0, max_retry_time_seconds) 240 | # If we are in draining mode, we immediately save the existing queue to disk 241 | # and use _process_requests() to emit the exit_handled signal. 242 | # We do this because we want to hurry up and let the player quit the game. 243 | else: 244 | _save_queue_to_disk() 245 | request_queue = [] 246 | _process_requests() 247 | 248 | 249 | func _process_requests() -> void: 250 | if not request_queue.is_empty() and not request_in_flight: 251 | var request: Dictionary = request_queue.front() 252 | request_in_flight = true 253 | var error = http_request.request( 254 | request["url"], 255 | request["headers"], 256 | HTTPClient.METHOD_POST, 257 | JSON.stringify(request["body"]) 258 | ) 259 | if error != OK: 260 | _handle_request_failure(error) 261 | # If we have successfully drained the queue, emit the exit_handled signal 262 | if should_drain_request_queue and request_queue.is_empty(): 263 | # We only want to emit the exit_handled signal in the next frame, 264 | # so that the caller has a chance to receive the signal. 265 | await get_tree().process_frame 266 | exit_handled.emit() 267 | 268 | 269 | func _init_config() -> void: 270 | # This should give us a nice randomized player ID with low chance of collision 271 | player_id = abs(randi() << 32 | randi()) 272 | config.set_value("general", "player_id", player_id) 273 | # We calculate the hash to prevent the player from arbitrarily changing the player ID 274 | # in the file. This is easy to bypass, and players could always manually send events 275 | # anyways, but this provides some basic protection. 276 | var hash = str(player_id).sha256_text() 277 | config.set_value("general", "hash", hash) 278 | config.set_value("general", "requested", consent_requested) 279 | config.set_value("general", "granted", consent_granted) 280 | config.save(config_file_path) 281 | 282 | 283 | func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: 284 | if response_code >= 200 and response_code <= 299: 285 | # This line doesn't work, possibly due to a bug in Godot. 286 | # Even with a non-2xx response code, the result is shown as a success. 287 | #if result == HTTPRequest.RESULT_SUCCESS: 288 | request_in_flight = false 289 | request_queue.pop_front() 290 | current_retry_time_seconds = min_retry_time_seconds 291 | # If we are draining the queue, process events as fast as possible 292 | if should_drain_request_queue: 293 | _process_requests() 294 | # Otherwise, take our time so as not to impact the frame rate 295 | else: 296 | retry_timer.start(current_retry_time_seconds) 297 | else: 298 | _handle_request_failure(response_code) 299 | 300 | 301 | func _on_retry_timer_timeout() -> void: 302 | _process_requests() 303 | 304 | 305 | func _on_quit_event_timer_timeout() -> void: 306 | add_event("Quit game") 307 | quit_event_interval_seconds = min(quit_event_interval_seconds + 10, MAX_QUIT_EVENT_INTERVAL_SECONDS) 308 | quit_event_timer.start(quit_event_interval_seconds) 309 | 310 | 311 | #func _notification(what): 312 | # if what == NOTIFICATION_WM_CLOSE_REQUEST: 313 | # handle_exit() 314 | # get_tree().quit() 315 | -------------------------------------------------------------------------------- /addons/quiver_analytics/analytics.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://cl357576hhwlm"] 2 | 3 | [ext_resource type="Script" path="res://addons/quiver_analytics/analytics.gd" id="1_fldbu"] 4 | 5 | [node name="Analytics" type="Node"] 6 | script = ExtResource("1_fldbu") 7 | 8 | [node name="HTTPRequest" type="HTTPRequest" parent="."] 9 | timeout = 5.0 10 | 11 | [node name="RetryTimer" type="Timer" parent="."] 12 | one_shot = true 13 | 14 | [node name="QuitEventTimer" type="Timer" parent="."] 15 | 16 | [connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"] 17 | [connection signal="timeout" from="RetryTimer" to="." method="_on_retry_timer_timeout"] 18 | [connection signal="timeout" from="QuitEventTimer" to="." method="_on_quit_event_timer_timeout"] 19 | -------------------------------------------------------------------------------- /addons/quiver_analytics/consent_dialog.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | class_name ConsentDialog 3 | extends CanvasLayer 4 | 5 | @onready var anim_player = $AnimationPlayer 6 | 7 | 8 | func show_with_animation(anim_name: String = "pop_up") -> void: 9 | anim_player.play(anim_name) 10 | 11 | 12 | func hide_with_animation(anim_name: String = "pop_up") -> void: 13 | anim_player.play_backwards(anim_name) 14 | 15 | 16 | func _on_approve_button_pressed() -> void: 17 | Analytics.approve_data_collection() 18 | hide() 19 | 20 | 21 | func _on_deny_button_pressed() -> void: 22 | Analytics.deny_data_collection() 23 | hide() 24 | -------------------------------------------------------------------------------- /addons/quiver_analytics/consent_dialog.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=6 format=3 uid="uid://d12k04516ets1"] 2 | 3 | [ext_resource type="Script" path="res://addons/quiver_analytics/consent_dialog.gd" id="1_uhvlc"] 4 | 5 | [sub_resource type="Animation" id="Animation_m08b6"] 6 | resource_name = "RESET" 7 | tracks/0/type = "value" 8 | tracks/0/imported = false 9 | tracks/0/enabled = true 10 | tracks/0/path = NodePath(".:visible") 11 | tracks/0/interp = 1 12 | tracks/0/loop_wrap = true 13 | tracks/0/keys = { 14 | "times": PackedFloat32Array(0), 15 | "transitions": PackedFloat32Array(1), 16 | "update": 1, 17 | "values": [false] 18 | } 19 | tracks/1/type = "value" 20 | tracks/1/imported = false 21 | tracks/1/enabled = true 22 | tracks/1/path = NodePath("PanelContainer:position") 23 | tracks/1/interp = 1 24 | tracks/1/loop_wrap = true 25 | tracks/1/keys = { 26 | "times": PackedFloat32Array(0), 27 | "transitions": PackedFloat32Array(1), 28 | "update": 0, 29 | "values": [Vector2(0, 521)] 30 | } 31 | tracks/2/type = "value" 32 | tracks/2/imported = false 33 | tracks/2/enabled = true 34 | tracks/2/path = NodePath("PanelContainer:modulate") 35 | tracks/2/interp = 1 36 | tracks/2/loop_wrap = true 37 | tracks/2/keys = { 38 | "times": PackedFloat32Array(0), 39 | "transitions": PackedFloat32Array(1), 40 | "update": 0, 41 | "values": [Color(1, 1, 1, 1)] 42 | } 43 | 44 | [sub_resource type="Animation" id="Animation_mqfr5"] 45 | resource_name = "fade_in" 46 | tracks/0/type = "value" 47 | tracks/0/imported = false 48 | tracks/0/enabled = true 49 | tracks/0/path = NodePath(".:visible") 50 | tracks/0/interp = 1 51 | tracks/0/loop_wrap = true 52 | tracks/0/keys = { 53 | "times": PackedFloat32Array(0.1), 54 | "transitions": PackedFloat32Array(1), 55 | "update": 1, 56 | "values": [true] 57 | } 58 | tracks/1/type = "value" 59 | tracks/1/imported = false 60 | tracks/1/enabled = true 61 | tracks/1/path = NodePath("PanelContainer:modulate") 62 | tracks/1/interp = 1 63 | tracks/1/loop_wrap = true 64 | tracks/1/keys = { 65 | "times": PackedFloat32Array(0.1, 1), 66 | "transitions": PackedFloat32Array(1, 1), 67 | "update": 0, 68 | "values": [Color(1, 1, 1, 0), Color(1, 1, 1, 1)] 69 | } 70 | 71 | [sub_resource type="Animation" id="Animation_le4fb"] 72 | resource_name = "popup" 73 | tracks/0/type = "value" 74 | tracks/0/imported = false 75 | tracks/0/enabled = true 76 | tracks/0/path = NodePath(".:visible") 77 | tracks/0/interp = 1 78 | tracks/0/loop_wrap = true 79 | tracks/0/keys = { 80 | "times": PackedFloat32Array(0), 81 | "transitions": PackedFloat32Array(1), 82 | "update": 1, 83 | "values": [true] 84 | } 85 | tracks/1/type = "value" 86 | tracks/1/imported = false 87 | tracks/1/enabled = true 88 | tracks/1/path = NodePath("PanelContainer:position") 89 | tracks/1/interp = 1 90 | tracks/1/loop_wrap = true 91 | tracks/1/keys = { 92 | "times": PackedFloat32Array(0, 1), 93 | "transitions": PackedFloat32Array(1, 10.5561), 94 | "update": 0, 95 | "values": [Vector2(2.08165e-12, 700), Vector2(0, 521)] 96 | } 97 | 98 | [sub_resource type="AnimationLibrary" id="AnimationLibrary_3qyvs"] 99 | _data = { 100 | "RESET": SubResource("Animation_m08b6"), 101 | "fade_in": SubResource("Animation_mqfr5"), 102 | "pop_up": SubResource("Animation_le4fb") 103 | } 104 | 105 | [node name="ConsentDialog" type="CanvasLayer"] 106 | visible = false 107 | script = ExtResource("1_uhvlc") 108 | 109 | [node name="PanelContainer" type="PanelContainer" parent="."] 110 | anchors_preset = 12 111 | anchor_top = 1.0 112 | anchor_right = 1.0 113 | anchor_bottom = 1.0 114 | offset_top = -127.0 115 | grow_horizontal = 2 116 | grow_vertical = 0 117 | 118 | [node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] 119 | layout_mode = 2 120 | theme_override_constants/margin_left = 20 121 | theme_override_constants/margin_top = 20 122 | theme_override_constants/margin_right = 20 123 | theme_override_constants/margin_bottom = 20 124 | 125 | [node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"] 126 | layout_mode = 2 127 | 128 | [node name="Label" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"] 129 | layout_mode = 2 130 | text = "We're trying to make the best game we can, but we need your help! With your permission, we'd like to collect information about your experience with the game. Your information will be anonymized to protect your privacy." 131 | horizontal_alignment = 1 132 | autowrap_mode = 2 133 | 134 | [node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"] 135 | layout_mode = 2 136 | size_flags_horizontal = 4 137 | theme_override_constants/separation = 20 138 | 139 | [node name="ApproveButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/HBoxContainer"] 140 | layout_mode = 2 141 | text = "Allow anonymized data collection" 142 | 143 | [node name="DenyButton" type="Button" parent="PanelContainer/MarginContainer/VBoxContainer/HBoxContainer"] 144 | layout_mode = 2 145 | text = "Opt out" 146 | 147 | [node name="AnimationPlayer" type="AnimationPlayer" parent="."] 148 | libraries = { 149 | "": SubResource("AnimationLibrary_3qyvs") 150 | } 151 | -------------------------------------------------------------------------------- /addons/quiver_analytics/plugin.cfg: -------------------------------------------------------------------------------- 1 | [plugin] 2 | 3 | name="Quiver Analytics" 4 | description="Get key insights into how players are interacting with your game while still respecting their privacy." 5 | author="Quiver" 6 | version="0.8" 7 | script="plugin.gd" 8 | -------------------------------------------------------------------------------- /addons/quiver_analytics/plugin.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | const AUTOLOAD_NAME := "Analytics" 5 | const CUSTOM_PROPERTIES := [ 6 | {"name": "quiver/general/auth_token", "default": "", "basic": true, "general": true}, 7 | {"name": "quiver/analytics/player_consent_required", "default": false, "basic": true, "general": false}, 8 | {"name": "quiver/analytics/config_file_path", "default": "user://analytics.cfg", "basic": false, "general": false}, 9 | {"name": "quiver/analytics/auto_add_event_on_launch", "default": true, "basic": false, "general": false}, 10 | {"name": "quiver/analytics/auto_add_event_on_quit", "default": true, "basic": false, "general": false}, 11 | ] 12 | 13 | func _enter_tree() -> void: 14 | # Migrate legacy setting 15 | if ProjectSettings.has_setting("quiver/analytics/auth_token"): 16 | var auth_token: String = ProjectSettings.get_setting("quiver/analytics/auth_token") 17 | if not ProjectSettings.has_setting("quiver/general/auth_token"): 18 | ProjectSettings.set_setting("quiver/general/auth_token", auth_token) 19 | ProjectSettings.set_setting("quiver/analytics/auth_token", null) 20 | 21 | for property in CUSTOM_PROPERTIES: 22 | var name = property["name"] 23 | var default = property["default"] 24 | var basic = property["basic"] 25 | if not ProjectSettings.has_setting(name): 26 | ProjectSettings.set_setting(name, default) 27 | ProjectSettings.set_initial_value(name, default) 28 | if basic: 29 | ProjectSettings.set_as_basic(name, true) 30 | add_autoload_singleton(AUTOLOAD_NAME, "res://addons/quiver_analytics/analytics.tscn") 31 | if not ProjectSettings.get_setting("quiver/general/auth_token"): 32 | printerr("[Quiver Analytics] Auth key hasn't been set for Quiver services.") 33 | 34 | 35 | func _exit_tree() -> void: 36 | remove_autoload_singleton(AUTOLOAD_NAME) 37 | for property in CUSTOM_PROPERTIES: 38 | var name = property["name"] 39 | if not property["general"]: 40 | ProjectSettings.set_setting(name, null) 41 | --------------------------------------------------------------------------------